Merge remote-tracking branch 'origin/main' into fix/553-rail-tab-icon-size

# Conflicts:
#	ROADMAP.md
This commit is contained in:
Mert Dede
2026-10-31 16:11:50 +03:00
103 changed files with 1225 additions and 322 deletions
+1
View File
@@ -3,6 +3,7 @@ plan/
plans/
/target/
/target-*/
/plugins/target/
/dist/
/corpus/
/log/
+5 -11
View File
@@ -6,10 +6,7 @@ inspection. It runs natively on macOS, Windows, Linux and FreeBSD, and in the br
Siblings: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop-class), `../designcraft`
(InDesign-class) and the other Crafting Apps; same conventions.
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out
next to `craft-apps/`, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session,
follow its standards, and contribute reusable learnings back there. Never code: repos don't share
code.
Repos don't share code.
## Start every session here
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, estimates) and
@@ -106,7 +103,7 @@ to `main`.
write manifests atomically, delete your target dir when done (disk is limited).
## Roadmap
Progress docs follow craftrules `standards/progress-docs.md`: [`ROADMAP.md`](ROADMAP.md) (one-page
Progress docs: [`ROADMAP.md`](ROADMAP.md) (one-page
summary and stage), [`docs/target-app-parity.md`](docs/target-app-parity.md) (the authoritative
assessment), [`docs/gaps.md`](docs/gaps.md) (ranked work list), [`docs/roadmap.md`](docs/roadmap.md)
(milestones), [`docs/architecture.md`](docs/architecture.md), the per-area `docs/*-parity.md` files
@@ -117,20 +114,17 @@ commit. Grade by behaviour against the incumbent, not by whether a menu item exi
## Contributor credits (About window)
- About ▸ Contributors are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See craftrules `standards/contributors.md`.
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
themselves).
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
+1 -1
View File
@@ -12,7 +12,7 @@ MIT OR Apache-2.0. No third-party icon sets are used.
| Path | Author | Source | Licence | Notes |
|---|---|---|---|---|
| `assets/app-icon/` (all files: `figcraft.svg`, `figcraft-small.svg`, `figcraft-*.png`, `figcraft.ico`, `hicolor/`) | The project owner (engraving-style drawing, vectorised) | Original work, see [`assets/app-icon/README.md`](assets/app-icon/README.md); derived files rendered by `cargo xtask icon` | MIT OR Apache-2.0, [`assets/app-icon/LICENSE.txt`](assets/app-icon/LICENSE.txt) | FigCraft app icon (chameleon) |
| `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png`, `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png`, `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png`, `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | storytold/craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`), not open source | Trademarks; unmodified use within FigCraft only |
| `docs/brand/artcraft-logo.svg`, `docs/brand/artcraft-logo.png`, `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png`, `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png`, `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`), not open source | Trademarks; unmodified use within FigCraft only |
| `docs/images/` (all screenshots and exports: `shot-*.png`, `art-*.png`) | FigCraft contributors | Screenshots of FigCraft showing synthetic demo documents made in FigCraft, and PNG exports of those documents by FigCraft's renderer | MIT OR Apache-2.0 | No third-party artwork |
| `examples/*.figcraft` | FigCraft contributors | Synthetic demo documents built through FigCraft's command API; invented copy, shapes and vectors drawn from scratch, no images or embedded fonts | MIT OR Apache-2.0 | Text uses Inter (OFL, from craft-fonts or the system); no third-party artwork |
+5 -11
View File
@@ -6,10 +6,7 @@ inspection. It runs natively on macOS, Windows, Linux and FreeBSD, and in the br
Siblings: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop-class), `../designcraft`
(InDesign-class) and the other Crafting Apps; same conventions.
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out
next to `craft-apps/`, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session,
follow its standards, and contribute reusable learnings back there. Never code: repos don't share
code.
Repos don't share code.
## Start every session here
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, estimates) and
@@ -107,7 +104,7 @@ to `main`.
write manifests atomically, delete your target dir when done (disk is limited).
## Roadmap
Progress docs follow craftrules `standards/progress-docs.md`: [`ROADMAP.md`](ROADMAP.md) (one-page
Progress docs: [`ROADMAP.md`](ROADMAP.md) (one-page
summary and stage), [`docs/target-app-parity.md`](docs/target-app-parity.md) (the authoritative
assessment), [`docs/gaps.md`](docs/gaps.md) (ranked work list), [`docs/roadmap.md`](docs/roadmap.md)
(milestones), [`docs/architecture.md`](docs/architecture.md), the per-area `docs/*-parity.md` files
@@ -118,20 +115,17 @@ commit. Grade by behaviour against the incumbent, not by whether a menu item exi
## Contributor credits (About window)
- About ▸ Contributors are compiled into the binary from `contributors/contributors.json`
(commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for
themselves). See craftrules `standards/contributors.md`.
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
themselves).
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
Generated
+36 -36
View File
@@ -1001,7 +1001,7 @@ dependencies = [
[[package]]
name = "craft-atomic"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"rustix 1.1.5",
]
@@ -1009,12 +1009,12 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
[[package]]
name = "craft-font-catalog"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"fontique",
"skrifa 0.47.0",
@@ -1025,7 +1025,7 @@ dependencies = [
[[package]]
name = "craft-font-store"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"craft-atomic",
"craft-font-catalog",
@@ -1041,12 +1041,12 @@ dependencies = [
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1069,7 +1069,7 @@ dependencies = [
[[package]]
name = "craft-keymap"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"egui",
"serde",
@@ -1079,7 +1079,7 @@ dependencies = [
[[package]]
name = "craft-menu"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"craft-keymap",
"craft-quit",
@@ -1092,7 +1092,7 @@ dependencies = [
[[package]]
name = "craft-quit"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"dispatch2",
"egui",
@@ -1103,7 +1103,7 @@ dependencies = [
[[package]]
name = "craft-settings"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"craft-atomic",
"dirs",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1134,7 +1134,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
@@ -1160,7 +1160,7 @@ dependencies = [
[[package]]
name = "craft-window"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"wayland-client",
]
@@ -1763,7 +1763,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
[[package]]
name = "figcraft"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"arboard",
"craft-menu",
@@ -1795,7 +1795,7 @@ dependencies = [
[[package]]
name = "figcraft-cli"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"clap",
"clap_complete",
@@ -1813,7 +1813,7 @@ dependencies = [
[[package]]
name = "figcraft-collab"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -1831,7 +1831,7 @@ dependencies = [
[[package]]
name = "figcraft-collab-net"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab",
@@ -1854,7 +1854,7 @@ dependencies = [
[[package]]
name = "figcraft-color"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"kurbo",
"serde",
@@ -1863,7 +1863,7 @@ dependencies = [
[[package]]
name = "figcraft-doc"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"figcraft-color",
"figcraft-geom",
@@ -1876,7 +1876,7 @@ dependencies = [
[[package]]
name = "figcraft-engine"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"base64 0.22.1",
"craft-docpath",
@@ -1910,7 +1910,7 @@ dependencies = [
[[package]]
name = "figcraft-format"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"figcraft-doc",
"figcraft-layout",
@@ -1927,7 +1927,7 @@ dependencies = [
[[package]]
name = "figcraft-geom"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"kurbo",
"serde",
@@ -1935,7 +1935,7 @@ dependencies = [
[[package]]
name = "figcraft-layout"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1943,7 +1943,7 @@ dependencies = [
[[package]]
name = "figcraft-mcp"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"base64 0.22.1",
"figcraft-collab-net",
@@ -1959,7 +1959,7 @@ dependencies = [
[[package]]
name = "figcraft-pathops"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"figcraft-doc",
"figcraft-geom",
@@ -1968,7 +1968,7 @@ dependencies = [
[[package]]
name = "figcraft-pdf"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -1985,7 +1985,7 @@ dependencies = [
[[package]]
name = "figcraft-plugin"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"figcraft-color",
"serde",
@@ -1998,7 +1998,7 @@ dependencies = [
[[package]]
name = "figcraft-render"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"crc32fast",
"figcraft-color",
@@ -2017,7 +2017,7 @@ dependencies = [
[[package]]
name = "figcraft-server"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"chrono",
"crc32fast",
@@ -2042,7 +2042,7 @@ dependencies = [
[[package]]
name = "figcraft-svg"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"base64 0.22.1",
"figcraft-color",
@@ -2058,7 +2058,7 @@ dependencies = [
[[package]]
name = "figcraft-text"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"core-text",
"craft-font-catalog",
@@ -2076,7 +2076,7 @@ dependencies = [
[[package]]
name = "figcraft-ui-egui"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"base64 0.22.1",
"chrono",
@@ -2104,7 +2104,7 @@ dependencies = [
[[package]]
name = "figcraft-vectordrawable"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"figcraft-color",
"figcraft-doc",
@@ -2118,7 +2118,7 @@ dependencies = [
[[package]]
name = "figcraft-web"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"base64 0.22.1",
"eframe",
@@ -3136,7 +3136,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545#e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6739,7 +6739,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.12.1"
version = "0.13.1"
dependencies = [
"figcraft-engine",
"serde_json",
+10 -10
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.12.1"
version = "0.13.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -36,25 +36,25 @@ figcraft-plugin = { path = "crates/plugin" }
# Keep coupled craft-libs crates on one rev. craft-update has no sibling-crate
# dependencies and is pinned separately for the suite updater rollout.
# Which paths stored in a document may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
craft-font-catalog = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs).
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# The macOS app menu whose Quit (⌘Q) asks the app before quitting, like the window's close button
# (craft-libs; the menu it replaces quits straight through `terminate:` and loses unsaved work).
craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-menu = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# Where the settings live: the suite's folder rules, portable mode included (craft-libs).
craft-settings = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-settings = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -183,4 +183,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
# panic while drawing unwinds without aborting.
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "e11f9d9aebea5f6b8e74643c1ea1ed172ba5f545" }
+8 -4
View File
File diff suppressed because one or more lines are too long
+2
View File
@@ -107,6 +107,8 @@ mod idle_io_tests;
#[cfg(test)]
mod library_actions_tests;
#[cfg(test)]
mod library_assets_tests;
#[cfg(test)]
mod library_detach_tests;
#[cfg(test)]
mod library_tests;
@@ -0,0 +1,145 @@
//! Styles and variables in a team library's analytics, end to end over loopback: a file that uses
//! them reports how many of its layers do, the library's owner sees them per style and variable
//! with their names and files, and a file that stops using them stops counting. Reports are cleaned
//! and capped, and the usage list keeps its encoding.
use std::time::{Duration, Instant};
use figcraft_collab::message::{Access, AssetUsage, ClientMsg, LibraryReply, LibraryReq, MAX_ASSET_USAGE, OpenLibrary, PROTOCOL, ServerMsg};
use figcraft_collab_net::Invite;
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::{Value, json};
use crate::test_util::{Raw, TempDir, config, publish, session};
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn id(v: &Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// `a`'s analytics once `done` holds for them; `b` reports again each time, as a check asked for
/// while the last one still runs is dropped.
fn usage_when(a: &mut Session, b: &mut Session, done: impl Fn(&Value) -> bool) -> Value {
let start = Instant::now();
loop {
b.refresh_server_libraries();
let u = ex(a, "library.usage", json!({}));
if done(&u) {
return u;
}
assert!(start.elapsed() < Duration::from_secs(30), "the reports never arrived: {u}");
std::thread::sleep(Duration::from_millis(20));
}
}
fn rect(s: &mut Session, x: i32) -> NodeId {
id(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 40, "height": 40})))
}
#[test]
fn styles_and_variables_are_counted_per_layer_and_file() {
let dir = TempDir::new("library-assets");
let cfg = config(dir.path());
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
// Ada's library: a colour style, a colour variable, and a Card that uses the style.
let mut a = session("Ada");
let r = rect(&mut a, 0);
let style = ex(&mut a, "style.create", json!({"type": "paint", "name": "Brand", "from": r}));
let brand = style["id"].as_str().unwrap().to_string();
let collection = ex(&mut a, "variable.createCollection", json!({"name": "Tokens"}));
ex(&mut a, "variable.create", json!({"name": "Accent", "type": "color", "collection": collection["id"], "value": "#0D99FF"}));
let card = id(&ex(&mut a, "frame.create", json!({"x": 0, "y": 100, "width": 100, "height": 40, "name": "Card"})));
ex(&mut a, "style.apply", json!({"ids": [card], "style": brand}));
ex(&mut a, "component.create", json!({"ids": [card]}));
let p = publish(&mut a, dir.path(), addr);
ex(&mut a, "library.publishToServer", json!({}));
// Bo uses the style on two layers and the variable on one, and places a Card: it belongs to
// the component, so it doesn't count for the style.
let mut b = session("Bo");
let lid = ex(&mut b, "library.add", json!({"path": p.view}))["id"].as_str().unwrap().to_string();
let (one, two, three) = (rect(&mut b, 0), rect(&mut b, 100), rect(&mut b, 200));
ex(&mut b, "library.applyStyle", json!({"library": lid, "style": "Brand", "ids": [one, two]}));
ex(&mut b, "library.bindVariable", json!({"library": lid, "variable": "Accent", "ids": [three], "field": "fill"}));
ex(&mut b, "library.place", json!({"library": lid, "component": "Card", "x": 300, "y": 0}));
let u = usage_when(&mut a, &mut b, |u| u["styles"][0]["layers"] == 2 && u["variables"][0]["layers"] == 1 && u["instances"] == 1);
assert_eq!(u["assetsKnown"], true, "{u}");
let s = &u["styles"][0];
assert_eq!((s["name"].clone(), s["type"].clone(), s["files"].clone()), (json!("Brand"), json!("paint"), json!(1)), "{u}");
let v = &u["variables"][0];
assert_eq!((v["name"].clone(), v["collection"].clone(), v["files"].clone()), (json!("Accent"), json!("Tokens"), json!(1)), "{u}");
assert_eq!(u["instances"], 1, "components count as before: {u}");
// Bo takes the style off one layer and the variable off its only one.
ex(&mut b, "style.detach", json!({"ids": [two], "which": "fill"}));
ex(&mut b, "variable.bind", json!({"ids": [three], "field": "fill"}));
let u = usage_when(&mut a, &mut b, |u| u["styles"][0]["layers"] == 1 && u["variables"].as_array().is_some_and(Vec::is_empty));
assert_eq!(u["styles"].as_array().unwrap().len(), 1, "{u}");
server.stop();
}
/// Reports from the wire: names cleaned, counts capped and long ids dropped, one count per id; the
/// reports survive the server's restart, and a server's list has only files that use something.
#[test]
fn asset_reports_are_capped_and_kept() {
let dir = TempDir::new("library-assets-wire");
let cfg = config(dir.path());
let mut server = crate::start(&cfg).unwrap();
let addr = server.addr();
let mut a = session("Ada");
let r = rect(&mut a, 0);
ex(&mut a, "style.create", json!({"type": "paint", "name": "Brand", "from": r}));
let p = publish(&mut a, dir.path(), addr);
ex(&mut a, "library.publishToServer", json!({}));
let view = Invite::parse(&p.view).unwrap();
let open = |addr| {
let mut raw = Raw::connect(addr);
raw.send(&ClientMsg::OpenLibrary(OpenLibrary {
protocol: PROTOCOL,
file: view.file.clone(),
token: view.token.clone(),
client_version: String::new(),
}));
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::View });
raw
};
let mut raw = open(addr);
let flood = (0..5_000).map(|i| (format!("s{i}"), 1)).collect();
let noted = |raw: &mut Raw, a| {
raw.send(&ClientMsg::Library(LibraryReq::ReportAssets(a)));
assert_eq!(answer(raw), LibraryReply::Noted);
};
noted(&mut raw, AssetUsage { file: "Spam".into(), styles: flood, variables: Vec::new() });
let odd = vec![("x".repeat(65), 7), ("a".into(), 4), ("a".into(), 9), ("b".into(), 0)];
noted(&mut raw, AssetUsage { file: "\u{7}Shop\n".into(), styles: Vec::new(), variables: odd });
noted(&mut raw, AssetUsage { file: "Nothing".into(), ..AssetUsage::default() });
raw.send(&ClientMsg::Library(LibraryReq::Assets));
let LibraryReply::Assets(files) = answer(&mut raw) else { panic!("no reports") };
assert_eq!(files.len(), 2, "a file that uses nothing isn't listed: {files:?}");
assert_eq!(files.iter().find(|f| f.file == "Spam").unwrap().styles.len(), MAX_ASSET_USAGE);
assert_eq!(files.iter().find(|f| f.file == "Shop").unwrap().variables, [("a".to_string(), 4)]);
// Older apps ask for the usage list, where nobody reported using the library.
raw.send(&ClientMsg::Library(LibraryReq::Usage));
assert_eq!(answer(&mut raw), LibraryReply::Usage(Vec::new()));
server.stop();
let mut server = crate::start(&cfg).unwrap();
let mut raw = open(server.addr());
raw.send(&ClientMsg::Library(LibraryReq::Assets));
let LibraryReply::Assets(kept) = answer(&mut raw) else { panic!("no reports") };
assert_eq!(kept, files);
server.stop();
}
fn answer(raw: &mut Raw) -> LibraryReply {
match raw.next() {
ServerMsg::Library(r) => *r,
other => panic!("not a library answer: {other:?}"),
}
}
+7 -3
View File
@@ -40,8 +40,8 @@ use std::path::{Path, PathBuf};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use figcraft_collab::message::{
Access, Admin, AdminReply, ClientMsg, Detached, FileDetached, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq, LinkInfo,
MAX_OWNED, NewLink, OwnedFile, PluginReply, PluginReq, ReportInfo, Usage, WeekActions,
Access, Admin, AdminReply, AssetUsage, ClientMsg, Detached, FileDetached, FileInfo, FileRow, FileUsage, HostInfo, LibraryReply, LibraryReq,
LinkInfo, MAX_OWNED, NewLink, OwnedFile, PluginReply, PluginReq, ReportInfo, Usage, WeekActions,
};
use figcraft_collab::{Change, NodeId, Replica, Server, same_secret};
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_blocks, ip_key};
@@ -106,7 +106,7 @@ pub const NO_PUBLIC_LIBRARY: &str = "on this public server only files published
const MAX_USERS: usize = 200;
const MAX_REPORTED: usize = 1_000;
/// Largest `usage.json` (and `actions.json`) read.
const MAX_USAGE_FILE: u64 = 4 << 20;
const MAX_USAGE_FILE: u64 = 16 << 20;
/// Weeks of a library's insertions and detachments kept (a year and the current week), and most
/// rows (a component in a week) kept in all.
const MAX_WEEKS: u64 = 53;
@@ -317,6 +317,10 @@ struct UsageEntry {
detached_updated: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
peaks: Vec<(NodeId, u32, u32)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
styles: Vec<(String, u32)>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
variables: Vec<(String, u32)>,
}
fn is_zero(n: &u64) -> bool {
@@ -274,6 +274,8 @@ impl Backend for Store {
LibraryReq::Detached => self.library_detached(file).map_or_else(failed, LibraryReply::Detached),
LibraryReq::ReportInserted(d) => self.report_inserted(file, d).map_or_else(failed, |()| LibraryReply::Noted),
LibraryReq::Actions => self.library_actions(file).map_or_else(failed, LibraryReply::Actions),
LibraryReq::ReportAssets(a) => self.report_assets(file, a).map_or_else(failed, |()| LibraryReply::Noted),
LibraryReq::Assets => self.library_assets(file).map_or_else(failed, LibraryReply::Assets),
}
}
+44 -10
View File
@@ -1,5 +1,7 @@
use figcraft_collab::message::MAX_ASSET_USAGE;
use super::{
ACTIONS, Action, BUSY_DOWNLOADS, Cursor, Detached, FILES, FileDetached, FileUsage, IpAddr, LIBRARY, LibraryMeta, LibraryReply,
ACTIONS, Action, AssetUsage, BUSY_DOWNLOADS, Cursor, Detached, FILES, FileDetached, FileUsage, IpAddr, LIBRARY, LibraryMeta, LibraryReply,
MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED, MAX_USAGE_FILE, MAX_USERS, MAX_WEEK_ROWS, MAX_WEEKS, META, NO_PUBLIC_LIBRARY, NodeId, Path, PathBuf,
Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, UsageEntry, WeekActions, clean_name, disk, fs, now, write_json,
};
@@ -12,6 +14,8 @@ enum Count {
}
const WEEK: u64 = 7 * 86_400;
/// Longest style or variable id a report may name.
const MAX_ASSET_ID: usize = 64;
/// The start of the week `t` falls in (seconds since 1970 UTC, a Monday at 00:00).
fn week_of(t: u64) -> u64 {
@@ -20,6 +24,20 @@ fn week_of(t: u64) -> u64 {
u64::try_from(monday.and_time(chrono::NaiveTime::MIN).and_utc().timestamp()).unwrap_or(0)
}
/// A report's counts cleaned: positive, one per key, at most [`MAX_REPORTED`].
fn cleaned<K: Ord>(counts: Vec<(K, u32)>) -> Vec<(K, u32)> {
let mut counts: Vec<_> = counts.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect();
counts.sort();
counts.dedup_by(|a, b| a.0 == b.0);
counts.truncate(MAX_REPORTED);
counts
}
/// A report's style or variable counts: ids of a sensible length, at most [`MAX_ASSET_USAGE`].
fn known_assets(counts: Vec<(String, u32)>) -> Vec<(String, u32)> {
counts.into_iter().filter(|(id, _)| id.len() <= MAX_ASSET_ID).take(MAX_ASSET_USAGE).collect()
}
/// A kept list of statistics, or an empty one when it is missing or damaged (it starts over).
fn read_kept<T: serde::de::DeserializeOwned>(path: &Path) -> Vec<T> {
if !path.exists() {
@@ -188,6 +206,13 @@ impl Store {
.collect())
}
/// The style and variable reports kept for a file's library (none when it has no library).
pub fn library_assets(&mut self, file: &str) -> Result<Vec<AssetUsage>, String> {
let list = self.usage_of(file)?.map(|u| u.list.as_slice()).unwrap_or_default();
let used = list.iter().filter(|e| !e.styles.is_empty() || !e.variables.is_empty());
Ok(used.map(|e| AssetUsage { file: e.usage.file.clone(), styles: e.styles.clone(), variables: e.variables.clone() }).collect())
}
/// A file's library's components' insertions and detachments week by week, over the last
/// [`MAX_WEEKS`] weeks (none when it has no library).
pub fn library_actions(&mut self, file: &str) -> Result<Vec<WeekActions>, String> {
@@ -209,6 +234,19 @@ impl Store {
})
}
/// Keep how many layers of one file use a file's library's styles and variables (as
/// [`Self::report_usage`] keeps its usage, beside it).
pub fn report_assets(&mut self, file: &str, assets: AssetUsage) -> Result<(), String> {
let variables = cleaned(known_assets(assets.variables));
self.report(file, &assets.file, known_assets(assets.styles), |e, styles| {
if e.styles == styles && e.variables == variables {
return false;
}
(e.styles, e.variables) = (styles, variables);
true
})
}
/// Keep how many instances of a file's library components one file has detached (as
/// [`Self::report_usage`] keeps its usage, beside it); what it detached since its last report
/// counts in this week.
@@ -241,24 +279,20 @@ impl Store {
Ok(())
}
/// Keep `counts` from the file called `name` (cleaned: positive counts, one per component, at
/// most [`MAX_REPORTED`]) in its entry with `set` (which says whether anything changed),
/// Keep `counts` from the file called `name` ([`cleaned`]) in its entry with `set` (which says whether anything changed),
/// keeping at most [`MAX_USERS`] files, the ones heard from most recently.
fn report(
fn report<K: Ord>(
&mut self,
file: &str,
name: &str,
counts: Vec<(NodeId, u32)>,
set: impl FnOnce(&mut UsageEntry, Vec<(NodeId, u32)>) -> bool,
counts: Vec<(K, u32)>,
set: impl FnOnce(&mut UsageEntry, Vec<(K, u32)>) -> bool,
) -> Result<(), String> {
let name = clean_name(name);
if name.is_empty() {
return Ok(());
}
let mut counts: Vec<_> = counts.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect();
counts.sort();
counts.dedup_by_key(|c| c.0);
counts.truncate(MAX_REPORTED);
let counts = cleaned(counts);
let Some(u) = self.usage_of(file)? else { return Ok(()) };
let at = match u.list.iter().position(|e| e.usage.file == name) {
Some(at) => at,
+1 -1
View File
@@ -8,7 +8,7 @@
//! await figcraft.execute("node.create", {kind: "frame", name: "Home"}) // engine command → its result
//! await figcraft.request("document.inspect") // control method → its result
//! await figcraft.commands() // command list
//! const {base64} = await figcraft.screenshot() // the canvas as PNG (base64)
//! const {data} = await figcraft.screenshot() // the canvas as PNG (base64)
//! await figcraft.openFile("home.fig", bytes) // `file.open` with the bytes
//! figcraft.captureDownloads(true) // saves stay in the page…
//! figcraft.files(); await figcraft.readFile("home.figcraft") // …to be read back
+1 -1
View File
@@ -22,7 +22,7 @@ its rounded corners, like the other Crafting Apps.
## Provenance
The owner's original engraving-style drawing (1500 px, keyed to the palette), supplied for
FigCraft and vectorised with craftrules `assets/logo-options/_tools/vectorize_tile.py`. The source
FigCraft and vectorised. The source
image is not kept in this repo. License: see `LICENSE.txt`.
## Files
+2 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
+1 -1
View File
@@ -234,7 +234,7 @@ impl LiveLink for NetLink {
let spawned = std::thread::Builder::new().name("library-fetch".into()).spawn(move || {
for (link, req) in jobs {
// A report's answer says nothing about the library (and an older server refuses it).
let report = matches!(req, LibraryReq::ReportDetached(_) | LibraryReq::ReportInserted(_));
let report = matches!(req, LibraryReq::ReportDetached(_) | LibraryReq::ReportInserted(_) | LibraryReq::ReportAssets(_));
let reply = manage::library(&link, req, &limits, CONNECT);
if !report {
inbox.push(Event::Library { link, reply });
+2 -2
View File
@@ -25,7 +25,7 @@ fn a_boards_kind_and_its_objects_metadata_sync() {
let mut edited = host.document().clone();
let c = edited.create(page, NodeKind::Vector { network: Default::default() }, "Connector", 0.0, 0.0, 1.0, 1.0).unwrap();
edited.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector {
from: End::Node { node: a, side: Side::Auto },
from: End::Node { node: a, side: Side::Auto, at: None },
to: End::Point { x: 5.0, y: 5.0 },
routing: Routing::Elbow,
path: None,
@@ -34,7 +34,7 @@ fn a_boards_kind_and_its_objects_metadata_sync() {
r.apply(&diff(&host, &edited).unwrap().changes, Mode::Strict).unwrap();
let mut moved = r.document().clone();
moved.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector {
from: End::Node { node: a, side: Side::Top },
from: End::Node { node: a, side: Side::Top, at: None },
to: End::Point { x: 9.0, y: 1.0 },
routing: Routing::Straight,
path: None,
+18
View File
@@ -617,6 +617,18 @@ pub struct Usage {
pub components: Vec<(NodeId, u32)>,
}
/// Most styles and most variables of one library one file reports using.
pub const MAX_ASSET_USAGE: usize = 250;
/// How many layers of one file use each of a library's styles and variables, as its app reports
/// them: the file's name and the counts (by their ids in the library).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetUsage {
pub file: String,
pub styles: Vec<(String, u32)>,
pub variables: Vec<(String, u32)>,
}
/// A file's last usage report, with when it came (seconds since 1970, UTC).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileUsage {
@@ -675,6 +687,11 @@ pub enum LibraryReq {
/// The library's components' insertions and detachments week by week (answered with
/// [`LibraryReply::Actions`]).
Actions,
/// How many layers of the asking file use the library's styles and variables (answered with
/// [`LibraryReply::Noted`]).
ReportAssets(AssetUsage),
/// The style and variable reports of the files that use the library.
Assets,
}
/// The answer to a [`LibraryReq`] (the first one on a connection answers [`OpenLibrary`]).
@@ -709,6 +726,7 @@ pub enum LibraryReply {
Noted,
Detached(Vec<FileDetached>),
Actions(Vec<WeekActions>),
Assets(Vec<AssetUsage>),
}
/// Opens a connection to a server's plugin directory (instead of [`Hello`]). Anyone who can reach
+15 -5
View File
@@ -157,7 +157,7 @@ impl Color {
return Self::from_hex(&t);
}
if let Some(inner) = t.strip_prefix("rgba(").or_else(|| t.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')')) {
let parts = css_args(inner);
let parts = css_args(inner)?;
let num = |i: usize| -> Option<f32> {
let p = parts.get(i)?;
if let Some(pc) = p.strip_suffix('%') {
@@ -176,7 +176,7 @@ impl Color {
return Some(Self::new(num(0)?, num(1)?, num(2)?, alpha));
}
if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) {
let parts = css_args(inner);
let parts = css_args(inner)?;
let h = parts.first()?.trim_end_matches("deg").parse::<f32>().ok()?;
let s = parts.get(1)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
let l = parts.get(2)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
@@ -244,14 +244,24 @@ impl Color {
}
}
fn css_args(inner: &str) -> Vec<String> {
inner.replace('/', ",").split([',', ' ']).map(str::trim).filter(|p| !p.is_empty()).map(str::to_string).collect()
/// Split CSS function arguments on `,` and `/` (each may be surrounded by spaces) and on runs of
/// whitespace. An empty argument between two separators makes the whole list invalid.
fn css_args(inner: &str) -> Option<Vec<String>> {
let mut out = Vec::new();
for seg in inner.replace('/', ",").split(',') {
let before = out.len();
out.extend(seg.split_whitespace().map(str::to_string));
if out.len() == before {
return None;
}
}
Some(out)
}
/// The arguments of `lch()` / `oklch()`: lightness, chroma, hue and alpha. Percentages are of
/// `l_full` / `c_full`; `none` is 0.
fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> {
let parts = css_args(inner);
let parts = css_args(inner)?;
if !(3..=4).contains(&parts.len()) {
return None;
}
+9
View File
@@ -148,3 +148,12 @@ fn css_hsl_rejects_non_finite() {
assert_eq!(Color::parse_css(s), None, "{s}");
}
}
#[test]
fn css_rejects_empty_arguments() {
for s in ["rgb(255,,0,0)", "rgb(,1,2,3)", "rgb(1,2,3,)", "hsl(10,,50%,50%)", "rgb(1,2,,)", "oklch(0.5,,0.1,20)"] {
assert_eq!(Color::parse_css(s), None, "{s}");
}
assert!(Color::parse_css("rgb(255 0 0 / 50%)").is_some());
assert!(Color::parse_css("hsl(120deg 50% 50%)").is_some());
}
+3
View File
@@ -577,6 +577,9 @@ pub enum End {
node: NodeId,
#[serde(default)]
side: Side,
/// Where on the layer it sits by hand, as fractions of its box (`None`: on its side).
#[serde(default, skip_serializing_if = "Option::is_none")]
at: Option<[f64; 2]>,
},
Point {
x: f64,
+21 -15
View File
@@ -12,12 +12,14 @@ use crate::{ConnectorPath, Routing, Side};
pub enum RouteEnd {
Point(Point),
Box(Rect, Side),
/// A point on the object in the box, placed by hand, with the side the route leaves by.
On(Rect, Side, Point),
}
impl RouteEnd {
fn centre(self) -> Point {
match self {
RouteEnd::Point(p) => p,
RouteEnd::Point(p) | RouteEnd::On(_, _, p) => p,
RouteEnd::Box(b, _) => b.center(),
}
}
@@ -166,22 +168,26 @@ fn attach(e: RouteEnd, other: Point) -> (Point, Option<Side>) {
match e {
RouteEnd::Point(p) => (p, None),
RouteEnd::Box(b, side) => {
let side = match side {
Side::Auto => {
let d = other - b.center();
// Compare against the box's proportions: a wide box takes the sides more.
if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) {
if d.x >= 0.0 { Side::Right } else { Side::Left }
} else if d.y >= 0.0 {
Side::Bottom
} else {
Side::Top
}
}
s => s,
};
let side = named_or_facing(b, side, other);
(side.point(b), Some(side))
}
RouteEnd::On(b, side, p) => (p, Some(named_or_facing(b, side, other))),
}
}
/// `side`, or for [`Side::Auto`] the side of `b` facing `other`.
fn named_or_facing(b: Rect, side: Side, other: Point) -> Side {
if side != Side::Auto {
return side;
}
let d = other - b.center();
// Compare against the box's proportions: a wide box takes the sides more.
if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) {
if d.x >= 0.0 { Side::Right } else { Side::Left }
} else if d.y >= 0.0 {
Side::Bottom
} else {
Side::Top
}
}
+22
View File
@@ -28,6 +28,8 @@ pub enum StrokeCap {
}
impl StrokeCap {
/// The caps a dash can have.
pub const DASH: [StrokeCap; 3] = [StrokeCap::None, StrokeCap::Round, StrokeCap::Square];
pub const ALL: [StrokeCap; 8] = [
StrokeCap::None,
StrokeCap::Round,
@@ -53,6 +55,14 @@ impl StrokeCap {
pub fn is_arrow(self) -> bool {
!matches!(self, StrokeCap::None | StrokeCap::Round | StrokeCap::Square)
}
/// The cap shape this draws on the stroke's band (arrowheads are drawn on top of it).
pub fn shape(self) -> StrokeCap {
match self {
StrokeCap::Round | StrokeCap::CircleFilled => StrokeCap::Round,
StrokeCap::Square | StrokeCap::DiamondFilled => StrokeCap::Square,
_ => StrokeCap::None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -82,6 +92,9 @@ pub struct StrokeProps {
pub miter_limit: f64,
/// Dash pattern (dash, gap, …); empty = solid.
pub dashes: Vec<f64>,
/// The cap of every dash, both ends (`None`: dashes take `cap_start` and `cap_end`).
#[serde(skip_serializing_if = "Option::is_none")]
pub dash_cap: Option<StrokeCap>,
/// Variable width: how the width changes along the path (`None` = uniform).
#[serde(skip_serializing_if = "Option::is_none")]
pub width_profile: Option<WidthProfile>,
@@ -151,6 +164,7 @@ impl Default for StrokeProps {
join: StrokeJoin::Miter,
miter_limit: 4.0,
dashes: Vec::new(),
dash_cap: None,
width_profile: None,
brush: None,
}
@@ -158,6 +172,14 @@ impl Default for StrokeProps {
}
impl StrokeProps {
/// The start and end cap the band is drawn with: a dashed stroke's dash cap when it has one.
pub fn band_caps(&self) -> [StrokeCap; 2] {
match self.dash_cap.filter(|_| !self.dashes.is_empty()) {
Some(c) => [c, c],
None => [self.cap_start, self.cap_end],
}
}
/// The variable width profile, when there is one with points.
pub fn profile(&self) -> Option<&WidthProfile> {
self.width_profile.as_ref().filter(|p| !p.points.is_empty())
+23
View File
@@ -129,6 +129,29 @@ pub const GAMEPAD_BUTTONS: [&str; 17] = [
"Gamepad Home",
];
/// What PlayStation and Nintendo Switch controllers call each of [`GAMEPAD_BUTTONS`], in the same
/// order: the same button by its place on the controller, so a Switch's bottom face button is its
/// B though it does what "Gamepad A" does. A trigger is stored by place and works on any of them.
pub const GAMEPAD_NAMES: [(&str, &str); 17] = [
("✕", "B"),
("○", "A"),
("□", "Y"),
("△", "X"),
("L1", "L"),
("R1", "R"),
("L2", "ZL"),
("R2", "ZR"),
("Share", "−"),
("Options", "+"),
("L3", "LS"),
("R3", "RS"),
("Up", "Up"),
("Down", "Down"),
("Left", "Left"),
("Right", "Right"),
("PS", "Home"),
];
/// The button at standard-layout index `index`, as a key trigger names it.
pub fn gamepad_button(index: usize) -> Option<&'static str> {
GAMEPAD_BUTTONS.get(index).copied()
+44 -3
View File
@@ -11,11 +11,12 @@
//! ([`Session::refresh_server_libraries`]) and keeps the last library fetched while the server
//! can't be reached.
use std::collections::{BTreeMap, BTreeSet};
use std::io::Cursor;
use std::sync::Arc;
use figcraft_collab::message::{Detached, LibraryReply, LibraryReq, Usage};
use figcraft_doc::{DetachCount, Document, NodeId, NodeKind, PublishedLibrary};
use figcraft_collab::message::{AssetUsage, Detached, LibraryReply, LibraryReq, MAX_ASSET_USAGE, Usage};
use figcraft_doc::{DetachCount, Document, ImportedRecord, NodeId, NodeKind, PublishedLibrary};
use crate::live::{NetReply, NetRequest};
use crate::{EngineError, Result, Session};
@@ -91,6 +92,42 @@ pub fn usage(doc: &Document, library: &str) -> Usage {
Usage { file: doc.name.clone(), components: counts.into_iter().take(MAX_USAGE).collect() }
}
/// How many layers of `doc` use each of `library`'s styles and variables, the most used first (a
/// instance, a layer inside one or the library copies belongs to its component, which is what's
/// counted); `None` when it has none of them, so a file that stops using them still reports that.
pub fn asset_usage(doc: &Document, library: &str) -> Option<AssetUsage> {
let keys = |records: &[ImportedRecord]| -> BTreeMap<String, String> {
records.iter().filter(|r| r.library == library).map(|r| (r.local.clone(), r.key.clone())).collect()
};
let (style_keys, variable_keys) = (keys(&doc.libraries.styles), keys(&doc.libraries.variables));
if style_keys.is_empty() && variable_keys.is_empty() {
return None;
}
let store = doc.library_store();
let (mut styles, mut variables) = (BTreeMap::<&str, u32>::new(), BTreeMap::<&str, u32>::new());
let instance = |n: &figcraft_doc::Node| matches!(n.kind, NodeKind::Instance { .. }) || crate::components::inside_instance(doc, n.id);
let own = doc.nodes().filter(|n| store.is_none_or(|st| doc.page_of(n.id) != Some(st)) && !instance(n));
for n in own {
let used = crate::library::style_links(n).filter_map(|s| style_keys.get(s)).collect::<BTreeSet<_>>();
for k in used {
*styles.entry(k).or_default() += 1;
}
let used = n.bound.values().filter_map(|v| variable_keys.get(v)).collect::<BTreeSet<_>>();
for k in used {
*variables.entry(k).or_default() += 1;
}
}
Some(AssetUsage { file: doc.name.clone(), styles: most_used(styles), variables: most_used(variables) })
}
/// `counts`, the most used first, at most [`MAX_ASSET_USAGE`].
fn most_used(counts: BTreeMap<&str, u32>) -> Vec<(String, u32)> {
let mut list: Vec<(String, u32)> = counts.into_iter().map(|(k, n)| (k.to_string(), n)).collect();
list.sort_by_key(|c| std::cmp::Reverse(c.1));
list.truncate(MAX_ASSET_USAGE);
list
}
/// How many instances of `library`'s components `doc` has detached, as its server keeps them;
/// `None` when it has detached none.
pub fn detached(doc: &Document, library: &str) -> Option<Detached> {
@@ -226,7 +263,11 @@ impl Session {
};
// Then the detaches and insertions, when there are any (a server from before them
// refuses the request, and nothing comes of it).
let reports = [detached(doc, &l.id).map(LibraryReq::ReportDetached), inserted(doc, &l.id).map(LibraryReq::ReportInserted)];
let reports = [
detached(doc, &l.id).map(LibraryReq::ReportDetached),
inserted(doc, &l.id).map(LibraryReq::ReportInserted),
asset_usage(doc, &l.id).map(LibraryReq::ReportAssets),
];
let fetch = LibraryReq::Fetch { have, usage: Some(usage(doc, &l.id)) };
std::iter::once(fetch).chain(reports.into_iter().flatten()).map(|req| (l.path.clone(), req))
})
+3
View File
@@ -251,6 +251,9 @@ fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>, options: &Inspe
if !n.stroke.dashes.is_empty() {
insert_prop(o, options, "dashes", || json!(n.stroke.dashes));
}
if let Some(cap) = n.stroke.dash_cap {
insert_prop(o, options, "dashCap", || json!(cap));
}
if n.stroke.join != StrokeJoin::Miter {
insert_prop(o, options, "strokeJoin", || json!(n.stroke.join));
} else if n.stroke.miter_limit != 4.0 {
+2 -2
View File
@@ -40,7 +40,7 @@ fn a_shape_gets_the_next_one_joined_on_each_side() {
assert!(matches!(nb.board, Some(BoardItem::Shape { shape: BoardShape::Diamond })), "{side}: the same shape");
assert_eq!(colour(&s, b), colour(&s, a), "{side}: the same colour");
let k = connector(&s, c);
assert_eq!((k.from, k.to), (End::Node { node: a, side: ends.0 }, End::Node { node: b, side: ends.1 }), "{side}");
assert_eq!((k.from, k.to), (End::Node { node: a, side: ends.0, at: None }, End::Node { node: b, side: ends.1, at: None }), "{side}");
assert_eq!(k.routing, s.tool.board.routing, "{side}: as the connector tool makes one");
assert_eq!(s.selection(), vec![b], "{side}: the new one is selected");
}
@@ -93,7 +93,7 @@ fn the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_wer
let (b, c) = (id(&r, "id"), id(&r, "connector"));
assert!(matches!(node(&s, b).board, Some(BoardItem::Shape { shape: BoardShape::Rectangle })));
assert_eq!(at(&s, b), (200.0 + CONNECTED_GAP, 0.0));
assert_eq!(connector(&s, c).from, End::Node { node: a, side: Side::Right });
assert_eq!(connector(&s, c).from, End::Node { node: a, side: Side::Right, at: None });
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1, "one undo step");
// Another side, as the command takes one.
let r = s.execute("board.nextSticky", &json!({"id": a, "side": "below"})).unwrap();
+10 -4
View File
@@ -25,7 +25,7 @@ fn three() -> (Session, [figcraft_doc::NodeId; 4]) {
fn reattach_moves_one_end_and_undoes() {
let (mut s, [a, b, c, k]) = three();
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": c, "side": "top"})).unwrap();
assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top });
assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top, at: None });
assert_eq!(connector(&s, k).from.node(), Some(a));
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 0.0)), "arrives on c's top: {:?}", drawn(&s, k));
s.execute("edit.undo", &json!({})).unwrap();
@@ -257,7 +257,7 @@ fn an_end_dropped_along_a_side_attaches_to_that_side() {
let want = (side != Side::Auto).then(|| side.point(bounds)).map(|p| [p.x, p.y]);
assert_eq!(lit, want.into_iter().collect::<Vec<_>>(), "{drop:?}");
s.pointer(&ev(PointerKind::Up, drop)).unwrap();
assert_eq!(connector(&s, k).from, End::Node { node: a, side }, "{drop:?}");
assert_eq!(connector(&s, k).from, End::Node { node: a, side, at: None }, "{drop:?}");
if side != Side::Auto {
assert_eq!(drawn(&s, k)[0], side.point(bounds), "the route leaves from that side");
}
@@ -277,7 +277,10 @@ fn the_connector_tool_takes_the_side_it_starts_and_ends_on() {
s.pointer(&ev(PointerKind::Move, 590.0, 330.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 590.0, 330.0)).unwrap();
let k = the_connector(&s).unwrap().id;
assert_eq!((connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Bottom }, End::Node { node: b, side: Side::Right }));
assert_eq!(
(connector(&s, k).from, connector(&s, k).to),
(End::Node { node: a, side: Side::Bottom, at: None }, End::Node { node: b, side: Side::Right, at: None })
);
}
#[test]
@@ -300,6 +303,9 @@ fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() {
s.pointer(&ev(PointerKind::Move, 352.0, 204.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 352.0, 204.0)).unwrap();
let k = the_connector(&s).unwrap().id;
assert_eq!((connector(&s, k).from, connector(&s, k).to), (End::Node { node: a, side: Side::Right }, End::Node { node: b, side: Side::Top }));
assert_eq!(
(connector(&s, k).from, connector(&s, k).to),
(End::Node { node: a, side: Side::Right, at: None }, End::Node { node: b, side: Side::Top, at: None })
);
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]);
}
+2 -2
View File
@@ -71,8 +71,8 @@ fn a_reshaped_connector_keeps_the_sides_it_left_by() {
let (portion, at) = handles[handles.len() / 2];
reshape(&mut s, k, portion as f64, at.x + 30.0, at.y + 30.0).unwrap();
let c = connector(&s, k);
assert!(matches!(c.from, End::Node { node, side } if node == a && side != Side::Auto), "{c:?}");
assert!(matches!(c.to, End::Node { node, side } if node == b && side != Side::Auto), "{c:?}");
assert!(matches!(c.from, End::Node { node, side, .. } if node == a && side != Side::Auto), "{c:?}");
assert!(matches!(c.to, End::Node { node, side, .. } if node == b && side != Side::Auto), "{c:?}");
}
/// "For the start and end portions of a connector, dragging can add an extra portion to the path."
+6 -1
View File
@@ -201,7 +201,12 @@ fn connectors_follow_what_they_join_and_undo_with_it() {
fn routes_are_the_same_for_the_same_document() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0})).unwrap(), "id");
let c = Connector { from: End::Node { node: a, side: Side::Bottom }, to: End::Point { x: 300.0, y: 500.0 }, routing: Routing::Elbow, path: None };
let c = Connector {
from: End::Node { node: a, side: Side::Bottom, at: None },
to: End::Point { x: 300.0, y: 500.0 },
routing: Routing::Elbow,
path: None,
};
let doc = s.doc().unwrap();
let r = route(doc, &c).unwrap();
assert_eq!(r, route(&doc.clone(), &c).unwrap());
+1 -1
View File
@@ -97,7 +97,7 @@ fn routes_take_named_sides_and_free_points() {
let b = id(&s.execute("board.shape", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(), "id");
let doc = s.doc().unwrap();
let elbow = |from, to| route(doc, &figcraft_doc::Connector { from, to, routing: figcraft_doc::Routing::Elbow, path: None }).unwrap();
let side = |n, side| End::Node { node: n, side };
let side = |n, side| End::Node { node: n, side, at: None };
let p = |x, y| End::Point { x, y };
let pt = Point::new;
// Side to side: out, across halfway, in.
+2 -2
View File
@@ -128,14 +128,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"board.connector",
"Connector",
"{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins",
"{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), fromAt?, toAt? ([x, y] on the layer, fractions of its box), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins",
true,
connector_cmd
),
spec!(
"board.reattach",
"Reattach connector",
"{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left)} → {id} moves one end of a connector",
"{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left), at? ([x, y] on the layer, fractions of its box)} → {id} moves one end of a connector",
true,
reattach
),
+27 -6
View File
@@ -6,8 +6,21 @@ use super::{
VectorNetwork, bad, cap_of, get, id_of, json, num, req_targets, require, routing_of, string,
};
/// One connector end from the params: a layer on this page, or a point.
fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result<End> {
/// Where on a layer a connector end sits, from the params: `[x, y]`, fractions of its box.
fn at_param(cmd: &str, p: &Value, key: &str) -> Result<Option<[f64; 2]>> {
let Some(v) = get(p, key).filter(|v| !v.is_null()) else { return Ok(None) };
match v.as_array().map(Vec::as_slice) {
Some([x, y]) => match (x.as_f64().filter(|x| x.is_finite()), y.as_f64().filter(|y| y.is_finite())) {
(Some(x), Some(y)) => Ok(Some([x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)])),
_ => Err(bad(cmd, format!("`{key}` is [x, y], fractions of the layer's box"))),
},
_ => Err(bad(cmd, format!("`{key}` is [x, y], fractions of the layer's box"))),
}
}
/// One connector end from the params (`keys`: its target, side and place on the layer): a layer on
/// this page, or a point.
fn end_param(cmd: &str, s: &Session, p: &Value, [key, side_key, at_key]: [&str; 3]) -> Result<End> {
let side = match string(p, side_key) {
Some(v) => Side::from_id(&v).ok_or_else(|| bad(cmd, format!("`{side_key}` is auto, top, right, bottom or left")))?,
None => Side::Auto,
@@ -30,7 +43,7 @@ fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Re
if matches!(n.board, Some(BoardItem::Connector(_))) {
return Err(bad(cmd, format!("`{key}` can't be another connector")));
}
Ok(End::Node { node, side })
Ok(End::Node { node, side, at: at_param(cmd, p, at_key)? })
}
None => Err(bad(cmd, format!("`{key}` is required"))),
}
@@ -39,8 +52,8 @@ fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Re
pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.connector";
require(s, cmd)?;
let from = end_param(cmd, s, p, "from", "fromSide")?;
let to = end_param(cmd, s, p, "to", "toSide")?;
let from = end_param(cmd, s, p, ["from", "fromSide", "fromAt"])?;
let to = end_param(cmd, s, p, ["to", "toSide", "toAt"])?;
if from.node().is_some() && from.node() == to.node() {
return Err(bad(cmd, "connect two different layers"));
}
@@ -106,7 +119,7 @@ pub(super) fn reattach(s: &mut Session, p: &Value) -> Result<Value> {
Some("to") => false,
_ => return Err(bad(cmd, "`end` is from or to")),
};
let end = end_param(cmd, s, p, "target", "side")?;
let end = end_param(cmd, s, p, ["target", "side", "at"])?;
let other = if from { c.to } else { c.from };
if end.node().is_some() && end.node() == other.node() {
return Err(bad(cmd, "connect two different layers"));
@@ -141,6 +154,14 @@ pub(super) fn side_json(e: End) -> Value {
}
}
/// Where a connector end sits on its layer, as the commands take it.
pub(super) fn at_json(e: End) -> Value {
match e {
End::Node { at: Some(at), .. } => json!(at),
_ => Value::Null,
}
}
/// The end of connector `id` that stays while its other one (`from`: its start) is dragged.
pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option<End> {
match &doc.node(id)?.board {
+4 -1
View File
@@ -297,7 +297,10 @@ pub fn route(doc: &Document, c: &Connector) -> Option<Vec<Point>> {
pub(super) fn route_ends(doc: &Document, c: &Connector) -> Option<(RouteEnd, RouteEnd)> {
let end = |e: &End| match e {
End::Point { x, y } => Some(RouteEnd::Point(Point::new(*x, *y))),
End::Node { node, side } => doc.world_bounds(*node).map(|b| RouteEnd::Box(b, *side)),
End::Node { node, side, at } => doc.world_bounds(*node).map(|b| match at {
Some([x, y]) => RouteEnd::On(b, *side, Point::new(b.x0 + x * b.width(), b.y0 + y * b.height())),
None => RouteEnd::Box(b, *side),
}),
};
Some((end(&c.from)?, end(&c.to)?))
}
+6 -1
View File
@@ -232,7 +232,12 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
None => make_sticky(doc, parent, to_at, "", color, &author)?,
Some(shape) => make_shape(doc, parent, to_at, next, shape, "", color)?,
};
let connector = Connector { from: End::Node { node: from, side: from_side }, to: End::Node { node: id, side: to_side }, routing, path: None };
let connector = Connector {
from: End::Node { node: from, side: from_side, at: None },
to: End::Node { node: id, side: to_side, at: None },
routing,
path: None,
};
let line = make(doc, page, connector, CAPS)?;
s.set_selection(vec![id]);
if boolean(p, "edit") == Some(true) {
+1 -1
View File
@@ -16,7 +16,7 @@ const MIN_RUN_PX: f64 = 28.0;
/// `end` with the side it leaves by named: a reshaped path holds only as long as its ends do.
fn pinned(end: End, side: Option<Side>) -> End {
match (end, side) {
(End::Node { node, .. }, Some(side)) => End::Node { node, side },
(End::Node { node, at, .. }, Some(side)) => End::Node { node, side, at },
_ => end,
}
}
+16 -6
View File
@@ -1,4 +1,4 @@
use super::connectors::{CAPS, end_json, make, other_end, side_json};
use super::connectors::{CAPS, at_json, end_json, make, other_end, side_json};
use super::derived::{ends_now, route};
use super::edit::label_of;
use super::items::edit_text;
@@ -10,6 +10,11 @@ use super::{
/// How near (screen pixels) the pointer must be to the middle of an object's side for a connector
/// end to attach there, however small the object is on screen.
const ATTACH_PX: f64 = 10.0;
/// Point `p` as fractions of box `b` (`None`: a box with no area).
fn part_at(b: figcraft_geom::Rect, p: Point) -> Option<[f64; 2]> {
(b.width() > 0.0 && b.height() > 0.0).then(|| [((p.x - b.x0) / b.width()).clamp(0.0, 1.0), ((p.y - b.y0) / b.height()).clamp(0.0, 1.0)])
}
/// The sides an object takes a connector end at (their middles are its attach points).
const SIDES: [Side; 4] = [Side::Top, Side::Right, Side::Bottom, Side::Left];
@@ -92,7 +97,8 @@ impl Session {
}
/// What a connector end at the pointer attaches to: the object there (at the side whose middle
/// is within [`ATTACH_PX`], else the side [`side_at`] the pointer), or the point.
/// is within [`ATTACH_PX`], else the side [`side_at`] the pointer; with Cmd/Ctrl held, right
/// there), or the point.
fn end_at(&self, ev: &PointerEvent) -> End {
let zoom = ev.zoom.max(1e-6);
let hit = (|| {
@@ -109,7 +115,10 @@ impl Session {
let far = |s: &Side| b.map(|b| (s.point(b) - ev.p()).hypot() * zoom).filter(|d| *d <= ATTACH_PX);
let near = SIDES.iter().filter_map(|s| Some((*s, far(s)?))).min_by(|x, y| x.1.total_cmp(&y.1)).map(|(s, _)| s);
let side = near.or(b.map(|b| side_at(b, ev.p()))).unwrap_or(Side::Auto);
Some(End::Node { node: t, side })
match b.and_then(|b| part_at(b, ev.p())).filter(|_| ev.cmd) {
Some(at) => Some(End::Node { node: t, side: Side::Auto, at: Some(at) }),
None => Some(End::Node { node: t, side, at: None }),
}
})();
hit.unwrap_or(End::Point { x: ev.x, y: ev.y })
}
@@ -128,7 +137,7 @@ impl Session {
/// (`at`, else its named side's) lit.
fn show_attach(&mut self, end: Option<End>, at: Option<Point>) {
let attach = match end {
Some(End::Node { node, side }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| {
Some(End::Node { node, side, .. }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| {
let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b)));
SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec()
}),
@@ -179,7 +188,8 @@ impl Session {
self.cancel_interaction()?;
let Some(to) = self.drag_end(ev, from, start) else { return Ok(()) };
let style = self.tool.board.routing.id();
let p = json!({"from": end_json(from), "fromSide": side_json(from), "to": end_json(to), "toSide": side_json(to), "style": style});
let (from_at, to_at) = (at_json(from), at_json(to));
let p = json!({"from": end_json(from), "fromSide": side_json(from), "fromAt": from_at, "to": end_json(to), "toSide": side_json(to), "toAt": to_at, "style": style});
self.execute("board.connector", &p)?;
self.board_placed();
Ok(())
@@ -214,7 +224,7 @@ impl Session {
return Ok(());
}
let end = if from { "from" } else { "to" };
self.execute("board.reattach", &json!({"id": id, "end": end, "target": end_json(target), "side": side_json(target)}))?;
self.execute("board.reattach", &json!({"id": id, "end": end, "target": end_json(target), "side": side_json(target), "at": at_json(target)}))?;
Ok(())
}
+2 -2
View File
@@ -59,7 +59,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"node.create",
"Create layer",
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]), closed? (vector), props? {node.set keys}, select? (default true)}",
"{type: rectangle|ellipse|polygon|star|line|arrow|vector|text|frame|section|component|slice, x?, y? (relative to the parent frame; groups don't count), width?, height?, parent? (id; default: the page), index?, name?, text? | characters? (for text), path? | points? (vector: SVG path data | [[x,y],..]; x/y move their own origin, so the layer's left/top is x/y plus their box's unscaled offset), closed? (vector), props? {node.set keys}, select? (default true)}",
true,
create
),
@@ -106,7 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"vector.create",
"Vector",
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}",
"{path (SVG path data) | points [[x,y],..], closed?, x?, y? (move the path's own origin: the layer's left/top is x/y plus the box's unscaled offset), width?, height? (scale the path; one keeps its proportion), parent?, index?, name?, props?, select? (default true)}",
true,
|s, p| create_kind(s, p, "vector")
),
+1
View File
@@ -328,6 +328,7 @@ pub(super) fn copy_props_of(doc: &Document, id: NodeId) -> Value {
"fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align,
"strokeSides": n.stroke.sides, "startCap": n.stroke.cap_start, "endCap": n.stroke.cap_end,
"strokeJoin": n.stroke.join, "miterLimit": n.stroke.miter_limit, "dashes": n.stroke.dashes,
"dashCap": n.stroke.dash_cap,
"widthProfile": super::super::props::width_points_json(n.stroke.width_profile.as_ref()), "brush": n.stroke.brush,
"effects": n.effects, "opacity": n.opacity, "blend": n.blend,
});
+38 -1
View File
@@ -8,7 +8,7 @@ use std::sync::Arc;
use figcraft_doc::{Document, ImportedComponent, ImportedRecord, LibraryLink, LibraryManifest, NodeId, PublishedLibrary, VariableId};
use serde_json::{Value, json};
use figcraft_collab::message::{FileDetached, LibraryReply, LibraryReq, WeekActions};
use figcraft_collab::message::{AssetUsage, FileDetached, LibraryReply, LibraryReq, WeekActions};
use super::CommandSpec;
use crate::library::{
@@ -427,6 +427,7 @@ fn usage_cmd(s: &mut Session, p: &Value) -> Result<Value> {
other => return Err(unexpected(&other)),
};
let detached = detach_reports(s.library_request(&link, LibraryReq::Detached))?;
let assets = asset_reports(s.library_request(&link, LibraryReq::Assets))?;
let days = num(p, "days").filter(|d| d.is_finite()).map_or(30, |d| d.round().clamp(1.0, 365.0) as u64);
// Weeks overlapping the period; a server from before weekly counts refuses the request.
let since = crate::cmd::comment::now_secs().saturating_sub(days * 86_400);
@@ -479,9 +480,45 @@ fn usage_cmd(s: &mut Session, p: &Value) -> Result<Value> {
"weeks": actions.weeks,
"files": rows,
"components": components,
"assetsKnown": assets.is_some(),
"styles": asset_rows(assets.iter().flatten().map(|a| &a.styles), |id| {
m.style(id).map(|x| json!({"name": x.name, "type": x.kind}))
}),
"variables": asset_rows(assets.iter().flatten().map(|a| &a.variables), |id| {
let v = m.variable(id)?;
Some(json!({"name": v.name, "collection": m.collection(&v.collection).map(|c| c.name.clone())}))
}),
}))
}
/// The style or variable reports' totals, the most used first: each one's `id`, `layers` and
/// `files`, with what `describe` says of it (nothing for one no longer published).
fn asset_rows<'a>(reports: impl Iterator<Item = &'a Vec<(String, u32)>>, describe: impl Fn(&str) -> Option<Value>) -> Vec<Value> {
let mut per = std::collections::BTreeMap::<&str, (u64, u64)>::new();
for (id, n) in reports.flatten() {
let e = per.entry(id).or_default();
e.0 += u64::from(*n);
e.1 += 1;
}
let mut rows: Vec<_> = per.into_iter().collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1.0));
let row = |(id, (layers, files)): (&str, (u64, u64))| {
let mut r = describe(id).unwrap_or_else(|| json!({}));
(r["id"], r["layers"], r["files"]) = (json!(id), json!(layers), json!(files));
r
};
rows.into_iter().map(row).collect()
}
/// The style and variable reports, `None` from a server from before them.
fn asset_reports(reply: Result<LibraryReply>) -> Result<Option<Vec<AssetUsage>>> {
match reply {
Ok(LibraryReply::Assets(a)) => Ok(Some(a)),
Err(EngineError::Waiting) => Err(EngineError::Waiting),
_ => Ok(None),
}
}
/// A period's insertions and detachments: per component, in all, and week by week (ascending).
struct PeriodActions {
per: std::collections::BTreeMap<NodeId, (u64, u64)>,
+10 -4
View File
@@ -18,7 +18,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 or \"50%\"), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, {$var: variable id or name}, or \"none\"/null: no fill), strokes, stroke (colour, {$var: variable id or name}, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], dashCap (none|round|square; null: dashes take startCap and endCap), 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), \
columnGap / rowGap (aliases of gap / crossGap), gridColumns, gridRows, columnTracks / rowTracks ([120 | \"1fr\" | \"auto\" | {sizing, value}]), \
@@ -581,6 +581,12 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
let d: Vec<f64> = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default();
doc.get_mut(id)?.stroke.dashes = d;
}
"dashCap" => {
let cap = (!v.is_null())
.then(|| enum_of(v).filter(|c| StrokeCap::DASH.contains(c)).ok_or_else(|| bad(cmd, "dashCap: none|round|square, or null")))
.transpose()?;
doc.get_mut(id)?.stroke.dash_cap = cap;
}
"widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?,
"brush" | "strokeBrush" => doc.get_mut(id)?.stroke.brush = brush_of(v).ok_or_else(|| bad(cmd, BRUSH_HELP))?,
_ => return Ok(false),
@@ -1120,9 +1126,9 @@ fn set_path(cmd: &str, doc: &mut Document, id: NodeId, v: &Value, props: &Map<St
pub fn override_group(k: &str) -> &'static str {
match k {
"fills" | "fill" => "fills",
"strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "strokeCap" | "startCap" | "endCap" | "strokeJoin" | "strokeSides"
| "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" | "miterLimit" | "miterAngle" | "widthProfile"
| "brush" | "strokeBrush" => "strokes",
"strokes" | "stroke" | "strokeWeight" | "strokeAlign" | "dashes" | "dashCap" | "strokeCap" | "startCap" | "endCap" | "strokeJoin"
| "strokeSides" | "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" | "miterLimit" | "miterAngle"
| "widthProfile" | "brush" | "strokeBrush" => "strokes",
"effects" => "effects",
"opacity" | "blend" | "blendMode" => "opacity",
"visible" => "visible",
@@ -0,0 +1,100 @@
//! A connector end placed on any part of an object: holding Cmd/Ctrl while moving it puts it right
//! where the pointer is, and it stays at that part of the object when the object moves or resizes.
use figcraft_doc::{End, NodeId, Side};
use figcraft_geom::Point;
use serde_json::json;
use crate::Session;
use crate::board_tests::{board, connector, drawn, id, near};
use crate::tools::{PointerEvent, PointerKind};
/// A board with two 200 × 100 shapes and a straight connector between them: (a, b, connector).
fn scene() -> (Session, NodeId, NodeId, NodeId) {
let mut s = board();
let mut shape = |x: f64| id(&s.execute("board.shape", &json!({"x": x, "y": 0, "width": 200, "height": 100})).unwrap(), "id");
let (a, b) = (shape(0.0), shape(500.0));
let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(), "id");
s.execute("select.set", &json!({"ids": [k]})).unwrap();
(s, a, b, k)
}
/// Drag the connector's end at `from` to `to`, with Cmd/Ctrl held or not.
fn drag_end(s: &mut Session, from: Point, to: Point, cmd: bool) {
let ev = |kind, p: Point| PointerEvent { cmd, ..PointerEvent::at(kind, p.x, p.y, 1.0) };
s.pointer(&ev(PointerKind::Down, from)).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(300.0, 300.0))).unwrap();
s.pointer(&ev(PointerKind::Move, to)).unwrap();
s.pointer(&ev(PointerKind::Up, to)).unwrap();
}
#[test]
fn cmd_puts_an_end_on_any_part_of_the_object() {
let (mut s, _, b, k) = scene();
let end = *drawn(&s, k).last().unwrap();
drag_end(&mut s, end, Point::new(650.0, 25.0), true);
assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Auto, at: Some([0.75, 0.25]) });
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 25.0)), "{:?}", drawn(&s, k));
// It undoes as one step, back to the side.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Auto, at: None });
}
#[test]
fn without_cmd_an_end_keeps_snapping_to_a_side() {
let (mut s, _, b, k) = scene();
let end = *drawn(&s, k).last().unwrap();
drag_end(&mut s, end, Point::new(650.0, 25.0), false);
assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Right, at: None });
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(700.0, 50.0)));
}
#[test]
fn the_end_stays_on_that_part_when_the_object_moves_or_resizes() {
let (mut s, _, b, k) = scene();
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "at": [0.5, 0.5]})).unwrap();
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(600.0, 50.0)));
s.execute("node.set", &json!({"id": b, "props": {"x": 600, "y": 100, "width": 400, "height": 200}})).unwrap();
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(800.0, 200.0)), "{:?}", drawn(&s, k));
}
#[test]
fn an_end_moved_back_without_cmd_leaves_its_place() {
let (mut s, _, b, k) = scene();
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "at": [0.5, 0.5]})).unwrap();
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "side": "top"})).unwrap();
assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Top, at: None });
}
#[test]
fn the_commands_take_a_place_and_refuse_junk() {
let (mut s, a, b, _) = scene();
let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "fromAt": [0.25, 0.5], "toAt": [9, -1]})).unwrap(), "id");
assert!(matches!(connector(&s, k).from, End::Node { at: Some([0.25, 0.5]), .. }));
assert!(matches!(connector(&s, k).to, End::Node { at: Some([1.0, 0.0]), .. }), "kept on the object");
for junk in [json!("middle"), json!([1]), json!([1, 2, 3]), json!(["x", 0]), json!({"x": 0, "y": 0})] {
assert!(s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "at": junk})).is_err());
}
}
#[test]
fn a_reshaped_path_keeps_the_end_where_it_is() {
let (mut s, a, b, _) = scene();
let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "curved", "toAt": [0.5, 0.5]})).unwrap(), "id");
s.execute("board.reshape", &json!({"id": k, "portion": 0, "x": 350, "y": 200})).unwrap();
assert!(matches!(connector(&s, k).to, End::Node { at: Some([0.5, 0.5]), .. }));
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(600.0, 50.0)), "{:?}", drawn(&s, k));
}
#[test]
fn it_saves_only_when_placed_by_hand() {
let (mut s, _, b, k) = scene();
let saved = |s: &Session| figcraft_format::to_json(s.doc().unwrap()).unwrap().to_string();
assert!(!saved(&s).contains("\"at\":"), "older files stay as they are");
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": b, "at": [0.5, 0.25]})).unwrap();
assert!(saved(&s).contains("\"at\":[0.5,0.25]"));
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let (open, _) = figcraft_format::load_bytes(&bytes).unwrap();
let kept = open.node(k).and_then(|n| n.board.clone());
assert!(matches!(kept, Some(figcraft_doc::BoardItem::Connector(c)) if matches!(c.to, End::Node { at: Some([0.5, 0.25]), .. })));
}
+71
View File
@@ -0,0 +1,71 @@
//! The Dash cap through the session: `node.set`, the saved file, inspection and copied properties.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::{NodeId, StrokeCap};
/// A 200-long vector with a dashed 10 px black stroke.
fn line(s: &mut Session) -> NodeId {
let v = id_of(&s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
s.execute("node.set", &json!({"id": v, "props": {"strokes": ["#000000"], "strokeWeight": 10, "strokeAlign": "center", "dashes": [20, 20]}}))
.unwrap();
v
}
fn set(s: &mut Session, id: NodeId, cap: Value) -> crate::Result<Value> {
s.execute("node.set", &json!({"id": id, "props": {"dashCap": cap}}))
}
fn cap(s: &Session, id: NodeId) -> Option<StrokeCap> {
s.doc().unwrap().node(id).unwrap().stroke.dash_cap
}
#[test]
fn node_set_takes_the_three_caps_and_null_clears_it() {
let mut s = Session::new();
let l = line(&mut s);
assert_eq!(cap(&s, l), None, "unset by default");
for (name, want) in [("round", StrokeCap::Round), ("square", StrokeCap::Square), ("none", StrokeCap::None)] {
set(&mut s, l, json!(name)).unwrap();
assert_eq!(cap(&s, l), Some(want));
}
for junk in [json!("lineArrow"), json!("triangleArrow"), json!("wobbly"), json!(3)] {
assert!(set(&mut s, l, junk).is_err());
}
assert_eq!(cap(&s, l), Some(StrokeCap::None), "a rejected value changes nothing");
set(&mut s, l, Value::Null).unwrap();
assert_eq!(cap(&s, l), None);
}
#[test]
fn it_saves_only_when_set_and_older_files_open_unset() {
let mut s = Session::new();
let l = line(&mut s);
let bytes = |s: &Session| figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let unset = figcraft_format::to_json(s.doc().unwrap()).unwrap().to_string();
assert!(!unset.contains("dash_cap") && !unset.contains("dashCap"), "not written when unset");
let (open, _) = figcraft_format::load_bytes(&bytes(&s)).unwrap();
assert_eq!(open.node(l).unwrap().stroke.dash_cap, None);
set(&mut s, l, json!("round")).unwrap();
let (open, _) = figcraft_format::load_bytes(&bytes(&s)).unwrap();
assert_eq!(open.node(l).unwrap().stroke.dash_cap, Some(StrokeCap::Round));
}
#[test]
fn inspect_shows_it_and_copied_properties_carry_it() {
let mut s = Session::new();
let (a, b) = (line(&mut s), line(&mut s));
set(&mut s, a, json!("square")).unwrap();
assert_eq!(s.execute("node.get", &json!({"id": a})).unwrap()["dashCap"], "square");
assert!(s.execute("node.get", &json!({"id": b})).unwrap().get("dashCap").is_none(), "only when set");
s.execute("edit.copyProperties", &json!({"id": a})).unwrap();
s.execute("edit.pasteProperties", &json!({"ids": [b]})).unwrap();
assert_eq!(cap(&s, b), Some(StrokeCap::Square));
// Copying an unset stroke clears what it pastes over.
set(&mut s, a, Value::Null).unwrap();
s.execute("edit.copyProperties", &json!({"id": a})).unwrap();
s.execute("edit.pasteProperties", &json!({"ids": [b]})).unwrap();
assert_eq!(cap(&s, b), None);
}
+4
View File
@@ -263,12 +263,16 @@ mod components_behaviour_tests;
#[cfg(test)]
mod components_scale_tests;
#[cfg(test)]
mod connector_anywhere_tests;
#[cfg(test)]
mod convert_container_tests;
#[cfg(test)]
mod cursor_tests;
#[cfg(test)]
mod dark_page_text_tests;
#[cfg(test)]
mod dash_cap_tests;
#[cfg(test)]
mod datafill_tests;
#[cfg(test)]
mod design_paste_tests;
@@ -91,9 +91,10 @@ fn library(w: &mut Wire, req: LibraryReq) -> LibraryReply {
LibraryReply::Published { version: 1 }
}
(LibraryReq::Usage, _) => LibraryReply::Usage(Vec::new()),
(LibraryReq::ReportDetached(_) | LibraryReq::ReportInserted(_), _) => LibraryReply::Noted,
(LibraryReq::ReportDetached(_) | LibraryReq::ReportInserted(_) | LibraryReq::ReportAssets(_), _) => LibraryReply::Noted,
(LibraryReq::Detached, _) => LibraryReply::Detached(Vec::new()),
(LibraryReq::Actions, _) => LibraryReply::Actions(Vec::new()),
(LibraryReq::Assets, _) => LibraryReply::Assets(Vec::new()),
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ impl<'a> Mapper<'a> {
};
let finite = |p: Point| p.x.is_finite() && p.y.is_finite();
let ok = match end {
RouteEnd::Point(p) => finite(p),
RouteEnd::Point(p) | RouteEnd::On(_, _, p) => finite(p),
RouteEnd::Box(b, _) => finite(b.origin()) && finite(Point::new(b.x1, b.y1)),
};
ok.then_some(end)
+3 -3
View File
@@ -187,15 +187,15 @@ fn tools() -> Vec<Value> {
obj(
json!({
"type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"},
"x": num("Left, relative to the parent frame (default 0)"),
"y": num("Top, relative to the parent frame (default 0)"),
"x": num("Left, relative to the parent frame (default 0). For a vector with `path` or `points`, x/y move their own origin instead: data whose box starts at (30, 0) makes a layer whose left is x + 30. Start the path at 0,0 to place its left edge at x, or set x afterwards with set_properties (which moves the left edge)"),
"y": num("Top, relative to the parent frame (default 0). For a vector with `path` or `points`, their own top is added, as for x"),
"width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap; for a vector with path, width/height scale the path, one alone keeping its proportion, and without them the path's own box is used)"),
"height": num("Height (default 100; ignored for lines/arrows and auto-height text)"),
"parent": id_schema("Parent frame/group/component id (default: the current page)"),
"index": int("Z-index among the parent's children (default: on top)"),
"name": string("Layer name"),
"text": string("Characters, for type text"),
"path": string("SVG path data, for type vector (e.g. \"M0 0 L100 0 L50 80 Z\")"),
"path": string("SVG path data, for type vector (e.g. \"M0 0 L100 0 L50 80 Z\"); its coordinates are kept relative to x/y, so a path that doesn't start at 0,0 is offset by its own box"),
"points": {"type": "array", "items": {"type": "array", "items": {"type": "number"}}, "description": "For type vector, instead of path: the points [[x, y], ...]"},
"closed": boolean("For type vector: close the path"),
"select": boolean("Select the new layer (default true)"),
+25
View File
@@ -325,3 +325,28 @@ fn set_properties_passes_on_what_it_skipped() {
assert!(c.ok("set_properties", json!({"ids": [f], "props": {"gap": 4}})).get("warnings").is_none());
assert!(c.err("set_properties", json!({"ids": [t], "props": {"gap": 4}})).contains("auto layout"));
}
#[test]
fn create_node_x_y_offset_a_vector_path_by_its_own_box_as_documented() {
// An agent placed `M95 0 L225 0 ...` (box starting at x 30) with x 830 expecting its left at
// 830: x/y move the path's own origin, so the layer lands at 860. The schema has to say so.
let mut c = Client::new();
let lid = "M95 0 L225 0 L290 130 L232 470 L88 470 L30 130 Z";
let offset = c.create(json!({"type": "vector", "path": lid, "x": 830, "y": 445, "width": 260, "height": 470}));
let n = c.node(&offset);
assert_eq!((n["x"].as_f64(), n["y"].as_f64(), n["width"].as_f64()), (Some(860.0), Some(445.0), Some(260.0)), "{n}");
// The same shape drawn from 0,0 lands at x/y.
let at_origin = c.create(json!({"type": "vector", "path": "M65 0 L195 0 L260 130 L202 470 L58 470 L0 130 Z", "x": 830, "y": 445}));
assert_eq!(c.node(&at_origin)["x"].as_f64(), Some(830.0));
// Points are placed the same way.
let points = c.create(json!({"type": "vector", "points": [[30, 0], [290, 130], [88, 470]], "closed": true, "x": 830, "y": 445}));
assert_eq!(c.node(&points)["x"].as_f64(), Some(860.0));
// set_properties x moves the left edge.
c.ok("set_properties", json!({"ids": [offset], "props": {"x": 830}}));
assert_eq!(c.node(&offset)["x"].as_f64(), Some(830.0));
let r = c.rpc("tools/list", json!({}));
let tool = r["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "create_node").unwrap().clone();
let x_doc = tool["inputSchema"]["properties"]["x"]["description"].as_str().unwrap();
assert!(x_doc.contains("path") && x_doc.contains("x + 30"), "{x_doc}");
assert!(tool["inputSchema"]["properties"]["path"]["description"].as_str().unwrap().contains("offset by its own box"));
}
+77
View File
@@ -0,0 +1,77 @@
//! The Dash cap draws both ends of every dash, on Dash and Custom strokes alike. Unset, dashes keep
//! the end point caps they have always taken, so older files look the same.
use figcraft_doc::{StrokeAlign, StrokeCap, StrokeProps, WidthPreset};
use figcraft_geom::Point;
use figcraft_geom::kurbo::{BezPath, Shape};
use crate::{stroke_center, stroke_outline};
/// 200 long: a `10, 10` pattern fits it as it is, a half dash at each end.
fn line() -> BezPath {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
p.line_to((200.0, 0.0));
p
}
fn dashed(dash_cap: Option<StrokeCap>) -> StrokeProps {
StrokeProps { weight: 4.0, dashes: vec![10.0, 10.0], dash_cap, align: StrokeAlign::Center, ..Default::default() }
}
fn area(props: &StrokeProps) -> f64 {
stroke_center(&line(), 4.0, props).area().abs()
}
#[test]
fn the_cap_draws_both_ends_of_each_dash() {
let dashes = 11.0; // Nine whole ones and the two halves at the ends.
let band = 200.0 / 2.0 * 4.0;
assert!((area(&dashed(Some(StrokeCap::None))) - band).abs() < 0.01);
let square = area(&dashed(Some(StrokeCap::Square))) - band;
assert!((square - dashes * 2.0 * 2.0 * 4.0).abs() < 0.01, "{square}");
let round = area(&dashed(Some(StrokeCap::Round))) - band;
assert!((round - dashes * std::f64::consts::PI * 4.0).abs() < 1.0, "{round}");
}
#[test]
fn unset_keeps_the_end_point_caps_and_set_overrides_them() {
let caps = |dash_cap| StrokeProps { cap_start: StrokeCap::Round, cap_end: StrokeCap::Square, ..dashed(dash_cap) };
let old = stroke_center(&line(), 4.0, &caps(None));
assert!(old.area().abs() > area(&dashed(None)), "dashes still take them");
assert_eq!(caps(None).band_caps(), [StrokeCap::Round, StrokeCap::Square]);
assert_eq!(area(&caps(Some(StrokeCap::None))), area(&dashed(Some(StrokeCap::None))));
}
#[test]
fn a_solid_stroke_ignores_it() {
let solid = |dash_cap| StrokeProps { dashes: vec![], cap_end: StrokeCap::Square, ..dashed(dash_cap) };
assert_eq!(area(&solid(Some(StrokeCap::Round))), area(&solid(None)));
}
#[test]
fn zero_length_dashes_become_dots_of_the_cap() {
let dots = |cap| StrokeProps { dashes: vec![0.0, 20.0], ..dashed(Some(cap)) };
assert!(area(&dots(StrokeCap::Round)) > 100.0);
assert!((area(&dots(StrokeCap::Square)) - 11.0 * 16.0).abs() < 0.1);
assert!(area(&dots(StrokeCap::None)) < 1e-6);
}
#[test]
fn arrowheads_stay_on_the_path_ends() {
let arrow = |dash_cap| StrokeProps { cap_end: StrokeCap::TriangleArrow, ..dashed(dash_cap) };
let p = stroke_outline(&line(), &arrow(Some(StrokeCap::Round)), false);
let plain = stroke_outline(&line(), &dashed(Some(StrokeCap::Round)), false);
assert!(p.bounding_box().y1 > plain.bounding_box().y1, "the head is still drawn");
}
#[test]
fn a_variable_width_dash_takes_it_too() {
let wide = |cap| StrokeProps { width_profile: WidthPreset::Eye.profile(), ..dashed(cap) };
let (none, square) =
(stroke_outline(&line(), &wide(Some(StrokeCap::None)), false), stroke_outline(&line(), &wide(Some(StrokeCap::Square)), false));
// The dash at the middle ends at 105.
let past = Point::new(106.0, 0.0);
assert_eq!(none.winding(past), 0);
assert_ne!(square.winding(past), 0);
}
+11 -7
View File
@@ -16,6 +16,8 @@ mod budget;
#[cfg(test)]
mod budget_tests;
#[cfg(test)]
mod dash_cap_tests;
#[cfg(test)]
mod dash_ends_tests;
#[cfg(test)]
mod dot_dash_tests;
@@ -314,9 +316,9 @@ pub fn union_faces<'a>(faces: impl IntoIterator<Item = &'a BezPath>) -> Result<B
}
pub(crate) fn kcap(c: StrokeCap) -> Cap {
match c {
StrokeCap::Round | StrokeCap::CircleFilled => Cap::Round,
StrokeCap::Square | StrokeCap::DiamondFilled => Cap::Square,
match c.shape() {
StrokeCap::Round => Cap::Round,
StrokeCap::Square => Cap::Square,
_ => Cap::Butt,
}
}
@@ -339,15 +341,17 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath
return BezPath::new();
}
let width = width.min(MAX_STROKE_WIDTH);
let caps = props.band_caps();
let style = kurbo::Stroke::new(width)
.with_join(kjoin(props.join))
.with_miter_limit(props.miter_limit.clamp(1.0, 100.0))
.with_start_cap(kcap(props.cap_start))
.with_end_cap(kcap(props.cap_end));
.with_start_cap(kcap(caps[0]))
.with_end_cap(kcap(caps[1]));
// Each subpath restarts the pattern, so it can add up to one more pattern's worth of dashes.
let subpaths = path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() as f64;
let round = [props.cap_start, props.cap_end].into_iter().any(|c| matches!(kcap(c), Cap::Round));
let pattern = width::dash_pattern(props).map(|p| p.into_iter().map(|d| if round && d == 0.0 { 1e-3 } else { d }).collect::<Vec<_>>());
// A zero-length dash is a dot of its cap, in viewers too; older squares stay undrawn here.
let dots = caps.into_iter().any(|c| matches!(kcap(c), Cap::Round)) || props.dash_cap == Some(StrokeCap::Square);
let pattern = width::dash_pattern(props).map(|p| p.into_iter().map(|d| if dots && d == 0.0 { 1e-3 } else { d }).collect::<Vec<_>>());
let opts = kurbo::StrokeOpts::default();
let Some(pat) = pattern.filter(|p| width::dashes_fit(path.perimeter(1.0) + subpaths * p.iter().sum::<f64>(), p)) else {
return kurbo::stroke(path.iter(), &style, &opts, 0.05);
+4 -13
View File
@@ -53,16 +53,6 @@ struct Style {
cap_end: StrokeCap,
}
/// The cap shape an end cap draws (arrowheads are added by the caller; their stroke ends round
/// or square like the uniform stroke does).
fn cap_shape(c: StrokeCap) -> StrokeCap {
match c {
StrokeCap::Round | StrokeCap::CircleFilled => StrokeCap::Round,
StrokeCap::Square | StrokeCap::DiamondFilled => StrokeCap::Square,
_ => StrokeCap::None,
}
}
/// Points of an arc around `c` from the direction of `from` turning by `sweep` radians, with the
/// radius going from `r0` to `r1` (ends excluded).
fn arc(out: &mut Vec<Point>, c: Point, from: Vec2, sweep: f64, r0: f64, r1: f64) {
@@ -134,7 +124,7 @@ fn side(segs: &[Seg], sigma: f64, closed: bool, style: Style) -> Vec<Point> {
fn cap(out: &mut Vec<Point>, c: Point, dir: Vec2, from: f64, to: f64, kind: StrokeCap) {
let n = dir.turn_90();
let (m, r) = (c + n * ((from - to) / 2.0), (from + to) / 2.0);
match cap_shape(kind) {
match kind.shape() {
StrokeCap::Square => {
out.push(m + n * r + dir * r);
out.push(m - n * r + dir * r);
@@ -172,7 +162,7 @@ fn outline_piece(path: &mut BezPath, pts: &[Point], hw: &[Half], closed: bool, s
// A zero-length piece is a dot with round caps.
let r = hw.first().map_or(0.0, |h| (h[0] + h[1]) / 2.0);
if let Some(p) = pts.first()
&& cap_shape(style.cap_start) == StrokeCap::Round
&& style.cap_start.shape() == StrokeCap::Round
&& r > 0.0
{
path.extend(kurbo::Shape::path_elements(&kurbo::Circle::new(*p, r), FLATTEN_TOL));
@@ -309,7 +299,8 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile
}
let profile = profile.sanitized();
let lines = polylines(path)?;
let style = Style { join: props.join, miter_limit: props.miter_limit.clamp(1.0, 100.0), cap_start: props.cap_start, cap_end: props.cap_end };
let [cap_start, cap_end] = props.band_caps();
let style = Style { join: props.join, miter_limit: props.miter_limit.clamp(1.0, 100.0), cap_start, cap_end };
let pattern = dash_pattern(props);
let mut budget = MAX_WIDTH_SAMPLES;
let mut out = BezPath::new();
+1
View File
@@ -254,6 +254,7 @@ fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc<BezPath>
d.to_bits().hash(&mut h);
}
props.dashes.len().hash(&mut h);
props.dash_cap.map(|c| c as u8).hash(&mut h);
// Points' own caps and joins and the fill rule Inside/Outside clip by (vector networks).
let net = match &n.kind {
NodeKind::Vector { network } => Some(network),
+15
View File
@@ -25,6 +25,21 @@ fn render(d: &figcraft_doc::Document, p: figcraft_doc::NodeId) -> Pixmap {
render_view(d, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 100, height: 100 }, &Options::default()).unwrap()
}
/// The stroke outline is cached: changing the dash cap alone has to redraw it.
#[test]
fn changing_the_dash_cap_redraws_the_dashes() {
let (mut d, p) = line_doc(None);
let line = d.node(p).unwrap().children[0];
let n = d.get_mut(line).unwrap();
(n.stroke.weight, n.stroke.dashes) = (10.0, vec![20.0, 20.0]);
// Two 40-long patterns fit the 80-long line, so a whole dash ends at x = 60 on the page.
assert!(!dark(&render(&d, p), 62, 50), "butt: a gap");
d.get_mut(line).unwrap().stroke.dash_cap = Some(figcraft_doc::StrokeCap::Square);
assert!(dark(&render(&d, p), 62, 50), "square: 5 further");
d.get_mut(line).unwrap().stroke.dash_cap = Some(figcraft_doc::StrokeCap::None);
assert!(!dark(&render(&d, p), 62, 50));
}
#[test]
fn wedge_profile_tapers_the_stroke() {
let (d, p) = line_doc(WidthPreset::Wedge.profile());
+1 -1
View File
@@ -819,7 +819,7 @@ impl W<'_> {
}
_ => return None,
};
let cap = match k.cap_start {
let cap = match k.band_caps()[0] {
StrokeCap::Round => " stroke-linecap=\"round\"",
StrokeCap::Square => " stroke-linecap=\"square\"",
_ => "",
+24
View File
@@ -367,6 +367,30 @@ fn dashed(kind: NodeKind, dashes: Vec<f64>) -> String {
export_svg(&d, &[n], &SvgOptions { simplify_stroke: true, ..SvgOptions::default() }).unwrap()
}
/// The `stroke-linecap` a dashed or solid line with a round start cap and `dash_cap` writes.
fn linecap(dashes: Vec<f64>, dash_cap: Option<figcraft_doc::StrokeCap>) -> Option<String> {
let mut d = Document::new("dash cap");
let p = d.first_page().unwrap();
let n = d.create(p, NodeKind::Line, "Line", 0.0, 0.0, 100.0, 0.0).unwrap();
let node = d.get_mut(n).unwrap();
node.strokes = vec![Paint::solid(Color::from_hex("000000").unwrap())];
node.stroke.align = figcraft_doc::StrokeAlign::Center;
(node.stroke.cap_start, node.stroke.cap_end) = (figcraft_doc::StrokeCap::Round, figcraft_doc::StrokeCap::Round);
(node.stroke.dashes, node.stroke.dash_cap) = (dashes, dash_cap);
let svg = export_svg(&d, &[n], &SvgOptions { simplify_stroke: true, ..SvgOptions::default() }).unwrap();
assert!(svg.contains("stroke-width"), "a stroke, not an outline: {svg}");
svg.split(" stroke-linecap=\"").nth(1).and_then(|r| r.split('"').next()).map(str::to_string)
}
#[test]
fn a_dash_cap_writes_the_line_cap_of_a_dashed_stroke() {
use figcraft_doc::StrokeCap::{None as Butt, Square};
assert_eq!(linecap(vec![4.0, 4.0], None).as_deref(), Some("round"), "unset: the end point cap");
assert_eq!(linecap(vec![4.0, 4.0], Some(Square)).as_deref(), Some("square"));
assert_eq!(linecap(vec![4.0, 4.0], Some(Butt)), None);
assert_eq!(linecap(vec![], Some(Square)).as_deref(), Some("round"), "solid: it doesn't apply");
}
/// A viewer draws what the canvas does: an open line's dashes fitted and started half a dash in.
#[test]
fn a_dashed_line_exports_as_the_canvas_draws_it() {
+3 -4
View File
@@ -1,8 +1,7 @@
//! Bake the contributor credits into the binary as static Rust tables (craftrules
//! standards/contributors.md). Nothing is read at run time.
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
//!
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml
//! entry is verified.
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
//! JSON says the entry was verified (`people_entry`).
//!
+1 -3
View File
@@ -228,7 +228,6 @@ status = "draft — needs native review"
"Check" = "تحقق"
"Check for Updates" = "التحقق من التحديثات"
"Check for updates…" = "التحقق من التحديثات…"
"Checking for updates…" = "جارٍ التحقق من التحديثات…"
"Checking the fonts installed on this computer…" = "جارٍ فحص الخطوط المثبتة على هذا الحاسوب…"
"Checking the server…" = "جارٍ التحقق من الخادم…"
"Chevron" = "شارة زاوية"
@@ -357,7 +356,6 @@ status = "draft — needs native review"
"Copy {value}" = "نسخ {value}"
"Corner radius" = "نصف قطر الزوايا"
"Corner smoothing" = "تنعيم الزوايا"
"Could not check for updates:\n{error}" = "تعذر التحقق من التحديثات:\n{error}"
"Couldn't download the variables" = "تعذر تنزيل المتغيرات"
"Couldn't download {name}" = "تعذر تنزيل {name}"
"Couldn't reach {server}: showing what this computer remembers." = "تعذر الاتصال بـ {server}؛ تُعرض البيانات المحفوظة على هذا الحاسوب."
@@ -392,6 +390,7 @@ status = "draft — needs native review"
"Dark" = "داكن"
"Darken" = "تعتيم"
"Dash" = "شرطة"
"Dash cap" = "طرف الشرطة"
"Dashes" = "الشرطات"
"Dashes take numbers separated by commas" = "تقبل الشرطات أرقامًا مفصولة بفواصل"
"Dashboard" = "لوحة المعلومات"
@@ -547,7 +546,6 @@ status = "draft — needs native review"
"Fetching from the server…" = "جارٍ الجلب من الخادم…"
"FigCraft couldn't open its window: {error}" = "تعذر على FigCraft فتح نافذته: {error}"
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "لا يدعم FigCraft جميع مزايا هذه الملفات بعد. تسرد الملاحظات ما أُهمل أو عُدّل في النسخة المستوردة؛ ويظل الملف الأصلي دون تغيير."
"FigCraft {version} is currently the newest version available." = "FigCraft {version} هو أحدث إصدار متاح حاليًا."
"File" = "ملف"
"File name" = "اسم الملف"
"File not found" = "لم يُعثر على الملف"
+1
View File
@@ -372,6 +372,7 @@ status = "draft — needs native review"
"Dark" = "ডার্ক"
"Darken" = "ডার্কেন"
"Dash" = "ড্যাশ"
"Dash cap" = "ড্যাশ ক্যাপ"
"Dashes" = "ড্যাশগুলো"
"Dashes take numbers separated by commas" = "ড্যাশগুলো কমা দিয়ে আলাদা করা সংখ্যা হবে"
"Dashboard" = "ড্যাশবোর্ড"
+1 -3
View File
@@ -228,7 +228,6 @@ status = "draft — needs native review"
"Check for Updates" = "Provjeri ažuriranja"
"Check for updates" = "Provjeri ažuriranja"
"Check for updates…" = "Provjeri ažuriranja…"
"Checking for updates…" = "Provjera ažuriranja…"
"Checking the fonts installed on this computer…" = "Provjera fontova instaliranih na ovom računaru…"
"Checking the server…" = "Provjera servera…"
"Chevron" = "Ševron"
@@ -359,7 +358,6 @@ status = "draft — needs native review"
"Copy {value}" = "Kopiraj {value}"
"Corner radius" = "Radijus ugla"
"Corner smoothing" = "Zaglađivanje uglova"
"Could not check for updates:\n{error}" = "Nije moguće provjeriti ažuriranja:\n{error}"
"Couldn't download the variables" = "Nije moguće preuzeti varijable"
"Couldn't download {name}" = "Nije moguće preuzeti {name}"
"Couldn't reach {server}: showing what this computer remembers." = "Server {server} nije dostupan: prikazuje se ono što ovaj računar pamti."
@@ -395,6 +393,7 @@ status = "draft — needs native review"
"Dark" = "Tamna"
"Darken" = "Zatamni"
"Dash" = "Crtica"
"Dash cap" = "Završetak crtice"
"Dashes" = "Crtice"
"Dashes take numbers separated by commas" = "Crtice su brojevi odvojeni zarezima"
"Dashboard" = "Kontrolna tabla"
@@ -553,7 +552,6 @@ status = "draft — needs native review"
"FigCraft couldn't open its window: {error}" = "FigCraft nije mogao otvoriti svoj prozor: {error}"
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft još ne podržava sve mogućnosti ovih datoteka. Napomene navode šta je izostavljeno ili promijenjeno u uvezenoj kopiji; originalna datoteka je nepromijenjena."
"FigCraft stopped because of an internal error." = "FigCraft je zaustavljen zbog interne greške."
"FigCraft {version} is currently the newest version available." = "FigCraft {version} je trenutno najnovija dostupna verzija."
"File" = "Datoteka"
"File name" = "Ime datoteke"
"File not found" = "Datoteka nije pronađena"
+1
View File
@@ -362,6 +362,7 @@ status = "draft — needs native review"
"Dark" = "Tmavý"
"Darken" = "Ztmavit"
"Dash" = "Čárkování"
"Dash cap" = "Zakončení čárek"
"Dashes" = "Čárkování"
"Dashes take numbers separated by commas" = "Čárkování zadejte jako čísla oddělená čárkami"
"Dashboard" = "Přehled"
+1
View File
@@ -362,6 +362,7 @@ status = "draft — needs native review"
"Dark" = "Dunkel"
"Darken" = "Abdunkeln"
"Dash" = "Gestrichelt"
"Dash cap" = "Strichende"
"Dashes" = "Striche"
"Dashes take numbers separated by commas" = "Striche sind Zahlen, durch Kommas getrennt"
"Decoration style" = "Linienstil"
+3 -3
View File
@@ -230,7 +230,6 @@ name = "English"
"Check for Updates" = "Check for Updates"
"Check for updates" = "Check for updates"
"Check for updates…" = "Check for updates…"
"Checking for updates…" = "Checking for updates…"
"Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…"
"Checking the server…" = "Checking the server…"
"Chevron" = "Chevron"
@@ -364,7 +363,6 @@ name = "English"
"Copy {value}" = "Copy {value}"
"Corner radius" = "Corner radius"
"Corner smoothing" = "Corner smoothing"
"Could not check for updates:\n{error}" = "Could not check for updates:\n{error}"
"Couldn't download the variables" = "Couldn't download the variables"
"Couldn't download {name}" = "Couldn't download {name}"
"Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers."
@@ -400,6 +398,7 @@ name = "English"
"Dark" = "Dark"
"Darken" = "Darken"
"Dash" = "Dash"
"Dash cap" = "Dash cap"
"Dashes" = "Dashes"
"Dashes take numbers separated by commas" = "Dashes take numbers separated by commas"
"Dashboard" = "Dashboard"
@@ -561,7 +560,6 @@ name = "English"
"FigCraft couldn't open its window: {error}" = "FigCraft couldn't open its window: {error}"
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged."
"FigCraft stopped because of an internal error." = "FigCraft stopped because of an internal error."
"FigCraft {version} is currently the newest version available." = "FigCraft {version} is currently the newest version available."
"File" = "File"
"File name" = "File name"
"File not found" = "File not found"
@@ -986,6 +984,7 @@ name = "English"
"Not read yet" = "Not read yet"
"Not shared" = "Not shared"
"Note {n}" = "Note {n}"
"Nothing counted yet" = "Nothing counted yet"
"Nothing changed since this version." = "Nothing changed since this version."
"Nothing changed since this was marked ready for dev." = "Nothing changed since this was marked ready for dev."
"Nothing here yet. Set a frame's status to list it." = "Nothing here yet. Set a frame's status to list it."
@@ -1319,6 +1318,7 @@ name = "English"
"Search results" = "Search results"
"Search fonts" = "Search fonts"
"This server doesn't count insertions" = "This server doesn't count insertions"
"This server doesn't count styles and variables" = "This server doesn't count styles and variables"
"Someone who left" = "Someone who left"
"Take the results off the board; they stay in the file" = "Take the results off the board; they stay in the file"
"Upload from computer" = "Upload from computer"
+1
View File
@@ -396,6 +396,7 @@ status = "draft — needs native review"
"Dark" = "Oscuro"
"Darken" = "Oscurecer"
"Dash" = "Discontinuo"
"Dash cap" = "Remate de guion"
"Dashes" = "Guiones"
"Dashes take numbers separated by commas" = "Los guiones son números separados por comas"
"Dashboard" = "Panel de control"
+1 -3
View File
@@ -359,7 +359,6 @@ status = "draft — needs native review"
"Copy {value}" = "Kopioi: {value}"
"Corner radius" = "Kulman säde"
"Corner smoothing" = "Kulman pehmennys"
"Could not check for updates:\n{error}" = "Päivityksiä ei voitu tarkistaa:\n{error}"
"Couldn't download the variables" = "Muuttujia ei voitu ladata"
"Couldn't download {name}" = "Kohdetta {name} ei voitu ladata"
"Couldn't reach {server}: showing what this computer remembers." = "Palvelimeen {server} ei saatu yhteyttä. Näytetään tämän tietokoneen muistamat tiedot."
@@ -396,6 +395,7 @@ status = "draft — needs native review"
"Dark" = "Tumma"
"Darken" = "Tummenna"
"Dash" = "Katkoviiva"
"Dash cap" = "Katkoviivan pää"
"Dashes" = "Katkoviivat"
"Dashes take numbers separated by commas" = "Anna katkoviivat pilkuilla erotettuina lukuina"
"Dashboard" = "Koontinäkymä"
@@ -556,7 +556,6 @@ status = "draft — needs native review"
"FigCraft couldn't open its window: {error}" = "FigCraft ei voinut avata ikkunaansa: {error}"
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ei vielä tue näiden tiedostojen kaikkia ominaisuuksia. Huomautukset kertovat, mitä tuodusta kopiosta jätettiin pois tai muutettiin. Alkuperäinen tiedosto säilyy ennallaan."
"FigCraft stopped because of an internal error." = "FigCraft pysähtyi sisäisen virheen vuoksi."
"FigCraft {version} is currently the newest version available." = "FigCraft {version} on tällä hetkellä uusin saatavilla oleva versio."
"File" = "Tiedosto"
"File name" = "Tiedostonimi"
"File not found" = "Tiedostoa ei löydy"
@@ -977,7 +976,6 @@ status = "draft — needs native review"
"Not read yet" = "Ei vielä luettu"
"Check for updates" = "Tarkista päivitykset"
"Check for updates…" = "Tarkista päivitykset…"
"Checking for updates…" = "Tarkistetaan päivityksiä…"
"Not checked yet" = "Ei vielä tarkistettu"
"Someone who left" = "Joku, joka lähti"
"Take the results off the board; they stay in the file" = "Poista tulokset taululta; ne jäävät tiedostoon"
+1
View File
@@ -372,6 +372,7 @@ status = "draft — needs native review"
"Dark" = "Sombre"
"Darken" = "Assombrir"
"Dash" = "Tirets"
"Dash cap" = "Extrémité des tirets"
"Dashes" = "Tirets"
"Dashes take numbers separated by commas" = "Les tirets sont des nombres séparés par des virgules"
"Dashboard" = "Tableau de bord"
+1
View File
@@ -377,6 +377,7 @@ status = "draft — needs native review"
"Dark" = "Gelap"
"Darken" = "Gelapkan"
"Dash" = "Putus-putus"
"Dash cap" = "Ujung garis putus"
"Dashes" = "Garis putus"
"Dashes take numbers separated by commas" = "Garis putus berupa angka yang dipisahkan koma"
"Dashboard" = "Dasbor"
+1
View File
@@ -356,6 +356,7 @@ status = "draft — needs native review"
"Dark" = "Scuro"
"Darken" = "Scurisci"
"Dash" = "Tratteggio"
"Dash cap" = "Estremità tratto"
"Dashes" = "Trattini"
"Dashes take numbers separated by commas" = "I trattini sono numeri separati da virgole"
"Dashboard" = "Dashboard"
+1
View File
@@ -370,6 +370,7 @@ status = "draft — needs native review"
"Dark" = "ダーク"
"Darken" = "比較(暗)"
"Dash" = "破線"
"Dash cap" = "破線の端"
"Dashes" = "ダッシュ"
"Dashes take numbers separated by commas" = "ダッシュはカンマ区切りの数値で入力します"
"Dashboard" = "ダッシュボード"
+1 -3
View File
@@ -377,6 +377,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Dark" = "ბნელი"
"Darken" = "ჩამუქება"
"Dash" = "ტირე"
"Dash cap" = "ტირის ბოლო"
"Dashes" = "ტირეები"
"Dashes take numbers separated by commas" = "ტირეები მძიმით გამოყოფილი რიცხვებია"
"Dashboard" = "სამართავი დაფა"
@@ -1882,19 +1883,16 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Best effort" = "მაქსიმალური მიახლოება"
"Check for Updates" = "განახლებების შემოწმება"
"Check for updates…" = "განახლებების შემოწმება…"
"Checking for updates…" = "განახლებების შემოწმება…"
"Compatibility" = "თავსებადობა"
"Dart constants for Flutter…" = "Dart-ის კონსტანტები Flutter-ისთვის…"
"Copy effect" = "ეფექტის კოპირება"
"Copy fill" = "შევსების კოპირება"
"Copy stroke" = "კონტურის კოპირება"
"Could not check for updates:\n{error}" = "განახლებების შემოწმება ვერ მოხერხდა:\n{error}"
"Couldn't download {name}" = "{name} ვერ ჩამოიტვირთა"
"Don't show this again" = "აღარ აჩვენო"
"Exact only" = "მხოლოდ ზუსტი"
"Exported {name}; {n} kinds of content were changed or left out" = { one = "ექსპორტირებულია: {name}; შეცვლილი ან გამოტოვებული შიგთავსის სახეობა: {n}", other = "ექსპორტირებულია: {name}; შეცვლილი ან გამოტოვებული შიგთავსის სახეობა: {n}" }
"Exported {n} files; some content was changed or left out" = { one = "ექსპორტირებულია ფაილი: {n}; ზოგი შიგთავსი შეიცვალა ან გამოტოვებულია", other = "ექსპორტირებულია ფაილი: {n}; ზოგი შიგთავსი შეიცვალა ან გამოტოვებულია" }
"FigCraft {version} is currently the newest version available." = "FigCraft {version} ამჟამად უახლესი ხელმისაწვდომი ვერსიაა."
"Format" = "ფორმატი"
"Ignore unsupported" = "მხარდაუჭერელის უგულებელყოფა"
"Leaving the prototype" = "პროტოტიპიდან გასვლა"
+1
View File
@@ -358,6 +358,7 @@ status = "draft — needs native review"
"Dark" = "다크"
"Darken" = "어둡게 하기"
"Dash" = "대시"
"Dash cap" = "대시 끝 모양"
"Dashes" = "대시"
"Dashes take numbers separated by commas" = "대시는 쉼표로 구분한 숫자로 입력하세요"
"Decoration style" = "장식 스타일"
+1
View File
@@ -374,6 +374,7 @@ status = "draft — needs native review"
"Dark" = "Donker"
"Darken" = "Darken"
"Dash" = "Streep"
"Dash cap" = "Streepuiteinde"
"Dashes" = "Streepjes"
"Dashes take numbers separated by commas" = "Streepjes zijn getallen gescheiden door komma's"
"Dashboard" = "Dashboard"
+1
View File
@@ -373,6 +373,7 @@ name = "Polski"
"Dark" = "Ciemny"
"Darken" = "Ciemniej"
"Dash" = "Kreska"
"Dash cap" = "Zakończenie kreski"
"Dashes" = "Kreski"
"Dashes take numbers separated by commas" = "Kreski to liczby oddzielone przecinkami"
"Dashboard" = "Pulpit"
+1
View File
@@ -357,6 +357,7 @@ status = "draft — needs native review"
"Dark" = "Escuro"
"Darken" = "Escurecer"
"Dash" = "Tracejado"
"Dash cap" = "Terminação do traço"
"Dashes" = "Traços"
"Dashes take numbers separated by commas" = "Os traços são números separados por vírgulas"
"Decoration style" = "Estilo da decoração"
+1
View File
@@ -373,6 +373,7 @@ name = "Русский"
"Dark" = "Тёмная"
"Darken" = "Затемнение"
"Dash" = "Штрих"
"Dash cap" = "Конец штриха"
"Dashes" = "Штрихи"
"Dashes take numbers separated by commas" = "Штрихи задаются числами через запятую"
"Dashboard" = "Панель аналитики"
+1 -3
View File
@@ -227,7 +227,6 @@ name = "Svenska"
"Check for Updates" = "Sök efter uppdateringar"
"Check for updates" = "Sök efter uppdateringar"
"Check for updates…" = "Sök efter uppdateringar…"
"Checking for updates…" = "Söker efter uppdateringar…"
"Checking the fonts installed on this computer…" = "Kontrollerar vilka typsnitt som är installerade på den här datorn…"
"Checking the server…" = "Kontrollerar servern…"
"Chevron" = "Vinkel"
@@ -361,7 +360,6 @@ name = "Svenska"
"Copy {value}" = "Kopiera {value}"
"Corner radius" = "Hörnradie"
"Corner smoothing" = "Hörnutjämning"
"Could not check for updates:\n{error}" = "Det gick inte att söka efter uppdateringar:\n{error}"
"Couldn't download the variables" = "Det gick inte att hämta variablerna"
"Couldn't download {name}" = "Det gick inte att hämta {name}"
"Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns."
@@ -397,6 +395,7 @@ name = "Svenska"
"Dark" = "Mörk"
"Darken" = "Mörkare"
"Dash" = "Streck"
"Dash cap" = "Streckände"
"Dashes" = "Streck"
"Dashes take numbers separated by commas" = "Streck anges som tal åtskilda med kommatecken"
"Dashboard" = "Instrumentpanel"
@@ -557,7 +556,6 @@ name = "Svenska"
"FigCraft couldn't open its window: {error}" = "FigCraft kunde inte öppna sitt fönster: {error}"
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft stöder ännu inte alla funktioner i de här filerna. Anteckningarna visar vad som utelämnades eller ändrades i den importerade kopian; originalfilen är oförändrad."
"FigCraft stopped because of an internal error." = "FigCraft stoppades på grund av ett internt fel."
"FigCraft {version} is currently the newest version available." = "FigCraft {version} är den senaste tillgängliga versionen."
"File" = "Fil"
"File name" = "Filnamn"
"File not found" = "Filen hittades inte"
+1
View File
@@ -371,6 +371,7 @@ status = "draft — needs native review"
"Dark" = "มืด"
"Darken" = "Darken"
"Dash" = "เส้นประ"
"Dash cap" = "ปลายเส้นประ"
"Dashes" = "เส้นประ"
"Dashes take numbers separated by commas" = "ใส่เส้นประเป็นตัวเลขคั่นด้วยจุลภาค"
"Dashboard" = "แดชบอร์ด"
+1
View File
@@ -366,6 +366,7 @@ name = "Türkçe"
"Dark" = "Koyu"
"Darken" = "Koyulaştır"
"Dash" = "Kesik çizgi"
"Dash cap" = "Kesik çizgi ucu"
"Dashes" = "Kesik çizgiler"
"Dashes take numbers separated by commas" = "Kesik çizgiler virgülle ayrılmış sayılardır"
"Dashboard" = "Pano"
+1
View File
@@ -357,6 +357,7 @@ status = "draft — needs native review"
"Dark" = "Tối"
"Darken" = "Làm tối"
"Dash" = "Nét đứt"
"Dash cap" = "Đầu nét đứt"
"Dashes" = "Các nét đứt"
"Dashes take numbers separated by commas" = "Nét đứt là các số cách nhau bằng dấu phẩy"
"Dashboard" = "Bảng điều khiển"
+1
View File
@@ -362,6 +362,7 @@ status = "draft — needs native review"
"Dark" = "深色"
"Darken" = "变暗"
"Dash" = "虚线"
"Dash cap" = "虚线端点"
"Dashes" = "虚线"
"Dashes take numbers separated by commas" = "虚线为以逗号分隔的数字"
"Decoration style" = "装饰线样式"
+38 -2
View File
@@ -234,8 +234,8 @@ fn the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing() {
let b = figcraft_engine::board::owner_of_text(doc, te.id).unwrap();
assert!(matches!(doc.node(b).unwrap().board, Some(BoardItem::Shape { shape: BoardShape::Diamond })));
let joined = doc.nodes().any(|n| {
matches!(&n.board, Some(BoardItem::Connector(k)) if k.from == figcraft_doc::End::Node { node: a, side: figcraft_doc::Side::Right }
&& k.to == figcraft_doc::End::Node { node: b, side: figcraft_doc::Side::Left })
matches!(&n.board, Some(BoardItem::Connector(k)) if k.from == figcraft_doc::End::Node { node: a, side: figcraft_doc::Side::Right, at: None }
&& k.to == figcraft_doc::End::Node { node: b, side: figcraft_doc::Side::Left, at: None })
});
assert!(joined, "a connector from the first shape's right side to the new one's left");
}
@@ -362,3 +362,39 @@ fn preferences_set_the_board_grid_and_the_default_dots_keep_their_size() {
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(has_text(&shapes, "Board grid") && !has_text(&shapes, "Spacing"));
}
/// Moving a selected connector's end with the mouse: it snaps to a side, and with Cmd/Ctrl held it
/// goes where the pointer is on the object.
#[test]
fn cmd_while_moving_a_connector_end_places_it_on_the_object() {
let mut u = board();
let a = id(&u.app.run("board.shape", json!({"shape": "rectangle", "x": 0, "y": 0, "width": 200, "height": 100})).unwrap());
let b = id(&u.app.run("board.shape", json!({"shape": "rectangle", "x": 400, "y": 0, "width": 200, "height": 100})).unwrap());
let k = id(&u.app.run("board.connector", json!({"from": a, "to": b, "style": "straight"})).unwrap());
u.app.run("select.set", json!({"ids": [k]}));
u.frame(vec![]);
u.frame(vec![]);
let end = |u: &Ui| match &u.app.session.doc().unwrap().node(k).unwrap().board {
Some(BoardItem::Connector(c)) => c.to,
other => panic!("{other:?}"),
};
let drag = |u: &mut Ui, mods: Modifiers| {
let from = u.app.to_screen(400.0, 50.0);
let to = u.app.to_screen(550.0, 25.0);
u.frame_with(vec![Event::PointerMoved(from)], mods);
let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: mods };
u.frame_with(vec![press(from, true)], mods);
for t in 1..=8 {
u.frame_with(vec![Event::PointerMoved(from + (to - from) * (t as f32 / 8.0))], mods);
}
u.frame_with(vec![press(to, false)], mods);
u.frame_with(vec![], mods);
};
drag(&mut u, Modifiers::COMMAND);
assert_eq!(end(&u), figcraft_doc::End::Node { node: b, side: figcraft_doc::Side::Auto, at: Some([0.75, 0.25]) });
u.app.run("edit.undo", json!({}));
u.app.run("select.set", json!({"ids": [k]}));
u.frame(vec![]);
drag(&mut u, Modifiers::NONE);
assert!(matches!(end(&u), figcraft_doc::End::Node { at: None, side: figcraft_doc::Side::Right, .. }), "{:?}", end(&u));
}
+36 -1
View File
@@ -3,7 +3,7 @@
//! imported `[4, 2, 1, 2]` stays whole.
use egui::{Event, Id, Key, Modifiers};
use figcraft_doc::NodeId;
use figcraft_doc::{NodeId, StrokeCap};
use figcraft_engine::Session;
use serde_json::json;
@@ -152,3 +152,38 @@ fn a_single_value_pattern_is_the_dash_and_the_gap() {
type_into(&mut u, "stroke_dash", "7");
assert_eq!(dashes(&u, r), [7.0, 2.0]);
}
/// Both dashed styles offer the Dash cap, and picking one sets it. Solid has no dashes to cap.
#[test]
fn the_dash_cap_shows_for_dash_and_custom_strokes() {
let has = |u: &mut Ui, text: &str| labelled(u).iter().any(|(t, _)| t == text);
let cap = |u: &Ui, r| u.app.session.doc().unwrap().node(r).unwrap().stroke.dash_cap;
let (mut u, _) = scene(json!([]));
assert!(!has(&mut u, "Dash cap"));
let (mut u, r) = scene(json!([6, 3]));
assert!(has(&mut u, "Dash cap"));
assert_eq!(cap(&u, r), None, "showing it changes nothing");
pick_style(&mut u, "None", "Round");
assert_eq!(cap(&u, r), Some(StrokeCap::Round));
pick_style(&mut u, "Dash", "Custom");
assert!(has(&mut u, "Dash cap"));
pick_style(&mut u, "Round", "Square");
assert_eq!(cap(&u, r), Some(StrokeCap::Square));
assert_eq!(dashes(&u, r), [6.0, 3.0]);
}
/// Unset, the setting shows the end point caps the dashes take.
#[test]
fn an_unset_dash_cap_shows_what_draws() {
let (mut u, r) = scene(json!([6, 3]));
let caps = |u: &mut Ui, start: &str, end: &str| {
u.app.run("node.set", json!({"ids": [r], "props": {"startCap": start, "endCap": end}}));
u.frame(vec![]);
u.frame(vec![]);
};
caps(&mut u, "round", "round");
assert!(labelled(&mut u).iter().any(|(t, _)| t == "Round"));
caps(&mut u, "round", "square");
assert!(labelled(&mut u).iter().any(|(t, _)| t == "Mixed"));
assert_eq!(u.app.session.doc().unwrap().node(r).unwrap().stroke.dash_cap, None);
}
+1 -1
View File
@@ -1,6 +1,6 @@
//! The About window's credits: every contributor (GitHub login always; display and real names only
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
//! build.rs from contributors/contributors.json; nothing is
//! read at run time.
use std::cmp::Ordering;
+16 -15
View File
@@ -233,12 +233,12 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
fn panel_windows(app: &mut FigApp, ctx: &Context) {
let t = Tokens::get(ctx);
let right = ctx.content_rect().right() - 252.0;
// (plugin, what was used; None: closed)
// (plugin, what was used, in order; None: closed)
let mut actions: Vec<(String, Option<Used>)> = Vec::new();
for (i, open) in app.session.plugins.panels.iter().enumerate() {
let id = open.manifest.id.as_str();
let mut keep = true;
let mut used = None;
let mut used = Vec::new();
// Side by side leftwards from the properties panel, three to a row.
let at = egui::pos2(right - PANEL_STEP * (i % 3) as f32, 52.0 + 28.0 * (i / 3) as f32);
floating(open.title(), ctx).id(Id::unique(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| {
@@ -247,8 +247,8 @@ fn panel_windows(app: &mut FigApp, ctx: &Context) {
});
if !keep {
actions.push((id.to_string(), None));
} else if used.is_some() {
actions.push((id.to_string(), used));
} else {
actions.extend(used.into_iter().map(|u| (id.to_string(), Some(u))));
}
}
for (id, used) in actions {
@@ -580,12 +580,13 @@ fn form_window(app: &mut FigApp, ctx: &Context) {
let title = if label.contains(name.as_str()) { label.to_string() } else { format!("{name}: {label}") };
window(&title, ctx).id(Id::unique("plugin_form")).open(&mut open).show(ctx, |ui| {
ui.set_width(320.0);
match controls(ui, &format!("{}/{}", form.id, form.command), &command.params, &form.values, &t) {
Some(Used::Set(name, v)) => {
form.values.insert(name, v);
for used in controls(ui, &format!("{}/{}", form.id, form.command), &command.params, &form.values, &t) {
match used {
Used::Set(name, v) => {
form.values.insert(name, v);
}
Used::Pick(control) => pick = Some(control),
}
Some(Used::Pick(control)) => pick = Some(control),
None => {}
}
ui.add_space(12.0);
ui.horizontal(|ui| {
@@ -604,8 +605,10 @@ fn form_window(app: &mut FigApp, ctx: &Context) {
/// Parameters or panel controls in two columns (label, field); buttons in a row share one line,
/// the first button filled. Returns what the user did to which control.
fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map<String, Value>, t: &Tokens) -> Option<Used> {
let mut changed = None;
fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map<String, Value>, t: &Tokens) -> Vec<Used> {
// In order: a tap's press and release share a frame, so a field that loses the keyboard
// commits in the same frame as the button that took it.
let mut used = Vec::new();
let mut first_button = true;
egui::Grid::new(("plugin_controls", salt)).num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| {
for row in params.chunk_by(|a, b| a.kind == ParamKind::Button && b.kind == ParamKind::Button) {
@@ -643,13 +646,11 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map<String, Valu
field(ui, Id::unique(("plugin_control", salt, &p.name)), p, &current)
}
};
if hit.is_some() {
changed = hit;
}
used.extend(hit);
ui.end_row();
}
});
changed
used
}
/// Panel images decoded lately, by their `data` (None: it couldn't be shown); the oldest go past
+20 -18
View File
@@ -1,10 +1,13 @@
//! Software updates, background check at startup, and startup prompt modal.
use crate::FigApp;
use crate::i18n::tr;
#[cfg(target_arch = "wasm32")]
use crate::i18n::tr;
#[cfg(not(target_arch = "wasm32"))]
use craft_update::updater::{Action, Status, Updater};
use craft_update::updater::{Action, Updater};
#[cfg(not(target_arch = "wasm32"))]
use craft_update::{CheckResultDialog, CheckResultResponse};
pub(crate) struct Runtime {
#[cfg(not(target_arch = "wasm32"))]
@@ -17,7 +20,10 @@ pub(crate) struct Runtime {
/// The context of the last frame, to wake the UI when a check is queued from a menu callback.
#[cfg(not(target_arch = "wasm32"))]
repaint: Option<egui::Context>,
/// The result dialog of a manual check is open.
pub(crate) manual_window_open: bool,
/// The web build has no updater; its only result is that checks are unsupported.
#[cfg(target_arch = "wasm32")]
pub(crate) status_message: Option<String>,
}
@@ -33,6 +39,7 @@ impl Default for Runtime {
#[cfg(not(target_arch = "wasm32"))]
repaint: None,
manual_window_open: false,
#[cfg(target_arch = "wasm32")]
status_message: None,
}
}
@@ -50,6 +57,7 @@ pub(crate) fn check(app: &mut FigApp) {
#[cfg(not(target_arch = "wasm32"))]
{
app.updates_rt.check_requested = true;
app.updates_rt.manual_window_open = true;
if let Some(ctx) = &app.updates_rt.repaint {
ctx.request_repaint();
}
@@ -88,18 +96,20 @@ pub(crate) fn show(app: &mut FigApp, ctx: &egui::Context) {
Action::Close => ctx.send_viewport_cmd_to(egui::ViewportId::ROOT, egui::ViewportCommand::Close),
Action::None => {}
}
// The window answers Help ▸ Check for Updates, so it follows the updater's status.
let rt = &mut app.updates_rt;
(rt.manual_window_open, rt.status_message) = match rt.updater.status() {
Status::Idle => (false, None),
Status::Checking => (true, None),
Status::UpToDate => {
(true, Some(tr("FigCraft {version} is currently the newest version available.").replace("{version}", env!("CARGO_PKG_VERSION"))))
if rt.manual_window_open {
match CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")).show(ctx, rt.updater.status()) {
CheckResultResponse::None => {}
CheckResultResponse::Close => {
rt.manual_window_open = false;
rt.updater.dismiss_status();
}
CheckResultResponse::CheckAgain => rt.check_requested = true,
}
Status::Failed(e) => (true, Some(tr("Could not check for updates:\n{error}").replace("{error}", e))),
};
}
}
#[cfg(target_arch = "wasm32")]
if app.updates_rt.manual_window_open {
let mut open = true;
let mut close = false;
@@ -111,8 +121,6 @@ pub(crate) fn show(app: &mut FigApp, ctx: &egui::Context) {
.show(ctx, |ui| {
if let Some(msg) = &app.updates_rt.status_message {
ui.label(msg);
} else {
ui.label(tr("Checking for updates…"));
}
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -126,10 +134,4 @@ pub(crate) fn show(app: &mut FigApp, ctx: &egui::Context) {
app.updates_rt.status_message = None;
}
}
// Closing the window dismisses the outcome it showed.
#[cfg(not(target_arch = "wasm32"))]
if !app.updates_rt.manual_window_open {
app.updates_rt.updater.dismiss_status();
}
}
+55 -6
View File
@@ -1036,11 +1036,22 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) {
} else {
hint(ui, tr("This server doesn't count insertions"));
}
let t = Tokens::get(ui.ctx());
let Some(kind) = usage_kind(ui, w, usage_id, &v) else {
ui.add_space(8.0);
return;
};
if kind != "components" {
for a in v[kind].as_array().into_iter().flatten().take(USAGE_ROWS) {
let (n, f) = (a["layers"].as_u64().unwrap_or(0), a["files"].as_u64().unwrap_or(0));
let counts = trf("{instances} in {files}", &[("instances", &trn("{n} layers", n as usize, &[])), ("files", &files_n(f))]);
usage_row(ui, w, a["name"].as_str().unwrap_or(tr("(no longer published)")), &counts);
}
ui.add_space(8.0);
return;
}
for c in v["components"].as_array().into_iter().flatten().take(USAGE_ROWS) {
let name = c["name"].as_str().unwrap_or(tr("(no longer published)"));
let (n, f) = (c["instances"].as_u64().unwrap_or(0), c["files"].as_u64().unwrap_or(0));
let (r, _) = ui.allocate_exact_size(vec2(w, 34.0), Sense::hover());
let gone = c["detached"].as_u64().unwrap_or(0);
// A component only detached (no instances left anywhere) says just that.
let right = if n == 0 && known && gone > 0 {
@@ -1051,14 +1062,52 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) {
// Inserted in the period, after what the component counts in all.
let added = c["insertions"].as_u64().unwrap_or(0);
let right = if actions && added > 0 { trf("{used} · {inserted}", &[("used", &right), ("inserted", &inserted_n(added))]) } else { right };
// Two lines: the counts don't leave a name room on one.
crate::bidi::paint_name(ui.painter(), pos2(r.min.x + PAD, r.min.y + 10.0), name, regular(11.0), t.text, w - PAD * 2.0);
let counts = crate::widgets::elide_font(ui, &right, w - PAD * 2.0, regular(10.0));
ui.painter().text(pos2(r.min.x + PAD, r.min.y + 25.0), Align2::LEFT_CENTER, counts, regular(10.0), t.text_secondary);
usage_row(ui, w, name, &right);
}
ui.add_space(8.0);
}
/// Which list Show usage shows: `components`, `styles` or `variables` (a choice kept with the
/// answer's `id`), `None` for nothing to list. A server from before style and variable reports
/// shows just components, and says so.
fn usage_kind(ui: &mut Ui, w: f32, id: Id, v: &Value) -> Option<&'static str> {
const KINDS: [(&str, &str); 3] = [("components", "Components"), ("styles", "Styles"), ("variables", "Variables")];
if v["assetsKnown"].as_bool() != Some(true) {
hint(ui, tr("This server doesn't count styles and variables"));
return Some(KINDS[0].0);
}
let id = id.with("kind");
let mut at = ui.data(|d| d.get_temp::<usize>(id)).unwrap_or(0);
ui.horizontal(|ui| {
ui.add_space(PAD);
let chosen = KINDS.get(at).map_or("", |k| tr(k.1));
let resp = crate::widgets::dropdown(ui, chosen, w - PAD * 2.0, None);
crate::right::menu_popup(&resp).width(w - PAD * 2.0).show(|ui| {
for (i, k) in KINDS.iter().enumerate() {
if crate::right::menu_item(ui, tr(k.1), "") {
at = i;
ui.close();
}
}
});
});
ui.data_mut(|d| d.insert_temp(id, at));
let kind = KINDS.get(at)?.0;
if v[kind].as_array().is_none_or(Vec::is_empty) {
hint(ui, tr("Nothing counted yet"));
}
Some(kind)
}
/// A Show usage row: two lines, as the counts don't leave a name room on one.
fn usage_row(ui: &mut Ui, w: f32, name: &str, counts: &str) {
let t = Tokens::get(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(w, 34.0), Sense::hover());
crate::bidi::paint_name(ui.painter(), pos2(r.min.x + PAD, r.min.y + 10.0), name, regular(11.0), t.text, w - PAD * 2.0);
let counts = crate::widgets::elide_font(ui, counts, w - PAD * 2.0, regular(10.0));
ui.painter().text(pos2(r.min.x + PAD, r.min.y + 25.0), Align2::LEFT_CENTER, counts, regular(10.0), t.text_secondary);
}
/// Draw the libraries enabled in this file with version/count/status, remove and swap
/// actions, add-file and pasted-link controls, and background fetch/cancel handling.
fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
+12 -4
View File
@@ -5,8 +5,8 @@
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_color::Paint;
use figcraft_doc::proto::{
Action, ConditionalBlock, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayAnchor, OverlayPosition, ScrollPosition,
Transition, Trigger, is_gamepad_button,
Action, ConditionalBlock, Direction, Easing, GAMEPAD_BUTTONS, GAMEPAD_NAMES, Interaction, KeyMods, Overflow, OverlayAnchor, OverlayPosition,
ScrollPosition, Transition, Trigger, is_gamepad_button,
};
use figcraft_doc::{Document, Node, NodeId, NodeKind, VariableValue, proto};
use serde_json::{Value, json};
@@ -822,9 +822,14 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte
let pads = widgets::dropdown(ui, tr("Controller"), PAD_MENU_W, None);
menu_popup(&pads).show(|ui| {
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
for b in GAMEPAD_BUTTONS {
for (b, (playstation, switch)) in GAMEPAD_BUTTONS.into_iter().zip(GAMEPAD_NAMES) {
let mark = if key.eq_ignore_ascii_case(b) { format!("✓ {b}") } else { b.to_string() };
if menu_item(ui, &mark, "") {
// The same button as other controllers call it (not a shortcut: `+` is a name).
let names = |p: &egui::Painter, row: Rect, _: Color32| {
let at = pos2(row.max.x - PAD_NAMES_INSET, row.center().y);
p.text(at, Align2::RIGHT_CENTER, format!("{playstation} · {switch}"), regular(11.0), t.text_secondary);
};
if crate::right::menu_item_with(ui, &mark, "", PAD_NAMES_W, names) {
ui.ctx().data_mut(|d| d.remove::<bool>(capture_id()));
patch(app, node, k, json!({"trigger": {"type": "keyPress", "key": b}}));
ui.close();
@@ -840,6 +845,9 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte
/// The width of the controller button menu beside a key trigger's key.
const PAD_MENU_W: f32 = 92.0;
/// The room in a row of that menu for what other controllers call the button, and its inset.
const PAD_NAMES_W: f32 = 76.0;
const PAD_NAMES_INSET: f32 = 12.0;
/// A destination picker for the action at `slot`: `make` builds the action for the destination.
struct DestinationPicker<'a, Make> {
+25 -9
View File
@@ -280,6 +280,27 @@ fn dash_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], dashes: Option<&
}
}
/// A dropdown of `caps` setting the prop `key`, showing `shown` (`None`: mixed).
fn cap_dropdown(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], key: &str, shown: Option<StrokeCap>, caps: &[StrokeCap], width: f32) {
let resp = widgets::dropdown(ui, shown.map(|c| tr(c.label())).unwrap_or(tr("Mixed")), width, None);
menu_popup(&resp).show(|ui| {
for &c in caps {
if menu_item_with(ui, tr(c.label()), "", CAP_ART_W, |p, r, col| cap_art(p, r, col, c, key == "startCap")) {
app.set_props(ids, json!({key: format!("{c:?}")}));
ui.close();
}
}
});
}
/// The cap of a dashed stroke's dashes. Unset, dashes take the end point caps: it shows those.
fn dash_cap_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], stroke: &StrokeProps) {
let [start, end] = stroke.band_caps().map(StrokeCap::shape);
widgets::sublabels(ui, tr("Dash cap"), None);
let half = widgets::col_width(ui.available_width());
row(ui, 32.0, |ui| cap_dropdown(app, ui, ids, "dashCap", (start == end).then_some(start), &StrokeCap::DASH, half));
}
/// Advanced stroke settings: dash pattern, width profile, join and the end points of open paths.
pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let ids = sel.to_vec();
@@ -287,6 +308,9 @@ pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel
let st = common(doc, sel, |n| n.stroke.clone());
let weight = st.as_ref().map(|s| s.weight).unwrap_or(1.0).max(1.0);
dash_settings(app, ui, &ids, st.as_ref().map(|s| s.dashes.as_slice()), weight);
if let Some(s) = st.as_ref().filter(|s| !s.dashes.is_empty()) {
dash_cap_row(app, ui, &ids, s);
}
width_profile_row(app, ui, &ids, st.as_ref().map(|s| s.profile().cloned()));
brush_row(app, ui, &ids, st.as_ref().map(|s| s.brush.clone()), ui.available_width());
let join = st.as_ref().map(|s| s.join);
@@ -313,15 +337,7 @@ pub(super) fn stroke_settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel
widgets::sublabels(ui, tr("Start point"), Some(tr("End point")));
row(ui, 32.0, |ui| {
for (key, cap) in [("startCap", st.as_ref().map(|s| s.cap_start)), ("endCap", st.as_ref().map(|s| s.cap_end))] {
let resp = widgets::dropdown(ui, cap.map(|c| tr(c.label())).unwrap_or(tr("Mixed")), half, None);
menu_popup(&resp).show(|ui| {
for c in StrokeCap::ALL {
if menu_item_with(ui, tr(c.label()), "", CAP_ART_W, |p, r, col| cap_art(p, r, col, c, key == "startCap")) {
app.set_props(&ids, json!({key: format!("{c:?}")}));
ui.close();
}
}
});
cap_dropdown(app, ui, &ids, key, cap, &StrokeCap::ALL, half);
}
});
}
+26
View File
@@ -183,3 +183,29 @@ fn a_slider_calls_the_plugin_back_once_when_let_go() {
let value = u.app.session.plugins.panel("org.test.panel").unwrap().values["opacity"].as_f64().unwrap();
assert!((value - live).abs() < 1e-9, "the plugin got the dragged value: {value}");
}
/// A trackpad or touch tap sends its press and release in one frame: the field it takes the
/// keyboard from commits in the frame the button is clicked, and the plugin gets both, in order.
#[test]
fn a_tap_on_a_button_keeps_what_was_typed_in_a_field() {
let mut u = Ui::new(session());
u.ctx.enable_accesskit();
u.frame(vec![]);
let at = |u: &mut Ui, label: &str| {
let nodes = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes;
let b = nodes.into_iter().map(|(_, n)| n).find(|n| n.label() == Some(label)).and_then(|n| n.bounds()).unwrap();
Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32)
};
let size = at(&mut u, "Size");
u.button(size, true);
u.button(size, false);
u.frame(vec![]);
u.frame(vec![egui::Event::Text("33".into())]);
let go = at(&mut u, "Go");
let press = |pressed| egui::Event::PointerButton { pos: go, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
let before = layers(&u);
u.frame(vec![egui::Event::PointerMoved(go), press(true), press(false)]);
u.frame(vec![]);
assert_eq!(u.app.session.plugins.panel("org.test.panel").unwrap().values["size"], 33.0, "the field's value arrived");
assert_eq!(layers(&u), before + 2, "the field's commit and the button each called the plugin");
}
+5
View File
@@ -251,6 +251,11 @@ fn the_interaction_editor_picks_a_controller_button() {
};
// The key field keeps recording keys; the menu beside it names the controller's buttons.
at(&mut u, "Controller");
// Each with what PlayStation and Switch controllers call the same button.
let shown = crate::live_tests::flat(&u.frame_output(vec![]).shapes);
for names in ["✕ · B", "□ · Y", "L2 · ZL", "Options · +", "PS · Home"] {
assert!(crate::live_tests::has_text(&shown, names), "{names}");
}
at(&mut u, "Gamepad Menu");
let trigger = u.app.session.doc().unwrap().node(layer).unwrap().interactions[0].trigger.clone();
assert_eq!(trigger, figcraft_doc::proto::Trigger::KeyPress { key: "Gamepad Menu".into() });
+3 -1
View File
@@ -1,6 +1,6 @@
# Boards parity
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-30 · **Change:** minor (a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-31 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre)
**Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind
(`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that
@@ -40,6 +40,7 @@ all end with the same geometry.
| Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was |
| Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before |
| Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) |
| A connector end on any part of an object | done | holding Cmd/Ctrl while moving an end places it where the pointer is on the object, as a share of its box, so it follows moves and resizes; the line leaves by the side facing the other end. Without the key an end snaps to a side |
| Connector paths reshaped by hand | done | a selected elbow or curved connector shows path handles: one on each run long enough to take one, one in the middle of a curve. Dragging a run moves it, and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins and is saved with the file; changing the style or moving an end to something else goes back to routing itself. No way back by itself otherwise: the public help describes none |
| Quick-create handles (drag out a connected copy) | missing | |
| Connector waypoints placed by hand | missing | routes are computed from the ends; no bend points of your own |
@@ -69,6 +70,7 @@ all end with the same geometry.
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged |
| 2026-10-31 | patch | M12, #1708: a connector end dropped on the outer half of a shape takes the side it is towards, instead of only within 10 px of a side's middle, so a drop anywhere along an edge chooses that edge (dragging a selected connector's end and with the connector tool, ring included); a drop in the middle of a shape still takes the facing side. |
| 2026-10-31 | minor | M12, #1710: a selected elbow or curved connector shows path handles. Dragging a run moves it and a first or last run gains an extra run to stay on its end; a curve's middle goes where it is dragged (`board.reshape`, one undo step). The shape travels with what the connector joins, is saved with the file, and goes back to routing itself when the style changes or an end moves to something else. |
| 2026-10-31 | minor | M12: boards have 23 shapes with text instead of 7: triangle down, parallelogram right and left, trapezoid, pentagon, hexagon, octagon, star, plus, arrow left and right, and the flowchart symbols database, document, predefined process, manual input and internal storage. The toolbar flyout wraps into rows of eight and the selection bar's shape menu is a grid of outlines named on hover. The stale "Quick-create handles: missing" row now says what exists. |
+7 -3
View File
@@ -257,7 +257,7 @@ Client → server:
| `Manage {protocol, key, client_version}` | Instead of `Hello`: manage a server's files with its key. |
| `Admin {publish {name, snapshot} \| files \| links {file} \| createLink {file, access, label} \| revoke {file, link} \| remove {file}}` | A request on a managing connection; each gets one `Admin` reply, in order. |
| `OpenLibrary {protocol, file, token, client_version}` | Instead of `Hello`: use a file's team library with one of the file's share links. |
| `Library {fetch {have?, usage?} \| publish {archive} \| usage \| reportDetached {file, components} \| detached \| reportInserted {file, components} \| actions}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`; `reportDetached` gives the asking file's detached instances per component the same way (answered `noted`), `detached` asks for every file's. The last two are newer: a server from before them can't read them and refuses the request. |
| `Library {fetch {have?, usage?} \| publish {archive} \| usage \| reportDetached {file, components} \| detached \| reportInserted {file, components} \| actions \| reportAssets {file, styles, variables} \| assets}` | A request on a library connection; each gets one `Library` reply, in order. `usage` is `{file, components: [(id, instances)]}`; `reportDetached` gives the asking file's detached instances per component the same way (answered `noted`), `detached` asks for every file's. The last two are newer: a server from before them can't read them and refuses the request. |
| `Signal {kind, text}` | Ephemeral presence, only after the server's `ready`: `chat` (the cursor chat message; empty: closed), `spotlight` (`"on"`, or empty: stopped) or `name` (your new name: the server keeps it, cleaned like a hello's, and sends everyone, you too, your `PeerJoined`; never passed on as a signal). |
| `OpenPlugins {protocol, key, client_version}` | Instead of `Hello`: use the server's plugin directory; `key` empty to browse, the server key to publish too. |
| `OpenHosting {protocol, client_version, file, owner}` | Instead of `Hello`: ask about the server's hosting; publish without the key (`Admin publish`) when it is open to the public; with a file's id and owner token, manage that file (`Admin links \| createLink \| revoke \| rename \| remove` of it). Answered with `Hosting` or `Refused`. |
@@ -279,7 +279,7 @@ Server → client:
| `Refused {reason}` | The connection is refused (protocol version, token, session full, a message out of place). |
| `Pong` | Keep-alive. |
| `Admin {ready \| published {file, edit, view} \| files \| links \| link \| done \| failed {reason}}` | A managing connection's answer: the key is right, a published file with its first two links (tokens shown once), listings, a new link, done, or why not. |
| `Library {ready {access} \| library {version, archive} \| unchanged {version} \| empty \| published {version} \| usage [{usage, updated}] \| failed {reason} \| noted \| detached [{detached, updated}] \| actions [{week, component, insertions, detachments}]}` | A library connection's answer: the link is right (with its access), the library (a `.figcraft` archive holding it), no newer version, no library yet, kept, the usage reports, or why not; then (newer) a detach report kept, and the detach reports. |
| `Library {ready {access} \| library {version, archive} \| unchanged {version} \| empty \| published {version} \| usage [{usage, updated}] \| failed {reason} \| noted \| detached [{detached, updated}] \| actions [{week, component, insertions, detachments}] \| assets [{file, styles, variables}]}` | A library connection's answer: the link is right (with its access), the library (a `.figcraft` archive holding it), no newer version, no library yet, kept, the usage reports, or why not; then (newer) a detach report kept, and the detach reports. |
| `Signal {client_id, signal: {kind, text}}` | Someone's cursor chat or spotlight, cleaned by the server; `ready` (about the client itself, after its welcome): signals may be sent. Only to clients whose hello announced `signals`. |
| `Hosting {open, operator, terms, idle_secs, max_file_mb, expires?}` | The answer to `OpenHosting`, and after a welcome to clients whose hello announced `hosting`: anyone may publish, how to reach the operator, the terms (a URL), how long files published without the key are kept unopened, the largest file, and this file's expiry (seconds since 1970). |
| `Admin {hosted {file, edit, view, owner, expires?} \| listing [row] \| reports [report] \| blocked [ip]}` | A file published without the key with its links and owner token (shown once), and the answers to the later managing requests. |
@@ -821,7 +821,11 @@ resuming with its offline edits, and leaving the session going back to snapshot
`reportInserted` the same way with the instances it has inserted (placed, pasted or duplicated;
not those nested in another instance). What either total grows past that file's highest counts
in the current week, a Monday at 00:00 UTC, over all files (`actions.json`, a year of weeks,
capped), and `actions` answers with those weeks. The variants are appended to the enums, so the older messages keep their bytes; an older server refuses the new
capped), and `actions` answers with those weeks. A file that holds copies of the library's
styles or variables sends `reportAssets` with how many of its layers use each (at most 250 of
each kind, the most used first; none is a report too, so a file that stops using them stops
counting); the server keeps the latest in the same `usage.json` entry and answers `assets` with
them. The variants are appended to the enums, so the older messages keep their bytes; an older server refuses the new
requests, and the app ignores that.
*Built* (the plugin directory; operating it: [`server.md`](server.md#plugin-directory), publishing and
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -52,7 +52,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Enable a library in a file; read-only linked copies | done | |
| Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) |
| Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) |
| Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; insertions and detachments week by week for a year (Show usage: the past 30 days, 90 days or year), for components only: no styles or variables, no chart |
| Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; insertions and detachments week by week for a year (Show usage: the past 30 days, 90 days or year), for components; the layers and files using each style and variable, without insertions, detachments or modes; no chart |
| Swap library (instances, styles and variables to another library's of the same names, overrides kept) | done | what has no match stays and is listed; one undo step |
## Styles and variables
@@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
| 2026-10-31 | minor | M6: as the help center describes code syntax, Dev Mode's SwiftUI and Compose code now writes a bound variable's iOS or Android code syntax in place of its value, as CSS already wrote the Web one: fills, strokes and their weight, sizes, corner radius, gaps, padding, opacity, the font family and size and the characters. A variable with no code syntax for the platform keeps the value, since a name made up here wouldn't exist in the code it lands in. ArkUI and Flutter have no code syntax platform and are unchanged. Test: `variables_behaviour_tests::swiftui_and_compose_name_variables_by_their_code_syntax`. Numbers unchanged. Grade unchanged |
| 2026-10-31 | minor | M3, gap 10: library analytics over time. A file counts the instances of library components it inserts (placed, created, pasted or duplicated, Alt-dragged copies too; not those nested in another instance) and reports them like its detaches (`ReportInserted`). The server counts what a file's insertion and detach totals grow past their highest in the current week, a Monday at 00:00 UTC, so an undo and a redo count once, and keeps a year of weeks in `actions.json` (`Actions`). `library.usage {days?}` adds `insertions`, `detachments` and `weeks`, and per component `insertions` and `detachments`; Show usage has a 30 days, 90 days or 1 year choice, the period's totals, and each component's counts on a line of their own. The new variants are appended, so older messages keep their bytes; an older server refuses them and Show usage says it doesn't count insertions. A file's first report after this counts its total in that week. Tests: `library_actions_tests`, `inserting_library_instances_counts_them`. Grade unchanged |
| 2026-10-30 | minor | M3, gap 14: a collection of this file can be extended, as the help center shows (right-click a collection, Extend collection); before, only a library's could. `variable.extendCollection {collection, name?}` makes "<name> (extended)" with its modes and one inheriting variable per variable, and the Variables editor's collection menu offers it beside Rename and Delete collection. Edits of the collection it extends reach it at once (values in modes it doesn't override, new variables and modes, renames, mode order), so normalising keeps extensions in step whenever variables change, not only on library updates; undo takes both back. An extended collection extends again (parents settle first; an extension doesn't take its parent's overrides as its own), and a loop of extensions from a hostile file comes apart into plain collections keeping their values. Deleting the parent leaves a plain collection with the values it showed, as for libraries. Tests: `extended_chain_tests` (engine), `variables_rename_tests::a_collection_of_this_file_extends_from_its_context_menu` (headless UI). Grade unchanged |
+4 -21
View File
@@ -1,16 +1,14 @@
# Contributors in the About window
**About ▸ Contributors** credits everyone who contributed to FigCraft, and **About ▸ Models** credits
the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard
[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md);
this page is the local copy of the decision.
the AI models named in `Co-Authored-By` trailers.
## Decision
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit.
username; never hand-edit.
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR.
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
@@ -32,20 +30,5 @@ display_name = "Nickname"
sync_github_name = true # use the name on your public GitHub profile as display name
```
or, with craftrules checked out next to `craft-apps/`:
```sh
python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name
```
Names show after the next refresh: the script checks that each entry was committed by its own
user (or by a maintainer with a `consent_source`). Delete your entry to remove your names.
## Refreshing (maintainers)
```sh
python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh
python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only
```
Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded.
Names show for entries committed by their own user (or by a maintainer with a `consent_source`).
Delete your entry to remove your names. Git author names and emails are never recorded.
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -135,7 +135,7 @@ source paint coordinates.
out), set-variable, set-variable-mode and conditional actions, scroll-to offsets, text decoration
looks, variable font axes, text on a path, nested list levels, list spacing, hanging punctuation,
leading trim, OpenType features, per-layer operations inside boolean groups, different stroke ends
on lines. Not written at all: stroke outlines of dashed and per-side strokes and of inside or
on lines, the Dash cap (no public name for it). Not written at all: stroke outlines of dashed and per-side strokes and of inside or
outside strokes on polygons, stars and vectors, glyph outlines of text (a reader lays text out
with its own fonts), version history and branches.
- **Checked by opening written files in other software:** the example documents and a small test
@@ -187,6 +187,7 @@ source paint coordinates.
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged |
| 2026-10-30 | minor | `.fig` import: a connector's label without a saved snapshot was skipped; now the connector imports as a frame holding its line and the label, as a connector with a saved snapshot does, the label centred on the line's middle by length (in a 200-wide box, laid out again with the fonts at hand), in the connector's fill paints or black. Its background isn't known without the snapshot and is left out. Not checked on real files. Test: `fig::tests::tail::a_labelled_connector_without_a_snapshot_keeps_its_label` |
| 2026-10-30 | minor | Pasted content row: an image the clipboard doesn't carry keeps its name and shows a placeholder that reads as missing; the paste names those layers in its message and opens the import report with its notes (#1646). Numbers unchanged |
| 2026-10-30 | minor | Pasted content row: styles a pasted layer links by its library's guid are found by their library asset, so paint-style colours on pasted components and instances no longer import as black; a link to a style that isn't there keeps the stored colour, with a note (#1638). Numbers unchanged |
+10 -8
View File
@@ -1,6 +1,6 @@
# Where FigCraft falls short of Figma
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (gap 10: library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 7: text reads only the fallback fonts it draws with; a board connector's path can be reshaped by dragging its path handles (#1710); gap 10: library analytics count insertions and show insertions and detachments over time; gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; gap 14: a collection of this file extends, following its edits at once; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; 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; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7)
Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from
doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The
@@ -23,7 +23,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) |
| 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) |
| 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) |
| 10 | Team libraries: no team-wide lists or accounts; analytics cover components only | Feature | 4–8 | [components-parity](components-parity.md) |
| 10 | Team libraries: no team-wide lists or accounts; analytics count insertions for components only | Feature | 4–8 | [components-parity](components-parity.md) |
| 11 | Spell check | Feature | 4–8 | [target-app-parity](target-app-parity.md) |
| 12 | Video fills; HEIC images | File format | 5–10 | [file-format-parity](file-format-parity.md) |
| 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) |
@@ -173,8 +173,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
still unmeasured (`crates/text/src/metrics_tests.rs`, and a local check over such documents in
`crates/engine/src/text_metrics_local_tests.rs`).
- **Estimate:** 15–30 h once `.fig` or REST import of real files is available; needs files whose
licence allows use (owner to supply or approve; never committed if not redistributable, see
craftrules `standards/test-corpora.md`).
licence allows use (owner to supply or approve; never committed if not redistributable).
### 4. Interaction fidelity not compared tool by tool
- **Missing:** a side-by-side pass of every tool and panel: cursors, modifier keys while dragging,
@@ -351,10 +350,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
- **Impact:** illustration-flavoured work moved into Figma Draw in 2025.
- **Estimate:** 1–3 h once winit (or a platform tablet API behind it) reports pressure there.
### 10. Team libraries: no team-wide lists or accounts; analytics cover components only
### 10. Team libraries: no team-wide lists or accounts; analytics count insertions for components only
- **Missing:** libraries listed for a whole team or organization (each file enables a library by
its link), accounts and per-person library permissions, analytics of styles and variables (a
component's instances, insertions and detachments are counted, the last two week by week),
its link), accounts and per-person library permissions, insertions, detachments and mode counts for
styles and variables (they count layers and files using them; a component's instances,
insertions and detachments are counted, the last two week by week),
update notices pushed by the server (apps ask every 30 seconds while the Assets panel shows).
- **Evidence:** a file's library is published to `figcraft-server` under the file and enabled in
other files by a share link, with per-asset versions, updates with overrides kept, usage counts
@@ -430,7 +430,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
- **Missing:** video triggers (on media hit/end) and play/pause/mute/seek actions (the REST
importer warns and skips them, `crates/format/src/rest/proto.rs`); controllers on macOS, Linux
and FreeBSD (the usual Rust controller library links libudev on Linux; a pure-Rust reader per
platform, or an owner decision on that dependency); PlayStation and Switch button labels.
platform, or an owner decision on that dependency).
- **Estimate:** 2–5 h, the media part after video fills (gap 12).
### 16. Dev Mode: the tail after dev resources
@@ -588,6 +588,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | Gap 10: library analytics list the layers and files using each style and variable; their insertions, detachments and modes stay on the missing list. |
| 2026-10-31 | minor | Gap 15: the Controller menu names buttons as PlayStation and Switch controllers do; that item leaves the missing list. Estimate unchanged. Test: `present::tail_tests`. |
| 2026-10-30 | minor | Gap 7: text reads only the fallback fonts it draws with: a face per fallback family and style, a file's bytes shared between families, the faces worked out once per style (first text laid out: 41 font files and 265 MiB read down to 3 files and 20 MiB on a Linux machine with Noto Sans CJK). Numbers unchanged |
| 2026-10-31 | minor | Gap 6: widgets draw lines and SVG pictures (plugin API 6), so that item leaves the missing list. |
| 2026-10-31 | minor | Gap 24: connector paths can be reshaped by hand (path handles and `board.reshape`), so that item leaves the missing list. Estimate unchanged. Tests: `board_reshape_tests`, `board_line_tests`. |
+1 -1
View File
@@ -6,7 +6,7 @@ FigCraft's interface has a localization layer with an English catalog, a Russian
by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian
Portuguese, Japanese, Korean, Simplified Chinese, Indonesian, Vietnamese, Bosnian and Czech drafts (machine-drafted,
waiting for native speakers' review), plus Swedish and Turkish catalogs. This page lists the twelve key
languages of the craftrules standard, then the other languages Figma ships. How to add a
languages, then the other languages Figma ships. How to add a
language: [`translating.md`](translating.md).
## What Figma ships
+10 -5
View File
@@ -483,17 +483,19 @@ one undo step and returns the ids it made:
in the app does both).
- `board.shape {shape, x, y, width?, height?, text?, color?, parent?}` → `{id, text}`: a shape with centred,
wrapping text: `rectangle roundedRectangle ellipse diamond triangle triangleDown parallelogramRight parallelogramLeft trapezoid pentagon hexagon octagon star plus arrowLeft arrowRight chevron speechBubble database document predefinedProcess manualInput internalStorage`.
- `board.connector {from, to, fromSide?, toSide?, style?: straight|elbow|curved, startCap?, endCap?, label?}`
- `board.connector {from, to, fromSide?, toSide?, fromAt?, toAt?, style?: straight|elbow|curved, startCap?, endCap?, label?}`
→ `{id, label?}`: `from`/`to` are layer ids or `{x, y}` page points; sides are `auto` (the side
facing the other end) or `top right bottom left`; caps are stroke caps (default an arrow at the
facing the other end) or `top right bottom left`; `fromAt`/`toAt` are `[x, y]` fractions of the
layer's box that put the end on that part of it (it leaves by `side`); caps are stroke caps (default an arrow at the
end). An elbow goes around the objects it joins when the short way would cross them; a curved
one leaves and arrives along the sides. The connector re-routes itself whenever what it joins
moves or resizes, also when someone else in a live session moves it; a deleted end stays where it
was as a point. `board.label {id, text}` sets its label (empty removes it).
- `board.reattach {id?, end: from|to, target, side?}` → `{id}`: moves one end of a connector
- `board.reattach {id?, end: from|to, target, side?, at?}` → `{id}`: moves one end of a connector
(default the selected one; its label stands for it) onto `target`, a layer id or an `{x, y}`
page point where the end stays free. Refused when both ends would be on one layer or the target
is a connector. Dragging a selected connector's end handle in the app does the same. A path
is a connector. Dragging a selected connector's end handle in the app does the same; with Cmd/Ctrl
held the end goes where the pointer is on the object (`at`, as in `board.connector`). A path
reshaped by hand goes back to routing itself.
- `board.reshape {id?, portion, x, y}` → `{id}`: moves one portion of a connector's path to that
page point, as dragging a path handle in the app does: run `portion` of an elbow (a first or last
@@ -764,6 +766,9 @@ On the canvas (`pointer`), dragging points, handles and pen clicks snap to other
midpoints (this vector's and other vectors'), the usual layer targets and the pixel grid; `cmd`
held while dragging turns the object snapping off.
Dash caps: `node.set {dashCap}` takes `none`, `round` or `square` for both ends of every dash of a
dashed stroke, and `null` clears it (dashes then take `startCap` and `endCap`, the default).
Variable width strokes: `stroke.widthProfile {ids?, preset?: uniform|wedge|taper|eye|rounded|pinch,
points?: [[t, scale], ...]}` (also `node.set {widthProfile}`; `t` runs 0–1 along each subpath by
length, `scale` multiplies the stroke weight, widths in between are interpolated smoothly; no
@@ -957,7 +962,7 @@ most when it can't be reached).
`library.acceptUpdates` takes it, overrides kept. The app's own background checks ask only about
team libraries added (`library.add`) or checked (`library.check {library?}`) on this computer,
so opening a file that names another one contacts nothing (#1097).
- `library.usage {library?, days?}` → `{id, name, instances, detached, detachesKnown, days,
- `library.usage {library?, days?}` → `{id, name, assetsKnown, styles [{id, name, type, layers, files}], variables [{id, name, collection, layers, files}], instances, detached, detachesKnown, days,
insertions, detachments, actionsKnown, weeks: [{week, insertions, detachments}], files: [{name,
updated, instances, detached}], components: [{id, name, instances, files, detached, insertions,
detachments}]}`: what the files using it reported (each fetch reports the asking document's name
+3 -2
View File
@@ -1,6 +1,6 @@
# Prototyping parity
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-30 · **Change:** minor ("Open in new tab" on Open link: off, the web build's own tab goes to the link; a manual overlay's position can be dragged on the canvas (#405); Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-31 · **Change:** minor (the Controller menu names each button as PlayStation and Switch controllers do; "Open in new tab" on Open link: off, the web build's own tab goes to the link; a manual overlay's position can be dragged on the canvas (#405); Open link notices now cover mail links, and skipping them covers only links the viewer fired (#1096); overlay positions relative to the layer that opens them; the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7)
**Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except
media control, every transition and easing exist; key/gamepad triggers take controller buttons,
@@ -86,7 +86,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Overlays: position, close on click outside, background, animate out, stacking, timers from when they open | done | |
| Scroll behaviour: overflow directions, fixed (drawn on top), sticky, preserve scroll position, scroll to with an offset, momentum and overscroll | done | |
| Key combinations (`Shift+K`), recorded in the editor and imported from REST files | done | |
| Controller buttons on key/gamepad triggers (Controller menu, REST import, `ui.input {gamepad}` on the control channel) | done | read from controllers on Windows (Windows.Gaming.Input) and in the browser (Gamepad API); macOS, Linux and FreeBSD don't read controllers yet; button names follow one layout (no PlayStation or Switch labels) |
| Controller buttons on key/gamepad triggers (Controller menu, naming each button as PlayStation and Switch controllers do as well, REST import, `ui.input {gamepad}` on the control channel) | done | read from controllers on Windows (Windows.Gaming.Input) and in the browser (Gamepad API); macOS, Linux and FreeBSD don't read controllers yet; button names follow one layout (no PlayStation or Switch labels) |
| Widgets react to clicks in a presented prototype | done | clicks only, like on the canvas |
| Interactive components: instances play their variant's interactions, overridden per trigger | done | |
| Drag-driven animations that follow the pointer, in their direction | done | |
@@ -105,6 +105,7 @@ tool; opt-in), **silent** (the documentation doesn't say; the case pins the curr
| Date | Change | Summary |
|---|---|---|
| 2026-10-31 | minor | Gap 15: the Controller menu names buttons as PlayStation and Switch controllers do; that item leaves the missing list. Estimate unchanged. Test: `present::tail_tests`. |
| 2026-10-30 | minor | Opening a link in place: Open link has "Open in new tab", on by default; off, the web build's own tab goes to the link after the notice (or at once with it skipped); the desktop app has no tab of its own and opens the browser either way. Saved in files only when off (`openInNewTab`) and read from REST files. |
| 2026-10-30 | patch | A manual overlay can be placed on the canvas (#405): while the interaction editor has an Open overlay action with Manual position open, the overlay frame shows dimmed, outlined, where it opens from the top-left of the layer that opens it (right below that layer without an offset), and dragging it moves it there. The X and Y fields follow while dragging, and the drag is one undo step (`crates/ui-egui/src/canvas/proto_canvas/overlay_ghost.rs`). Numbers unchanged |
| 2026-10-29 | patch | The Open link notice covers mail links, and skipping it covers only links the viewer fired: before, a mail link opened the mail app at once, also from an "After delay" trigger, and while the notice was skipped any trigger opened any number of web links. Now every openable link waits behind the notice; with it skipped, a link the viewer's own click, press, drag or key fired opens at once, one per frame, while a delay, the pointer passing over a layer or a timed mouse trigger still shows the notice, which then offers no "Don't show this again". Nothing opens while a notice shows, and a press under way when it appears ends there: before, its release could still open a link or navigate behind the notice. The mail notice says the link opens the mail app to write a message (#1096, `crates/ui-egui/src/present_links_tests.rs`). Numbers unchanged |
-6
View File
@@ -1,11 +1,5 @@
# Release playbook
The release playbook shared by every crafting app lives in craftrules:
[`../craftrules/release/playbook.md`](../../../craftrules/release/playbook.md)
(`storytold/craftrules`, checked out next to `craft-apps/`; the repository is not public). It is
the canonical copy; don't duplicate it here. Cutting a release step by step:
`craftrules/release/cut-a-release.md`.
FigCraft copies PhotoCraft's reference implementation (`.github/workflows/release.yml`,
`packaging/`, the `xtask version` command) with FigCraft's names. FigCraft-specific details are in
[releasing.md](releasing.md).
+2 -4
View File
@@ -7,9 +7,7 @@ Nobody sees a draft until a maintainer publishes it.
This is FigCraft's copy of the shared [release playbook](release-playbook.md) (PhotoCraft is the
reference implementation). User-facing names say **FigCraft**. Files, binaries and ids stay
lowercase (`figcraft-<version>-<platform>-<arch>.<ext>`, `ai.storyteller.figcraft`). The
operational runbook (secrets, the `release` ruleset, runner group, promoting the draft) is
craftrules `release/cut-a-release.md`.
lowercase (`figcraft-<version>-<platform>-<arch>.<ext>`, `ai.storyteller.figcraft`).
## FigCraft's names
@@ -216,7 +214,7 @@ never repo or org secrets. Names (identical in every app): `APPLE_CERTIFICATE`,
`WINDOWS_CERTIFICATE`, `WINDOWS_CERTIFICATE_PASSWORD`, `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`,
`AZURE_CLIENT_SECRET`, `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_CERT_PROFILE`.
Each is optional: a missing one gives an unsigned artifact and a `::warning::`. An org admin
provisions the environment, ruleset and secrets with craftrules `release/signing-setup.md`, and
provisions the environment, ruleset and secrets, and
adds the repo to the org's Default runner group so `macos-15-xlarge` can pick up the job.
## Checks

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