Merge remote-tracking branch 'origin/main' into 1235-transliterate-class-names

# Conflicts:
#	ROADMAP.md
#	crates/engine/Cargo.toml
This commit is contained in:
lileeei committed 2026-10-26 17:56:57 +08:00
commit 1138b13f17
365 files changed
+35428 -2844

No files matched your search

+8
View File
@@ -3,5 +3,13 @@
[env]
CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true }
# The Windows UI Automation adapter can be asked about a node that disappeared in the
# same frame. On MinGW, converting that HRESULT through windows-result's thread COM
# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra
# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. Every Windows GNU
# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks.
[target.'cfg(all(windows, target_env = "gnu"))']
rustflags = ["--cfg=windows_slim_errors"]
[alias]
xtask = "run -q -p xtask --"
+4 -5
View File
@@ -21,11 +21,10 @@ jobs:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter):
# crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts'
# pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin
# once one commit has both (storytold/craft-fonts#42).
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# Combined Latin presentation and core UI catalog. Required web builds enforce
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
require-craft-fonts: true
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
+1 -1
View File
@@ -32,4 +32,4 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
run-package-check: true
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
+4 -5
View File
@@ -24,13 +24,12 @@ jobs:
with:
app-name: figcraft
app-title: FigCraft
# Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only
# craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter.
# Re-pin once one commit has both (storytold/craft-fonts#42).
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
# Combined Latin presentation and core UI catalog. Required web builds enforce
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
has-server: true
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
# crates/text/build.rs keeps the web build to Inter.
# crates/text/build.rs keeps the web allowlist small (Inter and Georgian).
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
+11 -2
View File
@@ -26,8 +26,17 @@ Release builds embed fonts from the optional craft-fonts build input
|---|---|---|
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4.
FigCraft filters both families from every build in accordance with its asset policy.
egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates.
## egui dependency font icons
| Asset | Author | Source | Licence | Use |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
Generated
+496 -167
View File
File diff suppressed because it is too large. Load diff
+33 -10
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.11.1"
version = "0.11.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -32,11 +32,15 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
# 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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
# 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). The `egui` feature adds `decorate`/`decorations`.
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
# 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 = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -45,6 +49,7 @@ serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] }
regex = "1.13"
log = "0.4"
same-file = "1.0.6"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
proptest = "1"
@@ -82,9 +87,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable",
wat = "1.261"
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
webbrowser = "1"
@@ -125,14 +130,32 @@ codegen-units = 4
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
[profile.web-release]
inherits = "release"
opt-level = "s"
# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate.
opt-level = "z"
lto = "fat"
codegen-units = 1
debug = false
strip = true
panic = "abort"
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
# 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.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
+17
View File
@@ -51,3 +51,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
egui icon font subset (dependency asset)
----------------------------------------
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
uses the same MIT license. Upstream license text follows unchanged.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+12 -2
View File
@@ -121,6 +121,16 @@
<p align="center"><sub><b>Live sessions</b>: three apps on one <code>figcraft-server</code> file, with cursors, selections, cursor chat and spotlight</sub></p>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-8-board.png" alt="A board file named Sprint retro on a dot grid: a Sprint 14 retro made from the retrospective template, with Went well, To improve and Next steps sections of sticky notes, vote counts of 3, 2, 1 and 1 on four stickies after a vote has ended, and below it a pull request flow chart from the flow chart template whose shapes are joined by straight and elbow connectors with Yes and No labels; a timer pill in the top right shows 4:30 left with pause, add a minute, stop and Results buttons" width="100%">
<p align="center"><sub><b>Boards</b>: a whiteboard file kind with sticky notes, templates, connectors, a shared timer and voting, in the same live sessions</sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-9-devmode-measure.png" alt="Dev Mode on the Ridgeline home screen with the featured trail card selected: red redlines kept on the canvas show 118.3 from the card to the search field, 62.1 from the card to the first trail row and 256.3 along the card's left side, and the Inspect panel's Measurements section lists the three with their values under an Add measurement menu" width="100%">
<p align="center"><sub><b>Dev Mode measurements</b>: redlines placed between layers or along a side, saved in the file and following the layers · <code>examples/ridgeline-app.figcraft</code></sub></p>
</td>
</tr>
</table>
## Made in FigCraft
@@ -234,8 +244,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
+37 -9
View File
@@ -2,8 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (non-ASCII layer names transliterate in code export (#1235); Dev Mode measurements kept on the canvas and a read-only layers panel; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
the CLI, a JSON control channel and MCP. This page is the one-page summary; agents choosing work
@@ -19,13 +18,13 @@ start with [`docs/gaps.md`](docs/gaps.md).
| Essentials user | **~59%** | estimated: core features 77.8% × 0.92 launch/stability × 0.94 discoverability × 0.88 opening files people send ([method](docs/target-app-parity.md#essentials-user)) |
| Mainstream practitioner | **~50%** | estimated: weekly areas 72.7% × 0.9 interaction × 0.9 stability × 0.85 file exchange with Figma users ([method](docs/target-app-parity.md#mainstream-practitioner)) |
| Remaining to beta | **~155–280 h** one agent | estimated |
| Remaining to full parity | **~262–519 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
| Remaining to full parity | **~260–515 h** one agent (~83–145 h wall-clock with 5 agents) | estimated |
### Readiness by audience
| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates |
|---|---|---|---|
| Full target (ready for real work) | ~53% | ~268–522 h | multiplayer (42–84), localization (29–51), plugins (8–26), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Full target (ready for real work) | ~53% | ~266–518 h | multiplayer (42–84), localization (29–51), plugins (6–22), the interaction pass (40–60), GPU canvas (30–60), AI (30–60), the `.fig` import's tail (5–10) |
| Mainstream practitioner | ~50% | ~120–229 h | core editor depth incl. the `.fig` import's tail (49–98), the interaction pass (40–60), real-file corpus and platform QA (25–50), checking the rest of the experimental `.fig` export elsewhere: effects, images, prototypes, real files (5–15) |
| Essentials user | ~59% | ~55–100 h | the `.fig` import's tail so the files people send open well (5–10), core tool polish (25–45), interaction pass on the core tools (15–25), stability QA (10–20) |
@@ -65,7 +64,7 @@ native speakers' reviews of the nine language drafts need people.
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
| Ecosystem / plugins | 40% (own WASM plugins with panels, settings and widgets that take clicks, text and a property menu; a self-hosted plugin directory) | 6–22 | [gaps](docs/gaps.md) |
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
## Features
@@ -89,11 +88,11 @@ native speakers' reviews of the nine language drafts need people.
| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
| Plugins & widgets | 65% | 38% | 6–22 | [plugins](docs/plugins.md) |
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
| Boards (whiteboarding; not in the weighted numbers) | ~35% | ~20% | 30–60 | [boards-parity](docs/boards-parity.md) |
| Boards (whiteboarding; not in the weighted numbers) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) |
## Languages
@@ -110,11 +109,11 @@ native speakers' reviews of the nine language drafts need people.
| Japanese | ja | 99% | draft (needs native review) |
| German | de | 96% | draft (needs native review) |
| Korean | ko | 95% | draft (needs native review) |
| Vietnamese | vi | 0% | none |
| Vietnamese | vi | 100% | draft (needs native review) |
Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese
(Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America),
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸
French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸
Language; Traditional Chinese has no catalog; CJK UI
text uses an installed CJK font, loaded only when needed; adding a language:
[`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping,
@@ -133,8 +132,21 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
- **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged.
- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%.
- **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
- **2026-10-24** · Board templates and tables (M12): a templates picker in the board toolbar places a retrospective, a brainstorm, a weekly planner, a flow chart or a kanban board (`board.template`, all FigCraft's own) as ordinary board objects in one undo step; `board.table` makes a table of auto layout frames (up to 50 × 20) whose rows grow with their text, edited by row and column from the bar over the selection or `board.tableEdit`, typed into with a double-click. Boards ~35% → ~40% breadth, 30–60 h → 26–54 h; the weighted numbers are unchanged.
- **2026-10-24** · Plugin widgets take text input and a property menu (M11, gap 6, plugin API 4; older plugins run
unchanged): a double-click on a widget's editable field edits it like any text, and when editing ends the value goes
into the widget's state and its plugin redraws it, one undo step (`widget.setText` from scripts and MCP); a widget
can carry a few actions, toggles and colour choices, shown in a small bar over it while it's selected
(`widget.menu`). The Counter example has a label field and a menu (reset, steps of ten, a colour). Plugins & widgets
65%.
- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
- **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged.
- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged.
- **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged.
- **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged.
@@ -144,9 +156,14 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
- **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged.
- **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged.
- **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged.
- **2026-10-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed
filenames open normally; the global `--no-system-fonts` flag also respects the terminator.
Binary regression coverage opens five such native files and checks their JSON info.
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
- **2026-10-16** · Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged.
- **2026-10-15** · Dev Mode tail (M6, gap 16): generated layer trees start with the import lines of the connected components they call, once each per language; Compare changes takes any saved version as the before; a behaviour suite made the Dev Mode canvas inspect-only (no moving, resizing, drawing or editing; menus and shortcuts refuse design edits with a note), added Changed to the ready list and refused statuses on layers inside frames; read-only plugins show their panels from the Inspect tab. Dev Mode 97% / 76%.
- **2026-10-14** · Corner handles win over the corner-radius handles (M1, #292): a press on the inner half of a corner handle's square picked the radius handle 10 px in, so the drag neither resized nor (dragging outward) rounded anything. Inside the drawn square the corner now resizes both sides (in ratio with Shift or the link); further in the radius handle still answers. Regression test in the transform behaviour suite. Numbers unchanged.
- **2026-10-14** · Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged.
@@ -331,8 +348,18 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |
| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
| 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
| 2026-10-24 | minor | Board templates and tables (M12): five built-in templates (`board.template`, `board.templates`, a toolbar picker with pictures) and tables (`board.table`, `board.tableEdit`; rows and columns from the bar over the selection). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) |
| 2026-10-24 | minor | Plugin widgets take text input and a property menu (gap 6, plugin API 4; API 1–3 plugins and existing widgets unchanged): a widget's text layer can be an editable field, edited on the canvas with a double-click like any text (or `widget.setText`), its value cleaned and capped at 1,000 characters, written into the widget's state under the field's name and the field's text, and the plugin called back to redraw, all as one undo step that syncs in live sessions; a widget can carry a property menu of up to 8 actions, toggles and colour choices (up to 8 colours each), shown in a small bar over the widget while it is selected and driven with `widget.menu`; descriptions, values and menus read back from files are checked, an API 3 plugin that draws a field or a menu is refused, and older FigCraft builds refuse API 4 manifests. The Counter example gained a label field and a menu (reset, steps of ten, a colour); the SDK reads the new events. Hover and timers are still missing. Plugins & widgets 60%/35% → 65%/38%, 8–26 h → 6–22 h; Ecosystem dimension 38% → 40%; weighted totals unchanged at this rounding; full parity ~262–519 h → ~260–515 h |
| 2026-10-24 | minor | Branches in live sessions (gap 17): create, edit, review, update from main and merge while the file is in a session; the branch stays with its author (ids in their own client space, far above the main file's), merging is one synced edit and one undo step, peers' edits merge against the branch's own base and conflicts resolve as offline, a view link refuses merging, a branch made outside the session that adds layers is refused with a reason; a branch's history gets entries for its creation and updates from main; no wire format change. Left: branches others in the session can see, text runs item by item. Numbers unchanged |
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged |
| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged |
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
@@ -395,6 +422,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
| 2026-10-17 | minor | Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. |
| 2026-10-17 | minor | Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. |
| 2026-10-16 | minor | Adobe Fonts found on macOS (M7, #298): fonts activated through Adobe Fonts rendered in the fallback font without a warning, because Creative Cloud keeps them in its own folder as hidden files named by number, which the font scan never read. The macOS scan now includes `~/Library/Application Support/Adobe/CoreSync/plugins/livetype`, and files without an extension are indexed when they start with a font signature. Fonts are read in place. Regression test in the font database tests; docs/cli.md lists the folder. Numbers unchanged |
| 2026-10-16 | minor | Community fixes from #478 up (batches #591, #598, #602; 69 PRs) and a control-channel fix: a line that isn't JSON now ends a `--control` connection, so a web page can't drive the app through a browser request (#589). Highlights: page copies keep navigation, flows, prototype settings and guides; Find and Replace keeps line endings and case-insensitive matches; text hyperlinks survive import and export; recent colours record only what was picked; MCP screenshots respect `maxSize`; Swedish catalog (UI strings), Czech plurals; bounded canvas overscan while panning; Korean fallback at the run's weight; many vector-edit, guide, page and .fig round-trip fixes. Numbers unchanged |
| 2026-10-15 | minor | Drop files anywhere to open them (M7): dragging files over the window shows a "Drop to open" (home) or "Drop to import" (canvas) overlay. On the home screen each supported file opens as a document of its own and other files are turned away with a toast; on the canvas the files import at the drop point. Both wait behind a small card that is on screen before a large file blocks the window. A canvas import stays with the document and page that received the drop even if the tab or page changes meanwhile, and is cancelled with a message if that document or page is gone. Numbers unchanged. |
| 2026-10-15 | minor | M7: system font discovery skips entries observed as non-regular files before opening them, avoiding the reproduced named-pipe stall. Regular-file symlinks remain supported; directory depth, font limits and priorities are unchanged. Numbers unchanged. |
+4
View File
@@ -20,6 +20,10 @@ impl Args {
let mut bad: Option<CliError> = None;
let mut it = argv.iter();
while let Some(arg) = it.next() {
if arg == "--" {
a.positional.extend(it.cloned());
break;
}
let name = if let Some(n) = arg.strip_prefix("--") {
n
} else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) {
+98 -18
View File
@@ -5,7 +5,7 @@ use std::path::Path;
use std::process::ExitCode;
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
use figcraft_engine::Session;
use figcraft_engine::{BatchScope, BatchStep, Session};
use serde_json::{Value, json};
use crate::{CliError, CliResult};
@@ -292,8 +292,29 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
fn refuse_dropping_images(input: &str, out: &str, images_left_out: usize) -> CliResult<()> {
if images_left_out > 0 && figcraft_engine::file_access::same_file(input, out) {
return Err(CliError::Failed(format!(
"{out} wasn't written: {images_left_out} of its images weren't loaded (together they pass what FigCraft reads from one file), and writing over it would delete them. Write to another path; {input} keeps them."
)));
}
Ok(())
}
fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &figcraft_engine::DocState, out: &str) -> CliResult<()> {
if let Some((input, images_left_out)) = input {
refuse_dropping_images(input, out, images_left_out)?;
}
if let Some(images_left_out) = active.images_lost_saving_to(out) {
refuse_dropping_images(out, out, images_left_out)?;
}
Ok(())
}
pub fn convert(input: &str, out: &str) -> CliResult<ExitCode> {
let doc = load(input)?;
let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?;
refuse_dropping_images(input, out, loaded.images_past_budget)?;
let doc = loaded.doc;
let page = doc
.current_page
.filter(|p| doc.pages.contains(p))
@@ -318,7 +339,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul
}
/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines.
pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
let steps: Vec<Value> = match serde_json::from_str::<Value>(text) {
Ok(Value::Array(a)) => a,
Ok(Value::Object(o)) if o.contains_key("commands") => {
@@ -343,7 +364,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
Some(p @ Value::Object(_)) => p.clone(),
Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")),
};
Ok((cmd.to_string(), params))
let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?;
Ok(BatchStep { name, ..BatchStep::new(cmd, params) })
})
.collect()
}
@@ -352,43 +374,44 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut s = match input {
let (mut s, input_images_left_out) = match input {
Some(path) => {
let mut s = Session::empty();
s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?;
s
let left_out = s.ds().map_err(|e| CliError::Failed(e.to_string()))?.images_lost_saving_to(path).unwrap_or(0);
(s, left_out)
}
None => Session::new(),
None => (Session::new(), 0),
};
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
// (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
// a step that fails takes the whole script back instead of leaving the earlier steps applied.
// The result lines are held back until the script is through, so none of them reports work that
// was rolled back.
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
// (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the
// steps before it, and a step that fails takes the whole script back instead of leaving the
// earlier steps applied. The result lines are held back until the script is through, so none of
// them reports work that was rolled back.
let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut failed_at = 0;
let outcome = s.grouped(&format!("run {script}"), |s| {
for (i, (cmd, params)) in steps.iter().enumerate() {
for (i, step) in steps.iter().enumerate() {
failed_at = i;
let v = s.batch_step(&results, i, cmd, params)?;
s.batch_step(&mut scope, &step.command, &step.params)?;
s.ui_requests.clear();
results.push(v);
}
Ok(())
});
if let Err(e) = outcome {
let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str());
let i = failed_at;
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
eprintln!("the script's document changes were rolled back");
return Ok(ExitCode::from(1));
}
for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() {
println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v}));
}
if let Some(out) = out {
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?;
if ext(out) == "figcraft" {
// Through the engine, so the version history is kept.
write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?;
@@ -511,6 +534,43 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
/// A script names its steps with `as` and reads paths into their results, like `command.batch`.
#[test]
fn a_script_refers_to_named_steps_and_paths() {
let dir = temp_dir("run-ref");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}}
]"#,
)
.unwrap();
let out = dir.join("out.figcraft");
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
let mut s = Session::empty();
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
let doc = s.doc().unwrap();
let top = doc.node(s.page().unwrap()).unwrap().children.clone();
let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect();
assert_eq!(names, ["Card", "Copy"]);
std::fs::remove_dir_all(dir).unwrap();
}
/// Two steps with one name fail before anything runs.
#[test]
fn a_script_with_a_name_twice_runs_nothing() {
let dir = temp_dir("run-dup-name");
let script = dir.join("script.json");
std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap();
let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") };
assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}");
std::fs::remove_dir_all(dir).unwrap();
}
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
#[test]
fn a_failed_step_leaves_no_output() {
@@ -530,4 +590,24 @@ mod tests {
assert!(!out.exists(), "a script that failed writes nothing");
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn a_script_output_over_its_input_is_refused_whichever_tab_the_script_left_active() {
let dir = temp_dir("script-images-left-out");
let input = dir.join("big.figcraft");
std::fs::write(&input, b"kept").unwrap();
let link = dir.join("link.figcraft");
std::fs::hard_link(&input, &link).unwrap();
let (input, link, other) = (input.to_str().unwrap(), link.to_str().unwrap(), dir.join("other.figcraft"));
let duplicate = Session::new();
let active = duplicate.ds().unwrap();
for out in [input, link] {
let refused = refuse_dropping_images_from_script(Some((input, 3)), active, out);
assert!(matches!(&refused, Err(CliError::Failed(m)) if m.contains("3 of its images")), "{out}");
}
assert!(refuse_dropping_images_from_script(Some((input, 3)), active, other.to_str().unwrap()).is_ok());
assert!(refuse_dropping_images_from_script(Some((input, 0)), active, input).is_ok());
std::fs::remove_dir_all(dir).unwrap();
}
}
+30 -18
View File
@@ -34,9 +34,9 @@ fonts built into FigCraft, for renders that match on every machine. Otherwise in
indexed the first time text is laid out, as in the app.
Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what
wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file
per top-level frame into the <out> directory, or one file when <out> has an image extension or --node
is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an
image layer). `export` writes one file per top-level frame into the <out> directory, or one file when
<out> has an image extension or --node is given (`fig`, or an <out> ending in .fig: the whole file as .fig, experimental; other apps may
not open every file); PDFs take print options (a bleed like 3mm or 0.125in,
crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of
{\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step.
@@ -108,15 +108,27 @@ impl From<String> for CliError {
pub type CliResult<T> = Result<T, CliError>;
fn run(argv: &[String]) -> CliResult<ExitCode> {
let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts");
let argv: Vec<String> = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect();
if system_fonts {
fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Args> {
let flags = [flags, &["no-system-fonts"]].concat();
let args = Args::parse(argv, valued, &flags)?;
if !args.flag("no-system-fonts") {
// Text lays out with the installed fonts, like the desktop app, so headless renders and
// auto layout sizes match what users see.
figcraft_text::scan_system_fonts_on_demand();
} else {
// Nothing scans them then, not even a `.sketch` import matching its fonts.
figcraft_text::opt_out_of_system_fonts();
}
let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) };
Ok(args)
}
fn run(argv: &[String]) -> CliResult<ExitCode> {
let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else {
return Err(CliError::Usage("missing subcommand".into()));
};
let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?;
// Keep leading global flags with the subcommand's options, where values and `--` are parsed.
let rest: Vec<String> = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect();
match cmd.as_str() {
"help" | "--help" | "-h" => {
println!("{USAGE}");
@@ -127,13 +139,13 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"info" => {
let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?;
let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?;
let file = a.one_positional("info <file>")?;
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let file = a.one_positional("export <file> -o <out>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
let target =
@@ -141,7 +153,7 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
let a = Args::parse(rest, &ROOT_FLAGS, &[])?;
let a = parse_args(&rest, &ROOT_FLAGS, &[])?;
match a.positional.as_slice() {
[i, o] => confined(&a, || files::convert(i, o)),
_ => Err(CliError::Usage("convert <in> <out>".into())),
@@ -149,19 +161,19 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
"render" => {
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let a = parse_args(&rest, &valued, &[])?;
let file = a.one_positional("render <file> -o <out.png>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
}
"run" => {
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let a = parse_args(&rest, &valued, &[])?;
let script = a.one_positional("run <script.json|.jsonl>")?;
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
}
"commands" => {
let a = Args::parse(rest, &["filter"], &["json"])?;
let a = parse_args(&rest, &["filter"], &["json"])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("commands takes no positional arguments".into()));
}
@@ -170,7 +182,7 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
"mcp" => {
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let a = parse_args(&rest, &valued, &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
@@ -185,7 +197,7 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
mcp(attach.as_deref(), roots_of(&a)?)
}
"plugin" => {
let a = Args::parse(rest, &["server"], &[])?;
let a = parse_args(&rest, &["server"], &[])?;
match a.positional.as_slice() {
[verb, folder] if verb == "publish" => {
let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?;
@@ -195,10 +207,10 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
}
"perf" => {
let a = Args::parse(rest, &["layers", "instances"], &[])?;
let a = parse_args(&rest, &["layers", "instances"], &[])?;
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
let instances: Vec<usize> = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
perf::run(&sizes, &instances, system_fonts)
perf::run(&sizes, &instances, !a.flag("no-system-fonts"))
}
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
}
+32
View File
@@ -13,6 +13,38 @@ fn tmp(name: &str) -> std::path::PathBuf {
d
}
#[test]
fn option_terminator_allows_dash_prefixed_filenames() {
let dir = tmp("option-terminator");
let doc = figcraft_doc::Document::new("Dash-prefixed file");
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
for name in ["--drawing.figcraft", "-h", "--json", "--no-system-fonts", "--"] {
std::fs::write(dir.join(name), &bytes).unwrap();
let output = bin().current_dir(&dir).args(["info", "--json", "--no-system-fonts", "--", name]).output().unwrap();
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["file"], name);
assert_eq!(info["name"], "Dash-prefixed file");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn global_font_flag_respects_option_values() {
for filter in ["--", "--no-system-fonts"] {
let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap();
assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr));
assert!(serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap().is_array());
}
}
#[test]
fn global_font_flag_can_precede_the_subcommand() {
let output = bin().args(["--no-system-fonts", "commands", "--json"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(!serde_json::from_slice::<Vec<serde_json::Value>>(&output.stdout).unwrap().is_empty());
}
#[test]
fn bad_arguments_exit_2_without_panicking() {
for args in [
+26
View File
@@ -66,3 +66,29 @@ fn info_lists_the_fonts_a_file_uses_that_are_missing() {
let shown = String::from_utf8_lossy(&out.stdout);
assert!(shown.contains("missing font \"FigCraft Missing CLI\": 2 text layers"), "{shown}");
}
/// A synthetic `.sketch` file: one page with one Helvetica text.
const SKETCH: &str = "UEsDBBQAAAAIAOFFVl2B8d55RwAAAFcAAAANAAAAZG9jdW1lbnQuanNvbqtWik/OSSwuVrJSUErJTy7NTc0rUdJRUCpITE8FCUZXIynwDfYK9vdzy8xJDUpNSy1KzUtOBamNL0pNA0mD9egHKNXG1gIAUEsDBBQAAAAIAOFFVl0B3thgPQAAAEkAAAAJAAAAbWV0YS5qc29uq1ZKLChQslJQSs7P1UvKz0jNzUzMS85PycxL1yvOTi1JzjBW0lEAKQpLLSrOzM8DqTUzAYmVwQUMjUxrAVBLAwQUAAAACADhRVZdo5nc2bkAAABnAQAADAAAAHBhZ2VzL1AuanNvbm1PQQrCMBD8StmzB1voxZsgUg+CUG8iJU3XNhITaVZtLf27SWylFEPIzgyzs5sOMi6ZMbAK4M5KhEUAhc50fkVOu42TD05T7IaeDB7JWqxd16mbJBA29CfhOE3w5FJ/WQeNfZdWaYf6EgVVFseOVCjKiiwL495SRlSL/EFYpBao0geYEUIiYGoatpOaMxJaDfkSVekHRHNvB/t0PZuw1Yp+mvfMWsZfJSifSIIzt4IRbyeGUW/P2d0PUEsBAhQDFAAAAAgA4UVWXYHx3nlHAAAAVwAAAA0AAAAAAAAAAAAAAIABAAAAAGRvY3VtZW50Lmpzb25QSwECFAMUAAAACADhRVZdAd7YYD0AAABJAAAACQAAAAAAAAAAAAAAgAFyAAAAbWV0YS5qc29uUEsBAhQDFAAAAAgA4UVWXaOZ3Nm5AAAAZwEAAAwAAAAAAAAAAAAAAIAB1gAAAHBhZ2VzL1AuanNvblBLBQYAAAAAAwADAKwAAAC5AQAAAAA=";
/// Opening a `.sketch` file (whose import matches fonts against the installed ones) keeps to
/// `--no-system-fonts` too: the run still knows only the built-in families.
#[test]
fn sketch_imports_keep_to_no_system_fonts() {
let d = std::env::temp_dir().join(format!("figcraft-cli-fonts-sketch-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
let script = d.join("open.json");
let steps = json!([{"command": "file.open", "params": {"data": SKETCH, "name": "x.sketch"}}, {"command": "text.fonts"}]);
std::fs::write(&script, steps.to_string()).unwrap();
let out = Command::new(env!("CARGO_BIN_EXE_figcraft-cli"))
.arg("run")
.arg(&script)
.arg("--no-system-fonts")
.env_remove("FIGCRAFT_NO_SYSTEM_FONTS")
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines: Vec<Value> = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines[0]["ok"], json!(true), "{lines:?}");
let fonts: Vec<String> = serde_json::from_value(lines[1]["result"].clone()).unwrap();
assert_eq!(fonts, families(&["--no-system-fonts"], false), "the import didn't scan the system fonts");
}
+5 -4
View File
@@ -128,13 +128,14 @@ fn u64_at(b: &[u8], at: usize) -> Option<u64> {
/// A snapshot file's bytes.
pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result<Vec<u8>, String> {
let body = message::encode(state).map_err(|e| e.to_string())?;
let mut out = Vec::with_capacity(SNAP_HEADER + body.len());
let mut out = Vec::with_capacity(SNAP_HEADER);
out.extend_from_slice(SNAP_MAGIC);
out.push(FORMAT);
out.extend_from_slice(&generation.to_le_bytes());
out.extend_from_slice(&crc(&body).to_le_bytes());
out.extend_from_slice(&body);
out.extend_from_slice(&0u32.to_le_bytes());
let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?;
let sum = crc(&out[SNAP_HEADER..]);
out[SNAP_HEADER - 4..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes());
Ok(out)
}
+13 -6
View File
@@ -658,7 +658,10 @@ impl Store {
let dir = self.dir(&id);
let written = fs::create_dir_all(&dir)
.map_err(|e| e.to_string())
.and_then(|()| disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()))
.and_then(|()| {
disk::sync_dir(Some(&self.root.join(FILES)));
disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string())
})
.and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string()))
.and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()]))
// Last: a folder without its description is an upload cut short.
@@ -1480,14 +1483,18 @@ impl Backend for Store {
}
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| {
let _ = flush_log(file, log);
log.dirty
let compact = self.files.get_mut(file).is_some_and(|entry| {
let snapshot_seq = entry.meta.seq;
entry.log.as_mut().is_some_and(|log| {
// Issued client ids and refusals are durable in the log; only edits need a
// new image-bearing snapshot. Still try a snapshot if syncing the log failed.
flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq)
})
});
if dirty {
if compact {
self.compact(file, server);
}
// It closes anyway, everything in its snapshot: a compaction cut short needs no report.
// It closes anyway, with its state in the snapshot and durable log.
self.cut_short.retain(|f| f != file);
if let Some(e) = self.files.get_mut(file) {
e.log = None;
+41
View File
@@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() {
assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows");
}
#[test]
fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() {
let dir = TempDir::new("read-only-reconnects");
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
let (id, original) = {
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Read only");
let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap();
(id, bytes)
};
let mut last_client = 0;
for _ in 0..6 {
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let mut server = store.open(&id).unwrap();
let client = join(&mut server, Access::View);
assert!(client.id() > last_client, "issued ids survive closing and reopening the store");
last_client = client.id();
store.changed(&id, &mut server);
// Idle must sync even if no periodic flush ran before the connection closed.
assert!(store.idle(&id, &mut server));
assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot");
assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original);
let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap();
assert!(!log.entries.is_empty());
assert_eq!(server.seq(), 0);
}
}
#[test]
fn a_long_log_is_compacted_while_open() {
let dir = TempDir::new("compact");
@@ -252,6 +280,19 @@ fn temporary_directories_are_removed() {
assert!(!path.exists());
}
#[test]
fn snapshot_encoding_keeps_the_existing_framing_and_checksum() {
let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap();
let body = figcraft_collab::message::encode(&state).unwrap();
let mut legacy = b"FCSN\x01".to_vec();
legacy.extend_from_slice(&37u64.to_le_bytes());
legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes());
legacy.extend_from_slice(&body);
let encoded = disk::encode_snapshot(37, &state).unwrap();
assert_eq!(encoded, legacy);
assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state));
}
#[test]
fn random_bytes_never_panic_the_readers() {
let mut rng = Rng::new(7);
+3 -3
View File
@@ -2,12 +2,12 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover">
<title>FigCraft</title>
<link data-trunk rel="rust" data-bin="figcraft-web" data-cargo-profile="web-release" data-wasm-opt="z" data-wasm-opt-params="--enable-bulk-memory --enable-bulk-memory-opt --enable-nontrapping-float-to-int --enable-sign-ext --enable-reference-types --enable-multivalue" data-weak-refs />
<style>
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #f5f5f5; }
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; overscroll-behavior: none; background: #f5f5f5; }
#figcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; touch-action: none; }
#figcraft_loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
color: #6b6b6b; font: 14px system-ui, sans-serif; pointer-events: none; }
@media (prefers-color-scheme: dark) {
+9 -1
View File
@@ -69,7 +69,15 @@ struct WebShell {
}
impl eframe::App for WebShell {
// `FigApp::ui` runs its own per-frame logic first (fonts, theme), like the desktop app.
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
/// not repeat it.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.fig.ui(ui);
}
+8 -1
View File
@@ -21,7 +21,7 @@ figcraft-text = { workspace = true }
# craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable.
craft-window = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
@@ -33,8 +33,15 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
# The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language
# (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad).
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
# The system colour scheme (org.freedesktop.portal.Settings): winit reports no theme on Linux and
# the BSDs, so the interface's "system" mode follows the portal's instead. zbus comes in through
# accesskit already; this only makes it direct.
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft,
# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs
+1
View File
@@ -1,6 +1,7 @@
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
//! (no-op elsewhere).
fn main() {
println!("cargo::rustc-check-cfg=cfg(windows_slim_errors)");
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os == "windows" {
+39 -6
View File
@@ -6,7 +6,7 @@ use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use figcraft_ui_egui::ControlRequest;
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
use serde_json::{Value, json};
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
@@ -21,6 +21,34 @@ const TIMEOUT: Duration = Duration::from_secs(60);
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// Why a request's transport wait ended without a control reply.
#[derive(Debug, PartialEq, Eq)]
enum WaitFailure {
Timeout,
/// The UI side dropped its sender before replying (the app is closing).
Disconnected,
}
/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses.
/// A commit that got the gate first has already queued its reply: consume that reply instead of
/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing.
fn wait_for_reply(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate, timeout: Duration) -> Result<ControlReply, WaitFailure> {
match rx.recv_timeout(timeout) {
Ok(reply) => Ok(reply),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected),
}
}
/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically
/// against a gate whose commit already happened.
fn after_timeout(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate) -> Result<ControlReply, WaitFailure> {
match gate.cancel_if_open() {
RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected),
RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout),
}
}
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
target.retain(|request| !request.is_cancelled());
@@ -69,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut
}
}
/// Serve one control connection: read bounded JSON lines, reject oversized frames and a
/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input
/// answers with an error and closes the connection so HTTP body lines cannot become commands.
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
out.set_read_timeout(Some(timeout))?;
out.set_write_timeout(Some(timeout))?;
@@ -96,7 +127,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = v.get("params").cloned().unwrap_or(json!({}));
let (tx, rx) = std::sync::mpsc::channel();
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
let gate = RequestGate::new();
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
pending.retain(|request| !request.is_cancelled());
if pending.len() >= MAX_QUEUED_REQUESTS {
@@ -104,20 +135,22 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
continue;
}
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() });
drop(pending);
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
match rx.recv_timeout(timeout) {
match wait_for_reply(&rx, &gate, timeout) {
Ok(r) => match r.result {
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
},
Err(_) => {
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
Err(WaitFailure::Timeout) => {
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
}
Err(WaitFailure::Disconnected) => {
json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"})
}
}
}
Err(e) => {
+52 -2
View File
@@ -28,8 +28,9 @@ fn reply(reader: &mut BufReader<TcpStream>) -> Value {
serde_json::from_str(&line).unwrap()
}
/// Build a `ui.inspect` control request with a throwaway reply channel.
fn request() -> ControlRequest {
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() }
}
#[test]
@@ -115,6 +116,52 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
worker.join().unwrap().unwrap();
}
/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's
/// timeout branch consumes that reply and leaves the state `Replied`.
#[test]
fn a_committed_reply_wins_over_the_transport_timeout() {
let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id()));
let path = dir.join("shot.png");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel();
// The UI's final commit gets the gate first: the file and reply are both committed before the
// transport's timeout branch can look at the gate.
let committed = gate.commit(|| {
std::fs::write(&path, b"png").unwrap();
tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap();
});
assert!(committed.is_some());
assert!(path.is_file());
let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed");
assert_eq!(reply.id, json!(7));
assert!(reply.result.is_ok());
assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit");
assert!(path.is_file(), "a winning commit keeps its file");
let _ = std::fs::remove_dir_all(&dir);
}
/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate.
#[test]
fn a_timeout_cancels_an_open_request() {
let gate = RequestGate::new();
let (_tx, rx) = mpsc::channel::<ControlReply>();
assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout)));
assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout");
}
/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not
/// cancelled.
#[test]
fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel::<ControlReply>();
drop(tx);
assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected)));
assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision");
}
#[test]
fn idle_and_partial_frame_connections_expire() {
for bytes in [b"".as_slice(), b"{\"id\":"] {
@@ -227,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() {
worker.join().unwrap().unwrap();
}
/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted
/// and answered, and a window that finally draws discards its expired command before
/// dispatch.
#[test]
fn timed_out_requests_release_a_full_deferred_ui_queue() {
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
@@ -262,7 +312,7 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
// Include expired requests in the socket queue, too: admission must reclaim their slots.
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
let r = request();
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
r.state.cancel_if_open();
r
}));
let (mut client, server) = pair();
+41 -15
View File
@@ -15,6 +15,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
// A missing Windows GNU flag re-enables the COM error-info lookup that crashes while AccessKit
// reports a removed UI Automation node. Catch build configurations that drop the workaround.
#[cfg(all(target_os = "windows", target_env = "gnu", not(windows_slim_errors)))]
compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Automation responsive");
#[cfg(target_os = "macos")]
mod apple_events;
mod args;
@@ -62,6 +67,9 @@ struct App {
}
impl eframe::App for App {
/// Native eframe logic callback: store the first context, drain control requests, run
/// guarded background work, poll macOS open events, handle close requests and title
/// changes, and schedule recovery repaints on the app clock.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
{
let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -71,7 +79,9 @@ impl eframe::App for App {
}
let title = with_app(|fig| {
control_server::drain_queue(&self.queue, &mut fig.control_queue);
figcraft_ui_egui::control::drain_hidden(fig, ctx);
// Phase-safe background work: control queues, autosave/recovery deadlines, live polling.
// It runs on logic-only callbacks too, so it never reads UI input.
figcraft_ui_egui::windows::guard(fig, ctx, |fig| fig.background_tick(ctx));
#[cfg(target_os = "macos")]
apple_events::poll(&self.apple_events, fig, ctx);
// Closing the window asks about unsaved changes (and keeps the app running while other
@@ -86,8 +96,14 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.title = title;
}
let now = ctx.input(|i| i.time);
if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
// Recovery deadlines use the same monotonic clock as the app's background work: a
// logic-only tick leaves `ctx.input(i.time)` stale.
if let Some(wait) = with_app(|fig| {
let now = fig.now_secs();
recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))
})
.flatten()
{
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
}
}
@@ -117,6 +133,11 @@ impl eframe::App for App {
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FIGCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
@@ -149,21 +170,23 @@ fn main() -> eframe::Result {
// System fonts: index them in the background, then wake the UI (an idle window draws no frames)
// to lay text out again with them.
let ctx_slot: Arc<Mutex<Option<egui::Context>>> = Arc::new(Mutex::new(None));
let fonts_slot = ctx_slot.clone();
// Wakes the window (once it is there) from another thread.
let wake = |slot: &Arc<Mutex<Option<egui::Context>>>| {
let slot = slot.clone();
move || {
if let Some(c) = slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
}
};
let fonts_wake = wake(&ctx_slot);
std::thread::spawn(move || {
figcraft_text::scan_system_fonts();
if let Some(c) = fonts_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
fonts_wake();
});
let mut session = Session::empty();
// Live sessions: the network threads wake the window when something arrives.
let wake_slot = ctx_slot.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(move || {
if let Some(c) = wake_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
}))));
session.live.install(Box::new(figcraft_collab_net::NetLink::new(Arc::new(wake(&ctx_slot)))));
for f in &args.files {
if let Err(e) = session.execute("file.open", &json!({"path": f})) {
log::error!("{f}: {e}");
@@ -175,6 +198,7 @@ fn main() -> eframe::Result {
// The preferences first: they hold the online files a recovered document may be one of.
let recovery = recovery::Shared::default();
let svc = services::Desktop::load(recovery.clone());
svc.follow_system_dark(wake(&ctx_slot));
if let Some(prefs) = svc.prefs.clone() {
session.prefs = prefs;
}
@@ -199,6 +223,7 @@ fn main() -> eframe::Result {
// open (no frame then).
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
let mut fig = FigApp::new(session, Box::new(svc));
fig.set_startup_update_check(true);
fig.automation_roots = roots;
fig.ui.home = false;
// Files named on the command line are the user's too: their missing fonts are listed.
@@ -244,14 +269,15 @@ fn main() -> eframe::Result {
let result = eframe::run_native(
"FigCraft",
options,
Box::new(move |_cc| {
Box::new(move |cc| {
with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx));
Ok(Box::new(App {
queue,
ctx_slot,
title: String::new(),
recovery,
#[cfg(target_os = "macos")]
apple_events: apple_events.connect(&_cc.egui_ctx),
apple_events: apple_events.connect(&cc.egui_ctx),
}))
}),
);
+153 -9
View File
@@ -2,8 +2,8 @@
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
use std::sync::{Arc, Mutex};
use figcraft_engine::{Prefs, Session};
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
@@ -11,15 +11,20 @@ use serde_json::{Value, json};
pub struct Desktop {
clipboard: Option<arboard::Clipboard>,
egui_ctx: Option<egui::Context>,
/// Where the preferences live ([`config_dir`]).
dir: Option<PathBuf>,
pub prefs: Option<Prefs>,
/// The system's reduce-motion setting (read in the background at startup).
reduce_motion: Arc<AtomicBool>,
/// The system's colour scheme ([`watch_system_dark`]): 0 unknown, 1 dark, 2 light.
dark: Arc<AtomicU8>,
/// The system's interface language (read at startup).
language: Option<String>,
/// Recovery copies of unsaved files (written at once after a panic).
recovery: crate::recovery::Shared,
/// The folder of the last file or folder a dialog picked, where the next one starts.
last_folder: Arc<Mutex<Option<PathBuf>>>,
}
pub fn config_dir() -> Option<PathBuf> {
@@ -54,7 +59,33 @@ impl Desktop {
let reduce_motion = Arc::new(AtomicBool::new(false));
let flag = reduce_motion.clone();
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery }
let dark = Arc::new(AtomicU8::new(DARK_UNKNOWN));
Self {
clipboard: arboard::Clipboard::new().ok(),
egui_ctx: None,
dir,
prefs,
reduce_motion,
dark,
language: system_language(),
recovery,
last_folder: Arc::default(),
}
}
/// winit reports no system theme on Linux and the BSDs: there the freedesktop colour scheme
/// answers instead, read once and then watched in the background, calling `wake` when it
/// changes (an idle window draws no frames). Elsewhere this does nothing.
pub fn follow_system_dark(&self, wake: impl Fn() + Send + 'static) {
#[cfg(all(unix, not(target_os = "macos")))]
if let Err(e) = std::thread::Builder::new().name("figcraft-theme".into()).spawn({
let dark = self.dark.clone();
move || watch_system_dark(&dark, &wake)
}) {
log::info!("colour scheme: can't start the watcher: {e}");
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
let _ = wake;
}
}
@@ -136,6 +167,71 @@ fn command_says(program: &str, args: &[&str], want: &str) -> bool {
.is_some_and(|o| String::from_utf8_lossy(&o.stdout).trim() == want)
}
/// [`Desktop::dark`]: the colour scheme is unknown, dark, or light.
const DARK_UNKNOWN: u8 = 0;
const DARK_DARK: u8 = 1;
const DARK_LIGHT: u8 = 2;
/// The desktop's colour scheme through the freedesktop settings portal
/// (`org.freedesktop.appearance` / `color-scheme`: 1 dark, 2 light, 0 no preference, which is
/// light), read once and then watched for changes, each one calling `wake`. Without a session bus
/// or a portal the scheme stays unknown and the UI keeps its light fallback.
#[cfg(all(unix, not(target_os = "macos")))]
fn watch_system_dark(dark: &AtomicU8, wake: &dyn Fn()) {
use zbus::zvariant::Value;
const PORTAL: &str = "org.freedesktop.portal.Desktop";
const SETTINGS: &str = "org.freedesktop.portal.Settings";
const APPEARANCE: &str = "org.freedesktop.appearance";
// `Read` (portals before 1.17, which lack `ReadOne`) wraps the value in one more variant.
let scheme = |v: &Value| match v {
Value::U32(x) => *x,
Value::Value(inner) => match **inner {
Value::U32(x) => x,
_ => 0,
},
_ => 0,
};
let store = |v: u32| {
let new = match v {
1 => DARK_DARK,
2 => DARK_LIGHT,
_ => DARK_UNKNOWN,
};
if dark.swap(new, Ordering::Relaxed) != new {
wake();
}
};
let Ok(conn) = zbus::blocking::connection::Builder::session().and_then(|b| b.build()) else {
log::info!("colour scheme: no session bus: the system theme isn't followed");
return;
};
let read = |method: &str| -> zbus::Result<u32> {
let reply = conn.call_method(Some(PORTAL), "/org/freedesktop/portal/desktop", Some(SETTINGS), method, &(APPEARANCE, "color-scheme"))?;
let body = reply.body();
Ok(scheme(&body.deserialize::<Value>()?))
};
match read("ReadOne").or_else(|_| read("Read")) {
Ok(v) => store(v),
Err(e) => log::info!("colour scheme: can't read the system setting: {e}"),
}
let watch = || -> zbus::Result<()> {
let rule =
zbus::MatchRule::builder().msg_type(zbus::message::Type::Signal).sender(PORTAL)?.interface(SETTINGS)?.member("SettingChanged")?.build();
for msg in zbus::blocking::MessageIterator::for_match_rule(rule, &conn, Some(8))? {
let msg = msg?;
let body = msg.body();
let Ok((namespace, key, value)) = body.deserialize::<(String, String, Value<'_>)>() else { continue };
if namespace == APPEARANCE && key == "color-scheme" {
store(scheme(&value));
}
}
Ok(())
};
if let Err(e) = watch() {
log::info!("colour scheme: can't watch the system setting: {e}");
}
}
/// Show `path` in the file manager: selected in Finder and Explorer, its folder elsewhere.
fn reveal(path: &Path) -> std::io::Result<()> {
#[cfg(target_os = "macos")]
@@ -262,25 +358,66 @@ fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
}
}
/// The folder to start the next dialog in once `path` was picked: the file's folder, or the folder
/// picked itself.
pub(crate) fn folder_to_remember(path: &Path, is_folder: bool) -> Option<PathBuf> {
let folder = if is_folder { Some(path) } else { path.parent() };
folder.filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf)
}
/// The remembered folder, unless it is gone since: then the system's own choice.
pub(crate) fn start_folder(last: Option<PathBuf>) -> Option<PathBuf> {
last.filter(|f| f.is_dir())
}
impl Desktop {
/// A dialog that starts in the last folder used. The Linux portal does not remember it between
/// dialogs as macOS and Windows do.
fn dialog_in_last_folder(&self, title: &str) -> rfd::AsyncFileDialog {
let dialog = dialog(title);
match start_folder(self.last_folder.lock().ok().and_then(|l| l.clone())) {
Some(folder) => dialog.set_directory(folder),
None => dialog,
}
}
}
/// `pick`, with the folder of what it picks kept in `last` for the next dialog.
pub(crate) fn remembering(last: Arc<Mutex<Option<PathBuf>>>, pick: DialogFuture, is_folder: bool) -> DialogFuture {
Box::pin(async move {
let path = pick.await?;
if let (Some(folder), Ok(mut last)) = (folder_to_remember(&path, is_folder), last.lock()) {
*last = Some(folder);
}
Some(path)
})
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).add_filter("Supported files", exts).pick_file())
let pick = picked(self.dialog_in_last_folder(title).add_filter("Supported files", exts).pick_file());
remembering(self.last_folder.clone(), pick, false)
}
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
let mut dialog = dialog(title).set_file_name(name);
let mut dialog = self.dialog_in_last_folder(title).set_file_name(name);
for (label, extensions) in save_filters(exts) {
dialog = dialog.add_filter(label, extensions);
}
picked(dialog.save_file())
let pick = picked(dialog.save_file());
remembering(self.last_folder.clone(), pick, false)
}
fn pick_folder(&mut self, title: &str) -> DialogFuture {
picked(dialog(title).pick_folder())
let pick = picked(self.dialog_in_last_folder(title).pick_folder());
remembering(self.last_folder.clone(), pick, true)
}
fn clipboard_set_text(&mut self, s: &str) {
if let Some(c) = self.clipboard.as_mut() {
let _ = c.set_text(s.to_string());
if let Some(ctx) = &self.egui_ctx {
ctx.copy_text(s.to_string());
}
}
fn set_egui_context(&mut self, ctx: &egui::Context) {
self.egui_ctx = Some(ctx.clone());
}
fn clipboard_get_text(&mut self) -> Option<String> {
read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok())
}
@@ -324,6 +461,13 @@ impl Services for Desktop {
fn reduce_motion(&self) -> bool {
self.reduce_motion.load(Ordering::Relaxed)
}
fn system_dark(&self) -> Option<bool> {
match self.dark.load(Ordering::Relaxed) {
DARK_DARK => Some(true),
DARK_LIGHT => Some(false),
_ => None,
}
}
fn gamepad_held(&mut self) -> u32 {
crate::gamepad::held()
}
+25
View File
@@ -117,3 +117,28 @@ fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
let _ = std::fs::remove_dir_all(&dir);
}
/// #1290: the Linux portal opens every dialog where it likes, so the app keeps the folder itself.
#[test]
fn the_next_dialog_starts_in_the_folder_of_the_last_pick() {
use crate::services::{folder_to_remember, remembering, start_folder};
use std::path::PathBuf;
let dir = root("last-folder");
let file = dir.join("Poster.figcraft");
assert_eq!(folder_to_remember(&file, false), Some(dir.clone()), "a file: its folder");
assert_eq!(folder_to_remember(&dir, true), Some(dir.clone()), "a folder pick: the folder itself");
assert_eq!(folder_to_remember(std::path::Path::new("Poster.figcraft"), false), None, "a bare name has no folder");
let last = Arc::new(Mutex::new(None::<PathBuf>));
let mut pick = remembering(last.clone(), Box::pin(std::future::ready(Some(file.clone()))), false);
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
assert_eq!(pick.as_mut().poll(&mut cx), std::task::Poll::Ready(Some(file)));
assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a pick is remembered");
let mut cancelled = remembering(last.clone(), Box::pin(std::future::ready(None)), false);
assert_eq!(cancelled.as_mut().poll(&mut cx), std::task::Poll::Ready(None));
assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a cancelled dialog keeps the folder");
assert_eq!(start_folder(Some(dir.clone())), Some(dir.clone()));
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own");
}
+11 -4
View File
@@ -2,8 +2,15 @@
//! report it, instead of the app quitting silently (#261).
use figcraft_ui_egui::dialogs::window_failed as message;
use figcraft_ui_egui::i18n::tr;
const ERROR: &str = "WGPU error: Failed to create surface for any enabled backend: {}";
const BACKEND_HINT: &str = "WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose.";
const DRIVER_HINT: &str = if cfg!(windows) {
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:"
} else {
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try."
};
#[test]
fn the_message_carries_the_error_and_where_to_report_it() {
@@ -15,9 +22,9 @@ fn the_message_carries_the_error_and_where_to_report_it() {
#[test]
fn without_wgpu_backend_it_suggests_the_driver_and_another_graphics_api() {
let text = message(ERROR, false);
assert!(text.contains("graphics driver"), "{text}");
assert!(text.contains(tr(DRIVER_HINT)), "{text}");
assert!(text.contains("WGPU_BACKEND"), "another graphics API to try: {text}");
assert!(!text.contains("unsetting"), "{text}");
assert!(!text.contains(tr(BACKEND_HINT)), "{text}");
}
#[cfg(windows)]
@@ -30,6 +37,6 @@ fn on_windows_it_shows_how_to_start_with_opengl() {
#[test]
fn with_wgpu_backend_set_it_suggests_unsetting_it() {
let text = message(ERROR, true);
assert!(text.contains("unsetting"), "{text}");
assert!(!text.contains("graphics driver"), "the chosen backend is the likelier cause: {text}");
assert!(text.contains(tr(BACKEND_HINT)), "{text}");
assert!(!text.contains(tr(DRIVER_HINT)), "the chosen backend is the likelier cause: {text}");
}
+6
View File
@@ -72,6 +72,9 @@ sync_github_name = true
[people.AganzeFelicite]
sync_github_name = true
[people.mojtabajafarzadeh]
real_name = "Mojtaba Jafarzadeh"
[people.switch87]
real_name = "Gert Pellin"
display_name = "Gert Pellin"
@@ -80,3 +83,6 @@ sync_github_name = false
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.leandrodslv]
sync_github_name = true
+8 -2
View File
@@ -87,7 +87,10 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() {
let mut late = session("Cy");
late.execute("collab.join", &json!({"code": shown})).unwrap();
pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. }));
assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status);
// The port is free again: another program's host may have taken it, and that host refuses
// the code's secret.
let label = late.live().status.label();
assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}");
}
#[test]
@@ -103,7 +106,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
assert!(host.live().peers.is_empty());
// A typing mistake is caught before anything connects.
let mut typo: Vec<char> = local.code().chars().collect();
typo[7] = if typo[7] == 'A' { 'B' } else { 'A' };
// The last symbol contains only checksum bits. Changing payload bits can collide with
// the eight-bit checksum, so it does not guarantee a malformed code.
let last = typo.last_mut().unwrap();
*last = if *last == 'A' { 'B' } else { 'A' };
let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::<String>()})).unwrap_err().to_string();
assert!(e.contains("typing mistake"), "{e}");
// A view-only code doesn't exist here, and says where it does.
+20
View File
@@ -24,6 +24,12 @@ pub struct NetLimits {
pub hello_timeout: Duration,
/// A connection with no traffic for this long is dead.
pub idle_timeout: Duration,
/// How long a one-shot request connection (hosting, a library, a plugin directory — and, most
/// importantly, publishing a file) waits for the server's reply before giving up. Publishing
/// uploads a whole file and a large one on a slow link takes the server a while to receive and
/// answer, so this is longer than `idle_timeout` (a large project on a slow connection can
/// easily need several minutes).
pub request_reply_timeout: Duration,
/// A quiet client pings this often (keeps it under `idle_timeout`).
pub keepalive: Duration,
/// Messages queued for one client before it counts as stuck and is disconnected.
@@ -68,6 +74,7 @@ impl Default for NetLimits {
burst: 400.0,
hello_timeout: Duration::from_secs(10),
idle_timeout: Duration::from_secs(45),
request_reply_timeout: Duration::from_secs(300),
keepalive: Duration::from_secs(15),
queue: 1024,
write_timeout: Duration::from_secs(10),
@@ -85,3 +92,16 @@ impl Default for NetLimits {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_reply_wait_is_longer_than_idle() {
let d = NetLimits::default();
// Publishing uploads a whole file; a slow link must not cut it off at the session idle
// timeout. Keep the reply wait comfortably above it.
assert!(d.request_reply_timeout > d.idle_timeout);
}
}
+40 -15
View File
@@ -488,6 +488,21 @@ fn silent_connections_time_out() {
host.stop();
}
#[test]
fn connections_without_a_hello_are_told_why_they_end() {
// A socket's read timeout can end a moment before the hello's deadline; the connection still
// gets the refusal. Twelve at once, so that happens to some of them.
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.server.host.parse().unwrap();
std::thread::scope(|s| {
let waits: Vec<_> = (0..12).map(|_| s.spawn(move || Raw::connect(addr).refusal())).collect();
for w in waits {
assert_eq!(w.join().unwrap().as_deref(), Some("no hello in time"));
}
});
host.stop();
}
#[test]
fn guests_reconnect_after_a_network_failure() {
let (mut host, mut hp, invite) = start();
@@ -540,7 +555,9 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() {
let (mut host, mut hp, invite) = start_with(limits, Limits::default());
let probe = Probe::new();
let mut cfg = quick(GuestConfig::new(invite.clone(), "Ada".into()));
cfg.limits = limits;
// The guest pings as often as the host expects. Its own idle timeout stays long: it also bounds
// the wait for the welcome, which a loaded machine can take longer than 600 ms to send.
cfg.limits.keepalive = limits.keepalive;
let _g = Guest::start(cfg, probe.inbox.clone()).unwrap();
let mut gp = probe;
gp.wait("welcome", welcomed);
@@ -672,34 +689,42 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
}
/// How long a connection that never says hello stays open while `poke` sends it something every
/// 300 ms (told whether it's the first time); `None`: still open after 6 s.
/// 300 ms (told whether it's the first time); `None`: still open after 6 s. A thread of its own
/// reads the socket and notes when the host ends the stream, so pongs queued ahead of the close
/// and a slow poke loop don't delay it.
fn pre_hello_lifetime(mut poke: impl FnMut(&mut Raw, bool) -> bool) -> Option<Duration> {
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut r = Raw::connect(addr);
let mut watch = r.0.get_ref().try_clone().unwrap();
let t0 = Instant::now();
let (ended_tx, ended) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
let mut buf = [0u8; 1024];
let until = Instant::now() + WAIT;
loop {
match watch.read(&mut buf) {
Ok(0) => break,
Ok(_) => {}
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) && Instant::now() < until => {}
Err(_) => break,
}
}
let _ = ended_tx.send(Instant::now());
});
let (mut closed, mut first) = (None, true);
while t0.elapsed() < Duration::from_secs(6) {
if !poke(&mut r, std::mem::take(&mut first)) {
closed = Some(t0.elapsed());
break;
}
std::thread::sleep(Duration::from_millis(300));
// Drain what the host sent back (pongs, the close); an error means it hung up.
r.0.get_mut().set_read_timeout(Some(Duration::from_millis(10))).unwrap();
match r.0.read() {
Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => {
closed = Some(t0.elapsed());
break;
}
Err(tungstenite::Error::Io(e)) if !matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
closed = Some(t0.elapsed());
break;
}
_ => {}
if let Ok(at) = ended.recv_timeout(Duration::from_millis(300)) {
closed = Some(at.saturating_duration_since(t0));
break;
}
}
host.stop();
let _ = reader.join();
closed
}
+5 -3
View File
@@ -196,13 +196,15 @@ pub fn plugins(server: &Endpoint, key: &str, req: PluginReq, limits: &NetLimits,
Ok(r)
}
/// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the
/// server a while to keep).
/// A WebSocket to `server` that waits for answers up to the request-reply timeout (a large upload
/// takes the server a while to keep).
fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<Ws, String> {
let (stream, _, _) = open(server, timeout, limits)?;
let (ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message)))
.map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?;
ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?;
// A publish uploads a whole file; a large project on a slow link can take several minutes to
// receive and answer. The wait uses `request_reply_timeout`, not the session `idle_timeout`.
ws.get_ref().set_read_timeout(Some(limits.request_reply_timeout)).map_err(|e| e.to_string())?;
Ok(ws)
}
+18 -10
View File
@@ -195,17 +195,25 @@ impl Duplex {
impl Read for Duplex {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if let Some(d) = self.deadline {
let left = d.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(io::ErrorKind::TimedOut.into());
let n = loop {
if let Some(d) = self.deadline {
let left = d.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(io::ErrorKind::TimedOut.into());
}
self.read.set_read_timeout(Some(left))?;
}
self.read.set_read_timeout(Some(left))?;
}
let n = match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
}?;
let read = match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
};
match read {
// The socket's timeout can run out a moment before the deadline (it counts whole
// milliseconds): wait out the rest, so a read times out only once it has passed.
Err(e) if self.deadline.is_some() && matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {}
read => break read?,
}
};
if let Some(m) = self.meter.as_mut() {
m.add(n)?;
}
+10 -1
View File
@@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 {
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap()
}
/// Asserts two files are hosted and joined independently: each has its own port, session and
/// edits, presence stays in its file's session, and closing one file's session leaves the
/// other live.
#[test]
fn two_files_are_hosted_and_joined_independently() {
let mut host = session("Hana");
@@ -123,8 +126,14 @@ fn two_files_are_hosted_and_joined_independently() {
host.execute("doc.switch", &json!({"index": 0})).unwrap();
assert_eq!(host.live_summary()["link"], a["link"]);
assert_eq!(host.live_summary()["document"], 0);
// Each joined file opens in front when its welcome arrives, and the two joins race: the file
// whose welcome came last is in front.
assert_eq!(guest.ds().unwrap().uid, guest.docs[2].uid, "the file that arrived last is in front");
// Concurrent joins may complete in either order. Put B in front explicitly so the following
// assertions exercise A's background session independent of network timing.
guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap();
assert_eq!(guest.ds().unwrap().uid, gb, "B is in front while A receives edits");
// An edit in A reaches the guest's copy of A only, while B is in front at the guest.
assert_eq!(guest.ds().unwrap().uid, gb, "the file joined last is in front");
let in_a = rect(&mut host);
host.execute("doc.switch", &json!({"index": 1})).unwrap();
let in_b = rect(&mut host);
+9 -2
View File
@@ -223,6 +223,10 @@ pub enum DocProp {
VariableOrder,
/// A design file or a board.
Kind,
/// A board's countdown (`null`: none).
Timer,
/// A board's vote (`null`: none); the ballots are [`Entry::Ballot`]s.
Vote,
}
/// A document-level entry, by id.
@@ -242,6 +246,8 @@ pub enum Entry {
DevSnapshot(NodeId),
/// An image by content hash (value: `{width, height, bytes}` with the encoded bytes in base64).
Image(String),
/// A voter's ballot in a board's vote (value: the layer ids voted on).
Ballot(String),
}
/// One synced value a change writes: a node property, a document-level property or a
@@ -315,7 +321,7 @@ impl Entry {
Entry::Variable(id) => Some(id_space(id, VARIABLE_ID_PREFIX)),
Entry::Collection(id) => Some(id_space(id, COLLECTION_ID_PREFIX)),
Entry::Comment(id) => Some(space_of(*id)),
Entry::DevSnapshot(_) | Entry::Image(_) => None,
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
}
}
@@ -328,6 +334,7 @@ impl Entry {
Entry::Variable(id) => format!("variable {id}"),
Entry::DevSnapshot(id) => format!("ready-for-dev snapshot of {id}"),
Entry::Image(hash) => format!("image {hash}"),
Entry::Ballot(voter) => format!("ballot of {voter}"),
}
}
@@ -338,7 +345,7 @@ impl Entry {
Entry::Comment(_) => Some(Family::Comments),
Entry::Collection(_) => Some(Family::Collections),
Entry::Variable(_) => Some(Family::Variables),
Entry::DevSnapshot(_) | Entry::Image(_) => None,
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
}
}
}
+10
View File
@@ -11,6 +11,16 @@ use crate::message::{
use crate::sim::Rng;
use crate::{Error, Replica, diff};
#[test]
fn encoding_into_a_prefix_keeps_the_wire_body() {
let msg = moved_layer();
let prefix = b"container header".to_vec();
let bytes = crate::message::encode_into(&msg, prefix.clone()).unwrap();
assert_eq!(&bytes[..prefix.len()], prefix);
assert_eq!(&bytes[prefix.len()..], encode(&msg).unwrap());
assert_eq!(decode::<ClientMsg>(&bytes[prefix.len()..], bytes.len()).unwrap(), msg);
}
/// A typical edit: one layer dragged (its transform).
fn moved_layer() -> ClientMsg {
let mut doc = Document::new("f");
+12 -1
View File
@@ -211,6 +211,8 @@ doc_scalars! {
AnnotationCategories => annotation_categories,
Libraries => libraries,
Kind => kind,
Timer => timer,
Vote => vote,
}
/// Keyed maps (no order): changed and added entries are put, missing ones removed.
@@ -250,7 +252,8 @@ pub(crate) fn diff(a: &Document, b: &Document, out: &mut Vec<Change>) -> Result<
|k| Entry::Image(k.clone()),
image_json,
out,
)
)?;
diff_map(&a.votes, &b.votes, |x, y| x == y, |k| Entry::Ballot(k.clone()), Json::of, out)
}
/// Apply a document-level change. Returns whether it can change which pages are listed (the
@@ -286,6 +289,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
let img = image_of(value)?;
doc.images.insert(k.clone(), img);
}
Entry::Ballot(k) => {
doc.votes.insert(k.clone(), value.read("ballot")?);
}
},
Change::Remove { entry } => match entry {
Entry::Style(k) => STYLES.remove(doc, k),
@@ -298,6 +304,9 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
Entry::Image(k) => {
doc.images.remove(k);
}
Entry::Ballot(k) => {
doc.votes.remove(k);
}
},
_ => return Err(Error::Invalid("not a document-level change".into())),
}
@@ -334,6 +343,7 @@ pub(crate) fn entry_value(doc: &Document, entry: &Entry) -> Result<Option<Json>,
Entry::Variable(k) => doc.variables.variables.iter().find(|v| v.id == *k).map(Json::of),
Entry::DevSnapshot(k) => doc.dev_snapshots.get(k).map(|s| Json::of(s.as_ref())),
Entry::Image(k) => doc.images.get(k).map(image_json),
Entry::Ballot(k) => doc.votes.get(k).map(Json::of),
};
value.transpose()
}
@@ -347,6 +357,7 @@ pub(crate) fn has(doc: &Document, entry: &Entry) -> bool {
Entry::Variable(k) => doc.variables.variables.iter().any(|v| v.id == *k),
Entry::DevSnapshot(k) => doc.dev_snapshots.contains_key(k),
Entry::Image(k) => doc.images.contains_key(k),
Entry::Ballot(k) => doc.votes.contains_key(k),
}
}
+1
View File
@@ -92,6 +92,7 @@ impl fmt::Display for Key {
Entry::Variable(k) => write!(f, "variable/{k}"),
Entry::DevSnapshot(k) => write!(f, "devSnapshot/{k}"),
Entry::Image(k) => write!(f, "image/{k}"),
Entry::Ballot(k) => write!(f, "ballot/{k}"),
},
}
}
+7 -5
View File
@@ -739,11 +739,13 @@ pub fn utc_date(secs: u64) -> String {
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
pub fn encode<T: Serialize>(msg: &T) -> Result<Vec<u8>, Error> {
let body = postcard::to_allocvec(msg).map_err(|e| Error::Encode(e.to_string()))?;
let mut out = Vec::with_capacity(body.len() + 1);
out.push(WIRE_VERSION);
out.extend(body);
Ok(out)
encode_into(msg, Vec::new())
}
/// Append the binary form of `msg` to an owned prefix, without a second payload-sized buffer.
pub fn encode_into<T: Serialize>(msg: &T, mut prefix: Vec<u8>) -> Result<Vec<u8>, Error> {
prefix.push(WIRE_VERSION);
postcard::to_extend(msg, prefix).map_err(|e| Error::Encode(e.to_string()))
}
/// Read a binary message of at most `max_bytes` (untrusted input: never panics, allocates at most
+10 -2
View File
@@ -122,7 +122,8 @@ impl Color {
/// Parse hex the way the colour field does: `#` optional, 1, 2, 3, 4, 6 or 8 digits
/// (`F` → `FFFFFF`, `AB` → `ABABAB`, `ABC` → `AABBCC`).
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.trim().trim_start_matches('#');
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
if !s.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
@@ -194,7 +195,14 @@ impl Color {
/// CSS `rgba(...)` / hex text for copy-as-CSS.
pub fn to_css(self) -> String {
let [r, g, b, _] = self.to_rgba8();
if self.a >= 0.999 { self.to_css_hex() } else { format!("rgba({r}, {g}, {b}, {:.2})", self.a) }
if self.a >= 0.999 {
return self.to_css_hex();
}
// Two decimals when they keep the 8-bit alpha, three otherwise (near-transparent colours).
let a8 = |a: f32| (a.clamp(0.0, 1.0) * 255.0).round() as u8;
let short = format!("{:.2}", self.a);
let alpha = if short.parse::<f32>().is_ok_and(|v| a8(v) == a8(self.a)) { short } else { format!("{:.3}", self.a) };
format!("rgba({r}, {g}, {b}, {alpha})")
}
/// Linear interpolation in sRGB (as gradients render).
+17
View File
@@ -115,6 +115,15 @@ fn infinite_channels_clamp() {
assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0));
}
#[test]
fn hex_allows_at_most_one_leading_hash() {
assert!(Color::from_hex("##ff0000").is_none());
assert!(Color::from_hex("###fff").is_none());
assert!(Color::from_hex_or_name("##ff0000").is_none());
assert_eq!(Color::from_hex("#ff0000").unwrap().to_hex(), "FF0000");
assert_eq!(Color::from_hex("ff0000").unwrap().to_hex(), "FF0000");
}
#[test]
fn malformed_alpha_or_extra_args_rejected() {
assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None);
@@ -124,3 +133,11 @@ fn malformed_alpha_or_extra_args_rejected() {
assert_eq!(Color::parse_css("rgb(1,2,3)").unwrap().a, 1.0);
assert_eq!(Color::parse_css("rgba(1,2,3,0.5)").unwrap().a, 0.5);
}
#[test]
fn to_css_round_trips_every_alpha() {
for a in 0..=255u8 {
let c = Color::from_rgba8(10, 20, 30, a);
assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8(), c.to_rgba8(), "alpha {a}: {}", c.to_css());
}
}
+23 -4
View File
@@ -7,7 +7,7 @@
use figcraft_color::Color;
use figcraft_geom::kurbo::{Ellipse, RoundedRect};
use figcraft_geom::{BezPath, Point, Rect, Shape};
use figcraft_geom::{BezPath, Point, Rect, Shape, Vec2};
use serde::{Deserialize, Serialize};
use crate::NodeId;
@@ -57,13 +57,16 @@ pub enum BoardItem {
},
/// A marker or highlighter stroke (what the eraser takes).
Ink,
/// A table: a frame of row frames holding cell frames with text (its column lines are thin
/// rectangles laid over it).
Table,
}
impl BoardItem {
/// Objects selected and moved as a whole: a click on their text or outline picks them, and
/// they never take other layers in.
pub fn is_atom(&self) -> bool {
matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. })
matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table)
}
/// Objects whose first text layer is their text (a double-click or Enter edits it).
@@ -109,6 +112,7 @@ impl BoardItem {
BoardItem::Label { .. } => "label",
BoardItem::Stamp { .. } => "stamp",
BoardItem::Ink => "ink",
BoardItem::Table => "table",
}
}
}
@@ -341,8 +345,11 @@ impl Stamp {
pub enum Routing {
#[default]
Straight,
/// Right angles only: out of each end along its side's normal, turning once or twice.
/// Right angles only: out of each end along its side's normal, turning once or twice, around
/// the objects it joins when the short way would cross them.
Elbow,
/// One smooth curve, leaving and arriving along the sides' normals.
Curved,
}
impl Routing {
@@ -350,10 +357,11 @@ impl Routing {
match self {
Routing::Straight => "straight",
Routing::Elbow => "elbow",
Routing::Curved => "curved",
}
}
pub fn from_id(s: &str) -> Option<Routing> {
[Routing::Straight, Routing::Elbow].into_iter().find(|r| r.id().eq_ignore_ascii_case(s))
[Routing::Straight, Routing::Elbow, Routing::Curved].into_iter().find(|r| r.id().eq_ignore_ascii_case(s))
}
}
@@ -397,6 +405,17 @@ impl Side {
pub fn horizontal(self) -> bool {
matches!(self, Side::Left | Side::Right)
}
/// The way out of the side (y down; none for [`Side::Auto`]).
pub fn normal(self) -> Vec2 {
match self {
Side::Top => Vec2::new(0.0, -1.0),
Side::Right => Vec2::new(1.0, 0.0),
Side::Bottom => Vec2::new(0.0, 1.0),
Side::Left => Vec2::new(-1.0, 0.0),
Side::Auto => Vec2::ZERO,
}
}
}
/// One end of a connector: attached to a layer, or a free point on the page.
+111
View File
@@ -0,0 +1,111 @@
//! A board's facilitation state, shared by everyone on it: the countdown timer and the vote.
//!
//! The timer keeps when it ends (or, paused, how long is left), never the ticks: each client works
//! out the time left from its own clock. A vote's ballots are kept per voter, so two people voting
//! at once change different entries.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::{BoardItem, Document, NodeId};
/// The longest a timer runs (24 hours, in milliseconds).
pub const TIMER_MAX_MS: u64 = 24 * 60 * 60 * 1000;
/// The most votes one person gets in a vote.
pub const VOTES_MAX: u32 = 99;
/// A board's countdown.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "camelCase")]
pub enum BoardTimer {
/// Counting down to `ends` (milliseconds since the Unix epoch).
Running { ends: u64 },
/// Stopped with `left` milliseconds to go.
Paused { left: u64 },
}
impl BoardTimer {
/// Milliseconds left at `now` (ms since the Unix epoch); 0 once it has run out.
pub fn left(&self, now: u64) -> u64 {
match *self {
BoardTimer::Running { ends } => ends.saturating_sub(now).min(TIMER_MAX_MS),
BoardTimer::Paused { left } => left.min(TIMER_MAX_MS),
}
}
}
/// What a vote is on.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum VoteScope {
/// Any object on the board.
#[default]
All,
/// Sticky notes only.
Stickies,
/// The objects that were selected when it started.
Only { ids: Vec<NodeId> },
}
impl VoteScope {
/// The object a vote at layer `leaf` goes to: the nearest layer from it up that the vote is
/// on (`All`: `whole`, the object a click there picks).
pub fn target(&self, doc: &Document, leaf: NodeId, whole: NodeId) -> Option<NodeId> {
if matches!(self, VoteScope::All) {
return self.takes(doc, whole).then_some(whole);
}
std::iter::once(leaf).chain(doc.ancestors(leaf)).find(|id| self.takes(doc, *id))
}
/// Whether object `id` takes votes.
pub fn takes(&self, doc: &Document, id: NodeId) -> bool {
let Some(n) = doc.node(id) else { return false };
if doc.pages.contains(&id) || n.parent.is_none() {
return false;
}
match self {
VoteScope::All => true,
VoteScope::Stickies => matches!(n.board, Some(BoardItem::Sticky { .. })),
VoteScope::Only { ids } => ids.contains(&id),
}
}
}
/// A vote: how many votes each person has, what they go on, and whether it has ended (then
/// everyone's votes show). The ballots are [`Document::votes`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Vote {
pub per_person: u32,
#[serde(default)]
pub scope: VoteScope,
#[serde(default)]
pub ended: bool,
/// Who started it.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub by: String,
}
/// Every voter's ballot: the objects they put their votes on (once per vote).
pub type Ballots = BTreeMap<String, Vec<NodeId>>;
impl Vote {
/// The votes of `ballot` that count: on objects that are still there, up to the cap.
pub fn counted<'a>(&self, doc: &'a Document, ballot: &'a [NodeId]) -> impl Iterator<Item = NodeId> + 'a {
ballot.iter().copied().filter(|id| doc.node(*id).is_some()).take(self.per_person.min(VOTES_MAX) as usize)
}
/// The votes each object got, most first (ties in layer id order).
pub fn tally(&self, doc: &Document, ballots: &Ballots) -> Vec<(NodeId, u32)> {
let mut counts: BTreeMap<NodeId, u32> = BTreeMap::new();
for ballot in ballots.values() {
for id in self.counted(doc, ballot) {
*counts.entry(id).or_default() += 1;
}
}
let mut out: Vec<(NodeId, u32)> = counts.into_iter().collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
out
}
}
+27 -5
View File
@@ -11,6 +11,7 @@
pub mod board;
pub mod colors;
pub mod comments;
pub mod facilitation;
pub mod geometry;
pub mod handoff;
pub mod ids;
@@ -27,6 +28,7 @@ pub mod vector_regions;
pub use board::*;
pub use colors::*;
pub use comments::*;
pub use facilitation::*;
pub use figcraft_color as color;
pub use figcraft_geom as geom;
pub use handoff::*;
@@ -44,6 +46,10 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::sync::Arc;
/// Deepest descendant [`Document::world_render_bounds`] follows: as deep as the renderer draws
/// (`figcraft_render::MAX_EXPORT_DEPTH`), so a crafted chain can't overflow the stack (#1062).
const MAX_RENDER_DEPTH: usize = 256;
/// Stable node identifier, unique within a document.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
@@ -204,6 +210,15 @@ pub struct Document {
/// [`handoff`]).
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub dev_snapshots: BTreeMap<NodeId, Arc<DevSnapshot>>,
/// A board's countdown (see [`facilitation`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timer: Option<BoardTimer>,
/// A board's vote, running or ended.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub vote: Option<Vote>,
/// The vote's ballots, by voter.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub votes: Ballots,
}
impl Default for Document {
@@ -243,6 +258,9 @@ impl Document {
image_presets: Vec::new(),
annotation_categories: None,
dev_snapshots: BTreeMap::new(),
timer: None,
vote: None,
votes: BTreeMap::new(),
}
}
@@ -703,12 +721,16 @@ impl Document {
/// a clipping frame's own area, else that and what its visible children show (masked
/// children only within their mask). A group is what its children show, grown by its effects.
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
self.render_bounds_at(id, 0)
}
fn render_bounds_at(&self, id: NodeId, depth: usize) -> Option<Rect> {
let n = self.node(id)?;
let own = n.own_paint_bounds(self.world_transform(id));
if n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children() {
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH {
return Some(own);
}
Some(match (self.children_render_bounds(n), &n.kind) {
Some(match (self.children_render_bounds(n, depth + 1), &n.kind) {
(Some(kids), NodeKind::Group) => n.grow_by_effects(kids),
(Some(kids), _) => own.union(kids),
(None, _) => own,
@@ -716,15 +738,15 @@ impl Document {
}
/// What `n`'s visible children show, together: each mask's run within the mask's bounds.
fn children_render_bounds(&self, n: &Node) -> Option<Rect> {
fn children_render_bounds(&self, n: &Node, depth: usize) -> Option<Rect> {
let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask);
let mut out: Option<Rect> = None;
for run in self.paint_runs(n) {
let ids = n.children.get(run).unwrap_or(&[]);
let mask = ids.first().copied().filter(|m| self.node(*m).is_some_and(|c| c.is_mask && c.visible));
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.world_render_bounds(*i)).reduce(|a, b| a.union(b));
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth)).reduce(|a, b| a.union(b));
let r = match mask {
Some(m) => r.zip(self.world_render_bounds(m)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
Some(m) => r.zip(self.render_bounds_at(m, depth)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
None => r,
};
out = match (out, r) {
+28
View File
@@ -560,6 +560,34 @@ fn is_ancestor_holds_for_very_deep_chains() {
assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..))));
}
#[test]
fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() {
// A crafted file nests groups far deeper than anything draws (#1062). The leaf counts while
// it's within the renderer's depth and is left out past it, as the renderer leaves it out.
let chain = |depth: usize| {
let mut d = Document::new("x");
let mut parent = page(&d);
for _ in 0..depth {
parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
}
d.create(parent, NodeKind::Rectangle, "R", 100.0, 100.0, 10.0, 10.0).unwrap();
let top = d.node(page(&d)).unwrap().children[0];
d.world_render_bounds(top).unwrap()
};
assert_eq!(chain(MAX_RENDER_DEPTH).x1, 110.0);
assert_eq!(chain(20_000).x1, 10.0);
}
#[test]
fn text_insert_at_run_boundary_keeps_runs_disjoint() {
let mut t = TextData::new("0123456789");
t.restyle(0, 5, |s| s.font_size = 20.0);
t.restyle(5, 10, |s| s.font_size = 30.0);
t.replace_range(5, 5, "xx");
let runs: Vec<(usize, usize)> = t.runs.iter().map(|r| (r.start, r.end)).collect();
assert_eq!(runs, vec![(0, 7), (7, 12)]);
}
#[test]
fn many_small_vector_subpaths_build_and_stroke_without_rescans() {
// Many closed subpaths whose last point repeats the start (#F-332), a long open chain (#F-334)
+28 -2
View File
@@ -253,10 +253,17 @@ pub struct TextStyle {
pub style_id: Option<String>,
}
/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`,
/// for a distribution whose users mostly write a script Inter lacks); empty means Inter.
pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") {
Some(family) if !family.is_empty() => family,
_ => "Inter",
};
impl Default for TextStyle {
fn default() -> Self {
Self {
font_family: "Inter".into(),
font_family: DEFAULT_FONT.into(),
font_style: "Regular".into(),
font_size: 12.0,
line_height: LineHeight::Auto,
@@ -579,7 +586,14 @@ impl TextData {
}
self.runs.sort_by_key(|r| r.start);
let mut merged: Vec<StyleRun> = Vec::with_capacity(self.runs.len());
for r in std::mem::take(&mut self.runs) {
for mut r in std::mem::take(&mut self.runs) {
// Runs never overlap: a later run starts where the previous one ends at the earliest.
if let Some(prev) = merged.last() {
r.start = r.start.max(prev.end);
if r.start >= r.end {
continue;
}
}
match merged.last_mut() {
Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end,
_ => merged.push(r),
@@ -783,6 +797,18 @@ mod case_tests {
assert!(!valid_feature_tag("lig") && !valid_feature_tag("ligatures") && !valid_feature_tag("li\u{e9}"));
}
#[test]
fn new_text_starts_in_the_builds_default_font() {
// Inter, unless the build names another with `FIGCRAFT_DEFAULT_FONT` (empty means Inter).
let named = option_env!("FIGCRAFT_DEFAULT_FONT").filter(|f| !f.is_empty());
assert_eq!(DEFAULT_FONT, named.unwrap_or("Inter"));
let st = TextStyle::default();
assert_eq!(st.font_family, DEFAULT_FONT);
// The family is always written, so a saved file keeps it in a build with another default.
let saved = serde_json::to_value(&st).unwrap();
assert_eq!(saved["font_family"], DEFAULT_FONT);
}
#[test]
fn variations_are_backward_compatible_and_imply_weight() {
// Files written before variable fonts load with no custom axes, and stay byte-identical.
+2
View File
@@ -29,8 +29,10 @@ log = { workspace = true }
base64 = { workspace = true }
regex = { workspace = true }
deunicode = { workspace = true }
same-file = { workspace = true }
web-time = "1.1.0"
[dev-dependencies]
zip = { workspace = true }
wat = { workspace = true }
image = { workspace = true }
+192
View File
@@ -0,0 +1,192 @@
//! Step references in `command.batch`: a step's params can use what an earlier step returned.
//!
//! `{"$step": n}` stands for the `id` in the result of step `n` (zero-based), and `{"$ref": "name"}`
//! for the `id` in the result of the step that called itself `name` with `"as": "name"`. Either
//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the
//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way.
use std::collections::HashMap;
use serde_json::{Map, Value};
use crate::{Result, bad};
/// Deepest params nesting the resolver walks before giving up.
const MAX_DEPTH: usize = 64;
/// One step of a batch: a command, its params and the name later steps can call it by.
#[derive(Clone, Debug, PartialEq)]
pub struct BatchStep {
pub command: String,
pub params: Value,
/// The step's `as`, for `{"$ref": name}` in later steps.
pub name: Option<String>,
}
impl BatchStep {
/// A step without a name.
pub fn new(command: impl Into<String>, params: Value) -> Self {
Self { command: command.into(), params, name: None }
}
}
/// A step's `as` (`step` is its index, for the error): `None` when it has none.
pub fn step_name(step: usize, json: &Value) -> Result<Option<String>> {
match json.get("as") {
None | Some(Value::Null) => Ok(None),
Some(Value::String(name)) if !name.is_empty() => Ok(Some(name.clone())),
Some(_) => Err(bad("command.batch", format!("step {step}: `as` must be a name (a non-empty string)"))),
}
}
/// The steps of one batch run so far: their results, and which step each `as` name belongs to.
/// A nested `command.batch` step gets its own scope, so its references are to its own steps.
#[derive(Clone, Debug, Default)]
pub struct BatchScope {
names: HashMap<String, usize>,
results: Vec<Value>,
}
impl BatchScope {
/// The scope for running `steps`. Two steps with the same name fail here, before any step runs.
pub fn new(steps: &[BatchStep]) -> Result<Self> {
let mut names = HashMap::new();
for (i, step) in steps.iter().enumerate() {
let Some(name) = &step.name else { continue };
if let Some(first) = names.insert(name.clone(), i) {
return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name")));
}
}
Ok(Self { names, results: Vec::with_capacity(steps.len()) })
}
/// The results of the steps run so far, in order.
pub fn results(&self) -> &[Value] {
&self.results
}
pub fn into_results(self) -> Vec<Value> {
self.results
}
/// The index of the step that runs next.
pub fn next_step(&self) -> usize {
self.results.len()
}
pub(crate) fn push(&mut self, result: Value) {
self.results.push(result);
}
/// `params` of the next step with every reference in it replaced.
pub(crate) fn resolve(&self, params: &Value) -> Result<Value> {
self.resolve_in(params, 0)
}
fn resolve_in(&self, v: &Value, depth: usize) -> Result<Value> {
let step = self.next_step();
if depth > MAX_DEPTH {
return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels")));
}
match v {
Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o),
Value::Object(o) => {
let mut out = Map::with_capacity(o.len());
for (k, x) in o {
out.insert(k.clone(), self.resolve_in(x, depth + 1)?);
}
Ok(Value::Object(out))
}
Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
_ => Ok(v.clone()),
}
}
/// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for.
fn reference(&self, o: &Map<String, Value>) -> Result<Value> {
let step = self.next_step();
let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}"));
let key = if o.contains_key("$step") { "$step" } else { "$ref" };
if let Some(k) = o.keys().find(|k| *k != key && *k != "field") {
return Err(fail(format!("a `{key}` reference takes only `field`, not `{k}`")));
}
let (n, who) = if key == "$step" {
let n = o.get("$step").and_then(Value::as_u64).ok_or_else(|| fail("`$step` must be a step index (0, 1, …)".into()))?;
let n = usize::try_from(n).ok().filter(|n| *n < step).ok_or_else(|| {
let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) };
fail(format!("`$step` {n} must be an earlier step ({range})"))
})?;
(n, format!("step {n}"))
} else {
let name = o.get("$ref").and_then(Value::as_str).ok_or_else(|| fail("`$ref` must be a step name (a string)".into()))?;
let n = *self.names.get(name).ok_or_else(|| fail(format!("`$ref` \"{name}\" names no step ({})", self.known_names())))?;
if n >= step {
return Err(fail(format!("`$ref` \"{name}\" is step {n}, not an earlier step")));
}
(n, format!("step {n} (\"{name}\")"))
};
let path = match o.get("field") {
None => "id",
Some(Value::String(f)) => f.as_str(),
Some(_) => return Err(fail("`field` must be a string".into())),
};
let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?;
lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))
}
fn known_names(&self) -> String {
let mut names: Vec<_> = self.names.iter().collect();
names.sort_by_key(|(_, i)| **i);
if names.is_empty() {
return "no step has an `as` name".into();
}
let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
list.join(", ")
}
}
/// `path` in `v`: a key as written, else a slash path (`ids/0`, `bounds/x`; a leading `/` as in
/// JSON Pointer is allowed). The error is the detail after "has no `path`".
fn lookup<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> {
// A key as written first, so a key holding `/` still works as it did before paths.
if let Some(x) = v.get(path) {
return Ok(x);
}
let hint = if path.contains('.') && !path.contains('/') { " (paths use `/`, as in `ids/0`)" } else { "" };
walk(v, path.strip_prefix('/').unwrap_or(path)).map_err(|why| format!("{why}{hint}"))
}
fn walk<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> {
let mut cur = v;
let mut walked = String::new();
for seg in path.split('/') {
let here = if walked.is_empty() { "the result".to_string() } else { format!("`{walked}`") };
cur = match cur {
Value::Object(o) => o.get(seg).ok_or_else(|| if walked.is_empty() { String::new() } else { format!(": {here} has no `{seg}`") })?,
Value::Array(a) => match seg.parse::<usize>() {
Ok(i) => a.get(i).ok_or_else(|| {
let items = if a.len() == 1 { "1 item".to_string() } else { format!("{} items", a.len()) };
format!(": {here} has {items}")
})?,
Err(_) => return Err(format!(": {here} is a list, so `{seg}` must be an index (0, 1, …)")),
},
_ => return Err(format!(": {here} is {}", kind(cur))),
};
if !walked.is_empty() {
walked.push('/');
}
walked.push_str(seg);
}
Ok(cur)
}
fn kind(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "a list",
Value::Object(_) => "an object",
}
}
+170
View File
@@ -110,3 +110,173 @@ fn a_nested_batch_refers_to_its_own_steps() {
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one");
}
/// Runs `commands` as a batch and returns the error message, which must be `BadParams`.
fn batch_error(s: &mut Session, commands: serde_json::Value) -> String {
match s.execute("command.batch", &json!({"commands": commands})) {
Err(EngineError::BadParams { msg, .. }) => msg,
other => panic!("expected BadParams, got {other:?}"),
}
}
#[test]
fn a_named_step_is_referred_to_by_its_name() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"commands": [
{"command": "node.create", "as": "card", "params": {"type": "frame", "width": 200, "height": 100}},
{"command": "node.create", "as": "swatch", "params": {"type": "rectangle", "parent": {"$ref": "card"}, "width": 20, "height": 20}},
{"command": "node.set", "params": {"ids": [{"$ref": "swatch", "field": "id"}], "props": {"name": "Swatch"}}},
{"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0}}}
]}),
)
.unwrap();
let (frame, rect, text) = (id(&results[0]), id(&results[1]), id(&results[3]));
let doc = s.doc().unwrap();
assert_eq!(doc.node(rect).unwrap().parent, Some(frame));
assert_eq!(doc.node(rect).unwrap().name, "Swatch");
assert_eq!(doc.node(text).unwrap().parent, Some(frame), "`$step` and `$ref` mix in one batch");
assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step");
}
/// `edit.duplicate` answers `{"ids": [...]}`: a slash path in `field` takes one of them.
#[test]
fn a_field_path_indexes_into_a_list_in_the_result() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"commands": [
{"command": "node.create", "as": "a", "params": {"type": "frame", "name": "A", "width": 10, "height": 10}},
{"command": "node.create", "as": "b", "params": {"type": "frame", "name": "B", "width": 10, "height": 10}},
{"command": "edit.duplicate", "as": "copies", "params": {"ids": [{"$ref": "a"}, {"$ref": "b"}]}},
{"command": "node.set", "params": {"ids": [{"$ref": "copies", "field": "ids/1"}], "props": {"name": "B copy"}}},
{"command": "node.set", "params": {"ids": [{"$step": 2, "field": "/ids/0"}], "props": {"name": "A copy"}}}
]}),
)
.unwrap();
let copies = results[2]["ids"].as_array().unwrap().clone();
let doc = s.doc().unwrap();
assert_eq!(doc.node(crate::params::id_of(&copies[0]).unwrap()).unwrap().name, "A copy", "a leading `/` works too");
assert_eq!(doc.node(crate::params::id_of(&copies[1]).unwrap()).unwrap().name, "B copy");
}
#[test]
fn a_field_path_walks_nested_objects_and_takes_whole_values() {
let mut scope = crate::BatchScope::default();
scope.push(json!({"ids": [7, 8], "bounds": {"x": 1.5, "corners": [{"r": 4}]}, "a/b": "slash key"}));
let resolved = scope
.resolve(&json!({
"x": {"$step": 0, "field": "bounds/x"},
"r": {"$step": 0, "field": "bounds/corners/0/r"},
"all": {"$step": 0, "field": "ids"},
"key": {"$step": 0, "field": "a/b"},
"pointer": {"$step": 0, "field": "/ids"}
}))
.unwrap();
assert_eq!(resolved, json!({"x": 1.5, "r": 4, "all": [7, 8], "key": "slash key", "pointer": [7, 8]}), "a key holding `/` is still read as a key");
}
#[test]
fn bad_names_and_paths_fail_and_roll_back() {
let mut s = Session::new();
let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
let frame = |name: &str| json!({"command": "node.create", "as": name, "params": {"type": "frame", "width": 10, "height": 10}});
let dup = json!({"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}});
let set = |r: serde_json::Value| json!({"command": "node.set", "params": {"ids": [r], "props": {"name": "x"}}});
let later = json!({"command": "node.create", "as": "later", "params": {"type": "frame", "width": 10, "height": 10}});
for (commands, needle) in [
(json!([frame("card"), frame("card")]), "step 1: `as` \"card\" is already step 0's name"),
(json!([frame("card"), set(json!({"$ref": "nope"}))]), "step 1: `$ref` \"nope\" names no step (\"card\" is step 0)"),
(json!([{"command": "node.create", "params": {"type": "frame"}}, set(json!({"$ref": "nope"}))]), "no step has an `as` name"),
(json!([frame("card"), set(json!({"$ref": "later"})), later]), "step 1: `$ref` \"later\" is step 2, not an earlier step"),
(
json!([frame("card"), {"command": "node.set", "as": "self", "params": {"ids": [{"$ref": "self"}], "props": {}}}]),
"is step 1, not an earlier step",
),
(json!([frame("card"), set(json!({"$ref": 0}))]), "`$ref` must be a step name"),
(json!([frame("card"), set(json!({"$ref": "card", "id": 3}))]), "a `$ref` reference takes only `field`, not `id`"),
(json!([frame("card"), set(json!({"$ref": "card", "$step": 0}))]), "a `$step` reference takes only `field`, not `$ref`"),
(json!([frame("card"), {"command": "node.create", "as": "", "params": {}}]), "step 1: `as` must be a name"),
(json!([frame("card"), {"command": "node.create", "as": 3, "params": {}}]), "step 1: `as` must be a name"),
(
json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/1"}))]),
"step 2: step 1 (\"copy\")'s result has no `ids/1`: `ids` has 1 item",
),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/first"}))]), "`ids` is a list, so `first` must be an index"),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/0/x"}))]), "has no `ids/0/x`: `ids/0` is a number"),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "map/0"}))]), "step 1 (\"copy\")'s result has no `map/0`"),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids.0"}))]), "has no `ids.0` (paths use `/`, as in `ids/0`)"),
(json!([frame("card"), set(json!({"$step": 0, "field": "id/0"}))]), "has no `id/0`: `id` is a number"),
] {
let msg = batch_error(&mut s, commands);
assert!(msg.contains(needle), "{msg:?} should contain {needle:?}");
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back");
}
}
#[test]
fn text_that_looks_like_a_named_reference_is_left_alone() {
let mut s = Session::new();
let text = "{\"$ref\": \"card\"} $ref";
let results = s.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": text}}]})).unwrap();
let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone();
assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == text));
}
/// A nested batch has its own names: it can't see the outer batch's, may reuse them, and the
/// outer batch can't see its.
#[test]
fn a_nested_batch_has_its_own_names() {
let mut s = Session::new();
let frame = |name: &str| json!({"command": "node.create", "as": "frame", "params": {"type": "frame", "name": name, "width": 100, "height": 100}});
let results = s
.execute(
"command.batch",
&json!({"commands": [
frame("Outer"),
{"command": "command.batch", "as": "inner", "params": {"commands": [
frame("Inner"),
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}}
]}},
{"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "1/id"}], "props": {"name": "In inner"}}}
]}),
)
.unwrap();
let inner = &results[1];
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own frame, not the outer one");
assert_eq!(rect.name, "In inner", "a path indexes into a nested batch's result list");
let msg = batch_error(
&mut s,
json!([frame("Outer"), {"command": "command.batch", "params": {"commands": [
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}}
]}}]),
);
assert!(msg.contains("`$ref` \"frame\" names no step"), "the outer names aren't visible inside: {msg}");
}
#[test]
fn a_failing_command_names_its_step_and_rolls_back() {
let mut s = Session::new();
let frame = id(&s.execute("node.create", &json!({"type": "frame", "width": 50, "height": 50})).unwrap());
let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap());
let undo = s.ds().unwrap().history.undo.len();
let name = s.doc().unwrap().node(s.page().unwrap()).unwrap().name.clone();
let msg = batch_error(
&mut s,
json!([
{"command": "text.set", "params": {"ids": [text.0], "text": "novo"}},
{"command": "text.set", "params": {"ids": [frame.0], "text": "erro"}},
{"command": "page.rename", "params": {"name": "Depois do erro"}}
]),
);
// Still the command's own kind of error, its message saying which step.
assert!(msg.starts_with("step 1: ") && msg.contains("is not text"), "{msg}");
assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig"));
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name);
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
}
+244 -56
View File
@@ -12,7 +12,8 @@ use figcraft_doc::{
HConstraint, LayoutMode, MainAlign, Node, NodeId, NodeKind, Routing, STAMP_SIZE, Shadow, Side, Sizing, Stamp, StrokeAlign, StrokeCap, StrokeJoin,
TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color,
};
use figcraft_geom::{Affine, Point, Rect};
use figcraft_geom::kurbo::PathEl;
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
@@ -33,7 +34,7 @@ const STICKY_GAP: f64 = 24.0;
/// A new shape's size.
const SHAPE: f64 = 160.0;
/// Text on board objects, and the author line under a sticky's note.
const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E);
pub(crate) const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E);
const AUTHOR_INK: Color = Color::from_rgb8_const(0x5C, 0x5A, 0x55);
/// Connectors' default colour and weight.
const LINE: Color = Color::from_rgb8_const(0x3A, 0x39, 0x36);
@@ -98,10 +99,17 @@ 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, 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), 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",
true,
reattach
),
spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd),
spec!(
"board.stamp",
@@ -127,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"board.setStyle",
"Board style",
"{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow, startCap?, endCap? (connectors)}",
"{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow|curved, startCap?, endCap? (connectors)}",
true,
set_style
),
@@ -143,7 +151,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
board_tool(s, ToolId::BoardShape)
}),
spec!("tool.connector", "Connector", "{style?: straight|elbow} (drag from one object to another)", false, |s, p| {
spec!("tool.connector", "Connector", "{style?: straight|elbow|curved} (drag from one object to another)", false, |s, p| {
if let Some(v) = string(p, "style") {
s.tool.board.routing = routing_of("tool.connector", &v)?;
}
@@ -205,7 +213,7 @@ fn stamp_of(cmd: &str, v: &str) -> Result<Stamp> {
}
fn routing_of(cmd: &str, v: &str) -> Result<Routing> {
Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight or elbow, not `{v}`")))
Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight, elbow or curved, not `{v}`")))
}
fn cap_of(cmd: &str, p: &Value, key: &str) -> Result<Option<StrokeCap>> {
@@ -232,7 +240,7 @@ fn line_shade(c: Color) -> Color {
/// Your name on what you make: the name from Preferences, else the one a live session gave you
/// (none for the default name outside a session).
fn author(s: &Session) -> String {
pub(crate) fn author(s: &Session) -> String {
let name = crate::live::display_name(&s.prefs);
if name.is_empty() { s.live().info.as_ref().map(|i| i.name.clone()).unwrap_or_default() } else { name }
}
@@ -277,7 +285,7 @@ fn soft_shadow() -> Effect {
}
/// A text layer filling its frame's width (the frame's auto layout places it).
fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result<NodeId> {
pub(crate) fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result<NodeId> {
let first: String = text.lines().next().unwrap_or("").chars().take(64).collect();
let name = if first.trim().is_empty() { "Text" } else { first.as_str() };
let mut td = TextData {
@@ -606,7 +614,7 @@ fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
n.stroke.cap_end = end;
n.board = Some(BoardItem::Connector(c));
})?;
place_route(doc, id, &pts);
place_route(doc, id, &pts, routing);
let mut out = json!({"id": id});
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
out["label"] = json!(set_label(doc, id, &text)?);
@@ -615,6 +623,80 @@ fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// The connector `id` names: a connector, or the label on one.
fn connector_of(cmd: &str, doc: &Document, id: NodeId) -> Result<(NodeId, Connector)> {
match doc.get(id)?.board.clone() {
Some(BoardItem::Connector(c)) => Ok((id, c)),
Some(BoardItem::Label { connector }) => match doc.get(connector)?.board.clone() {
Some(BoardItem::Connector(c)) => Ok((connector, c)),
_ => Err(bad(cmd, "that isn't a connector")),
},
_ => Err(bad(cmd, "that isn't a connector")),
}
}
fn reattach(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.reattach";
require(s, cmd)?;
let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?;
let (id, mut c) = connector_of(cmd, s.doc()?, id)?;
let from = match string(p, "end").as_deref() {
Some("from") => true,
Some("to") => false,
_ => return Err(bad(cmd, "`end` is from or to")),
};
let end = end_param(cmd, s, p, "target", "side")?;
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"));
}
*(if from { &mut c.from } else { &mut c.to }) = end;
let doc = s.doc_mut()?;
let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?;
if pts.windows(2).all(|w| w.first() == w.get(1)) {
return Err(bad(cmd, "the ends are in the same place"));
}
doc.get_mut(id)?.board = Some(BoardItem::Connector(c));
place_route(doc, id, &pts, c.routing);
Ok(json!({"id": id}))
}
/// A connector end as `board.connector` and `board.reattach` take it.
fn end_json(e: End) -> Value {
match e {
End::Node { node, .. } => json!(node),
End::Point { x, y } => json!({"x": x, "y": y}),
}
}
/// The end of connector `id` that stays while its other one (`from`: its start) is dragged.
fn other_end(doc: &Document, id: NodeId, from: bool) -> Option<End> {
match &doc.node(id)?.board {
Some(BoardItem::Connector(c)) => Some(if from { c.to } else { c.from }),
_ => None,
}
}
/// The selected connector (one, alone and unlocked) and its two ends on the page as drawn.
pub fn selected_connector_ends(doc: &Document, sel: &[NodeId]) -> Option<(NodeId, [Point; 2])> {
let [id] = sel else { return None };
if !matches!(doc.node(*id)?.board, Some(BoardItem::Connector(_))) || crate::tools::locked_in(doc, *id) {
return None;
}
let (a, b) = ends_now(doc, *id)?;
Some((*id, [a, b]))
}
/// How near (screen pixels) a press must be to a selected connector's end to drag it.
pub const END_HANDLE_PX: f64 = 8.0;
/// The selected connector's end under `p` at `zoom`, nearest first (`true`: its start).
pub(crate) fn end_handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<(NodeId, bool)> {
let (id, [a, b]) = selected_connector_ends(doc, sel)?;
let (da, db) = ((a - p).hypot(), (b - p).hypot());
(da.min(db) * zoom.max(1e-6) <= END_HANDLE_PX).then_some((id, da <= db))
}
/// The label of connector `id`, if it has one.
pub fn label_of(doc: &Document, id: NodeId) -> Option<NodeId> {
let parent = doc.node(id)?.parent?;
@@ -664,12 +746,7 @@ fn label_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.label";
require(s, cmd)?;
let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?;
let doc = s.doc()?;
let id = match doc.node(id).and_then(|n| n.board.clone()) {
Some(BoardItem::Connector(_)) => id,
Some(BoardItem::Label { connector }) => connector,
_ => return Err(bad(cmd, "that isn't a connector")),
};
let (id, _) = connector_of(cmd, s.doc()?, id)?;
let text = req_string(cmd, p, "text")?;
let label = set_label(s.doc_mut()?, id, &text)?;
Ok(json!({"id": id, "label": label}))
@@ -697,7 +774,7 @@ fn set_color(s: &mut Session, p: &Value) -> Result<Value> {
n.strokes = vec![Paint::solid(line_shade(color))];
}
BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))],
BoardItem::Label { .. } | BoardItem::Stamp { .. } => continue,
BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table => continue,
}
changed.push(id);
}
@@ -941,7 +1018,7 @@ pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTr
n.board = Some(BoardItem::Connector(c));
}
if let Some(pts) = route(doc, &c) {
place_route(doc, id, &pts);
place_route(doc, id, &pts, c.routing);
}
}
for (label, connector) in labels {
@@ -974,7 +1051,7 @@ fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet<NodeId>
if !touched.contains(&id) || b.transform == n.transform || b.board != n.board {
return false;
}
let Some(expected) = route(doc, c).and_then(|pts| figcraft_geom::points_bounds(pts.iter().copied())).map(|b| b.origin()) else { return false };
let Some(expected) = route(doc, c).and_then(|pts| finite_box(&route_path(&pts, c.routing))).map(|b| b.origin()) else { return false };
let d = (doc.world_transform(id) * Point::ZERO) - expected;
if d.hypot() <= 1e-9 {
return false;
@@ -1017,7 +1094,12 @@ fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> {
fn route_mid(doc: &Document, id: NodeId) -> Option<Point> {
let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None };
let w = doc.world_transform(id);
let pts: Vec<Point> = network.vertices.iter().map(|v| w * v.point()).collect();
let mut pts: Vec<Point> = Vec::new();
figcraft_geom::kurbo::flatten(network.stroke_path().iter(), 0.25, |el| {
if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
pts.push(w * p);
}
});
let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum();
let mut left = total / 2.0;
for s in pts.windows(2) {
@@ -1031,19 +1113,42 @@ fn route_mid(doc: &Document, id: NodeId) -> Option<Point> {
pts.first().copied()
}
/// Write the route `pts` (page points) into connector `id`: its box, place and path. Untouched
/// when it already runs there, so a connector that didn't move isn't an edit.
fn place_route(doc: &mut Document, id: NodeId, pts: &[Point]) {
/// The path through a route's page points `pts` (see [`route`]): one curve for a curved
/// connector, straight runs otherwise.
fn route_path(pts: &[Point], routing: Routing) -> BezPath {
let mut path = BezPath::new();
match (routing, pts) {
(Routing::Curved, [p0, c0, c1, p1]) => {
path.move_to(*p0);
path.curve_to(*c0, *c1, *p1);
}
_ => {
for (i, p) in pts.iter().enumerate() {
if i == 0 { path.move_to(*p) } else { path.line_to(*p) }
}
}
}
path
}
/// The box a path is drawn in, when it is finite.
fn finite_box(path: &BezPath) -> Option<Rect> {
let b = path.bounding_box();
(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()).then_some(b)
}
/// Write the route `pts` (page points, see [`route`]) into connector `id`: its box, place and
/// path. Untouched when it already runs there, so a connector that didn't move isn't an edit.
fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing: Routing) {
let Some(n) = doc.node(id) else { return };
let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p));
let inv = pw.inverse();
let local: Vec<Point> = pts.iter().map(|p| inv * *p).collect();
let Some(b) = figcraft_geom::points_bounds(local.iter().copied()) else { return };
if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) {
return;
}
let rel: Vec<Point> = local.iter().map(|p| Point::new(p.x - b.x0, p.y - b.y0)).collect();
let network = VectorNetwork::from_points(&rel, false);
let local = pw.inverse() * route_path(pts, routing);
let Some(b) = finite_box(&local) else { return };
let rel = Affine::translate((-b.x0, -b.y0)) * local;
let network = match routing {
Routing::Curved => VectorNetwork::from_path(&rel),
_ => VectorNetwork::from_points(&rel.elements().iter().filter_map(PathEl::end_point).collect::<Vec<_>>(), false),
};
let t = Affine::translate((b.x0, b.y0));
let same =
n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network);
@@ -1057,8 +1162,12 @@ fn place_route(doc: &mut Document, id: NodeId, pts: &[Point]) {
}
}
/// How far an elbow connector going around the objects it joins keeps from them.
const ROOM: f64 = 24.0;
/// The page points connector `c` runs through: from the middle of the side of each attached layer
/// facing the other end (or the side it names), straight or in right angles.
/// facing the other end (or the side it names), straight, in right angles, or (curved) its two
/// ends with the curve's two control points between them.
pub fn route(doc: &Document, c: &Connector) -> Option<Vec<Point>> {
let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n));
let centre = |e: &End, b: Option<Rect>| match e {
@@ -1067,25 +1176,36 @@ pub fn route(doc: &Document, c: &Connector) -> Option<Vec<Point>> {
};
let (fb, tb) = (bounds(&c.from), bounds(&c.to));
let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?);
let (p0, h0) = attach(&c.from, fb, tc)?;
let (p1, h1) = attach(&c.to, tb, fc)?;
let mut pts = match c.routing {
Routing::Straight => vec![p0, p1],
Routing::Elbow => elbow(p0, h0, p1, h1),
let (p0, s0) = attach(&c.from, fb, tc)?;
let (p1, s1) = attach(&c.to, tb, fc)?;
let pts = match c.routing {
Routing::Straight => tidy(vec![p0, p1]),
Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::<Vec<_>>()),
Routing::Curved => curve(p0, s0, p1, s1),
};
pts.dedup();
// A turn that doesn't turn (the ends in line) is no point of the route.
let in_line = |i: usize, b: &Point| match (i.checked_sub(1).and_then(|j| pts.get(j)), pts.get(i + 1)) {
(Some(a), Some(c)) => (a.x == b.x && b.x == c.x) || (a.y == b.y && b.y == c.y),
_ => false,
};
let pts: Vec<Point> = pts.iter().enumerate().filter(|(i, b)| !in_line(*i, b)).map(|(_, b)| *b).collect();
pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts)
}
/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and
/// whether that side faces sideways (`None`: a free point).
fn attach(e: &End, b: Option<Rect>, other: Point) -> Option<(Point, Option<bool>)> {
/// `pts` without repeats or turns that don't turn (three in a line across or down).
fn tidy(pts: Vec<Point>) -> Vec<Point> {
let mut out: Vec<Point> = Vec::with_capacity(pts.len());
for p in pts {
if out.last() == Some(&p) {
continue;
}
if let [.., a, b] = out.as_slice()
&& ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y))
{
out.pop();
}
out.push(p);
}
out
}
/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that
/// side (`None`: a free point).
fn attach(e: &End, b: Option<Rect>, other: Point) -> Option<(Point, Option<Side>)> {
match e {
End::Point { x, y } => Some((Point::new(*x, *y), None)),
End::Node { side, .. } => {
@@ -1104,14 +1224,55 @@ fn attach(e: &End, b: Option<Rect>, other: Point) -> Option<(Point, Option<bool>
}
s => *s,
};
Some((side.point(b), Some(side.horizontal())))
Some((side.point(b), Some(side)))
}
}
}
/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals
/// (`h`: sideways; a free end follows the other).
fn elbow(p0: Point, h0: Option<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no
/// way): the ends and the two control points between them.
fn curve(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>) -> Vec<Point> {
let reach = (p1 - p0).hypot() / 2.0;
let pull = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach);
vec![p0, pull(p0, s0), pull(p1, s1), p1]
}
/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a
/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`,
/// else the cheapest of a few ways around them (by length, and turns).
fn elbow(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>, boxes: &[Rect]) -> Vec<Point> {
let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal)));
let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct };
if !crosses(&direct, boxes) {
return direct;
}
// Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects.
let out = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM);
let (a, b) = (out(p0, s0), out(p1, s1));
let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]];
for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] {
ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]);
}
for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] {
ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]);
}
let cost = |r: &Vec<Point>| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::<f64>() + ROOM * r.len() as f64;
ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct)
}
/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge,
/// or out of one, is outside).
fn crosses(pts: &[Point], boxes: &[Rect]) -> bool {
const EDGE: f64 = 0.5;
pts.windows(2).any(|s| {
let (a, b) = (s[0], s[1]);
boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE)
})
}
/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end
/// follows the other): halfway across between two sides facing the same way, one turn otherwise.
fn short_elbow(p0: Point, h0: Option<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs());
let h1 = h1.unwrap_or(h0);
match (h0, h1) {
@@ -1229,16 +1390,42 @@ impl Session {
if to.node().is_some() && to.node() == from.node() {
return Ok(());
}
let json_end = |e: End| match e {
End::Node { node, .. } => json!(node),
End::Point { x, y } => json!({"x": x, "y": y}),
};
let style = self.tool.board.routing.id();
self.execute("board.connector", &json!({"from": json_end(from), "to": json_end(to), "style": style}))?;
self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?;
self.board_placed();
Ok(())
}
/// Dragging the selected connector `id`'s end (`from`: its start): a line from its other end
/// to the pointer, and the object it would attach to outlined.
pub(crate) fn reattach_drag(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) {
self.tool.gesture = Gesture::Reattach { id, from, start };
let Ok(doc) = self.doc() else { return };
let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a });
let other = other_end(doc, id, from).and_then(|e| e.node());
self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]);
self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other);
if let Ok(ds) = self.ds_mut() {
ds.revision += 1;
}
}
/// The end let go: attached to the object under the pointer, or free there (`board.reattach`).
/// A click, or a drop on the object the other end is on, changes nothing.
pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> {
self.tool.overlay.rubber = None;
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
return Ok(());
}
let target = self.end_at(ev);
let other = other_end(self.doc()?, id, from).and_then(|e| e.node());
if target.node().is_some() && target.node() == other {
return Ok(());
}
self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?;
Ok(())
}
/// The eraser at the pointer: the marker and highlighter strokes it touches go (until the
/// gesture ends, which is one undo step).
pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> {
@@ -1285,7 +1472,7 @@ impl Session {
/// label to type in). True when it was one.
pub(crate) fn board_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId]) -> Result<bool> {
let whole = figcraft_doc::geometry::select_target(doc, leaf, sel, false);
let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| owner_of_text(doc, te.id) == Some(whole));
let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| doc.is_ancestor(whole, te.id));
let Some(item) = doc.node(whole).and_then(|n| n.board.clone()).filter(|_| !editing) else { return Ok(false) };
self.tool.gesture = Gesture::None;
self.cancel_interaction()?;
@@ -1300,6 +1487,7 @@ impl Session {
};
edit_text(self, label)
}
BoardItem::Table => crate::board_table::edit_cell(self, doc, leaf),
b if b.has_text() => edit_text(self, whole),
_ => Ok(false),
}
+179
View File
@@ -0,0 +1,179 @@
//! Board connectors, continued: moving an end onto another object or off into the canvas (the
//! command and a drag of the selected connector's end), curved connectors, and elbows that go
//! around the objects they join.
use figcraft_doc::{BoardItem, End, NodeKind, Routing, Side};
use figcraft_geom::{Point, Rect};
use serde_json::json;
use crate::Session;
use crate::board::route;
use crate::board_tests::{board, connector, drawn, id, near, node};
use crate::tools::{PointerEvent, PointerKind, handle_at};
/// A board with three 100 × 100 shapes in a row 300 apart, and an elbow connector from the
/// first to the second: (a, b, c, connector).
fn three() -> (Session, [figcraft_doc::NodeId; 4]) {
let mut s = board();
let mut shape = |x: f64| id(&s.execute("board.shape", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap(), "id");
let (a, b, c) = (shape(0.0), shape(300.0), shape(600.0));
let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap(), "id");
(s, [a, b, c, k])
}
#[test]
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).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();
assert_eq!(connector(&s, k).to.node(), Some(b), "one undo step");
// Off into the canvas: the end stays there, free. The selection is the default connector.
s.execute("select.set", &json!({"ids": [k]})).unwrap();
s.execute("board.reattach", &json!({"end": "from", "target": {"x": -200, "y": 400}})).unwrap();
assert_eq!(connector(&s, k).from, End::Point { x: -200.0, y: 400.0 });
assert!(near(drawn(&s, k)[0], Point::new(-200.0, 400.0)));
// A label stands for its connector.
let label = id(&s.execute("board.label", &json!({"id": k, "text": "next"})).unwrap(), "label");
s.execute("board.reattach", &json!({"id": label, "end": "from", "target": c})).unwrap();
assert_eq!(connector(&s, k).from.node(), Some(c));
}
#[test]
fn reattach_refuses_what_it_cant_join() {
let (mut s, [a, b, _, k]) = three();
let refused = |s: &mut Session, p| s.execute("board.reattach", &p).is_err();
assert!(refused(&mut s, json!({"id": k, "end": "to", "target": a})), "both ends on one layer");
assert!(refused(&mut s, json!({"id": k, "end": "middle", "target": b})));
assert!(refused(&mut s, json!({"id": k, "end": "to"})), "no target");
assert!(refused(&mut s, json!({"id": a, "end": "to", "target": b})), "not a connector");
assert!(refused(&mut s, json!({"id": k, "end": "to", "target": k})), "a connector isn't an end");
assert!(refused(&mut s, json!({"id": k, "end": "to", "target": {"x": "a", "y": 1}})));
assert!(refused(&mut s, json!({"id": k, "end": "to", "target": b, "side": "inside"})));
let mut d = Session::new();
d.execute("file.new", &json!({})).unwrap();
assert!(d.execute("board.reattach", &json!({"end": "to", "target": {"x": 0, "y": 0}})).is_err(), "boards only");
assert_eq!(connector(&s, k).to.node(), Some(b), "nothing changed");
}
#[test]
fn dragging_a_selected_connectors_end_reattaches_it() {
let (mut s, [a, b, c, k]) = three();
s.execute("select.set", &json!({"ids": [k]})).unwrap();
let doc = s.doc().unwrap();
assert_eq!(handle_at(doc, &[k], Point::new(100.0, 50.0), 1.0), None, "no box handles on a connector");
let end = *drawn(&s, k).last().unwrap();
let ev = |k, p: Point| PointerEvent::at(k, p.x, p.y, 1.0);
// Onto c.
s.pointer(&ev(PointerKind::Down, end)).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(500.0, 50.0))).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(650.0, 50.0))).unwrap();
assert_eq!(s.tool.overlay.drop_target, Some(c), "the object it would attach to shows");
assert!(s.tool.overlay.rubber.is_some());
s.pointer(&ev(PointerKind::Up, Point::new(650.0, 50.0))).unwrap();
assert_eq!(connector(&s, k).to.node(), Some(c));
assert_eq!(connector(&s, k).from.node(), Some(a));
assert_eq!((s.tool.overlay.drop_target, s.tool.overlay.rubber), (None, None));
assert_eq!(s.selection(), vec![k], "still selected");
// The start into empty canvas: free where it was let go.
let start = drawn(&s, k)[0];
s.pointer(&ev(PointerKind::Down, start + figcraft_geom::Vec2::new(3.0, 0.0))).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(100.0, 400.0))).unwrap();
s.pointer(&ev(PointerKind::Up, Point::new(100.0, 400.0))).unwrap();
assert_eq!(connector(&s, k).from, End::Point { x: 100.0, y: 400.0 });
// A click on an end, or a drop on the other end's object, changes nothing.
let before = connector(&s, k);
let end = *drawn(&s, k).last().unwrap();
s.pointer(&ev(PointerKind::Down, end)).unwrap();
s.pointer(&ev(PointerKind::Up, end)).unwrap();
assert_eq!(connector(&s, k), before);
s.execute("board.reattach", &json!({"id": k, "end": "from", "target": b})).unwrap();
let before = connector(&s, k);
s.pointer(&ev(PointerKind::Down, *drawn(&s, k).last().unwrap())).unwrap();
s.pointer(&ev(PointerKind::Move, Point::new(350.0, 50.0))).unwrap();
assert_eq!(s.tool.overlay.drop_target, None, "not onto the other end's object");
s.pointer(&ev(PointerKind::Up, Point::new(350.0, 50.0))).unwrap();
assert_eq!(connector(&s, k), before);
}
#[test]
fn curved_connectors_leave_and_arrive_along_the_sides() {
let (mut s, [a, _, c, k]) = three();
s.execute("board.setStyle", &json!({"ids": [k], "style": "curved"})).unwrap();
assert_eq!(connector(&s, k).routing, Routing::Curved);
let n = node(&s, k);
let NodeKind::Vector { network } = &n.kind else { panic!("a vector") };
assert_eq!((network.vertices.len(), network.segments.len()), (2, 1));
assert!(!network.segments[0].is_straight(), "one curve");
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]);
// Side to side in a line: the curve's control points lie on that line, so its box is flat.
assert!(n.height.abs() < 1e-9 && (n.width - 200.0).abs() < 1e-9, "{} × {}", n.width, n.height);
// From a's bottom to c's top: out downwards and in from above; the box holds the whole curve.
let k2 = id(
&s.execute("board.connector", &json!({"from": a, "to": c, "fromSide": "bottom", "toSide": "top", "style": "curved", "label": "x"})).unwrap(),
"id",
);
let pts = route(s.doc().unwrap(), &connector(&s, k2)).unwrap();
assert_eq!(pts.len(), 4);
assert!(pts[1].y > pts[0].y && pts[2].y < pts[3].y, "{pts:?}");
let b = s.doc().unwrap().world_bounds(k2).unwrap();
assert!(b.y0 <= 0.0 + 1e-9 && b.y1 >= 100.0 - 1e-9, "{b:?}");
// The label sits on the curve's middle (by length): here, by symmetry, the middle of the box.
let label = crate::board::label_of(s.doc().unwrap(), k2).unwrap();
let lc = s.doc().unwrap().world_bounds(label).unwrap().center();
assert!((lc - b.center()).hypot() < 0.5, "{lc:?} vs {:?}", b.center());
assert!(s.execute("board.setStyle", &json!({"ids": [k], "style": "wavy"})).is_err());
s.execute("tool.connector", &json!({"style": "curved"})).unwrap();
assert_eq!(s.tool.board.routing, Routing::Curved);
}
/// Whether `pts` (a right-angled route) runs through the inside of `r`.
fn through(pts: &[Point], r: Rect) -> bool {
pts.windows(2).any(|w| {
let (a, b) = (w[0], w[1]);
a.x.min(b.x) < r.x1 - 0.5 && a.x.max(b.x) > r.x0 + 0.5 && a.y.min(b.y) < r.y1 - 0.5 && a.y.max(b.y) > r.y0 + 0.5
})
}
#[test]
fn elbows_go_around_the_objects_they_join() {
let (mut s, [a, b, _, k]) = three();
// Out of a's left and b's right: the short way runs through both, so it goes over them.
s.execute("board.connector", &json!({"from": a, "to": b, "fromSide": "left", "toSide": "right", "style": "elbow"})).unwrap();
let k2 = s.selection()[0];
let pts = drawn(&s, k2);
let (ra, rb) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(300.0, 0.0, 400.0, 100.0));
assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}");
assert_eq!(pts.first().copied(), Some(Point::new(0.0, 50.0)));
assert_eq!(pts.last().copied(), Some(Point::new(400.0, 50.0)));
assert_eq!(pts.len(), 6, "out, over, down and in: {pts:?}");
assert!(pts.iter().all(|p| p.y <= 50.0), "over the top, the nearer way round: {pts:?}");
assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles only");
// Facing each other, the short way stays.
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]);
// An end at the bottom of b leaving towards a above it: around b's side and up.
s.execute("board.reattach", &json!({"id": k2, "end": "to", "target": b, "side": "bottom"})).unwrap();
let pts = drawn(&s, k2);
assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}");
assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0)));
}
#[test]
fn reattach_survives_junk() {
let (mut s, [_, _, _, k]) = three();
for p in [
json!({"id": k, "end": "to", "target": {"x": 1e308, "y": -1e308}}),
json!({"id": k, "end": "from", "target": {"x": f64::MAX, "y": 0}, "side": 5}),
json!({"id": [k], "end": ["to"], "target": [1]}),
json!({"ids": "x", "end": null, "target": null}),
json!({"id": k, "end": "to", "target": 99_999}),
] {
let _ = s.execute("board.reattach", &p);
let _ = s.execute("board.setStyle", &json!({"ids": [k], "style": "curved"}));
let _ = s.execute("board.reattach", &p);
}
assert!(matches!(node(&s, k).board, Some(BoardItem::Connector(_))));
assert!(drawn(&s, k).iter().all(|p| p.x.is_finite() && p.y.is_finite()));
}
+300
View File
@@ -0,0 +1,300 @@
//! Board tables: a frame of row frames holding cell frames with text, all ordinary auto layout
//! layers. The table hugs its rows' height, the rows share its width and the cells share their
//! row's width, so a cell that wraps more text makes its row taller. Each row but the last draws
//! a line under itself and the column lines are thin rectangles over the table that stretch with
//! it (they are rebuilt after every edit, like the first row's header tint).
use figcraft_color::{Color, Paint};
use figcraft_doc::{
AutoLayout, BoardItem, Constraints, Document, HConstraint, LayoutMode, Node, NodeId, NodeKind, Sizing, StrokeAlign, TextAlignH, VConstraint,
};
use figcraft_geom::Affine;
use serde_json::{Value, json};
use crate::board::{INK, add_text, require};
use crate::cmd::CommandSpec;
use crate::params::{get, id, num, num_or, opt_index, req_string};
use crate::{EngineError, Result, Session, bad, spec, textedit};
/// The most rows and columns a table has.
pub const MAX_ROWS: usize = 50;
pub const MAX_COLUMNS: usize = 20;
/// A new column's width, and a cell's height before its text is measured.
const CELL_W: f64 = 160.0;
const CELL_H: f64 = 40.0;
/// Room around a cell's text: [top, right, bottom, left].
const CELL_PAD: [f64; 4] = [10.0, 12.0, 10.0, 12.0];
const TEXT_SIZE: f64 = 14.0;
const LINE_W: f64 = 1.0;
const GRID: Color = Color::from_rgb8_const(0xCF, 0xCB, 0xC4);
const HEADER: Color = Color::from_rgb8_const(0xF1, 0xEF, 0xEB);
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"board.table",
"Table",
"{rows?, columns? (default 3 × 3, or the size of `cells`; at most 50 × 20), cells? ([[text…]…] row by row), x?, y? (its top-left on the page; default centred in the view)} → {id, rows, columns, cells: [[text id…]…]}",
true,
table_cmd
),
spec!(
"board.tableEdit",
"Edit table",
"{id? (a table or a layer in one; default the selection), action: addRow|addColumn|removeRow|removeColumn, index? (where the new one goes, or which one goes; default the last)} → {id, rows, columns, cells}",
true,
edit_cmd
),
]
}
/// A count from 1 to `max` (`default` when missing).
fn count(cmd: &str, p: &Value, key: &str, default: usize, max: usize) -> Result<usize> {
match num(p, key) {
None if get(p, key).is_none() => Ok(default),
Some(v) if v.fract() == 0.0 && (1.0..=max as f64).contains(&v) => Ok(v as usize),
_ => Err(bad(cmd, format!("`{key}` is a whole number from 1 to {max}"))),
}
}
/// A frame for the table at `index` in `parent`, made by `f`.
fn frame(doc: &mut Document, parent: NodeId, index: Option<usize>, name: &str, size: (f64, f64), f: impl FnOnce(&mut Node)) -> Result<NodeId> {
let id = doc.alloc_id();
let mut n = Node::new(id, NodeKind::Frame, name, size.0, size.1);
n.fills.clear();
n.clip = false;
f(&mut n);
doc.insert(n, parent, index)?;
Ok(id)
}
fn make_cell(doc: &mut Document, row: NodeId, index: Option<usize>, width: f64, text: &str) -> Result<NodeId> {
let cell = frame(doc, row, index, "Cell", (width, CELL_H), |n| {
n.layout = Some(AutoLayout { mode: LayoutMode::Vertical, gap: 0.0, padding: CELL_PAD, ..AutoLayout::default() });
n.child_layout.sizing_h = Sizing::Fill;
n.child_layout.sizing_v = Sizing::Hug;
n.child_layout.min_h = Some(CELL_H);
})?;
add_text(doc, cell, text, TEXT_SIZE, INK, TextAlignH::Left, (width - CELL_PAD[1] - CELL_PAD[3]).max(1.0))?;
Ok(cell)
}
fn make_row(doc: &mut Document, table: NodeId, index: Option<usize>, width: f64, texts: &[&str]) -> Result<NodeId> {
let row = frame(doc, table, index, "Row", (width, CELL_H), |n| {
n.layout = Some(AutoLayout { mode: LayoutMode::Horizontal, gap: 0.0, padding: [0.0; 4], ..AutoLayout::default() });
n.child_layout.sizing_h = Sizing::Fill;
n.child_layout.sizing_v = Sizing::Hug;
n.stroke.weight = LINE_W;
n.stroke.align = StrokeAlign::Inside;
n.stroke.sides = Some([0.0, 0.0, LINE_W, 0.0]);
})?;
let cw = width / texts.len().max(1) as f64;
for t in texts {
make_cell(doc, row, None, cw, t)?;
}
Ok(row)
}
/// A table of `texts` (rows of cell texts, all as long) at `at` on `parent`.
pub(crate) fn make_table(doc: &mut Document, parent: NodeId, at: (f64, f64), texts: &[Vec<&str>]) -> Result<NodeId> {
let columns = texts.first().map_or(1, Vec::len).max(1);
let w = CELL_W * columns as f64;
let table = frame(doc, parent, None, "Table", (w, CELL_H * texts.len() as f64), |n| {
n.transform = Affine::translate(at);
n.fills = vec![Paint::solid(Color::WHITE)];
n.strokes = vec![Paint::solid(GRID)];
n.stroke.weight = LINE_W;
n.stroke.align = StrokeAlign::Inside;
n.corners.radii = [4.0; 4];
n.clip = true;
// The outline draws over the rows' edges rather than taking room from them.
n.layout = Some(AutoLayout { mode: LayoutMode::Vertical, gap: 0.0, padding: [0.0; 4], strokes_included: false, ..AutoLayout::default() });
n.child_layout.sizing_v = Sizing::Hug;
n.board = Some(BoardItem::Table);
})?;
for row in texts {
make_row(doc, table, None, w, row)?;
}
restyle(doc, table)?;
Ok(table)
}
/// Table `table`'s rows (its laid-out children) and column lines (the others).
fn parts(doc: &Document, table: NodeId) -> (Vec<NodeId>, Vec<NodeId>) {
let kids = doc.node(table).map(|n| n.children.clone()).unwrap_or_default();
kids.into_iter().partition(|c| doc.node(*c).is_some_and(|n| !n.child_layout.absolute))
}
fn cells(doc: &Document, row: NodeId) -> Vec<NodeId> {
doc.node(row).map(|n| n.children.clone()).unwrap_or_default()
}
/// The first row tinted, a line under every row but the last, and the column lines over it all.
fn restyle(doc: &mut Document, table: NodeId) -> Result<()> {
let (rows, lines) = parts(doc, table);
for l in lines {
doc.remove(l)?;
}
let last = rows.len().saturating_sub(1);
for (i, r) in rows.iter().enumerate() {
let n = doc.get_mut(*r)?;
n.fills = if i == 0 { vec![Paint::solid(HEADER)] } else { Vec::new() };
n.strokes = if i < last { vec![Paint::solid(GRID)] } else { Vec::new() };
}
let columns = rows.first().map_or(0, |r| cells(doc, *r).len());
let (w, h) = doc.get(table).map(|t| (t.width, t.height))?;
for i in 1..columns {
let x = w * i as f64 / columns as f64 - LINE_W / 2.0;
let id = doc.alloc_id();
let mut n = Node::new(id, NodeKind::Rectangle, "Column line", LINE_W, h);
n.fills = vec![Paint::solid(GRID)];
n.transform = Affine::translate((x, 0.0));
n.child_layout.absolute = true;
n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::TopBottom };
n.locked = true;
doc.insert(n, table, None)?;
}
Ok(())
}
/// Each cell's text layer, row by row (null for a cell without one).
fn cell_texts(doc: &Document, table: NodeId) -> Value {
let text = |cell: NodeId| {
doc.node(cell).and_then(|n| n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_)))))
};
let (rows, _) = parts(doc, table);
Value::Array(rows.iter().map(|r| Value::Array(cells(doc, *r).into_iter().map(|c| json!(text(c))).collect())).collect())
}
fn answer(doc: &Document, table: NodeId) -> Value {
let (rows, _) = parts(doc, table);
let columns = rows.first().map_or(0, |r| cells(doc, *r).len());
json!({"id": table, "rows": rows.len(), "columns": columns, "cells": cell_texts(doc, table)})
}
fn table_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.table";
require(s, cmd)?;
let given: Vec<Vec<String>> = match get(p, "cells") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(rows)) => rows
.iter()
.map(|r| match r {
Value::Array(c) => c.iter().map(|t| t.as_str().map(str::to_string).ok_or_else(|| bad(cmd, "`cells` holds text"))).collect(),
_ => Err(bad(cmd, "`cells` is a list of rows, each a list of texts")),
})
.collect::<Result<_>>()?,
Some(_) => return Err(bad(cmd, "`cells` is a list of rows, each a list of texts")),
};
let widest = given.iter().map(Vec::len).max().unwrap_or(0);
let rows = count(cmd, p, "rows", if given.is_empty() { 3 } else { given.len() }, MAX_ROWS)?;
let columns = count(cmd, p, "columns", if widest == 0 { 3 } else { widest }, MAX_COLUMNS)?;
if given.len() > rows || widest > columns {
return Err(bad(cmd, format!("`cells` doesn't fit in {rows} × {columns}")));
}
let texts: Vec<Vec<&str>> =
(0..rows).map(|r| (0..columns).map(|c| given.get(r).and_then(|row| row.get(c)).map_or("", String::as_str)).collect()).collect();
let (w, h) = (CELL_W * columns as f64, CELL_H * rows as f64);
let view = &s.ds()?.view;
let at = (num_or(p, "x", view.cx - w / 2.0), num_or(p, "y", view.cy - h / 2.0));
if !(at.0.is_finite() && at.1.is_finite()) {
return Err(bad(cmd, "`x` and `y` are finite numbers"));
}
let page = s.page()?;
let doc = s.doc_mut()?;
let table = make_table(doc, page, at, &texts)?;
let out = answer(doc, table);
s.set_selection(vec![table]);
Ok(out)
}
/// The table `id` is or is in.
fn table_of(doc: &Document, id: NodeId) -> Option<NodeId> {
std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(|n| n.board == Some(BoardItem::Table)))
}
fn edit_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.tableEdit";
require(s, cmd)?;
let target = id(p, "id").or_else(|| s.selection().first().copied()).ok_or_else(|| EngineError::Disabled(cmd.into(), "select a table".into()))?;
let doc = s.doc()?;
doc.get(target)?;
let table = table_of(doc, target).ok_or_else(|| bad(cmd, "that isn't a table"))?;
let action = req_string(cmd, p, "action")?;
let index = opt_index(cmd, p, "index")?;
let (rows, _) = parts(doc, table);
let columns = rows.first().map_or(0, |r| cells(doc, *r).len()).max(1);
let width = doc.get(table)?.width;
let doc = s.doc_mut()?;
// `n` things: `index` (default `last`) must be at most `end`.
let at = |end: usize, last: usize| match index {
Some(i) if i > end => Err(bad(cmd, format!("`index` is from 0 to {end}"))),
Some(i) => Ok(i),
None => Ok(last),
};
match action.as_str() {
"addRow" => {
if rows.len() >= MAX_ROWS {
return Err(bad(cmd, format!("a table has at most {MAX_ROWS} rows")));
}
let i = at(rows.len(), rows.len())?;
// Rows come before the column lines among the table's layers.
let child = rows.get(i).or(rows.last()).and_then(|r| doc.node(table).and_then(|t| t.children.iter().position(|c| c == r)));
let child = child.map(|c| if i >= rows.len() { c + 1 } else { c });
make_row(doc, table, child, width, &vec![""; columns])?;
}
"removeRow" => {
if rows.len() <= 1 {
return Err(bad(cmd, "a table keeps at least one row"));
}
let i = at(rows.len() - 1, rows.len() - 1)?;
if let Some(r) = rows.get(i) {
doc.remove(*r)?;
}
}
"addColumn" => {
if columns >= MAX_COLUMNS {
return Err(bad(cmd, format!("a table has at most {MAX_COLUMNS} columns")));
}
let i = at(columns, columns)?;
let cw = width / columns as f64;
doc.get_mut(table)?.width = width + cw;
for r in &rows {
make_cell(doc, *r, Some(i), cw, "")?;
}
}
"removeColumn" => {
if columns <= 1 {
return Err(bad(cmd, "a table keeps at least one column"));
}
let i = at(columns - 1, columns - 1)?;
doc.get_mut(table)?.width = width - width / columns as f64;
for r in &rows {
if let Some(c) = cells(doc, *r).get(i) {
doc.remove(*c)?;
}
}
}
other => return Err(bad(cmd, format!("`action` is addRow, addColumn, removeRow or removeColumn, not `{other}`"))),
}
restyle(doc, table)?;
let out = answer(doc, table);
s.set_selection(vec![table]);
Ok(out)
}
/// A double-click on a table: type in the cell under the pointer (`leaf`). False when it hit no
/// cell (a column line, the table's edge).
pub(crate) fn edit_cell(s: &mut Session, doc: &Document, leaf: NodeId) -> Result<bool> {
let in_table = |c: &NodeId| {
let row = doc.node(*c).and_then(|n| n.parent);
row.and_then(|r| doc.node(r)).and_then(|r| r.parent).and_then(|t| doc.node(t)).is_some_and(|t| t.board == Some(BoardItem::Table))
};
let Some(cell) = std::iter::once(leaf).chain(doc.ancestors(leaf)).find(in_table) else { return Ok(false) };
let Some(text) = cells(doc, cell).into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) else {
return Ok(false);
};
textedit::begin(s, text, None, false)?;
textedit::key(s, "SelectAll", false, false, false)?;
Ok(true)
}
+257
View File
@@ -0,0 +1,257 @@
//! Boards: templates (each placed as board objects in one undo step) and tables (made from auto
//! layout frames, edited by rows and columns, typed into with the text tools, synced live).
use figcraft_doc::{BoardItem, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::board_tests::{board, id, node};
use crate::collab_live_tests::Room;
use crate::tools::{PointerEvent, PointerKind};
use crate::{EngineError, Session};
fn count(s: &Session, f: impl Fn(&BoardItem) -> bool) -> usize {
s.doc().unwrap().nodes().filter(|n| n.board.as_ref().is_some_and(&f)).count()
}
fn ids(v: &Value, key: &str) -> Vec<NodeId> {
v[key].as_array().unwrap().iter().map(|i| NodeId(i.as_u64().unwrap())).collect()
}
/// Rows of cell texts' characters.
fn texts(s: &Session, table: &Value) -> Vec<Vec<String>> {
let chars = |v: &Value| match &node(s, NodeId(v.as_u64().unwrap())).kind {
NodeKind::Text(td) => td.characters.clone(),
_ => panic!("not text"),
};
table["cells"].as_array().unwrap().iter().map(|r| r.as_array().unwrap().iter().map(chars).collect()).collect()
}
/// A table's column lines (its laid-out-over children): their x and height.
fn lines(s: &Session, table: NodeId) -> Vec<(f64, f64)> {
let doc = s.doc().unwrap();
let t = doc.node(table).unwrap();
t.children.iter().map(|c| doc.node(*c).unwrap()).filter(|n| n.child_layout.absolute).map(|n| (n.transform.translation().x, n.height)).collect()
}
#[test]
fn every_template_places_board_objects_in_one_undo_step() {
let mut s = board();
let list = s.execute("board.templates", &json!({})).unwrap();
let names: Vec<&str> = list.as_array().unwrap().iter().map(|t| t["id"].as_str().unwrap()).collect();
assert_eq!(names, ["retrospective", "brainstorm", "weeklyPlanner", "flowChart", "kanban"]);
for (i, name) in names.iter().enumerate() {
let before = s.doc().unwrap().nodes().count();
let r = s.execute("board.template", &json!({"name": name, "x": i as f64 * 2000.0, "y": 0})).unwrap();
let made = ids(&r, "ids");
assert!(!made.is_empty(), "{name}");
assert_eq!(s.selection(), made, "{name}: what it placed is selected");
let doc = s.doc().unwrap();
// It sits where it was asked to, about its listed size.
let b = doc.bounds_of(&made).unwrap();
assert!((b.x0 - i as f64 * 2000.0).abs() < 1.0 && b.y0.abs() < 1.0, "{name} at {b:?}");
let size = &list[i];
assert!((b.width() - size["width"].as_f64().unwrap()).abs() < 2.0, "{name}: width {}", b.width());
assert!(b.height() <= size["height"].as_f64().unwrap() + 2.0, "{name}: height {}", b.height());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().nodes().count(), before, "{name}: one undo step");
s.execute("edit.redo", &json!({})).unwrap();
}
// By name too, centred in the view without a place.
let view = s.ds().unwrap().view.clone();
let r = s.execute("board.template", &json!({"name": "Kanban board"})).unwrap();
let b = s.doc().unwrap().bounds_of(&ids(&r, "ids")).unwrap();
assert!((b.center().x - view.cx).abs() < 2.0, "{b:?} centred on {}", view.cx);
assert!(s.execute("board.template", &json!({"name": "party"})).is_err());
assert!(s.execute("board.template", &json!({})).is_err());
let mut d = Session::new();
d.execute("file.new", &json!({})).unwrap();
assert!(matches!(d.execute("board.template", &json!({"name": "kanban"})), Err(EngineError::Disabled(..))), "boards only");
assert!(matches!(d.execute("board.table", &json!({})), Err(EngineError::Disabled(..))), "boards only");
}
#[test]
fn templates_are_made_of_ordinary_board_objects() {
let mut s = board();
s.execute("board.template", &json!({"name": "retrospective", "x": 0, "y": 0})).unwrap();
let doc = s.doc().unwrap();
let sections: Vec<_> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Section { .. })).collect();
assert_eq!(sections.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["Went well", "To improve", "Next steps"]);
for sec in &sections {
let stickies = sec.children.iter().filter(|c| matches!(doc.node(**c).unwrap().board, Some(BoardItem::Sticky { .. }))).count();
assert_eq!(stickies, 2, "{}", sec.name);
}
// No author on a template's stickies.
assert!(doc.nodes().filter(|n| matches!(n.board, Some(BoardItem::Sticky { .. }))).all(|n| n.board.as_ref().unwrap().author().is_empty()));
// The flow chart's connectors join its shapes, labelled at the decision.
s.execute("board.template", &json!({"name": "flowChart", "x": 0, "y": 1000})).unwrap();
let doc = s.doc().unwrap();
let joined: Vec<_> = doc
.nodes()
.filter_map(|n| match &n.board {
Some(BoardItem::Connector(c)) => Some((c.from.node().unwrap(), c.to.node().unwrap())),
_ => None,
})
.collect();
assert_eq!(joined.len(), 6);
assert!(joined.iter().all(|(a, b)| matches!(doc.node(*a).unwrap().board, Some(BoardItem::Shape { .. }))
&& matches!(doc.node(*b).unwrap().board, Some(BoardItem::Shape { .. }))));
assert_eq!(count(&s, |b| matches!(b, BoardItem::Label { .. })), 2);
// The brainstorm's topic is joined to each of its four clusters.
s.execute("board.template", &json!({"name": "brainstorm", "x": 3000, "y": 0})).unwrap();
assert_eq!(count(&s, |b| matches!(b, BoardItem::Connector(_))), 10);
}
#[test]
fn a_table_is_auto_layout_frames_with_a_header_and_column_lines() {
let mut s = board();
let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [["Name", "Role"], ["Ada", "Lead"]], "rows": 3})).unwrap();
let t = id(&r, "id");
assert_eq!((r["rows"].as_u64(), r["columns"].as_u64()), (Some(3), Some(2)));
assert_eq!(texts(&s, &r), [["Name", "Role"], ["Ada", "Lead"], ["", ""]]);
assert_eq!(node(&s, t).board, Some(BoardItem::Table));
assert_eq!(s.selection(), [t]);
let table = node(&s, t);
assert_eq!(table.width, 320.0);
let rows: Vec<_> = table.children.iter().map(|c| node(&s, *c)).filter(|n| !n.child_layout.absolute).collect();
assert_eq!(rows.len(), 3);
assert!(rows[0].fills.len() == 1 && rows[1].fills.is_empty(), "the header row is tinted");
assert!(rows[1].strokes.len() == 1 && rows[2].strokes.is_empty(), "a line under every row but the last");
let cell = node(&s, rows[1].children[1]);
assert_eq!((cell.width, cell.transform.translation().x), (160.0, 160.0), "cells share their row");
assert_eq!(table.height, rows.iter().map(|r| r.height).sum::<f64>(), "the table hugs its rows");
assert_eq!(lines(&s, t), [(159.5, table.height)], "the column line runs the table's height");
// Caps and refusals.
for bad in
[json!({"rows": 51}), json!({"columns": 21}), json!({"rows": 0}), json!({"rows": 2.5}), json!({"cells": [["a", "b", "c"]], "columns": 2})]
{
assert!(s.execute("board.table", &bad).is_err(), "{bad}");
}
assert!(s.execute("board.table", &json!({"rows": 50, "columns": 20})).is_ok(), "the largest table");
// It saves and reopens.
let doc = s.doc().unwrap();
let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap();
assert_eq!(back.node(t).unwrap().board, Some(BoardItem::Table));
}
#[test]
fn table_edits_add_and_remove_rows_and_columns_keeping_text() {
let mut s = board();
let r = s.execute("board.table", &json!({"x": 0, "y": 0, "cells": [["a", "b"], ["c", "d"]]})).unwrap();
let t = id(&r, "id");
let edit = |s: &mut Session, p: Value| s.execute("board.tableEdit", &p);
let r = edit(&mut s, json!({"id": t, "action": "addRow", "index": 1})).unwrap();
assert_eq!(texts(&s, &r), [["a", "b"], ["", ""], ["c", "d"]]);
let r = edit(&mut s, json!({"action": "addColumn", "index": 0})).unwrap();
assert_eq!(texts(&s, &r), [["", "a", "b"], ["", "", ""], ["", "c", "d"]]);
assert_eq!(node(&s, t).width, 480.0, "a new column as wide as the others");
assert_eq!(lines(&s, t).len(), 2);
let r = edit(&mut s, json!({"action": "removeRow", "index": 0})).unwrap();
assert_eq!(texts(&s, &r), [["", "", ""], ["", "c", "d"]]);
let first = node(&s, node(&s, t).children[0]);
assert_eq!(first.fills.len(), 1, "the new first row is the header");
let r = edit(&mut s, json!({"action": "removeColumn"})).unwrap();
assert_eq!(texts(&s, &r), [["", ""], ["", "c"]]);
assert_eq!(node(&s, t).width, 320.0);
// From a cell inside it too.
let cell_text = NodeId(r["cells"][1][1].as_u64().unwrap());
let r = edit(&mut s, json!({"id": cell_text, "action": "addRow"})).unwrap();
assert_eq!(r["rows"], 3);
// Undo, one step each.
s.execute("edit.undo", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, t).width, 480.0);
// Refusals: the wrong action or index, the last row or column, beyond the caps, not a table.
for p in [
json!({"id": t, "action": "explode"}),
json!({"id": t, "action": "addRow", "index": 9}),
json!({"id": t, "action": "removeColumn", "index": 3}),
json!({"id": t, "action": "addRow", "index": -1}),
json!({"id": t}),
] {
assert!(edit(&mut s, p.clone()).is_err(), "{p}");
}
let one = id(&s.execute("board.table", &json!({"rows": 1, "columns": 1})).unwrap(), "id");
assert!(edit(&mut s, json!({"id": one, "action": "removeRow"})).is_err());
assert!(edit(&mut s, json!({"id": one, "action": "removeColumn"})).is_err());
let full = id(&s.execute("board.table", &json!({"rows": 50, "columns": 20})).unwrap(), "id");
assert!(edit(&mut s, json!({"id": full, "action": "addRow"})).is_err());
assert!(edit(&mut s, json!({"id": full, "action": "addColumn"})).is_err());
let sticky = id(&s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap(), "id");
assert!(edit(&mut s, json!({"id": sticky, "action": "addRow"})).is_err());
}
#[test]
fn a_double_click_types_in_the_cell_and_its_row_grows_with_the_text() {
let mut s = board();
let r = s.execute("board.table", &json!({"x": 0, "y": 0})).unwrap();
let t = id(&r, "id");
let h0 = node(&s, t).height;
s.execute("select.none", &json!({})).unwrap();
// The middle cell (columns 160 wide, rows about 40 tall).
let row_h = h0 / 3.0;
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 240.0, row_h * 1.5, 1.0)).unwrap();
let te = s.ds().unwrap().text_edit.clone().expect("typing in the cell");
assert_eq!(te.id.0, r["cells"][1][1].as_u64().unwrap());
crate::textedit::insert(
&mut s,
"A longer note
over
several lines",
)
.unwrap();
crate::textedit::end(&mut s).unwrap();
let h1 = node(&s, t).height;
assert!(h1 > h0 + 10.0, "the row grew: {h0} → {h1}");
assert!(lines(&s, t).iter().all(|(_, h)| (*h - h1).abs() < 1e-6), "column lines follow: {:?} vs {h1}", lines(&s, t));
// A plain click picks the table whole.
s.execute("select.none", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 240.0, 10.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 240.0, 10.0, 1.0)).unwrap();
assert_eq!(s.selection(), [t]);
}
#[test]
fn tables_and_templates_sync_in_live_sessions() {
let s = board();
let mut room = Room::new(s.doc().unwrap().clone(), 2, 11);
let t = id(&room.s(0).execute("board.table", &json!({"x": 0, "y": 0})).unwrap(), "id");
room.s(1).execute("board.template", &json!({"name": "flowChart", "x": 0, "y": 600})).unwrap();
room.settle();
room.check();
room.s(1).execute("board.tableEdit", &json!({"id": t, "action": "addColumn"})).unwrap();
room.settle();
room.check();
let doc = room.server.document();
assert_eq!(doc.node(t).unwrap().width, 640.0);
assert_eq!(doc.nodes().filter(|n| matches!(n.board, Some(BoardItem::Connector(_)))).count(), 6);
}
#[test]
fn template_and_table_commands_survive_junk() {
let junk = [
json!({}),
json!(null),
json!([1]),
json!({"name": 7, "x": "NaN", "y": 1e308, "rows": "x", "columns": -1, "cells": 5}),
json!({"name": "kanban", "x": -1e308, "y": 1e308, "rows": 1e308, "columns": 0.5, "cells": [[1, 2]]}),
json!({"name": "\u{0}", "cells": [["a"], "b"], "rows": 50, "columns": 20}),
json!({"id": "x", "action": 5, "index": 1e308}),
json!({"id": -1, "action": "addRow", "index": -1}),
json!({"id": 99_999, "action": "removeColumn", "index": "NaN"}),
json!({"id": 2, "action": "addColumn", "index": 0}),
];
for cmd in ["board.template", "board.templates", "board.table", "board.tableEdit"] {
for j in &junk {
let mut s = board();
let t = s.execute("board.table", &json!({"x": 0, "y": 0})).unwrap()["id"].clone();
let _ = s.execute("board.template", &json!({"name": "brainstorm"}));
let mut j = j.clone();
if j.get("action").is_some_and(|a| a == "addColumn") {
j["id"] = t;
}
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, &j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}
+261
View File
@@ -0,0 +1,261 @@
//! Board templates: ready-made layouts (a retrospective, a brainstorm, a weekly planner, a flow
//! chart, a kanban board) placed on a board as ordinary board objects in one undo step. They are
//! built in code by running the board commands, so everything in them edits like anything else on
//! the board; their wording is FigCraft's own.
use std::sync::Arc;
use figcraft_doc::{Document, NodeId};
use serde_json::{Value, json};
use crate::board::require;
use crate::cmd::CommandSpec;
use crate::params::{id_of, num, string};
use crate::{EngineError, Result, Session, bad, spec};
/// A board template.
pub struct BoardTemplate {
pub id: &'static str,
pub name: &'static str,
/// One line about what is in it.
pub description: &'static str,
/// Its width and height on the board.
pub size: (f64, f64),
make: fn(&mut Builder) -> Result<()>,
}
pub static TEMPLATES: &[BoardTemplate] = &[
BoardTemplate {
id: "retrospective",
name: "Retrospective",
description: "Three sections to look back on a stretch of work: what went well, what to improve, what to do next",
size: (1280.0, 592.0),
make: retrospective,
},
BoardTemplate {
id: "brainstorm",
name: "Brainstorm",
description: "A question in the middle and four clusters of ideas around it",
size: (1344.0, 760.0),
make: brainstorm,
},
BoardTemplate {
id: "weeklyPlanner",
name: "Weekly planner",
description: "A column for each working day with a note to start from",
size: (1380.0, 632.0),
make: weekly_planner,
},
BoardTemplate {
id: "flowChart",
name: "Flow chart",
description: "Steps, a decision and its two outcomes joined by connectors",
size: (1220.0, 360.0),
make: flow_chart,
},
BoardTemplate {
id: "kanban",
name: "Kanban board",
description: "To do, Doing and Done columns with cards to move along",
size: (900.0, 812.0),
make: kanban,
},
];
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"board.template",
"Board template",
"{name (a template id from board.templates), x?, y? (its top-left on the page; default centred in the view)} → {name, ids} its sections, stickies, shapes, connectors and text, selected",
true,
template_cmd
),
spec!("board.templates", "List board templates", "{} → [{id, name, description, width, height}]", false, |_, _| {
Ok(Value::Array(
TEMPLATES
.iter()
.map(|t| json!({"id": t.id, "name": t.name, "description": t.description, "width": t.size.0, "height": t.size.1}))
.collect(),
))
}),
]
}
/// The template called `name` (its id, or its name in any case).
pub fn find(name: &str) -> Option<&'static BoardTemplate> {
let name = name.trim();
TEMPLATES.iter().find(|t| t.id.eq_ignore_ascii_case(name) || t.name.eq_ignore_ascii_case(name))
}
fn template_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.template";
require(s, cmd)?;
let names = || TEMPLATES.iter().map(|t| t.id).collect::<Vec<_>>().join(", ");
let name = string(p, "name").ok_or_else(|| bad(cmd, format!("`name` is one of {}", names())))?;
let t = find(&name).ok_or_else(|| bad(cmd, format!("no template `{name}`: one of {}", names())))?;
let view = &s.ds()?.view;
let at = (num(p, "x").unwrap_or(view.cx - t.size.0 / 2.0), num(p, "y").unwrap_or(view.cy - t.size.1 / 2.0));
if !(at.0.is_finite() && at.1.is_finite()) {
return Err(bad(cmd, "`x` and `y` are finite numbers"));
}
let page = s.page()?;
let mut b = Builder { s, page, at, made: Vec::new() };
(t.make)(&mut b)?;
let made = b.made;
s.set_selection(made.clone());
Ok(json!({"name": t.id, "ids": made}))
}
/// A board with template `id` at the page's origin and nothing else (the picker's previews).
pub fn preview(id: &str) -> Result<Document> {
let mut s = Session::new();
s.execute("file.newBoard", &json!({"name": id}))?;
s.execute("board.template", &json!({"name": id, "x": 0, "y": 0}))?;
let ds = s.docs.pop().ok_or(EngineError::NoDocument)?;
Ok(Arc::try_unwrap(ds.doc).unwrap_or_else(|d| (*d).clone()))
}
/// Places a template's objects: positions are from its top-left (`at` on the page), or inside the
/// section they go in.
struct Builder<'a> {
s: &'a mut Session,
page: NodeId,
at: (f64, f64),
/// What it placed on the page.
made: Vec<NodeId>,
}
impl Builder<'_> {
/// Run board command `cmd` (in this command's undo step); the layer it made.
fn run(&mut self, cmd: &str, p: Value) -> Result<NodeId> {
let spec = crate::cmd::find_command(cmd).ok_or_else(|| EngineError::UnknownCommand(cmd.into()))?;
let v = (spec.run)(self.s, &p)?;
v.get("id").and_then(id_of).ok_or_else(|| EngineError::Other(format!("`{cmd}` made no layer")))
}
/// A layer on the page at (`x`, `y`) from the template's corner.
fn top(&mut self, cmd: &str, x: f64, y: f64, p: Value) -> Result<NodeId> {
let id = self.run(cmd, placed(p, self.page, (self.at.0 + x, self.at.1 + y)))?;
self.made.push(id);
Ok(id)
}
fn title(&mut self, text: &str) -> Result<NodeId> {
self.top("node.create", 0.0, 0.0, json!({"type": "text", "text": text, "props": {"fontSize": 32, "fills": ["#1E1E1E"]}}))
}
fn section(&mut self, name: &str, (x, y, w, h): (f64, f64, f64, f64), fill: &str) -> Result<NodeId> {
self.top("node.create", x, y, json!({"type": "section", "name": name, "width": w, "height": h, "props": {"fills": [fill]}}))
}
/// A sticky in `section` (or on the page) at (`x`, `y`) in it.
fn sticky(&mut self, section: Option<NodeId>, (x, y): (f64, f64), text: &str, color: &str) -> Result<NodeId> {
let p = json!({"text": text, "color": color, "author": false});
match section {
Some(sec) => self.run("board.sticky", placed(p, sec, (x, y))),
None => self.top("board.sticky", x, y, p),
}
}
fn shape(&mut self, shape: &str, (x, y, w, h): (f64, f64, f64, f64), text: &str, color: &str) -> Result<NodeId> {
self.top("board.shape", x, y, json!({"shape": shape, "width": w, "height": h, "text": text, "color": color}))
}
fn connector(&mut self, from: NodeId, to: NodeId, extra: Value) -> Result<NodeId> {
let id = self.run("board.connector", with(extra, [("from", json!(from)), ("to", json!(to))]))?;
self.made.push(id);
Ok(id)
}
}
/// Params `p` with `entries` added (`p` an object).
fn with<const N: usize>(mut p: Value, entries: [(&str, Value); N]) -> Value {
if let Some(o) = p.as_object_mut() {
o.extend(entries.map(|(k, v)| (k.to_string(), v)));
}
p
}
/// Params `p` placing a layer in `parent` at `at`.
fn placed(p: Value, parent: NodeId, at: (f64, f64)) -> Value {
with(p, [("parent", json!(parent)), ("x", json!(at.0)), ("y", json!(at.1))])
}
fn retrospective(b: &mut Builder) -> Result<()> {
b.title("Retrospective")?;
let parts = [
("Went well", "#EAF6E1", "green", ["Something that helped us", "A win worth repeating"]),
("To improve", "#FCE8EF", "pink", ["Something that slowed us down", "A question we still have"]),
("Next steps", "#E6F0FC", "blue", ["One change to try next", "Who looks after it?"]),
];
for (i, (name, fill, color, notes)) in parts.into_iter().enumerate() {
let sec = b.section(name, (i as f64 * 440.0, 72.0, 400.0, 520.0), fill)?;
for (j, note) in notes.into_iter().enumerate() {
b.sticky(Some(sec), (100.0, 60.0 + j as f64 * 230.0), note, color)?;
}
}
Ok(())
}
fn brainstorm(b: &mut Builder) -> Result<()> {
let topic = b.shape("ellipse", (532.0, 300.0, 280.0, 160.0), "What are we trying to solve?", "violet")?;
let clusters = [((0.0, 0.0), "yellow", 1), ((920.0, 0.0), "orange", 0), ((0.0, 560.0), "green", 1), ((920.0, 560.0), "blue", 0)];
for ((x, y), color, near) in clusters {
let notes = [b.sticky(None, (x, y), "An idea", color)?, b.sticky(None, (x + 224.0, y), "Build on it", color)?];
if let Some(n) = notes.get(near) {
b.connector(topic, *n, json!({"style": "straight", "endCap": "none"}))?;
}
}
Ok(())
}
fn weekly_planner(b: &mut Builder) -> Result<()> {
b.title("This week")?;
let days = [
("Monday", "Plan the week"),
("Tuesday", "Focus for the day"),
("Wednesday", "Focus for the day"),
("Thursday", "Focus for the day"),
("Friday", "Wrap up and share"),
];
for (i, (day, note)) in days.into_iter().enumerate() {
let sec = b.section(day, (i as f64 * 280.0, 72.0, 260.0, 560.0), "#FFFFFF")?;
b.sticky(Some(sec), (30.0, 60.0), note, if i % 2 == 0 { "yellow" } else { "orange" })?;
}
Ok(())
}
fn flow_chart(b: &mut Builder) -> Result<()> {
let start = b.shape("ellipse", (0.0, 30.0, 160.0, 100.0), "Start", "green")?;
let step = b.shape("rectangle", (240.0, 20.0, 180.0, 120.0), "Do the first step", "blue")?;
let check = b.shape("diamond", (500.0, 0.0, 200.0, 160.0), "Did it work?", "yellow")?;
let carry = b.shape("roundedRectangle", (800.0, 20.0, 180.0, 120.0), "Carry on", "blue")?;
let done = b.shape("ellipse", (1060.0, 30.0, 160.0, 100.0), "Done", "green")?;
let other = b.shape("roundedRectangle", (510.0, 240.0, 180.0, 120.0), "Try another way", "orange")?;
let elbow = json!({"style": "elbow"});
b.connector(start, step, elbow.clone())?;
b.connector(step, check, elbow.clone())?;
b.connector(check, carry, json!({"style": "elbow", "label": "Yes"}))?;
b.connector(carry, done, elbow)?;
b.connector(check, other, json!({"style": "elbow", "fromSide": "bottom", "toSide": "top", "label": "No"}))?;
// Back round to the step: out of the side, into the step from below.
b.connector(other, step, json!({"style": "elbow", "fromSide": "left", "toSide": "bottom"}))?;
Ok(())
}
fn kanban(b: &mut Builder) -> Result<()> {
b.title("Team board")?;
let columns: [(&str, &str, &[&str]); 3] = [
("To do", "yellow", &["Write the brief", "Sketch the first screens", "Plan a test session"]),
("Doing", "blue", &["Review the copy"]),
("Done", "green", &["Set up the board"]),
];
for (i, (name, color, cards)) in columns.into_iter().enumerate() {
let sec = b.section(name, (i as f64 * 310.0, 72.0, 280.0, 740.0), "#FFFFFF")?;
for (j, card) in cards.iter().enumerate() {
b.sticky(Some(sec), (40.0, 60.0 + j as f64 * 220.0), card, color)?;
}
}
Ok(())
}
+2
View File
@@ -277,6 +277,8 @@ fn board_commands_survive_junk() {
json!({"ids": ["x", -1, 99_999], "color": "#GGG", "fontSize": -1, "side": "up", "edit": "yes"}),
json!({"id": 2, "text": "", "path": "M0 0 L", "points": [[1]], "highlighter": "yes"}),
json!({"path": "M0 0 L 1e308 1e308", "points": [[0, 0], [1e308, 1]]}),
json!({"end": "from", "target": {"x": 1e308, "y": -1e308}, "side": "inside", "style": "curved"}),
json!({"id": 99_999, "end": 7, "target": [1], "style": "wavy"}),
];
let ids: Vec<&str> = crate::cmd::all_commands()
.iter()
+240
View File
@@ -0,0 +1,240 @@
//! Branches of a file in a live session: the branch stays with its author and makes ids in their
//! own client space; merging is one synced edit (and one step of the author's undo history) that
//! everyone receives; peers' edits on main while the branch is open merge against the branch's own
//! base, and conflicts with them resolve as offline; update from main takes them in; a view link
//! still refuses merging; a branch made outside the session that adds layers is refused instead
//! of being sent for the server to refuse. Plus a fuzz test with random real commands.
use figcraft_collab::sim::Rng;
use figcraft_doc::ids::space_of;
use figcraft_doc::{Document, NodeId};
use serde_json::{Map, Value, json};
use crate::Session;
use crate::branch_tests::{base_with_lists, random_edit};
use crate::collab_live_tests::{Room, frame_and_rect};
use crate::collab_tests::base;
use crate::live::{Access, Event, Role, VIEW_ONLY};
use crate::live_tests::{info, push, relay, serve_as, session};
fn ok(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn me(s: &Session) -> u32 {
s.live().client().unwrap().id()
}
fn version_names(s: &mut Session) -> Vec<String> {
ok(s, "version.list", json!({})).as_array().unwrap().iter().map(|v| v["name"].as_str().unwrap().to_string()).collect()
}
/// The conflict keys of branch `id`'s review.
fn conflict_keys(s: &mut Session, id: &str) -> Vec<String> {
let review = ok(s, "branch.review", json!({"id": id}));
review["conflicts"].as_array().unwrap().iter().map(|c| c["key"].as_str().unwrap().to_string()).collect()
}
#[test]
fn a_branch_merges_into_the_session_as_one_synced_edit() {
let mut room = Room::new(base(), 2, 7);
let (f, r) = frame_and_rect(room.server.document());
let you = me(room.s(0));
let id = ok(room.s(0), "branch.create", json!({"name": "Explore"}))["id"].as_str().unwrap().to_string();
// On the branch tab: a recolour, a new layer and a new style.
ok(room.s(0), "fill.set", json!({"ids": [r], "color": "#FF0000"}));
let made = NodeId(ok(room.s(0), "shape.rectangle", json!({"x": 900, "y": 900, "width": 10, "height": 10}))["id"].as_u64().unwrap());
assert_eq!(space_of(made.0), you, "the branch makes ids in your own client space");
ok(room.s(0), "style.create", json!({"type": "paint", "name": "Brand", "from": r}));
room.settle();
assert!(!room.server.document().contains(made), "the branch stays with its author");
// Someone else edits the main file meanwhile.
ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Renamed by a peer"}}));
room.settle();
assert_eq!(conflict_keys(room.s(0), &id), Vec::<String>::new());
assert_eq!(ok(room.s(0), "branch.merge", json!({"id": id}))["merged"], true);
room.settle();
room.check();
let doc = room.server.document();
assert!(doc.contains(made), "everyone has the merge");
assert_eq!(doc.node(f).unwrap().name, "Renamed by a peer", "the peer's edit stays");
assert!(doc.styles.iter().any(|st| st.name == "Brand"));
assert_ne!(doc.node(r).unwrap().fills, base().node(r).unwrap().fills);
// The merge is one step of the author's own undo history.
ok(room.s(0), "edit.undo", json!({}));
room.settle();
room.check();
assert!(!room.server.document().contains(made));
assert_eq!(room.server.document().node(f).unwrap().name, "Renamed by a peer");
}
#[test]
fn conflicts_with_peers_resolve_as_offline() {
let mut room = Room::new(base(), 2, 3);
let (f, r) = frame_and_rect(room.server.document());
let r2 = room.server.document().node(f).unwrap().children[1];
let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string();
ok(room.s(0), "node.set", json!({"id": r, "props": {"name": "Kept on the branch"}}));
ok(room.s(0), "node.set", json!({"id": r2, "props": {"opacity": 0.5}}));
// A peer deletes the layer the branch edited and sets the same property another way.
ok(room.s(1), "edit.delete", json!({"ids": [r]}));
ok(room.s(1), "node.set", json!({"id": r2, "props": {"opacity": 0.2}}));
room.settle();
let keys = conflict_keys(room.s(0), &id);
let mut want = vec![format!("{r}/layer"), format!("{r2}/opacity")];
want.sort();
let mut got = keys.clone();
got.sort();
assert_eq!(got, want);
assert!(room.s(0).execute("branch.merge", &json!({"id": id})).is_err(), "conflicts first");
// The branch's edits win the deletion, main wins the opacity.
let picks: Map<String, Value> = keys.iter().map(|k| (k.clone(), json!(if k.ends_with("/layer") { "branch" } else { "main" }))).collect();
ok(room.s(0), "branch.merge", json!({"id": id, "resolutions": picks}));
room.settle();
room.check();
let doc = room.server.document();
assert_eq!(doc.node(r).map(|n| n.name.as_str()), Some("Kept on the branch"), "the deleted layer is back, with the branch's edit");
assert_eq!(doc.node(r2).unwrap().opacity, 0.2);
}
#[test]
fn update_from_main_takes_in_peers_edits_and_shows_in_the_branch_history() {
let mut room = Room::new(base(), 2, 5);
let (f, r) = frame_and_rect(room.server.document());
let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string();
ok(room.s(0), "node.set", json!({"id": r, "props": {"name": "Branch"}}));
ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Peer 1"}}));
room.settle();
assert_eq!(ok(room.s(0), "branch.updateFromMain", json!({}))["updated"], true);
assert_eq!(room.s(0).doc().unwrap().node(f).unwrap().name, "Peer 1", "the branch tab has the peer's edit");
assert_eq!(version_names(room.s(0)), ["Branch created", "Updated from main"]);
// The new base is main as it was then: a later peer edit is main's change, not a conflict.
ok(room.s(1), "node.set", json!({"id": f, "props": {"name": "Peer 2"}}));
room.settle();
assert_eq!(conflict_keys(room.s(0), &id), Vec::<String>::new());
ok(room.s(0), "branch.merge", json!({}));
room.settle();
room.check();
let doc = room.server.document();
assert_eq!((doc.node(f).unwrap().name.as_str(), doc.node(r).unwrap().name.as_str()), ("Peer 2", "Branch"));
}
#[test]
fn a_view_link_still_refuses_merging() {
let (mut s, script) = session();
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
let mut doc = Document::new("Shared");
let page = doc.first_page().unwrap();
let card = doc.create(page, figcraft_doc::NodeKind::Rectangle, "Card", 0.0, 0.0, 10.0, 10.0).unwrap();
let (_server, id, welcome) = serve_as(&doc, Access::View);
let mut you = info(Role::Guest, id);
you.access = Access::View;
push(&script, vec![Event::Welcome { info: you, peers: vec![] }, welcome]);
s.live_poll();
assert_eq!(s.active_read_only(), Some(VIEW_ONLY));
// The branch is yours to edit; merging it would edit the file you may only view.
ok(&mut s, "branch.create", json!({}));
ok(&mut s, "node.set", json!({"id": card, "props": {"name": "Mine"}}));
ok(&mut s, "branch.updateFromMain", json!({}));
match s.execute("branch.merge", &json!({})) {
Err(crate::EngineError::Disabled(_, why)) => assert_eq!(why, VIEW_ONLY),
other => panic!("expected a refusal, got {other:?}"),
}
assert!(script.lock().unwrap().msgs.is_empty(), "nothing was sent");
}
#[test]
fn a_branch_made_outside_the_session_merges_edits_but_not_new_layers() {
let (mut s, script) = session();
let r = NodeId(ok(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}))["id"].as_u64().unwrap());
let edits = ok(&mut s, "branch.create", json!({"name": "Edits"}))["id"].as_str().unwrap().to_string();
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Edited before the session"}}));
ok(&mut s, "doc.switch", json!({"index": 0}));
let adds = ok(&mut s, "branch.create", json!({"name": "Adds"}))["id"].as_str().unwrap().to_string();
ok(&mut s, "shape.ellipse", json!({"x": 90, "y": 0, "width": 20, "height": 20}));
ok(&mut s, "doc.switch", json!({"index": 0}));
s.execute("collab.host", &json!({})).unwrap();
let (mut server, id, welcome) = serve_as(s.doc().unwrap(), Access::Edit);
push(&script, vec![welcome]);
s.live_poll();
assert!(s.live().syncing());
// Its new layer is in another id space: refused here rather than by the server.
match s.execute("branch.merge", &json!({"id": adds})) {
Err(crate::EngineError::Disabled(_, why)) => assert!(why.contains("outside this live session"), "{why}"),
other => panic!("expected a refusal, got {other:?}"),
}
assert_eq!(s.doc().unwrap().node_count(), server.document().node_count(), "nothing merged");
// Edits of layers everyone has go through.
ok(&mut s, "branch.merge", json!({"id": edits}));
let answers = relay(&script, &mut server, id);
push(&script, answers);
s.live_poll();
assert_eq!(server.document().node(r).unwrap().name, "Edited before the session");
assert!(*s.doc().unwrap() == *server.document());
}
/// The live fuzz test: three people edit with random real commands (export settings and guides
/// included) while one of them works on a branch; every conflict resolved for a random side, the
/// merge goes through the server as it is and everyone converges on it.
#[test]
fn random_edits_on_a_branch_merge_into_a_live_session() {
let (mut created, mut conflicted) = (0, 0);
for seed in 0..8 {
let mut room = Room::new(base_with_lists(), 3, seed);
let mut rng = Rng::new(seed + 50);
let id = ok(room.s(0), "branch.create", json!({}))["id"].as_str().unwrap().to_string();
for _ in 0..30 {
let i = rng.below(3);
let (cmd, params) = random_edit(room.s(i).doc().unwrap(), &mut rng, true);
let _ = room.s(i).execute(cmd, &params);
if rng.chance(0.3) {
room.settle();
}
}
room.settle();
let keys = conflict_keys(room.s(0), &id);
conflicted += usize::from(!keys.is_empty());
let picks: Map<String, Value> = keys.into_iter().map(|k| (k, json!(if rng.chance(0.5) { "branch" } else { "main" }))).collect();
let before = room.server.document().clone();
ok(room.s(0), "branch.merge", json!({"id": id, "resolutions": picks}));
let merged = room.s(0).doc().unwrap().clone();
assert!(merged.validate().is_empty(), "seed {seed}: {:?}", merged.validate());
created += usize::from(merged.ids().any(|n| !before.contains(n)));
room.settle();
room.check();
assert!(*room.server.document() == merged, "seed {seed}: the server took the merge as it is");
}
assert!(created >= 3 && conflicted >= 2, "{created} merges added layers, {conflicted} had conflicts");
}
/// No branch command panics on junk parameters in a live session, from the main file's tab or
/// the branch's, and the session stays in sync.
#[test]
fn branch_commands_survive_junk_params_in_a_live_session() {
let junk = [
json!({}),
json!(null),
json!([1, 2]),
json!({"id": 5, "name": 1e308, "resolutions": [1]}),
json!({"id": "nope", "resolutions": {"1/layer": "both", "doc/name": 7}}),
json!({"name": "\u{0}".repeat(500), "resolutions": {"x": "branch"}}),
];
let mut room = Room::new(base(), 2, 11);
ok(room.s(0), "branch.create", json!({}));
let ids: Vec<&str> = crate::branches::specs().iter().map(|s| s.id).collect();
for tab in [0, 1] {
for id in &ids {
for j in &junk {
let s = room.s(0);
if s.docs.len() > tab {
let _ = s.execute("doc.switch", &json!({"index": tab}));
}
if let Err(crate::EngineError::Internal { cmd, msg }) = s.execute(id, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
room.settle();
}
}
}
room.check();
}
+51 -9
View File
@@ -1,9 +1,9 @@
//! Branches through commands: create, edit on both sides, review, resolve, merge as one undo step,
//! update from main, archive, saving with the file, and a fuzz test over random real commands.
use figcraft_collab::merge::{Side, plan};
use figcraft_collab::merge::{Key, Side, plan};
use figcraft_collab::sim::Rng;
use figcraft_collab::{Change, DocProp, Mode, Replica, Target};
use figcraft_collab::{Change, DocProp, Mode, Prop, Replica, Target};
use figcraft_doc::{Document, NodeId, NodeKind};
use serde_json::{Value, json};
@@ -215,26 +215,68 @@ fn shows(changes: &[Change], doc: &Document) -> Result<(), String> {
Ok(())
}
/// The fuzz test: random real commands on a branch and on main. Without conflicts the merge (both
/// ways) equals applying both change sets and shows every change of either side; with conflicts,
/// resolving them all for either side gives a valid document.
/// [`base`] with two export settings and two guides on its first frame (lists that merge item by
/// item).
pub(crate) fn base_with_lists() -> Document {
let mut doc = base();
let f = doc.node(doc.first_page().unwrap()).unwrap().children[0];
let n = doc.get_mut(f).unwrap();
n.exports = ["@1", "@2"].map(|suffix| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() }).to_vec();
n.guides = vec![figcraft_doc::Guide { vertical: true, offset: 10.0 }, figcraft_doc::Guide { vertical: false, offset: 20.0 }];
doc
}
/// A random real command: often an edit of a frame's export settings or guides, else
/// [`random_command`].
pub(crate) fn random_edit(doc: &Document, rng: &mut Rng, with_undo: bool) -> (&'static str, Value) {
if rng.chance(0.6) {
return random_command(doc, rng, with_undo);
}
let frames: Vec<NodeId> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Frame)).map(|n| n.id).collect();
let f = rng.pick(&frames).map_or(json!(0), |i| json!(i));
let i = rng.below(3);
match rng.below(6) {
0 => ("export.add", json!({"ids": [f]})),
1 => ("export.update", json!({"ids": [f], "index": i, "suffix": format!("@{}", rng.below(9))})),
2 => ("export.remove", json!({"ids": [f], "index": i})),
3 => ("guide.add", json!({"frame": f, "vertical": rng.chance(0.5), "offset": rng.below(300)})),
4 => ("guide.move", json!({"frame": f, "index": i, "offset": rng.below(300)})),
_ => ("guide.remove", json!({"frame": f, "index": i})),
}
}
/// The fuzz test: random real commands on a branch and on main (export settings and guides
/// included). Without conflicts the merge (both ways) equals applying both change sets and shows
/// every change of either side; with conflicts, resolving them all for either side gives a valid
/// document.
#[test]
fn random_edits_on_both_sides_merge() {
let (mut disjoint, mut conflicted) = (0, 0);
let (mut disjoint, mut conflicted, mut lists) = (0, 0, 0);
for seed in 0..60 {
let mut rng = Rng::new(seed);
let start = base();
let start = base_with_lists();
let mut on_branch = start.clone();
on_branch.set_client_id(start.highest_id_space() + 1);
let mut sides = [Session::with_document(on_branch), Session::with_document(start.clone())];
for s in &mut sides {
for _ in 0..1 + rng.below(5) {
let (cmd, params) = random_command(s.doc().unwrap(), &mut rng, false);
let (cmd, params) = random_edit(s.doc().unwrap(), &mut rng, false);
let _ = s.execute(cmd, &params);
}
}
let [b, m] = sides.map(|s| s.doc().unwrap().clone());
let p = plan(&start, &b, &m).unwrap();
// Export settings or guides of a layer changed on both sides that merged item by item.
let list_writes = |changes: &[Change]| -> std::collections::BTreeSet<Key> {
changes
.iter()
.filter_map(|c| match c {
Change::Set { id, prop: prop @ (Prop::Exports | Prop::Guides), .. } => Some(Key::Node(*id, *prop)),
_ => None,
})
.collect()
};
lists += list_writes(&p.branch).intersection(&list_writes(&p.main)).filter(|k| !p.conflicts.iter().any(|c| c.key == **k)).count();
if p.conflicts.is_empty() {
disjoint += 1;
let none = |_: &figcraft_collab::merge::Key| None;
@@ -260,5 +302,5 @@ fn random_edits_on_both_sides_merge() {
}
}
}
assert!(disjoint >= 10 && conflicted >= 5, "{disjoint} disjoint, {conflicted} with conflicts");
assert!(disjoint >= 10 && conflicted >= 5 && lists >= 3, "{disjoint} disjoint, {conflicted} with conflicts, {lists} lists merged item by item");
}
+6 -6
View File
@@ -35,12 +35,12 @@ fn branched() -> (Session, String, NodeId) {
fn versions_saved_on_a_branch_are_the_branchs_own() {
let (mut s, _, r) = branched();
let original = layer_name(&s, r);
// A new branch's history starts empty: the main file's versions stay with it.
assert!(names(&mut s).is_empty());
// A new branch's history starts with its creation: the main file's versions stay with it.
assert_eq!(names(&mut s), ["Branch created"]);
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "Before"}}));
let v = ok(&mut s, "file.saveVersion", json!({"name": "Branch v1"}))["id"].clone();
ok(&mut s, "node.set", json!({"id": r, "props": {"name": "After"}}));
assert_eq!(names(&mut s), ["Branch v1"]);
assert_eq!(names(&mut s), ["Branch created", "Branch v1"]);
// Viewing a branch version shows it read-only; going back shows the branch as it is.
ok(&mut s, "version.preview", json!({"id": v}));
assert_eq!(layer_name(&s, r), "Before");
@@ -77,13 +77,13 @@ fn branch_versions_save_with_the_file_and_survive_merging() {
ok(&mut t, "file.open", json!({"path": path}));
assert_eq!(names(&mut t), ["Main v1"]);
ok(&mut t, "branch.open", json!({"id": id}));
assert_eq!(names(&mut t), ["Branch v1", "Autosave"]);
assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]);
// Merging adds a version to the main file's history; the branch keeps its own.
ok(&mut t, "branch.merge", json!({}));
assert_eq!(names(&mut t), ["Main v1", "Autosave", "Merged “Explore”"]);
assert_eq!(layer_name(&t, r), "On the branch, later");
ok(&mut t, "branch.open", json!({"id": id}));
assert_eq!(names(&mut t), ["Branch v1", "Autosave"]);
assert_eq!(names(&mut t), ["Branch created", "Branch v1", "Autosave"]);
std::fs::remove_dir_all(&dir).ok();
}
@@ -94,7 +94,7 @@ fn archiving_keeps_the_branch_history() {
ok(&mut s, "branch.archive", json!({}));
assert_eq!(names(&mut s), ["Main v1"]);
ok(&mut s, "branch.open", json!({"id": id}));
assert_eq!(names(&mut s), ["Branch v1"]);
assert_eq!(names(&mut s), ["Branch created", "Branch v1"]);
}
#[test]
+118 -16
View File
@@ -4,6 +4,13 @@
//! both sides with it. Branches are saved in the main file ([`figcraft_format::Branch`]); a
//! branch tab's edits and its own version history go into its branch ([`sync`]) and are saved with
//! the main file. Merging adds a version to the main file's history; the branch keeps its own.
//!
//! In a live session a branch stays with the person who made it (others don't see it), and
//! merging it is one edit of the session document, which syncs to everyone like any other. The
//! session server takes new layers only in the sender's own id space, so a branch made in the
//! session makes its ids in yours, far above the ids the main file makes there ([`WINDOW`]).
//! Merging a branch made outside the session is refused when it adds layers, styles, variables or
//! comments (their ids are in another space).
use std::collections::BTreeMap;
use std::sync::Arc;
@@ -11,6 +18,7 @@ use std::sync::Arc;
pub use figcraft_collab::merge::Side;
use figcraft_collab::merge::{self, Conflict, Key, Plan};
use figcraft_collab::{Change, Entry, Prop, Target};
use figcraft_doc::ids::space_of;
use figcraft_doc::{Document, NodeId};
use figcraft_format::Branch;
pub use figcraft_format::BranchState;
@@ -27,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"branch.create",
"Create branch",
"{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base) → {id, name, index (its tab)}",
"{name?} a copy of the file to work on separately, opened in a new tab; it remembers the file as it is now (its base); in a live session it stays yours → {id, name, index (its tab)}",
false,
create
),
@@ -53,14 +61,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"branch.updateFromMain",
"Update from main",
"{id?, resolutions? ({<conflict key>: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch; refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}",
"{id?, resolutions? ({<conflict key>: \"branch\" | \"main\"})} bring the main file's changes since the branch was made into the branch (a version in its history); refused while a conflict has no resolution. Not undoable (the branch tab's undo history starts over) → {updated, changes, conflicts}",
false,
|s, p| combine(s, p, Side::Main)
),
spec!(
"branch.merge",
"Merge branch",
"{id?, resolutions? ({<conflict key>: \"branch\" | \"main\"})} merge the branch into the main file as one undo step, then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}",
"{id?, resolutions? ({<conflict key>: \"branch\" | \"main\"})} merge the branch into the main file as one undo step (in a live session one edit everyone gets; refused on a view link), then archive it and show the main file; refused while a conflict has no resolution → {merged, changes, conflicts, index}",
false,
|s, p| combine(s, p, Side::Branch)
),
@@ -219,11 +227,42 @@ fn disabled(cmd: &str, why: impl Into<String>) -> EngineError {
EngineError::Disabled(cmd.into(), why.into())
}
/// shortcut: branches of a file in a live session are refused (its participants' client ids
/// aren't known here, so a branch's id space could clash); allow them once the server hands out
/// branch id spaces.
fn refuse_live(s: &Session, cmd: &str, uid: u64) -> Result<()> {
if s.live_doc(uid) { Err(disabled(cmd, "branches aren't available in a live session")) } else { Ok(()) }
/// shortcut: in a live session a branch makes ids in your own client space starting this far
/// above every id made there on the main file or its other branches, so neither side makes the
/// other's ids before the merge unless one makes 16 million first; have the server hand out
/// branch id spaces if that ever matters.
const WINDOW: u64 = 1 << 24;
/// Where a new branch of the file in tab `main` starts making ids in your live session's client
/// space `client`: [`WINDOW`] above the main file's and every branch's next id there (`None`: the
/// space is full).
fn live_window(s: &Session, main: u64, client: u32) -> Option<NodeId> {
let m = s.docs.iter().find(|d| d.uid == main)?;
let tabs = s.docs.iter().filter(|d| d.branch_of.as_ref().is_some_and(|(t, _)| *t == main)).map(|d| d.doc.next_id());
let next = m.branches.iter().map(|b| b.head.next_id()).chain(tabs).chain([m.doc.next_id()]);
let top = next.filter(|n| space_of(n.0) == client).max()?;
let start = top.0.checked_add(WINDOW)?;
(space_of(start) == client).then_some(NodeId(start))
}
/// What merging into a live session's document would add that its server won't take from client
/// `client`: a new layer outside your id space (unless it is a layer of a base taken in this
/// session, `in_session`, that main deleted since: the server takes those back), or a new style,
/// variable, collection or comment in another client's space. `None`: everything goes through.
fn foreign(plan: &Plan, base: &Document, main: &Document, merged: &Document, client: u32, in_session: bool) -> Option<String> {
let outside = |n: NodeId| space_of(n.0) != client && !(in_session && base.contains(n));
if let Some(n) = merged.nodes().find(|n| !main.contains(n.id) && outside(n.id)) {
return Some(format!("layer “{}”", n.name));
}
plan.changes(Side::Branch).iter().find_map(|c| match c {
Change::Put { entry, .. }
if entry.id_space().is_some_and(|sp| sp != 0 && sp != client)
&& plan.base().value(&Target::Entry(entry.clone())).ok().flatten().is_none() =>
{
Some(entry.describe())
}
_ => None,
})
}
fn create(s: &mut Session, p: &Value) -> Result<Value> {
@@ -232,7 +271,7 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
if ds.branch_of.is_some() {
return Err(disabled(CMD, "this tab is a branch: make branches from the main file"));
}
refuse_live(s, CMD, ds.uid)?;
let live = s.live_client_id(CMD, ds.uid)?;
if ds.branches.len() >= figcraft_format::MAX_BRANCHES {
return Err(disabled(CMD, format!("the file has the most branches it can keep ({})", figcraft_format::MAX_BRANCHES)));
}
@@ -240,12 +279,22 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Branch {}", ds.branches.len() + 1));
let name = figcraft_format::clip(&name, figcraft_format::MAX_VERSION_NAME);
let base = ds.document_itself().clone();
// Its own id space, above every space the file, its live sessions and its branches used.
let client = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0);
let client = client.checked_add(1).ok_or_else(|| disabled(CMD, "no id space left for a new branch"))?;
let no_space = || disabled(CMD, "no id space left for a new branch");
// In a live session: your own client space, far above the main file's ids there. Otherwise its
// own id space, above every space the file, its live sessions and its branches used.
let (client, start) = match live {
Some(c) => (c, Some(live_window(s, ds.uid, c).ok_or_else(no_space)?)),
None => {
let top = ds.branches.iter().map(|b| b.client).chain([base.highest_id_space(), base.client_id()]).max().unwrap_or(0);
(top.checked_add(1).ok_or_else(no_space)?, None)
}
};
let mut head = (*base).clone();
head.name = name.clone();
head.set_client_id(client);
if let Some(start) = start {
head.reserve_ids(NodeId(start.0 - 1));
}
let id = crate::cmd::file::unique_id(|id| ds.branches.iter().any(|b| b.id == id));
let uid = ds.uid;
let time = crate::cmd::comment::now_secs();
@@ -259,7 +308,13 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
head: Arc::new(head),
versions: Vec::new(),
});
if live.is_some() {
s.live_branch_base(uid, &id);
}
let index = open_tab(s, uid, &id)?;
if let Some(tab) = s.docs.get_mut(index) {
crate::cmd::file::add_version(tab, CREATED, "", false);
}
Ok(json!({"id": id, "name": name, "index": index}))
}
@@ -269,11 +324,22 @@ fn open_tab(s: &mut Session, main: u64, id: &str) -> Result<usize> {
s.active = i;
return Ok(i);
}
let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?;
let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?;
let live = s.live_client_id("branch.open", main)?;
let m = s.docs.iter().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?;
let b = m.branches.iter().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?;
let mut doc = (*b.head).clone();
// A branch read from a file has no client id yet.
doc.set_client_id(b.client);
// In your live session, a branch that would make ids right where the main file makes them (one
// read from a file that made none yet) moves up.
if live == Some(b.client)
&& doc.next_id().0.abs_diff(m.doc.next_id().0) < WINDOW
&& let Some(start) = live_window(s, main, b.client)
{
doc.reserve_ids(NodeId(start.0 - 1));
}
let m = s.docs.iter_mut().find(|d| d.uid == main).ok_or(EngineError::NoDocument)?;
let b = m.branches.iter_mut().find(|b| b.id == id).ok_or_else(|| bad("branch.open", format!("no branch `{id}`")))?;
// The branch's own version history: the tab's versions while it is open ([`sync`]).
let mut tab = DocState::new(doc, None).with_versions(b.versions.clone());
tab.branch_of = Some((main, id.to_string()));
@@ -340,7 +406,13 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result<Value> {
}
// The main file itself, not a version shown in its place.
m.end_preview();
refuse_live(s, cmd, main_uid)?;
// Merging edits the session document, which a view link (or a file on its way) can't.
if from == Side::Branch
&& let Some(why) = s.live_read_only(main_uid)
{
return Err(disabled(cmd, why));
}
let live = s.live_client_id(cmd, main_uid)?;
let picks = resolutions(cmd, p)?;
let (base, head, main) = documents(s, mi, bi).ok_or(EngineError::NoDocument)?;
let plan = merge::plan(&base, &head, &main).map_err(|e| EngineError::Other(e.to_string()))?;
@@ -351,6 +423,14 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result<Value> {
}
let into = if from == Side::Branch { &main } else { &head };
let (mut doc, touched) = plan.merge(into, from, &pick).map_err(|e| EngineError::Other(format!("can't merge: {e}")))?;
if let Some(client) = live.filter(|_| from == Side::Branch)
&& let Some(what) = foreign(&plan, &base, &main, &doc, client, s.live_branch_in_session(main_uid, &id))
{
let why = format!(
"the branch adds {what}, made outside this live session, which the session can't take: merge it once the file has left the session"
);
return Err(disabled(cmd, why));
}
doc.mark_dirty(touched);
normalize(&mut doc, into);
let doc = Arc::new(doc);
@@ -383,11 +463,33 @@ fn combine(s: &mut Session, p: &Value, from: Side) -> Result<Value> {
b.base = main;
b.head = doc;
}
if live.is_some() {
s.live_branch_base(main_uid, &id);
}
note(s, mi, bi, UPDATED);
out["updated"] = json!(true);
}
Ok(out)
}
/// The branch's own history entries for its creation and its updates from main.
const CREATED: &str = "Branch created";
const UPDATED: &str = "Updated from main";
/// Add version `name` to the history of branch `bi` of the file in tab `mi`: its tab's when it is
/// open (it goes into the branch with [`sync`]), else the branch's.
fn note(s: &mut Session, mi: usize, bi: usize, name: &str) {
let Some((uid, id)) = s.docs.get(mi).and_then(|m| Some((m.uid, m.branches.get(bi)?.id.clone()))) else { return };
if let Some(tab) = tab_of(s, uid, &id).and_then(|t| s.docs.get_mut(t)) {
crate::cmd::file::add_version(tab, name, "", false);
return;
}
let Some(b) = s.docs.get_mut(mi).and_then(|m| m.branches_mut().get_mut(bi)) else { return };
let mut tab = DocState::new((*b.head).clone(), None).with_versions(std::mem::take(&mut b.versions));
crate::cmd::file::add_version(&mut tab, name, "", false);
b.versions = tab.versions;
}
/// Show merged `doc` in tab `ds`.
fn show(ds: &mut DocState, doc: Arc<Document>) {
ds.doc = doc;
@@ -458,7 +560,7 @@ fn entry_label(plan: &Plan, entry: &Entry, value: Option<&Value>) -> Option<(&'s
Entry::Variable(_) => "variable",
Entry::Collection(_) => "variable collection",
Entry::Comment(_) => "comment",
Entry::DevSnapshot(_) | Entry::Image(_) => return None,
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => return None,
};
let base = plan.base().value(&Target::Entry(entry.clone())).ok().flatten();
let named = value.or(base.as_ref().map(|j| &j.0)).and_then(|v| v.get("name").or_else(|| v.get("text"))).and_then(Value::as_str);
+9 -2
View File
@@ -206,8 +206,9 @@ fn distribute(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect();
items.sort_by(|a, b| if horizontal { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
let total: f64 = items.iter().map(|(_, b)| if horizontal { b.width() } else { b.height() }).sum();
let (Some(first), Some(last)) = (items.first(), items.last()) else { return ok() };
let span = if horizontal { last.1.x1 - first.1.x0 } else { last.1.y1 - first.1.y0 };
let Some(first) = items.first() else { return ok() };
let end = items.iter().map(|(_, b)| if horizontal { b.x1 } else { b.y1 }).fold(f64::MIN, f64::max);
let span = if horizontal { end - first.1.x0 } else { end - first.1.y0 };
let gap = num(p, "spacing").unwrap_or((span - total) / (items.len().saturating_sub(1).max(1)) as f64);
let mut pos = if horizontal { first.1.x0 } else { first.1.y0 };
for (i, b) in items.clone() {
@@ -494,9 +495,15 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
t.paragraph_spacing *= k;
t.paragraph_indent *= k;
t.list_spacing *= k;
if let Some(tp) = &mut t.path {
tp.scale(k, k);
}
}
if let NodeKind::Vector { network } = &mut n.kind {
network.scale(k, k);
for v in &mut network.vertices {
v.radius *= k;
}
}
n.width *= k;
n.height *= k;
+28 -1
View File
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"image.place",
"Place image",
"{data (base64) | path, x?, y?, width?, height?, parent?, mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
"{data (base64) | path, x?, y?, width?, height?, parent?, target? (an existing layer: the image is added on top of its fills instead of making a layer), mode? (fill|fit|crop|tile)} → {id, image, width, height, imageProfile (its embedded colour profile; null: taken as sRGB)}",
true,
place_image
),
@@ -394,9 +394,28 @@ fn read_path(path: &str) -> Result<Vec<u8>> {
Err(crate::EngineError::Other(format!("{path}: files can't be read in the browser")))
}
/// Layers that can show an image inside their own outline.
pub fn accepts_image_fill(kind: &NodeKind) -> bool {
kind.is_shape() || kind.is_frame_like() || matches!(kind, NodeKind::Text(_) | NodeKind::BooleanOp { .. })
}
/// Add an image fill on top of the layer's fills: the others keep their place and variable
/// bindings; a linked fill style no longer describes the list, so the link goes.
fn add_image_fill(node: &mut Node, image: String, mode: ImageScaleMode) {
node.fills.push(Paint::image(image, mode));
node.styles.fill = None;
}
/// Place an image as a rectangle with an image fill (Figma's image layer).
pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "image.place";
let target = opt_id(cmd, p, "target")?;
if let Some(id) = target {
let node = s.doc()?.node(id).ok_or(crate::EngineError::NoNode(id))?;
if !accepts_image_fill(&node.kind) {
return Err(bad(cmd, "`target` must be a layer that can have a fill"));
}
}
let bytes = if let Some(b64) = string(p, "data") {
use base64::Engine as _;
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
@@ -412,6 +431,14 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
"tile" => ImageScaleMode::Tile,
_ => ImageScaleMode::Fill,
};
if let Some(id) = target {
let doc = s.doc_mut()?;
let (hash, _, _) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
let profile = figcraft_render::images::profile_name(&hash, doc.images.get(&hash));
let node = doc.get_mut(id)?;
add_image_fill(node, hash.clone(), mode);
return Ok(json!({"id": id, "image": hash, "width": node.width, "height": node.height, "imageProfile": profile}));
}
let page = s.page()?;
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let ds = s.ds_mut()?;
+139 -6
View File
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -235,6 +235,22 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)),
spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)),
spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)),
spec!("fill.copy", "Copy fill", "{id?, index? (default the top fill)}", false, |s, p| paint_copy(s, p, true)),
spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one)}", true, |s, p| paint_paste(s, p, true)),
spec!(
"stroke.copy",
"Copy stroke",
"{id?, index? (default the top stroke)} its paint and the stroke's weight, alignment, sides, caps, joins and dashes",
false,
|s, p| paint_copy(s, p, false)
),
spec!(
"stroke.paste",
"Paste stroke",
"{ids?, index? (replace the stroke there; without: add one)} the copied stroke's settings come with its paint",
true,
|s, p| paint_paste(s, p, false)
),
spec!(
"fill.pattern",
"Pattern fill",
@@ -282,6 +298,8 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!("effect.copy", "Copy effect", "{id?, index? (default the top effect)}", false, effect_copy),
spec!("effect.paste", "Paste effect", "{ids?, index? (replace the effect there; without: add one)}", true, effect_paste),
spec!("grid.add", "Add layout grid", "{ids?, grid? {kind: grid|columns|rows, ..}}", true, |s, p| {
let g: LayoutGrid = parse("grid.add", p, "grid")?.unwrap_or_default();
for id in req_targets("grid.add", s, p)? {
@@ -342,15 +360,16 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| {
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| {
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
let cmds: Vec<(String, Value)> = match get(p, "commands") {
let cmds: Vec<BatchStep> = match get(p, "commands") {
Some(Value::Array(a)) => {
let mut out = Vec::new();
for (i, c) in a.iter().enumerate() {
let cmd =
c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
out.push((cmd.to_string(), c.get("params").cloned().unwrap_or(json!({}))));
let params = c.get("params").cloned().unwrap_or(json!({}));
out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) });
}
out
}
@@ -541,6 +560,7 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
s.clipboard.nodes = nodes;
s.clipboard.images = images;
s.clipboard.bounds = bounds;
s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1);
Ok(())
}
@@ -600,6 +620,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
figcraft_format::fig::is_fig(&bytes).then_some(bytes)
}
/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing,
/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers,
/// store them as a foreign-file clipboard source with a bumped layers generation, then
/// delegate to `paste` and append import warnings.
fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.pasteDesignLayers";
let bytes = match (string(p, "html"), string(p, "data")) {
@@ -614,10 +638,28 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into()));
}
let (nodes, images, bounds) = copied(&doc, &tops);
// A design-app paste replaces the copied layers, not the copied rows: a `fill.copy` before it
// still pastes afterwards.
let props = s.clipboard.props.take();
let paint = s.clipboard.paint.take();
let stroke = s.clipboard.stroke.take();
let effect = s.clipboard.effect.take();
// No open file has id 0. So paste treats this copy as a copy from a different file.
s.clipboard =
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() };
let layers_generation = s.clipboard.layers_generation.wrapping_add(1);
s.clipboard = crate::Clipboard {
nodes,
images,
props,
paint,
stroke,
effect,
bounds,
source_doc: 0,
source: Some(doc),
source_path: None,
copied_components: BTreeSet::new(),
layers_generation,
};
let mut out = paste(s, p)?;
if !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
@@ -1418,6 +1460,61 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
ok()
}
/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke
/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along.
fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
let cmd = if fills { "fill.copy" } else { "stroke.copy" };
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
let at = opt_index(cmd, p, "index")?;
let (paint, stroke) = {
let doc = s.doc()?;
let n = doc.get(id)?;
let list = if fills { &n.fills } else { &n.strokes };
let at = at.unwrap_or(list.len().saturating_sub(1));
let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?;
(paint, (!fills).then(|| n.stroke.clone()))
};
s.clipboard.paint = Some((fills, paint.clone()));
s.clipboard.stroke = stroke;
serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string()))
}
/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A
/// stroke's copied settings go onto each target too.
fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
let cmd = if fills { "fill.paste" } else { "stroke.paste" };
let Some((copied_fills, paint)) = s.clipboard.paint.clone() else {
return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into()));
};
if copied_fills != fills {
let what = if fills { "stroke" } else { "fill" };
return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}")));
}
let ids = req_targets(cmd, s, p)?;
// A copied pattern pointing at a target or a layer inside it would make the layer pattern
// itself; `fill.pattern` refuses the same, and the renderer would drop the pattern.
if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind {
let doc = s.doc()?;
if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) {
return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it"));
}
}
let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?});
let out = match opt_index(cmd, p, "index")? {
Some(at) => {
q["index"] = json!(at);
paint_set(s, &q, fills)?
}
None => paint_list(s, &q, fills, true)?,
};
if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) {
for id in ids {
s.doc_mut()?.get_mut(id)?.stroke = stroke.clone();
}
}
Ok(out)
}
fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "fill.pattern";
let src = req_id(CMD, p, "source")?;
@@ -1454,3 +1551,39 @@ fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
}
ok()
}
/// Copy effect `index` (the top one by default) of the first target into the clipboard.
fn effect_copy(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "effect.copy";
let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?;
let at = opt_index(CMD, p, "index")?;
let e = {
let doc = s.doc()?;
let list = &doc.get(id)?.effects;
let at = at.unwrap_or(list.len().saturating_sub(1));
list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))?
};
s.clipboard.effect = Some(e.clone());
serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string()))
}
/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one.
fn effect_paste(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "effect.paste";
let Some(e) = s.clipboard.effect.clone() else {
return Err(EngineError::Disabled(CMD.into(), "no copied effect".into()));
};
let at = opt_index(CMD, p, "index")?;
for id in req_targets(CMD, s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
match at {
Some(i) => {
if let Some(slot) = n.effects.get_mut(i) {
*slot = e.clone();
}
}
None => n.effects.push(e.clone()),
}
}
ok()
}
+373
View File
@@ -0,0 +1,373 @@
//! Running a session on a board: a countdown everyone sees and votes on objects.
//!
//! Both live in the document ([`figcraft_doc::facilitation`]) so they reach everyone in a live
//! session, and neither is an undo step: undoing an edit leaves the timer and the vote as they are.
use std::sync::Arc;
use figcraft_doc::{BoardTimer, Document, NodeId, TIMER_MAX_MS, VOTES_MAX, Vote, VoteScope};
use figcraft_geom::{Point, Rect};
use serde_json::{Value, json};
use crate::board::{require, text_of};
use crate::cmd::CommandSpec;
use crate::params::{id, num, req_string, req_targets, string};
use crate::tools::PointerEvent;
use crate::{EngineError, Result, Session, bad, spec};
/// Minutes a timer starts with, and adds, when none are given.
const START_MINUTES: f64 = 5.0;
const ADD_MINUTES: f64 = 1.0;
/// Votes each person gets when none are given.
const VOTES: u32 = 3;
/// A vote badge's radius on screen (logical pixels).
pub const BADGE_PX: f64 = 11.0;
/// The longest a voter name is kept.
const MAX_VOTER: usize = 64;
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"board.timer",
"Timer",
"{action: start|pause|resume|add|stop, minutes? (start: default 5; add: default 1; up to 1440)} → the timer's state (as board.timerState) one countdown per board, the same for everyone in a live session",
false,
timer_cmd
),
spec!(
"board.timerState",
"Timer state",
"{} → {state: off|running|paused|done, left (ms), text (m:ss), ends? (ms since 1970, while running)}",
false,
|s, _| {
require(s, "board.timerState")?;
Ok(timer_json(s.doc()?.timer, now_ms()))
}
),
spec!(
"board.vote",
"Voting session",
"{action: start|cast|retract|end|clear, votes? (start: per person, 1–99, default 3), on? (start: all|stickies|selection; default all), ids? (start with on: selection; default the selection), id (cast, retract: the object), voter? (default you)} → the vote's state (as board.voteResults) one vote at a time; others' votes show once it ends",
false,
vote_cmd
),
spec!(
"board.voteResults",
"Voting results",
"{voter?} → {state: off|running|ended, votesPerPerson, on, by, left (your votes left), mine [{id, votes}], voters, results? [{id, name, votes, rank}] (once ended, most votes first)}",
false,
|s, p| {
require(s, "board.voteResults")?;
let who = voter(s, p);
Ok(vote_json(s.doc()?, &who))
}
),
]
}
/// Milliseconds since the Unix epoch (0 if the clock is before it).
pub fn now_ms() -> u64 {
web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
/// `left` milliseconds as a clock: "4:05", "1:02:03" (whole seconds, rounded up).
pub fn clock(left: u64) -> String {
let s = left.div_ceil(1000);
let (h, m, s) = (s / 3600, s / 60 % 60, s % 60);
if h > 0 { format!("{h}:{m:02}:{s:02}") } else { format!("{m}:{s:02}") }
}
/// The timer's state at `now`: off, running, paused or done (run out, until someone stops it).
pub fn timer_state(t: Option<BoardTimer>, now: u64) -> &'static str {
match t {
None => "off",
Some(BoardTimer::Paused { .. }) => "paused",
Some(t) if t.left(now) == 0 => "done",
Some(_) => "running",
}
}
fn timer_json(t: Option<BoardTimer>, now: u64) -> Value {
let left = t.map_or(0, |t| t.left(now));
let ends = match t {
Some(BoardTimer::Running { ends }) => Some(ends),
_ => None,
};
json!({"state": timer_state(t, now), "left": left, "text": clock(left), "ends": ends})
}
/// `minutes` (or `default`) as milliseconds: more than 0, at most a day.
fn duration(cmd: &str, p: &Value, default: f64) -> Result<u64> {
let m = match p.get("minutes").filter(|v| !v.is_null()) {
Some(_) => num(p, "minutes").ok_or_else(|| bad(cmd, "`minutes` must be a number"))?,
None => default,
};
let ms = (m * 60_000.0).round();
if !(1000.0..=TIMER_MAX_MS as f64).contains(&ms) {
return Err(bad(cmd, "`minutes` must be from 1 second to 1440 minutes"));
}
Ok(ms as u64)
}
/// The timer after `action` at `now` (`Ok(None)`: stopped).
pub fn timer_after(cmd: &str, t: Option<BoardTimer>, action: &str, p: &Value, now: u64) -> Result<Option<BoardTimer>> {
let off = || EngineError::Disabled(cmd.into(), "there is no timer: start one".into());
Ok(match action {
"start" => Some(BoardTimer::Running { ends: now.saturating_add(duration(cmd, p, START_MINUTES)?) }),
"stop" => None,
"pause" => match t {
Some(r @ BoardTimer::Running { .. }) if r.left(now) > 0 => Some(BoardTimer::Paused { left: r.left(now) }),
_ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't running".into())),
},
"resume" => match t {
Some(BoardTimer::Paused { left }) => Some(BoardTimer::Running { ends: now.saturating_add(left) }),
_ => return Err(EngineError::Disabled(cmd.into(), "the timer isn't paused".into())),
},
"add" => {
let more = duration(cmd, p, ADD_MINUTES)?;
let left = t.ok_or_else(off)?.left(now).saturating_add(more).min(TIMER_MAX_MS);
match t {
Some(BoardTimer::Paused { .. }) => Some(BoardTimer::Paused { left }),
_ => Some(BoardTimer::Running { ends: now.saturating_add(left) }),
}
}
other => return Err(bad(cmd, format!("unknown action `{other}` (start|pause|resume|add|stop)"))),
})
}
fn timer_cmd(s: &mut Session, p: &Value) -> Result<Value> {
require(s, "board.timer")?;
let action = req_string("board.timer", p, "action")?;
let now = now_ms();
let t = timer_after("board.timer", s.doc()?.timer, &action, p, now)?;
if s.doc()?.timer != t {
s.doc_mut()?.timer = t;
}
Ok(timer_json(t, now))
}
/// Who votes: `voter` from the params, else you as the live session knows you (your client id
/// there, so two people of the same name vote apart), else your name.
pub fn voter(s: &Session, p: &Value) -> String {
if let Some(v) = string(p, "voter").map(|v| v.trim().chars().filter(|c| !c.is_control()).take(MAX_VOTER).collect::<String>())
&& !v.is_empty()
{
return v;
}
let live = s.ds().ok().filter(|ds| s.live_doc(ds.uid)).and_then(|ds| s.live_of(ds.uid).info.as_ref());
match live {
Some(info) => format!("peer {}", info.me),
None => crate::cmd::comment::user_name(s),
}
}
fn running<'a>(cmd: &str, doc: &'a Document) -> Result<&'a Vote> {
match &doc.vote {
Some(v) if !v.ended => Ok(v),
_ => Err(EngineError::Disabled(cmd.into(), "no vote is running".into())),
}
}
fn vote_cmd(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "board.vote";
require(s, CMD)?;
let action = req_string(CMD, p, "action")?;
let who = voter(s, p);
match action.as_str() {
"start" => start_vote(s, p, CMD)?,
"cast" | "retract" => change_ballot(s, p, CMD, &who, action == "cast")?,
"end" => {
running(CMD, s.doc()?)?;
if let Some(v) = s.doc_mut()?.vote.as_mut() {
v.ended = true;
}
}
"clear" => {
let doc = s.doc()?;
if doc.vote.is_some() || !doc.votes.is_empty() {
let doc = s.doc_mut()?;
doc.vote = None;
doc.votes.clear();
}
}
other => return Err(bad(CMD, format!("unknown action `{other}` (start|cast|retract|end|clear)"))),
}
Ok(vote_json(s.doc()?, &who))
}
/// `board.vote {action: start}`: a new vote (votes per person, on what), clearing the last results.
fn start_vote(s: &mut Session, p: &Value, cmd: &str) -> Result<()> {
if s.doc()?.vote.as_ref().is_some_and(|v| !v.ended) {
return Err(EngineError::Disabled(cmd.into(), "a vote is already running: end it first".into()));
}
let per = match p.get("votes").filter(|v| !v.is_null()) {
Some(_) => num(p, "votes")
.filter(|n| n.fract() == 0.0 && (1.0..=f64::from(VOTES_MAX)).contains(n))
.ok_or_else(|| bad(cmd, "`votes` must be a whole number from 1 to 99"))?,
None => f64::from(VOTES),
} as u32;
let scope = match string(p, "on").as_deref().unwrap_or("all") {
"all" => VoteScope::All,
"stickies" => VoteScope::Stickies,
"selection" => {
let doc = s.doc()?;
let ids: Vec<NodeId> = req_targets(cmd, s, p)?.into_iter().filter(|i| VoteScope::All.takes(doc, *i)).collect();
if ids.is_empty() {
return Err(bad(cmd, "pick the objects to vote on (not pages)"));
}
VoteScope::Only { ids }
}
other => return Err(bad(cmd, format!("unknown `on` `{other}` (all|stickies|selection)"))),
};
let by = crate::board::author(s);
let doc = s.doc_mut()?;
doc.votes.clear();
doc.vote = Some(Vote { per_person: per, scope, ended: false, by });
Ok(())
}
/// `board.vote {action: cast|retract, id}`: one vote of `who`'s placed on (`cast`) or taken back
/// from an object, within the running vote's scope and votes per person.
fn change_ballot(s: &mut Session, p: &Value, cmd: &str, who: &str, cast: bool) -> Result<()> {
let target = id(p, "id").ok_or_else(|| bad(cmd, "`id` must be the object to vote on"))?;
let doc = s.doc()?;
let vote = running(cmd, doc)?;
let mut ballot: Vec<NodeId> = vote.counted(doc, doc.votes.get(who).map_or(&[], Vec::as_slice)).collect();
if cast {
if !vote.scope.takes(doc, target) {
return Err(match doc.node(target) {
None => EngineError::NoNode(target),
Some(_) => EngineError::Disabled(cmd.into(), "this vote isn't on that object".into()),
});
}
if ballot.len() >= vote.per_person as usize {
return Err(EngineError::Disabled(cmd.into(), "you have no votes left: remove one first".into()));
}
ballot.push(target);
} else {
let i =
ballot.iter().rposition(|i| *i == target).ok_or_else(|| EngineError::Disabled(cmd.into(), "you have no vote on that object".into()))?;
ballot.remove(i);
}
let votes = &mut s.doc_mut()?.votes;
if ballot.is_empty() {
votes.remove(who);
} else {
votes.insert(who.to_string(), ballot);
}
Ok(())
}
/// `who`'s votes per object, in the order first voted on.
pub fn mine(doc: &Document, who: &str) -> Vec<(NodeId, u32)> {
let (Some(vote), Some(ballot)) = (&doc.vote, doc.votes.get(who)) else { return Vec::new() };
let mut out: Vec<(NodeId, u32)> = Vec::new();
for id in vote.counted(doc, ballot) {
match out.iter_mut().find(|(i, _)| *i == id) {
Some((_, n)) => *n += 1,
None => out.push((id, 1)),
}
}
out
}
/// What an object is called in the results: its text (a sticky's note), else its name.
pub fn object_name(doc: &Document, id: NodeId) -> String {
let text = text_of(doc, id).and_then(|t| doc.node(t)).and_then(|n| n.kind.text()).map(|t| t.characters.trim().to_string());
let name = text.filter(|t| !t.is_empty()).or_else(|| doc.node(id).map(|n| n.name.clone())).unwrap_or_default();
let line = name.lines().next().unwrap_or_default();
let short: String = line.chars().take(60).collect();
if short.len() < name.len() { format!("{short}…") } else { short }
}
fn vote_json(doc: &Document, who: &str) -> Value {
let Some(vote) = &doc.vote else { return json!({"state": "off"}) };
let on = match &vote.scope {
VoteScope::All => json!("all"),
VoteScope::Stickies => json!("stickies"),
VoteScope::Only { ids } => json!({"selection": ids}),
};
let mine = mine(doc, who);
let used: u32 = mine.iter().map(|m| m.1).sum();
let voters = doc.votes.values().filter(|b| vote.counted(doc, b).next().is_some()).count();
let mut out = json!({
"state": if vote.ended { "ended" } else { "running" },
"votesPerPerson": vote.per_person,
"on": on,
"by": vote.by,
"left": vote.per_person.saturating_sub(used),
"mine": mine.iter().map(|(id, n)| json!({"id": id, "votes": n})).collect::<Vec<_>>(),
"voters": voters,
});
if vote.ended {
let results: Vec<Value> =
ranked(doc, vote).into_iter().map(|(rank, id, n)| json!({"id": id, "name": object_name(doc, id), "votes": n, "rank": rank})).collect();
out["results"] = json!(results);
}
out
}
/// The objects with votes, most first, each with its rank (ties share one: "1, 2, 2, 4").
pub fn ranked(doc: &Document, vote: &Vote) -> Vec<(usize, NodeId, u32)> {
let mut last = None;
let mut rank = 0;
vote.tally(doc, &doc.votes)
.into_iter()
.enumerate()
.map(|(i, (id, n))| {
if last != Some(n) {
rank = i + 1;
last = Some(n);
}
(rank, id, n)
})
.collect()
}
/// Where object `b`'s vote badge sits (its top-right corner) and its radius, in page units at
/// `zoom` screen pixels per unit.
pub fn badge(b: Rect, zoom: f64) -> (Point, f64) {
(Point::new(b.x1, b.y0), BADGE_PX / zoom.max(1e-6))
}
impl Session {
/// A press with the Move tool while a vote runs: on one of your vote badges, or with Alt, it
/// takes that vote back; on an object the vote is on, it puts a vote there. False when the
/// press is for the tool (no vote running, or nothing to vote on there).
pub(crate) fn vote_down(&mut self, ev: &PointerEvent) -> Result<bool> {
let doc = self.doc()?;
let Some(vote) = doc.vote.as_ref().filter(|v| !v.ended) else { return Ok(false) };
let who = voter(self, &Value::Null);
let p = ev.p();
let on_badge = mine(doc, &who).into_iter().map(|(id, _)| id).find(|id| {
doc.world_bounds(*id).is_some_and(|b| {
let (c, r) = badge(b, ev.zoom);
(p - c).hypot() <= r
})
});
let hit = || {
let leaf = self.hit(doc, self.page().ok()?, p, 2.0 / ev.zoom.max(1e-6))?;
vote.scope.target(doc, leaf, figcraft_doc::geometry::select_target(doc, leaf, &[], false))
};
let (action, target) = match on_badge {
Some(id) => ("retract", id),
None => match hit() {
Some(id) => (if ev.alt { "retract" } else { "cast" }, id),
None => return Ok(false),
},
};
self.execute("board.vote", &json!({"action": action, "id": target}))?;
Ok(true)
}
}
/// Undo and redo bring back a document as it was, but the timer and the vote aren't undoable:
/// keep them as they are now (`from`).
pub(crate) fn keep(from: &Document, to: &mut Arc<Document>) {
if from.timer != to.timer || from.vote != to.vote || from.votes != to.votes {
let d = Arc::make_mut(to);
d.timer = from.timer;
d.vote = from.vote.clone();
d.votes = from.votes.clone();
}
}
+353 -22
View File
@@ -42,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.open",
"Open",
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
"{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `<name>.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)",
false,
open
),
@@ -63,10 +63,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.saveAs",
"Save as",
"{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
"{path | data: true (no path: returns the file as base64 `data` with its `name`, for a download)} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
false,
save_as
),
spec!(
"file.markSaved",
"Mark saved",
"{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)",
false,
mark_saved
),
spec!(
"file.export",
"Export",
@@ -77,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.exportLayers",
"Export layers",
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}",
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}",
false,
export_layers
),
@@ -98,7 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.import",
"Import",
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
"{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON, .fig and .sketch pages are appended with their variables and flows (REST, .fig and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)",
true,
import
),
@@ -156,7 +163,10 @@ pub fn specs() -> Vec<CommandSpec> {
let i = s.active;
let uid = s.ds()?.uid;
if let Some(slot) = s.docs.get_mut(i) {
*slot = DocState::new(loaded.doc, Some(path)).with_versions(loaded.versions).with_branches(loaded.branches);
*slot = DocState::new(loaded.doc, Some(path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, loaded.images_past_budget);
}
// Edits on its branch tabs are reverted too.
crate::branches::close_tabs(s, uid, None);
@@ -548,6 +558,9 @@ fn load_with(
if let Some(r) = load_fig(bytes, &file_stem(name_hint)) {
return Ok((r?, true));
}
if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) {
return Ok((r?, true));
}
if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) {
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
link_instances(&mut doc);
@@ -563,6 +576,219 @@ fn load_with(
}
}
/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try.
fn load_sketch(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
if !figcraft_format::sketch::is_sketch(bytes) {
return None;
}
// The import and what follows read the installed fonts: the system's must be known first (the
// desktop app scans them in the background; this waits for that scan).
figcraft_text::fontdb::scan_system_fonts();
let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face);
let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?;
Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| {
match_font_families(&mut doc);
platform_tracking(&mut doc);
sketch_line_heights(&mut doc);
link_instances(&mut doc);
reflow_overridden_text(&mut doc);
figcraft_format::Loaded { issues: warnings, ..doc.into() }
}))
}
/// Instances of a stack sized apart from their main are laid out at their size, and texts in
/// instances whose string an override changed are measured again with the auto layout around them
/// re-flowing (Sketch saves layouts measured with the master's string). The rest keeps the layout
/// Sketch saved.
fn reflow_overridden_text(doc: &mut Document) {
let changed: Vec<NodeId> = doc
.nodes()
.filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) {
(NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters,
_ => false,
})
.map(|n| n.id)
.collect();
let m = crate::measure::TextMeasure;
// Instances of a stack at another size than their main: the stack at their size, innermost first.
let mut resized: Vec<(usize, NodeId)> = doc
.nodes()
.filter(|n| n.layout.is_some())
.filter(|n| match &n.kind {
NodeKind::Instance { component, .. } => {
doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6)
}
_ => false,
})
.map(|n| (doc.ancestors(n.id).len(), n.id))
.collect();
resized.sort_by_key(|r| std::cmp::Reverse(r.0));
for (_, id) in resized {
figcraft_layout::layout_frame(doc, id, &m);
}
for id in changed {
if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) {
figcraft_layout::set_size(doc, id, w, h);
}
figcraft_layout::layout_up(doc, id, &m);
}
}
/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from
/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and
/// case.
fn match_font_families(doc: &mut Document) {
let installed = figcraft_text::fontdb::family_names();
let mut cache: std::collections::BTreeMap<String, Option<String>> = std::collections::BTreeMap::new();
let mut fix = |style: &mut figcraft_doc::TextStyle| {
let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed));
if let Some(f) = found {
style.font_family = f.clone();
}
};
let ids: Vec<NodeId> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect();
for id in ids {
if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) {
fix(&mut td.style);
for r in &mut td.runs {
fix(&mut r.style);
}
}
}
// Shared text styles too, so they stay like the layers linked to them.
for st in &mut doc.styles {
if let StyleKind::Text { style } = &mut st.kind {
fix(style);
}
}
}
/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine
/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so
/// lines measure and break as they did there. A named style's optical size follows the text size
/// there too (FigCraft keeps the instance's), so the imported text carries it as an `opsz` value.
fn platform_tracking(doc: &mut Document) {
let mut cache: std::collections::BTreeMap<(String, String), Option<std::sync::Arc<figcraft_text::fontdb::Face>>> =
std::collections::BTreeMap::new();
let mut face = |style: &figcraft_doc::TextStyle| {
cache
.entry((style.font_family.clone(), style.font_style.clone()))
.or_insert_with(|| {
figcraft_text::fontdb::has_family(&style.font_family)
.then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style))
.flatten()
})
.clone()
};
let mut track = |style: &mut figcraft_doc::TextStyle| {
let Some(f) = face(style) else { return };
let t = f.tracking(style.font_size);
if t != 0.0 && t.is_finite() {
let px = style.letter_spacing.resolve(style.font_size);
style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t);
}
if f.axis("opsz").is_some() && f.instance(&style.font_style).is_some() && style.font_size.is_finite() {
style.variations.entry("opsz".into()).or_insert(style.font_size);
}
};
let ids: Vec<NodeId> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect();
for id in ids {
if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) {
track(&mut td.style);
for r in &mut td.runs {
track(&mut r.style);
}
}
}
for st in &mut doc.styles {
if let StyleKind::Text { style } = &mut st.kind {
track(style);
}
}
}
/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their
/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this.
const TALL_LINE_FAMILIES: [&str; 11] =
["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"];
/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its
/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch
/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth
/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres
/// the font's own ascent and descent in the line. `(0, 0)` without metrics.
pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) {
let k = size / upem;
let (a, d) = (ascent * k, descent * k);
if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 {
return (0.0, 0.0);
}
let natural = a.round() + d.round();
let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural };
let sketch_baseline = line - d.round();
let figcraft_baseline = (line - (a + d)) / 2.0 + a;
(line, sketch_baseline - figcraft_baseline)
}
/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line,
/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a
/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text
/// styles take the line too.
fn sketch_line_heights(doc: &mut Document) {
// Per family and style: the face's ascent, descent and units per em.
type Metrics = Option<(f64, f64, f64)>;
let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new();
let mut set = |style: &mut TextStyle| -> f64 {
if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 {
return 0.0;
}
let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| {
figcraft_text::fontdb::has_family(&style.font_family)
.then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style))
.flatten()
.map(|f| (f.ascent, f.descent, f.upem))
});
let Some((a, d, upem)) = m else { return 0.0 };
let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size);
if line > 0.0 {
style.line_height = LineHeight::Percent(line / style.font_size * 100.0);
}
down
};
let ids: Vec<NodeId> = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect();
for id in ids {
// An auto layout places its texts itself (its next pass would take a move back).
let laid_out =
doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()));
let Some(n) = doc.node_mut(id) else { continue };
let NodeKind::Text(td) = &mut n.kind else { continue };
let down = set(&mut td.style);
for r in &mut td.runs {
set(&mut r.style);
}
// Down in the layer's own frame (a rotated text moves along its own axis).
if !laid_out && down.is_finite() && down != 0.0 {
n.transform *= figcraft_doc::geom::Affine::translate((0.0, down));
}
}
for st in &mut doc.styles {
if let StyleKind::Text { style } = &mut st.kind {
set(style);
}
}
}
/// The installed family `family` means when it isn't installed under that exact name: the one
/// whose name is the same without spaces, ignoring case.
pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option<String> {
let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::<String>();
if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) {
return None;
}
let want = key(family);
installed.iter().find(|f| key(f) == want).cloned()
}
/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try.
pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
let r = figcraft_format::fig::try_import(bytes, name)?;
@@ -731,7 +957,13 @@ fn open(s: &mut Session, p: &Value) -> Result<Value> {
});
}
let summary = import_summary(&loaded.doc);
s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches));
let images_left_out = if native { loaded.images_past_budget } else { 0 };
s.docs.push(
DocState::new(loaded.doc, native.then(|| path.clone()))
.with_versions(loaded.versions)
.with_branches(loaded.branches)
.with_images_left_in_file(&path, images_left_out),
);
s.active = s.docs.len() - 1;
let mut out = json!({"index": s.active, "summary": summary});
out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues);
@@ -819,7 +1051,9 @@ impl FileSnapshot {
let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else {
return Ok(false);
};
write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?;
let path = self.path.as_deref().ok_or(EngineError::NoDocument)?;
refuse_dropping_images(ds, path)?;
write(path, bytes)?;
// The history or branches may have changed since: then they still count as unsaved.
if ds.revision == self.revision {
ds.versions_changed = false;
@@ -831,6 +1065,16 @@ impl FileSnapshot {
}
}
fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> {
match ds.images_lost_saving_to(path) {
Some(count) => Err(EngineError::Other(format!(
"{} wasn't saved: {count} of its images weren't loaded (together they pass what FigCraft reads from one file), and saving over it would delete them. Save it under another name; this file keeps them.",
file_stem(path)
))),
None => Ok(()),
}
}
/// The branch tabs of the file in tab `uid` were saved with it.
fn branch_tabs_saved(s: &mut Session, uid: u64) {
for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) {
@@ -855,37 +1099,63 @@ fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool {
let path =
string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None });
path.is_some_and(|path| ext(&path) != "fig")
path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p))
}
/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download).
fn wants_data(p: &Value) -> bool {
p.get("data").and_then(Value::as_bool) == Some(true)
}
/// Save the file of the active tab (the main file for a branch tab, with its branches), or export
/// the active document as an experimental `.fig` copy without changing its native save state.
fn save_to(s: &mut Session, path: String) -> Result<Value> {
if ext(&path) == "fig" {
/// Without a path the file comes back as base64 `data` for the caller to deliver (#969).
fn save_to(s: &mut Session, path: Option<String>) -> Result<Value> {
if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") {
return export_fig(s, &json!({"path": path}));
}
let i = crate::branches::file_tab(s);
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
// A download (no path) writes over no file.
if let Some(path) = path.as_deref() {
refuse_dropping_images(ds, path)?;
}
let mut snapshot = FileSnapshot::of(ds);
let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old));
if follows_path && ds.path.as_deref() != Some(path.as_str()) {
Arc::make_mut(&mut snapshot.doc).name = file_stem(&path);
if let Some(path) = path.as_deref()
&& follows_path
&& ds.path.as_deref() != Some(path)
{
Arc::make_mut(&mut snapshot.doc).name = file_stem(path);
}
let bytes = snapshot.encode()?;
let Some(path) = path else {
// The download can still be refused: the caller marks the file saved once it was delivered.
let name = format!("{}.figcraft", ds.doc.name);
return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}));
};
write(&path, &bytes)?;
ds.doc = snapshot.doc;
ds.path = Some(path.clone());
ds.mark_saved();
ds.revision += 1;
let uid = ds.uid;
branch_tabs_saved(s, uid);
mark_saved(s, &json!({}))?;
remember(s, &path);
Ok(json!({"path": path, "bytes": bytes.len()}))
}
/// The file of the active tab (with its branch tabs) now matches what was written or delivered.
fn mark_saved(s: &mut Session, _: &Value) -> Result<Value> {
let i = crate::branches::file_tab(s);
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
ds.mark_saved();
ds.revision += 1;
let uid = ds.uid;
branch_tabs_saved(s, uid);
Ok(json!({"saved": true}))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = string(p, "path") {
return save_to(s, path);
return save_to(s, Some(path));
}
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
// Earlier builds wrote native data under a .fig name: never overwrite that file with an export.
@@ -893,7 +1163,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))
}
Some(path) => save_to(s, path),
Some(path) => save_to(s, Some(path)),
None => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into()))
@@ -907,8 +1177,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
if ext(&path).is_empty() {
path.push_str(".figcraft");
}
save_to(s, path)
save_to(s, Some(path))
}
None if wants_data(p) => save_to(s, None),
None => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into()))
@@ -1159,6 +1430,20 @@ fn size_param(cmd: &str, p: &Value) -> Result<Option<ExportSize>> {
/// Most files one `file.exportLayers` writes.
const MAX_EXPORT_FILES: usize = 1000;
/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy
/// are all in memory at once (in the browser too).
const MAX_ZIP_BYTES: usize = 256 << 20;
/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together.
fn add_member<'a>(members: &mut Vec<(&'a str, Vec<u8>)>, name: &'a str, bytes: Vec<u8>, limit: usize) -> Result<()> {
let total = members.iter().map(|(_, b)| b.len()).sum::<usize>().saturating_add(bytes.len());
if total > limit {
let mb = limit >> 20;
return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder")));
}
members.push((name, bytes));
Ok(())
}
/// One file of a layer export: the layer, its setting and the file name.
pub struct ExportJob {
@@ -1204,12 +1489,15 @@ fn export_path(name: &str) -> String {
if parts.is_empty() { "Untitled".into() } else { parts.join("/") }
}
/// Characters no file name may contain (path separators and ones file systems reject).
pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|";
/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and
/// control characters become `_`); everything else is kept, up to the first 200 characters.
/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before
/// the number and extension it adds).
pub fn safe_file_name(name: &str) -> String {
name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect()
name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect()
}
/// Layers on `page` that have export settings (document order).
@@ -1230,6 +1518,9 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into()));
}
let dir = string(p, "dir");
// The browser saves one download at a time: several files go as one zip (#969).
let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false);
let mut members = Vec::new();
// Print options passed here stand in for every PDF setting's own.
let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?;
let mut files = Vec::with_capacity(jobs.len());
@@ -1249,12 +1540,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
write(&path, &bytes)?;
f["path"] = json!(path);
}
None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?,
None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)),
}
files.push(f);
}
let with_settings: std::collections::BTreeSet<NodeId> = jobs.iter().map(|j| j.id).collect();
Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}))
let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())});
if zip {
let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?;
r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive));
}
Ok(r)
}
/// A PDF with one page per group of layers (an sRGB document), titled after the file; `area`
@@ -1331,7 +1628,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(out)
}
"figcraft" | "json" | "fig" => {
"figcraft" | "json" | "fig" | "sketch" => {
let resolver = path.as_deref().map(rest_images);
let images: figcraft_format::rest::ImageResolver = match &resolver {
Some(r) => r,
@@ -1601,6 +1898,25 @@ mod rest_import_tests {
assert_eq!(s.docs.len(), open, "a refused open adds nothing");
}
#[test]
fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() {
// The browser has no paths: Save as returns the file for a download (#969).
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path");
let recent = s.prefs.recent_files.clone();
let r = s.execute("file.saveAs", &json!({"data": true})).unwrap();
assert_eq!(r["name"], "Untitled.figcraft");
assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits");
s.execute("file.markSaved", &json!({})).unwrap();
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(s.ds().unwrap().path, None, "still no path to save over");
assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember");
let mut t = Session::new();
t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap();
assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen");
}
#[test]
fn open_and_import_rest_json() {
let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id()));
@@ -1768,3 +2084,18 @@ mod staging_write_tests {
let _ = std::fs::remove_dir_all(&d);
}
}
#[cfg(test)]
mod zip_cap_tests {
use super::add_member;
#[test]
fn zip_members_stop_at_the_limit_and_keep_what_fit() {
let mut members = Vec::new();
add_member(&mut members, "a.png", vec![0; 6], 10).unwrap();
add_member(&mut members, "b.png", vec![0; 4], 10).unwrap();
let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string();
assert!(err.contains("as one zip"), "{err}");
assert_eq!(members.iter().map(|(n, _)| *n).collect::<Vec<_>>(), ["a.png", "b.png"]);
}
}
+4
View File
@@ -5,6 +5,7 @@ pub mod comment;
pub mod component;
pub mod create;
pub mod edit;
pub mod facilitate;
pub mod file;
pub mod image;
pub mod layout;
@@ -113,6 +114,9 @@ fn registry() -> &'static Registry {
list.extend(crate::datafill::specs());
list.extend(crate::textfit::specs());
list.extend(crate::board::specs());
list.extend(facilitate::specs());
list.extend(crate::board_table::specs());
list.extend(crate::board_templates::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {
if index.insert(s.id, i).is_some() {
+20 -2
View File
@@ -144,7 +144,9 @@ pub(crate) fn sizing(v: &Value) -> Option<Sizing> {
pub(crate) fn line_height(v: &Value) -> Option<LineHeight> {
match v {
Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto),
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LineHeight::Percent),
Value::String(s) if s.trim().ends_with('%') => {
s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).map(LineHeight::Percent)
}
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
other => f64_of(other).map(LineHeight::Pixels),
}
@@ -152,7 +154,9 @@ pub(crate) fn line_height(v: &Value) -> Option<LineHeight> {
pub(crate) fn letter_spacing(v: &Value) -> Option<LetterSpacing> {
match v {
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LetterSpacing::Percent),
Value::String(s) if s.trim().ends_with('%') => {
s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).map(LetterSpacing::Percent)
}
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
other => f64_of(other).map(LetterSpacing::Pixels),
}
@@ -1277,3 +1281,17 @@ pub fn describe(doc: &Document, id: NodeId) -> Value {
let _ = Color::BLACK;
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_finite_percent_strings_are_rejected() {
for s in ["NaN%", "inf%", "-inf%"] {
assert!(line_height(&json!(s)).is_none(), "line_height accepted {s}");
assert!(letter_spacing(&json!(s)).is_none(), "letter_spacing accepted {s}");
}
assert!(matches!(line_height(&json!("150%")), Some(LineHeight::Percent(p)) if p == 150.0));
}
}
+9 -6
View File
@@ -456,16 +456,19 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
let closed = figcraft_doc::geometry::is_closed(&n);
let Some(geom) = (match &n.kind {
NodeKind::Text(_) | NodeKind::BooleanOp { .. } => figcraft_render::node_outline(doc, &n).map(|(p, _)| p),
_ => figcraft_doc::geometry::stroke_geometry(&n),
let Some((area, rule)) = (match &n.kind {
NodeKind::Text(_) => figcraft_render::node_outline(doc, &n)
.map(|(p, _)| (figcraft_render::draw::stroke_area(&n, &p, closed), figcraft_render::FillRule::Winding)),
_ => figcraft_render::stroke_fill(doc, &n),
}) else {
continue;
};
let area = figcraft_render::draw::stroke_area(&n, &geom, closed);
let mut path = (*area).clone();
// Union the pieces so the outline is one clean shape.
if let Ok(u) = figcraft_pathops::simplify(&path) {
// Simplify non-zero strokes into even-odd contours. Inside/outside rings already use
// even-odd: interpreting their same-direction contours as non-zero would fill the hole.
if rule == figcraft_render::FillRule::Winding
&& let Ok(u) = figcraft_pathops::simplify(&path)
{
path = u;
}
let bb = path.bounding_box();
+2 -2
View File
@@ -1009,8 +1009,8 @@ fn bound_field(n: &Node, field: &str) -> Option<String> {
let cl = &n.child_layout;
let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied());
Some(match field {
"width" => format!("{}", n.width),
"height" => format!("{}", n.height),
"width" | "w" => format!("{}", n.width),
"height" | "h" => format!("{}", n.height),
"minWidth" => format!("{:?}", cl.min_w),
"maxWidth" => format!("{:?}", cl.max_w),
"minHeight" => format!("{:?}", cl.min_h),
+18 -8
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.export",
"Export variables",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight), path? (write it there)} → {text}, or {path} when written",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written",
false,
export
),
@@ -46,8 +46,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let text = match string(p, "format").as_deref().unwrap_or("json") {
"json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls))
.map_err(|e| EngineError::Other(e.to_string()))?,
"css" => css(doc, &colls),
_ => return Err(bad(cmd, "format: json|css")),
"css" => css(doc, &colls, false),
"tailwind" => css(doc, &colls, true),
_ => return Err(bad(cmd, "format: json|css|tailwind")),
};
match string(p, "path") {
Some(path) => {
@@ -66,16 +67,25 @@ fn css_safe(text: &str) -> String {
/// Each collection's variables as custom properties (named as generated code names them): the
/// default mode's values on `:root`, every other mode's on `[data-<collection>="<mode>"]`;
/// aliases are `var(--name)`.
fn css(doc: &Document, colls: &[&Collection]) -> String {
fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
let vars = &doc.variables;
// Tailwind theme names, unique per file and the same ones page code reads.
let theme = crate::tailwind::Theme::new(doc);
let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) };
// Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and
// font weight don't.
let roles = crate::token_roles::Roles::new(doc);
let mut out = String::new();
let mut out = if tailwind { "@import \"tailwindcss\";\n\n".to_string() } else { String::new() };
for c in colls {
let attr = css_var_name(&c.name);
for (i, m) in c.modes.iter().enumerate() {
let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) };
let sel = match i {
// `static`: Tailwind otherwise drops theme variables no utility uses, and pages also
// read them through `var()` and `(--name)` utilities.
0 if tailwind => "@theme static".to_string(),
0 => ":root".to_string(),
_ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)),
};
out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name)));
for v in vars.variables.iter().filter(|v| v.collection == c.id) {
let value = match v.values.get(&m.id) {
@@ -85,12 +95,12 @@ fn css(doc: &Document, colls: &[&Collection]) -> String {
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
Some(VariableValue::Boolean(b)) => b.to_string(),
Some(VariableValue::Alias(a)) => match vars.get(a) {
Some(t) => format!("var({})", css_var_of(t)),
Some(t) => format!("var({})", name(t)),
None => continue,
},
None => continue,
};
out.push_str(&format!(" {}: {value};\n", css_var_of(v)));
out.push_str(&format!(" {}: {value};\n", name(v)));
}
out.push_str("}\n");
}
+1 -1
View File
@@ -427,7 +427,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"pref.language",
"Interface language",
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}",
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|…; else English}",
false,
|s, p| {
let lang = req_string("pref.language", p, "language")?;
+9 -6
View File
@@ -37,7 +37,7 @@ const MAX_WARNINGS: usize = 50;
/// The languages a component can be connected in.
pub fn language(s: &str) -> Option<CodeLang> {
CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))
CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))
}
/// The layer `id`'s links are stored on: its component set, else its component (an instance's
@@ -64,7 +64,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
let path = &link.import_path;
match lang {
_ if path.is_empty() => String::new(),
CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
CodeLang::React | CodeLang::ArkUi => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)),
CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()),
_ => format!("import {path}"),
}
@@ -267,6 +267,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
(Some(l), CodeLang::React) => format!("<{} />", l.name),
(Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name),
(Some(l), CodeLang::SwiftUi) => format!("{}()", l.name),
(Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name),
(Some(l), _) => format!("{{ {}() }}", l.name),
(None, _) => value_expr(doc, c, p, v, lang),
}
@@ -317,6 +318,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String {
format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::<String>())
}
CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::<Vec<_>>().join(", ")),
_ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::<Vec<_>>().join(", ")),
}
}
@@ -334,13 +336,14 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res
}
let path = &link.import_path;
let path_ok = match lang {
CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
CodeLang::React | CodeLang::ArkUi => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'),
CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'),
_ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'),
};
if path.chars().count() > MAX_IMPORT || !path_ok {
return Err(match lang {
CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
CodeLang::ArkUi => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"),
CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"),
_ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"),
});
@@ -377,7 +380,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result<Option<String>> {
/// The `language` parameter (default React).
fn lang_param(cmd: &str, p: &Value) -> Result<CodeLang> {
match text(cmd, p, "language")? {
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose")),
Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose|arkui")),
None => Ok(CodeLang::React),
}
}
@@ -407,7 +410,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"codeConnect.set",
"Connect to code",
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}",
true,
|s, p| {
let cmd = "codeConnect.set";
@@ -522,7 +525,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
let (Some(component), Some(lang), Some(name), Some(template)) =
(field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str))
else {
warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose), name and template strings", i + 1));
warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose|arkui), name and template strings", i + 1));
continue;
};
let link = CodeLink {
+11 -1
View File
@@ -72,6 +72,16 @@ fn connected_instances_show_the_real_snippet() {
json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": "<Card title={title} dotted={Show dot} />"}),
);
assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n<Card title={\"Say \\\"hi\\\"\"} dotted={false} />\n");
// ArkUI: an ArkTS import and an object-literal call.
ex(
&mut s,
"codeConnect.set",
json!({"id": c, "language": "arkui", "importPath": "@acme/arkui", "template": "Card({ title: {title}, dotted: {Show dot} })"}),
);
assert_eq!(code(&mut s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n");
let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "arkui"}));
assert_eq!(v["usage"], "Card({ title: \"Say \\\"hi\\\"\", dotted: false })\n");
assert_eq!(v["connected"]["import"], "import { Card } from \"@acme/arkui\";");
assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path");
ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"}));
// inspect.componentCode: the snippet as the usage, and the link.
@@ -105,7 +115,7 @@ fn connected_instances_show_the_real_snippet() {
assert!(code(&mut s, i, "swiftui").starts_with("import AcmeUI"));
ex(&mut s, "edit.undo", json!({}));
assert!(code(&mut s, i, "react").starts_with("import { Card }"));
assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 2);
assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 3);
assert!(s.execute("codeConnect.remove", &json!({"id": c})).is_err(), "nothing left");
}
+407 -22
View File
@@ -1,9 +1,9 @@
//! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the
//! layer itself, HTML or React for its subtree, and SwiftUI or Jetpack Compose (stacks for auto
//! layout, absolute offsets otherwise). Components become components whose properties are
//! parameters and instances become calls passing their values (see [`crate::component_codegen`]);
//! variables bound to a layer become CSS custom properties. The output is a starting point, not a
//! pixel-exact translation.
//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS;
//! stacks for auto layout, absolute offsets otherwise). Components become components whose
//! properties are parameters and instances become calls passing their values (see
//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The
//! output is a starting point, not a pixel-exact translation.
use figcraft_color::{Color, PaintKind};
use figcraft_doc::*;
@@ -18,13 +18,13 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.code",
"Code for layer",
"{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
"{id?, language?: css|html|react|leptos|tailwind|swiftui|compose|arkui (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
false,
|s, p| {
let cmd = "inspect.code";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?;
let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?,
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|tailwind|swiftui|compose|arkui"))?,
None => CodeLang::Css,
};
let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() {
@@ -40,7 +40,14 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad(cmd, "no such layer"));
}
let connected = crate::code_connect::link(doc, id, lang).is_some();
let code = code_for(doc, id, lang);
let mobile = match get(p, "mobile") {
None | Some(serde_json::Value::Null) => None,
Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?),
};
let code = match (lang, mobile) {
(CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)),
_ => code_for(doc, id, lang),
};
let code = match rem {
Some(root) if lang.is_web() => px_to_rem(&code, root),
_ => code,
@@ -61,20 +68,27 @@ pub enum CodeLang {
React,
/// A Leptos component (`view!` markup) with its stylesheet.
Leptos,
/// A React function component styled with Tailwind CSS v4 utilities (no stylesheet).
Tailwind,
SwiftUi,
Compose,
/// HarmonyOS ArkUI: an ArkTS struct (`@Component`, `build`).
ArkUi,
}
impl CodeLang {
pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose];
pub const ALL: [CodeLang; 8] =
[CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi];
pub fn id(self) -> &'static str {
match self {
CodeLang::Css => "css",
CodeLang::Html => "html",
CodeLang::React => "react",
CodeLang::Leptos => "leptos",
CodeLang::Tailwind => "tailwind",
CodeLang::SwiftUi => "swiftui",
CodeLang::Compose => "compose",
CodeLang::ArkUi => "arkui",
}
}
pub fn label(self) -> &'static str {
@@ -83,11 +97,14 @@ impl CodeLang {
CodeLang::Html => "HTML + CSS",
CodeLang::React => "React",
CodeLang::Leptos => "Leptos",
CodeLang::Tailwind => "React + Tailwind",
CodeLang::SwiftUi => "SwiftUI",
CodeLang::Compose => "Compose",
CodeLang::ArkUi => "ArkUI",
}
}
/// Languages whose lengths are CSS pixels (they can be shown in rem).
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
/// are utility classes).
pub fn is_web(self) -> bool {
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
}
@@ -95,6 +112,9 @@ impl CodeLang {
if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") {
return Some(CodeLang::React);
}
if s.eq_ignore_ascii_case("arkts") {
return Some(CodeLang::ArkUi);
}
CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s))
}
}
@@ -109,7 +129,12 @@ pub(crate) fn num(v: f64) -> String {
return "0".into();
}
let r = (v * 100.0).round() / 100.0;
if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") }
if (r - r.round()).abs() < 1e-9 {
// `as i64` saturates past 2^63, so larger whole values are printed from the float.
if r.abs() < 9e18 { format!("{}", r.round() as i64) } else { format!("{:.0}", r.round()) }
} else {
format!("{r}")
}
}
/// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end
@@ -194,7 +219,8 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
match lang {
CodeLang::Css => css(doc, id),
CodeLang::Html => html(doc, id),
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => crate::component_codegen::code(doc, id, lang),
}
}
@@ -316,25 +342,32 @@ pub(crate) fn indent(depth: usize) -> String {
/// The CSS custom properties generated code uses: name → fallback value, in first-use order.
#[derive(Default)]
pub(crate) struct Tokens(pub Vec<(String, String)>);
pub(crate) struct Tokens {
vars: Vec<(String, String)>,
/// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name.
pub tailwind: bool,
/// The file's theme names (computed on first use).
theme: Option<crate::tailwind::Theme>,
}
impl Tokens {
/// `var(--name, value)`, remembering the variable.
fn var(&mut self, variable: &Variable, value: String) -> String {
let name = css_var_of(variable);
fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String {
let name =
if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) };
let out = format!("var({name}, {value})");
if !self.0.iter().any(|(n, _)| *n == name) {
self.0.push((name, value));
if !self.vars.iter().any(|(n, _)| *n == name) {
self.vars.push((name, value));
}
out
}
/// A `:root` rule declaring the variables (empty without any).
pub fn root_rule(&self) -> String {
if self.0.is_empty() {
if self.vars.is_empty() {
return String::new();
}
let decls: String = self.0.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
format!(":root {{\n{decls}}}\n")
}
}
@@ -365,7 +398,7 @@ pub fn css_var_of(v: &Variable) -> String {
/// `value`, or a reference to the variable bound to the first of `fields` that has one.
fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String {
match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) {
Some(v) => t.var(v, value),
Some(v) => t.var(doc, v, value),
None => value,
}
}
@@ -701,6 +734,14 @@ pub(crate) struct Markup<'c, 'a> {
/// Each layer's path in the tree (names with sibling counts), class and declarations, so
/// variants can be compared layer by layer.
pub rules: Vec<(String, String, String)>,
/// Instances as their own (overridden) layers instead of component calls, except those
/// connected to code (page code).
pub inline: bool,
/// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages.
pub fluid: bool,
/// With `fluid`, other layers at least this share of the root's width are fluid too (a
/// divider, a banner image); smaller ones (icons) keep their size. Default: none.
pub fluid_leaves: f64,
}
impl<'c, 'a> Markup<'c, 'a> {
@@ -715,6 +756,9 @@ impl<'c, 'a> Markup<'c, 'a> {
root_class: None,
root_attrs: String::new(),
rules: Vec::new(),
inline: false,
fluid: false,
fluid_leaves: f64::INFINITY,
}
}
@@ -796,7 +840,10 @@ impl<'c, 'a> Markup<'c, 'a> {
}
}
// Nested instances are calls (component code), in a wrapper that places them when needed.
if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
// Page code inlines instances, except those connected to code (codeConnect.set): their
// snippet, e.g. a design-system `<Button>`.
let connected = self.inline && crate::code_connect::link(doc, n.id, CodeLang::React).is_some();
if self.code() && (!self.inline || connected) && !root && matches!(n.kind, NodeKind::Instance { .. }) {
let at = depth + usize::from(!decls.is_empty());
let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
let inner = match self.ctx.param(n, "mainComponent") {
@@ -816,6 +863,11 @@ impl<'c, 'a> Markup<'c, 'a> {
let hugs = n.layout.is_some() || matches!(n.kind, NodeKind::Text(_));
let hug = (hugs && n.child_layout.sizing_h == Sizing::Hug, hugs && n.child_layout.sizing_v == Sizing::Hug);
let mut own = css_decls(doc, n, &mut self.tokens, hug);
let page = self.ctx.doc.node(self.ctx.root).map_or(f64::INFINITY, |r| r.width);
if self.fluid && (n.layout.is_some() || matches!(n.kind, NodeKind::Text(_)) || (!root && n.width >= page * self.fluid_leaves)) {
// Full width, or sized by the flex row (`flex: 1`): no cap needed.
own = fluid(&own, n.width >= page - 0.5 || decls.contains("flex: 1 1 0;"), n.layout.is_some());
}
if n.layout.is_none() && !n.children.is_empty() && !root && in_flow {
own.push_str("position: relative;\n");
}
@@ -882,6 +934,28 @@ impl<'c, 'a> Markup<'c, 'a> {
}
}
/// `decls` for a box that follows the page: a fixed width becomes `width: 100%` capped at that
/// width (or just the full width for boxes as wide as the page), and a frame's fixed height a
/// minimum, so content can wrap and grow.
// ponytail: width-based heuristic; fixed-size leaves (icons, images, shapes) keep their pixels.
fn fluid(decls: &str, full: bool, frame: bool) -> String {
let mut out = String::with_capacity(decls.len() + 32);
for line in decls.lines() {
if let Some(w) = line.strip_prefix("width: ") {
out.push_str("width: 100%;\n");
if !full {
out.push_str(&format!("max-width: {w}\n"));
}
} else if let Some(h) = line.strip_prefix("height: ").filter(|_| frame) {
out.push_str(&format!("min-height: {h}\n"));
} else {
out.push_str(line);
out.push('\n');
}
}
out
}
// ------------------------------------------------------------------ SwiftUI
fn swift_color(c: Color) -> String {
@@ -1177,6 +1251,232 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
}
}
// ------------------------------------------------------------------ ArkUI (ArkTS)
/// A colour as ArkUI writes it: `'#RRGGBB'`, or `'#AARRGGBB'` with transparency.
fn ark_color(c: Color) -> String {
let [r, g, b, a] = c.to_rgba8();
if a == 255 { format!("'#{r:02X}{g:02X}{b:02X}'") } else { format!("'#{a:02X}{r:02X}{g:02X}{b:02X}'") }
}
/// A layer's corner radii as `.borderRadius(...)` (uniform or per corner).
fn ark_radius(n: &Node, mods: &mut Vec<String>) {
if let Some(r) = n.corners.uniform() {
if r > 0.0 {
mods.push(format!(".borderRadius({})", num(r)));
}
} else if n.corners.radii.iter().any(|r| *r > 0.0) {
let [tl, tr, br, bl] = n.corners.radii;
mods.push(format!(".borderRadius({{ topLeft: {}, topRight: {}, bottomRight: {}, bottomLeft: {} }})", num(tl), num(tr), num(br), num(bl)));
}
}
/// ArkUI for a layer and its subtree at `depth`; `extra` attributes go last (a free child's
/// position). A layer a boolean parameter shows sits in an `if`.
pub(crate) fn arkui(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra: &[String]) {
let Some(n) = ctx.doc.node(id) else { return };
if id != ctx.root
&& let Some(p) = ctx.param(n, "visible")
{
let pad = indent(depth);
out.push_str(&format!("{pad}if (this.{}) {{\n", p.ident));
arkui_layer(ctx, n, depth + 1, out, extra);
out.push_str(&format!("{pad}}}\n"));
return;
}
arkui_layer(ctx, n, depth, out, extra);
}
fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
let doc = ctx.doc;
let pad = indent(depth);
if ctx.too_deep(depth) {
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
return;
}
// Nested instances are calls; an instance swap parameter is a builder.
let swap = ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root);
let call = match swap {
Some(p) => Some(format!("{pad}this.{}()\n", p.ident)),
None => ctx.call(n, CodeLang::ArkUi, depth),
};
if let Some(call) = call {
if let Some(p) = swap.filter(|_| !extra.is_empty()) {
// The builder returns nothing, so a position goes on a container around the call.
out.push_str(&format!("{pad}Stack() {{\n{pad} this.{}()\n{pad}}}\n", p.ident));
for m in extra {
out.push_str(&format!("{pad}{m}\n"));
}
return;
}
out.push_str(&call);
for m in extra {
out.push_str(&format!("{pad} {m}\n"));
}
return;
}
let mut mods: Vec<String> = Vec::new();
match &n.kind {
NodeKind::Text(t) => {
let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident));
out.push_str(&format!("{pad}Text({text})\n"));
let s = &t.style;
mods.push(format!(".fontSize({})", num(s.font_size)));
mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900)));
mods.push(format!(".fontFamily({})", literal(&s.font_family, false)));
if s.italic() {
mods.push(".fontStyle(FontStyle.Italic)".into());
}
if let Some(c) = first_fill(n) {
mods.push(format!(".fontColor({})", ark_color(c)));
}
match s.line_height {
LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))),
LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))),
LineHeight::Auto => {}
}
match s.letter_spacing {
LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))),
LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))),
_ => {}
}
match t.align_h {
TextAlignH::Left => {}
TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()),
TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()),
TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()),
}
// Aligning needs a box to align in: the text layer's own width.
if !matches!(t.align_h, TextAlignH::Left) {
mods.push(format!(".width({})", num(n.width)));
}
match s.case {
TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()),
TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()),
_ => {}
}
match s.decoration {
TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()),
TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()),
TextDecoration::None => {}
}
}
NodeKind::Ellipse { .. } => {
out.push_str(&format!("{pad}Ellipse({{ width: {}, height: {} }})\n", num(n.width), num(n.height)));
if let Some(c) = first_fill(n) {
mods.push(format!(".fill({})", ark_color(c)));
}
if let Some(c) = stroke_color(n) {
mods.push(format!(".stroke({})", ark_color(c)));
mods.push(format!(".strokeWidth({})", num(n.stroke.weight)));
}
}
NodeKind::Rectangle => {
out.push_str(&format!("{pad}Column()\n"));
if let Some(c) = first_fill(n) {
mods.push(format!(".backgroundColor({})", ark_color(c)));
}
ark_radius(n, &mut mods);
mods.push(format!(".width({})", num(n.width)));
mods.push(format!(".height({})", num(n.height)));
}
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)),
Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)),
None => "Stack({ alignContent: Alignment.TopStart })".to_string(),
};
out.push_str(&format!("{pad}{open} {{\n"));
match ctx.param(n, "slot") {
// A slot shows the instance's content, or else the main's (its default builder).
Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)),
None => {
for c in ctx.kids(n) {
// Free children sit at their position inside a Stack, and so does a child
// that ignores its auto layout parent.
let pos: Vec<String> = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) {
Some(cn) => {
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))]
}
None => Vec::new(),
};
arkui(ctx, c, depth + 1, out, &pos);
}
}
}
out.push_str(&format!("{pad}}}\n"));
if let Some(l) = &n.layout {
let [t, r, b, lft] = l.padding;
if [t, r, b, lft].iter().any(|v| *v > 0.0) {
mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft)));
}
let justify = match l.main_align {
MainAlign::Min => None,
MainAlign::Center => Some("FlexAlign.Center"),
MainAlign::Max => Some("FlexAlign.End"),
MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"),
MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"),
MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"),
};
if let Some(j) = justify {
mods.push(format!(".justifyContent({j})"));
}
let cross = match l.cross_align {
CrossAlign::Min => None,
CrossAlign::Center => Some("Center"),
CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }),
CrossAlign::Baseline => None,
};
if let Some(c) = cross {
let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" };
mods.push(format!(".alignItems({ty}.{c})"));
}
}
mods.push(format!(".width({})", num(n.width)));
mods.push(format!(".height({})", num(n.height)));
if let Some(c) = first_fill(n) {
mods.push(format!(".backgroundColor({})", ark_color(c)));
}
if radius(n) > 0.0 {
mods.push(".clip(true)".into());
}
ark_radius(n, &mut mods);
}
_ => {
out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image($r('app.media.asset'))\n", n.kind.label()));
mods.push(format!(".width({})", num(n.width)));
mods.push(format!(".height({})", num(n.height)));
}
}
// Ellipses take their stroke as a shape attribute (above).
if !matches!(n.kind, NodeKind::Ellipse { .. })
&& let Some(c) = stroke_color(n)
{
mods.push(format!(".border({{ width: {}, color: {} }})", num(n.stroke.weight), ark_color(c)));
}
if let Some(s) = drop_shadow(n) {
mods.push(format!(
".shadow({{ radius: {}, color: {}, offsetX: {}, offsetY: {} }})",
num(s.radius / 2.0),
ark_color(s.color),
num(s.offset.0),
num(s.offset.1)
));
}
if n.opacity < 1.0 {
mods.push(format!(".opacity({})", num(f64::from(n.opacity))));
}
let rot = figcraft_geom::rotation_degrees(n.transform);
if rot.abs() > 0.01 && rot.is_finite() {
mods.push(format!(".rotate({{ angle: {} }})", num(-rot)));
}
mods.extend(extra.iter().cloned());
for m in mods {
out.push_str(&format!("{pad} {m}\n"));
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1184,6 +1484,14 @@ mod tests {
use crate::params::id_of;
use serde_json::json;
#[test]
fn num_keeps_huge_whole_numbers() {
assert_eq!(num(1e20), "100000000000000000000");
assert_eq!(num(-1e20), "-100000000000000000000");
assert_eq!(num(1e18), "1000000000000000000");
assert_eq!(num(-0.0), "0");
}
fn card(s: &mut Session) -> NodeId {
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()["id"]).unwrap();
s.execute("node.set", &json!({"id": f, "props": {"fills": ["#FFFFFF"], "cornerRadius": 12}})).unwrap();
@@ -1216,6 +1524,33 @@ mod tests {
assert!(s.execute("inspect.code", &json!({"id": f, "language": "cobol"})).is_err());
}
#[test]
fn arkui_for_an_auto_layout_card() {
let mut s = Session::new();
let f = card(&mut s);
let doc = s.doc().unwrap();
let ark = code_for(doc, f, CodeLang::ArkUi);
for want in [
"Row({ space: ",
"Text(\"Say \\\"hi\\\" $5\\nnow\")",
".fontSize(",
".fontWeight(",
"Ellipse({ width: 24, height: 24 })",
".backgroundColor('#FFFFFF')",
".borderRadius(12)",
".width(",
".height(100)",
".clip(true)",
] {
assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
}
// Through the command, and "arkts" reads as ArkUI too.
let v = s.execute("inspect.code", &json!({"id": f, "language": "arkui"})).unwrap();
assert_eq!(v["code"].as_str().unwrap(), ark);
assert_eq!(v["language"], "arkui");
assert_eq!(s.execute("inspect.code", &json!({"id": f, "language": "arkts"})).unwrap()["language"], "arkui");
}
#[test]
fn free_children_get_offsets_and_deep_trees_are_capped() {
let mut s = Session::new();
@@ -1231,9 +1566,59 @@ mod tests {
assert!(sw.contains("…"), "depth capped");
let kt = code_for(doc, root, CodeLang::Compose);
assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
let ark = code_for(doc, root, CodeLang::ArkUi);
assert!(ark.contains(".position({ x: 7, y: 9 })"), "{ark}");
assert!(ark.contains("…"), "depth capped");
assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
}
#[test]
fn arkui_aligned_text_gets_the_layers_width() {
let mut s = Session::new();
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap();
let t = id_of(&s.execute("text.create", &json!({"text": "Hi", "x": 0, "y": 0, "parent": f})).unwrap()["id"]).unwrap();
s.execute("node.set", &json!({"id": t, "props": {"textAlign": "center", "width": 120.0}})).unwrap();
let doc = s.doc().unwrap();
let ark = code_for(doc, f, CodeLang::ArkUi);
assert!(ark.contains(".textAlign(TextAlign.Center)"), "{ark}");
let width = doc.node(t).map(|n| n.width).unwrap_or_default();
assert!(ark.contains(&format!(".width({})", num(width))), "the alignment has a box: {ark}");
}
#[test]
fn arkui_places_a_child_that_ignores_its_auto_layout_parent() {
let mut s = Session::new();
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap();
let c = id_of(&s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 20, "height": 20, "parent": f})).unwrap()["id"]).unwrap();
s.execute("node.set", &json!({"id": c, "props": {"absolute": true}})).unwrap();
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
let doc = s.doc().unwrap();
let [_, _, _, _, x, y] = doc.node(c).unwrap().transform.as_coeffs();
let ark = code_for(doc, f, CodeLang::ArkUi);
assert!(ark.contains("Row(") || ark.contains("Column("), "the parent is still auto layout: {ark}");
assert!(ark.contains(&format!(".position({{ x: {}, y: {} }})", num(x), num(y))), "the absolute child keeps its place: {ark}");
}
#[test]
fn arkui_wraps_a_positioned_instance_swap_so_the_position_lands_on_a_container() {
let mut s = Session::new();
let icon = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 400, "width": 24, "height": 24, "name": "Icon"})).unwrap()["id"]).unwrap();
s.execute("shape.star", &json!({"x": 0, "y": 0, "width": 24, "height": 24, "parent": icon})).unwrap();
let heart = id_of(&s.execute("component.create", &json!({"ids": [icon]})).unwrap()["id"]).unwrap();
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 120, "height": 40, "name": "Chip"})).unwrap()["id"]).unwrap();
let nested = id_of(&s.execute("instance.create", &json!({"component": heart, "x": 8, "y": 8, "parent": f})).unwrap()["id"]).unwrap();
let chip = id_of(&s.execute("component.create", &json!({"ids": [f]})).unwrap()["id"]).unwrap();
s.execute("component.addProperty", &json!({"id": chip, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested})).unwrap();
let doc = s.doc().unwrap();
let ark = code_for(doc, chip, CodeLang::ArkUi);
assert!(ark.contains("Stack() {\n"), "the builder is wrapped: {ark}");
assert!(ark.contains("this.icon()"), "{ark}");
// The position follows the wrapper's closing brace, not the void builder call.
let lines: Vec<&str> = ark.lines().collect();
let at = lines.iter().position(|l| l.contains(".position({ x: 8, y: 8 })")).unwrap_or_else(|| panic!("no position: {ark}"));
assert_eq!(lines[at - 1].trim(), "}", "the position is on a container: {ark}");
}
#[test]
fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() {
let mut s = Session::new();
@@ -1246,7 +1631,7 @@ mod tests {
.unwrap();
}
let doc = s.doc().unwrap();
for lang in [CodeLang::SwiftUi, CodeLang::Compose] {
for lang in [CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi] {
let code = code_for(doc, root, lang);
assert!(code.contains("// deep evil() more() …\n"), "{code}");
for line in code.lines() {
+3 -3
View File
@@ -246,9 +246,9 @@ fn a_group_of_commands_syncs_as_one_step() {
let (f, r) = frame_and_rect(room.server.document());
let start = room.server.replica().clone();
let cmds = vec![
("node.set".to_string(), json!({"id": r, "props": {"name": "A"}})),
("node.set".to_string(), json!({"id": f, "props": {"name": "B"}})),
("shape.rectangle".to_string(), json!({"x": 0, "y": 0, "width": 5, "height": 5})),
crate::BatchStep::new("node.set", json!({"id": r, "props": {"name": "A"}})),
crate::BatchStep::new("node.set", json!({"id": f, "props": {"name": "B"}})),
crate::BatchStep::new("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5})),
];
room.s(0).batch("Three", &cmds).unwrap();
room.pump();
+117 -15
View File
@@ -1,16 +1,17 @@
//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack
//! Compose component whose properties are its parameters (a variant property → a union or enum, boolean →
//! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot →
//! optional content), and an instance becomes a call passing the values it sets. Variants of a set
//! share one definition (the set's default variant); in React and Leptos their differences become CSS
//! rules on `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component
//! connected to code (see [`crate::code_connect`]) shows its real snippet instead.
//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI, Jetpack
//! Compose or ArkUI component whose properties are its parameters (a variant property → a union or
//! enum, boolean → a flag showing the layers bound to it, text → a string, instance swap → a view
//! parameter, slot → optional content), and an instance becomes a call passing the values it sets.
//! Variants of a set share one definition (the set's default variant); in React and Leptos their
//! differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode` and
//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its
//! real snippet instead.
use figcraft_doc::*;
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::{CodeLang, Ctx, Markup, class_name, compose, indent, literal, swift};
use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift};
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
use crate::params::*;
use crate::{bad, spec};
@@ -67,14 +68,14 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.componentCode",
"Component code",
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
"{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}",
false,
|s, p| {
let cmd = "inspect.componentCode";
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?;
let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) {
Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l,
Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")),
Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))) => l,
Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose|arkui")),
None => CodeLang::React,
};
let doc = s.doc()?;
@@ -107,9 +108,10 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's
/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it
/// in a function component), with the imports of the connected components it calls at the top.
/// Code for any layer in React, Leptos, SwiftUI, Compose or ArkUI: an instance's call and its
/// component's definition, a component's (or set's) definition, or the layer tree (React and
/// Leptos wrap it in a function component), with the imports of the connected components it calls
/// at the top.
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
let Some(n) = doc.node(id) else { return String::new() };
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
@@ -129,6 +131,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
swift(&Ctx::new(doc, id), id, 0, &mut out, &[]);
out
}
CodeLang::ArkUi => {
let mut out = String::new();
arkui(&Ctx::new(doc, id), id, 0, &mut out, &[]);
out
}
_ => {
let mut out = String::new();
compose(&Ctx::new(doc, id), id, 0, &mut out);
@@ -225,7 +232,9 @@ pub fn type_name(name: &str) -> String {
/// Words no generated parameter may be called (keywords of TypeScript, Swift and Kotlin, and
/// names the generated code uses).
const RESERVED: &[&str] = &[
"arguments",
"as",
"await",
"body",
"break",
"case",
@@ -239,6 +248,7 @@ const RESERVED: &[&str] = &[
"do",
"else",
"enum",
"eval",
"export",
"extends",
"extension",
@@ -249,6 +259,7 @@ const RESERVED: &[&str] = &[
"function",
"guard",
"if",
"implements",
"import",
"in",
"init",
@@ -264,6 +275,7 @@ const RESERVED: &[&str] = &[
"operator",
"package",
"private",
"protected",
"protocol",
"public",
"ref",
@@ -290,7 +302,7 @@ const RESERVED: &[&str] = &[
"yield",
];
/// Types the generated SwiftUI and Compose code names: a variant enum may not shadow them.
/// Types the generated SwiftUI, Compose and ArkUI code names: a variant enum may not shadow them.
const TYPES: &[&str] = &[
"Alignment",
"AnyView",
@@ -299,6 +311,7 @@ const TYPES: &[&str] = &[
"Boolean",
"Box",
"CaseIterable",
"Circle",
"CircleShape",
"Color",
"Column",
@@ -307,21 +320,30 @@ const TYPES: &[&str] = &[
"EdgeInsets",
"Ellipse",
"EmptyView",
"FlexAlign",
"Font",
"FontStyle",
"FontWeight",
"Group",
"HStack",
"HorizontalAlign",
"Image",
"Modifier",
"Rect",
"RectangleShape",
"RoundedCornerShape",
"RoundedRectangle",
"Row",
"Shape",
"Stack",
"String",
"Text",
"TextAlign",
"TextCase",
"TextDecorationType",
"Unit",
"VStack",
"VerticalAlign",
"View",
"ZStack",
];
@@ -483,6 +505,9 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value
(Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)),
(None, CodeLang::Leptos) => "|| ()".into(),
(Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"),
// ArkUI takes a builder: `@BuilderParam` is a `() => void`.
(Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"),
(None, CodeLang::ArkUi) => "() => {}".into(),
(Some(t), _) => format!("{{ {t}() }}"),
(None, CodeLang::React) => "null".into(),
(None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(),
@@ -561,6 +586,11 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
format!("{pad}<{t}{attrs} />\n")
}
CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ")),
CodeLang::ArkUi => {
let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::<Vec<_>>().join(", ");
// ArkTS calls take an object literal, or nothing at all.
if list.is_empty() { format!("{pad}{t}()\n") } else { format!("{pad}{t}({{ {list} }})\n") }
}
_ => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n} = {e}")).collect::<Vec<_>>().join(", ")),
})
}
@@ -593,6 +623,13 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S
}
format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1))
}
CodeLang::ArkUi => {
let mut out = String::new();
for k in kids {
arkui(&ctx, k, depth + 2, &mut out, &[]);
}
format!("() => {{\n{out}{}}}", indent(depth + 1))
}
_ => {
let mut out = String::new();
for k in kids {
@@ -611,6 +648,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String {
CodeLang::SwiftUi => swift_definition(doc, c),
CodeLang::Compose => compose_definition(doc, c),
CodeLang::Leptos => leptos(doc, c),
CodeLang::ArkUi => arkui_definition(doc, c),
_ => react(doc, c),
}
}
@@ -807,3 +845,67 @@ fn compose_definition(doc: &Document, c: &Component) -> String {
out.push_str("}\n");
out
}
/// An ArkUI (`@Component`) struct: variant enums, `@Prop` parameters, a default builder for each
/// instance swap and slot, and the `build` body.
fn arkui_definition(doc: &Document, c: &Component) -> String {
let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 1 };
let mut out = String::new();
for p in &c.params {
if let ParamKind::Variant(opts) = &p.kind {
out.push_str(&format!("export enum {} {{ {} }}\n\n", enum_name(c, p, false), cases(opts, false).join(", ")));
}
}
// A default builder per instance swap or slot, named apart from every parameter.
let mut used: Vec<String> = c.params.iter().map(|p| p.ident.clone()).collect();
let mut builders: Vec<(String, String)> = Vec::new();
for p in &c.params {
if !matches!(p.kind, ParamKind::InstanceSwap | ParamKind::Slot) {
continue;
}
let base = format!("default{}Builder", capitalized(&p.ident));
let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}{k}") }).find(|n| !used.contains(n)).unwrap_or(base);
used.push(name.clone());
builders.push((p.ident.clone(), name));
}
out.push_str("@Component\nexport struct ");
out.push_str(&c.type_name);
out.push_str(" {\n");
for p in &c.params {
match &p.kind {
ParamKind::Variant(_) => {
out.push_str(&format!(" @Prop {}: {} = {}\n", p.ident, enum_name(c, p, false), value_expr(doc, c, p, &p.default, CodeLang::ArkUi)))
}
ParamKind::Boolean => out.push_str(&format!(" @Prop {}: boolean = {}\n", p.ident, p.default.as_bool().unwrap_or(false))),
ParamKind::Text => out.push_str(&format!(" @Prop {}: string = {}\n", p.ident, value_expr(doc, c, p, &p.default, CodeLang::ArkUi))),
ParamKind::InstanceSwap | ParamKind::Slot => {
let name = builders.iter().find(|(ident, _)| *ident == p.ident).map_or("", |(_, name)| name.as_str());
out.push_str(&format!(" @BuilderParam {}: () => void = this.{name}\n", p.ident));
}
}
}
for (ident, name) in &builders {
let Some(p) = c.params.iter().find(|p| p.ident == *ident) else { continue };
out.push_str(&format!("\n @Builder\n {name}() {{\n"));
match &p.kind {
ParamKind::InstanceSwap => {
if let Some(t) = id_of(&p.default).and_then(|i| component_type(doc, i)) {
out.push_str(&format!(" {t}()\n"));
}
}
ParamKind::Slot => {
let layer = slots_of(doc, c.comp).into_iter().find(|(_, slot)| slot == &p.prop).map(|(layer, _)| layer);
let kids = layer.and_then(|l| doc.node(l)).map(|n| ctx.kids(n)).unwrap_or_default();
for k in kids {
arkui(&ctx, k, 2, &mut out, &[]);
}
}
_ => {}
}
out.push_str(" }\n");
}
out.push_str("\n build() {\n");
arkui(&ctx, c.comp, 2, &mut out, &[]);
out.push_str(" }\n}\n");
out
}
@@ -43,6 +43,18 @@ fn button_set(s: &mut Session) -> (NodeId, NodeId, NodeId) {
(set, variants[0], variants[1])
}
#[test]
fn strict_mode_reserved_words_are_not_parameter_names() {
let mut s = Session::new();
let (c, title, _) = card(&mut s);
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Protected", "type": "text", "layer": title}));
let react = code(&mut s, c, "react");
assert!(react.contains("protectedValue"), "React renames the property:\n{react}");
for bad in [" protected?", " protected =", "{protected}"] {
assert!(!react.contains(bad), "reserved word used as a binding ({bad:?}):\n{react}");
}
}
#[test]
fn components_take_their_properties_as_parameters() {
let mut s = Session::new();
@@ -71,6 +83,17 @@ fn components_take_their_properties_as_parameters() {
for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] {
assert!(kt.contains(want), "Compose has {want:?}:\n{kt}");
}
let ark = code(&mut s, c, "arkui");
for want in [
"@Component\nexport struct Card {",
" @Prop showDot: boolean = true",
" @Prop title: string = \"Hello\"",
" build() {",
" if (this.showDot) {",
"Text(this.title)",
] {
assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
}
let leptos = code(&mut s, c, "leptos");
for want in [
"use leptos::prelude::*;\n\n#[component]\npub fn Card(\n",
@@ -117,12 +140,14 @@ fn instances_are_calls_passing_the_values_they_set() {
assert!(react.contains("export function Card({"));
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title=\"Buy\" />\n");
// Text that needs escaping is an expression in JSX and escaped per language.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}}));
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Card showDot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Card show_dot={false} title={\"Say \\\"hi\\\" $5\\nnow\"} />\n");
}
@@ -173,6 +198,9 @@ fn variant_sets_share_one_definition() {
let kt = code(&mut s, set, "compose");
assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}");
assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}");
let ark = code(&mut s, set, "arkui");
assert!(ark.contains("export enum ButtonState { Default, Hover }"), "{ark}");
assert!(ark.contains(" @Prop state: ButtonState = ButtonState.Default"), "{ark}");
let leptos = code(&mut s, set, "leptos");
for want in [
"#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}",
@@ -189,6 +217,7 @@ fn variant_sets_share_one_definition() {
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Button state=\"Hover\" />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Button(state: .hover)\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Button(state = ButtonState.Hover)\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Button({ state: ButtonState.Hover })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Button state=ButtonState::Hover />\n");
}
@@ -212,6 +241,14 @@ fn instance_swap_properties_take_views() {
assert!(swift.contains(" var icon: AnyView = AnyView(HeartIcon())"), "{swift}");
let kt = code(&mut s, chip, "compose");
assert!(kt.contains(" icon: @Composable () -> Unit = { HeartIcon() },") && kt.contains("icon()"), "{kt}");
let ark = code(&mut s, chip, "arkui");
for want in [
" @BuilderParam icon: () => void = this.defaultIconBuilder",
" @Builder\n defaultIconBuilder() {\n HeartIcon()\n }",
" this.icon()",
] {
assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
}
let leptos = code(&mut s, chip, "leptos");
assert!(leptos.contains(" #[prop(into, default = (|| view! { <HeartIcon /> }).into())] icon: ViewFn,"), "{leptos}");
assert!(leptos.contains("{icon.run()}"), "{leptos}");
@@ -220,6 +257,7 @@ fn instance_swap_properties_take_views() {
assert_eq!(component_code(&mut s, i, "react")["usage"], "<Chip icon={<StarIcon />} />\n");
assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Chip(icon: AnyView(StarIcon()))\n");
assert_eq!(component_code(&mut s, i, "compose")["usage"], "Chip(icon = { StarIcon() })\n");
assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Chip({ icon: () => { StarIcon() } })\n");
assert_eq!(component_code(&mut s, i, "leptos")["usage"], "<Chip icon={|| view! { <StarIcon /> }} />\n");
}
@@ -235,6 +273,14 @@ fn slots_take_optional_content() {
assert!(react.contains(" content?: React.ReactNode;") && react.contains("{content ?? ("), "{react}");
assert!(code(&mut s, panel, "swiftui").contains("var content: AnyView? = nil"));
assert!(code(&mut s, panel, "compose").contains("content: (@Composable () -> Unit)? = null,"));
let ark = code(&mut s, panel, "arkui");
for want in [
" @BuilderParam content: () => void = this.defaultContentBuilder",
" @Builder\n defaultContentBuilder() {\n",
" this.content()",
] {
assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}");
}
let leptos = code(&mut s, panel, "leptos");
assert!(leptos.contains(" #[prop(optional, into)] content: Option<ViewFn>,"), "{leptos}");
assert!(leptos.contains("{match &content { Some(c) => c.run(), None => view! {\n") && leptos.contains("}.into_any() }}\n"), "{leptos}");
@@ -248,6 +294,8 @@ fn slots_take_optional_content() {
assert!(usage.starts_with("<Panel content={(\n") && usage.contains("<Badge />"), "{usage}");
let usage = component_code(&mut s, i, "leptos")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("<Panel content={|| view! {\n") && usage.contains("<Badge />") && usage.ends_with("}} />\n"), "{usage}");
let usage = component_code(&mut s, i, "arkui")["usage"].as_str().unwrap_or_default().to_string();
assert!(usage.starts_with("Panel({ content: () => {\n") && usage.contains("Badge()") && usage.ends_with("} })\n"), "{usage}");
}
#[test]
+2 -1
View File
@@ -274,13 +274,14 @@ fn vector_edit_mode_shows_each_tool_and_modifier() {
let mut s = Session::new();
let path = id(&ex(&mut s, "vector.create", json!({"points": [[100, 100], [300, 100], [300, 300]], "closed": false})));
ex(&mut s, "vector.edit.begin", json!({"id": path}));
let (point, segment, empty) = ((300.0, 100.0), (200.0, 100.0), (200.0, 250.0));
let (point, segment, midpoint, empty) = ((300.0, 100.0), (150.0, 100.0), (200.0, 100.0), (200.0, 250.0));
use Cursor::*;
// (vector tool, modifiers, where, cursor, why)
type Row = (&'static str, Mods, (f64, f64), Cursor, &'static str);
let rows: Vec<Row> = vec![
("move", NONE, point, Default, "the arrow over a point"),
("move", NONE, segment, Default, "the arrow over a segment"),
("move", NONE, midpoint, Pen(PenCursor::Add), "the arrow over a segment's midpoint: adds a point there"),
("move", NONE, empty, Default, "the arrow elsewhere"),
("move", ALT, segment, Pen(PenCursor::Add), "Alt over a segment adds a point there"),
("move", ALT, point, Default, "Alt over a point adds nothing"),
+12
View File
@@ -203,6 +203,18 @@ fn text_on_a_path_left_empty_puts_the_path_back_without_an_undo_step() {
}
}
#[test]
fn scaling_text_scales_its_path() {
let mut s = Session::new();
let t = id(s.execute("text.create", &json!({"text": "Around", "x": 0, "y": 0})).unwrap());
let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100})).unwrap());
s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap();
let w0 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width();
s.execute("transform.scale", &json!({"ids": [t], "factor": 2.0})).unwrap();
let w1 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width();
assert!((w1 - w0 * 2.0).abs() < 1.0, "path width {w0} -> {w1}");
}
#[test]
fn svg_text_paths_import_as_text_on_a_path() {
// SVG text is parsed with Inter standing in for every family (CI has no fonts).
+324
View File
@@ -0,0 +1,324 @@
//! A board's timer and votes: the commands, what undo leaves alone, saving, live sessions, clicks
//! that vote, and junk parameters.
use figcraft_doc::{BoardTimer, NodeId, VoteScope};
use serde_json::{Value, json};
use crate::board_tests::{board, id};
use crate::cmd::facilitate::{clock, timer_after, timer_state};
use crate::collab_live_tests::Room;
use crate::tools::{PointerEvent, PointerKind};
use crate::{EngineError, Session};
const MIN: u64 = 60_000;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn sticky(s: &mut Session, x: f64, text: &str) -> NodeId {
id(&ex(s, "board.sticky", json!({"x": x, "y": 0, "text": text})), "id")
}
#[test]
fn the_timer_counts_from_its_end_time() {
let now = 1_000_000;
let none = json!({});
let t = timer_after("t", None, "start", &json!({"minutes": 2}), now).unwrap();
assert_eq!(t, Some(BoardTimer::Running { ends: now + 2 * MIN }));
assert_eq!(timer_state(t, now + MIN), "running");
assert_eq!(timer_state(t, now + 3 * MIN), "done", "run out: done until stopped");
// Paused half way: the time left stays put however long the pause.
let p = timer_after("t", t, "pause", &none, now + MIN).unwrap();
assert_eq!(p, Some(BoardTimer::Paused { left: MIN }));
assert_eq!(p.unwrap().left(now + 100 * MIN), MIN);
// A minute more while paused, then on again.
let p = timer_after("t", p, "add", &none, now + 5 * MIN).unwrap();
assert_eq!(p, Some(BoardTimer::Paused { left: 2 * MIN }));
let r = timer_after("t", p, "resume", &none, now + 10 * MIN).unwrap();
assert_eq!(r, Some(BoardTimer::Running { ends: now + 12 * MIN }));
// Adding to a timer that ran out starts from now.
let r = timer_after("t", r, "add", &json!({"minutes": 0.5}), now + 20 * MIN).unwrap();
assert_eq!(r, Some(BoardTimer::Running { ends: now + 20 * MIN + MIN / 2 }));
assert_eq!(timer_after("t", r, "stop", &none, now).unwrap(), None);
// What can't be done says so.
assert!(timer_after("t", None, "pause", &none, now).is_err());
assert!(timer_after("t", None, "add", &none, now).is_err());
assert!(timer_after("t", r, "resume", &none, now).is_err());
assert!(timer_after("t", Some(BoardTimer::Running { ends: now }), "pause", &none, now).is_err(), "nothing left to pause");
for m in [json!(0), json!(-1), json!(1441), json!("NaN"), json!(1e308), json!("x"), json!([1])] {
assert!(timer_after("t", None, "start", &json!({"minutes": m}), now).is_err(), "{m}");
}
// At most a day, also by adding.
let long = timer_after("t", None, "start", &json!({"minutes": 1440}), now).unwrap();
assert_eq!(timer_after("t", long, "add", &none, now).unwrap().unwrap().left(now), 1440 * MIN);
assert!(timer_after("t", None, "start", &json!({"minutes": 1}), u64::MAX).is_ok(), "no overflow");
}
#[test]
fn clocks_read_minutes_and_seconds() {
assert_eq!(clock(0), "0:00");
assert_eq!(clock(1), "0:01", "a part of a second left still shows");
assert_eq!(clock(5 * MIN), "5:00");
assert_eq!(clock(65_000), "1:05");
assert_eq!(clock(3_723_000), "1:02:03");
}
#[test]
fn timer_commands_drive_the_boards_timer() {
let mut s = board();
assert_eq!(ex(&mut s, "board.timerState", json!({}))["state"], "off");
let r = ex(&mut s, "board.timer", json!({"action": "start", "minutes": 3}));
assert_eq!(r["state"], "running");
let left = r["left"].as_u64().unwrap();
assert!(left > 3 * MIN - 5000 && left <= 3 * MIN, "{r}");
assert!(r["ends"].as_u64().is_some());
assert_eq!(ex(&mut s, "board.timer", json!({"action": "pause"}))["state"], "paused");
let r = ex(&mut s, "board.timer", json!({"action": "add"}));
assert!(r["left"].as_u64().unwrap() > 4 * MIN - 5000, "{r}");
assert_eq!(ex(&mut s, "board.timer", json!({"action": "resume"}))["state"], "running");
assert_eq!(ex(&mut s, "board.timer", json!({"action": "stop"}))["state"], "off");
assert!(s.doc().unwrap().timer.is_none());
// Not an undo step.
assert_eq!(s.undo_depth().0, 0);
assert!(matches!(s.execute("board.timer", &json!({"action": "pause"})), Err(EngineError::Disabled(..))));
assert!(s.execute("board.timer", &json!({"action": "jump"})).is_err());
// Boards only.
let mut d = Session::new();
d.execute("file.new", &json!({})).unwrap();
for (cmd, p) in [
("board.timer", json!({"action": "start"})),
("board.timerState", json!({})),
("board.vote", json!({"action": "start"})),
("board.voteResults", json!({})),
] {
assert!(matches!(d.execute(cmd, &p), Err(EngineError::Disabled(..))), "{cmd}");
}
}
#[test]
fn votes_are_capped_hidden_until_the_end_and_ranked() {
let mut s = board();
let a = sticky(&mut s, 0.0, "Ship it");
let b = sticky(&mut s, 300.0, "Wait");
let c = sticky(&mut s, 600.0, "Rethink");
let shape = id(&ex(&mut s, "board.shape", json!({"x": 0, "y": 400})), "id");
let r = ex(&mut s, "board.vote", json!({"action": "start", "votes": 2, "on": "stickies"}));
assert_eq!((r["state"].as_str(), r["left"].as_u64(), r["on"].as_str()), (Some("running"), Some(2), Some("stickies")));
assert!(matches!(s.execute("board.vote", &json!({"action": "start"})), Err(EngineError::Disabled(..))), "one vote at a time");
let cast = |s: &mut Session, who: &str, on: NodeId| s.execute("board.vote", &json!({"action": "cast", "id": on, "voter": who}));
// Ada puts both her votes on one sticky; a third is refused, and so is a shape.
cast(&mut s, "Ada", a).unwrap();
let r = cast(&mut s, "Ada", a).unwrap();
assert_eq!((r["left"].as_u64(), r["mine"].clone()), (Some(0), json!([{"id": a.0, "votes": 2}])));
assert!(matches!(cast(&mut s, "Ada", b), Err(EngineError::Disabled(..))), "no votes left");
assert!(matches!(cast(&mut s, "Bo", shape), Err(EngineError::Disabled(..))), "stickies only");
assert!(matches!(cast(&mut s, "Bo", NodeId(99_999)), Err(EngineError::NoNode(_))));
cast(&mut s, "Bo", b).unwrap();
cast(&mut s, "Bo", c).unwrap();
cast(&mut s, "Cy", b).unwrap();
cast(&mut s, "Cy", c).unwrap();
// Cy takes one back and puts it elsewhere.
s.execute("board.vote", &json!({"action": "retract", "id": c, "voter": "Cy"})).unwrap();
assert!(s.execute("board.vote", &json!({"action": "retract", "id": c, "voter": "Cy"})).is_err(), "no vote there any more");
cast(&mut s, "Cy", a).unwrap();
// While it runs, you see your own votes only.
let r = ex(&mut s, "board.voteResults", json!({"voter": "Bo"}));
assert!(r.get("results").is_none(), "{r}");
assert_eq!(r["mine"], json!([{"id": b.0, "votes": 1}, {"id": c.0, "votes": 1}]));
assert_eq!(r["voters"], 3);
assert_eq!(s.undo_depth().0, 4, "votes are no undo steps (only the four objects)");
ex(&mut s, "board.vote", json!({"action": "end"}));
assert!(s.execute("board.vote", &json!({"action": "cast", "id": b, "voter": "Dee"})).is_err(), "ended");
let r = ex(&mut s, "board.voteResults", json!({}));
assert_eq!(r["state"], "ended");
assert_eq!(
r["results"],
json!([
{"id": a.0, "name": "Ship it", "votes": 3, "rank": 1},
{"id": b.0, "name": "Wait", "votes": 2, "rank": 2},
{"id": c.0, "name": "Rethink", "votes": 1, "rank": 3},
])
);
// A deleted object's votes no longer count.
ex(&mut s, "edit.delete", json!({"ids": [a]}));
assert_eq!(ex(&mut s, "board.voteResults", json!({}))["results"][0]["id"], b.0);
ex(&mut s, "board.vote", json!({"action": "clear"}));
assert_eq!(ex(&mut s, "board.voteResults", json!({}))["state"], "off");
assert!(s.doc().unwrap().votes.is_empty());
}
#[test]
fn a_vote_on_the_selection_takes_only_those() {
let mut s = board();
let a = sticky(&mut s, 0.0, "A");
let b = sticky(&mut s, 300.0, "B");
ex(&mut s, "select.set", json!({"ids": [a]}));
let r = ex(&mut s, "board.vote", json!({"action": "start", "on": "selection"}));
assert_eq!(r["on"], json!({"selection": [a.0]}));
assert_eq!(r["votesPerPerson"], 3);
assert!(s.execute("board.vote", &json!({"action": "cast", "id": b})).is_err());
ex(&mut s, "board.vote", json!({"action": "cast", "id": a}));
// A finished vote may be replaced by a new one, which starts with no votes.
ex(&mut s, "board.vote", json!({"action": "end"}));
ex(&mut s, "board.vote", json!({"action": "start", "on": "all", "votes": 1}));
assert!(s.doc().unwrap().votes.is_empty());
assert_eq!(s.doc().unwrap().vote.as_ref().unwrap().scope, VoteScope::All);
for bad in [json!({"votes": 0}), json!({"votes": 100}), json!({"votes": 1.5}), json!({"on": "pages"}), json!({"on": "selection", "ids": []})] {
let mut p = bad.clone();
p["action"] = json!("start");
ex(&mut s, "board.vote", json!({"action": "clear"}));
assert!(s.execute("board.vote", &p).is_err(), "{p}");
}
}
#[test]
fn undo_leaves_the_timer_and_the_vote_alone() {
let mut s = board();
let a = sticky(&mut s, 0.0, "A");
ex(&mut s, "board.timer", json!({"action": "start"}));
ex(&mut s, "board.vote", json!({"action": "start"}));
ex(&mut s, "board.vote", json!({"action": "cast", "id": a}));
let b = sticky(&mut s, 300.0, "B");
s.undo().unwrap();
s.undo().unwrap();
let doc = s.doc().unwrap();
assert!(doc.node(a).is_none() && doc.node(b).is_none(), "the stickies are undone");
assert!(doc.timer.is_some() && doc.vote.is_some() && doc.votes.len() == 1, "the timer and the vote stay");
ex(&mut s, "board.timer", json!({"action": "stop"}));
s.redo().unwrap();
assert!(s.doc().unwrap().timer.is_none(), "redo doesn't bring a stopped timer back");
assert!(s.doc().unwrap().node(a).is_some());
}
#[test]
fn the_timer_and_votes_save_and_old_files_open() {
let mut s = board();
let a = sticky(&mut s, 0.0, "A");
ex(&mut s, "board.timer", json!({"action": "start"}));
ex(&mut s, "board.timer", json!({"action": "pause"}));
ex(&mut s, "board.vote", json!({"action": "start", "on": "stickies"}));
ex(&mut s, "board.vote", json!({"action": "cast", "id": a}));
let doc = s.doc().unwrap().clone();
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
assert_eq!((back.timer, &back.vote, &back.votes), (doc.timer, &doc.vote, &doc.votes));
// A file from before: none of the fields.
let mut v = figcraft_format::to_json(&doc).unwrap();
for k in ["timer", "vote", "votes"] {
assert!(v.as_object_mut().unwrap().remove(k).is_some(), "{k} is saved");
}
let (old, _) = figcraft_format::from_json(&v.to_string()).unwrap();
assert!(old.timer.is_none() && old.vote.is_none() && old.votes.is_empty());
// Design files don't write them.
let plain = figcraft_format::to_json(&figcraft_doc::Document::new("D")).unwrap();
assert!(plain.get("timer").is_none() && plain.get("vote").is_none() && plain.get("votes").is_none());
}
#[test]
fn everyone_in_a_live_session_shares_the_timer_and_the_vote() {
let mut s = board();
let a = sticky(&mut s, 0.0, "A");
let b = sticky(&mut s, 300.0, "B");
ex(&mut s, "board.timer", json!({"action": "start", "minutes": 10}));
let mut room = Room::new(s.doc().unwrap().clone(), 2, 11);
assert!(room.s(1).doc().unwrap().timer.is_some(), "joiners get the running timer");
let t0 = room.s(0).doc().unwrap().timer;
assert_eq!(room.s(1).doc().unwrap().timer, t0);
ex(room.s(1), "board.timer", json!({"action": "pause"}));
ex(room.s(0), "board.vote", json!({"action": "start", "votes": 2}));
room.settle();
room.check();
assert!(matches!(room.s(0).doc().unwrap().timer, Some(BoardTimer::Paused { .. })), "a pause reaches everyone");
// Both vote at once: each ballot is its own entry, so neither is lost.
ex(room.s(0), "board.vote", json!({"action": "cast", "id": a}));
ex(room.s(1), "board.vote", json!({"action": "cast", "id": a}));
ex(room.s(1), "board.vote", json!({"action": "cast", "id": b}));
room.settle();
room.check();
assert_eq!(room.server.document().votes.len(), 2, "two voters, apart though both are called the same");
assert_eq!(ex(room.s(0), "board.voteResults", json!({}))["mine"], json!([{"id": a.0, "votes": 1}]));
ex(room.s(1), "board.vote", json!({"action": "end"}));
room.settle();
let r = ex(room.s(0), "board.voteResults", json!({}));
assert_eq!(r["results"], json!([{"id": a.0, "name": "A", "votes": 2, "rank": 1}, {"id": b.0, "name": "B", "votes": 1, "rank": 2}]));
// Undo in a session takes back your own edits only, not the vote.
room.s(0).undo().unwrap();
room.settle();
room.check();
assert!(room.server.document().vote.is_some());
}
#[test]
fn clicks_vote_while_a_vote_runs() {
let mut s = board();
let a = sticky(&mut s, 0.0, "A");
ex(&mut s, "select.none", json!({}));
let click = |s: &mut Session, x: f64, y: f64, alt: bool| {
let mut down = PointerEvent::at(PointerKind::Down, x, y, 1.0);
down.alt = alt;
s.pointer(&down).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap();
};
// No vote: a click selects.
click(&mut s, 100.0, 100.0, false);
assert_eq!(s.selection(), vec![a]);
ex(&mut s, "select.none", json!({}));
ex(&mut s, "board.vote", json!({"action": "start"}));
click(&mut s, 100.0, 100.0, false);
click(&mut s, 50.0, 150.0, false);
assert!(s.selection().is_empty(), "votes, not selection");
assert_eq!(crate::cmd::facilitate::mine(s.doc().unwrap(), "You"), vec![(a, 2)]);
// Alt takes one back; so does a click on your badge (the sticky's top-right corner).
click(&mut s, 100.0, 100.0, true);
click(&mut s, 199.0, 1.0, false);
assert!(s.doc().unwrap().votes.is_empty());
// Empty canvas is the tool's as ever.
click(&mut s, 900.0, 900.0, false);
assert!(s.doc().unwrap().votes.is_empty());
}
#[test]
fn facilitation_commands_survive_junk() {
let junk = [
json!({}),
json!(null),
json!([1]),
json!({"action": 5, "minutes": "NaN", "votes": -1, "on": [1], "id": "x", "voter": {"a": 1}}),
json!({"action": "start", "minutes": 1e308, "votes": 1e308, "on": "selection", "ids": ["x", -1]}),
json!({"action": "add", "minutes": -1e308}),
json!({"action": "cast", "id": 99_999, "voter": "\u{0}\u{7}"}),
json!({"action": "retract", "id": -1, "voter": "x".repeat(10_000)}),
json!({"action": "end"}),
json!({"action": "clear", "ids": 7}),
json!({"action": "resume", "minutes": [1]}),
];
for cmd in ["board.timer", "board.timerState", "board.vote", "board.voteResults"] {
for j in &junk {
let mut s = board();
let a = sticky(&mut s, 0.0, "A");
ex(&mut s, "board.timer", json!({"action": "start"}));
ex(&mut s, "board.vote", json!({"action": "start"}));
ex(&mut s, "board.vote", json!({"action": "cast", "id": a}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
// A voter name is kept short.
assert!(s.doc().unwrap().votes.keys().all(|k| k.chars().count() <= 64));
}
}
}
#[test]
fn ties_share_a_rank() {
let mut s = board();
let ids: Vec<NodeId> = (0..4).map(|i| sticky(&mut s, f64::from(i) * 300.0, "x")).collect();
ex(&mut s, "board.vote", json!({"action": "start", "votes": 2}));
for (who, on) in [("a", 0), ("a", 1), ("b", 1), ("b", 0), ("c", 2), ("c", 3), ("d", 2)] {
ex(&mut s, "board.vote", json!({"action": "cast", "id": ids[on], "voter": who}));
}
ex(&mut s, "board.vote", json!({"action": "end"}));
let r = ex(&mut s, "board.voteResults", json!({}));
let ranks: Vec<u64> = r["results"].as_array().unwrap().iter().map(|x| x["rank"].as_u64().unwrap()).collect();
assert_eq!(ranks, vec![1, 1, 1, 4], "{r}");
}
+5
View File
@@ -115,6 +115,11 @@ pub(crate) fn within_app_folder(path: &Path, folder: &Path) -> bool {
matches!((std::fs::canonicalize(path), std::fs::canonicalize(folder)), (Ok(p), Ok(f)) if p.starts_with(&f))
}
pub fn same_file(a: impl AsRef<Path>, b: impl AsRef<Path>) -> bool {
let (a, b) = (a.as_ref(), b.as_ref());
a == b || same_file::is_same_file(a, b).unwrap_or(false)
}
/// `p` without Windows' `\\?\` prefix, for messages.
fn shown(p: &Path) -> String {
let s = p.to_string_lossy();
+8 -4
View File
@@ -64,16 +64,20 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect()
}
/// Every string value of variable `id` in any mode, following aliases (at most 32 deep).
/// Every string value of variable `id` in any mode, following aliases (each variable once).
fn string_values(vars: &Variables, id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut todo = vec![(id.to_string(), 0)];
while let Some((id, depth)) = todo.pop() {
let mut seen = BTreeSet::new();
let mut todo = vec![id.to_string()];
while let Some(id) = todo.pop() {
if !seen.insert(id.clone()) {
continue;
}
let Some(var) = vars.get(&id) else { continue };
for v in var.values.values() {
match v {
VariableValue::String(s) => out.push(s.clone()),
VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)),
VariableValue::Alias(a) => todo.push(a.clone()),
_ => {}
}
}
+19
View File
@@ -119,6 +119,25 @@ fn blank_families_and_aliases_are_handled() {
assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]);
}
/// Aliases in several modes are followed once per variable: a chain that branches in every mode
/// would otherwise grow to billions of steps (a file can hold one).
#[test]
fn branching_alias_chains_are_walked_once_per_variable() {
let mut s = Session::new();
s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap();
for i in 0..32 {
s.execute("variable.create", &json!({"collection": "Type", "name": format!("V{i}"), "type": "string", "value": "x"})).unwrap();
}
let doc = s.doc_mut().unwrap();
let ids: Vec<String> = doc.variables.variables.iter().map(|v| v.id.clone()).collect();
for (i, var) in doc.variables.variables.iter_mut().enumerate() {
let next = ids.get(i + 1).cloned().unwrap_or_else(|| "FigCraft Missing H".into());
let leaf = |v: String| if i + 1 < ids.len() { VariableValue::Alias(v) } else { VariableValue::String(v) };
var.values = [("m1".to_string(), leaf(next.clone())), ("m2".to_string(), leaf(next))].into();
}
assert_eq!(string_values(&doc.variables, &ids[0]), vec!["FigCraft Missing H", "FigCraft Missing H"]);
}
#[test]
fn needs_an_open_document() {
let mut s = Session::empty();
+5 -1
View File
@@ -547,7 +547,11 @@ fn branches_and_the_version_history() {
// On the branch tab.
// A branch keeps its own version history: a version saved on its tab is listed there only.
ex(&mut s, "file.saveVersion", json!({"name": "On the branch"}));
assert_eq!(versions(&mut s).iter().map(|v| v["name"].clone()).collect::<Vec<_>>(), [json!("On the branch")], "a branch's own history");
assert_eq!(
versions(&mut s).iter().map(|v| v["name"].clone()).collect::<Vec<_>>(),
[json!("Branch created"), json!("On the branch")],
"a branch's own history"
);
rect(&mut s, 900.0, 900.0, None);
ex(&mut s, "doc.switch", json!({"index": 0}));
let made_on_main = rect(&mut s, -500.0, 0.0, None);
@@ -0,0 +1,81 @@
use serde_json::json;
use crate::Session;
use crate::cmd::file::FileSnapshot;
fn rect(s: &mut Session, x: f64) {
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap();
}
fn opened_with_images_left_in_file(name: &str) -> (std::path::PathBuf, String, Session) {
let dir = std::env::temp_dir().join(format!("figcraft-images-left-{name}-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("big.figcraft").to_string_lossy().to_string();
let mut s = Session::new();
rect(&mut s, 0.0);
s.execute("file.save", &json!({"path": path})).unwrap();
let mut t = Session::empty();
t.execute("file.open", &json!({"path": path})).unwrap();
let i = t.active;
let tab = t.docs.remove(i).with_images_left_in_file(&path, 3);
t.docs.insert(i, tab);
(dir, path, t)
}
#[test]
fn a_file_with_images_left_out_is_not_saved_over() {
let (dir, path, mut s) = opened_with_images_left_in_file("save");
let before = std::fs::read(&path).unwrap();
rect(&mut s, 20.0);
let err = s.execute("file.save", &json!({})).unwrap_err().to_string();
assert!(err.contains("3 of its images") && err.contains("another name"), "{err}");
assert_eq!(std::fs::read(&path).unwrap(), before);
assert!(s.ds().unwrap().is_dirty());
let copy = dir.join("copy.figcraft").to_string_lossy().to_string();
s.execute("file.saveAs", &json!({"path": copy})).unwrap();
assert!(!s.ds().unwrap().is_dirty());
rect(&mut s, 40.0);
s.execute("file.save", &json!({})).unwrap();
assert!(s.execute("file.saveAs", &json!({"path": path})).is_err());
assert_eq!(std::fs::read(&path).unwrap(), before);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_snapshot_encoded_in_the_background_does_not_save_over_it() {
let (dir, path, mut s) = opened_with_images_left_in_file("snapshot");
let before = std::fs::read(&path).unwrap();
rect(&mut s, 20.0);
let file = FileSnapshot::of(s.ds().unwrap());
let bytes = file.encode().unwrap();
assert!(file.save(&mut s, &bytes).is_err());
assert_eq!(std::fs::read(&path).unwrap(), before);
assert!(s.ds().unwrap().is_dirty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_file_without_images_left_out_saves_as_before() {
let (dir, path, mut s) = opened_with_images_left_in_file("none");
let i = s.active;
let tab = s.docs.remove(i).with_images_left_in_file(&path, 0);
s.docs.insert(i, tab);
rect(&mut s, 20.0);
s.execute("file.save", &json!({})).unwrap();
assert!(!s.ds().unwrap().is_dirty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_hard_link_to_the_file_is_not_saved_over() {
let (dir, path, mut s) = opened_with_images_left_in_file("hard-link");
let link = dir.join("link.figcraft");
let _ = std::fs::remove_file(&link);
std::fs::hard_link(&path, &link).unwrap();
let before = std::fs::read(&path).unwrap();
rect(&mut s, 20.0);
assert!(s.execute("file.saveAs", &json!({"path": link.to_string_lossy()})).is_err());
assert_eq!(std::fs::read(&path).unwrap(), before);
let _ = std::fs::remove_dir_all(&dir);
}
+93 -61
View File
@@ -13,7 +13,10 @@ pub mod al_keys;
pub mod annotations;
pub mod arc_handles;
pub mod background;
pub mod batch;
pub mod board;
pub mod board_table;
pub mod board_templates;
pub mod booleans;
pub mod branches;
pub mod catalog;
@@ -60,6 +63,7 @@ pub mod snap;
pub mod style_vars;
pub mod synthetic;
pub mod synthetic_ds;
pub mod tailwind;
pub mod templates;
pub mod textedit;
pub mod textfit;
@@ -69,6 +73,7 @@ pub mod tools;
pub mod vectoredit;
pub mod widgets;
pub use batch::{BatchScope, BatchStep};
pub use cmd::{CommandSpec, all_commands, find_command};
pub use figcraft_collab;
pub use figcraft_doc as doc;
@@ -114,53 +119,24 @@ impl From<figcraft_doc::DocError> for EngineError {
}
}
impl EngineError {
/// This error from step `step` of a batch, saying so in its message; the kind stays (errors
/// without a message stay as they are).
fn at_step(self, step: usize) -> Self {
match self {
EngineError::BadParams { cmd, msg } => EngineError::BadParams { cmd, msg: format!("step {step}: {msg}") },
EngineError::Other(msg) => EngineError::Other(format!("step {step}: {msg}")),
e => e,
}
}
}
pub type Result<T> = std::result::Result<T, EngineError>;
pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() }
}
/// Deepest params nesting `resolve_step_refs` walks before giving up.
const STEP_REF_MAX_DEPTH: usize = 64;
/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default
/// `id`) of `results[n]`, the result of an earlier batch step.
fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result<Value> {
if depth > STEP_REF_MAX_DEPTH {
return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels")));
}
match v {
Value::Object(o) if o.contains_key("$step") => {
if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") {
return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`")));
}
let n = o
.get("$step")
.and_then(Value::as_u64)
.ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?;
let field = match o.get("field") {
None => "id",
Some(Value::String(f)) => f.as_str(),
Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))),
};
let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else {
let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) };
return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})")));
};
result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`")))
}
Value::Object(o) => {
let mut out = serde_json::Map::with_capacity(o.len());
for (k, x) in o {
out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?);
}
Ok(Value::Object(out))
}
Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
_ => Ok(v.clone()),
}
}
/// One undo step.
#[derive(Clone, Debug)]
pub struct HistoryEntry {
@@ -329,6 +305,17 @@ pub struct TextEdit {
pub anchor: usize,
/// The node was created by this edit (deleted if left empty).
pub created: bool,
/// Identity of this editing session (`textedit::begin` assigns a fresh one).
///
/// The IME owner is `(document uid, node)`, which stays the same through ordinary edits
/// and caret moves; this token changes only when editing is started again, so an IME
/// context is never silently inherited by a replaced target that reuses the same node.
///
/// It is deliberately not serialized: current consumers only inspect a live session. A
/// serialized `TextEdit` restored from data would get zero here, so it must not be restored
/// into a live editor without minting (or otherwise invalidating) a new session.
#[serde(default, skip_serializing)]
pub editing_session: u64,
/// An IME composition in progress, shown inline at the caret; the document gets it on commit.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preedit: Option<Preedit>,
@@ -387,6 +374,7 @@ pub struct DocState {
/// The last duplicate while its copies are the selection: Duplicate repeats its offset (see
/// [`cmd::edit::Duplicated`]; not saved).
pub(crate) last_duplicate: Option<cmd::edit::Duplicated>,
images_left_in_file: Option<(String, usize)>,
}
/// A version shown on the canvas in place of the document: read-only until it's restored or the
@@ -407,7 +395,8 @@ pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore
/// Commands that first end a version preview: they act on the document itself. Saving does so
/// only with a native destination; export copies and pending format choices keep the preview.
const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"];
const ENDS_PREVIEW: &[&str] =
&["file.save", "file.saveAs", "file.markSaved", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"];
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -448,11 +437,21 @@ impl DocState {
rotation_origin: None,
view_from_prefs: false,
last_duplicate: None,
images_left_in_file: None,
};
st.zoom_to_fit();
st
}
pub fn with_images_left_in_file(mut self, path: &str, count: usize) -> Self {
self.images_left_in_file = (count > 0).then(|| (path.to_string(), count));
self
}
pub fn images_lost_saving_to(&self, path: &str) -> Option<usize> {
self.images_left_in_file.as_ref().filter(|(file, _)| file_access::same_file(file, path)).map(|(_, count)| *count)
}
/// The selected ruler guide (its owner and index): it counts while no layer is selected and it
/// is still there on this page.
pub fn selected_guide(&self) -> Option<(NodeId, usize)> {
@@ -769,11 +768,21 @@ pub struct Clipboard {
pub images: std::collections::BTreeMap<String, figcraft_doc::ImageData>,
/// Copied properties (Copy properties / Paste properties).
pub props: Option<Value>,
/// A copied fill or stroke row (`fill.copy`, `stroke.copy`): whether it's a fill, and the paint.
pub paint: Option<(bool, figcraft_color::Paint)>,
/// The stroke settings (`weight`, alignment, sides…) copied with a stroke's paint.
pub stroke: Option<figcraft_doc::StrokeProps>,
/// A copied effect row (`effect.copy`).
pub effect: Option<figcraft_doc::Effect>,
/// The page-space bounds of the copied selection.
pub bounds: Option<figcraft_geom::Rect>,
pub source_doc: u64,
/// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused).
pub copied_components: std::collections::BTreeSet<NodeId>,
/// Monotonic generation of the copied layers (contents, images, bounds and source). A UI
/// clipboard mirror records it, so a later programmatic copy that never touched the system
/// clipboard is not mistaken for the user's own copy just because the text still matches.
pub layers_generation: u64,
/// The document copied from (pastes into other files follow its mains) and its file path.
pub source: Option<Arc<Document>>,
pub source_path: Option<String>,
@@ -1206,8 +1215,9 @@ impl Session {
}
let Some(e) = ds.history.undo.pop() else { return Ok(false) };
let redo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page };
ds.history.redo.push(redo);
ds.doc = e.doc;
cmd::facilitate::keep(&redo.doc, &mut ds.doc);
ds.history.redo.push(redo);
ds.selection = e.selection;
if ds.doc.pages.contains(&e.page) {
ds.page = e.page;
@@ -1242,8 +1252,9 @@ impl Session {
}
let Some(e) = ds.history.redo.pop() else { return Ok(false) };
let undo = HistoryEntry { label: e.label.clone(), doc: ds.doc.clone(), selection: ds.selection.clone(), page: ds.page };
ds.history.undo.push(undo);
ds.doc = e.doc;
cmd::facilitate::keep(&undo.doc, &mut ds.doc);
ds.history.undo.push(undo);
ds.selection = e.selection;
if ds.doc.pages.contains(&e.page) {
ds.page = e.page;
@@ -1259,28 +1270,31 @@ impl Session {
}
/// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id`
/// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field),
/// so a step can target a layer that an earlier step of the same batch created. Any object
/// with a `$step` key in a step's params is such a reference, except inside a nested
/// `command.batch` step: its references are to its own steps.
pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result<Vec<Value>> {
/// in the result of the earlier step `n`, and `{"$ref": "name"}` by the `id` in the result of
/// the earlier step whose `as` is `name`; either takes a `field`, a key or slash path (`ids/0`)
/// into that result. So a step can target a layer that an earlier step of the same batch
/// created. Any object with a `$step` or `$ref` key in a step's params is such a reference,
/// except inside a nested `command.batch` step: its references are to its own steps.
pub fn batch(&mut self, label: &str, steps: &[BatchStep]) -> Result<Vec<Value>> {
let mut scope = BatchScope::new(steps)?;
self.grouped(label, |s| {
let mut results: Vec<Value> = Vec::with_capacity(cmds.len());
for (step, (id, p)) in cmds.iter().enumerate() {
let v = s.batch_step(&results, step, id, p)?;
results.push(v);
for step in steps {
s.batch_step(&mut scope, &step.command, &step.params)?;
}
Ok(results)
Ok(scope.into_results())
})
}
/// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of
/// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one
/// undo step; a caller with its own list — the CLI's script runner — uses this so its steps
/// resolve `$step` the same way, inside its own [`Session::grouped`].
pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result<Value> {
let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? };
self.execute(cmd, &p)
/// The next step of a batch: the references in `params` resolve against `scope` (the steps
/// before it), the command runs and its result joins `scope`. [`Session::batch`] runs a list
/// of these as one undo step; a caller with its own list — the CLI's script runner — uses this
/// so its steps resolve references the same way, inside its own [`Session::grouped`].
pub fn batch_step(&mut self, scope: &mut BatchScope, cmd: &str, params: &Value) -> Result<()> {
let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? };
// Say which step failed; a nested batch names its own steps.
let v = self.execute(cmd, &p).map_err(|e| if cmd == "command.batch" { e } else { e.at_step(scope.next_step()) })?;
scope.push(v);
Ok(())
}
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
@@ -1626,6 +1640,10 @@ mod batch_step_ref_tests;
#[cfg(test)]
mod binding_lists_tests;
#[cfg(test)]
mod board_connector_tests;
#[cfg(test)]
mod board_tables_tests;
#[cfg(test)]
mod board_tests;
#[cfg(test)]
mod board_tools_tests;
@@ -1634,6 +1652,8 @@ mod booleans_tests;
#[cfg(test)]
mod bound_alpha_tests;
#[cfg(test)]
mod branch_live_tests;
#[cfg(test)]
mod branch_tests;
#[cfg(test)]
mod branch_versions_tests;
@@ -1700,6 +1720,8 @@ mod export_depth_tests;
#[cfg(test)]
mod extended_chain_tests;
#[cfg(test)]
mod facilitate_tests;
#[cfg(test)]
mod fig_export_assets_tests;
#[cfg(test)]
mod fig_export_tests;
@@ -1734,6 +1756,8 @@ mod huge_stroke_tests;
#[cfg(test)]
mod id_params_behaviour_tests;
#[cfg(test)]
mod images_left_in_file_tests;
#[cfg(test)]
mod import_export_tests;
#[cfg(test)]
mod import_journal_tests;
@@ -1820,6 +1844,8 @@ pub(crate) mod plugins_tests;
#[cfg(test)]
mod progressive_blur_tests;
#[cfg(test)]
mod property_copy_tests;
#[cfg(test)]
mod proto_behaviour_tests;
#[cfg(test)]
mod proto_conditional_tests;
@@ -1872,6 +1898,8 @@ mod shapes_behaviour_tests;
#[cfg(test)]
mod shortcut_parity_tests;
#[cfg(test)]
mod sketch_import_tests;
#[cfg(test)]
mod slot_tests;
#[cfg(test)]
mod stroke_style_tests;
@@ -1884,6 +1912,8 @@ mod styles_behaviour_tests;
#[cfg(test)]
mod svg_import_tests;
#[cfg(test)]
mod tailwind_tests;
#[cfg(test)]
mod templates_tests;
#[cfg(test)]
mod tests;
@@ -1936,6 +1966,8 @@ mod version_tests;
#[cfg(test)]
mod view_behaviour_tests;
#[cfg(test)]
mod widget_input_tests;
#[cfg(test)]
mod widgets_tests;
#[cfg(test)]
+27 -1
View File
@@ -37,7 +37,7 @@
//! short-lived presence the host passes on to everyone, never stored. A spotlight makes everyone
//! follow the person who started it; moving your own view stops following, as with any follow.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::net::IpAddr;
use std::sync::Arc;
@@ -599,6 +599,9 @@ pub struct Live {
pub spotlight: Option<u32>,
/// You spotlight: everyone follows you.
pub spotlighting: bool,
/// The branches of the session's file made or last updated from main in this session: every
/// layer of their base was in it (see [`crate::branches`]).
branch_bases: BTreeSet<String>,
}
impl Live {
@@ -1251,6 +1254,29 @@ impl Session {
self.tab().and_then(|uid| self.live_read_only(uid))
}
/// Your client id in document `uid`'s live session (`None`: it isn't in one). Refused for
/// `cmd` while the session is on its way: there is no id yet.
pub(crate) fn live_client_id(&self, cmd: &str, uid: u64) -> Result<Option<u32>> {
let Some(live) = self.live.doc(uid) else { return Ok(None) };
match &live.client {
Some(c) => Ok(Some(c.id())),
None if live.status.active() => Err(EngineError::Disabled(cmd.into(), "wait until the file's live session has started".into())),
None => Ok(None),
}
}
/// Branch `id` of document `uid` got its base from the session document just now.
pub(crate) fn live_branch_base(&mut self, uid: u64, id: &str) {
if let Some(live) = self.live.doc_mut(uid).filter(|l| l.syncing()) {
live.branch_bases.insert(id.to_string());
}
}
/// Whether branch `id` of document `uid` got its base in the live session it is in now.
pub(crate) fn live_branch_in_session(&self, uid: u64, id: &str) -> bool {
self.live.doc(uid).is_some_and(|l| l.syncing() && l.branch_bases.contains(id))
}
/// Whether document `uid` is a live session's synced document: its edits sync and its undo
/// history is the user's own.
pub fn live_doc(&self, uid: u64) -> bool {
+17
View File
@@ -272,6 +272,23 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
assert_eq!((&r["status"], &r["code"], &r["share"]), (&json!("off"), &Value::Null, &Value::Null));
}
#[test]
fn sharing_online_uses_a_separate_key_param() {
// The Share popover sends the server key as its own field when the address (from Preferences,
// which never holds a key) needs one. It must reach `publish` and not leak into the summary.
let (mut s, script) = session();
ex(&mut s, "pref.publishServer", json!({"url": "wss://share.example/"}));
let r = ex(&mut s, "collab.share", json!({"key": "KeyKeyKeyKeyKeyKeyKey1"}));
assert_eq!(r["status"], "connecting");
{
let sc = script.lock().unwrap();
let [p] = sc.published.as_slice() else { panic!("published {}", sc.published.len()) };
assert_eq!(p.server, "wss://share.example/");
assert_eq!(p.key.as_deref(), Some("KeyKeyKeyKeyKeyKeyKey1"));
}
assert!(!r.to_string().contains("KeyKey"), "the server's key is nowhere in the summary");
}
#[test]
fn a_server_without_join_codes_leaves_the_links() {
let (mut s, script) = session();
+18
View File
@@ -250,3 +250,21 @@ fn measure_distances_command() {
assert!(s.execute("measure.distances", &json!({"ids": [f]})).is_err(), "top level: pass `to`");
assert!(s.execute("measure.distances", &json!({"ids": [r], "to": 99999})).is_err());
}
#[test]
fn leaving_the_canvas_clears_the_hover_measurement() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0, None);
rect(&mut s, 100.0, 0.0, 50.0, 50.0, None);
ex(&mut s, "select.set", json!({"ids": [a]}));
assert!(!hover(&mut s, 125.0, 25.0, true, false, false).is_empty());
assert!(s.tool.overlay.measure_box.is_some());
s.pointer_left();
assert!(s.tool.overlay.guides.is_empty(), "{:?}", s.tool.overlay.guides);
assert_eq!(s.tool.overlay.measure_box, None);
// Dev Mode measures without Alt; leaving clears those too.
s.tool.dev_mode = true;
assert!(!hover(&mut s, 125.0, 25.0, false, false, false).is_empty());
s.pointer_left();
assert!(s.tool.overlay.guides.is_empty());
}
+2 -2
View File
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"component.playground",
"Component playground",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|swiftui|compose (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
"{id? (an instance, a component or a component set; default the selection), properties? {name: value} (variant, boolean, text and instance-swap properties, as instance.setProperties takes them), language? css|html|react|swiftui|compose|arkui (default react), path? (write the preview there as a PNG), scale? (the PNG's, default 2)} → {component, properties: [{name, type, options?, value}], language, code, width, height, path?} the component with those property values, tried on a copy: its code and its look. Nothing in the document changes",
false,
|s, p| {
let cmd = "component.playground";
@@ -63,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
Some(_) => return Err(bad(cmd, "properties: {name: value}")),
};
let lang = match string(p, "language") {
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose"))?,
Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|swiftui|compose|arkui"))?,
None => CodeLang::React,
};
let (copy, inst) = build(s.doc()?, target, &values)?;
+68 -7
View File
@@ -176,6 +176,39 @@ fn writes_widget_data(params: &Value, depth: usize) -> bool {
struct EngineHost<'s> {
s: &'s mut Session,
manifest: &'s Manifest,
/// Files offered for saving in this call.
saves: u32,
}
/// Longest file name a plugin may suggest, in characters.
const MAX_SAVE_NAME: usize = 128;
/// Longest MIME type a plugin may name, in bytes.
const MAX_SAVE_MIME: usize = 100;
/// Largest file a plugin may offer for saving, in bytes (the sandbox's message limit).
const MAX_SAVE_BYTES: usize = 16 * 1024 * 1024;
/// Files a plugin may offer per call.
const MAX_SAVES: u32 = 1;
/// A file name a plugin suggested, cleaned up: a bare name without folders, drive letters, `..` or
/// characters no file system takes. `None` when nothing usable is left.
pub(crate) fn sanitize_save_name(name: &str) -> Option<String> {
let name = name.trim();
let bad = name.is_empty()
|| name.chars().count() > MAX_SAVE_NAME
|| name == "."
|| name.contains("..")
|| name.chars().any(|c| c.is_control() || crate::cmd::file::FORBIDDEN_FILE_NAME_CHARS.contains(c));
if bad { None } else { Some(name.to_string()) }
}
/// `mime` when it looks like `type/subtype` in ASCII, else the generic binary type.
fn sanitize_mime(mime: &str) -> String {
let mime = mime.trim();
let token = |t: &str| !t.is_empty() && t.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '+' | '-' | '_'));
match mime.split_once('/') {
Some((a, b)) if mime.len() <= MAX_SAVE_MIME && token(a) && token(b) => mime.to_ascii_lowercase(),
_ => "application/octet-stream".into(),
}
}
impl figcraft_plugin::Host for EngineHost<'_> {
@@ -249,6 +282,27 @@ impl figcraft_plugin::Host for EngineHost<'_> {
}
Ok(())
}
fn save_file(&mut self, name: &str, mime: &str, data: &[u8]) -> std::result::Result<(), String> {
if !self.manifest.permissions.save_file {
return Err(format!("{} didn't ask permission to save files (`permissions.saveFile`)", self.manifest.name));
}
let name = sanitize_save_name(name).ok_or_else(|| {
format!("not a usable file name: use a plain name of 1–{MAX_SAVE_NAME} characters without folders or `/ \\ : .. < > \" | ? *`")
})?;
if data.len() > MAX_SAVE_BYTES {
return Err(format!("a file to save is limited to {} MB", MAX_SAVE_BYTES / (1024 * 1024)));
}
if self.saves >= MAX_SAVES {
return Err("a plugin can offer one file per run".into());
}
self.saves += 1;
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(data);
// The app asks the user where it goes; programmatic callers drop the request.
self.s.request_ui("dialog.pluginSave", json!({"plugin": self.manifest.id, "name": name, "mime": sanitize_mime(mime), "data": data}));
Ok(())
}
}
/// The installed plugin `id`.
@@ -459,14 +513,21 @@ pub(crate) fn call_plugin(
input: &str,
) -> std::result::Result<RunOutput, (EngineError, bool)> {
let mut usable = false;
s.grouped(label, |s| {
let mut host = EngineHost { s, manifest: m };
instance.call(input, &mut host).map_err(|e| {
usable = matches!(e, PluginError::Failed(_));
plugin_error(&m.name, e)
let ui_before = s.ui_requests.len();
let r = s
.grouped(label, |s| {
let mut host = EngineHost { s, manifest: m, saves: 0 };
instance.call(input, &mut host).map_err(|e| {
usable = matches!(e, PluginError::Failed(_));
plugin_error(&m.name, e)
})
})
})
.map_err(|e| (e, usable))
.map_err(|e| (e, usable));
// A failed run opens no dialog (a file it offered to save is dropped with it).
if r.is_err() {
s.ui_requests.truncate(ui_before);
}
r
}
/// What `plugin.run` and `plugin.event` return.
+126
View File
@@ -407,6 +407,132 @@ fn a_full_undo_history_keeps_its_steps_and_tampered_manifests_are_refused() {
assert!(matches!(s.execute("plugin.run", &json!({"id": "org.test.many"})), Err(EngineError::BadParams { .. })));
}
/// A module that offers `files` (name, mime, data) with `save_file` in order, outputs the last
/// refusal and returns `exit`.
fn saver(files: &[(&str, &str, &[u8])], exit: i32) -> Vec<u8> {
let hex = |b: &[u8]| b.iter().map(|b| format!("\\{b:02x}")).collect::<String>();
let mut data = String::new();
let mut body = String::new();
let mut at = 0usize;
for (name, mime, bytes) in files {
let (n, m) = (at, at + name.len());
let d = m + mime.len();
data += &format!(
"(data (i32.const {n}) \"{}\") (data (i32.const {m}) \"{}\") (data (i32.const {d}) \"{}\")\n",
hex(name.as_bytes()),
hex(mime.as_bytes()),
hex(bytes)
);
body += &format!(
"(local.set $st (call $save_file (i32.const {n}) (i32.const {}) (i32.const {m}) (i32.const {}) (i32.const {d}) (i32.const {})))
(if (local.get $st) (then (call $set_output (i32.const 30000) (call $reply_read (i32.const 30000) (i32.const 500)))))\n",
name.len(),
mime.len(),
bytes.len()
);
at = d + bytes.len();
}
let wat = format!(
r#"(module
(import "figcraft" "save_file" (func $save_file (param i32 i32 i32 i32 i32 i32) (result i32)))
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
(memory (export "memory") 1)
{data}
(func (export "figcraft_run") (param $n i32) (result i32) (local $st i32)
{body}
(i32.const {exit})))"#
);
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
}
fn save_manifest(id: &str, save_file: bool) -> Value {
json!({"id": id, "name": "Saver", "version": "1.0.0", "main": "plugin.wasm", "api": 4, "permissions": {"access": "read", "saveFile": save_file}})
}
fn saves(s: &Session) -> Vec<&crate::UiRequest> {
s.ui_requests.iter().filter(|r| r.kind == "dialog.pluginSave").collect()
}
#[test]
fn a_plugin_without_the_permission_cannot_save() {
let mut s = Session::new();
install(&mut s, save_manifest("org.test.nosave", false), &saver(&[("a.txt", "text/plain", b"hi")], 0));
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.nosave"}));
assert!(out["output"].as_str().unwrap_or_default().contains("permission"), "{out}");
assert!(saves(&s).is_empty(), "no dialog without the permission");
}
#[test]
fn a_plugin_with_the_permission_requests_one_save_dialog_with_its_bytes() {
let mut s = Session::new();
install(&mut s, save_manifest("org.test.save", true), &saver(&[("out.jsx", "TEXT/Javascript", b"\x00hi\xff")], 0));
ex(&mut s, "plugin.run", json!({"id": "org.test.save"}));
let reqs = saves(&s);
let [r] = reqs.as_slice() else { panic!("one request, got {}", reqs.len()) };
assert_eq!(r.data["plugin"], "org.test.save");
assert_eq!(r.data["name"], "out.jsx");
assert_eq!(r.data["mime"], "text/javascript");
assert_eq!(r.data["data"], b64(b"\x00hi\xff"));
// Nothing in the request is a path.
assert!(r.data.get("path").is_none());
}
#[test]
fn a_second_file_in_one_run_and_bad_names_are_refused() {
let mut s = Session::new();
install(&mut s, save_manifest("org.test.two", true), &saver(&[("a.txt", "text/plain", b"1"), ("b.txt", "text/plain", b"2")], 0));
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.two"}));
assert_eq!(saves(&s).len(), 1, "only the first file");
assert!(out["output"].as_str().unwrap_or_default().contains("one file"), "{out}");
s.ui_requests.clear();
install(&mut s, save_manifest("org.test.bad", true), &saver(&[("../etc/passwd", "text/plain", b"1")], 0));
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.bad"}));
assert!(saves(&s).is_empty());
assert!(out["output"].as_str().unwrap_or_default().contains("file name"), "{out}");
}
#[test]
fn an_odd_mime_type_becomes_generic_binary() {
let mut s = Session::new();
install(&mut s, save_manifest("org.test.mime", true), &saver(&[("a.bin", "not a type", b"1")], 0));
ex(&mut s, "plugin.run", json!({"id": "org.test.mime"}));
assert_eq!(saves(&s)[0].data["mime"], "application/octet-stream");
}
#[test]
fn a_run_that_fails_afterwards_leaves_no_dialog_behind() {
let mut s = Session::new();
install(&mut s, save_manifest("org.test.late", true), &saver(&[("a.txt", "text/plain", b"1")], 1));
run_err(&mut s, "org.test.late");
assert!(saves(&s).is_empty(), "a failed run opens no dialog");
}
#[test]
fn file_commands_stay_closed_to_plugins() {
let mut s = Session::new();
let calls = [("file.saveAs", json!({"path": "/tmp/x.figcraft"}))];
install(&mut s, manifest("org.test.files", "read", &[]), &script(&calls, OnRefusal::Report, ""));
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.files"}));
assert!(out["output"].as_str().unwrap_or_default().contains("isn't available to plugins"), "{out}");
}
#[test]
fn save_names_are_plain_file_names() {
use crate::plugins::sanitize_save_name as clean;
let long = "a".repeat(129);
let max = "a".repeat(128);
// 128 two-byte characters are within the limit even though they take 256 bytes.
let wide = "é".repeat(128);
for bad in ["", " ", ".", "..", "../x", "a/b", "a\\b", "C:x", "a..b", "a\nb", "a\u{0}b", "a\u{7f}b", "a*b", long.as_str()] {
assert_eq!(clean(bad), None, "{bad:?}");
}
assert_eq!(clean(" out.jsx ").as_deref(), Some("out.jsx"));
assert_eq!(clean(&max).as_deref(), Some(max.as_str()));
assert_eq!(clean(&wide).as_deref(), Some(wide.as_str()));
assert_eq!(clean("Entwurf – Büro.jsx").as_deref(), Some("Entwurf – Büro.jsx"));
}
#[test]
fn reinstalling_under_an_existing_id_drops_the_old_settings() {
let mut s = Session::new();
+120
View File
@@ -0,0 +1,120 @@
//! Copying one fill, stroke or effect row (`fill.copy`, `stroke.copy`, `effect.copy`) and pasting
//! it onto other layers (`fill.paste`, `stroke.paste`, `effect.paste`): the Design panel's row
//! menus.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Effect, EffectKind, NodeId};
use serde_json::{Value, json};
use crate::Session;
fn id(v: Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn rect(s: &mut Session) -> NodeId {
id(ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})))
}
fn node(s: &Session, id: NodeId) -> figcraft_doc::Node {
s.doc().unwrap().node(id).unwrap().clone()
}
#[test]
fn a_copied_fill_replaces_a_fill_row_or_adds_one() {
let mut s = Session::new();
let a = rect(&mut s);
let b = rect(&mut s);
let red = Paint::solid(Color::from_hex("#FF0000").unwrap());
ex(&mut s, "fill.add", json!({"ids": [a], "paint": red}));
let copied = ex(&mut s, "fill.copy", json!({"id": a, "index": 1}));
assert_eq!(copied["type"], "solid", "the copy reports the paint it took");
// Onto b's fill row: it replaces that paint.
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
assert_eq!(node(&s, b).fills, vec![red.clone()]);
// With no row: it adds one on top.
ex(&mut s, "fill.paste", json!({"ids": [b]}));
assert_eq!(node(&s, b).fills, [red.clone(), red]);
// Each paste is one undo step.
ex(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, b).fills.len(), 1);
}
#[test]
fn a_copied_stroke_pastes_as_a_stroke_only() {
let mut s = Session::new();
let a = rect(&mut s);
let b = rect(&mut s);
let blue = Paint::solid(Color::from_hex("#0000FF").unwrap());
ex(&mut s, "stroke.add", json!({"ids": [a], "paint": blue}));
ex(&mut s, "node.set", json!({"ids": [a], "props": {"strokeWeight": 3.0, "strokeAlign": "outside"}}));
ex(&mut s, "stroke.copy", json!({"id": a, "index": 0}));
ex(&mut s, "stroke.add", json!({"ids": [b]}));
ex(&mut s, "stroke.paste", json!({"ids": [b], "index": 0}));
assert_eq!(node(&s, b).strokes, [blue]);
assert_eq!(node(&s, b).stroke, node(&s, a).stroke, "the weight and alignment come with the paint");
assert_eq!((node(&s, b).stroke.weight, node(&s, b).stroke.align), (3.0, figcraft_doc::StrokeAlign::Outside));
// A copied fill doesn't paste as a stroke, and the target keeps its stroke.
ex(&mut s, "fill.copy", json!({"id": a}));
assert!(s.execute("stroke.paste", &json!({"ids": [b]})).is_err());
assert_eq!(node(&s, b).strokes.len(), 1);
// A layer with no stroke has nothing to copy.
assert!(s.execute("stroke.copy", &json!({"id": b, "index": 1})).is_err());
}
#[test]
fn a_copied_effect_replaces_an_effect_row_or_adds_one() {
let mut s = Session::new();
let a = rect(&mut s);
let b = rect(&mut s);
ex(&mut s, "effect.add", json!({"ids": [a], "effect": {"type": "dropShadow", "radius": 7, "offset": [2, 3]}}));
ex(&mut s, "effect.copy", json!({"id": a, "index": 0}));
let e = node(&s, a).effects[0].clone();
// Onto b's effect row: it replaces that effect.
ex(&mut s, "effect.add", json!({"ids": [b], "type": "layerBlur"}));
ex(&mut s, "effect.paste", json!({"ids": [b], "index": 0}));
assert_eq!(node(&s, b).effects, vec![e.clone()]);
// With no row: it adds one on top.
ex(&mut s, "effect.paste", json!({"ids": [b]}));
assert_eq!(node(&s, b).effects, [e.clone(), e]);
ex(&mut s, "edit.undo", json!({}));
assert_eq!(node(&s, b).effects.len(), 1);
let Effect { kind: EffectKind::DropShadow(shadow), visible } = node(&s, b).effects[0].clone() else {
panic!("the pasted effect is a drop shadow")
};
assert_eq!((shadow.radius, shadow.offset, visible), (7.0, (2.0, 3.0), true));
}
#[test]
fn pasting_design_layers_keeps_a_copied_row() {
let mut s = Session::new();
let a = rect(&mut s);
ex(&mut s, "fill.copy", json!({"id": a}));
// Layers pasted from another app replace the copied layers, not the copied fill row.
let mut other = Session::new();
other.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let exported = other.execute("file.export", &json!({"format": "fig"})).unwrap();
ex(&mut s, "edit.pasteDesignLayers", json!({"data": exported["data"]}));
let b = rect(&mut s);
ex(&mut s, "fill.paste", json!({"ids": [b], "index": 0}));
}
#[test]
fn a_copied_pattern_cannot_paste_into_a_layer_it_draws() {
let mut s = Session::new();
let outer = id(ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
let inner = id(ex(&mut s, "node.create", json!({"type": "rectangle", "x": 10, "y": 10, "width": 20, "height": 20, "parent": outer})));
// A pattern taken from the frame's child, on a layer beside the frame.
let source = rect(&mut s);
ex(&mut s, "fill.pattern", json!({"ids": [source], "source": inner}));
ex(&mut s, "fill.copy", json!({"id": source}));
// Onto the frame that holds the pattern's source it would draw itself, as `fill.pattern` refuses.
assert!(s.execute("fill.paste", &json!({"ids": [outer]})).is_err());
// Onto a layer that doesn't hold the pattern's source it still pastes.
let other = rect(&mut s);
ex(&mut s, "fill.paste", json!({"ids": [other], "index": 0}));
}
+1 -1
View File
@@ -252,7 +252,7 @@ fn fig_copy_from_branch_exports_active_document_without_saving_main() {
let persisted = figcraft_format::load_bytes_with_versions(&std::fs::read(&native).unwrap()).unwrap();
assert!(!persisted.doc.nodes().any(|n| n.name == "Branch rectangle"));
assert!(persisted.branches[0].head.nodes().any(|n| n.name == "Branch rectangle"));
assert_eq!(persisted.branches[0].versions[0].name, "Branch version");
assert_eq!(persisted.branches[0].versions.last().unwrap().name, "Branch version");
assert!(!s.ds().unwrap().is_dirty());
assert!(!s.docs[0].is_dirty());
}
+14
View File
@@ -240,3 +240,17 @@ fn the_scale_tool_only_acts_on_handles() {
assert_rect(bounds(&s, r), [10.0, 10.0, 110.0, 60.0]);
assert!(near(s.doc().unwrap().node(r).unwrap().width, 100.0));
}
#[test]
fn scale_node_scales_vector_point_corner_radius() {
let mut s = Session::new();
let v = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"id": v, "props": {"cornerRadius": 10}}));
ex(&mut s, "transform.scale", json!({"ids": [v], "factor": 2}));
let d = s.doc().unwrap();
let NodeKind::Vector { network } = &d.node(v).unwrap().kind else { panic!("not a vector") };
assert!(!network.vertices.is_empty());
for vx in &network.vertices {
assert_eq!(vx.radius, 20.0, "corner radius scaled with the layer");
}
}
@@ -175,6 +175,30 @@ fn double_click_enters_one_level() {
assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(k.r), "double-click on a shape edits its points");
}
#[test]
fn a_double_click_anywhere_on_the_selected_shape_edits_its_points() {
let mut s = Session::new();
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}}));
ex(&mut s, "stroke.add", json!({"ids": [r]}));
// Selected by its outline; the empty middle is no hit at all (the shape has no fill).
click(&mut s, 0.0, 50.0, NONE);
assert_eq!(s.selection(), vec![r]);
s.pointer(&ev(PointerKind::DoubleClick, 50.0, 50.0, NONE)).unwrap();
assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(r), "a double-click on the shape edits its points");
}
#[test]
fn a_double_click_on_an_unselected_shape_edits_it_without_a_selecting_click() {
let mut s = Session::new();
let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "select.none", json!({}));
// The press and release of the click that selects it, and the double-click, came in one frame:
// only the double-click reached the tool, with nothing selected.
s.pointer(&ev(PointerKind::DoubleClick, 50.0, 50.0, NONE)).unwrap();
assert_eq!(s.ds().unwrap().vector_edit.as_ref().map(|v| v.id), Some(r));
}
#[test]
fn marquee_selects_what_it_touches() {
let mut s = Session::new();
@@ -137,6 +137,35 @@ fn a_stroked_shape_gives_its_ring_and_keeps_its_look() {
assert!(kids.iter().position(|k| *k == o) > kids.iter().position(|k| *k == r), "the outline sits above the shape");
}
#[test]
fn a_centred_stroke_on_a_sharp_star_makes_three_regions() {
let mut s = Session::new();
// Five sharp tips: a centred stroke's outline folds over itself at each one and meets the
// star's fill, which used to give overlapping regions and a spiked outline along them.
let mut pts = Vec::new();
for i in 0..10 {
let a = -std::f64::consts::FRAC_PI_2 + i as f64 * std::f64::consts::PI / 5.0;
let r = if i % 2 == 0 { 100.0 } else { 40.0 };
pts.push([150.0 + r * a.cos(), 150.0 + r * a.sin()]);
}
let v = id(&ex(&mut s, "vector.create", json!({"points": pts, "closed": true})));
ex(&mut s, "node.set", json!({"id": v, "props": {"fill": "#ff0000", "strokes": ["#0000ff"], "strokeWeight": 8, "strokeAlign": "center"}}));
s.set_selection(vec![v]);
let out = ex(&mut s, "shapeBuilder.regions", json!({}));
assert_eq!(out["regions"].as_array().unwrap().len(), 3, "the inside and the stroke's two halves: {out}");
let a = crate::shapebuilder::arrangement(&mut s, &[v]).unwrap();
for (i, r) in a.regions.iter().enumerate() {
for (j, o) in a.regions.iter().enumerate() {
if let (false, Some(p)) = (i == j, r.point) {
assert_eq!(o.path.winding(p), 0, "regions {i} and {j} overlap at {p:?}");
}
}
}
let mut covers: Vec<(bool, bool)> = a.regions.iter().map(|r| (r.parts.iter().any(|p| p.stroke), r.parts.iter().any(|p| !p.stroke))).collect();
covers.sort();
assert_eq!(covers, vec![(false, true), (true, false), (true, true)], "fill only, stroke only, both");
}
#[test]
fn stroke_regions_survive_junk() {
for j in [
+529
View File
@@ -0,0 +1,529 @@
//! `.sketch` files through the file commands. The importer's own tests (fixtures built in code)
//! are in `figcraft_format::sketch`.
use std::io::Write;
use base64::Engine;
use serde_json::{Value, json};
use figcraft_doc::NodeKind;
use crate::Session;
/// A minimal `.sketch` zip with one page per `(id, name)`, saved by `app_version`.
fn sketch(app_version: &str, pages: &[(&str, &str)]) -> Vec<u8> {
sketch_with(app_version, pages, &[])
}
/// [`sketch`] with `layers` on the first page.
fn sketch_with(app_version: &str, pages: &[(&str, &str)], layers: &[Value]) -> Vec<u8> {
sketch_with_doc(app_version, pages, layers, json!({}))
}
/// [`sketch_with`] with the fields of `extra` added to `document.json` (shared styles…).
fn sketch_with_doc(app_version: &str, pages: &[(&str, &str)], layers: &[Value], extra: Value) -> Vec<u8> {
let refs: Vec<Value> = pages.iter().map(|(id, _)| json!({"_class": "MSJSONFileReference", "_ref": format!("pages/{id}")})).collect();
let mut document = json!({"_class": "document", "pages": refs});
for (k, v) in extra.as_object().into_iter().flatten() {
document[k] = v.clone();
}
let mut out = Vec::new();
{
let mut z = zip::ZipWriter::new(std::io::Cursor::new(&mut out));
let o = zip::write::SimpleFileOptions::default();
z.start_file("document.json", o).unwrap();
z.write_all(document.to_string().as_bytes()).unwrap();
z.start_file("meta.json", o).unwrap();
// The format version that Sketch writes (103 for 50; 110 and up from 52).
let version = if app_version.split('.').next().and_then(|v| v.parse::<u32>().ok()).is_some_and(|v| v >= 52) { 125 } else { 103 };
z.write_all(json!({"app": "com.bohemiancoding.sketch3", "appVersion": app_version, "version": version}).to_string().as_bytes()).unwrap();
for (i, (id, name)) in pages.iter().enumerate() {
let ls = if i == 0 { layers.to_vec() } else { Vec::new() };
z.start_file(format!("pages/{id}.json"), o).unwrap();
z.write_all(json!({"_class": "page", "do_objectID": id, "name": name, "layers": ls}).to_string().as_bytes()).unwrap();
}
z.finish().unwrap();
}
out
}
fn temp(name: &str, bytes: &[u8]) -> (std::path::PathBuf, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("figcraft-sketch-test-{}-{name}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
(dir, path)
}
fn page_names(s: &Session) -> Vec<String> {
let doc = s.doc().unwrap();
doc.pages.iter().map(|p| doc.node(*p).unwrap().name.clone()).collect()
}
#[test]
fn opened_sketch_files_have_their_pages_and_no_path() {
let (dir, path) = temp("design.sketch", &sketch("50.2", &[("A", "Screens"), ("B", "Symbols")]));
let mut s = Session::new();
let r = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(r.get("summary").is_some());
assert_eq!(page_names(&s), ["Screens", "Symbols"]);
// Never saved over: the document has no path, so Save asks for one.
assert!(s.docs[s.active].path.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sketch_import_appends_pages() {
let (dir, path) = temp("more.sketch", &sketch("83.2", &[("A", "Imported")]));
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let before = page_names(&s).len();
s.execute("file.import", &json!({"path": path.to_string_lossy()})).unwrap();
let names = page_names(&s);
assert_eq!(names.len(), before + 1);
assert_eq!(names.last().unwrap(), "Imported");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sketch_data_opens_with_a_summary() {
let data = base64::engine::general_purpose::STANDARD.encode(sketch("50.2", &[("A", "Page")]));
let mut s = Session::new();
let r = s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
assert!(r.get("summary").is_some());
assert_eq!(page_names(&s), ["Page"]);
}
#[test]
fn old_and_damaged_sketch_files_fail_cleanly() {
let mut s = Session::new();
let (dir, path) = temp("old.sketch", &sketch("47.1", &[("A", "P")]));
let e = s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap_err().to_string();
assert!(e.contains("older than 48"), "{e}");
let mut broken = sketch("50.2", &[("A", "P")]);
broken.truncate(broken.len() / 2);
std::fs::write(&path, &broken).unwrap();
assert!(s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
fn shape(class: &str, name: &str, frame: [f64; 4], extra: Value) -> Value {
let mut l = json!({"_class": class, "do_objectID": name, "name": name, "isVisible": true, "resizingConstraint": 63,
"frame": {"x": frame[0], "y": frame[1], "width": frame[2], "height": frame[3]}, "style": {"fills": []}});
for (k, v) in extra.as_object().unwrap() {
l[k] = v.clone();
}
l
}
fn fill(grey: f64) -> Value {
json!({"style": {"fills": [{"isEnabled": true, "fillType": 0, "color": {"red": grey, "green": grey, "blue": grey, "alpha": 1}}]}})
}
/// Sketch draws a mask's own fill under what it clips; the clipped layers show inside it.
#[test]
fn sketch_masks_show_their_content() {
let oval = shape("oval", "Ring", [0.0, 0.0, 60.0, 60.0], json!({"booleanOperation": -1, "points": []}));
let mut mask = shape("shapeGroup", "Circle", [0.0, 0.0, 60.0, 60.0], fill(0.8));
mask["hasClippingMask"] = json!(true);
mask["layers"] = json!([oval]);
let pts = ["{0, 0}", "{1, 0}", "{1, 1}", "{0, 1}"].map(|p| json!({"point": p, "curveFrom": p, "curveTo": p, "curveMode": 1}));
let body = shape("shapePath", "Square", [0.0, 0.0, 20.0, 20.0], json!({"points": pts, "isClosed": true, "booleanOperation": -1}));
let mut content = shape("shapeGroup", "Body", [20.0, 20.0, 20.0, 20.0], fill(0.2));
content["layers"] = json!([body]);
let icon = shape("group", "Icon", [0.0, 0.0, 60.0, 60.0], json!({"layers": [mask, content]}));
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("50.2", &[("A", "Page")], &[icon]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let icon = doc.nodes().find(|n| n.name == "Icon").unwrap().id;
let pix = figcraft_render::render_node(doc, icon, 1.0).unwrap();
let px = |x: u32, y: u32| pix.pixel(x, y).unwrap();
// The square in the middle, dark grey.
let c = px(30, 30);
assert!(c.alpha() > 200 && c.red() < 100, "content: {c:?}");
// The mask's own fill around it, light grey; outside the circle nothing.
let m = px(30, 8);
assert!(m.alpha() > 200 && m.red() > 150, "mask fill: {m:?}");
assert!(px(2, 2).alpha() < 50);
}
/// Family names guessed from PostScript names find the installed family whatever its spaces.
#[test]
fn sketch_font_families_match_installed_ones() {
let installed = ["PingFang SC".to_string(), "Helvetica Neue".to_string(), "IBM Plex Sans".to_string()];
let m = |f: &str| crate::cmd::file::installed_family(f, &installed);
assert_eq!(m("Ping Fang SC").as_deref(), Some("PingFang SC"));
assert_eq!(m("Helvetica Neue"), None, "already right");
assert_eq!(m("IBMPlex Sans").as_deref(), Some("IBM Plex Sans"));
assert_eq!(m("Comic Sans"), None);
}
/// Editing a master changes its instances, and a resized instance's layout isn't an override.
#[test]
fn sketch_instances_stay_live() {
let tag = shape("rectangle", "Tag", [80.0, 0.0, 20.0, 40.0], json!({"resizingConstraint": 60, "points": []}));
let master = shape("symbolMaster", "Bar", [0.0, 0.0, 100.0, 40.0], json!({"symbolID": "S", "layers": [tag]}));
let inst = shape("symbolInstance", "Wide", [0.0, 100.0, 200.0, 40.0], json!({"symbolID": "S", "overrideValues": []}));
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[master, inst]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let find = |s: &Session, inst: bool| {
let doc = s.doc().unwrap();
let root = doc
.nodes()
.find(|n| if inst { matches!(n.kind, NodeKind::Instance { .. }) } else { matches!(n.kind, NodeKind::Component { .. }) })
.unwrap()
.id;
doc.subtree(root).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Tag")).unwrap()
};
let (main_tag, inst_tag) = (find(&s, false), find(&s, true));
assert!(s.doc().unwrap().node(inst_tag).unwrap().overrides.is_empty(), "{:?}", s.doc().unwrap().node(inst_tag).unwrap().overrides);
s.execute("node.set", &json!({"id": main_tag, "props": {"fill": "#ff0000"}})).unwrap();
let doc = s.doc().unwrap();
let fill = &doc.node(inst_tag).unwrap().fills;
assert!(fill.iter().any(|p| matches!(p.kind, figcraft_doc::color::PaintKind::Solid { color } if color.r > 0.99 && color.g < 0.01)), "{fill:?}");
let b = doc.world_bounds(inst_tag).unwrap();
assert!((b.x0 - 180.0).abs() < 1e-6, "{b:?}");
}
/// Local only (`FIGCRAFT_SKETCH_SAMPLES` names a folder of `.sketch` files): each opens (Sketch 48
/// and newer) with its instances linked and finite geometry, or is refused as too old.
#[test]
fn local_sketch_samples_open() {
let Ok(dir) = std::env::var("FIGCRAFT_SKETCH_SAMPLES") else { return };
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect();
files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch")));
files.sort();
assert!(!files.is_empty(), "no .sketch files in {dir}");
for path in files {
let started = std::time::Instant::now();
let mut s = Session::new();
match s.execute("file.open", &json!({"path": path.to_string_lossy()})) {
Err(e) => {
let e = e.to_string();
assert!(e.contains("older than 48"), "{}: {e}", path.display());
eprintln!("{}: refused as too old", path.display());
}
Ok(r) => {
let doc = s.doc().unwrap();
let (mut layers, mut instances, mut unlinked) = (0, 0, 0);
for n in doc.nodes() {
layers += 1;
assert!(
n.width.is_finite() && n.height.is_finite() && n.transform.as_coeffs().iter().all(|c| c.is_finite()),
"{}: {}",
path.display(),
n.name
);
if let NodeKind::Instance { component, .. } = n.kind {
instances += 1;
if !doc.contains(component) {
unlinked += 1;
}
}
}
assert_eq!(unlinked, 0, "{}", path.display());
let notes = r.get("warnings").and_then(|w| w.as_array()).map_or(0, Vec::len);
eprintln!("{}: {layers} layers, {instances} instances, {notes} notes, {:?}", path.display(), started.elapsed());
}
}
}
}
/// Local only (`FIGCRAFT_SKETCH_SAMPLES` and `FIGCRAFT_SKETCHTOOL`, the path of Sketch's
/// `sketchtool`): every artboard of each sample (Sketch 48 and newer) drawn by FigCraft is compared
/// with Sketch's own export of it. Prints the share of pixels that differ per artboard (a channel
/// off by more than 40, on white); fails past 10% (a broken artboard, not antialiasing or fonts).
#[test]
fn local_sketch_samples_match_sketch() {
let (Ok(dir), Ok(tool)) = (std::env::var("FIGCRAFT_SKETCH_SAMPLES"), std::env::var("FIGCRAFT_SKETCHTOOL")) else { return };
// Text draws in the fonts Sketch uses only once the system fonts are known.
figcraft_text::fontdb::scan_system_fonts();
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(&dir).unwrap().filter_map(|e| e.ok().map(|e| e.path())).collect();
files.retain(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("sketch")));
files.sort();
let mut worst: Vec<(f64, String)> = Vec::new();
for path in files {
let mut s = Session::new();
if s.execute("file.open", &json!({"path": path.to_string_lossy()})).is_err() {
continue;
}
let out = std::env::temp_dir().join(format!("figcraft-sketch-ref-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&out);
let status = std::process::Command::new(&tool)
.args(["export", "artboards"])
.arg(&path)
.arg(format!("--output={}", out.display()))
.args(["--formats=png", "--scales=1", "--use-id-for-name=YES", "--include-symbols=NO"])
.output()
.unwrap();
assert!(status.status.success(), "sketchtool failed on {}", path.display());
// Artboards by page and order, from the file itself.
let archive = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap();
let mut archive = archive;
let mut read = |name: &str| -> Value {
let mut s = String::new();
std::io::Read::read_to_string(&mut archive.by_name(name).unwrap(), &mut s).unwrap();
serde_json::from_str(&s).unwrap()
};
let document = read("document.json");
let doc = s.doc().unwrap();
let refs: Vec<String> = document["pages"].as_array().unwrap().iter().map(|r| r["_ref"].as_str().unwrap().to_string()).collect();
for (page_ref, page_id) in refs.iter().zip(doc.pages.iter()) {
let page = read(&format!("{page_ref}.json"));
let boards: Vec<String> = page["layers"]
.as_array()
.unwrap()
.iter()
.filter(|l| l["_class"] == "artboard")
.map(|l| l["do_objectID"].as_str().unwrap().to_string())
.collect();
let frames: Vec<figcraft_doc::NodeId> =
doc.node(*page_id).unwrap().children.iter().copied().filter(|c| matches!(doc.node(*c).unwrap().kind, NodeKind::Frame)).collect();
assert_eq!(boards.len(), frames.len(), "{}: artboards of {page_ref}", path.display());
for (id, frame) in boards.iter().zip(frames) {
let Ok(reference) = image::open(out.join(format!("{id}.png"))) else { continue };
let reference = reference.to_rgba8();
let ours = figcraft_render::render_node(doc, frame, 1.0).unwrap();
let (w, h) = (reference.width().min(ours.width()), reference.height().min(ours.height()));
let ours_rgba = figcraft_render::to_rgba8(&ours);
// Both on white, straight alpha.
let on_white = |c: &[u8], i: usize| {
let a = f64::from(c[3]) / 255.0;
f64::from(c[i]) * a + 255.0 * (1.0 - a)
};
let mut bad = 0u64;
for y in 0..h {
for x in 0..w {
let r = reference.get_pixel(x, y).0;
let at = ((y * ours.width() + x) * 4) as usize;
let Some(o) = ours_rgba.get(at..at + 4) else { continue };
if (0..3).any(|i| (on_white(&r, i) - on_white(o, i)).abs() > 40.0) {
bad += 1;
}
}
}
let share = bad as f64 * 100.0 / (f64::from(w) * f64::from(h)).max(1.0);
let name = doc.node(frame).unwrap().name.clone();
eprintln!("{share:6.2}% {} {name}", path.file_name().unwrap().to_string_lossy());
worst.push((share, format!("{}: {name}", path.display())));
}
}
let _ = std::fs::remove_dir_all(&out);
}
worst.sort_by(|a, b| b.0.total_cmp(&a.0));
assert!(worst.first().is_none_or(|w| w.0 < 10.0), "{worst:?}");
}
/// Sketch draws Apple's fonts with the tracking their `trak` table gives at each size (the
/// platform does it on its own); the imported text carries it as letter spacing (macOS only).
#[test]
fn sketch_text_keeps_the_platform_tracking() {
if !std::path::Path::new("/System/Library/Fonts/SFNS.ttf").exists() {
return;
}
figcraft_text::fontdb::scan_system_fonts();
let font = json!({"name": "SFUIText-Regular", "size": 13});
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}});
let t = shape("text", "Label", [0.0, 0.0, 100.0, 20.0], json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0}));
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[t]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") };
let ls = td.style.letter_spacing.resolve(13.0);
assert!((ls - (-12.0 * 13.0 / 2048.0)).abs() < 1e-6, "{ls}");
// A named style's optical size follows the size there: the text carries it.
let f = figcraft_text::fontdb::find_face(&td.style.font_family, &td.style.font_style).unwrap();
let named = f.axis("opsz").is_some() && f.instance(&td.style.font_style).is_some();
assert_eq!(td.style.variations.get("opsz").copied(), named.then_some(13.0), "{:?}", td.style);
}
/// A text an override changed is measured again, and the stack around it re-flows: a hugging chip
/// grows to its new label.
#[test]
fn sketch_overridden_text_reflows_its_stack() {
let font = json!({"name": "Helvetica", "size": 12});
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}});
let label = shape(
"text",
"Label",
[20.0, 4.0, 14.0, 16.0],
json!({"attributedString": {"string": "Go", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}),
);
let dot = shape("oval", "Dot", [8.0, 9.0, 6.0, 6.0], json!({"points": []}));
let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 0, "alignItems": 1, "allGuttersGap": 6, "stackingOrder": 0});
let master = shape(
"symbolMaster",
"Chip",
[0.0, 0.0, 44.0, 24.0],
json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1,
"leftPadding": 8, "rightPadding": 10, "topPadding": 4, "bottomPadding": 4, "layers": [label, dot]}),
);
let ov = json!([{"_class": "overrideValue", "overrideName": "Label_stringValue", "value": "A much longer label here"}]);
let inst = shape(
"symbolInstance",
"Long",
[0.0, 100.0, 160.0, 24.0],
json!({"symbolID": "S", "overrideValues": ov, "horizontalSizing": 1, "verticalSizing": 1}),
);
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let inst = doc.nodes().find(|n| n.name == "Long").unwrap().id;
let label = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Label")).unwrap();
let dot = doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == "Dot")).unwrap();
let (i, l, d) = (doc.world_bounds(inst).unwrap(), doc.world_bounds(label).unwrap(), doc.world_bounds(dot).unwrap());
assert!(l.x1 - l.x0 > 60.0, "the label was measured: {l:?}");
assert!((d.x0 - i.x0 - 8.0).abs() < 0.5, "the dot after the padding: {d:?} in {i:?}");
assert!((l.x0 - d.x1 - 6.0).abs() < 0.5, "the label after the gap: {l:?}");
assert!((i.x1 - l.x1 - 10.0).abs() < 0.5, "the chip hugs: {i:?} {l:?}");
}
/// An instance sized apart from its master lays its master's stack out at its own size: a wide
/// button keeps its label and icon together in the middle.
#[test]
fn sketch_resized_stack_instances_reflow() {
let a = shape("rectangle", "A", [22.0, 12.0, 40.0, 20.0], json!({"points": []}));
let b = shape("rectangle", "B", [70.0, 12.0, 20.0, 20.0], json!({"points": []}));
let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 0, "justifyContent": 1, "alignItems": 1, "allGuttersGap": 8, "stackingOrder": 0});
let master = shape(
"symbolMaster",
"Button",
[0.0, 0.0, 112.0, 44.0],
json!({"symbolID": "S", "groupBehavior": 1, "groupLayout": layout, "horizontalSizing": 1, "verticalSizing": 1,
"leftPadding": 22, "rightPadding": 22, "topPadding": 12, "bottomPadding": 12, "layers": [b, a]}),
);
let inst = shape(
"symbolInstance",
"Wide",
[0.0, 100.0, 342.0, 44.0],
json!({"symbolID": "S", "overrideValues": [], "horizontalSizing": 0, "verticalSizing": 1}),
);
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[master, inst]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let inst = doc.nodes().find(|n| n.name == "Wide").unwrap().id;
let at = |name: &str| doc.world_bounds(doc.subtree(inst).into_iter().find(|id| doc.node(*id).is_some_and(|n| n.name == name)).unwrap()).unwrap();
let (a, b) = (at("A"), at("B"));
// 40 + 8 + 20 = 68 wide, centred in 342: from 137.
assert!((a.x0 - 137.0).abs() < 0.5, "{a:?}");
assert!((b.x0 - a.x1 - 8.0).abs() < 0.5, "{a:?} {b:?}");
assert!((doc.world_bounds(inst).unwrap().x1 - 342.0).abs() < 1e-6);
}
/// Sketch's natural line: the font's ascent and descent each rounded to whole pixels, without its
/// line gap, the baseline at the rounded ascent; the classic Mac fonts get a fifth more, all of it
/// above the text.
#[test]
fn sketch_natural_line_rule() {
use crate::cmd::file::sketch_line;
// Helvetica Neue Medium (975 + 217 of 1000, a gap of 29 left out) at 17 px: 16.575 → 17 and
// 3.689 → 4, a line of 21 with the baseline at 17. FigCraft puts the baseline at
// (21 − 20.264) / 2 + 16.575 = 16.943, so the text moves down 0.057.
let (line, down) = sketch_line(975.0, 217.0, 1000.0, "Helvetica Neue", 17.0);
assert!((line - 21.0).abs() < 1e-9 && (down - 0.057).abs() < 1e-9, "{line} {down}");
// Helvetica at 100 px: 77 + 23 and a fifth more, 120, all of it above: the baseline at 97
// where FigCraft centres it at 87.
let (line, down) = sketch_line(1577.0, 471.0, 2048.0, "helvetica", 100.0);
assert!((line - 120.0).abs() < 1e-9 && (down - 10.0).abs() < 0.05, "{line} {down}");
assert_eq!(sketch_line(0.0, 0.0, 0.0, "X", 12.0), (0.0, 0.0), "no metrics, no change");
assert_eq!(sketch_line(900.0, 200.0, 1000.0, "X", f64::NAN), (0.0, 0.0));
}
/// Text without a fixed line height gets Sketch's natural line as a share of its size, in the
/// layer and in shared text styles.
#[test]
fn sketch_text_gets_sketchs_natural_line() {
if !std::path::Path::new("/System/Library/Fonts/HelveticaNeue.ttc").exists() {
return;
}
figcraft_text::fontdb::scan_system_fonts();
let font = json!({"name": "HelveticaNeue", "size": 100});
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}});
let t = shape("text", "Label", [0.0, 0.0, 200.0, 120.0], json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0}));
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("64", &[("A", "Page")], &[t]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") };
// 95.2 → 95 and 21.3 → 21: 116 px, as a share of the size.
let figcraft_doc::LineHeight::Percent(p) = td.style.line_height else { panic!("{:?}", td.style.line_height) };
assert!((p - 116.0).abs() < 1e-9, "{p}");
}
/// A text an auto layout places isn't moved to Sketch's baseline (the next layout pass would take
/// the move back): laying the stack out again leaves it where the import put it.
#[test]
fn sketch_text_in_a_stack_stays_put() {
if !std::path::Path::new("/System/Library/Fonts/Helvetica.ttc").exists() {
return;
}
figcraft_text::fontdb::scan_system_fonts();
let font = json!({"name": "Helvetica", "size": 40});
let run = json!({"location": 0, "length": 2, "attributes": {"MSAttributedStringFontAttribute": {"attributes": font}}});
let label = shape(
"text",
"Label",
[10.0, 10.0, 50.0, 48.0],
json!({"attributedString": {"string": "Hi", "attributes": [run]}, "horizontalSizing": 1, "verticalSizing": 1}),
);
let layout = json!({"_class": "MSImmutableFlexGroupLayout", "flexDirection": 1, "justifyContent": 0, "alignItems": 0, "allGuttersGap": 0, "stackingOrder": 0});
let stack = shape(
"group",
"Stack",
[0.0, 0.0, 200.0, 68.0],
json!({"groupBehavior": 1, "groupLayout": layout, "leftPadding": 10, "topPadding": 10, "rightPadding": 10, "bottomPadding": 10, "layers": [label]}),
);
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with("2026.3.1", &[("A", "Page")], &[stack]));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let mut doc = s.doc().unwrap().clone();
let stack = doc.nodes().find(|n| n.name == "Stack").unwrap().id;
let label = doc.nodes().find(|n| n.name == "Label").unwrap().id;
let before = doc.world_bounds(label).unwrap();
figcraft_layout::layout_frame(&mut doc, stack, &crate::measure::TextMeasure);
let after = doc.world_bounds(label).unwrap();
assert!((before.y0 - after.y0).abs() < 1e-6, "{before:?} → {after:?}");
}
/// Shared text styles get what their linked layers get on import: the installed family's name and
/// the platform's tracking (a layer and its style stay alike).
#[test]
fn sketch_shared_text_styles_match_their_layers() {
if !std::path::Path::new("/System/Library/Fonts/SFNS.ttf").exists() {
return;
}
figcraft_text::fontdb::scan_system_fonts();
let attrs = json!({"MSAttributedStringFontAttribute": {"attributes": {"name": "SFUIText-Regular", "size": 13}}});
let style = json!({"_class": "style", "textStyle": {"_class": "textStyle", "encodedAttributes": attrs.clone()}});
let shared = json!({"_class": "sharedStyle", "do_objectID": "TS", "name": "Body", "value": style.clone()});
let run = json!({"location": 0, "length": 2, "attributes": attrs});
let mut linked = style;
linked["sharedObjectID"] = json!("TS");
let t = shape(
"text",
"Label",
[0.0, 0.0, 100.0, 20.0],
json!({"attributedString": {"string": "Hi", "attributes": [run]}, "textBehaviour": 0, "style": linked}),
);
let extra = json!({"layerTextStyles": {"_class": "sharedTextStyleContainer", "objects": [shared]}});
let data = base64::engine::general_purpose::STANDARD.encode(sketch_with_doc("64", &[("A", "Page")], &[t], extra));
let mut s = Session::new();
s.execute("file.open", &json!({"data": data, "name": "x.sketch"})).unwrap();
let doc = s.doc().unwrap();
let NodeKind::Text(td) = &doc.nodes().find(|n| n.name == "Label").unwrap().kind else { panic!("not text") };
let st = doc
.styles
.iter()
.find_map(|st| match &st.kind {
figcraft_doc::StyleKind::Text { style } if st.name == "Body" => Some(style.clone()),
_ => None,
})
.unwrap();
assert_eq!(st.font_family, td.style.font_family);
assert_eq!(st.letter_spacing.resolve(13.0), td.style.letter_spacing.resolve(13.0), "{st:?}");
}
+519
View File
@@ -0,0 +1,519 @@
//! React + Tailwind CSS v4 for a layer tree, ready to drop into a web project: every layer's look
//! as utility classes instead of a stylesheet, bound variables as theme utilities (`bg-accent`,
//! `rounded-card`) that `variable.export {format: "tailwind"}` declares in an `@theme` block, auto
//! layout frames and text that grow with the page (`w-full max-w-[…]`) instead of fixed pixel
//! boxes, and instances inlined with their overrides. Given a mobile frame too, the code is mobile
//! first: the mobile frame's classes, and the desktop frame's where they differ behind `lg:`.
use std::collections::{BTreeMap, HashMap};
use figcraft_doc::{Document, NodeId, NodeKind, Variable, VariableId, VariableType};
use crate::codegen::{CodeLang, Ctx, Markup, css_var_name, css_var_of, num};
use crate::component_codegen::component_type_name;
use crate::token_roles::{NumberRole, Roles};
/// The breakpoint desktop classes sit behind in responsive code.
// ponytail: one breakpoint (lg = 1024px); a tablet frame would need a second pass and `md:`.
const BREAKPOINT: &str = "lg";
/// The roles and Tailwind theme names of a file's variables, computed once per export so code
/// and the `@theme` block always agree.
#[derive(Default)]
pub struct Theme {
roles: Roles,
names: HashMap<VariableId, String>,
}
impl Theme {
pub fn new(doc: &Document) -> Self {
let vars = &doc.variables.variables;
let roles = Roles::new(doc);
let mut t = Theme { roles, names: HashMap::new() };
// Short names ("accent") where they are unique, the full path ("palette-accent") where two
// variables would share one, and a numeric suffix as a last resort.
let short: Vec<String> = vars.iter().map(|v| t.candidate(v, false)).collect();
// Linear in the number of variables: counts for clashes, a set of names taken and the next
// suffix per base, so a file with very many variables can't make this quadratic.
let mut count: HashMap<&str, usize> = HashMap::new();
for n in &short {
*count.entry(n.as_str()).or_default() += 1;
}
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut next: HashMap<String, usize> = HashMap::new();
for (v, s) in vars.iter().zip(&short) {
let base = if count.get(s.as_str()).copied().unwrap_or(0) > 1 { t.candidate(v, true) } else { s.clone() };
let mut name = base.clone();
if taken.contains(&name) {
let k = next.entry(base.clone()).or_insert(1);
loop {
*k += 1;
name = format!("{base}-{k}");
if !taken.contains(&name) {
break;
}
}
}
taken.insert(name.clone());
t.names.insert(v.id.clone(), name);
}
t
}
/// What a number variable measures; a number nothing tells about is a length.
pub fn role(&self, v: &Variable) -> NumberRole {
self.roles.role(v)
}
/// The theme variable for `v`, unique in the file (see [`Theme::new`]).
pub fn name(&self, v: &Variable) -> String {
self.names.get(&v.id).cloned().unwrap_or_else(|| self.candidate(v, false))
}
/// `--color-*` for colours, `--radius-*`, `--text-*`, `--spacing-*` for numbers by role,
/// `--font-*` for font families, others their CSS name; from the last path segment, or the
/// whole path (`full`). A name already in the namespace isn't prefixed twice.
fn candidate(&self, v: &Variable, full: bool) -> String {
let ns = match v.kind {
VariableType::Color => Some("color"),
VariableType::Number => match self.role(v) {
NumberRole::Radius => Some("radius"),
NumberRole::FontSize => Some("text"),
NumberRole::Spacing => Some("spacing"),
NumberRole::Length | NumberRole::Unitless => None,
},
VariableType::String if v.scopes.as_ref().is_some_and(|s| s.iter().any(|s| s == "FONT_FAMILY")) => Some("font"),
_ => None,
};
let Some(ns) = ns else {
return if full { css_var_name(&v.name) } else { css_var_of(v) };
};
let source = if full { v.name.as_str() } else { v.name.rsplit('/').next().unwrap_or(&v.name) };
let slug = css_var_name(source);
let slug = slug.trim_start_matches('-');
let slug = [format!("{ns}s-"), format!("{ns}-")].iter().find_map(|p| slug.strip_prefix(p.as_str())).unwrap_or(slug);
// A variable named after its namespace ("radius", "colors") is the namespace's base value.
let slug = if slug == ns || slug == format!("{ns}s") { "base" } else { slug };
format!("--{ns}-{slug}")
}
}
// ------------------------------------------------------------------ declarations → classes
/// An arbitrary value: spaces become underscores (Tailwind's escape), underscores are escaped.
fn arb(v: &str) -> String {
format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_"))
}
/// The custom property a `var(--name, fallback)` value reads, when that one call is the whole
/// value (not `var(--a, 4px) 8px`, four corners of which only the first is a variable).
fn var_name(v: &str) -> Option<&str> {
let inner = v.trim().strip_prefix("var(")?;
let mut depth = 1usize;
let close = inner.char_indices().find_map(|(i, c)| {
match c {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
(depth == 0).then_some(i)
})?;
if close + 1 != inner.len() {
return None;
}
Some(inner.split([',', ')']).next()?.trim())
}
/// `util` with `value`: a theme utility when the value reads a variable of the namespace
/// (`bg-accent`), else the variable in parentheses (`bg-(--x)`, with a type hint for `text-`
/// sizes), else an arbitrary value.
fn util(util: &str, ns: &str, value: &str, hint: &str) -> String {
match var_name(value) {
Some(name) => match name.strip_prefix(&format!("--{ns}-")) {
Some(key) if !ns.is_empty() => format!("{util}-{key}"),
_ => format!("{util}-({hint}{name})"),
},
None => format!("{util}-{}", arb(value)),
}
}
/// A length on the spacing scale (4px steps) when it fits one, else arbitrary.
fn spacing(prefix: &str, value: &str) -> String {
if var_name(value).is_some() {
return util(prefix, "spacing", value, "");
}
match value.strip_suffix("px").and_then(|n| n.parse::<f64>().ok()) {
Some(0.0) => format!("{prefix}-0"),
Some(px) if px > 0.0 && px <= 384.0 && (px / 4.0).fract() == 0.0 => format!("{prefix}-{}", num(px / 4.0)),
Some(px) if px < 0.0 && (px / 4.0).fract() == 0.0 => format!("-{prefix}-{}", num(-px / 4.0)),
_ => format!("{prefix}-{}", arb(value)),
}
}
/// The values of a `padding` shorthand (top right bottom left), as utilities.
fn padding(value: &str) -> Vec<(String, String)> {
let mut parts = Vec::new();
let mut depth = 0usize;
let mut cur = String::new();
for c in value.chars() {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
' ' if depth == 0 => {
parts.push(std::mem::take(&mut cur));
continue;
}
_ => {}
}
cur.push(c);
}
parts.push(cur);
let [t, r, b, l] = match parts.as_slice() {
[a] => [a, a, a, a],
[t, r, b, l] => [t, r, b, l],
_ => return vec![("padding".into(), format!("p-{}", arb(value)))],
};
let zero = |v: &str| v == "0px" || v == "0";
let mut out = Vec::new();
if t == r && r == b && b == l {
if !zero(t) {
out.push(("padding".into(), spacing("p", t)));
}
} else if t == b && l == r {
for (k, p, v) in [("padding-y", "py", t), ("padding-x", "px", l)] {
if !zero(v) {
out.push((k.into(), spacing(p, v)));
}
}
} else {
for (k, p, v) in [("padding-top", "pt", t), ("padding-right", "pr", r), ("padding-bottom", "pb", b), ("padding-left", "pl", l)] {
if !zero(v) {
out.push((k.into(), spacing(p, v)));
}
}
}
out
}
fn font_weight(v: &str) -> String {
let named = match v.trim() {
"100" => "thin",
"200" => "extralight",
"300" => "light",
"400" => "normal",
"500" => "medium",
"600" => "semibold",
"700" => "bold",
"800" => "extrabold",
"900" => "black",
_ => return format!("font-{}", arb(v)),
};
format!("font-{named}")
}
/// A border shorthand (`1px solid c`) on `side` ("" or "t", "r", "b", "l") as width + colour.
fn border(side: &str, value: &str) -> Vec<(String, String)> {
// `<width> solid <colour>`, where the width may be `var(--w, 2px)`: split at the style.
let (width, color) = value.split_once(" solid ").unwrap_or((value, ""));
let (width, color) = (width.trim(), color.trim());
let b = if side.is_empty() { "border".to_string() } else { format!("border-{side}") };
let key = format!("border{}", if side.is_empty() { String::new() } else { format!("-{side}") });
let w = match width {
"1px" => b.clone(),
"0px" => format!("{b}-0"),
w => match var_name(w) {
// `border-(--w)` would read as a colour; the hint makes it a width.
Some(name) => format!("{b}-(length:{name})"),
None => format!("{b}-{}", arb(w)),
},
};
let mut out = vec![(format!("{key}-width"), w)];
if !color.is_empty() {
out.push(("border-color".into(), util("border", "color", color, "")));
}
out
}
/// One CSS declaration as utilities, keyed by the property they set (so responsive code can tell
/// which mobile class a desktop class replaces).
fn decl(key: &str, value: &str) -> Vec<(String, String)> {
let one = |c: String| vec![(key.to_string(), c)];
let kw = |c: &str| vec![(key.to_string(), c.to_string())];
match (key, value) {
("display", "flex") => kw("flex"),
("display", "block") => kw("block"),
("flex-direction", "column") => kw("flex-col"),
("flex-direction", _) => kw("flex-row"),
("flex-wrap", "wrap") => kw("flex-wrap"),
// Defaults flexbox already has.
("justify-content", "flex-start") | ("gap", "0px") => Vec::new(),
("justify-content", v) => one(format!("justify-{}", v.trim_start_matches("flex-").trim_start_matches("space-"))),
("align-items", v) => one(format!("items-{}", v.trim_start_matches("flex-"))),
("align-content", v) => one(format!("content-{}", v.trim_start_matches("flex-").trim_start_matches("space-"))),
("align-self", "stretch") => kw("self-stretch"),
("flex", "1 1 0") => kw("flex-1"),
("position", v) => one(v.to_string()),
("left" | "top" | "right" | "bottom", v) => one(spacing(key, v)),
("width", "100%") => kw("w-full"),
("width", v) => one(spacing("w", v)),
("height", v) => one(spacing("h", v)),
("max-width", v) => one(format!("max-w-{}", arb(v))),
("min-height", v) => one(format!("min-h-{}", arb(v))),
("margin-inline", "auto") => kw("mx-auto"),
("gap", v) => one(spacing("gap", v)),
("padding", v) => padding(v),
("border-radius", "50%") => kw("rounded-full"),
("border-radius", v) if v.strip_suffix("px").and_then(|n| n.parse::<f64>().ok()).is_some_and(|r| r >= 999.0) => kw("rounded-full"),
("border-radius", v) if v.contains(' ') && var_name(v).is_none() => one(format!("rounded-{}", arb(v))),
("border-radius", v) => one(util("rounded", "radius", v, "")),
("opacity", v) => match v.parse::<f64>() {
Ok(o) if ((o * 100.0).round() - o * 100.0).abs() < 1e-9 => one(format!("opacity-{}", num(o * 100.0))),
_ => one(util("opacity", "", v, "")),
},
("background", v) if v.contains("gradient(") => one(format!("bg-{}", arb(v))),
// An image shorthand (`url(…) center / cover`) is a whole `background`, not a bg colour.
("background", v) if v.starts_with("url(") => one(format!("[background:{}]", arb(v).trim_matches(['[', ']']))),
("background", v) => one(util("bg", "color", v, "color:")),
("color", v) => one(util("text", "color", v, "color:")),
("border", v) => border("", v),
("border-top", v) => border("t", v),
("border-right", v) => border("r", v),
("border-bottom", v) => border("b", v),
("border-left", v) => border("l", v),
("box-shadow", v) => one(format!("shadow-{}", arb(v))),
("filter", v) => one(match v.strip_prefix("blur(").and_then(|r| r.strip_suffix(')')) {
Some(r) => format!("blur-{}", arb(r)),
None => format!("[filter:{}]", arb(v).trim_matches(['[', ']'])),
}),
("backdrop-filter", v) => one(match v.strip_prefix("blur(").and_then(|r| r.strip_suffix(')')) {
Some(r) => format!("backdrop-blur-{}", arb(r)),
None => format!("[backdrop-filter:{}]", arb(v).trim_matches(['[', ']'])),
}),
("mix-blend-mode", v) => one(format!("mix-blend-{v}")),
("transform", v) => match v.strip_prefix("rotate(").and_then(|r| r.strip_suffix(')')) {
Some(r) => one(format!("rotate-{}", arb(r))),
None => one(format!("[transform:{}]", arb(v).trim_matches(['[', ']']))),
},
("transform-origin", "top left" | "0 0") => kw("origin-top-left"),
// `font-(--x)` would read as a weight; the hint makes an un-namespaced variable a family.
("font-family", v) if var_name(v).is_some() => one(util("font", "font", v, "family-name:")),
("font-family", v) => one(format!("font-{}", arb(&format!("'{}'", v.trim_matches('"'))))),
("font-size", v) => one(util("text", "text", v, "length:")),
("font-weight", v) => one(font_weight(v)),
("font-style", "italic") => kw("italic"),
("line-height", v) => match v.strip_suffix('%').and_then(|p| p.parse::<f64>().ok()) {
Some(p) => one(format!("leading-{}", arb(&num(p / 100.0)))),
None => one(util("leading", "", v, "")),
},
("letter-spacing", v) => one(util("tracking", "", v, "")),
("text-align", v) => one(format!("text-{v}")),
("text-transform", "uppercase") => kw("uppercase"),
("text-transform", "lowercase") => kw("lowercase"),
("text-transform", "capitalize") => kw("capitalize"),
("text-decoration", "underline") => kw("underline"),
("text-decoration", "line-through") => kw("line-through"),
_ => one(format!("[{key}:{}]", arb(value).trim_matches(['[', ']']))),
}
}
/// A rule's declarations (`key: value;` lines) as (property, utility) pairs in order.
pub fn classes(decls: &str) -> Vec<(String, String)> {
decls
.lines()
.filter_map(|l| {
let (k, v) = l.trim().trim_end_matches(';').split_once(':')?;
Some(decl(k.trim(), v.trim()))
})
.flatten()
.collect()
}
/// The utility that undoes a mobile class at the breakpoint when desktop sets nothing for it.
fn reset(key: &str) -> Option<String> {
// Border widths by side: `border-t-width` → `border-t-0`.
if let Some(side) = key.strip_prefix("border").and_then(|k| k.strip_suffix("-width")) {
return Some(format!("border{side}-0"));
}
Some(String::from(match key {
"display" => "block",
"flex-direction" => "flex-row",
"flex-wrap" => "flex-nowrap",
"flex" => "flex-initial",
"align-self" => "self-auto",
"width" => "w-auto",
"height" => "h-auto",
"max-width" => "max-w-none",
"min-height" => "min-h-0",
"text-align" => "text-left",
"justify-content" => "justify-start",
"gap" => "gap-0",
"position" => "static",
"text-transform" => "normal-case",
"opacity" => "opacity-100",
"border-radius" => "rounded-none",
"box-shadow" => "shadow-none",
"font-style" => "not-italic",
// The whole background: a mobile image or gradient would survive a colour-only reset.
"background" => "bg-none bg-transparent",
"border-color" => "border-current",
"letter-spacing" => "tracking-normal",
"line-height" => "leading-normal",
"text-decoration" => "no-underline",
"align-items" => "items-stretch",
"filter" => "filter-none",
"backdrop-filter" => "backdrop-filter-none",
"transform" => "rotate-0",
"left" | "top" | "right" | "bottom" => return Some(format!("{key}-auto")),
"mix-blend-mode" => "mix-blend-normal",
_ => return None,
}))
}
/// The sides (top, right, bottom, left) a padding utility's property sets.
fn padding_sides(key: &str) -> [bool; 4] {
match key {
"padding" => [true; 4],
"padding-x" => [false, true, false, true],
"padding-y" => [true, false, true, false],
"padding-top" => [true, false, false, false],
"padding-right" => [false, true, false, false],
"padding-bottom" => [false, false, true, false],
"padding-left" => [false, false, false, true],
_ => [false; 4],
}
}
/// Utilities zeroing `sides` (as few as cover them: `py-0`, `px-0`, else per side).
fn zero_padding(sides: [bool; 4]) -> Vec<&'static str> {
let [t, r, b, l] = sides;
let mut out = Vec::new();
match (t, b) {
(true, true) => out.push("py-0"),
(true, false) => out.push("pt-0"),
(false, true) => out.push("pb-0"),
_ => {}
}
match (r, l) {
(true, true) => out.push("px-0"),
(true, false) => out.push("pr-0"),
(false, true) => out.push("pl-0"),
_ => {}
}
out
}
/// Mobile classes plus, behind the breakpoint, the desktop ones that differ (and resets for what
/// desktop leaves unset). A layer missing on mobile is hidden there.
pub(crate) fn merge(mobile: Option<&[(String, String)]>, desktop: &[(String, String)]) -> Vec<String> {
let hidden = [("display".to_string(), "hidden".to_string())];
// A mobile frame's widths are the phone's, not caps: below the breakpoint boxes fill the page.
let fluid: Vec<(String, String)>;
let mobile = match mobile {
Some(m) => {
fluid = m.iter().filter(|(k, _)| k != "max-width").cloned().collect();
fluid.as_slice()
}
None => &hidden,
};
let mut out: Vec<String> = mobile.iter().map(|(_, c)| c.clone()).collect();
let bp = |c: &str| format!("{BREAKPOINT}:{c}");
for (_, c) in desktop {
if !mobile.iter().any(|(_, m)| m == c) {
out.push(bp(c));
}
}
for (k, _) in mobile.iter().filter(|(k, _)| !k.starts_with("padding")) {
if !desktop.iter().any(|(d, _)| d == k)
&& let Some(r) = reset(k)
{
out.extend(r.split_whitespace().map(bp));
}
}
// Padding utilities overlap (p, px/py, pt…): compare side by side, and zero on desktop the
// sides mobile pads and desktop doesn't.
let cover = |list: &[(String, String)]| {
list.iter().fold([false; 4], |mut acc, (k, _)| {
for (a, s) in acc.iter_mut().zip(padding_sides(k)) {
*a |= s;
}
acc
})
};
let (m, d) = (cover(mobile), cover(desktop));
let missing = [m[0] && !d[0], m[1] && !d[1], m[2] && !d[2], m[3] && !d[3]];
out.extend(zero_padding(missing).into_iter().map(bp));
out.dedup();
out
}
/// The markup walk for `id` in Tailwind mode: (markup, rules by path).
fn walk(doc: &Document, id: NodeId) -> (String, Vec<(String, String, String)>) {
let ctx = Ctx::new(doc, id);
let mut m = Markup::new(&ctx, CodeLang::React);
m.inline = true;
m.fluid = true;
m.fluid_leaves = 0.5;
m.tokens.tailwind = true;
m.node(id, None, 2, "");
(m.markup, m.rules)
}
/// React + Tailwind for `id` (with `mobile`, mobile first with desktop classes behind `lg:`).
pub fn code(doc: &Document, id: NodeId, mobile: Option<NodeId>) -> String {
let Some(n) = doc.node(id) else { return String::new() };
let (mut markup, rules) = walk(doc, id);
let small: Option<BTreeMap<String, Vec<(String, String)>>> =
mobile.filter(|m| doc.contains(*m) && *m != id).map(|m| walk(doc, m).1.into_iter().map(|(p, _, d)| (p, classes(&d))).collect());
// ponytail: layers only on mobile are listed, not merged in; their place in the tree is ambiguous.
let only_mobile: Vec<String> = small
.iter()
.flat_map(BTreeMap::keys)
.filter(|p| !rules.iter().any(|(d, _, _)| d == *p))
.map(|p| p.trim_start_matches('/').split('/').map(|seg| seg.rsplit_once('#').map_or(seg, |(n, _)| n)).collect::<Vec<_>>().join(" › "))
.take(20)
.collect();
// Longest class names first, so "card" never rewrites inside "card-2"'s attribute.
let mut rules = rules;
rules.sort_by_key(|(_, c, _)| std::cmp::Reverse(c.len()));
let paths: Vec<String> = rules.iter().map(|(p, _, _)| p.clone()).collect();
for (path, class, decls) in &rules {
let own = classes(decls);
// Layer names may contain "/": the parent is the longest other path this one extends.
let parent =
paths.iter().filter(|p| p.len() < path.len() && path.starts_with(&format!("{p}/"))).max_by_key(|p| p.len()).map_or("", String::as_str);
let list = match &small {
// Inside a layer hidden on mobile: desktop's classes as they are.
Some(m) if !path.is_empty() && !m.contains_key(parent) => own.into_iter().map(|(_, c)| c).collect(),
Some(m) => merge(m.get(path).map(Vec::as_slice), &own),
None => own.into_iter().map(|(_, c)| c).collect(),
};
markup = markup.replace(&format!("className=\"{class}\""), &format!("className=\"{}\"", list.join(" ")));
}
// Imports of the instances written as their connected code component, once each.
let mut imports: Vec<String> = Vec::new();
for i in doc.subtree(id).into_iter().take(20_000) {
if i != id
&& doc.node(i).is_some_and(|k| matches!(k.kind, NodeKind::Instance { .. }))
&& let Some(l) = crate::code_connect::link(doc, i, CodeLang::React)
{
let line = crate::code_connect::import_line(l, CodeLang::React);
if !line.is_empty() && !imports.contains(&line) {
imports.push(line);
}
}
}
let imports = if imports.is_empty() { String::new() } else { format!("{}\n\n", imports.join("\n")) };
let name = component_type_name(&n.name);
let mut mode = if small.is_some() { format!(", mobile first; desktop from {BREAKPOINT}") } else { String::new() };
if !only_mobile.is_empty() {
mode.push_str(&format!(". Only in the mobile frame, add by hand with {BREAKPOINT}:hidden: {}", only_mobile.join(", ")));
}
let header = comment_safe(&format!("{} as React + Tailwind CSS v4{mode}. Theme: variable.export {{format: \"tailwind\"}}.", n.name));
format!("// {header}\n{imports}export function {name}() {{\n return (\n{markup} );\n}}\n")
}
/// `text` for one `//` comment line: every JS line terminator (control characters, U+2028,
/// U+2029) becomes a space, so layer names in it can't end the comment and inject code.
fn comment_safe(text: &str) -> String {
text.chars().take(2_000).map(|c| if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') { ' ' } else { c }).collect()
}
+342
View File
@@ -0,0 +1,342 @@
//! React + Tailwind code for pages: utilities instead of a stylesheet, theme utilities for bound
//! variables, fluid boxes, inlined instances, per-side borders, mobile-first responsive merges and
//! the Tailwind theme export.
use crate::Session;
use crate::components::tests::{ex, idv};
use crate::tailwind::{classes, merge};
use figcraft_doc::NodeId;
use serde_json::json;
fn code(s: &mut Session, id: NodeId, extra: serde_json::Value) -> String {
let mut p = json!({"id": id, "language": "tailwind"});
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
p.extend(e.clone());
}
ex(s, "inspect.code", p)["code"].as_str().unwrap_or_default().to_string()
}
/// A 1440 "Page" (vertical auto layout, bg bound to Brand/Accent) holding a 960 "Hero" text and a
/// horizontal "Row" of two "Card" frames that fill it. Returns (page, row).
fn page(s: &mut Session, name: &str, width: f64, row_dir: &str) -> (NodeId, NodeId) {
let p = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 900, "name": name})));
ex(s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
ex(s, "node.set", json!({"id": p, "props": {"gap": 24, "padding": 32}}));
let t = idv(&ex(s, "text.create", json!({"text": "Seu site em 7 dias", "x": 0, "y": 0, "parent": p, "props": {"name": "Hero"}})));
ex(s, "node.set", json!({"id": t, "props": {"width": 960, "textAutoResize": "height"}}));
let row = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 800, "height": 200, "parent": p, "name": "Row"})));
ex(s, "layout.add", json!({"ids": [row], "direction": row_dir}));
for _ in 0..2 {
let c = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": row, "name": "Card"})));
ex(s, "node.set", json!({"id": c, "props": {"sizingH": "fill"}}));
}
(p, row)
}
fn brand(s: &mut Session) {
ex(s, "variable.createCollection", json!({"name": "Brand"}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "Brand/Accent", "type": "color", "value": "#3DDC97"}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "card", "type": "number", "value": 12}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 0.5}));
}
#[test]
fn utilities_replace_the_stylesheet_and_variables_become_theme_utilities() {
let mut s = Session::new();
brand(&mut s);
let (p, row) = page(&mut s, "Landing", 1440.0, "horizontal");
ex(&mut s, "variable.bind", json!({"ids": [p], "field": "fill", "variable": "Brand/Accent"}));
ex(&mut s, "node.set", json!({"id": row, "props": {"cornerRadius": 12}}));
ex(&mut s, "variable.bind", json!({"ids": [row], "field": "cornerRadius", "variable": "card"}));
let out = code(&mut s, p, json!({}));
assert!(out.contains("export function Landing() {"), "{out}");
assert!(!out.contains("import") && !out.contains("<style") && !out.contains(".css"), "no stylesheet: {out}");
for want in ["flex flex-col", "gap-6", "p-8", "bg-accent", "rounded-card", "flex-1", "w-full"] {
assert!(out.contains(want), "{want} in {out}");
}
// The page is as wide as itself: just full width; the 960 text is capped, not fixed.
assert!(out.contains("max-w-[960px]") && !out.contains("w-[1440px]") && !out.contains("max-w-[1440px]"), "{out}");
}
#[test]
fn per_side_strokes_are_borders_on_those_sides() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 60, "name": "Nav"})));
ex(&mut s, "node.set", json!({"id": f, "props": {"strokes": ["#262A31"], "strokeWeight": 1, "strokeSides": [0, 0, 1, 0]}}));
let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string();
assert!(css.contains("border-bottom: 1px solid") && !css.contains("border:"), "{css}");
let tw = code(&mut s, f, json!({}));
assert!(tw.contains("border-b ") && (tw.contains("border-[#262a31]") || tw.contains("border-[#262A31]")), "{tw}");
// Without per-side weights it is one border.
ex(&mut s, "node.set", json!({"id": f, "props": {"strokeSides": null, "strokeWeight": 2}}));
let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string();
assert!(css.contains("border: 2px solid"), "{css}");
}
#[test]
fn instances_are_inlined_with_their_overrides() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Benefit"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
ex(&mut s, "text.create", json!({"text": "Original", "x": 0, "y": 0, "parent": f, "props": {"name": "Title"}}));
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 600, "height": 200, "name": "Benefits"})));
ex(&mut s, "layout.add", json!({"ids": [holder], "direction": "horizontal"}));
let inst = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": holder})));
let doc = s.doc().unwrap();
let title = doc.node(inst).unwrap().children[0];
ex(&mut s, "text.set", json!({"id": title, "characters": "Agenda sozinha"}));
let out = code(&mut s, holder, json!({}));
assert!(out.contains("Agenda sozinha") && !out.contains("<Benefit"), "inlined with override: {out}");
}
#[test]
fn a_mobile_frame_makes_the_code_mobile_first() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
// A layer only on desktop is hidden on mobile.
ex(&mut s, "text.create", json!({"text": "Só no desktop", "x": 0, "y": 0, "parent": desk, "props": {"name": "Aside"}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
assert!(out.contains("mobile first"), "{out}");
assert!(out.contains("flex flex-col") && out.contains("lg:flex-row"), "row stacks on mobile: {out}");
assert!(out.contains("hidden") && out.contains("lg:block"), "desktop-only layer: {out}");
assert!(s.execute("inspect.code", &json!({"id": desk, "language": "tailwind", "mobile": 999_999})).is_err(), "a missing mobile frame is refused");
}
#[test]
fn declarations_map_to_idiomatic_utilities() {
let got: Vec<String> = classes(
"display: flex;\njustify-content: space-between;\nalign-items: center;\npadding: 8px 16px 8px 16px;\nfont-weight: 600;\nfont-size: 18px;\nline-height: 120%;\nopacity: 0.5;\ncolor: var(--color-muted, #9aa3ae);\nbox-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.25);\nwhite-space: nowrap;\n",
)
.into_iter()
.map(|(_, c)| c)
.collect();
let want = [
"flex",
"justify-between",
"items-center",
"py-2",
"px-4",
"font-semibold",
"text-[18px]",
"leading-[1.2]",
"opacity-50",
"text-muted",
"shadow-[0px_4px_12px_0px_rgba(0,_0,_0,_0.25)]",
"[white-space:nowrap]",
];
assert_eq!(got, want);
}
#[test]
fn variables_export_with_units_and_as_a_tailwind_theme() {
let mut s = Session::new();
brand(&mut s);
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12, "opacity": 0.5}}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "card"}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;"), "{css}");
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
for want in ["@import \"tailwindcss\";", "@theme static {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] {
assert!(tw.to_lowercase().contains(&want.to_lowercase()), "{want} in {tw}");
}
}
#[test]
fn desktop_padding_on_other_sides_is_not_reset_away() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 24, "paddingBottom": 24, "paddingLeft": 64, "paddingRight": 64}}));
ex(&mut s, "node.set", json!({"id": mob, "props": {"padding": 24}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
assert!(out.contains("p-6 ") && out.contains("lg:py-6 lg:px-16"), "{out}");
assert!(!out.contains("lg:p-0"), "a reset would undo lg:px-16: {out}");
}
#[test]
fn a_layer_name_cant_break_out_of_the_header_comment() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "A\rB\u{2028}alert(1)\u{2029}C\nD"})));
let out = code(&mut s, f, json!({}));
let header = out.lines().next().unwrap_or_default();
assert!(header.starts_with("// ") && header.contains("alert(1)"), "{out}");
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
let name = out.lines().nth(1).and_then(|l| l.strip_prefix("export function ")).and_then(|l| l.strip_suffix("() {")).unwrap_or_default();
assert!(!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), "an identifier: {name:?}");
}
#[test]
fn slashed_layer_names_and_phone_widths_merge_right() {
let mut s = Session::new();
let mut frames = Vec::new();
for (name, width, dir, sizing) in [("Landing", 1440.0, "horizontal", "hug"), ("Landing mobile", 390.0, "vertical", "fill")] {
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 600, "name": name})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
let ctas = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 342, "height": 120, "parent": p, "name": "CTAs"})));
ex(&mut s, "layout.add", json!({"ids": [ctas], "direction": dir}));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 48, "parent": ctas, "name": "Button/Primary"})));
ex(&mut s, "layout.add", json!({"ids": [b], "direction": "horizontal"}));
ex(&mut s, "node.set", json!({"id": b, "props": {"sizingH": sizing}}));
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": width * 0.8, "height": 1, "parent": p, "props": {"name": "Divider"}}));
frames.push(p);
}
// A layer only the mobile frame has.
ex(&mut s, "text.create", json!({"text": "7 dias", "x": 0, "y": 0, "parent": frames[1], "props": {"name": "Tag"}}));
let out = code(&mut s, frames[0], json!({"mobile": frames[1]}));
let button =
out.lines().find(|l| l.contains("lg:") && l.contains("rounded") || l.contains("self-stretch") && l.contains("flex-row")).unwrap_or_default();
assert!(button.contains("self-stretch") && button.contains("lg:self-auto"), "the slashed name keeps its mobile classes: {out}");
assert!(!out.contains("max-w-[342px] ") || out.contains("lg:max-w-[342px]"), "no phone-width cap below lg: {out}");
assert!(out.contains("Only in the mobile frame") && out.contains("Tag"), "{out}");
let divider = out.lines().filter(|l| l.contains("<div") && (l.contains("h-[1px]") || l.contains("h-px"))).count();
assert!(out.contains("w-full") && divider <= 2, "{out}");
}
#[test]
fn a_variable_named_after_its_namespace_is_its_base() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "radius", "type": "number", "value": 12}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12}}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "radius"}));
assert!(code(&mut s, f, json!({})).contains("rounded-base"));
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
assert!(tw.contains("--radius-base: 12px;") && !tw.contains("--radius-radius"), "{tw}");
}
#[test]
fn mobile_only_layer_names_cant_break_out_of_the_header_comment() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0, "parent": mob, "props": {"name": "Tag\nalert(1)\u{2028}y"}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
let header = out.lines().next().unwrap_or_default();
assert!(header.starts_with("// ") && header.contains("alert(1)"), "listed inside the comment: {out}");
assert!(out.lines().nth(1).is_some_and(|l| l.starts_with("export function ")), "{out}");
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
}
#[test]
fn page_code_leaves_resets_to_tailwind_preflight() {
let mut s = Session::new();
let (p, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let out = code(&mut s, p, json!({}));
for leak in ["box-sizing", "[margin:0]", "text-decoration", "cursor"] {
assert!(!out.contains(leak), "{leak} in {out}");
}
}
#[test]
fn instances_connected_to_code_are_their_snippet_with_one_import() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 160, "height": 44, "name": "Button"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
let label = idv(&ex(&mut s, "text.create", json!({"text": "Botão", "x": 0, "y": 0, "parent": f, "props": {"name": "Label"}})));
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.addProperty", json!({"id": comp, "name": "Label", "type": "text", "layer": label}));
ex(
&mut s,
"codeConnect.set",
json!({"id": comp, "language": "react", "importPath": "@/components/ui/button", "name": "Button", "template": "<Button size=\"lg\">{Label}</Button>"}),
);
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 1440, "height": 400, "name": "Landing"})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
for text in ["Agendar diagnóstico", "Ver exemplos"] {
let i = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": p})));
ex(&mut s, "instance.setProperties", json!({"id": i, "properties": {"Label": text}}));
}
let out = code(&mut s, p, json!({}));
assert_eq!(out.matches("import { Button } from \"@/components/ui/button\";").count(), 1, "{out}");
assert!(out.contains("<Button size=\"lg\">") && out.contains("Agendar diagnóstico") && out.contains("Ver exemplos"), "{out}");
assert!(!out.contains(">Botão<"), "the component's own layers aren't inlined: {out}");
// Unconnected instances are still inlined.
ex(&mut s, "codeConnect.remove", json!({"id": comp, "language": "react"}));
let out = code(&mut s, p, json!({}));
assert!(!out.contains("import ") && out.contains(">Agendar diagnóstico<"), "{out}");
}
#[test]
fn review_same_named_variables_keep_distinct_theme_names() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Palette/Accent", "type": "color", "value": "#3DDC97"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Semantic/Accent", "type": "color", "value": "#7C5CFF"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Muted", "type": "color", "value": "#9AA3AE"}));
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 200, "name": "Page"})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "A"})));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "B"})));
ex(&mut s, "variable.bind", json!({"ids": [a], "field": "fill", "variable": "Palette/Accent"}));
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "Semantic/Accent"}));
let out = code(&mut s, p, json!({}));
assert!(out.contains("bg-palette-accent") && out.contains("bg-semantic-accent"), "{out}");
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_lowercase();
assert!(tw.contains("--color-palette-accent: #3ddc97;") && tw.contains("--color-semantic-accent: #7c5cff;"), "{tw}");
assert!(tw.contains("--color-muted:"), "unique short names stay short: {tw}");
}
#[test]
fn review_an_alias_target_takes_its_readers_role() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "half", "type": "number", "value": 0.5}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 1}));
ex(&mut s, "variable.set", json!({"variable": "fade", "value": {"alias": "half"}}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
assert!(css.contains("--half: 0.5;") && !css.contains("0.5px"), "{css}");
}
#[test]
fn review_borders_images_and_fonts_from_variables_are_valid_utilities() {
let got: Vec<String> = classes(
"border: var(--stroke, 2px) solid #000000;\nborder-bottom: var(--hair, 1px) solid var(--color-border, #262a31);\nbackground: url(image.png) center / cover;\nfont-family: var(--brand-font, \"Inter\");\n",
)
.into_iter()
.map(|(_, c)| c)
.collect();
assert_eq!(
got,
[
"border-(length:--stroke)",
"border-[#000000]",
"border-b-(length:--hair)",
"border-border",
"[background:url(image.png)_center_/_cover]",
"font-(family-name:--brand-font)"
]
);
}
#[test]
fn review_desktop_defaults_reset_mobile_values_and_padding_per_side() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "node.set", json!({"id": mob, "props": {"opacity": 0.5, "padding": 24}}));
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 0, "paddingBottom": 0, "paddingLeft": 64, "paddingRight": 64}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
let root = out.lines().nth(3).unwrap_or_default();
assert!(root.contains("opacity-50") && root.contains("lg:opacity-100"), "{root}");
assert!(root.contains("p-6") && root.contains("lg:px-16") && root.contains("lg:py-0"), "{root}");
}
#[test]
fn review_desktop_without_fill_clears_a_mobile_image_or_gradient() {
let got = merge(Some(&[("background".to_string(), "[background:url(image.png)_center_/_cover]".to_string())]), &[]);
assert!(got.contains(&"lg:bg-none".to_string()) && got.contains(&"lg:bg-transparent".to_string()), "{got:?}");
}
#[test]
fn per_corner_radii_with_a_variable_first_keep_every_corner() {
let got: Vec<String> =
classes("border-radius: var(--radius-a, 4px) 8px 8px 8px;\nborder-radius: var(--radius-card, 4px);\n").into_iter().map(|(_, c)| c).collect();
assert_eq!(got, ["rounded-[var(--radius-a,_4px)_8px_8px_8px]", "rounded-card"]);
}
Loaded 100 of 365 files, more files were not shown because too many files have changed in this diff. Show more