Merge main into #1053

This commit is contained in:
bflatastic
2026-10-26 22:57:25 +07:00
538 changed files with 53180 additions and 4244 deletions
+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 --"
+15 -73
View File
@@ -1,9 +1,7 @@
# CI for pull requests and main: format, clippy, tests, dependency layering, asset attribution and
# the wasm32 check (the same gates as `cargo xtask ci`). Linux only: macOS is built and tested only
# by release.yml (the org's macOS runners are kept for releases; craftrules release/playbook.md),
# and Windows/FreeBSD packaging have their own workflows.
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
@@ -13,76 +11,20 @@ on:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUST_BACKTRACE: 1
# Debug and test builds keep line tables (file:line in panic backtraces) but drop the rest of the
# debug info: faster codegen and links, and a smaller target dir.
CARGO_PROFILE_DEV_DEBUG: line-tables-only
# A newer push to a PR branch cancels its older run. Runs on main are never cancelled, so every
# main state that gets a run is checked to the end.
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.ref }}
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
test:
timeout-minutes: 35
name: test (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Install Linux system dependencies (egui/winit/rfd)
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
libgl1-mesa-dev libgtk-3-dev
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
components: clippy, rustfmt
# Only main saves caches; PRs restore main's, so PR caches don't evict main's.
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
- name: Format
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
# Without craft-fonts (the optional CRAFT_FONTS_DIR input, which .cargo/config.toml points at
# a sibling checkout that CI doesn't have): everything must build and pass without it.
- name: Test
run: cargo test --workspace --locked
- name: Dependency layering
run: cargo run --locked -q -p xtask -- layers
- name: Asset attribution
run: cargo run --locked -q -p xtask -- assets
wasm:
timeout-minutes: 30
name: wasm32 check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
targets: wasm32-unknown-unknown
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
key: wasm32
save-if: ${{ github.ref == 'refs/heads/main' }}
cache-on-failure: true
# The engine, the UI crate and apps/figcraft-web must keep compiling for the browser.
- name: Check wasm-safe crates
run: cargo run --locked -q -p xtask -- wasm
gate:
# 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:
# 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
+6 -6
View File
@@ -4,12 +4,10 @@
# before it can fail a release. Nothing is uploaded and no secrets are used.
name: FreeBSD
# The VM build takes one to several hours, so it runs nightly on main rather than on every push
# or dependency change: a drifted dependency still shows up within a day. PRs that change the
# FreeBSD packaging itself still run it.
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# request: a drifted dependency still shows up within a day, and always before a release ships.
# PRs that change the FreeBSD workflow or packaging itself still run it.
on:
schedule:
- cron: "17 2 * * *"
push:
branches: [release]
pull_request:
@@ -17,6 +15,8 @@ on:
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
schedule:
- cron: "17 2 * * *"
workflow_dispatch:
concurrency:
@@ -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:
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency:
group: sonarcloud-${{ github.ref }}
cancel-in-progress: true
# A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
sonarcloud:
+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
+535 -170
View File
File diff suppressed because it is too large Load Diff
+43 -11
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.9.0"
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"
@@ -79,9 +84,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"
@@ -103,11 +108,15 @@ todo = "deny"
unimplemented = "deny"
unreachable = "deny"
[profile.dev.package."*"]
opt-level = 2
# Workspace crates keep file:line in panic backtraces; dependencies carry no debug info. Both make
# the test build faster and the target dir smaller.
[profile.dev]
opt-level = 1
debug = "line-tables-only"
[profile.dev.package."*"]
opt-level = 2
debug = false
[profile.release]
lto = "thin"
@@ -118,9 +127,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.
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.
+107 -11
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (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
@@ -19,13 +19,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) |
@@ -50,7 +50,7 @@ against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h);
native speakers' reviews of the six language drafts need people.
native speakers' reviews of the nine language drafts need people.
## By dimension
@@ -63,9 +63,9 @@ native speakers' reviews of the six language drafts need people.
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-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
@@ -86,13 +86,14 @@ native speakers' reviews of the six language drafts need people.
| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) |
| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| Dev Mode / inspect | 96% | 72% | 0–1 | [dev-mode-parity](docs/dev-mode-parity.md) |
| 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) | ~47% | ~29% | 20–44 | [boards-parity](docs/boards-parity.md) |
## Languages
@@ -109,11 +110,11 @@ native speakers' reviews of the six 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,
@@ -126,14 +127,44 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
2. Real-file corpus and measured fidelity: 15–30 h.
3. Tool-by-tool interaction pass against Figma: 40–60 h.
4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h).
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
## Progress log
- **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.
- **2026-10-15** · Removing someone from a live session (M9, #338): the host or the file's owner removes a person from the avatar's menu or with `collab.removePerson`; they keep their copy and can't rejoin while the session runs. Numbers unchanged.
- **2026-10-15** · Languages (M10, #257, #263, #469, #477, #288): Russian (one/few/many plurals) and Turkish catalogs reviewed by native speakers, Czech and Indonesian drafts, the Japanese draft completed; Preferences ▸ Language lists 12 languages and Latin American Spanish. Numbers unchanged.
- **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged.
- **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.
- **2026-10-13** · Vector edit mode regions (M4): with the Move tool, the filled area under the pointer is hatched with "/" lines in the selection colour, clipped by the area's own fill rule so holes stay clear; the hatch goes near points (3.5 px) and segments (2 px), and its spacing follows octave folding (8 px at every power-of-two zoom), fixed to the shape. A click selects the area with its points (they move with it but keep the unselected look), Shift adds or removes one, a press on a selected area drags the whole selection as one undo step, and a click on an empty spot clears the areas and stays in edit mode. `vector.edit.select {regions}` and `vector.edit.state` (`regions`, `regionVertices`, `regionHover`); `vector_regions_tests` and `regions_tests`. Numbers unchanged.
@@ -316,6 +347,69 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 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 |
| 2026-10-15 | minor | Removing someone from a live session (#338): right-click a person's avatar ▸ Remove *name* from session, or `collab.removePerson {client}`, when you host the session or own the file on a server; the session ends for them (they keep their copy), the code or link they hold no longer gets them back in while it runs, and everyone else stays. Numbers unchanged |
| 2026-10-15 | minor | Pages list (#272, #258): a long pages list scrolls in its own area, so the layers below stay in view; page and layer drops count only inside the list's visible rows. Numbers unchanged |
| 2026-10-15 | minor | Variables export in the browser (#368): Export… ▸ Variables JSON / CSS custom properties did nothing in the web build and now arrives as a download; importing there still needs a file picker. Numbers unchanged |
| 2026-10-15 | minor | Font picker keys (#352, #345): Up and Down step through the font families from the current one, keeping the highlight in view and previewing it on the canvas; Enter applies it, Escape leaves the text as it was. Numbers unchanged |
| 2026-10-15 | minor | Leaving Design ends the pen path (#308, #307): switching to Prototype or Inspect while the Pen draws ends the path and goes back to Move (pressing Design again keeps drawing), and a click on the toolbar no longer reaches the canvas under it. Numbers unchanged |
| 2026-10-15 | minor | Vector edit mode keeps the canvas still (#322): the edit mode hints in the Design panel wrap, so entering edit mode and picking a region no longer shift the canvas by about 25 points in the middle of the click (which dragged the region picked). Numbers unchanged |
| 2026-10-15 | minor | Drop shadows under a background blur (#317, #255): a layer with both casts its shadows from what it paints (a translucent fill casts a lighter shadow than an opaque one) and reads the backdrop only after drawing them, so a shadow shown behind transparent areas is seen through the blur. Numbers unchanged |
| 2026-10-15 | minor | When the window can't open (#306, #261): Windows release builds have no console, so a graphics failure at start quit without a word; the error is now logged and shown in a message box in the interface language, with what to try (unset `WGPU_BACKEND`, update the driver, OpenGL) and where to report it, skipped with `--control`. Numbers unchanged |
| 2026-10-15 | minor | Crashes on Windows (#271, #314, #337; #224 and two more): a text field focused before it was drawn aborted the screen-reader layer; double-clicking a page, creating a variable collection and renaming a style folder no longer close the app. Numbers unchanged |
| 2026-10-15 | minor | Deeply nested layers (#330, #210): one depth guard (256) on the three recursive layout paths, so a deep file can't run the layout out of stack. Numbers unchanged |
| 2026-10-15 | minor | Image memory budgets (#326; #203, #189): decoded images and a file's images have budgets; the decode cache evicts its least recently used entries. Numbers unchanged |
| 2026-10-15 | minor | macOS file dialogs (#253, #250) no longer re-enter the event loop; a dialog's result goes to the file and window it was opened for, and dialogs are parented to the main window. Numbers unchanged |
| 2026-10-15 | minor | Recovery copies (#297, #303, #331, #336; #202, #185, #187): a copy counts only after its write succeeds and a failed one is retried; saves and closes wait for the write in flight; copies keep version history and branches; a recovered copy stays until this run has its own. Numbers unchanged |
| 2026-10-15 | minor | Text edits (#280, #278) no longer roll back earlier changes; a drag released off the canvas ends at the pointer's last canvas position. Numbers unchanged |
| 2026-10-15 | minor | Rollback (#282, #301; #196): a failed command rolls back during a drag or other gesture too; a failed batch keeps the redo steps. Numbers unchanged |
| 2026-10-15 | minor | Atomic save and export (#284): staging files are uniquely named and created exclusively, so a file or link already at the old fixed staging name is never written through, and two writers of one file no longer share one. Numbers unchanged |
| 2026-10-15 | minor | Alt-drag copies (#283) snap to their original, and Duplicate (Cmd+D) then repeats the last copy's offset. Numbers unchanged |
| 2026-10-15 | minor | The pen (#277) shows its Bezier handles while a point is dragged out. Numbers unchanged |
| 2026-10-15 | minor | Corner handles (#293, #292): a press on a corner handle resizes instead of picking the corner-radius handle. Numbers unchanged |
| 2026-10-15 | minor | New text (#372, #362) takes a readable colour from the frame it is drawn in, not only the page (text drawn in a white frame on a dark page was white). Numbers unchanged |
| 2026-10-15 | minor | Outline stroke (#386) works on every selected layer. Numbers unchanged |
| 2026-10-15 | minor | Send to back (#444) counts per parent when the selection spans frames. Numbers unchanged |
| 2026-10-15 | minor | Align buttons (#276) are off when the selection has nothing to align to. Numbers unchanged |
| 2026-10-15 | minor | Gradient type icons (#275) show their gradient. Numbers unchanged |
| 2026-10-15 | minor | Corner resize cursors (#388) stay diagonal at any proportions; the text selection highlight (#390) stays inside the box on the last line. Numbers unchanged |
| 2026-10-15 | minor | New pages (#311, #295) take the current page's background; the theme still decides a new file's page. Numbers unchanged |
| 2026-10-15 | minor | A slice's export preview (#265) shows the content under it. Numbers unchanged |
| 2026-10-15 | minor | SVG export (#327, #325) keeps a nested frame's fill and stroke inside its parent's clip. Numbers unchanged |
| 2026-10-15 | minor | Home screen (#318, #324): recent files notice files deleted while it is open; a long path no longer draws over the file name. Numbers unchanged |
| 2026-10-15 | minor | The Small caps sample in Type settings (#355) draws from capitals, no longer a box where the UI font lacks the small-capital letter. Numbers unchanged |
| 2026-10-15 | minor | Web clipboard (#375): copy and paste in the browser go through the system clipboard, so a paste after a copy brings the copied layers instead of whatever text the clipboard held. Numbers unchanged |
| 2026-10-15 | minor | Zoom previews (#312): when renders from scratch are slow, a new zoom first shows the whole view coarsely instead of the page colour, then the refined image; images of another file or page never show. Numbers unchanged |
| 2026-10-15 | minor | Hidden windows (#313, #332; #269): a minimised or covered window answers control and MCP requests (commands, inspection, `ui.render`) at once, each window on its own; screenshots and input still wait for it to be on screen. Numbers unchanged |
| 2026-10-15 | minor | Server (#340, #302): a compaction cut short closes the file instead of confirming lost edits (#183); each team library version is kept in its own file (#206). Numbers unchanged |
| 2026-10-15 | minor | Counters (#343, #344, #353; #309): collab sequence numbers, layer name counters and opacity digits can't overflow. Numbers unchanged |
| 2026-10-15 | minor | Commands refuse bad indexes and ids (#475, #384, #310): `page.select` and `doc.switch`, `effect.set` and `export.update` / `export.remove`, `page.move` with a non-page id. Numbers unchanged |
| 2026-10-15 | minor | Find and replace (#358, #361) changes every occurrence, case-insensitively, also when the replacement contains the search text. Numbers unchanged |
| 2026-10-15 | minor | The gradient shorthand (#254, #252) is read by the engine, so the CLI, control channel and MCP share it. Numbers unchanged |
| 2026-10-15 | minor | .fig import (#279, #281): resized instances lay out their layers by their constraints (#266); explicit variable modes whose collection is named by its asset key, as library collections are, come across (#267). Numbers unchanged |
| 2026-10-15 | minor | Colour emoji (#300, #268) draw from their own colour tables (sbix and CBDT/EBDT bitmap strikes, COLR v0 layers), a colour face tried first; COLR v1 still draws outlines. Numbers unchanged |
| 2026-10-15 | minor | macOS (#290, #289): `.figcraft` files open from Finder, Open With and the Dock. Numbers unchanged |
| 2026-10-15 | minor | Windows (#294, #291): the first preferred language alone picks the UI language. Numbers unchanged |
| 2026-10-15 | minor | Unix font discovery (#474) follows `XDG_DATA_HOME`. Numbers unchanged |
| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged |
| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged |
| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
| 2026-10-22 | minor | Type settings keeps its width (#1029): in languages whose labels run past the 110 px label column (French "Retrait de paragraphe", "Espacement de liste"), the List spacing, Paragraph indent, Max lines and decoration rows were wider than the popover, which then widened every frame while open. Those fields now take the width left after their label; English is unchanged. Regression test in `type_settings_tests`. Numbers unchanged. |
| 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged |
| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
@@ -327,10 +421,12 @@ 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. |
| 2026-10-15 | minor | Canvas retains a bounded margin while panning so newly exposed strips can reuse the previous render. Numbers unchanged. |
| 2026-10-15 | minor | Dev Mode tail (M6, gap 16): import lines for connected components inside generated layer trees (definitions, slots and swapped-in instances too); `dev.compare {version}` and the compare view's Compare with menu compare with any saved version; behaviour suite (5 tests; 4 failed before the fixes): an inspect-only Dev Mode canvas with menus, shortcuts, nudges, opacity keys and paste refusing design edits, toolbar drawing tools returning to the design, Changed in the ready list (`inspect.readyForDev {status: changed}`), statuses only on sections and top-level frames; read-only plugins' panels from the Inspect tab. Dev Mode 96%/72% → 97%/76%, 0–1 h → 2–4 h (measurements kept on the canvas and a read-only layers panel counted as missing); header totals not recomputed |
| 2026-10-15 | minor | Typography spacing icons scrub with the shared number-field gesture, modifier rates and one Undo step per drag (#373). Units and selected-character targeting are preserved. Numbers unchanged. |
| 2026-10-14 | minor | System theme on first run (M7): preferences without a theme default to System; the first frame reads the OS appearance directly, including when returning from a manual theme. Saved Light/Dark choices stay explicit; unavailable OS appearance falls back to Light. Numbers unchanged. |
| 2026-10-14 | minor | Home screen recent files as cards (M7): each card shows the picture its file carries on its first page's colour, the file's icon, name and "Edited ... ago"; sort by name, creation or last change either way; a table view; a click selects, a double-click or Enter opens, arrow keys move; Open, Show in folder and Remove from recents on right-click. Numbers unchanged. |
+36 -8
View File
@@ -1,6 +1,6 @@
//! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value`
//! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s.
//! Unknown options are usage errors.
//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`].
use std::collections::BTreeMap;
@@ -16,8 +16,14 @@ pub struct Args {
impl Args {
pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Self> {
let mut a = Args::default();
// The first bad option, reported once the rest of the line is checked for `--help`.
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())) {
@@ -30,19 +36,27 @@ impl Args {
Some((n, v)) => (n, Some(v.to_string())),
None => (name, None),
};
// `--help` / `-h` anywhere among the options (not as an option's value) asks for help.
if inline.is_none() && matches!(name, "help" | "h") {
return Err(CliError::Help);
}
if valued.contains(&name) {
let v = match inline {
Some(v) => v,
None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?,
};
a.values.entry(name.to_string()).or_default().push(v);
match inline.or_else(|| it.next().cloned()) {
Some(v) => a.values.entry(name.to_string()).or_default().push(v),
None => {
bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value")));
}
}
} else if flags.contains(&name) && inline.is_none() {
a.flags.push(name.to_string());
} else {
return Err(CliError::Usage(format!("unknown option `{arg}`")));
bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`")));
}
}
Ok(a)
match bad {
Some(e) => Err(e),
None => Ok(a),
}
}
pub fn value(&self, k: &str) -> Option<String> {
@@ -135,4 +149,18 @@ mod tests {
let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap();
assert_eq!(a.positional, ["-5"]);
}
#[test]
fn help_is_asked_for_anywhere_but_as_a_value() {
// Also after a bad option: help wins over the usage error (#1161 review).
for argv in
[&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]]
{
assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}");
}
let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap();
assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h")));
// `--help=x` is no request for help, just an unknown option.
assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_))));
}
}
+125 -28
View File
@@ -1,10 +1,11 @@
//! The file subcommands: info, export, convert, render and run.
use std::ffi::OsStr;
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};
@@ -19,7 +20,7 @@ fn load_noted(path: &str) -> CliResult<(Document, Vec<String>)> {
}
fn ext(path: &str) -> String {
Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("").to_ascii_lowercase()
}
fn image_format(e: &str) -> Option<ExportFormat> {
@@ -207,10 +208,13 @@ pub fn info(path: &str, as_json: bool) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`],
/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters
/// and 200 bytes (file systems allow 255, and a number and the extension still fit).
fn sanitize(name: &str) -> String {
let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect();
let s = s.trim().trim_matches('.').to_string();
if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() }
let s = figcraft_engine::cmd::file::safe_file_name(name);
let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace());
if s.is_empty() { "layer".into() } else { s.char_indices().take(100).take_while(|(i, c)| i + c.len_utf8() <= 200).map(|(_, c)| c).collect() }
}
/// What `export` exports and how.
@@ -268,14 +272,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
return Err(CliError::Failed("the page is empty: nothing to export".into()));
}
let mut used = std::collections::BTreeSet::new();
let mut next_suffix: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for id in ids {
let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer"));
let mut name = base.clone();
let mut k = 2;
while !used.insert(name.to_lowercase()) {
name = format!("{base} {k}");
k += 1;
}
let base_lower = base.to_lowercase();
let next = next_suffix.entry(base_lower).or_insert(1);
let name = loop {
let k = *next;
*next += 1;
let candidate = if k == 1 { base.clone() } else { format!("{base} {k}") };
if used.insert(candidate.to_lowercase()) {
break candidate;
}
};
let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned();
write(&path, &bytes_of(&[id])?)?;
println!("{path}");
@@ -283,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))
@@ -309,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") => {
@@ -334,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()
}
@@ -343,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()))?)?;
@@ -411,6 +443,14 @@ mod tests {
assert_eq!(sanitize("Home / Hero"), "Home _ Hero");
assert_eq!(sanitize("../.."), "_");
assert_eq!(sanitize(""), "layer");
// The same characters the app's export keeps (#1154): only what file systems refuse goes.
assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)");
assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é");
assert_eq!(sanitize("a\\b:c*d?e\"f<g>h|i\tj"), "a_b_c_d_e_f_g_h_i_j");
assert_eq!(sanitize(" .hidden. "), "hidden");
assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed");
assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100);
assert_eq!(sanitize(&"\u{1F600}".repeat(100)), "\u{1F600}".repeat(50), "at most 200 bytes, whole characters");
}
#[test]
@@ -494,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() {
@@ -513,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();
}
}
+42 -23
View File
@@ -27,16 +27,16 @@ USAGE:
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]]
figcraft-cli help | --help | --version
figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too)
Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the
fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are
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.
@@ -46,10 +46,11 @@ the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell hi
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
only inside a write root, everything else refused -- so giving no write root denies writing
altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`,
`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes
as the user who started it, as before. A running app confines itself: start it with
`figcraft --control PORT` and the same flags (`--attach` refuses them).
altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands,
and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a
subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is
confined by the app it attaches to, not by flags passed to the CLI: start that app with
`figcraft --control PORT` and the same roots (`--attach` refuses them).
See docs/cli.md and docs/mcp.md.";
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
@@ -74,6 +75,10 @@ fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
match run(&argv) {
Ok(code) => code,
Err(CliError::Help) => {
println!("{USAGE}");
ExitCode::SUCCESS
}
Err(CliError::Usage(msg)) => {
eprintln!("error: {msg}\n\n{USAGE}");
ExitCode::from(2)
@@ -87,6 +92,8 @@ fn main() -> ExitCode {
#[derive(Debug)]
pub enum CliError {
/// `--help` or `-h` after a subcommand: usage on stdout, exit code 0.
Help,
/// Bad arguments: usage is printed, exit code 2.
Usage(String),
/// The operation failed: exit code 1.
@@ -101,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}");
@@ -120,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 =
@@ -134,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())),
@@ -142,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()));
}
@@ -163,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()));
}
@@ -178,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()))?;
@@ -188,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}`"))),
}
@@ -1,5 +1,4 @@
//! The binary end to end.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::process::{Command, Stdio};
@@ -14,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 [
@@ -40,6 +71,40 @@ fn bad_arguments_exit_2_without_panicking() {
assert_eq!(out.status.code(), Some(1));
}
/// `--help` / `-h` after a subcommand prints the usage on stdout and exits 0, as it does first
/// (#1155), whatever else is on the line; nothing runs.
#[test]
fn help_after_a_subcommand_prints_the_usage() {
for args in [
&["--help"][..],
&["-h"],
&["help"],
&["mcp", "--help"],
&["mcp", "-h"],
&["info", "--help"],
&["export", "--help"],
&["export", "x.figcraft", "-o", "out", "--help"],
&["convert", "-h"],
&["render", "--help"],
&["run", "--help"],
&["commands", "--help"],
&["plugin", "publish", "--help"],
&["perf", "--help"],
&["mcp", "--attach", "notaport", "--help"],
&["mcp", "--bogus", "--help"],
] {
let out = bin().args(args).stdin(Stdio::null()).output().unwrap();
assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr));
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("USAGE") && text.contains("figcraft-cli mcp"), "{args:?}: {text}");
assert!(out.stderr.is_empty(), "{args:?}: nothing on stderr, got {}", String::from_utf8_lossy(&out.stderr));
}
// A value that happens to be `-h` is still a value, not a request for help.
let out = bin().args(["commands", "--filter", "--help"]).output().unwrap();
assert_eq!(out.status.code(), Some(0));
assert!(!String::from_utf8_lossy(&out.stdout).contains("USAGE"), "--filter took `--help` as its text");
}
#[test]
fn run_export_info_convert() {
let d = tmp("run");
@@ -81,6 +146,30 @@ fn run_export_info_convert() {
std::fs::remove_dir_all(&d).ok();
}
/// `export` into a folder names the files as the app's export does (#1154): brackets, `+`, `&`,
/// `%`, `#` and commas stay, what file systems refuse becomes `_`.
#[test]
fn export_file_names_keep_what_the_app_keeps() {
let d = tmp("names");
let script = d.join("s.json");
let names = ["Homepage - desktop (live + verbeteringen)", "Q&A, 50% #1 [v2]", "a:b*c?d|e"];
let steps: Vec<serde_json::Value> = names
.iter()
.enumerate()
.map(|(i, n)| serde_json::json!({"command": "frame.create", "params": {"x": i * 200, "y": 0, "width": 20, "height": 20, "name": n}}))
.collect();
std::fs::write(&script, serde_json::to_string(&steps).unwrap()).unwrap();
let file = d.join("doc.figcraft");
assert!(bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap().status.success());
let dir = d.join("out");
let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
for want in ["Homepage - desktop (live + verbeteringen).png", "Q&A, 50% #1 [v2].png", "a_b_c_d_e.png"] {
assert!(dir.join(want).is_file(), "{want} in {:?}", std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).collect::<Vec<_>>());
}
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn run_takes_the_documented_gradient_shorthand() {
// #252: only MCP used to accept it; `run` said "`fills` must be paints or colours".
@@ -1,5 +1,4 @@
//! Depth refusal through the actual CLI, including its exit status and destination writes.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_doc::color::{Color, Paint};
use figcraft_doc::{Document, NodeKind};
@@ -1,6 +1,5 @@
//! Headless runs lay text out with the installed fonts, like the desktop app, unless
//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
@@ -67,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");
}
+12
View File
@@ -0,0 +1,12 @@
//! The crate's integration tests, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod export_depth;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod fonts;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod pdf;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod pdf_print;
@@ -1,5 +1,4 @@
//! `figcraft-cli export --format pdf` and `convert … .pdf` end to end.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
@@ -1,5 +1,4 @@
//! `figcraft-cli export` with PDF print options: bleed, marks, CMYK; bad lengths are usage errors.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::process::Command;
+3 -3
View File
@@ -120,15 +120,15 @@ impl Abuse {
open
}
/// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones
/// before it; enough wrong ones in a row lock the address out.
/// `ip` tried a join code that was right (`ok`) or wrong: a right one doesn't forgive the wrong
/// ones before it (they only decay with time); enough wrong ones lock the address out.
pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) {
if !ok {
self.wrong_codes.take(now);
}
for (k, times) in ip_blocks(ip) {
let a = self.address(k, f64::from(times), now);
if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
if now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
a.wrong = 0;
}
if !ok {
+1 -1
View File
@@ -11,7 +11,7 @@ use crate::store::Store;
use crate::test_util::{TempDir, config};
fn args(s: &[&str]) -> Vec<String> {
s.iter().map(|a| a.to_string()).collect()
s.iter().map(ToString::to_string).collect()
}
fn run(dir: &std::path::Path, command: Command) -> Result<String, String> {
+12 -8
View File
@@ -262,17 +262,21 @@ fn the_limits_on_codes_over_time() {
let t0 = Instant::now();
let me = ip("198.51.100.9");
let mut abuse = Abuse::new(AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
// A right code forgives the wrong ones before it.
// A right code, even from a neighbour in the same /64, doesn't forgive the wrong ones.
let a = ip("2001:db8:1:2::1");
let b = ip("2001:db8:1:2::2");
for _ in 0..WRONG_CODES - 1 {
abuse.code_tried(a, false, t0);
}
abuse.code_tried(b, true, t0);
abuse.code_tried(a, true, t0);
assert_eq!(abuse.code_locked(a, t0), None);
abuse.code_tried(a, false, t0);
assert_eq!(abuse.code_locked(a, t0), Some(CODE_LOCKOUT));
// Wrong codes: locked for the lockout, then free again.
for _ in 0..WRONG_CODES {
abuse.code_tried(me, false, t0);
}
abuse.code_tried(me, true, t0);
for _ in 0..WRONG_CODES - 1 {
abuse.code_tried(me, false, t0);
}
assert_eq!(abuse.code_locked(me, t0), None);
// One more: locked for the lockout, then free again.
abuse.code_tried(me, false, t0);
assert_eq!(abuse.code_locked(me, t0), Some(CODE_LOCKOUT));
assert!(abuse.code_locked(me, t0 + CODE_LOCKOUT - Duration::from_secs(1)).is_some());
assert_eq!(abuse.code_locked(me, t0 + CODE_LOCKOUT), None);
+7 -5
View File
@@ -13,6 +13,7 @@
//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its
//! first damaged record (a write cut short).
use std::ffi::OsStr;
use std::fs::{self, File};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
@@ -49,7 +50,7 @@ pub fn free_bytes(path: &Path) -> Option<u64> {
/// `path` with `.tmp` added: where its next version is written first.
pub fn temp_of(path: &Path) -> PathBuf {
let mut name = path.file_name().map(|n| n.to_os_string()).unwrap_or_default();
let mut name = path.file_name().map(OsStr::to_os_string).unwrap_or_default();
name.push(".tmp");
path.with_file_name(name)
}
@@ -127,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)
}
@@ -838,3 +838,25 @@ fn a_library_publish_that_fails_keeps_serving_the_old_version() {
assert_eq!(served(fetch(&mut store)), (2, v2));
assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted");
}
#[test]
fn a_library_publish_cannot_claim_the_highest_version() {
let dir = TempDir::new("libmax");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
let mut s = Session::new();
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
s.execute("component.create", &json!({"ids": [f]})).unwrap();
s.execute("library.publish", &json!({})).unwrap();
let mut lib = s.doc().unwrap().published.clone().unwrap().as_ref().clone();
let mut archive = |version: u64| {
lib.manifest.version = version;
figcraft_engine::library_server::archive(&lib).unwrap()
};
let high = 1u64 << 32;
assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a first publish can't claim the highest version");
assert_eq!(store.publish_library(&file.id, &archive(high)).unwrap(), high, "the highest accepted first version goes in");
assert_eq!(store.publish_library(&file.id, &archive(high + 1)).unwrap(), high + 1, "and a newer one still follows it");
assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a jump to the highest version is refused");
assert_eq!(store.publish_library(&file.id, &archive(high + 2)).unwrap(), high + 2, "later versions still go in");
}
+74
View File
@@ -164,6 +164,64 @@ fn caps_per_address_and_for_everyone() {
assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY);
}
#[test]
fn caps_per_address_count_the_wider_ipv6_blocks() {
let dir = TempDir::new("block-caps");
let mut c = open(dir.path());
c.max_files_per_ip = 1;
c.publishes_per_hour = 1000;
let mut store = Store::open(dir.path(), c.store()).unwrap();
// A /48 keeps 4 times one address's files: /64s in different /56s of it.
for i in 0..4 {
store.publish_open(ip(&format!("2001:db8:1:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap();
}
let e = store.publish_open(ip("2001:db8:1:400::1"), "Five", &snapshot("Five")).unwrap_err();
assert!(e.contains("files on this server already"), "{e}");
// A /56 keeps twice one address's files.
store.publish_open(ip("2001:db8:2:1::1"), "One", &snapshot("One")).unwrap();
store.publish_open(ip("2001:db8:2:2::1"), "Two", &snapshot("Two")).unwrap();
let e = store.publish_open(ip("2001:db8:2:3::1"), "Three", &snapshot("Three")).unwrap_err();
assert!(e.contains("files on this server already"), "{e}");
drop(store);
// Bytes: one address keeps one file's worth, so a /48 keeps 4 files.
let probe = TempDir::new("block-caps-probe");
let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap();
let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes;
let dir = TempDir::new("block-caps-bytes");
let mut s = open(dir.path()).store();
s.hosting.max_files_per_ip = 100;
s.hosting.max_bytes_per_ip = size;
let mut store = Store::open(dir.path(), s).unwrap();
for i in 0..4 {
store.publish_open(ip(&format!("2001:db8:3:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap();
}
let e = store.publish_open(ip("2001:db8:3:400::1"), "One", &snapshot("One")).unwrap_err();
assert!(e.contains("MiB it may keep"), "{e}");
}
#[test]
fn edits_count_the_wider_ipv6_blocks_too() {
use figcraft_collab_net::hub::Backend;
let probe = TempDir::new("block-room-probe");
let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap();
let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes;
let dir = TempDir::new("block-room");
let mut s = open(dir.path()).store();
s.hosting.max_files_per_ip = 100;
// One address keeps 2.25 files' worth, so its /56 keeps 4.5.
s.hosting.max_bytes_per_ip = size * 2 + size / 4;
let mut store = Store::open(dir.path(), s).unwrap();
let mut ids = Vec::new();
for i in 0..4 {
ids.push(store.publish_open(ip(&format!("2001:db8:4:{i:x}::1")), "One", &snapshot("One")).unwrap().0.id);
}
let server = Backend::open(&mut store, &ids[0]).unwrap();
// Its own /64 has room for this, but its /56 doesn't.
let e = Backend::room(&mut store, &ids[0], &server, (size * 3 / 4) as usize).unwrap_err();
assert!(e.contains("have reached their"), "{e}");
Backend::room(&mut store, &ids[0], &server, (size / 4) as usize).unwrap();
}
#[test]
fn idle_files_published_without_the_key_expire() {
let dir = TempDir::new("expiry");
@@ -196,6 +254,22 @@ fn idle_files_published_without_the_key_expire() {
assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id));
}
#[test]
fn offline_commands_do_not_sweep_idle_files() {
let dir = TempDir::new("offline-sweep");
let mut c = open(dir.path());
c.idle_days = 30.0;
let mut store = Store::open(dir.path(), c.store()).unwrap();
let (public, ..) = store.publish_open(ip("203.0.113.9"), "Public", &snapshot("Public")).unwrap();
store.age(&public.id, 31 * 86_400);
drop(store);
let store = Store::try_open(dir.path(), c.store()).unwrap().unwrap();
drop(store);
assert!(dir.path().join("files").join(&public.id).exists(), "a command deleted an idle file");
let _serving = Store::open(dir.path(), c.store()).unwrap();
assert!(!dir.path().join("files").join(&public.id).exists());
}
#[test]
fn rate_limits_and_blocks_refuse_with_a_reason() {
let dir = TempDir::new("limits");
+3 -1
View File
@@ -261,7 +261,9 @@ fn fuzzed_frames_dont_hurt_the_server() {
break;
}
}
let _ = ws.read();
// read() returns Result<Message>, not a lock guard; the fuzz loop ends here on purpose and
// the close frame from the server is not interesting.
let _ = ws.read(); // NOSONAR
}
// Still serving, the file intact.
let mut b = join(&p.edit, "Bo");
+131 -29
View File
@@ -42,7 +42,7 @@ use figcraft_collab::message::{
PluginReply, PluginReq, ReportInfo, Usage,
};
use figcraft_collab::{Change, Replica, Server, same_secret};
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key};
use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_blocks, ip_key};
use figcraft_collab_net::token;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@@ -291,7 +291,18 @@ fn valid_id(s: &str) -> bool {
/// A name others may see: trimmed, no control characters, at most 256 characters.
fn clean_name(s: &str) -> String {
s.trim().chars().filter(|c| !c.is_control()).take(256).collect()
s.trim().chars().filter(|c| !c.is_control() && !is_bidi_control(*c)).take(256).collect()
}
/// A report's reason: trimmed, control and bidi characters turned into spaces, at most `MAX_REASON` characters.
fn clean_reason(s: &str) -> String {
let s: String = s.chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).collect();
s.trim().chars().take(MAX_REASON).collect()
}
/// Bidirectional format characters, which could reorder what a terminal shows.
fn is_bidi_control(c: char) -> bool {
matches!(c, '\u{61C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
}
/// Take the data directory's lock: `None` when someone else holds it.
@@ -302,6 +313,10 @@ pub fn lock(root: &Path) -> Result<Option<File>, String> {
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut dirs, 0o700);
dirs.create(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?;
// `DirBuilder` only applies the mode to directories it creates; tighten an existing one too.
#[cfg(unix)]
fs::set_permissions(root, std::os::unix::fs::PermissionsExt::from_mode(0o700))
.map_err(|e| format!("can't restrict the data directory {}: {e}", root.display()))?;
let f = OpenOptions::new()
.create(true)
.truncate(false)
@@ -334,16 +349,27 @@ fn write_key(root: &Path) -> Result<String, String> {
Ok(key)
}
/// The highest version a file's first library publish may claim. Publishing counts up by one (a
/// library carried over from another server keeps its number), so no real history gets near it.
/// Later publishes may go one past the version on the server, so there is always a next one.
const MAX_FIRST_LIBRARY_VERSION: u64 = 1 << 32;
impl Store {
/// Use the data directory `root` (made if missing). Fails when another server or command holds
/// it. Files that can't be read are skipped and listed in [`problems`](Self::problems).
pub fn open(root: &Path, cfg: StoreConfig) -> Result<Store, String> {
Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))
let mut store = Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))?;
store.sweep();
Ok(store)
}
/// [`open`](Self::open), or `None` when someone else holds the directory.
/// [`open`](Self::open) without the expiry sweep (for commands that only look or edit), or
/// `None` when someone else holds the directory.
pub fn try_open(root: &Path, cfg: StoreConfig) -> Result<Option<Store>, String> {
let Some(lock) = lock(root)? else { return Ok(None) };
for name in [KEY, BLOCKED, REPORTS] {
let _ = fs::remove_file(disk::temp_of(&root.join(name)));
}
let key = load_key(root)?;
let dir = root.join(FILES);
fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?;
@@ -374,7 +400,6 @@ impl Store {
store.scan(&dir);
store.blocked = store.read_list::<String>(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect();
store.reports = store.read_list(REPORTS);
store.sweep();
Ok(Some(store))
}
@@ -425,12 +450,20 @@ impl Store {
/// A file folder's description and links, after checking its snapshot and log. Leftover
/// temporary files (writes cut short) are removed.
fn check(&self, path: &Path) -> Result<Entry, String> {
for name in [SNAPSHOT, LOG, META, LINKS] {
for name in [SNAPSHOT, LOG, META, LINKS, USAGE] {
let tmp = disk::temp_of(&path.join(name));
if tmp.exists() {
let _ = fs::remove_file(&tmp);
}
}
if let Ok(entries) = fs::read_dir(path) {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if name.starts_with("library-") && name.ends_with(".bin.tmp") {
let _ = fs::remove_file(e.path());
}
}
}
let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?;
let links: Vec<Link> =
serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?;
@@ -514,6 +547,34 @@ impl Store {
self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view))
}
/// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's
/// caps it gets, and the files and bytes kept by the files published from addresses in it. An
/// address that doesn't parse counts as itself only, ×1, by its exact text.
fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> {
let blocks_of = |text: &str| text.parse::<IpAddr>().map(|a| ip_blocks(ip_key(a))).unwrap_or_default();
let kept: Vec<(&str, Vec<_>, u64)> = self
.files
.iter()
.filter_map(|(id, e)| {
let from = e.meta.ip.as_deref()?;
Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id)))
})
.collect();
let sum = |mine: &mut dyn Iterator<Item = &(&str, Vec<_>, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n)));
let blocks = blocks_of(ip);
if blocks.is_empty() {
let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip));
return vec![(1, files, bytes)];
}
blocks
.into_iter()
.map(|(block, times)| {
let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block)));
(times, files, bytes)
})
.collect()
}
/// Keep a document published without the key from `ip`, within the caps on what one address
/// and everyone may keep: the file, its links and its owner token.
pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> {
@@ -525,19 +586,21 @@ impl Store {
if !self.abuse.allow(ip, Action::Publish, Instant::now()) {
return Err("too many files published from your address lately: try again in an hour".into());
}
let ip_text = ip.to_string();
let mine: Vec<&String> = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip_text.as_str())).map(|(id, _)| id).collect();
if mine.len() >= max_files {
return Err(format!(
"your address keeps {} files on this server already, the most it may: remove one (with its owner token) or wait until one expires",
mine.len()
));
let blocks = self.per_block(&ip.to_string());
for &(times, files, _) in &blocks {
if files >= max_files.saturating_mul(times as usize) {
return Err(format!(
"your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires"
));
}
}
let used_ip: u64 = mine.iter().map(|id| self.bytes(id)).sum();
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
let fits = |n: u64| {
if used_ip.saturating_add(n) > max_ip {
return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", max_ip >> 20));
for &(times, _, used) in &blocks {
let cap = max_ip.saturating_mul(u64::from(times));
if used.saturating_add(n) > cap {
return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20));
}
}
if used_all.saturating_add(n) > max_all {
return Err("this server is full: it takes no more files for now (try again later, or another server)".into());
@@ -595,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.
@@ -748,7 +814,7 @@ impl Store {
/// Keep a report about `file` (from someone who joined it with `link`, at `ip`).
pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> {
let reason: String = reason.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REASON).collect();
let reason = clean_reason(reason);
if reason.is_empty() {
return Err("say what is wrong with the file".into());
}
@@ -973,6 +1039,12 @@ impl Store {
if m.version <= old.version {
return Err(format!("version {} is on the server already: publish again to follow it", old.version));
}
// A jump would let a client use up the numbers and leave no newer version to publish.
if m.version > old.version.saturating_add(1) {
return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1)));
}
} else if m.version > MAX_FIRST_LIBRARY_VERSION {
return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version));
}
// Each version has a file of its own, written before the description names it: a failed
// description write or a crash in between leaves the previous version served whole, never
@@ -1090,9 +1162,11 @@ impl Store {
let h = &self.cfg.hosting;
let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) };
let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes);
let used_ip: u64 = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip.as_str())).map(|(id, _)| self.bytes(id)).sum();
if used_ip.saturating_add(n) > max_ip {
return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", max_ip >> 20));
for (times, _, used) in self.per_block(&ip) {
let cap = max_ip.saturating_mul(u64::from(times));
if used.saturating_add(n) > cap {
return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20));
}
}
let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum();
if used_all.saturating_add(n) > max_all {
@@ -1110,7 +1184,24 @@ impl Store {
let mut issues = Vec::new();
let mut fresh_log = true;
let mut kept = 0;
match disk::read_capped(&dir.join(LOG), self.cfg.max_file_bytes).and_then(|b| disk::read_log(&b)) {
let log_path = dir.join(LOG);
// A log that's there but can't be read (an IO error, or over a lowered cap) may hold
// confirmed edits: the file isn't opened, and the log is left as it is for another try.
let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) {
Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")),
r => r,
};
// One that isn't a log is moved aside before a new one replaces it (or the file isn't opened).
let read = match read.map(|b| disk::read_log(&b)) {
Ok(Err(why)) => {
let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now()));
fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?;
Err(format!("{why}; it was moved aside to {}", aside.display()))
}
Ok(Ok(log)) => Ok(log),
Err(why) => Err(why),
};
match read {
Ok(log) if log.generation == generation => {
fresh_log = false;
for entry in log.entries {
@@ -1167,7 +1258,10 @@ impl Store {
Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 }
}
// A new snapshot and log of the next generation, so appends start clean.
_ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?,
_ => {
let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?;
self.rewrite(file, next, &server).map_err(|e| e.why)?
}
};
self.entry(file)?.log = Some(log);
Ok(server)
@@ -1243,7 +1337,11 @@ impl Store {
fn compact(&mut self, file: &str, server: &Server) {
self.compactions += 1;
let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return };
match self.rewrite(file, generation + 1, server) {
let Some(next) = generation.checked_add(1) else {
log::error!("file {file}: compaction failed: the file's generation can't go any higher");
return;
};
match self.rewrite(file, next, server) {
Ok(log) => {
if let Ok(e) = self.entry(file) {
e.log = Some(log);
@@ -1385,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;
+178
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");
@@ -229,6 +257,17 @@ fn one_server_per_data_directory() {
assert_eq!(again.key(), key, "the key is kept");
}
#[cfg(unix)]
#[test]
fn an_existing_data_directory_is_made_private() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new("perms");
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap();
assert!(crate::store::lock(dir.path()).unwrap().is_some());
let mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700);
}
#[test]
fn temporary_directories_are_removed() {
let path = {
@@ -241,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);
@@ -313,3 +365,129 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() {
let name = page_name(&restored);
assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}");
}
#[test]
fn a_log_that_cant_be_read_is_never_overwritten() {
let dir = TempDir::new("unreadlog");
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
let log_path = dir.path().join(FILES);
let last = "Confirmed page name number 39 with some padding";
let id = {
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
store.changed(&id, &mut server);
for i in 0..40 {
rename(&mut server, &mut a, &format!("Confirmed page name number {i} with some padding"));
store.changed(&id, &mut server);
}
assert!(store.flush().is_empty());
id
};
let log_path = log_path.join(&id).join(LOG);
let before = fs::read(&log_path).unwrap();
// The cap lowered just under the log: it can't be read now, but it must still be there after.
let lowered = StoreConfig { max_file_bytes: before.len() as u64 - 1, ..c.clone() };
{
let mut store = Store::open(dir.path(), lowered).unwrap();
let opened = store.open(&id);
assert!(opened.is_err(), "the file isn't opened without its log");
assert!(!store.problems().is_empty(), "the problem is reported");
assert_eq!(fs::read(&log_path).unwrap(), before, "the log is left as it was");
}
let mut store = Store::open(dir.path(), c).unwrap();
let server = store.open(&id).unwrap();
assert_eq!(page_name(&server), last, "every confirmed edit comes back");
}
#[test]
fn a_log_that_isnt_one_is_moved_aside() {
let dir = TempDir::new("badlog");
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
let (id, _, _) = publish(&mut store, "Busy");
let folder = dir.path().join(FILES).join(&id);
let junk = b"not a log at all, but maybe worth keeping".to_vec();
fs::write(folder.join(LOG), &junk).unwrap();
drop(store);
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
store.open(&id).unwrap();
assert!(store.problems().iter().any(|p| p.contains("moved aside")), "{:?}", store.problems());
let kept = fs::read_dir(&folder).unwrap().filter_map(|e| e.ok()).any(|e| fs::read(e.path()).is_ok_and(|b| b == junk));
assert!(kept, "the old log's bytes are kept beside the file");
}
const BIDI: [char; 12] =
['\u{61C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}'];
#[test]
fn names_and_report_reasons_lose_bidi_controls() {
let dir = TempDir::new("bidi");
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
for (n, m) in BIDI.into_iter().enumerate() {
let ip: std::net::IpAddr = format!("192.0.2.{}", n + 1).parse().unwrap();
let (id, _, _) = publish(&mut store, &format!("a{m}b"));
assert_eq!(store.list(&|_| None)[0].name, "ab", "name U+{:04X}", m as u32);
store.keep_report(&id, None, ip, &format!("a{m}b")).unwrap();
assert_eq!(store.reports().last().unwrap().reason, "a b", "reason U+{:04X}", m as u32);
store.remove(&id).unwrap();
}
// A reason that is only control characters says nothing.
let (id, _, _) = publish(&mut store, "blank");
for blank in ["\0", "\u{202E}", " \0\t\u{2066} "] {
assert!(store.keep_report(&id, None, "192.0.2.99".parse().unwrap(), blank).is_err(), "{blank:?}");
}
}
#[test]
fn a_maxed_out_generation_does_not_wrap() {
let dir = TempDir::new("maxgen");
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Max");
let path = dir.path().join(FILES).join(&id);
let (_, state) = disk::decode_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap();
// Loading: no log goes with the snapshot, so the next generation would overflow.
fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX, &state).unwrap()).unwrap();
fs::remove_file(path.join(LOG)).unwrap();
assert!(store.open(&id).is_err());
// Compacting: the open log is at the largest generation.
fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX - 1, &state).unwrap()).unwrap();
let _ = fs::remove_file(path.join(LOG));
let mut server = store.open(&id).unwrap();
let mut a = join(&mut server, Access::Edit);
store.changed(&id, &mut server);
for i in 0..4 {
rename(&mut server, &mut a, &format!("P{i}"));
store.changed(&id, &mut server);
store.flush();
}
let snap = disk::check_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap();
assert_eq!(snap, u64::MAX);
}
#[test]
fn every_leftover_temporary_file_is_removed_at_startup() {
let dir = TempDir::new("leftovers");
let id = {
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
let id = publish(&mut store, "A").0;
store.flush();
id
};
let folder = dir.path().join(FILES).join(&id);
let leftovers = [
dir.path().join("blocked.json.tmp"),
dir.path().join("reports.json.tmp"),
dir.path().join("server.key.tmp"),
folder.join("usage.json.tmp"),
folder.join("library-1.bin.tmp"),
];
for path in &leftovers {
fs::write(path, b"half").unwrap();
}
let _store = Store::open(dir.path(), cfg(dir.path())).unwrap();
for path in &leftovers {
assert!(!path.exists(), "leftover not cleaned at startup: {}", path.display());
}
}
@@ -2,7 +2,6 @@
//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
//! still restores silently.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_collab::message::{Access, ClientMsg};
use figcraft_collab::{Client, Outgoing, Replica, Server};
+5
View File
@@ -0,0 +1,5 @@
//! The crate's integration tests, built as one test binary.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod header_recovery;
mod problem_retention;
+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) {
+10 -2
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);
}
@@ -117,7 +125,7 @@ impl Services for WebServices {
}
fn open_url(&mut self, url: &str) {
let Some(window) = web_sys::window() else { return };
if let Err(e) = window.open_with_url_and_target(url, "_blank") {
if let Err(e) = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer") {
log::error!("couldn't open {url}: {e:?}");
}
}
+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" {
+1 -1
View File
@@ -3,7 +3,7 @@
use crate::args::{Args, file_url_path, is_invite, parse};
fn args(a: &[&str]) -> Vec<String> {
a.iter().map(|s| s.to_string()).collect()
a.iter().map(ToString::to_string).collect()
}
#[test]
+49 -7
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>>>;
@@ -16,6 +16,38 @@ const MAX_LINE: usize = 32 * 1024 * 1024;
const MAX_CONNECTIONS: usize = 8;
const MAX_QUEUED_REQUESTS: usize = 8;
const TIMEOUT: Duration = Duration::from_secs(60);
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
/// example while the process has no file descriptors left, and retrying at once would keep a
/// 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>) {
@@ -38,7 +70,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
eprintln!("FigCraft control server listening on 127.0.0.1:{port}");
let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || {
let mut workers = Vec::new();
for stream in listener.incoming().flatten() {
for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT);
}
});
@@ -47,6 +79,11 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) {
}
}
/// The connections `incoming` accepts, calling `wait` after each failed accept.
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
}
fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec<std::thread::JoinHandle<()>>, timeout: Duration) {
workers.retain(|worker| !worker.is_finished());
if workers.len() >= MAX_CONNECTIONS {
@@ -60,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))?;
@@ -87,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 {
@@ -95,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) => {
+64 -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\":"] {
@@ -131,6 +178,18 @@ fn idle_and_partial_frame_connections_expire() {
}
}
/// A failed accept is followed by a wait before the next one, so a listener that keeps failing
/// does not spin, and the connections around the failures still come through.
#[test]
fn a_failed_accept_waits_before_the_next() {
let failed = || Err(std::io::Error::other("no file descriptors left"));
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
let mut waits = 0;
let connections: Vec<i32> = accepted(incoming.into_iter(), || waits += 1).collect();
assert_eq!(connections, [1, 2]);
assert_eq!(waits, 3, "one wait per failed accept");
}
#[test]
fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
let pending = queue();
@@ -215,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};
@@ -250,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),
}))
}),
);
+26 -4
View File
@@ -206,10 +206,12 @@ impl Recovery {
let doc = d.doc.clone();
let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid));
// Which online file it is and what of the server's this very copy has (a line each:
// the server, the file's id, then the session, client id and last change), while it
// the server, the file's id, then the session, client id, last change and the secret the
// client id resumes with), while it
// is one. Written after the copy, never before: a note must not promise more of the
// server's changes than its copy holds.
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
let online =
s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret));
let job = move || {
if !write_atomic(&path, &doc, &versions, &branches) {
return false;
@@ -312,7 +314,10 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
let (server, id, at) = (lines.next()?, lines.next()?, lines.next()?);
let mut at = at.split(' ').map(|n| n.parse::<u64>().ok());
let (session, client, seq) = (at.next()??, at.next()??, at.next()??);
let params = json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq});
// Absent in notes written before ids had secrets.
let secret = at.next().flatten().unwrap_or(0);
let params =
json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq, "secret": secret});
s.execute("collab.recoverFile", &params).ok()?.get("session")?.as_u64()
}
@@ -338,7 +343,24 @@ fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false };
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok()
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true).mode(0o600);
let Ok(mut f) = opts.open(&tmp) else { return false };
if std::io::Write::write_all(&mut f, &bytes).is_err() {
let _ = std::fs::remove_file(&tmp);
return false;
}
}
#[cfg(not(unix))]
{
if std::fs::write(&tmp, bytes).is_err() {
return false;
}
}
std::fs::rename(&tmp, path).is_ok()
}
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
+1 -1
View File
@@ -193,7 +193,7 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
let left = crash(&s, &root);
let note = std::fs::read_to_string(left.join(format!("{}.online", s.docs[0].uid))).unwrap();
let at = s.online_of(s.docs[0].uid).unwrap().2;
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {}", at.session, at.client_id, at.seq), "which file, who the app was, what it had");
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret), "which file, who the app was, what it had");
assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret");
let (mut next, script, mut r, opened) = restart(&s, &root);
assert_eq!(opened, 0, "nothing comes back as a plain copy");
+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}");
}
+15
View File
@@ -71,3 +71,18 @@ 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"
sync_github_name = false
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
[people.leandrodslv]
sync_github_name = true
+44
View File
@@ -51,3 +51,47 @@ fn a_closed_file_forgets_whom_it_barred() {
assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it");
assert!(hub.barred.contains_key("other"), "another file's stay");
}
#[test]
fn a_kicked_connection_with_a_full_queue_is_shut() {
use std::io::Read;
use std::net::TcpListener;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::mpsc::sync_channel;
use std::time::Duration;
use super::{Conn, Kind, Line, Out};
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (server_side, _) = listener.accept().unwrap();
client.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
// Stands in for the writer thread's handle: it keeps the socket open after the Conn is gone.
let _writer = server_side.try_clone().unwrap();
let (tx, _rx) = sync_channel(2);
while tx.try_send(Out::Bytes(Default::default(), None)).is_ok() {}
let out = Line {
tx,
queued: Arc::default(),
block: Arc::new(AtomicUsize::new(0)),
all: Arc::new(AtomicUsize::new(0)),
max: 1 << 20,
max_block: usize::MAX,
max_all: usize::MAX,
};
let ip = IpAddr::V4(Ipv4Addr::LOCALHOST);
let mut hub = Hub {
in_flight: InFlight(Default::default()),
backend: Closes,
rooms: HashMap::new(),
conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing, ip })]),
local: None,
outbox: Vec::new(),
barred: HashMap::new(),
};
hub.kick(&[1], "kicked");
assert!(hub.conns.is_empty());
let mut buf = [0u8; 1];
assert_eq!(client.read(&mut buf).ok(), Some(0), "the kicked socket must be shut (client sees EOF)");
}
+11 -3
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.
@@ -141,7 +147,9 @@ fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) {
let t = std::thread::spawn(move || {
for stream in listener.incoming().take(2).flatten() {
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
let _ = ws.read();
// read() returns Result<Message>, not a lock guard; this test server deliberately
// ignores whatever the client sent first.
let _ = ws.read(); // NOSONAR
let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() };
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&refused).unwrap())));
let _ = ws.close(None);
+55 -16
View File
@@ -402,6 +402,8 @@ enum HubIn {
sock: TcpStream,
/// Its size (in flight until handled).
bytes: usize,
/// Told when the hub takes the connection in (dropped unsent when it doesn't).
admitted: SyncSender<()>,
},
Msg {
conn: u64,
@@ -811,17 +813,22 @@ fn encode(msg: &ServerMsg) -> Option<Bytes> {
/// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps).
fn refuse(out: &Line, why: &str) {
refused(out, why);
}
/// [`refuse`], and whether the close was queued (it isn't when the queue is full).
fn refused(out: &Line, why: &str) -> bool {
if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) {
let _ = out.tx.try_send(Out::Bytes(b, None));
}
let _ = out.tx.try_send(Out::Close(CloseCode::Policy, reason(why)));
out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok()
}
/// What the server tells a client when it is too busy to take more from it.
const BUSY: &str = "the server is busy: try again in a moment";
/// Read the next binary message (skipping pings) and its size, or why the connection should end.
fn next_message(ws: &mut WebSocket<Duplex>, max: usize) -> Result<Option<(ClientMsg, usize)>, String> {
/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end.
fn next_message(ws: &mut WebSocket<Duplex>, max: usize, deadline: Option<Instant>) -> Result<Option<(ClientMsg, usize)>, String> {
loop {
return match ws.read() {
Ok(Message::Binary(b)) => {
@@ -830,6 +837,13 @@ fn next_message(ws: &mut WebSocket<Duplex>, max: usize) -> Result<Option<(Client
message::decode::<ClientMsg>(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string())
}
Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()),
// Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and
// a message trickled in is cut off by it (`Duplex::read_until`).
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_))
if deadline.is_some_and(|d| Instant::now() >= d) =>
{
Err("no hello in time".into())
}
Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue,
Ok(Message::Close(_)) => Ok(None),
Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)),
@@ -851,6 +865,7 @@ fn too_large(max: usize) -> String {
/// One connection's reader (the writer runs on its own thread).
fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimits, open: Open, shared: &Shared) {
let _ = stream.set_nodelay(true);
let hello_deadline = Instant::now() + limits.hello_timeout;
if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
}
@@ -859,7 +874,9 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
}
let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return };
let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer);
let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return };
let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return };
// The handshake and the hello must be in by the deadline, however slowly they trickle in.
rd.read_until(Some(hello_deadline));
let cfg = config(limits.max_client_message);
let mut forwarded = None;
// The handshake callback's type is tungstenite's (its error is an HTTP response).
@@ -923,7 +940,10 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
log::info!("event=refused ip={ip} reason=connections_per_address");
end(Some("too many connections from your address: close some and try again"));
} else {
match next_message(&mut ws, limits.max_hello) {
let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline));
// In time or not, the hello's deadline is over: what's read next has timeouts of its own.
ws.get_mut().read_until(None);
match hello {
Ok(Some((
first @ (ClientMsg::Hello(_)
| ClientMsg::Manage(_)
@@ -946,9 +966,13 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) {
end(Some(BUSY));
} else {
ws.set_config(|c| *c = config(max));
let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes }).is_ok());
if opened && ws.get_ref().set_read_timeout(Some(idle)).is_ok() {
let (admitted, verdict) = sync_channel(1);
let opened =
sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok());
// Nothing more is read until the hub took the connection in (its token checked):
// only then may it send messages of the full size.
if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() {
ws.set_config(|c| *c = config(max));
read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end);
let _ = hub.send(HubIn::Gone { conn });
}
@@ -962,6 +986,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
drop(counted);
// The block's counters go with the last handle on them (the table forgets it then).
drop(block);
ws.get_mut().read_until(None);
linger(&mut ws);
drop(out);
// The writer closes the socket once it has sent what's queued (a refusal, a close).
@@ -981,7 +1006,7 @@ fn read_loop(
let mut bucket = Bucket::new(limits.rate, limits.burst);
let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST);
loop {
match next_message(ws, max) {
match next_message(ws, max, None) {
Ok(Some((msg, bytes))) => {
let now = Instant::now();
if !bucket.take(now) {
@@ -1074,6 +1099,8 @@ struct Pending {
out: Line,
sock: TcpStream,
ip: IpAddr,
/// Told when it is taken in.
admitted: SyncSender<()>,
}
/// A connection the hub took in.
@@ -1195,7 +1222,7 @@ impl<B: Backend> Hub<B> {
fn handle(&mut self, m: HubIn) {
match m {
HubIn::Open { conn, ip, first, out, sock, bytes } => {
HubIn::Open { conn, ip, first, out, sock, bytes, admitted } => {
self.in_flight.give(bytes);
// The operator (with the server key) gets in from anywhere; everyone else is
// checked (blocked addresses, connection rates).
@@ -1204,7 +1231,7 @@ impl<B: Backend> Hub<B> {
log::info!("event=refused ip={ip} reason={why:?}");
return refuse(&out, &why);
}
let c = Pending { out, sock, ip };
let c = Pending { out, sock, ip, admitted };
match first {
ClientMsg::Hello(hello) => self.join(conn, hello, c),
ClientMsg::Manage(m) => self.manage(conn, &m, c),
@@ -1399,6 +1426,7 @@ impl<B: Backend> Hub<B> {
if let Some(c) = old.and_then(|old| self.conns.remove(&old)) {
shutdown(&c.sock);
}
let _ = c.admitted.try_send(());
self.conns
.insert(conn, Conn { out: c.out, sock: c.sock, kind: Kind::Joined { file: file.clone(), id, link: admission.link }, ip: c.ip });
self.queue(&file, more);
@@ -1429,7 +1457,9 @@ impl<B: Backend> Hub<B> {
barred.insert(c.ip);
}
log::info!("event=removed file={file} client={id} ip={}", c.ip);
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into()));
if c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())).is_err() {
shutdown(&c.sock);
}
self.gone(conn);
Ok(())
}
@@ -1444,6 +1474,7 @@ impl<B: Backend> Hub<B> {
/// Take in connection `c` as `kind` and send it `first`.
fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) {
let _ = c.admitted.try_send(());
self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip });
self.answer(conn, first);
}
@@ -1543,8 +1574,10 @@ impl<B: Backend> Hub<B> {
// People in the file are told the session is over for them, as when a host stops (they
// keep their copy; nothing retries); a library request is refused.
for conn in &people {
if let Some(c) = self.conns.get(conn) {
let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into()));
if let Some(c) = self.conns.get(conn)
&& c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into())).is_err()
{
shutdown(&c.sock);
}
self.gone(*conn);
}
@@ -1593,8 +1626,10 @@ impl<B: Backend> Hub<B> {
/// Refuse connections with `why` and drop them at once (nothing more they send counts).
fn kick(&mut self, conns: &[u64], why: &str) {
for conn in conns {
if let Some(c) = self.conns.get(conn) {
refuse(&c.out, why);
if let Some(c) = self.conns.get(conn)
&& !refused(&c.out, why)
{
shutdown(&c.sock);
}
self.gone(*conn);
}
@@ -1747,3 +1782,7 @@ mod queue_tests;
#[cfg(test)]
#[path = "barred_tests.rs"]
mod barred_tests;
#[cfg(test)]
#[path = "preauth_tests.rs"]
mod preauth_tests;
+81 -6
View File
@@ -11,11 +11,18 @@ use figcraft_engine::live::{Event, Presence};
pub type Waker = Arc<dyn Fn() + Send + Sync>;
/// Most discrete events kept between two drains (joins, leaves, status); the oldest go first.
/// Document sync events don't count and are never dropped: the sync client treats a missing
/// batch as a fatal sequence gap, so losing one would end the session.
const MAX_EVENTS: usize = 10_000;
#[derive(Default)]
struct State {
events: VecDeque<Event>,
/// Arrival number of the next event, so [`Inbox::drain`] can merge the two queues in order.
next: u64,
/// [`Event::Sync`] events, never dropped.
syncs: VecDeque<(u64, Event)>,
/// Every other event, capped at [`MAX_EVENTS`].
others: VecDeque<(u64, Event)>,
presence: BTreeMap<u32, Presence>,
}
@@ -43,11 +50,17 @@ impl Inbox {
if let Event::PeerLeft(id) = &e {
s.presence.remove(id);
}
if s.events.len() >= MAX_EVENTS {
log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest");
s.events.pop_front();
let n = s.next;
s.next += 1;
if matches!(e, Event::Sync(_)) {
s.syncs.push_back((n, e));
return;
}
s.events.push_back(e);
if s.others.len() >= MAX_EVENTS {
log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest");
s.others.pop_front();
}
s.others.push_back((n, e));
});
}
@@ -62,8 +75,70 @@ impl Inbox {
/// peer.
pub fn drain(&self) -> Vec<figcraft_engine::live::Event> {
let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner);
let mut out: Vec<Event> = s.events.drain(..).collect();
let mut syncs = std::mem::take(&mut s.syncs).into_iter().peekable();
let mut others = std::mem::take(&mut s.others).into_iter().peekable();
let mut out = Vec::with_capacity(syncs.len() + others.len() + s.presence.len());
loop {
let sync_first = match (syncs.peek(), others.peek()) {
(Some((a, _)), Some((b, _))) => a < b,
(Some(_), None) => true,
(None, Some(_)) => false,
(None, None) => break,
};
let next = if sync_first { syncs.next() } else { others.next() };
out.extend(next.map(|(_, e)| e));
}
out.extend(std::mem::take(&mut s.presence).into_iter().map(|(id, p)| Event::Presence(id, p)));
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use figcraft_collab::message::ServerMsg;
fn reject(batch_id: u64) -> Event {
Event::Sync(ServerMsg::Reject { batch_id, reason: String::new(), fixes: Vec::new() })
}
fn sync_ids(events: &[Event]) -> Vec<u64> {
events
.iter()
.filter_map(|e| match e {
Event::Sync(ServerMsg::Reject { batch_id, .. }) => Some(*batch_id),
_ => None,
})
.collect()
}
#[test]
fn a_full_inbox_never_drops_sync_events() {
let inbox = Inbox::new(Arc::new(|| {}));
inbox.push(reject(1));
for _ in 0..MAX_EVENTS {
inbox.push(Event::PeerLeft(7));
}
inbox.push(reject(2));
for _ in 0..MAX_EVENTS {
inbox.push(Event::PeerLeft(7));
}
let out = inbox.drain();
assert_eq!(sync_ids(&out), vec![1, 2]);
assert!(out.len() <= MAX_EVENTS + 2, "{} events kept", out.len());
}
#[test]
fn sync_events_past_the_cap_are_all_kept_in_order() {
let inbox = Inbox::new(Arc::new(|| {}));
let n = MAX_EVENTS as u64 + 5;
for id in 0..n {
inbox.push(reject(id));
}
inbox.push(Event::PeerLeft(7));
let out = inbox.drain();
let ids = sync_ids(&out);
assert_eq!(ids.len() as u64, n, "Sync events dropped");
assert!(ids.iter().copied().eq(0..n), "Sync events out of order");
}
}
+2 -2
View File
@@ -189,9 +189,9 @@ pub(crate) enum Taken {
/// in answer must go out on this connection.
pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken {
match m {
ServerMsg::Welcome { session, client_id, seq, ref peers, access, .. } => {
ServerMsg::Welcome { session, client_id, seq, ref peers, access, secret, .. } => {
if let Ok(mut r) = resume.lock() {
*r = Some(Resume { session, client_id, seq });
*r = Some(Resume { session, client_id, seq, secret });
}
// The sync client opens the document (and fills in its name and pages).
let peers = peers.clone();
+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);
}
}
+73 -2
View File
@@ -2,7 +2,7 @@
//! refusals, presence relay and throttling, leaving and dropping, limits, malformed frames, and
//! reconnecting. Every test stops its host and closes its sockets.
use std::io::Read;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -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);
@@ -670,3 +687,57 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
assert!(!welcomed(&gp.events));
host.stop();
}
/// 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. 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;
}
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
}
#[test]
fn pings_do_not_keep_a_connection_without_a_hello_alive() {
let closed = pre_hello_lifetime(|r, _| r.0.send(Message::Ping(Bytes::new())).is_ok() && r.0.flush().is_ok());
assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}");
}
#[test]
fn a_hello_trickled_in_fragments_does_not_outlast_the_deadline() {
// A binary message begun and never finished: one masked byte per frame, each in time for the
// read timeout.
let closed = pre_hello_lifetime(|r, first| r.0.get_mut().write_all(&[if first { 0x02 } else { 0x00 }, 0x81, 0, 0, 0, 0, b'x']).is_ok());
assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "trickled pre-hello connection survived: {closed:?}");
}
+60 -4
View File
@@ -196,19 +196,27 @@ 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)
}
/// Close politely: read until the server's close arrives (or the socket ends), so it isn't lost.
/// How long `close` waits for the server's close.
const CLOSE_DRAIN: Duration = Duration::from_secs(2);
/// Close politely: read until the server's close arrives (or the socket ends), so it isn't lost,
/// but no longer than [`CLOSE_DRAIN`] (a server that keeps pinging, or goes quiet, would pin the
/// loop).
fn close(mut ws: Ws) {
let _ = ws.close(None);
ws.get_mut().read_until(Some(std::time::Instant::now() + CLOSE_DRAIN));
while ws.read().is_ok() {}
}
@@ -240,3 +248,51 @@ fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<
};
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Whether `close` returns within 3 s against a server that never answers the close and does
/// `serve` instead, with reads waiting up to `idle`.
fn closes_in_time(idle: Duration, serve: impl FnOnce(WebSocket<std::net::TcpStream>) + Send + 'static) -> bool {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
let (s, _) = listener.accept().unwrap();
if let Ok(ws) = tungstenite::accept(s) {
serve(ws);
}
});
let limits = NetLimits { idle_timeout: idle, ..NetLimits::default() };
let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false };
let ws = connect(&server, &limits, Duration::from_secs(2)).unwrap();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
close(ws);
let _ = tx.send(());
});
rx.recv_timeout(Duration::from_secs(3)).is_ok()
}
/// A server that keeps pinging and never answers the close must not pin `close` forever.
#[test]
fn close_returns_against_a_server_that_keeps_pinging() {
let pinging = closes_in_time(Duration::from_millis(300), |mut ws| {
while ws.send(Message::Ping(Bytes::new())).is_ok() {
std::thread::sleep(Duration::from_millis(10));
}
});
assert!(pinging, "close() kept draining a pinging server");
}
/// Nor one that goes quiet, however long the idle timeout.
#[test]
fn close_returns_against_a_silent_server() {
let silent = closes_in_time(Duration::from_secs(10), |ws| {
std::thread::sleep(Duration::from_secs(5));
drop(ws);
});
assert!(silent, "close() waited out the idle timeout");
}
}
+29 -6
View File
@@ -33,14 +33,16 @@ pub struct Duplex {
tls: Option<Tls>,
/// What the bytes read count against (a server's reader), if anything.
meter: Option<Meter>,
/// When reading must be over, however slowly the bytes come ([`Duplex::read_until`]).
deadline: Option<Instant>,
}
impl Duplex {
/// Two handles on `stream` (for the reader and the writer) and one to shut it down with.
pub fn split(stream: TcpStream, tls: Option<Tls>) -> io::Result<(Duplex, Duplex, TcpStream)> {
let write = Arc::new(Mutex::new(stream.try_clone()?));
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None };
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None };
let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None, deadline: None };
let b = Duplex { read: stream.try_clone()?, write, tls, meter: None, deadline: None };
Ok((a, b, stream))
}
@@ -49,6 +51,12 @@ impl Duplex {
self.meter = Some(meter);
}
/// Reads on this handle fail once `deadline` passes, even when each one gets a few bytes in
/// time (a message trickled in can't outlast it). `None`: only the read timeout applies.
pub fn read_until(&mut self, deadline: Option<Instant>) {
self.deadline = deadline;
}
/// What was read so far became a message (handed on): it no longer counts as being read.
pub fn read_done(&mut self) {
if let Some(m) = self.meter.as_mut() {
@@ -187,10 +195,25 @@ impl Duplex {
impl Read for Duplex {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let n = match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
}?;
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))?;
}
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)?;
}
+72
View File
@@ -0,0 +1,72 @@
//! A connection whose hello the backend hasn't admitted yet is held to the hello's size limit: it
//! can't have a message of the full size read while its token is unchecked.
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::mpsc::{Receiver, channel};
use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, ServerMsg, Signal};
use figcraft_collab::{Client, Server};
use tungstenite::{Bytes, Message};
use super::{Admission, Backend, Closing, Hosting};
use crate::net::NetLimits;
/// A backend that refuses every hello, once it is told to.
struct Slow(Receiver<()>);
impl Backend for Slow {
fn admit(&mut self, _file: &str, _token: &str) -> Result<Admission, String> {
let _ = self.0.recv();
Err("wrong token".into())
}
fn open(&mut self, _file: &str) -> Result<Server, String> {
Err("no".into())
}
fn closing(&self) -> Closing {
Closing::Ended
}
}
fn connect(addr: SocketAddr) -> tungstenite::WebSocket<TcpStream> {
let s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0
}
#[test]
fn an_unadmitted_connection_cant_send_a_large_message() {
let limits = NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() };
let (release, wait) = channel();
let mut hosting = Hosting::serve(TcpListener::bind("127.0.0.1:0").unwrap(), Slow(wait), limits).unwrap();
let addr = hosting.addr();
let sender = std::thread::spawn(move || {
let mut ws = connect(addr);
let hello = message::encode(&Client::hello("0123456789abcdef", "bad", "x", "")).unwrap();
ws.send(Message::Binary(Bytes::from(hello))).unwrap();
let big = ClientMsg::Signal(Signal { kind: Signal::CHAT.into(), text: "x".repeat(1 << 20) });
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&big).unwrap())));
let mut refused = None;
while let Ok(m) = ws.read() {
if let Message::Binary(b) = m
&& let Ok(ServerMsg::Refused { reason }) = message::decode(&b, 64 << 20)
{
refused = Some(reason);
}
}
refused
});
let mut peak = 0;
let until = Instant::now() + Duration::from_secs(2);
while Instant::now() < until {
peak = peak.max(hosting.in_flight_bytes());
std::thread::sleep(Duration::from_millis(2));
}
release.send(()).unwrap();
let refused = sender.join().unwrap();
hosting.stop();
assert!(peak <= limits.max_hello, "a message of {peak} bytes was read before the token was checked");
assert_eq!(refused.as_deref(), Some("wrong token"));
}
+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);
+45
View File
@@ -0,0 +1,45 @@
//! Boards sync: the file kind reaches joiners in their snapshot, and a layer's board metadata (a
//! connector's ends, a sticky's author) syncs as one property, through both encodings.
use figcraft_doc::{BoardItem, Connector, Document, End, FileKind, NodeKind, Routing, Side};
use crate::change::{Change, DocProp, Prop};
use crate::replica::{Mode, Replica};
use crate::{diff, message};
#[test]
fn a_boards_kind_and_its_objects_metadata_sync() {
let mut doc = Document::new("Board");
doc.kind = FileKind::Board;
let page = doc.first_page().unwrap();
let a = doc.create(page, NodeKind::Frame, "Sticky", 0.0, 0.0, 200.0, 200.0).unwrap();
doc.node_mut(a).unwrap().board = Some(BoardItem::Sticky { author: "Ada".into() });
let host = Replica::new(doc);
// A joiner's copy, built from the snapshot, is a board with the sticky's metadata.
let snap = host.snapshot().unwrap();
assert!(snap.iter().any(|c| matches!(c, Change::SetDoc { prop: DocProp::Kind, .. })));
let joiner = Replica::from_snapshot(&snap).unwrap();
assert_eq!(joiner.document().kind, FileKind::Board);
assert_eq!(joiner.document().node(a).unwrap().board, host.document().node(a).unwrap().board);
// Re-attaching a connector's end is one property write, the same after the wire.
let mut edited = host.document().clone();
let c = edited.create(page, NodeKind::Vector { network: Default::default() }, "Connector", 0.0, 0.0, 1.0, 1.0).unwrap();
edited.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector {
from: End::Node { node: a, side: Side::Auto },
to: End::Point { x: 5.0, y: 5.0 },
routing: Routing::Elbow,
}));
let mut r = host.clone();
r.apply(&diff(&host, &edited).unwrap().changes, Mode::Strict).unwrap();
let mut moved = r.document().clone();
moved.node_mut(c).unwrap().board = Some(BoardItem::Connector(Connector {
from: End::Node { node: a, side: Side::Top },
to: End::Point { x: 9.0, y: 1.0 },
routing: Routing::Straight,
}));
let d = diff(&r, &moved).unwrap();
assert!(matches!(d.changes.as_slice(), [Change::Set { prop: Prop::Board, .. }]), "{:?}", d.changes);
let wire: Vec<Change> = message::decode(&message::encode(&d.changes).unwrap(), usize::MAX).unwrap();
r.apply(&wire, Mode::Strict).unwrap();
assert_eq!(r.document(), &moved);
}
+13 -2
View File
@@ -181,6 +181,8 @@ node_props! {
Annotations => annotations,
CodeLinks => code_links,
DevResources => dev_resources,
Measurements => measurements,
Board => board,
}
/// A document-level property. Lists of entries with ids (styles, comments, variable collections,
@@ -219,6 +221,12 @@ pub enum DocProp {
Variables,
/// Variable ids in order.
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.
@@ -238,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
@@ -311,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,
}
}
@@ -324,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}"),
}
}
@@ -334,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 -5
View File
@@ -41,6 +41,8 @@ pub struct Client {
/// The session joined (see [`ServerMsg::Welcome`]).
session: u64,
id: u32,
/// What this client resumes its id with (see [`Resume::secret`]).
secret: u64,
/// The server's state as of `seq`.
confirmed: Replica,
seq: u64,
@@ -84,7 +86,7 @@ impl Client {
/// Start from the server's `Welcome` (which must carry a snapshot).
pub fn join(welcome: ServerMsg) -> Result<Client, Error> {
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access } = welcome else {
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access, secret } = welcome else {
return Err(Error::Protocol("expected a welcome with the document".into()));
};
let mut confirmed = Replica::from_snapshot(&snapshot)?;
@@ -93,6 +95,7 @@ impl Client {
Ok(Client {
session,
id: client_id,
secret,
view: confirmed.clone(),
confirmed,
seq,
@@ -415,9 +418,10 @@ impl Client {
ClientMsg::Hello(Hello { resume: Some(self.resume()), ..hello })
}
/// What a reconnect resumes with: this client's session, id and last sequence number.
/// What a reconnect resumes with: this client's session, id, last sequence number and the
/// secret its id was given with.
pub fn resume(&self) -> Resume {
Resume { session: self.session, client_id: self.id, seq: self.seq }
Resume { session: self.session, client_id: self.id, seq: self.seq, secret: self.secret }
}
/// Handle a message from the server. Returns messages to send: batches that were waiting for
@@ -458,10 +462,11 @@ impl Client {
Ok(Vec::new())
}
ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())),
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => {
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret } => {
if session != self.session || client_id != self.id {
return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access });
return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret });
}
self.secret = secret;
self.access = access;
self.rejoin(seq, catch_up, &processed, peers, recompute)
}
+13 -1
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");
@@ -38,7 +48,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
name: "Ada".into(),
color: "#FF8800".into(),
client_version: "0.2.0".into(),
resume: Some(Resume { session: 77, client_id: 4, seq: 10 }),
resume: Some(Resume { session: 77, client_id: 4, seq: 10, secret: 0x5EC2E7 }),
}),
moved_layer(),
cursor(),
@@ -102,6 +112,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
processed: Processed::default(),
peers: vec![peer.clone()],
access: Access::Edit,
secret: 0x5EC2E7,
},
ServerMsg::Welcome {
session: 77,
@@ -111,6 +122,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
processed: Processed { last_batch: 7, refused: vec![3, 6] },
peers: vec![],
access: Access::View,
secret: 1,
},
ServerMsg::Changes(Accepted { seq: 11, author: 4, batch_id: 2, changes: vec![set.clone(), Change::Delete { id: NodeId(5) }] }),
ServerMsg::Reject { batch_id: 3, reason: "no".into(), fixes: vec![set] },
+16 -7
View File
@@ -132,18 +132,15 @@ impl<T: Keyed> List<T> {
let keys: Vec<T::Key> = value.read("order")?;
let list = (self.get_mut)(doc);
let mut slots: Vec<Option<T>> = std::mem::take(list).into_iter().map(Some).collect();
let mut at: BTreeMap<T::Key, Vec<usize>> = BTreeMap::new();
let mut at: BTreeMap<T::Key, std::collections::VecDeque<usize>> = BTreeMap::new();
for (i, e) in slots.iter().enumerate() {
if let Some(e) = e {
at.entry(e.key()).or_default().push(i);
at.entry(e.key()).or_default().push_back(i);
}
}
for k in keys {
let Some(idx) = at.get_mut(&k) else { continue };
if idx.is_empty() {
continue;
}
let i = idx.remove(0);
let Some(i) = idx.pop_front() else { continue };
if let Some(e) = slots.get_mut(i).and_then(Option::take) {
list.push(e);
}
@@ -213,6 +210,9 @@ doc_scalars! {
ImagePresets => image_presets,
AnnotationCategories => annotation_categories,
Libraries => libraries,
Kind => kind,
Timer => timer,
Vote => vote,
}
/// Keyed maps (no order): changed and added entries are put, missing ones removed.
@@ -252,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
@@ -288,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),
@@ -300,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())),
}
@@ -336,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()
}
@@ -349,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),
}
}
+2
View File
@@ -123,6 +123,8 @@ impl Recompute for NoRecompute {
fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet<NodeId>) {}
}
#[cfg(test)]
mod board_sync_tests;
#[cfg(test)]
mod client_tests;
#[cfg(test)]
+8 -5
View File
@@ -6,11 +6,12 @@
//! values, or a layer deleted on one side while the other side edited it or something inside it
//! (including adding layers to it or moving layers into or out of it). The layer lists whose items
//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype
//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides
//! changing different items of the list (or one adding items at its end) is no conflict, only the
//! same item changed both ways or the list shortened on a side while the other changed it. Nothing
//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the
//! other side's changes to it are left out. Everything else from both sides is kept.
//! interactions, accessibility annotations, Dev Mode resources and measurements) merge item by
//! item: both sides changing different items of the list (or one adding items at its end) is no
//! conflict, only the same item changed both ways or the list shortened on a side while the other
//! changed it. Nothing that conflicts is merged until it is resolved: each conflict is resolved for
//! one side, and the other side's changes to it are left out. Everything else from both sides is
//! kept.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
@@ -91,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}"),
},
}
}
@@ -176,6 +178,7 @@ const PER_ITEM: &[Prop] = &[
// Not `CodeLinks`: there is one per language, and lining them up by position could leave two
// for one language (the second hidden), so they merge whole.
Prop::DevResources,
Prop::Measurements,
];
/// Both sides' writes `b` and `m` of list property `key` merged item by item against the base
+15 -6
View File
@@ -94,6 +94,10 @@ pub struct Resume {
pub client_id: u32,
/// The last sequence number it applied.
pub seq: u64,
/// The secret the server gave with this client id ([`ServerMsg::Welcome`]'s `secret`): only
/// the client the id was given to can resume it (0: none).
#[serde(default)]
pub secret: u64,
}
/// Someone in the session.
@@ -635,7 +639,8 @@ pub enum ServerMsg {
/// Joined: the session's identity (a server that restarted without its saved state is a new
/// session), this connection's client id, the current sequence number, the document (or the
/// batches missed since a reconnecting client's last sequence number), what the server did
/// with this client's batches so far, who else is here and what this client may do.
/// with this client's batches so far, who else is here, what this client may do and the
/// secret it resumes its id with ([`Resume::secret`]; only this client is told).
Welcome {
session: u64,
client_id: u32,
@@ -644,6 +649,8 @@ pub enum ServerMsg {
processed: Processed,
peers: Vec<Peer>,
access: Access,
#[serde(default)]
secret: u64,
},
/// Accepted changes, in order, to everyone (the author's own come back as confirmation).
Changes(Accepted),
@@ -732,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
+19
View File
@@ -253,3 +253,22 @@ fn random_drops_and_reconnects_converge() {
}
assert!(snapshots > 0, "no reconnect needed a snapshot");
}
#[test]
fn resume_needs_the_issued_secret() {
let mut s = Server::new(base(), "tok", Limits { max_peers: 2, ..Limits::default() });
let mut host = join(&mut s);
let guest = join(&mut s);
// A participant who knows the (public) session and the host's id can't take that id over,
// whether the session is full or not, and with view-only access or not.
let forged = message::Resume { client_id: host.id(), ..guest.resume() };
assert_eq!(forged.session, host.resume().session);
let refused = s.join_as(Hello { resume: Some(forged), ..hello() }, message::Access::View);
assert!(matches!(refused, Err(ServerMsg::Refused { .. })), "a forged resume of a live id is refused: {refused:?}");
s.leave(guest.id());
let (id, _) = s.join_as(Hello { resume: Some(forged), ..hello() }, message::Access::View).unwrap();
assert_ne!(id, host.id(), "a forged resume joins as new");
// The real client still comes back as itself.
let resend = come_back(&mut s, &mut host);
assert!(resend.is_empty());
}
+27 -7
View File
@@ -7,7 +7,7 @@ use figcraft_doc::{Document, NodeId};
use serde::{Deserialize, Serialize};
use crate::change::{Change, Entry, Prop, Target};
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg, Signal, reads_signals};
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_signals};
use crate::replica::{Mode, Replica, create_change};
use crate::{Error, Limits, docprops, same_secret};
@@ -61,6 +61,9 @@ pub struct Server {
/// Every client id handed out (a reconnecting client keeps its id) and what was done with
/// its batches.
issued: BTreeMap<u32, Processed>,
/// The secret each client id resumes with (sent only in that client's welcome). Not saved:
/// after a restart, the first to come back for an id that isn't connected sets it.
secrets: BTreeMap<u32, u64>,
/// The latest accepted batches, oldest first (a reconnecting client gets what it missed).
log: VecDeque<Accepted>,
/// Changes in `log`, and about how many bytes it holds ([`weight`]).
@@ -116,6 +119,7 @@ impl Server {
deleted: BTreeSet::new(),
removed: BTreeSet::new(),
issued: BTreeMap::new(),
secrets: BTreeMap::new(),
log: VecDeque::new(),
log_changes: 0,
log_bytes: 0,
@@ -219,6 +223,7 @@ impl Server {
&& let Some(old) = self.issued.keys().copied().find(|k| !self.peers.contains_key(k))
{
self.issued.remove(&old);
self.secrets.remove(&old);
}
}
@@ -266,7 +271,7 @@ impl Server {
if !same_secret(&hello.token, &self.token) {
return Err(refuse("wrong session token"));
}
let resume = hello.resume.filter(|r| r.session == self.session && self.issued.contains_key(&r.client_id));
let resume = hello.resume.filter(|r| r.session == self.session && self.issued.contains_key(&r.client_id) && self.may_resume(r));
if self.peers.len() >= self.limits.max_peers && resume.is_none_or(|r| !self.peers.contains_key(&r.client_id)) {
return Err(refuse("the session is full"));
}
@@ -282,15 +287,17 @@ impl Server {
Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()),
_ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?),
};
let id = match resume {
Some(r) => r.client_id,
let (id, secret) = match resume {
Some(r) => (r.client_id, *self.secrets.entry(r.client_id).or_insert(r.secret)),
None => {
let id = self.next_client;
self.next_client += 1;
self.issued.insert(id, Processed::default());
let secret = new_session_id().max(1);
self.secrets.insert(id, secret);
self.forget_oldest();
self.note(Journal::Issued { client: id });
id
(id, secret)
}
};
let processed = self.issued.get(&id).cloned().unwrap_or_default();
@@ -298,7 +305,7 @@ impl Server {
let signals = reads_signals(&hello.client_version);
let peer = Peer { client_id: id, name: hello.name, color: hello.color };
let others = self.peers.values().filter(|p| p.client_id != id).cloned().collect();
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access };
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access, secret };
self.peers.insert(id, peer.clone());
if access == Access::View {
self.viewers.insert(id);
@@ -318,6 +325,16 @@ impl Server {
Ok((id, outs))
}
/// Whether `r` may take its client id back: it carries the secret that id was given with, or
/// (after a restart, which forgets secrets) a secret for an id nobody is connected as.
fn may_resume(&self, r: &Resume) -> bool {
r.secret != 0
&& match self.secrets.get(&r.client_id) {
Some(s) => *s == r.secret,
None => !self.peers.contains_key(&r.client_id),
}
}
/// Client `id`'s connection closed.
pub fn leave(&mut self, id: u32) -> Vec<Outgoing> {
self.presence.remove(&id);
@@ -541,11 +558,14 @@ impl Server {
nodes.extend(t.write(self.state.value(&t)?.as_ref()));
}
}
let mut restored = std::collections::BTreeSet::new();
for id in gone {
match doc.node(id) {
Some(_) => {
for d in doc.subtree(id) {
if let Some(n) = doc.node(d) {
if restored.insert(d)
&& let Some(n) = doc.node(d)
{
nodes.push(create_change(n, self.state.key(d))?);
}
}
+1 -1
View File
@@ -103,7 +103,7 @@ impl BlendMode {
/// The mode named by its label or CSS keyword, ignoring case, spaces and dashes. Labels come
/// first: "normal" is Normal, not pass-through (whose CSS keyword is also `normal`).
pub fn from_name(s: &str) -> Option<Self> {
let key: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
let key: String = s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase();
Self::ALL
.into_iter()
.find(|m| m.label().replace(' ', "").to_ascii_lowercase() == key)
+38 -7
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).
@@ -355,7 +363,7 @@ impl ImageFilters {
}
/// The built-in adjustment presets: `(id, label, adjustments)`. "Reset" is the identity.
pub const PRESETS: [(&'static str, &'static str, ImageFilters); 7] = [
pub const PRESETS: [(&str, &str, ImageFilters); 7] = [
("vivid", "Vivid", ImageFilters { contrast: 0.2, saturation: 0.45, highlights: -0.1, shadows: 0.1, ..ImageFilters::NONE }),
("muted", "Muted", ImageFilters { contrast: -0.15, saturation: -0.45, ..ImageFilters::NONE }),
("warm", "Warm", ImageFilters { temperature: 0.5, tint: 0.1, saturation: 0.1, ..ImageFilters::NONE }),
@@ -549,7 +557,7 @@ pub enum PaintKind {
}
/// One fill or stroke paint.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Paint {
#[serde(flatten)]
pub kind: PaintKind,
@@ -559,19 +567,41 @@ pub struct Paint {
pub visible: bool,
#[serde(default)]
pub blend: BlendMode,
/// The part of the opacity that is the alpha of a typed solid colour (see [`Paint::solid`]),
/// the rest being an opacity set on its own: a colour variable bound to the paint replaces
/// this part only. 1: all of it is the paint's own.
#[serde(default = "one", rename = "colorAlpha", skip_serializing_if = "is_one")]
pub color_alpha: f32,
}
/// Paints compare by what they paint; where the opacity came from doesn't matter.
impl PartialEq for Paint {
fn eq(&self, other: &Self) -> bool {
self.kind == other.kind && self.opacity == other.opacity && self.visible == other.visible && self.blend == other.blend
}
}
fn yes() -> bool {
true
}
fn is_one(x: &f32) -> bool {
*x == 1.0
}
impl Paint {
pub fn solid(color: Color) -> Self {
Self { kind: PaintKind::Solid { color: color.with_alpha(1.0) }, opacity: color.a, visible: true, blend: BlendMode::Normal }
Self {
kind: PaintKind::Solid { color: color.with_alpha(1.0) },
opacity: color.a,
visible: true,
blend: BlendMode::Normal,
color_alpha: color.a,
}
}
pub fn gradient(g: Gradient) -> Self {
Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal }
Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }
}
pub fn image(hash: impl Into<String>, mode: ImageScaleMode) -> Self {
@@ -587,12 +617,13 @@ impl Paint {
opacity: 1.0,
visible: true,
blend: BlendMode::Normal,
color_alpha: 1.0,
}
}
/// Layer `source` (a node id) tiled at its own size.
pub fn pattern(source: u64) -> Self {
Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal }
Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }
}
/// The solid colour (with the paint opacity folded into alpha), if solid.
+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());
}
}
+452
View File
@@ -0,0 +1,452 @@
//! Boards: a file kind for whiteboarding, and the small piece of metadata board objects carry.
//!
//! Board objects are ordinary nodes (frames, vectors, text) with a [`BoardItem`] on them, so
//! rendering, hit testing, undo, saving, export, copy and paste and live sessions treat them like
//! any other layer. The metadata says what the object is for: which text a sticky note fits, what
//! a connector joins, who placed a stamp.
use figcraft_color::Color;
use figcraft_geom::kurbo::{Ellipse, RoundedRect};
use figcraft_geom::{BezPath, Point, Rect, Shape, Vec2};
use serde::{Deserialize, Serialize};
use crate::NodeId;
/// What a file is for: designing screens, or a board to think together on.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum FileKind {
#[default]
Design,
Board,
}
impl FileKind {
pub fn is_design(&self) -> bool {
*self == FileKind::Design
}
pub fn id(self) -> &'static str {
match self {
FileKind::Design => "design",
FileKind::Board => "board",
}
}
}
/// What a board object is.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum BoardItem {
/// A sticky note: a frame whose first text layer is the note (its size shrinks as the note
/// grows) and, below it, who wrote it.
Sticky {
#[serde(default, skip_serializing_if = "String::is_empty")]
author: String,
},
/// A shape with text: a frame holding the outline (a vector) and a centred text.
Shape { shape: BoardShape },
/// A line between two objects or points (a vector, re-routed when they move).
Connector(Connector),
/// The label in the middle of connector `connector` (a frame holding its text).
Label { connector: NodeId },
/// A stamp, and who placed it.
Stamp {
stamp: Stamp,
#[serde(default, skip_serializing_if = "String::is_empty")]
author: String,
},
/// 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 { .. } | BoardItem::Table)
}
/// Objects whose first text layer is their text (a double-click or Enter edits it).
pub fn has_text(&self) -> bool {
matches!(self, BoardItem::Sticky { .. } | BoardItem::Shape { .. } | BoardItem::Label { .. })
}
/// Who made it (stickies and stamps; empty when unknown).
pub fn author(&self) -> &str {
match self {
BoardItem::Sticky { author } | BoardItem::Stamp { author, .. } => author,
_ => "",
}
}
/// Point the layers this object refers to (a connector's ends, a label's connector) at their
/// copies in `map`; the others stay.
pub fn remap(&mut self, map: &std::collections::BTreeMap<NodeId, NodeId>) {
match self {
BoardItem::Connector(c) => {
for end in [&mut c.from, &mut c.to] {
if let End::Node { node, .. } = end
&& let Some(m) = map.get(node)
{
*node = *m;
}
}
}
BoardItem::Label { connector } => {
if let Some(m) = map.get(connector) {
*connector = *m;
}
}
_ => {}
}
}
pub fn kind_id(&self) -> &'static str {
match self {
BoardItem::Sticky { .. } => "sticky",
BoardItem::Shape { .. } => "shape",
BoardItem::Connector(_) => "connector",
BoardItem::Label { .. } => "label",
BoardItem::Stamp { .. } => "stamp",
BoardItem::Ink => "ink",
BoardItem::Table => "table",
}
}
}
/// The colours stickies, shapes and connectors come in: soft tints that dark text reads on.
pub const BOARD_COLORS: [(&str, Color); 8] = [
("yellow", Color::from_rgb8_const(0xFF, 0xE7, 0x8A)),
("orange", Color::from_rgb8_const(0xFF, 0xC9, 0x94)),
("red", Color::from_rgb8_const(0xFF, 0xAE, 0xA6)),
("pink", Color::from_rgb8_const(0xF7, 0xB9, 0xDA)),
("violet", Color::from_rgb8_const(0xD4, 0xC2, 0xFC)),
("blue", Color::from_rgb8_const(0xAC, 0xD3, 0xFA)),
("green", Color::from_rgb8_const(0xC2, 0xE8, 0xA6)),
("gray", Color::from_rgb8_const(0xE3, 0xE1, 0xDD)),
];
/// A board colour by name ("yellow") or as hex ("#FFE78A").
pub fn board_color(s: &str) -> Option<Color> {
let s = s.trim();
BOARD_COLORS.iter().find(|(n, _)| n.eq_ignore_ascii_case(s)).map(|(_, c)| *c).or_else(|| Color::from_hex(s))
}
/// The page colour boards start with: a warm light grey the colours stand out on.
pub const BOARD_PAGE: Color = Color::from_rgb8_const(0xF4, 0xF3, 0xF0);
/// The outlines shapes with text come in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum BoardShape {
#[default]
Rectangle,
RoundedRectangle,
Ellipse,
Diamond,
Triangle,
SpeechBubble,
Chevron,
}
impl BoardShape {
pub const ALL: [BoardShape; 7] = [
BoardShape::Rectangle,
BoardShape::RoundedRectangle,
BoardShape::Ellipse,
BoardShape::Diamond,
BoardShape::Triangle,
BoardShape::SpeechBubble,
BoardShape::Chevron,
];
pub fn id(self) -> &'static str {
match self {
BoardShape::Rectangle => "rectangle",
BoardShape::RoundedRectangle => "roundedRectangle",
BoardShape::Ellipse => "ellipse",
BoardShape::Diamond => "diamond",
BoardShape::Triangle => "triangle",
BoardShape::SpeechBubble => "speechBubble",
BoardShape::Chevron => "chevron",
}
}
pub fn from_id(s: &str) -> Option<BoardShape> {
BoardShape::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
BoardShape::Rectangle => "Rectangle",
BoardShape::RoundedRectangle => "Rounded rectangle",
BoardShape::Ellipse => "Ellipse",
BoardShape::Diamond => "Diamond",
BoardShape::Triangle => "Triangle",
BoardShape::SpeechBubble => "Speech bubble",
BoardShape::Chevron => "Chevron",
}
}
/// The outline in a `w` × `h` box at the origin.
pub fn path(self, w: f64, h: f64) -> BezPath {
let poly = |pts: &[(f64, f64)]| {
let mut p = BezPath::new();
for (i, (x, y)) in pts.iter().enumerate() {
let q = Point::new(x * w, y * h);
if i == 0 { p.move_to(q) } else { p.line_to(q) }
}
p.close_path();
p
};
let r = w.min(h);
match self {
BoardShape::Rectangle => Rect::new(0.0, 0.0, w, h).to_path(0.1),
BoardShape::RoundedRectangle => RoundedRect::new(0.0, 0.0, w, h, r * 0.16).to_path(0.1),
BoardShape::Ellipse => Ellipse::from_rect(Rect::new(0.0, 0.0, w, h)).to_path(0.1),
BoardShape::Diamond => poly(&[(0.5, 0.0), (1.0, 0.5), (0.5, 1.0), (0.0, 0.5)]),
BoardShape::Triangle => poly(&[(0.5, 0.0), (1.0, 1.0), (0.0, 1.0)]),
BoardShape::Chevron => poly(&[(0.0, 0.0), (0.72, 0.0), (1.0, 0.5), (0.72, 1.0), (0.0, 1.0), (0.28, 0.5)]),
BoardShape::SpeechBubble => {
// A rounded body over the bottom fifth, with a tail down to the left.
let (b, c) = (h * 0.8, r * 0.14);
let mut p = BezPath::new();
p.move_to((c, 0.0));
p.line_to((w - c, 0.0));
p.quad_to((w, 0.0), (w, c));
p.line_to((w, b - c));
p.quad_to((w, b), (w - c, b));
p.line_to((w * 0.42, b));
p.line_to((w * 0.18, h));
p.line_to((w * 0.24, b));
p.line_to((c, b));
p.quad_to((0.0, b), (0.0, b - c));
p.line_to((0.0, c));
p.quad_to((0.0, 0.0), (c, 0.0));
p.close_path();
p
}
}
}
/// Where the text goes, as insets of the box (top, right, bottom, left, in fractions of its
/// size): inside the outline's widest part.
pub fn text_insets(self) -> [f64; 4] {
match self {
BoardShape::Rectangle | BoardShape::RoundedRectangle => [0.08, 0.08, 0.08, 0.08],
BoardShape::Ellipse => [0.16, 0.16, 0.16, 0.16],
BoardShape::Diamond => [0.25, 0.25, 0.25, 0.25],
BoardShape::Triangle => [0.45, 0.22, 0.06, 0.22],
BoardShape::SpeechBubble => [0.08, 0.08, 0.28, 0.08],
BoardShape::Chevron => [0.12, 0.3, 0.12, 0.3],
}
}
}
/// The stamps: round badges with a mark.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Stamp {
#[default]
ThumbsUp,
Heart,
PlusOne,
Star,
Question,
Check,
}
/// One part of a stamp's mark: SVG path data on the badge's 40-unit box, filled or stroked.
pub struct MarkPart {
pub d: &'static str,
pub filled: bool,
}
/// A stamp badge's size.
pub const STAMP_SIZE: f64 = 40.0;
impl Stamp {
pub const ALL: [Stamp; 6] = [Stamp::ThumbsUp, Stamp::Heart, Stamp::PlusOne, Stamp::Star, Stamp::Question, Stamp::Check];
pub fn id(self) -> &'static str {
match self {
Stamp::ThumbsUp => "thumbsUp",
Stamp::Heart => "heart",
Stamp::PlusOne => "plusOne",
Stamp::Star => "star",
Stamp::Question => "question",
Stamp::Check => "check",
}
}
pub fn from_id(s: &str) -> Option<Stamp> {
Stamp::ALL.into_iter().find(|b| b.id().eq_ignore_ascii_case(s))
}
pub fn label(self) -> &'static str {
match self {
Stamp::ThumbsUp => "Thumbs up",
Stamp::Heart => "Heart",
Stamp::PlusOne => "Plus one",
Stamp::Star => "Star",
Stamp::Question => "Question",
Stamp::Check => "Check",
}
}
/// The badge's colour (the mark is white on it).
pub fn color(self) -> Color {
match self {
Stamp::ThumbsUp => Color::from_rgb8_const(0x3B, 0x82, 0xE8),
Stamp::Heart => Color::from_rgb8_const(0xE5, 0x48, 0x5F),
Stamp::PlusOne => Color::from_rgb8_const(0x2E, 0xA0, 0x63),
Stamp::Star => Color::from_rgb8_const(0xEE, 0x9B, 0x1A),
Stamp::Question => Color::from_rgb8_const(0x84, 0x5E, 0xD8),
Stamp::Check => Color::from_rgb8_const(0x14, 0x96, 0x8C),
}
}
/// The mark, drawn for FigCraft from lines, arcs and simple outlines.
pub fn mark(self) -> &'static [MarkPart] {
match self {
// A cuff and a raised hand with its thumb up.
Stamp::ThumbsUp => &[
MarkPart { d: "M10.5 19 H15 V29 H10.5 Z", filled: true },
MarkPart {
d: "M16.5 19 L20.6 11.4 C21.6 9.8 24.6 10.6 24.2 13 L23.4 17 H28.2 C29.8 17 30.9 18.5 30.4 20 L28.4 27.3 C28 28.4 27.1 29 25.9 29 H16.5 Z",
filled: true,
},
],
// Two round lobes meeting in a point.
Stamp::Heart => &[MarkPart {
d: "M20 29.5 C12.5 24.5 9.5 20.6 10.4 16.4 C11.3 12.2 16.8 11.2 20 15.4 C23.2 11.2 28.7 12.2 29.6 16.4 C30.5 20.6 27.5 24.5 20 29.5 Z",
filled: true,
}],
Stamp::PlusOne => &[MarkPart { d: "M10 20 H20 M15 15 V25", filled: false }, MarkPart { d: "M24.5 15 L27.5 12.5 V27.5", filled: false }],
Stamp::Star => &[MarkPart {
d: "M20 10.5 L22.8 16.7 L29.5 17.4 L24.5 21.9 L25.9 28.6 L20 25.2 L14.1 28.6 L15.5 21.9 L10.5 17.4 L17.2 16.7 Z",
filled: true,
}],
Stamp::Question => &[
MarkPart { d: "M15.6 16.2 C15.6 10.6 24.4 10.6 24.4 16 C24.4 19.6 20 19.9 20 23.6", filled: false },
MarkPart { d: "M18.2 28 A1.8 1.8 0 1 0 21.8 28 A1.8 1.8 0 1 0 18.2 28 Z", filled: true },
],
Stamp::Check => &[MarkPart { d: "M12.5 20.5 L18 26 L28 14.5", filled: false }],
}
}
}
/// How a connector runs between its ends.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Routing {
#[default]
Straight,
/// 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 {
pub fn id(self) -> &'static str {
match self {
Routing::Straight => "straight",
Routing::Elbow => "elbow",
Routing::Curved => "curved",
}
}
pub fn from_id(s: &str) -> Option<Routing> {
[Routing::Straight, Routing::Elbow, Routing::Curved].into_iter().find(|r| r.id().eq_ignore_ascii_case(s))
}
}
/// The side of an object a connector end is attached to (at its middle).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Side {
/// The side facing the other end.
#[default]
Auto,
Top,
Right,
Bottom,
Left,
}
impl Side {
pub fn from_id(s: &str) -> Option<Side> {
Some(match s.to_ascii_lowercase().as_str() {
"auto" => Side::Auto,
"top" => Side::Top,
"right" => Side::Right,
"bottom" => Side::Bottom,
"left" => Side::Left,
_ => return None,
})
}
/// The side's middle on `b`.
pub fn point(self, b: Rect) -> Point {
let c = b.center();
match self {
Side::Top => Point::new(c.x, b.y0),
Side::Right => Point::new(b.x1, c.y),
Side::Bottom => Point::new(c.x, b.y1),
Side::Left => Point::new(b.x0, c.y),
Side::Auto => c,
}
}
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.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum End {
Node {
node: NodeId,
#[serde(default)]
side: Side,
},
Point {
x: f64,
y: f64,
},
}
impl End {
pub fn node(&self) -> Option<NodeId> {
match self {
End::Node { node, .. } => Some(*node),
End::Point { .. } => None,
}
}
}
/// What a connector joins and how it runs (its arrowheads are its stroke's caps).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Connector {
pub from: End,
pub to: End,
#[serde(default)]
pub routing: Routing,
}
+3
View File
@@ -35,6 +35,9 @@ impl Effect {
impl TextStyle {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
self.fills.iter_mut().flatten().for_each(|p| p.for_each_color_mut(f));
if let Some(c) = self.decoration_look.as_deref_mut().and_then(|l| l.color.as_mut()) {
f(c);
}
}
}
+8
View File
@@ -134,3 +134,11 @@ fn profile_and_presets_serialize_only_when_set() {
assert_eq!(back.color_profile, ColorProfile::DisplayP3);
assert_eq!(back.image_presets, d.image_presets);
}
#[test]
fn text_decoration_colour_is_visited() {
let mut s = TextStyle::default();
s.set_look(|l| l.color = Some(RED));
s.for_each_color_mut(&mut |c| *c = Color::BLACK);
assert_eq!(s.look().color, Some(Color::BLACK), "decoration colour not visited");
}
+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 -4
View File
@@ -17,8 +17,10 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
| NodeKind::Section { .. } => figcraft_geom::rounded_rect(w, h, c.radii, c.smoothing),
NodeKind::Slice => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0),
NodeKind::Ellipse { start, sweep, inner } => figcraft_geom::ellipse(w, h, *start, *sweep, *inner),
NodeKind::Polygon { count } => figcraft_geom::rounded_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0]),
NodeKind::Star { count, inner_ratio } => figcraft_geom::rounded_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0]),
NodeKind::Polygon { count } => figcraft_geom::smoothed_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0], c.smoothing),
NodeKind::Star { count, inner_ratio } => {
figcraft_geom::smoothed_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0], c.smoothing)
}
NodeKind::Line => {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
@@ -72,7 +74,16 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => {
n.local_rect().inflate(tol_l, tol_l).contains(lp)
}
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && path.winding(lp) != 0)),
_ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))),
}
}
/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it).
fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool {
match &n.kind {
NodeKind::Vector { network } => network.fill_contains(lp),
_ => path.winding(lp) != 0,
}
}
@@ -117,7 +128,7 @@ pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolR
/// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask
/// below it, a hidden one masks nothing) doesn't cover the point, so nothing of `c` shows there.
fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool {
pub fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool {
if !doc.has_masks() {
return false;
}
@@ -255,6 +266,18 @@ pub fn select_target(doc: &Document, leaf: NodeId, context: &[NodeId], deep: boo
if deep {
return leaf;
}
let target = level_target(doc, leaf, context);
// A board object is picked whole (a sticky, not its text), unless a layer inside it is selected.
let mut chain = doc.ancestors(target);
chain.reverse();
chain.push(target);
chain
.into_iter()
.find(|c| doc.node(*c).is_some_and(Node::is_board_atom) && !context.iter().any(|s| s != c && doc.is_ancestor(*c, *s)))
.unwrap_or(target)
}
fn level_target(doc: &Document, leaf: NodeId, context: &[NodeId]) -> NodeId {
// Chain from the page's child down to the leaf.
let mut chain: Vec<NodeId> = doc.ancestors(leaf);
chain.reverse();
+30
View File
@@ -71,8 +71,38 @@ impl Document {
if let (Some(now), Some(r)) = (self.node(root), d.nodes.get_mut(&root)) {
Arc::make_mut(r).parent = now.parent;
}
// A damaged file can give a historical layer itself (or an ancestor) as a child; a copy
// with a cycle under the root would send every walk of it round forever.
if !d.is_tree_under(root) {
return None;
}
d.dev_snapshots.clear();
d.dirty = crate::Dirty::default();
Some(d)
}
/// Whether every layer under `root` is reached once (no layer is its own descendant or listed
/// by two parents), walking iteratively and stopping at the first repeat.
fn is_tree_under(&self, root: NodeId) -> bool {
let mut seen = BTreeSet::new();
let mut stack = vec![root];
while let Some(id) = stack.pop() {
if !seen.insert(id) {
return false;
}
let Some(n) = self.nodes.get(&id) else {
return false;
};
for &c in &n.children {
let Some(child) = self.nodes.get(&c) else {
return false;
};
if child.parent != Some(id) {
return false;
}
stack.push(c);
}
}
true
}
}
+51
View File
@@ -34,3 +34,54 @@ fn a_snapshot_shows_the_layer_as_it_was() {
doc.dev_snapshots.clear();
assert!(serde_json::to_value(&doc).unwrap().get("dev_snapshots").is_none());
}
#[test]
fn a_snapshot_with_a_layer_inside_itself_is_refused() {
let mut doc = Document::new("d");
let page = doc.first_page().unwrap();
let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap();
let mut snap = doc.snapshot_subtree(f).unwrap();
// A damaged file: the historical frame lists itself as a child.
let mut bad = (*snap.nodes[0]).clone();
bad.children.push(f);
snap.nodes[0] = Arc::new(bad);
doc.dev_snapshots.insert(f, Arc::new(snap));
assert!(doc.with_dev_snapshot(f).is_none());
}
#[test]
fn a_snapshot_with_missing_or_nonreciprocal_child_is_refused() {
use crate::NodeId;
let mut doc = Document::new("d");
let page = doc.first_page().unwrap();
let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap();
let _c1 = doc.create(f, NodeKind::Rectangle, "R1", 0.0, 0.0, 10.0, 10.0).unwrap();
let snap = doc.snapshot_subtree(f).unwrap();
let mut missing = snap.clone();
let mut bad_root = (*missing.nodes[0]).clone();
bad_root.children.push(NodeId(99999));
missing.nodes[0] = Arc::new(bad_root);
doc.dev_snapshots.insert(f, Arc::new(missing));
assert!(doc.with_dev_snapshot(f).is_none());
let mut nonrecip = snap.clone();
let mut bad_child = (*nonrecip.nodes[1]).clone();
bad_child.parent = Some(NodeId(88888));
nonrecip.nodes[1] = Arc::new(bad_child);
doc.dev_snapshots.insert(f, Arc::new(nonrecip));
assert!(doc.with_dev_snapshot(f).is_none());
}
#[test]
fn validate_is_linear_in_a_wide_parent() {
let mut doc = Document::new("d");
let page = doc.first_page().unwrap();
for i in 0..5_000 {
doc.create(page, NodeKind::Rectangle, "R", f64::from(i), 0.0, 1.0, 1.0).unwrap();
}
assert!(doc.validate().is_empty());
let kid = doc.node(page).unwrap().children[0];
doc.get_mut(page).unwrap().children.retain(|c| *c != kid);
assert!(doc.validate().iter().any(|m| m.contains("not listed in parent")));
}
+82 -18
View File
@@ -8,8 +8,10 @@
//! live in higher crates.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod board;
pub mod colors;
pub mod comments;
pub mod facilitation;
pub mod geometry;
pub mod handoff;
pub mod ids;
@@ -23,8 +25,10 @@ pub mod vector;
pub mod vector_faces;
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::*;
@@ -42,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)]
@@ -157,6 +165,9 @@ pub struct ImageData {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Document {
pub name: String,
/// A design file or a board (files from before boards are design files).
#[serde(default, skip_serializing_if = "FileKind::is_design")]
pub kind: FileKind,
/// Page ids in order.
pub pages: Vec<NodeId>,
nodes: imbl::OrdMap<NodeId, Arc<Node>>,
@@ -199,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 {
@@ -221,6 +241,7 @@ impl Document {
pub fn without_pages(name: impl Into<String>) -> Self {
Self {
name: name.into(),
kind: FileKind::Design,
pages: Vec::new(),
nodes: imbl::OrdMap::new(),
dirty: Dirty::default(),
@@ -237,6 +258,9 @@ impl Document {
image_presets: Vec::new(),
annotation_categories: None,
dev_snapshots: BTreeMap::new(),
timer: None,
vote: None,
votes: BTreeMap::new(),
}
}
@@ -269,7 +293,16 @@ impl Document {
/// A fresh unused id (in this document's client space during a collaboration session; see
/// [`set_client_id`](Self::set_client_id)).
pub fn alloc_id(&mut self) -> NodeId {
self.next_id.alloc()
// A file's saved `next_id` can be stale (behind ids already in use): skip taken ids.
let mut id = self.next_id.alloc();
while self.nodes.contains_key(&id) {
let next = self.next_id.alloc();
if next == id {
break;
}
id = next;
}
id
}
/// The id [`alloc_id`](Self::alloc_id) returns next.
@@ -453,8 +486,9 @@ impl Document {
return true;
}
guard += 1;
if guard > 10_000 {
return false;
// More hops than nodes means the parent chain loops; fail closed so callers reject the move.
if guard > self.nodes.len() {
return true;
}
cur = self.node(c).and_then(|n| n.parent);
}
@@ -687,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,
@@ -700,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) {
@@ -821,6 +859,11 @@ impl Document {
return Err(DocError::NotContainer(parent));
}
let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?;
// Never overwrite an existing node, or another copy, with a copy.
let mut copied = std::collections::BTreeSet::new();
if let Some(taken) = nodes.iter().filter_map(|n| map.get(&n.id)).find(|i| self.nodes.contains_key(*i) || !copied.insert(**i)) {
return Err(DocError::Invalid(format!("node {taken} already exists")));
}
for n in nodes {
let Some(new_id) = map.get(&n.id) else { continue };
let mut c = n.clone();
@@ -835,6 +878,16 @@ impl Document {
{
*component = *m;
}
// A measurement copied with the layer it measures to measures to the copy; one to a
// layer left behind isn't copied.
c.measurements.retain_mut(|m| match m.to {
Some(t) => map.get(&t).map(|n| m.to = Some(*n)).is_some(),
None => true,
});
// A connector (or label) copied with what it joins joins the copies.
if let Some(b) = c.board.as_mut() {
b.remap(map);
}
// A pattern tiling a layer of the copy tiles the copy's layer.
for p in c.fills.iter_mut().chain(c.strokes.iter_mut()) {
if let figcraft_color::PaintKind::Pattern(pt) = &mut p.kind
@@ -984,39 +1037,50 @@ impl Document {
/// Check structural invariants (used by tests and after loading untrusted files).
pub fn validate(&self) -> Vec<String> {
// Hostile files can break an invariant per child; keep only the first few reports.
const MAX_ISSUES: usize = 100;
fn note(issues: &mut Vec<String>, msg: impl FnOnce() -> String) {
if issues.len() < MAX_ISSUES {
issues.push(msg());
}
}
let mut issues = Vec::new();
// Every (parent, child) edge and the page list, indexed once so each node's checks are
// lookups: a wide parent or a long page list doesn't make validation quadratic.
let edges: std::collections::BTreeSet<(NodeId, NodeId)> = self.nodes.iter().flat_map(|(p, n)| n.children.iter().map(|c| (*p, *c))).collect();
let page_ids: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
for (id, n) in self.nodes.iter() {
if n.id != *id {
issues.push(format!("node key {id} has id {}", n.id));
note(&mut issues, || format!("node key {id} has id {}", n.id));
}
let mut seen = std::collections::BTreeSet::new();
for c in &n.children {
if !seen.insert(*c) {
issues.push(format!("child {c} listed twice in {id}"));
note(&mut issues, || format!("child {c} listed twice in {id}"));
}
match self.node(*c) {
Some(cn) if cn.parent == Some(*id) => {}
Some(_) => issues.push(format!("child {c} of {id} has another parent")),
None => issues.push(format!("child {c} of {id} missing")),
Some(_) => note(&mut issues, || format!("child {c} of {id} has another parent")),
None => note(&mut issues, || format!("child {c} of {id} missing")),
}
}
if let Some(p) = n.parent
&& !self.node(p).is_some_and(|pn| pn.children.contains(id))
&& !(self.node(p).is_some() && edges.contains(&(p, *id)))
{
issues.push(format!("{id} not listed in parent {p}"));
note(&mut issues, || format!("{id} not listed in parent {p}"));
}
// The library store's root is a page that is never listed.
let listed = self.pages.contains(id) || (n.is_page() && self.libraries.store == Some(*id));
let listed = page_ids.contains(id) || (n.is_page() && self.libraries.store == Some(*id));
if n.is_page() != listed {
issues.push(format!("page list mismatch for {id}"));
note(&mut issues, || format!("page list mismatch for {id}"));
}
if n.is_page() && n.parent.is_some() {
issues.push(format!("page {id} has a parent"));
note(&mut issues, || format!("page {id} has a parent"));
}
}
let reachable = self.reachable();
for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) {
issues.push(format!("{id} is not under a page (cycle or detached)"));
note(&mut issues, || format!("{id} is not under a page (cycle or detached)"));
}
issues
}
+29
View File
@@ -237,6 +237,17 @@ pub struct DevResource {
pub name: String,
}
/// A measurement kept on the canvas for developers, stored on the layer it starts from: to another
/// layer (`to`), or across one of the layer's own sides (`side`). Dev Mode draws it from the layers'
/// current bounds, so it follows them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Measurement {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<NodeId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub side: Option<crate::Side>,
}
/// A kind of annotation (Development, Accessibility…): its stable id, name and the colour its
/// callouts are drawn in.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -386,6 +397,11 @@ impl NodeKind {
self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. })
}
/// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it).
pub fn has_corner_smoothing(&self) -> bool {
self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. })
}
pub fn text(&self) -> Option<&TextData> {
match self {
NodeKind::Text(t) => Some(t),
@@ -518,6 +534,12 @@ pub struct Node {
/// Dev resources: links for developers (docs, code, tickets…), in the order they were added.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dev_resources: Vec<DevResource>,
/// Dev Mode measurements that start from this layer, in the order they were added.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub measurements: Vec<Measurement>,
/// On a board: what this object is (a sticky note, a connector, a stamp…).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub board: Option<crate::BoardItem>,
}
pub(crate) fn yes() -> bool {
@@ -628,6 +650,8 @@ impl Node {
annotations: Vec::new(),
code_links: Vec::new(),
dev_resources: Vec::new(),
measurements: Vec::new(),
board: None,
}
}
@@ -653,6 +677,11 @@ impl Node {
matches!(self.kind, NodeKind::Page { .. })
}
/// A board object selected and moved as a whole (a sticky, a shape with text, a stamp…).
pub fn is_board_atom(&self) -> bool {
self.board.as_ref().is_some_and(crate::BoardItem::is_atom)
}
/// A section whose contents are hidden: only its name and outline show; its children are
/// skipped when rendering and hit testing.
pub fn hides_contents(&self) -> bool {
+2 -2
View File
@@ -475,7 +475,7 @@ impl WidthPreset {
}
pub fn from_id(s: &str) -> Option<WidthPreset> {
let s: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect();
let s: String = s.chars().filter(char::is_ascii_alphanumeric).collect();
WidthPreset::ALL
.into_iter()
.find(|p| p.id().eq_ignore_ascii_case(&s) || p.label().replace(' ', "").eq_ignore_ascii_case(&s))
@@ -795,7 +795,7 @@ fn yes() -> bool {
impl Effect {
/// Effect types in the order the Design panel lists them: (label, type name).
pub const TYPES: [(&'static str, &'static str); 7] = [
pub const TYPES: [(&str, &str); 7] = [
("Drop shadow", "dropShadow"),
("Inner shadow", "innerShadow"),
("Layer blur", "layerBlur"),
+1 -1
View File
@@ -1003,7 +1003,7 @@ impl PrototypeSettings {
];
/// The device whose screen is [`custom_size`](Self::custom_size) (no body is drawn around it).
pub const CUSTOM_DEVICE: &'static str = "Custom size";
pub const CUSTOM_DEVICE: &str = "Custom size";
/// A new custom size.
pub const CUSTOM_DEFAULT: [f64; 2] = [1280.0, 720.0];
/// The largest side a custom size may have (points).
+6 -1
View File
@@ -169,7 +169,12 @@ impl Expr {
})
.collect();
match (op, parts.as_slice()) {
(ExprOp::Not, [x]) => format!("not {x}"),
// `not done` reads as a variable named "not done" if there is one: then
// `not (done)`.
(ExprOp::Not, [x]) => {
let bare = format!("not {x}");
if x.starts_with('(') || Expr::parse(&bare, vars).is_ok_and(|e| e == *self) { bare } else { format!("not ({x})") }
}
(ExprOp::Negate, [x]) => format!("-{x}"),
_ => parts.join(&format!(" {} ", op.symbol())),
}
+8 -5
View File
@@ -459,16 +459,19 @@ impl Parser<'_> {
if self.at_keyword("mode") && self.next_after(4) == Some('(') {
return self.mode_of();
}
if let Some((id, n)) = self.variable_here() {
self.i += n;
return self.in_mode(id);
}
let var = self.variable_here();
// A bare `true`/`false` is the literal, also when a variable has that name (it reads
// as `{true}`); longer names that start with it ("true value") stay variables.
for (word, v) in [("true", true), ("false", false)] {
if self.at_keyword(word) {
if self.at_keyword(word) && var.as_ref().is_none_or(|(_, n)| *n <= word.len()) {
self.i += word.len();
return Ok(Expr::Value(VariableValue::Boolean(v)));
}
}
if let Some((id, n)) = var {
self.i += n;
return self.in_mode(id);
}
let word: String = self.s.iter().skip(self.i).take_while(|c| ident(**c)).collect();
if word.is_empty() { Err(self.err(format!("Unexpected {c}"))) } else { Err(self.err(format!("No variable `{word}`"))) }
}
+46
View File
@@ -195,3 +195,49 @@ fn names_that_look_like_literals_read_back_as_variables() {
assert_eq!(parse("1"), n(1.0));
assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into())));
}
#[test]
fn not_of_a_variable_reads_back_when_a_name_starts_with_not() {
use ExprOp::*;
let mut v = vars();
let b = v.variables.iter().find(|x| x.id == "b").unwrap().clone();
for (id, name) in [("done", "done"), ("nd", "not done")] {
let mut x = b.clone();
x.id = id.into();
x.name = name.into();
v.variables.push(x);
}
let not_done = Expr::op(Not, vec![Expr::var("done")]);
for e in [
not_done.clone(),
Expr::op(And, vec![Expr::var("b"), not_done.clone()]),
Expr::op(Equals, vec![not_done.clone(), Expr::Value(VariableValue::Boolean(true))]),
Expr::op(Not, vec![not_done.clone()]),
] {
let shown = e.display(&v);
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "shown as {shown:?}");
}
assert_eq!(Expr::op(Not, vec![Expr::var("b")]).display(&vars()), "not b-name", "plain names stay bare");
}
#[test]
fn literal_true_false_survive_variables_named_true_false() {
use ExprOp::*;
let lit = |x: bool| Expr::Value(VariableValue::Boolean(x));
for name in ["true", "false", "TRUE"] {
let mut v = vars();
if let Some(var) = v.variables.iter_mut().find(|x| x.id == "b") {
var.name = name.into();
}
for e in [
Expr::op(Equals, vec![Expr::var("b"), lit(true)]),
Expr::op(Equals, vec![Expr::var("b"), lit(false)]),
Expr::op(Not, vec![lit(false)]),
Expr::var("b"),
] {
let shown = e.display(&v);
assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}");
}
assert_eq!(Expr::parse(&format!("{{{name}}}"), &v), Ok(Expr::var("b")));
}
}
+180
View File
@@ -326,6 +326,20 @@ fn repair_drops_cycles_and_repeated_children() {
assert_eq!(bad.subtree(p), vec![p, r]);
}
#[test]
fn validate_caps_the_issue_list() {
let mut d = Document::new("x");
let p = page(&d);
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
let mut v = serde_json::to_value(&d).unwrap();
v["nodes"][f.to_string()]["children"] = serde_json::json!(vec![g; 5000]);
let bad: Document = serde_json::from_value(v).unwrap();
let issues = bad.validate();
assert!(!issues.is_empty());
assert!(issues.len() <= 100, "{}", issues.len());
}
#[test]
fn auto_layout_loads_files_without_grid_tracks() {
// Saved before grid tracks, cells and spans existed.
@@ -464,3 +478,169 @@ fn text_style_empty_range_links_and_unlinks_all() {
e.unlink_text_style(0, 0);
assert!(e.style.style_id.is_none());
}
#[test]
fn stale_next_id_never_overwrites_existing_nodes() {
let mut d = Document::new("x");
let p = page(&d);
let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
let a = d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 10.0, 10.0).unwrap();
let mut v = serde_json::to_value(&d).unwrap();
v["next_id"] = serde_json::json!(1);
let mut bad: Document = serde_json::from_value(v).unwrap();
let before: Vec<(NodeId, String, Option<NodeId>)> =
[p, f, a].iter().map(|i| (*i, bad.node(*i).unwrap().name.clone(), bad.node(*i).unwrap().parent)).collect();
let n = bad.node_count();
let copy = bad.duplicate_into(f, p, None).unwrap();
assert!(![p, f, a].contains(&copy));
assert_eq!(bad.node_count(), n + 2);
let copies = bad.duplicate_all_into(&[f], p).unwrap();
assert_eq!(bad.node_count(), n + 4);
assert!(copies.iter().all(|c| ![p, f, a, copy].contains(c)));
for (i, name, parent) in &before {
let node = bad.node(*i).unwrap();
assert_eq!((&node.name, node.parent), (name, *parent));
}
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
// A paste whose id map points at an existing node fails and changes nothing.
let nodes: Vec<Node> = bad.subtree(f).into_iter().filter_map(|i| bad.node(i).cloned()).collect();
let mut map: BTreeMap<NodeId, NodeId> = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect();
map.insert(a, p);
let snapshot = bad.clone();
assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err());
assert_eq!(bad, snapshot);
assert_eq!(bad.node(p).unwrap().name, snapshot.node(p).unwrap().name);
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
// So does one that maps two nodes to the same new id.
let mut map: BTreeMap<NodeId, NodeId> = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect();
map.insert(a, map[&f]);
assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err());
assert_eq!(bad, snapshot);
}
#[test]
fn hit_test_even_odd_vector_hole_misses() {
let ring = |rule| {
let sq = |o: f64, s: f64, base: usize| {
(
[(o, o), (o + s, o), (o + s, o + s), (o, o + s)].map(|(x, y)| Vertex::new(x, y)),
[Segment::line(base, base + 1), Segment::line(base + 1, base + 2), Segment::line(base + 2, base + 3), Segment::line(base + 3, base)],
)
};
let (v1, s1) = sq(0.0, 200.0, 0);
let (v2, s2) = sq(60.0, 80.0, 4);
VectorNetwork {
vertices: v1.into_iter().chain(v2).collect(),
segments: s1.into_iter().chain(s2).collect(),
regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }],
}
};
let mut d = Document::new("x");
let p = page(&d);
let eo = d.create(p, NodeKind::Vector { network: ring(WindingRule::EvenOdd) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap();
assert_eq!(d.hit_test(p, Point::new(20.0, 100.0), 0.0, None), Some(eo));
assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), None, "the hole is not filled");
let mut d = Document::new("x");
let p = page(&d);
let nz = d.create(p, NodeKind::Vector { network: ring(WindingRule::NonZero) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap();
assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), Some(nz));
}
#[test]
fn is_ancestor_holds_for_very_deep_chains() {
let mut d = Document::new("x");
let root = d.create(page(&d), NodeKind::Frame, "root", 0.0, 0.0, 1.0, 1.0).unwrap();
let mut leaf = root;
for _ in 0..10_005 {
leaf = d.create(leaf, NodeKind::Frame, "n", 0.0, 0.0, 1.0, 1.0).unwrap();
}
assert!(d.is_ancestor(root, leaf));
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)
// and a big rounded polygon (#F-333): each used to scan the whole network per step.
let mut p = figcraft_geom::kurbo::BezPath::new();
for i in 0..3_000 {
let x = f64::from(i) * 10.0;
p.move_to((x, 0.0));
p.line_to((x + 5.0, 0.0));
p.line_to((x + 5.0, 5.0));
p.line_to((x, 0.0));
p.close_path();
}
let net = VectorNetwork::from_path(&p);
assert_eq!(net.vertices.len(), 3 * 3_000);
assert_eq!(net.regions[0].loops.len(), 3_000);
assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len()));
assert_eq!(net.stroke_runs().len(), 3_000);
let pts: Vec<Point> = (0..20_000).map(|i| Point::new(f64::from(i), f64::from(i % 7))).collect();
let chain = VectorNetwork::from_points(&pts, false);
let runs = chain.stroke_runs();
assert_eq!(runs.len(), 1);
assert!(!runs[0].closed);
let ring: Vec<Point> = (0..4_000)
.map(|i| {
let t = f64::from(i) / 4_000.0 * std::f64::consts::TAU;
Point::new(1000.0 * t.cos(), 1000.0 * t.sin() * if i % 2 == 0 { 1.0 } else { 0.9 })
})
.collect();
let mut poly = VectorNetwork::from_points(&ring, true);
for v in &mut poly.vertices {
v.radius = 0.5;
}
let rounded = poly.rounded().unwrap();
assert!(rounded.regions[0].loops[0].len() > poly.segments.len(), "arcs are walked between the sides");
assert_eq!(rounded.regions[0].loops[0].len(), rounded.segments.len());
}
#[test]
fn two_segment_closed_loop_rounded_corners() {
let mut v0 = Vertex::new(0.0, 0.0);
v0.radius = 2.0;
let mut v1 = Vertex::new(10.0, 0.0);
v1.radius = 2.0;
let net = VectorNetwork {
vertices: vec![v0, v1],
segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }],
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }],
};
let r = net.rounded().unwrap();
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
let path = net.fill_path();
assert!(!path.elements().is_empty());
}
+28 -2
View File
@@ -270,10 +270,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,
@@ -596,7 +603,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),
@@ -800,6 +814,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.
+64 -30
View File
@@ -134,6 +134,16 @@ pub struct VectorNetwork {
pub regions: Vec<Region>,
}
/// Point the subpath's last segment (the only one ending at the fresh vertex `from`) at `to`,
/// so merging a duplicate endpoint doesn't rescan the segments of earlier subpaths.
fn redirect_last_segment(net: &mut VectorNetwork, loop_segs: &[usize], from: usize, to: usize) {
if let Some(s) = loop_segs.last().and_then(|i| net.segments.get_mut(*i))
&& s.b == from
{
s.b = to;
}
}
impl VectorNetwork {
/// A network from a polyline / polygon.
pub fn from_points(points: &[Point], closed: bool) -> Self {
@@ -169,11 +179,7 @@ impl VectorNetwork {
if a == f || loop_segs.len() < 2 || (pa - pf).hypot() > 1e-6 || a + 1 != net.vertices.len() {
return;
}
for s in net.segments.iter_mut() {
if s.b == a {
s.b = f;
}
}
redirect_last_segment(net, loop_segs, a, f);
net.vertices.pop();
all_loops.push(std::mem::take(loop_segs));
};
@@ -219,11 +225,7 @@ impl VectorNetwork {
let same = matches!((close_pt, first_pt), (Some(c), Some(f)) if (c - f).hypot() <= 1e-6);
if same && a != f {
// Merge the duplicate end vertex into the first one.
for s in net.segments.iter_mut() {
if s.b == a {
s.b = f;
}
}
redirect_last_segment(&mut net, &loop_segs, a, f);
if a + 1 == net.vertices.len() {
net.vertices.pop();
}
@@ -385,6 +387,7 @@ impl VectorNetwork {
s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y);
s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y);
}
let mut by_vertex: std::collections::BTreeMap<usize, usize> = std::collections::BTreeMap::new();
for c in corners {
if let Some(v) = out.vertices.get_mut(c.v) {
(v.x, v.y) = (c.a.x, c.a.y);
@@ -400,15 +403,28 @@ impl VectorNetwork {
}
out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb });
let arc = out.segments.len() - 1;
// The arc goes between the two segments wherever a loop walks through the point.
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
let n = lp.len();
let between =
|k: usize| matches!((lp.get(k), lp.get((k + 1) % n)), (Some(&x), Some(&y)) if (x, y) == (c.s1, c.s2) || (x, y) == (c.s2, c.s1));
if let Some(k) = (0..n).find(|k| between(*k)) {
lp.insert(k + 1, arc);
by_vertex.insert(c.v, arc);
}
// The arc goes between the two segments wherever a loop walks through the point. Each loop
// is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic.
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
let Some(&first_idx) = lp.first() else { continue };
let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) {
(Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b,
(Some(first), _) => first.a,
_ => 0,
};
let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()));
let mut cur = start_v;
for &x in lp.iter() {
rebuilt.push(x);
let Some(s) = self.segments.get(x) else { continue };
cur = if s.a == cur { s.b } else { s.a };
if let Some(&arc) = by_vertex.get(&cur) {
rebuilt.push(arc);
}
}
*lp = rebuilt;
}
Some(out)
}
@@ -547,25 +563,40 @@ impl VectorNetwork {
}
}
let degree = |v: usize| degrees.get(v).copied().unwrap_or(0);
let next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> {
self.segments.iter().enumerate().find_map(|(i, s)| {
if used.get(i).copied().unwrap_or(true) {
return None;
// Each vertex's segments in segment order, and how far down that list the used ones reach,
// so finding the next unused segment never rescans the whole network.
let mut adjacent: Vec<Vec<(usize, bool)>> = vec![Vec::new(); self.vertices.len()];
for (i, s) in self.segments.iter().enumerate() {
if let Some(l) = adjacent.get_mut(s.a) {
l.push((i, false));
}
if s.b != s.a
&& let Some(l) = adjacent.get_mut(s.b)
{
l.push((i, true));
}
}
let mut cursor = vec![0usize; self.vertices.len()];
let mut remaining = self.segments.len();
let mut next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> {
let list = adjacent.get(v)?;
let at = cursor.get_mut(v)?;
while let Some(&(i, rev)) = list.get(*at) {
if !used.get(i).copied().unwrap_or(true) {
return Some((i, rev));
}
if s.a == v {
Some((i, false))
} else if s.b == v {
Some((i, true))
} else {
None
}
})
*at += 1;
}
None
};
let stop = |v: usize| degree(v) != 2 || breaks(v);
// Start runs at endpoints, junctions and breaks first, then whatever remains (pure loops).
let mut starts: Vec<usize> = (0..self.vertices.len()).filter(|v| stop(*v)).collect();
starts.extend((0..self.vertices.len()).filter(|v| !stop(*v)));
for v0 in starts {
if remaining == 0 {
break;
}
while let Some((si, rev)) = next_from(v0, &used) {
let Some(start) = self.vertices.get(v0) else { break };
let mut path = BezPath::new();
@@ -574,8 +605,11 @@ impl VectorNetwork {
let mut closed = false;
let mut seg = Some((si, rev));
while let Some((i, r)) = seg {
if let Some(u) = used.get_mut(i) {
if let Some(u) = used.get_mut(i)
&& !*u
{
*u = true;
remaining = remaining.saturating_sub(1);
}
let Some(s) = self.segments.get(i) else { break };
self.push_seg(&mut path, s, r);
+2
View File
@@ -28,8 +28,10 @@ thiserror = { workspace = true }
log = { workspace = true }
base64 = { workspace = true }
regex = { workspace = true }
same-file = { workspace = true }
web-time = "1.1.0"
[dev-dependencies]
zip = { workspace = true }
wat = { workspace = true }
image = { workspace = true }
+12
View File
@@ -221,3 +221,15 @@ fn canvas_stacking_paints_and_exports_the_first_child_on_top() {
s.undo().unwrap();
assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]);
}
#[test]
fn a_size_given_with_a_new_path_fixes_that_axis() {
let mut s = Session::new();
let f = al_frame(&mut s, json!({}));
let v = id(s.execute("node.create", &json!({"type": "vector", "path": "M0 0 L10 10", "closed": false, "parent": f})).unwrap());
set(&mut s, v, json!({"sizingH": "fill"}));
assert_eq!(sizing(&s, v).0, Sizing::Fill);
set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30}));
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
}
+1 -1
View File
@@ -171,7 +171,7 @@ fn ready_for_dev_lists_frames_by_status() {
};
let ready = ex(&mut s, "inspect.readyForDev", json!({}));
assert_eq!(names(&ready), vec!["Login", "Onboarding", "Profile"], "every page, page and layer order");
assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev"}));
assert_eq!(ready["items"][0], json!({"id": a.0, "name": "Login", "page": page.0, "status": "readyForDev", "changed": false}));
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]);
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any", "page": page}))), vec!["Login", "Signup", "Onboarding"]);
assert!(s.execute("inspect.readyForDev", &json!({"status": "none"})).is_err());
+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);
}
+1 -1
View File
@@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() {
let fills = s.doc().unwrap().node(r).unwrap().fills.clone();
assert_eq!(fills.len(), 2, "the second fill stays");
assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) });
assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included");
assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity");
assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) });
// Removing the bound (first) fill takes the binding with it.
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
File diff suppressed because it is too large Load Diff
+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(())
}
+365
View File
@@ -0,0 +1,365 @@
//! Boards: the file kind, sticky notes, shapes with text, connectors that follow what they join
//! (locally, through undo and in live sessions), stamps, ink and the eraser, and refusals.
use figcraft_doc::{
BOARD_PAGE, BoardItem, BoardShape, Connector, End, FileKind, Node, NodeId, NodeKind, Routing, Side, Stamp, StrokeCap, board_color,
};
use figcraft_geom::Point;
use serde_json::{Value, json};
use crate::board::{route, sticky_font_size};
use crate::collab_live_tests::Room;
use crate::tools::{PointerEvent, PointerKind, ToolId};
use crate::{EngineError, Session};
pub(crate) fn board() -> Session {
let mut s = Session::new();
s.execute("file.newBoard", &json!({"name": "Ideas"})).unwrap();
s
}
pub(crate) fn id(v: &Value, key: &str) -> NodeId {
v[key].as_u64().map(NodeId).unwrap_or_else(|| panic!("no {key} in {v}"))
}
pub(crate) fn node(s: &Session, id: NodeId) -> Node {
s.doc().unwrap().node(id).unwrap().clone()
}
pub(crate) fn connector(s: &Session, id: NodeId) -> Connector {
match node(s, id).board {
Some(BoardItem::Connector(c)) => c,
other => panic!("not a connector: {other:?}"),
}
}
/// Connector `id`'s points on the page.
pub(crate) fn drawn(s: &Session, id: NodeId) -> Vec<Point> {
let doc = s.doc().unwrap();
let NodeKind::Vector { network } = &doc.node(id).unwrap().kind else { panic!("not a vector") };
let w = doc.world_transform(id);
network.vertices.iter().map(|v| w * v.point()).collect()
}
pub(crate) fn near(a: Point, b: Point) -> bool {
(a - b).hypot() < 1e-6
}
#[test]
fn a_board_is_a_kind_of_file_that_saves_and_reopens() {
let s = board();
let doc = s.doc().unwrap();
assert_eq!(doc.kind, FileKind::Board);
assert!(matches!(doc.node(doc.pages[0]).unwrap().kind, NodeKind::Page { background, .. } if background == BOARD_PAGE));
let (back, _) = figcraft_format::from_json(&figcraft_format::to_json(doc).unwrap().to_string()).unwrap();
assert_eq!(back.kind, FileKind::Board);
// Files from before boards (no kind) are design files, and design files don't write one.
let mut d = Session::new();
d.execute("file.new", &json!({})).unwrap();
let json = figcraft_format::to_json(d.doc().unwrap()).unwrap();
assert!(json.get("kind").is_none());
assert_eq!(figcraft_format::from_json(&json.to_string()).unwrap().0.kind, FileKind::Design);
assert_eq!(crate::inspect::document(&s, 0, false)["kind"], "board", "inspect shows the kind");
}
#[test]
fn board_commands_refuse_on_design_files_and_prototyping_refuses_on_boards() {
let mut d = Session::new();
d.execute("file.new", &json!({})).unwrap();
for (cmd, p) in [("board.sticky", json!({"x": 0, "y": 0})), ("tool.sticky", json!({})), ("tool.set", json!({"tool": "marker"}))] {
assert!(matches!(d.execute(cmd, &p), Err(EngineError::Disabled(..))), "{cmd} on a design file");
}
let mut s = board();
for cmd in ["view.devMode", "view.prototype", "proto.present", "proto.flow.add", "dev.focus"] {
let e = s.execute(cmd, &json!({})).unwrap_err();
assert!(e.to_string().contains("boards have no prototypes"), "{cmd}: {e}");
}
}
#[test]
fn a_sticky_is_a_square_frame_whose_note_shrinks_as_it_grows() {
let mut s = board();
s.prefs.user_name = "Ada".into();
let r = s.execute("board.sticky", &json!({"x": 10, "y": 20, "text": "Hi", "color": "blue"})).unwrap();
let (st, note) = (id(&r, "id"), id(&r, "text"));
let n = node(&s, st);
assert!(n.is_board_atom() && matches!(&n.board, Some(BoardItem::Sticky { author }) if author == "Ada"));
assert_eq!((n.width, n.height), (200.0, 200.0));
assert_eq!(n.fills[0].solid_color(), board_color("blue"));
assert!(n.layout.is_some() && !n.effects.is_empty());
assert_eq!(n.children.len(), 2, "the note and its author");
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0);
// A click on the note picks the sticky whole.
let doc = s.doc().unwrap();
assert_eq!(figcraft_doc::geometry::select_target(doc, note, &[], false), st);
// A long note takes a smaller size; a very long one grows the sticky past its square.
let long = "word ".repeat(120);
s.execute("node.set", &json!({"id": note, "props": {"characters": long}})).unwrap();
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, sticky_font_size(&long));
assert!(sticky_font_size(&long) < 16.0);
assert!(node(&s, st).width == 200.0 && node(&s, st).height >= 200.0);
// Undo puts the short note and its size back.
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(node(&s, note).kind.text().unwrap().style.font_size, 24.0);
}
#[test]
fn next_sticky_goes_beside_in_the_same_colour_and_emptied_notes_stay() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "color": "pink"})).unwrap(), "id");
let r = s.execute("board.nextSticky", &json!({})).unwrap();
let b = id(&r, "id");
let (na, nb) = (node(&s, a), node(&s, b));
assert_eq!(nb.transform.translation().x, na.width + 24.0);
assert_eq!(nb.fills, na.fills);
assert_eq!(s.selection(), vec![b]);
// Typing in it and emptying it again keeps its note; ending selects the sticky.
let note = id(&r, "text");
crate::board::edit_text(&mut s, b).unwrap();
crate::textedit::insert(&mut s, "x").unwrap();
crate::textedit::key(&mut s, "Backspace", false, false, false).unwrap();
crate::textedit::end(&mut s).unwrap();
assert!(s.doc().unwrap().contains(note));
assert_eq!(s.selection(), vec![b]);
}
#[test]
fn shapes_carry_text_and_change_outline() {
let mut s = board();
let r = s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "text": "Decide", "color": "green"})).unwrap();
let sh = id(&r, "id");
let n = node(&s, sh);
assert_eq!(n.board, Some(BoardItem::Shape { shape: BoardShape::Diamond }));
assert_eq!(n.children.len(), 2);
assert!(matches!(node(&s, n.children[0]).kind, NodeKind::Vector { .. }));
s.execute("board.setStyle", &json!({"ids": [sh], "shape": "speechBubble", "fontSize": 24})).unwrap();
assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: BoardShape::SpeechBubble }));
assert_eq!(node(&s, id(&r, "text")).kind.text().unwrap().style.font_size, 24.0);
s.execute("board.setColor", &json!({"ids": [sh], "color": "#FF0000"})).unwrap();
assert_eq!(node(&s, n.children[0]).fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into()));
assert!(s.execute("board.shape", &json!({"shape": "blob", "x": 0, "y": 0})).is_err());
}
#[test]
fn connectors_follow_what_they_join_and_undo_with_it() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "then"})).unwrap(), "id");
let pts = drawn(&s, c);
assert!(near(pts[0], Point::new(200.0, 100.0)) && near(pts[1], Point::new(400.0, 100.0)), "{pts:?}");
assert_eq!(node(&s, c).stroke.cap_end, StrokeCap::TriangleArrow);
// Its label sits at the middle.
let label = crate::board::label_of(s.doc().unwrap(), c).unwrap();
let lb = s.doc().unwrap().world_bounds(label).unwrap();
assert!(near(lb.center(), Point::new(300.0, 100.0)), "{lb:?}");
// Move one end: the connector re-routes (elbow now, out of the facing sides).
s.execute("board.setStyle", &json!({"ids": [c], "style": "elbow"})).unwrap();
s.execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 300})).unwrap();
let pts = drawn(&s, c);
assert!(near(pts[0], Point::new(200.0, 100.0)) && near(*pts.last().unwrap(), Point::new(400.0, 400.0)), "{pts:?}");
assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles: {pts:?}");
// Undo takes the move back and the route with it.
s.execute("edit.undo", &json!({})).unwrap();
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0)));
// A deleted end stays where it was, as a point.
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
assert!(matches!(connector(&s, c).to, End::Point { .. }));
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0)));
// A connector moved on its own takes its free end along; the attached one stays.
s.execute("transform.move", &json!({"ids": [c], "dx": 0, "dy": 50})).unwrap();
assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 400.0 && y == 150.0), "{:?}", connector(&s, c));
assert!(near(drawn(&s, c)[0], Point::new(200.0, 100.0)));
// Deleting the connector takes its label.
s.execute("edit.delete", &json!({"ids": [c]})).unwrap();
assert!(!s.doc().unwrap().contains(label));
assert!(s.execute("board.connector", &json!({"from": a, "to": a})).is_err());
}
#[test]
fn routes_are_the_same_for_the_same_document() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0})).unwrap(), "id");
let c = Connector { from: End::Node { node: a, side: Side::Bottom }, to: End::Point { x: 300.0, y: 500.0 }, routing: Routing::Elbow };
let doc = s.doc().unwrap();
let r = route(doc, &c).unwrap();
assert_eq!(r, route(&doc.clone(), &c).unwrap());
// Down out of the bottom, across halfway, down into the point.
assert_eq!(r, vec![Point::new(80.0, 160.0), Point::new(80.0, 330.0), Point::new(300.0, 330.0), Point::new(300.0, 500.0)]);
}
#[test]
fn stamps_and_ink_and_the_eraser() {
let mut s = board();
s.prefs.user_name = "Grace".into();
let st = id(&s.execute("board.stamp", &json!({"x": 100, "y": 100, "stamp": "heart"})).unwrap(), "id");
let n = node(&s, st);
assert_eq!(n.board, Some(BoardItem::Stamp { stamp: Stamp::Heart, author: "Grace".into() }));
assert_eq!(s.doc().unwrap().world_bounds(st).unwrap().center(), Point::new(100.0, 100.0));
assert!(!n.children.is_empty());
// Marker and highlighter strokes through the tools; the eraser takes what it touches.
let draw = |s: &mut Session, tool: &str, y: f64| {
s.execute("tool.set", &json!({"tool": tool})).unwrap();
for (k, x) in [(PointerKind::Down, 0.0), (PointerKind::Move, 50.0), (PointerKind::Move, 100.0), (PointerKind::Up, 150.0)] {
s.pointer(&PointerEvent::at(k, x, y, 1.0)).unwrap();
}
};
draw(&mut s, "marker", 300.0);
draw(&mut s, "highlighter", 400.0);
let inks: Vec<Node> = s.doc().unwrap().nodes().filter(|n| n.board == Some(BoardItem::Ink)).cloned().collect();
assert_eq!(inks.len(), 2);
assert!(inks.iter().any(|n| n.opacity < 1.0 && n.stroke.weight > 10.0), "a highlighter stroke");
let undo = s.undo_depth().0;
s.execute("tool.eraser", &json!({})).unwrap();
for (k, x) in [(PointerKind::Down, 20.0), (PointerKind::Move, 60.0), (PointerKind::Up, 80.0)] {
s.pointer(&PointerEvent::at(k, x, 300.0, 1.0)).unwrap();
}
assert_eq!(s.doc().unwrap().nodes().filter(|n| n.board == Some(BoardItem::Ink)).count(), 1, "the marker stroke is gone");
assert_eq!(s.undo_depth().0, undo + 1, "one step");
assert_eq!(s.tool.tool, ToolId::Eraser);
}
#[test]
fn the_sticky_and_connector_tools() {
let mut s = board();
s.execute("tool.sticky", &json!({"color": "orange"})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, 1.0)).unwrap();
let te = s.ds().unwrap().text_edit.clone().expect("typing in the new sticky");
let a = crate::board::owner_of_text(s.doc().unwrap(), te.id).unwrap();
assert_eq!(node(&s, a).fills[0].solid_color(), board_color("orange"));
crate::textedit::insert(&mut s, "First").unwrap();
crate::textedit::end(&mut s).unwrap();
let b = id(&s.execute("board.sticky", &json!({"x": 500, "y": 0})).unwrap(), "id");
s.execute("tool.connector", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 300.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 600.0, 100.0, 1.0)).unwrap();
let c = s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).map(|n| n.id).unwrap();
let k = connector(&s, c);
assert_eq!((k.from.node(), k.to.node()), (Some(a), Some(b)));
}
#[test]
fn boards_sync_in_live_sessions() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "A"})).unwrap(), "id");
let mut room = Room::new(s.doc().unwrap().clone(), 2, 7);
assert_eq!(room.s(1).doc().unwrap().kind, FileKind::Board, "joiners get a board");
let b = id(&room.s(1).execute("board.sticky", &json!({"x": 400, "y": 0, "text": "B"})).unwrap(), "id");
room.settle();
let c = id(&room.s(0).execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id");
room.s(1).execute("board.stamp", &json!({"x": 50, "y": 50, "stamp": "star"})).unwrap();
room.settle();
room.check();
// Person 1 moves the sticky person 0 connected: both see the connector follow it.
room.s(1).execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 250})).unwrap();
room.s(0).execute("board.setColor", &json!({"ids": [a], "color": "violet"})).unwrap();
room.settle();
room.check();
let doc = room.server.document();
let NodeKind::Vector { network } = &doc.node(c).unwrap().kind else { panic!("not a vector") };
let end = doc.world_transform(c) * network.vertices.last().unwrap().point();
assert_eq!(end, Point::new(400.0, 350.0), "to the moved sticky's facing side");
assert_eq!(doc.node(a).unwrap().fills[0].solid_color(), board_color("violet"));
}
#[test]
fn board_commands_survive_junk() {
let junk = [
json!({}),
json!(null),
json!([1]),
json!({"x": "NaN", "y": 1e308, "text": 5, "color": [1], "author": {"x": 1}, "parent": "p"}),
json!({"x": -1e308, "y": 0, "color": "neon", "shape": 7, "width": -5, "height": 1e308, "stamp": "skull"}),
json!({"from": {"x": "a"}, "to": 99_999, "fromSide": "inside", "style": 3, "startCap": "spike", "endCap": 7, "label": [1]}),
json!({"from": {"x": 1e308, "y": -1e308}, "to": {"x": 0, "y": 0}, "style": "elbow", "label": "\u{0}"}),
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()
.map(|c| c.id)
.filter(|i| {
i.starts_with("board.")
|| ["tool.sticky", "tool.shape", "tool.connector", "tool.marker", "tool.highlighter", "tool.eraser", "tool.stamp", "file.newBoard"]
.contains(i)
})
.collect();
assert!(ids.len() >= 17, "{ids:?}");
for cmd in ids {
for j in &junk {
let mut s = board();
let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone();
let b = s.execute("board.shape", &json!({"x": 300, "y": 0})).unwrap()["id"].clone();
let _ = s.execute("board.connector", &json!({"from": a, "to": b, "label": "x"}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
// Every other command on a board with objects on it.
for spec in crate::cmd::all_commands() {
if spec.id.starts_with("file.") && spec.id != "file.newBoard" || spec.id == "text.rescanFonts" || spec.id == "image.place" {
continue;
}
for j in &junk[..3] {
let mut s = board();
let a = s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap()["id"].clone();
let b = s.execute("board.stamp", &json!({"x": 300, "y": 0})).unwrap()["id"].clone();
let _ = s.execute("board.connector", &json!({"from": a, "to": b}));
if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) {
panic!("{cmd} panicked on {j}: {msg}");
}
}
}
}
#[test]
fn a_press_on_a_stickys_background_picks_and_drags_it() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "text": "Hi"})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id");
s.execute("select.none", &json!({})).unwrap();
// Below the note, on the sticky's own fill (a top-level frame's background is the canvas).
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 150.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 250.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 450.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 450.0, 1.0)).unwrap();
assert_eq!(s.selection(), vec![a]);
assert_eq!(node(&s, a).transform.translation().y, 300.0);
assert!(near(drawn(&s, c)[0], Point::new(200.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c));
}
#[test]
fn a_copy_of_joined_objects_joins_the_copies() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "label": "x"})).unwrap(), "id");
let label = crate::board::label_of(s.doc().unwrap(), c).unwrap();
s.execute("edit.duplicate", &json!({"ids": [a, b, c, label]})).unwrap();
let copies = s.selection();
assert_eq!(copies.len(), 4, "{copies:?}");
let k = copies.iter().find_map(|i| match node(&s, *i).board {
Some(BoardItem::Connector(k)) => Some(k),
_ => None,
});
let k = k.unwrap();
assert!(copies.contains(&k.from.node().unwrap()) && copies.contains(&k.to.node().unwrap()), "{k:?} {copies:?}");
// Copy and paste too.
s.execute("select.set", &json!({"ids": [a, b, c]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let pasted = s.selection();
let k = pasted.iter().find_map(|i| match node(&s, *i).board {
Some(BoardItem::Connector(k)) => Some(k),
_ => None,
});
let k = k.unwrap();
assert!(pasted.contains(&k.from.node().unwrap()) && pasted.contains(&k.to.node().unwrap()), "{k:?} {pasted:?}");
}
+277
View File
@@ -0,0 +1,277 @@
//! Boards, continued: copies and pastes (within a file and from another), routes with named sides
//! and free points, connectors inside moving sections, styles and labels with their refusals, and
//! the shape, stamp, highlighter and connector tools with double-clicks.
use figcraft_doc::{BoardItem, BoardShape, End, NodeId, Side, Stamp, StrokeCap, board_color};
use figcraft_geom::Point;
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, ToolId};
/// The connector among `ids`, if any.
fn connector_in(s: &Session, ids: &[NodeId]) -> Option<figcraft_doc::Connector> {
let doc = s.doc().ok()?;
ids.iter().find_map(|i| match &doc.node(*i)?.board {
Some(BoardItem::Connector(k)) => Some(*k),
_ => None,
})
}
#[test]
fn pasting_into_another_board_detaches_what_was_left_behind() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
let r = s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "x"})).unwrap();
let (c, label) = (id(&r, "id"), id(&r, "label"));
let first = s.active;
// A second board whose own ids are the same small numbers as the first's.
let second = s.execute("file.newBoard", &json!({})).unwrap()["index"].clone();
for x in [0, 300, 600, 900] {
s.execute("board.sticky", &json!({"x": x, "y": 500})).unwrap();
}
let others: Vec<NodeId> = s.doc().unwrap().nodes().map(|n| n.id).collect();
s.execute("doc.switch", &json!({"index": first})).unwrap();
s.execute("select.set", &json!({"ids": [a, c, label]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("doc.switch", &json!({"index": second})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let doc = s.doc().unwrap();
let pasted = doc.nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_))) && !others.contains(&n.id)).unwrap().clone();
let k = connector(&s, pasted.id);
let from = k.from.node().unwrap();
assert!(!others.contains(&from) && matches!(doc.node(from).unwrap().board, Some(BoardItem::Sticky { .. })), "joins the pasted sticky");
let to = drawn(&s, pasted.id).last().copied().unwrap();
assert!(matches!(k.to, End::Point { x, y } if near(Point::new(x, y), to)), "the end left behind stays where it is drawn: {k:?}");
let pasted_label = crate::board::label_of(doc, pasted.id).expect("the label came with its connector");
assert!(!others.contains(&pasted_label));
// A label copied without its connector doesn't come.
s.execute("doc.switch", &json!({"index": first})).unwrap();
s.execute("select.set", &json!({"ids": [label]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("doc.switch", &json!({"index": second})).unwrap();
let labels = |s: &Session| s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Label { .. }))).count();
let before = labels(&s);
s.execute("edit.paste", &json!({})).unwrap();
assert_eq!(labels(&s), before);
// Within its own file, a connector pasted alone stays joined to the same layers.
s.execute("doc.switch", &json!({"index": first})).unwrap();
s.execute("select.set", &json!({"ids": [c]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let k = connector_in(&s, &s.selection()).unwrap();
assert_eq!((k.from.node(), k.to.node()), (Some(a), Some(b)));
}
#[test]
fn duplicating_and_alt_dragging_keep_connections_inside_copied_sections() {
let mut s = board();
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": -50, "y": -50, "width": 400, "height": 400})).unwrap(), "id");
let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id");
let b = id(&s.execute("board.sticky", &json!({"x": 600, "y": 0})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b})).unwrap(), "id");
s.execute("edit.duplicate", &json!({"ids": [sec, c]})).unwrap();
let copies = s.selection();
let from = connector_in(&s, &copies).unwrap().from.node().unwrap();
let doc = s.doc().unwrap();
assert!(from != a && copies.iter().any(|r| doc.is_ancestor(*r, from)), "the copy joins the sticky inside the copied section");
// Alt-drag copies the same way.
s.execute("select.set", &json!({"ids": [b, c]})).unwrap();
let press = |k, x| PointerEvent { alt: true, ..PointerEvent::at(k, x, 100.0, 1.0) };
for (k, x) in [(PointerKind::Down, 700.0), (PointerKind::Move, 760.0), (PointerKind::Move, 800.0), (PointerKind::Up, 800.0)] {
s.pointer(&press(k, x)).unwrap();
}
let copies = s.selection();
assert_eq!(copies.len(), 2);
let k = connector_in(&s, &copies).unwrap();
assert!(copies.contains(&k.to.node().unwrap()), "{k:?}");
}
#[test]
fn routes_take_named_sides_and_free_points() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(), "id");
let b = id(&s.execute("board.shape", &json!({"x": 300, "y": 300, "width": 100, "height": 100})).unwrap(), "id");
let doc = s.doc().unwrap();
let elbow = |from, to| route(doc, &figcraft_doc::Connector { from, to, routing: figcraft_doc::Routing::Elbow }).unwrap();
let side = |n, side| End::Node { node: n, side };
let p = |x, y| End::Point { x, y };
let pt = Point::new;
// Side to side: out, across halfway, in.
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(100.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(300.0, 350.0)]);
// A side and a top or bottom: one turn.
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(100.0, 50.0), pt(350.0, 50.0), pt(350.0, 300.0)]);
assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 100.0), pt(50.0, 350.0), pt(300.0, 350.0)]);
// Two free points: the longer way first; in a line, no turns.
assert_eq!(elbow(p(0.0, 0.0), p(100.0, 40.0)), vec![pt(0.0, 0.0), pt(50.0, 0.0), pt(50.0, 40.0), pt(100.0, 40.0)]);
assert_eq!(elbow(p(0.0, 0.0), p(100.0, 0.0)), vec![pt(0.0, 0.0), pt(100.0, 0.0)]);
// An automatic side faces the other end.
let straight = route(doc, &figcraft_doc::Connector { from: side(a, Side::Auto), to: p(50.0, 500.0), routing: figcraft_doc::Routing::Straight });
assert_eq!(straight.unwrap(), vec![pt(50.0, 100.0), pt(50.0, 500.0)]);
assert_eq!(Side::from_id("TOP"), Some(Side::Top));
assert_eq!(Side::from_id("inside"), None);
// The same place twice is refused, as are a side that doesn't exist and a point without numbers.
assert!(s.execute("board.connector", &json!({"from": {"x": 5, "y": 5}, "to": {"x": 5, "y": 5}})).is_err());
assert!(s.execute("board.connector", &json!({"from": a, "to": b, "fromSide": "inside"})).is_err());
assert!(s.execute("board.connector", &json!({"from": a, "to": {"x": "a"}})).is_err());
assert!(s.execute("board.connector", &json!({"from": a})).is_err());
}
#[test]
fn a_connector_follows_a_layer_whose_section_moves_and_ignores_unrelated_edits() {
let mut s = board();
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 300})).unwrap(), "id");
let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id");
let b = id(&s.execute("board.stamp", &json!({"x": 800, "y": 150})).unwrap(), "id");
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(), "id");
assert!(near(drawn(&s, c)[0], Point::new(250.0, 150.0)));
// Only the section is edited; the sticky inside it moves with it, and so does the connector.
s.execute("transform.move", &json!({"ids": [sec], "dx": 0, "dy": 100})).unwrap();
assert!(near(drawn(&s, c)[0], Point::new(250.0, 250.0)), "{:?}", drawn(&s, c));
// An edit that touches neither end leaves the connector as it was.
let before = s.doc().unwrap().node_arc(c).cloned().unwrap();
s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap();
assert!(std::sync::Arc::ptr_eq(&before, s.doc().unwrap().node_arc(c).unwrap()));
}
#[test]
fn styles_labels_and_their_refusals() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "author": false})).unwrap(), "id");
assert_eq!(node(&s, a).children.len(), 1, "no author line");
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0, "author": " Lin "})).unwrap(), "id");
assert!(matches!(node(&s, b).board, Some(BoardItem::Sticky { ref author }) if author == "Lin"));
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "startCap": "circleFilled", "endCap": "none"})).unwrap(), "id");
assert_eq!((node(&s, c).stroke.cap_start, node(&s, c).stroke.cap_end), (StrokeCap::CircleFilled, StrokeCap::None));
// Labels: set, change, size, remove; named by the label itself too.
let l = id(&s.execute("board.label", &json!({"id": c, "text": "first"})).unwrap(), "label");
assert_eq!(id(&s.execute("board.label", &json!({"id": l, "text": "second"})).unwrap(), "label"), l, "the same label, new text");
let text = crate::board::text_of(s.doc().unwrap(), l).unwrap();
assert_eq!(node(&s, text).kind.text().unwrap().characters, "second");
s.execute("board.setStyle", &json!({"ids": [l], "fontSize": 36})).unwrap();
assert_eq!(node(&s, text).kind.text().unwrap().style.font_size, 36.0);
assert!(s.execute("board.label", &json!({"id": c, "text": ""})).unwrap()["label"].is_null());
assert!(!s.doc().unwrap().contains(l));
// What doesn't take a style or colour says so.
assert!(s.execute("board.setStyle", &json!({"ids": [a], "fontSize": 30})).is_err(), "stickies size their own text");
assert!(s.execute("board.setStyle", &json!({"ids": [c], "fontSize": 0})).is_err());
let st = id(&s.execute("board.stamp", &json!({"x": 0, "y": 0, "stamp": "check"})).unwrap(), "id");
assert!(s.execute("board.setColor", &json!({"ids": [st], "color": "blue"})).is_err());
assert!(s.execute("board.setColor", &json!({"ids": [a]})).is_err());
assert!(s.execute("board.label", &json!({"id": a, "text": "x"})).is_err());
// A palette colour on a line draws in its deeper shade; a hex colour as given.
s.execute("board.setColor", &json!({"ids": [c], "color": "blue"})).unwrap();
assert!(node(&s, c).strokes[0].solid_color().is_some() && node(&s, c).strokes[0].solid_color() != board_color("blue"));
s.execute("board.setColor", &json!({"ids": [c], "color": "#123456"})).unwrap();
assert_eq!(node(&s, c).strokes[0].solid_color().map(|c| c.to_hex()), Some("123456".into()));
// Next stickies on each side, and refusals.
for (side, dx, dy) in [("below", 0.0, 224.0), ("left", -224.0, 0.0), ("above", 0.0, -224.0)] {
let n = id(&s.execute("board.nextSticky", &json!({"id": a, "side": side})).unwrap(), "id");
let t = node(&s, n).transform.translation();
assert_eq!((t.x, t.y), (dx, dy), "{side}");
}
assert!(s.execute("board.nextSticky", &json!({"id": a, "side": "diagonal"})).is_err());
assert!(s.execute("board.nextSticky", &json!({"id": c})).is_err());
assert!(s.execute("board.ink", &json!({})).is_err());
assert!(s.execute("board.sticky", &json!({"x": 0, "y": 0, "parent": c})).is_err(), "a connector holds no layers");
}
#[test]
fn the_shape_stamp_highlighter_and_connector_tools_and_double_clicks() {
let mut s = board();
let click = |s: &mut Session, x, y| {
s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap();
};
s.execute("tool.shape", &json!({"shape": "roundedRectangle"})).unwrap();
click(&mut s, 100.0, 100.0);
let sh = s.selection()[0];
assert_eq!(node(&s, sh).board, Some(BoardItem::Shape { shape: BoardShape::RoundedRectangle }));
assert_eq!(s.tool.tool, ToolId::Move, "back to Move after placing");
s.execute("tool.stamp", &json!({"stamp": "question"})).unwrap();
click(&mut s, 400.0, 100.0);
click(&mut s, 450.0, 100.0);
let stamps = s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Stamp { stamp: Stamp::Question, .. }))).count();
assert_eq!(stamps, 2);
assert_eq!(s.tool.tool, ToolId::Stamp, "stamps go on one after another");
assert!(s.execute("tool.stamp", &json!({"stamp": "skull"})).is_err());
s.execute("tool.highlighter", &json!({"color": "pink"})).unwrap();
assert_eq!(Some(s.tool.board.highlighter), board_color("pink"));
s.execute("tool.marker", &json!({"color": "#336699"})).unwrap();
assert_eq!(s.tool.board.marker.to_hex(), "336699");
// A connector drag that is only a click, or from an object to itself, makes nothing.
s.execute("tool.connector", &json!({"style": "straight"})).unwrap();
click(&mut s, 100.0, 100.0);
s.pointer(&PointerEvent::at(PointerKind::Down, 60.0, 120.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Move, 100.0, 140.0, 1.0)).unwrap();
assert!(s.tool.overlay.rubber.is_some(), "the rubber band shows while dragging");
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 140.0, 1.0)).unwrap();
assert!(!s.doc().unwrap().nodes().any(|n| matches!(n.board, Some(BoardItem::Connector(_)))));
// From the shape out to empty canvas: a connector to a free point.
s.pointer(&PointerEvent::at(PointerKind::Down, 80.0, 140.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 80.0, 600.0, 1.0)).unwrap();
let c = s.doc().unwrap().nodes().find(|n| matches!(n.board, Some(BoardItem::Connector(_)))).map(|n| n.id).unwrap();
assert!(matches!(connector(&s, c).to, End::Point { .. }));
// Double-clicks: on the shape its text, on the connector a new label, both typed into.
s.execute("tool.move", &json!({})).unwrap();
s.execute("select.none", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 80.0, 140.0, 1.0)).unwrap();
let te = s.ds().unwrap().text_edit.clone().expect("typing in the shape");
assert_eq!(crate::board::owner_of_text(s.doc().unwrap(), te.id), Some(sh));
crate::textedit::end(&mut s).unwrap();
let line = drawn(&s, c);
let m = line[0].midpoint(line[1]);
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, m.x, m.y, 1.0)).unwrap();
let label = crate::board::label_of(s.doc().unwrap(), c).expect("a label was added");
let te = s.ds().unwrap().text_edit.clone().expect("typing in the label");
assert_eq!(crate::board::owner_of_text(s.doc().unwrap(), te.id), Some(label));
}
#[test]
fn an_ink_stroke_ends_where_the_button_comes_up() {
let mut s = board();
s.execute("tool.marker", &json!({})).unwrap();
// Down and up with no move between: the stroke still runs from the press to the release.
s.pointer(&PointerEvent::at(PointerKind::Down, 0.0, 0.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 150.0, 0.0, 1.0)).unwrap();
let doc = s.doc().unwrap();
let ink = doc.nodes().find(|n| matches!(n.board, Some(BoardItem::Ink))).map(|n| n.id).expect("a stroke");
let b = doc.world_bounds(ink).unwrap();
assert!(b.x0.abs() < 6.0 && (b.x1 - 150.0).abs() < 6.0, "from 0 to 150 (stroke width aside): {b:?}");
}
#[test]
fn a_second_sticky_ends_the_first_ones_typing_and_undoes_alone() {
let mut s = board();
s.execute("tool.sticky", &json!({})).unwrap();
s.prefs.keep_tool = true;
s.pointer(&PointerEvent::at(PointerKind::Down, 100.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 100.0, 1.0)).unwrap();
let text = s.ds().unwrap().text_edit.as_ref().expect("typing in the new sticky").id;
crate::textedit::insert(&mut s, "first note").unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 500.0, 100.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 500.0, 100.0, 1.0)).unwrap();
let stickies = |s: &Session| s.doc().unwrap().nodes().filter(|n| matches!(n.board, Some(BoardItem::Sticky { .. }))).count();
let typed = |s: &Session| s.doc().unwrap().node(text).unwrap().kind.text().unwrap().characters.clone();
assert_eq!(stickies(&s), 2);
crate::textedit::end(&mut s).unwrap();
s.undo().unwrap();
assert_eq!(stickies(&s), 1, "the second sticky is its own undo step");
assert_eq!(typed(&s), "first note", "the first sticky keeps what was typed in it");
}
#[test]
fn a_board_tool_left_on_presses_as_move_in_a_design_file() {
let mut s = board();
s.execute("tool.sticky", &json!({})).unwrap();
s.execute("file.new", &json!({})).unwrap();
let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap(), "id");
s.execute("select.none", &json!({})).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, 50.0, 50.0, 1.0)).unwrap();
assert_eq!(s.tool.tool, ToolId::Move);
assert_eq!(s.selection(), vec![r], "the press selected as Move does");
}

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