mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 03:16:53 -06:00
+16
-73
@@ -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,21 @@ 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@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@v2
|
||||
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@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
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:
|
||||
# The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter):
|
||||
# crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts'
|
||||
# pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin
|
||||
# once one commit has both (storytold/craft-fonts#42).
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
packages: >-
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
libgtk-3-dev
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# 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:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -81,6 +81,7 @@ code.
|
||||
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
### Quality gates (before every commit)
|
||||
|
||||
@@ -81,6 +81,7 @@ code.
|
||||
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
### Quality gates (before every commit)
|
||||
|
||||
Generated
+33
-23
@@ -742,6 +742,15 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -1456,7 +1465,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-window",
|
||||
@@ -1482,7 +1491,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1496,9 +1505,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1511,7 +1521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1534,7 +1544,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1543,7 +1553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1555,7 +1565,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
@@ -1583,7 +1593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-pathops",
|
||||
@@ -1598,7 +1608,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1606,7 +1616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1614,7 +1624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1630,7 +1640,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1639,7 +1649,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1656,7 +1666,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1669,7 +1679,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1687,7 +1697,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
@@ -1711,7 +1721,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1727,7 +1737,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1741,9 +1751,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"figcraft-color",
|
||||
@@ -1766,7 +1777,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -2602,8 +2613,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -5871,7 +5881,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+15
-4
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.8.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -45,6 +45,8 @@ serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
regex = "1.13"
|
||||
log = "0.4"
|
||||
# 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"
|
||||
imbl = { version = "7", features = ["serde"] }
|
||||
postcard = { version = "1", default-features = false, features = ["alloc"] }
|
||||
@@ -101,11 +103,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"
|
||||
@@ -122,3 +128,8 @@ codegen-units = 1
|
||||
debug = false
|
||||
strip = true
|
||||
panic = "abort"
|
||||
|
||||
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
|
||||
# panic while drawing unwinds without aborting.
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png" width="128" height="128" alt="FigCraft icon: an engraved chameleon on a mint-green tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">FigCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
|
||||
+70
-5
@@ -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-24 · **Change:** minor (boards: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); 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
|
||||
@@ -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,7 +63,7 @@ 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) |
|
||||
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
@@ -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 | 97% | 76% | 2–4 | [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) |
|
||||
| Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) |
|
||||
| Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
| Boards (whiteboarding; not in the weighted numbers) | ~35% | ~20% | 30–60 | [boards-parity](docs/boards-parity.md) |
|
||||
|
||||
## Languages
|
||||
|
||||
@@ -126,14 +127,25 @@ 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-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-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-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-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 +328,58 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-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 |
|
||||
@@ -331,6 +395,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| 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. |
|
||||
|
||||
@@ -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,6 +16,8 @@ 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() {
|
||||
let name = if let Some(n) = arg.strip_prefix("--") {
|
||||
@@ -30,19 +32,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 +145,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(_))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The file subcommands: info, export, convert, render and run.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::path::Path;
|
||||
use std::process::ExitCode;
|
||||
|
||||
@@ -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.
|
||||
@@ -411,6 +415,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]
|
||||
|
||||
@@ -27,7 +27,7 @@ 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
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
@@ -40,6 +39,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 +114,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
@@ -748,7 +811,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 +1036,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 +1159,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 +1181,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 +1255,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 +1334,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);
|
||||
|
||||
@@ -229,6 +229,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 = {
|
||||
@@ -313,3 +324,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());
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -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};
|
||||
@@ -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,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]
|
||||
|
||||
@@ -16,6 +16,10 @@ 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);
|
||||
|
||||
/// 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 +42,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 +51,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 {
|
||||
|
||||
@@ -131,6 +131,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();
|
||||
|
||||
@@ -71,3 +71,8 @@ sync_github_name = true
|
||||
|
||||
[people.AganzeFelicite]
|
||||
sync_github_name = true
|
||||
|
||||
[people.switch87]
|
||||
real_name = "Gert Pellin"
|
||||
display_name = "Gert Pellin"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
|
||||
@@ -141,7 +141,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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};
|
||||
@@ -670,3 +670,49 @@ 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.
|
||||
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 t0 = Instant::now();
|
||||
let (mut closed, mut first) = (None, true);
|
||||
while t0.elapsed() < Duration::from_secs(6) {
|
||||
if !poke(&mut r, std::mem::take(&mut first)) {
|
||||
closed = Some(t0.elapsed());
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
// Drain what the host sent back (pongs, the close); an error means it hung up.
|
||||
r.0.get_mut().set_read_timeout(Some(Duration::from_millis(10))).unwrap();
|
||||
match r.0.read() {
|
||||
Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => {
|
||||
closed = Some(t0.elapsed());
|
||||
break;
|
||||
}
|
||||
Err(tungstenite::Error::Io(e)) if !matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
|
||||
closed = Some(t0.elapsed());
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
host.stop();
|
||||
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:?}");
|
||||
}
|
||||
|
||||
@@ -206,9 +206,15 @@ fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result<W
|
||||
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 +246,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +195,13 @@ impl Duplex {
|
||||
|
||||
impl Read for Duplex {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
if let Some(d) = self.deadline {
|
||||
let left = d.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() {
|
||||
return Err(io::ErrorKind::TimedOut.into());
|
||||
}
|
||||
self.read.set_read_timeout(Some(left))?;
|
||||
}
|
||||
let n = match &self.tls {
|
||||
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
|
||||
None => self.read.read(buf),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -13,6 +13,7 @@ figcraft-doc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
imbl = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -181,6 +181,7 @@ node_props! {
|
||||
Annotations => annotations,
|
||||
CodeLinks => code_links,
|
||||
DevResources => dev_resources,
|
||||
Board => board,
|
||||
}
|
||||
|
||||
/// A document-level property. Lists of entries with ids (styles, comments, variable collections,
|
||||
@@ -219,6 +220,8 @@ pub enum DocProp {
|
||||
Variables,
|
||||
/// Variable ids in order.
|
||||
VariableOrder,
|
||||
/// A design file or a board.
|
||||
Kind,
|
||||
}
|
||||
|
||||
/// A document-level entry, by id.
|
||||
|
||||
@@ -213,6 +213,7 @@ doc_scalars! {
|
||||
ImagePresets => image_presets,
|
||||
AnnotationCategories => annotation_categories,
|
||||
Libraries => libraries,
|
||||
Kind => kind,
|
||||
}
|
||||
|
||||
/// Keyed maps (no order): changed and added entries are put, missing ones removed.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -696,22 +696,38 @@ pub enum ServerMsg {
|
||||
},
|
||||
}
|
||||
|
||||
/// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock
|
||||
/// instant moved into chrono's range by whole 400-year cycles). The calendar repeats every 400
|
||||
/// years (146_097 days), so month, day and time of day come from chrono and only the year is
|
||||
/// carried, which keeps the result exact for any `i64` day count. This is not hand-rolled calendar
|
||||
/// math: chrono still does the date, the cycle count just shifts the year.
|
||||
fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), nanos.min(999_999_999));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
|
||||
/// messages).
|
||||
/// messages). Years past chrono's own range (262143) keep converting: the instant is moved into
|
||||
/// range by whole 400-year cycles and the real year restored, so every `u64` reads as the date the
|
||||
/// old hand-rolled conversion produced (e.g. `u64::MAX` → `584554051223-11-09 07:00 UTC`).
|
||||
pub fn utc_date(secs: u64) -> String {
|
||||
let days = i64::try_from(secs / 86_400).unwrap_or(0);
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
// Days to a civil date (proleptic Gregorian).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
|
||||
// The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), and the
|
||||
// seconds-of-day is < 86_400, so the conversion is always exact (no epoch fallback). Splitting
|
||||
// with `u64` arithmetic (as the old code did) avoids the `i64` seconds overflow near `u64::MAX`.
|
||||
let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32);
|
||||
let (y, t) = utc_parts(days, sod, 0);
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old code did not).
|
||||
format!("{y:04}-{}", t.format("%m-%d %H:%M UTC"))
|
||||
}
|
||||
|
||||
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
|
||||
@@ -752,3 +768,25 @@ pub fn decode_json<T: DeserializeOwned>(text: &str, max_bytes: usize) -> Result<
|
||||
}
|
||||
serde_json::from_str(text).map_err(|e| Error::Decode(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `utc_date` keeps the exact `YYYY-MM-DD HH:MM UTC` output the hand-rolled civil-from-days
|
||||
/// code produced (leap days and all).
|
||||
#[test]
|
||||
fn utc_date_formats_seconds_since_1970() {
|
||||
assert_eq!(utc_date(0), "1970-01-01 00:00 UTC");
|
||||
assert_eq!(utc_date(1_000_000), "1970-01-12 13:46 UTC");
|
||||
assert_eq!(utc_date(951_782_400), "2000-02-29 00:00 UTC");
|
||||
assert_eq!(utc_date(1_709_164_800), "2024-02-29 00:00 UTC");
|
||||
assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC");
|
||||
// A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did.
|
||||
assert_eq!(utc_date(253_402_300_800), "10000-01-01 00:00 UTC");
|
||||
// Past chrono's own year range (262143) the instant is moved into range by whole 400-year
|
||||
// cycles and the real year restored, so it keeps the old hand-rolled output instead of
|
||||
// falling back to the epoch.
|
||||
assert_eq!(utc_date(u64::MAX), "584554051223-11-09 07:00 UTC");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+27
-4
@@ -549,7 +549,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 +559,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 +609,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.
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
//! 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};
|
||||
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,
|
||||
}
|
||||
|
||||
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 { .. })
|
||||
}
|
||||
|
||||
/// 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
Elbow,
|
||||
}
|
||||
|
||||
impl Routing {
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Routing::Straight => "straight",
|
||||
Routing::Elbow => "elbow",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<Routing> {
|
||||
[Routing::Straight, Routing::Elbow].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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
@@ -72,7 +72,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +264,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();
|
||||
|
||||
+43
-11
@@ -8,6 +8,7 @@
|
||||
//! 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 geometry;
|
||||
@@ -23,6 +24,7 @@ pub mod vector;
|
||||
pub mod vector_faces;
|
||||
pub mod vector_regions;
|
||||
|
||||
pub use board::*;
|
||||
pub use colors::*;
|
||||
pub use comments::*;
|
||||
pub use figcraft_color as color;
|
||||
@@ -157,6 +159,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>>,
|
||||
@@ -221,6 +226,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(),
|
||||
@@ -269,7 +275,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 +468,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);
|
||||
}
|
||||
@@ -821,6 +837,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 +856,10 @@ impl Document {
|
||||
{
|
||||
*component = *m;
|
||||
}
|
||||
// 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 +1009,46 @@ 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();
|
||||
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))
|
||||
{
|
||||
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));
|
||||
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
|
||||
}
|
||||
|
||||
@@ -518,6 +518,9 @@ 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>,
|
||||
/// 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 +631,7 @@ impl Node {
|
||||
annotations: Vec::new(),
|
||||
code_links: Vec::new(),
|
||||
dev_resources: Vec::new(),
|
||||
board: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,6 +657,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 {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())),
|
||||
}
|
||||
|
||||
@@ -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}`"))) }
|
||||
}
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,84 @@ 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(©));
|
||||
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(..))));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -0,0 +1,363 @@
|
||||
//! 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]]}),
|
||||
];
|
||||
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:?}");
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -129,3 +129,36 @@ fn code_gives_a_translucent_stroke_its_alpha_once() {
|
||||
let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string();
|
||||
assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binding_keeps_an_opacity_set_on_the_paint() {
|
||||
// A 15% tint whose colour comes from a variable (#923): the bind replaces the colour only.
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "red", "type": "color", "value": "#FF0000"}));
|
||||
let props = json!({"fills": [{"type": "solid", "color": "#000000", "opacity": 0.15}]});
|
||||
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "props": props})).unwrap();
|
||||
let r = NodeId(r["id"].as_u64().unwrap());
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "red"}));
|
||||
let p = s.doc().unwrap().node(r).unwrap().fills[0].clone();
|
||||
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0xFF, 0, 0) });
|
||||
assert!(near(p.opacity, 0.15), "{}", p.opacity);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
|
||||
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.15));
|
||||
// An opacity set on a `#RRGGBBAA` fill is the user's too, and stays.
|
||||
let (mut s, r) = setup("#FF0000", "#FF0000");
|
||||
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.25}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
assert!(near(alpha(&s, r), 0.25), "{}", alpha(&s, r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unbind_and_rebind_keeps_the_opacity_set_on_the_paint() {
|
||||
// A paint at 50% bound to a half-transparent colour shows a quarter, unbound or bound again.
|
||||
let (mut s, r) = setup("#FF000080", "#FF000080");
|
||||
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
|
||||
for variable in [json!("tint"), json!(null), json!("tint"), json!(null)] {
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": variable}));
|
||||
assert!(near(alpha(&s, r), HALF / 2.0), "{variable}: {}", alpha(&s, r));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,9 @@ pub const SEP: &str = "-";
|
||||
pub static MENU: &[MenuItem] = &[
|
||||
// File
|
||||
m!("File", "New design file", "file.new"),
|
||||
// FigCraft's own: starter templates (not counted by `parity`, like the other items of its own
|
||||
// listed in `OWN`).
|
||||
m!("File", "New board", "file.newBoard"),
|
||||
// FigCraft's own: boards and starter templates (not counted by `parity`, like the other items
|
||||
// of its own listed in `OWN`).
|
||||
m!("File", "New from template…", "file.newFromTemplate"),
|
||||
m!("File", "New window", "window.new"),
|
||||
m!("File", "Open…", "file.open"),
|
||||
@@ -289,7 +290,7 @@ pub fn items() -> impl Iterator<Item = &'static MenuItem> {
|
||||
}
|
||||
|
||||
/// Items of FigCraft's own that the parity count leaves out (as it does the `collab.` ones).
|
||||
const OWN: [&str; 3] = ["file.newFromTemplate", "help.gettingStarted", "text.missingFonts"];
|
||||
const OWN: [&str; 4] = ["file.newFromTemplate", "file.newBoard", "help.gettingStarted", "text.missingFonts"];
|
||||
|
||||
/// Coverage: (implemented, total, missing ids). `extra` lists ids implemented by the UI layer.
|
||||
pub fn parity(extra: &[&str]) -> (usize, usize, Vec<&'static str>) {
|
||||
|
||||
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"component.resetOverride",
|
||||
"Reset override",
|
||||
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}",
|
||||
"{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|modes|component|props|slot; all when omitted)}",
|
||||
true,
|
||||
reset_override
|
||||
),
|
||||
@@ -786,6 +786,38 @@ fn push_changes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"layers": pushed}))
|
||||
}
|
||||
|
||||
fn restore_prop_defs(doc: &Document, props: &BTreeMap<String, Value>) -> BTreeMap<String, PropDef> {
|
||||
let mut defs = BTreeMap::new();
|
||||
for (k, v) in props {
|
||||
let def = match v {
|
||||
Value::Bool(b) => PropDef::Boolean { default: *b },
|
||||
Value::String(t) => PropDef::Text { default: t.clone() },
|
||||
other => match id_of(other) {
|
||||
Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() },
|
||||
_ => continue,
|
||||
},
|
||||
};
|
||||
defs.insert(k.clone(), def);
|
||||
}
|
||||
defs
|
||||
}
|
||||
|
||||
fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap<NodeId, NodeId> {
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
let mut used: BTreeSet<NodeId> = BTreeSet::new();
|
||||
map.insert(i, component);
|
||||
used.insert(component);
|
||||
for d in sub.iter().filter(|d| **d != i) {
|
||||
let src = doc.node(*d).and_then(|n| n.source);
|
||||
let nid = match src {
|
||||
Some(s) if !doc.contains(s) && used.insert(s) => s,
|
||||
_ => doc.alloc_id(),
|
||||
};
|
||||
map.insert(*d, nid);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.restoreMain";
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
|
||||
@@ -804,35 +836,13 @@ fn restore_main(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// The main comes back with its old id (other instances point to it) and its layers with the
|
||||
// ids their copies mirror, so every instance re-links by source.
|
||||
let sub = doc.subtree(i);
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
let mut used: BTreeSet<NodeId> = BTreeSet::new();
|
||||
map.insert(i, component);
|
||||
used.insert(component);
|
||||
for d in sub.iter().filter(|d| **d != i) {
|
||||
let src = doc.node(*d).and_then(|n| n.source);
|
||||
let nid = match src {
|
||||
Some(s) if !doc.contains(s) && used.insert(s) => s,
|
||||
_ => doc.alloc_id(),
|
||||
};
|
||||
map.insert(*d, nid);
|
||||
}
|
||||
let map = restore_id_map(doc, i, component, &sub);
|
||||
let mut nodes = Vec::new();
|
||||
let mut nested = Vec::new();
|
||||
for d in &sub {
|
||||
let Some(mut n) = doc.node(*d).cloned() else { continue };
|
||||
if *d == i {
|
||||
let mut defs = BTreeMap::new();
|
||||
for (k, v) in &props {
|
||||
let def = match v {
|
||||
Value::Bool(b) => PropDef::Boolean { default: *b },
|
||||
Value::String(t) => PropDef::Text { default: t.clone() },
|
||||
other => match id_of(other) {
|
||||
Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() },
|
||||
_ => continue,
|
||||
},
|
||||
};
|
||||
defs.insert(k.clone(), def);
|
||||
}
|
||||
let defs = restore_prop_defs(doc, &props);
|
||||
n.kind = NodeKind::Component { description: String::new(), props: defs };
|
||||
n.source = None;
|
||||
n.overrides.clear();
|
||||
|
||||
@@ -111,7 +111,8 @@ pub fn frame_at(doc: &Document, page: NodeId, pt: Point, exclude: &[NodeId]) ->
|
||||
return None;
|
||||
}
|
||||
let n = doc.node(id)?;
|
||||
if !n.visible || n.locked {
|
||||
// Board objects (stickies, shapes with text) never take layers in.
|
||||
if !n.visible || n.locked || n.is_board_atom() {
|
||||
return None;
|
||||
}
|
||||
let is_container = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. });
|
||||
|
||||
@@ -495,6 +495,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
out.push(nid);
|
||||
}
|
||||
// Board connectors copied with what they join join the copies.
|
||||
let map = crate::board::copy_map(doc, &ids, &out);
|
||||
crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false);
|
||||
s.set_selection(out.clone());
|
||||
s.ds_mut()?.last_duplicate = Some(Duplicated { sources: ids, copies: out.clone() });
|
||||
Ok(json!({"ids": out}))
|
||||
@@ -676,6 +679,10 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
|
||||
out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?);
|
||||
all.extend(map);
|
||||
}
|
||||
// Board connectors pasted with what they join join the pasted copies.
|
||||
let originals: Vec<(NodeId, figcraft_doc::BoardItem)> =
|
||||
clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect();
|
||||
crate::board::rejoin(doc, &originals, &all, !copy_local);
|
||||
if let Some(src) = foreign {
|
||||
crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?;
|
||||
}
|
||||
@@ -692,7 +699,10 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let target = id(p, "parent").or_else(|| {
|
||||
sel.first()
|
||||
.copied()
|
||||
.filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. })))
|
||||
.filter(|i| {
|
||||
sel.len() == 1
|
||||
&& doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom())
|
||||
})
|
||||
.filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i)))
|
||||
});
|
||||
let parent = target.unwrap_or(page);
|
||||
@@ -1366,10 +1376,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) };
|
||||
if c.a < 1.0 {
|
||||
slot.opacity = c.a;
|
||||
slot.color_alpha = c.a;
|
||||
}
|
||||
}
|
||||
if let Some(o) = num(p, "opacity") {
|
||||
slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32;
|
||||
slot.color_alpha = 1.0;
|
||||
}
|
||||
if let Some(v) = boolean(p, "visible") {
|
||||
slot.visible = v;
|
||||
|
||||
@@ -11,22 +11,34 @@ use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{DocState, EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// A new file of `kind` in a tab of its own.
|
||||
fn new_file(s: &mut Session, p: &Value, kind: FileKind) -> Result<Value> {
|
||||
let name = string(p, "name").unwrap_or_else(|| if kind == FileKind::Board { "Untitled board" } else { "Untitled" }.into());
|
||||
let mut doc = Document::new(name);
|
||||
doc.kind = kind;
|
||||
// A property of the page from here on: a later change of theme leaves it alone. Boards are
|
||||
// light whatever the theme.
|
||||
let background = if kind == FileKind::Board { BOARD_PAGE } else { s.new_page_background() };
|
||||
for page in doc.pages.clone() {
|
||||
if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
|
||||
*b = background;
|
||||
}
|
||||
}
|
||||
s.docs.push(DocState::new(doc, None));
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active, "kind": kind.id()}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("file.new", "New design file", "{name?}", false, |s, p| {
|
||||
let name = string(p, "name").unwrap_or_else(|| "Untitled".into());
|
||||
let mut doc = Document::new(name);
|
||||
// A property of the page from here on: a later change of theme leaves it alone.
|
||||
let background = s.new_page_background();
|
||||
for page in doc.pages.clone() {
|
||||
if let Some(NodeKind::Page { background: b, .. }) = doc.node_mut(page).map(|n| &mut n.kind) {
|
||||
*b = background;
|
||||
}
|
||||
}
|
||||
s.docs.push(DocState::new(doc, None));
|
||||
s.active = s.docs.len() - 1;
|
||||
Ok(json!({"index": s.active}))
|
||||
}),
|
||||
spec!("file.new", "New design file", "{name?}", false, |s, p| new_file(s, p, FileKind::Design)),
|
||||
spec!(
|
||||
"file.newBoard",
|
||||
"New board",
|
||||
"{name?} a board: sticky notes, shapes with text, connectors, marker and stamps on a light page",
|
||||
false,
|
||||
|s, p| new_file(s, p, FileKind::Board)
|
||||
),
|
||||
spec!(
|
||||
"file.open",
|
||||
"Open",
|
||||
@@ -366,7 +378,7 @@ fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result<usize> {
|
||||
if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) }
|
||||
}
|
||||
|
||||
fn read_version(v: &Version) -> Result<Arc<Document>> {
|
||||
pub(crate) fn read_version(v: &Version) -> Result<Arc<Document>> {
|
||||
v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name)))
|
||||
}
|
||||
|
||||
@@ -476,11 +488,11 @@ pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
}
|
||||
|
||||
fn ext(path: &str) -> String {
|
||||
std::path::Path::new(path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase()
|
||||
std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn file_stem(path: &str) -> String {
|
||||
std::path::Path::new(path).file_stem().and_then(|e| e.to_str()).unwrap_or("Untitled").to_string()
|
||||
std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string()
|
||||
}
|
||||
|
||||
/// The counts the import report shows for a freshly loaded document.
|
||||
@@ -1169,8 +1181,11 @@ fn export_path(name: &str) -> String {
|
||||
if parts.is_empty() { "Untitled".into() } else { parts.join("/") }
|
||||
}
|
||||
|
||||
/// `name` without path separators and characters file systems reject.
|
||||
fn safe_file_name(name: &str) -> String {
|
||||
/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and
|
||||
/// control characters become `_`); everything else is kept, up to the first 200 characters.
|
||||
/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before
|
||||
/// the number and extension it adds).
|
||||
pub fn safe_file_name(name: &str) -> String {
|
||||
name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,7 @@ fn registry() -> &'static Registry {
|
||||
list.extend(crate::online::specs());
|
||||
list.extend(crate::invite_links::specs());
|
||||
list.extend(crate::templates::specs());
|
||||
list.extend(crate::board::specs());
|
||||
let mut index = HashMap::new();
|
||||
for (i, s) in list.iter().enumerate() {
|
||||
if index.insert(s.id, i).is_some() {
|
||||
|
||||
@@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st
|
||||
|
||||
/// Keys `node.set` understands (documented for agents).
|
||||
pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \
|
||||
strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \
|
||||
clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \
|
||||
mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \
|
||||
@@ -372,14 +372,21 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
|
||||
/// bindings last so paints set in the same call don't detach them.
|
||||
/// `"none"` (either case) and null ask for no paint, as an empty `fills`/`strokes` list does.
|
||||
fn paint_none(v: &Value) -> bool {
|
||||
v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none"))
|
||||
}
|
||||
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style
|
||||
/// links after the values so a link wins over values its style sets, variable bindings last so
|
||||
/// paints set in the same call don't detach them.
|
||||
fn apply_order(k: &str) -> u8 {
|
||||
match k {
|
||||
"width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
"boundVariables" => 4,
|
||||
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
|
||||
"boundVariables" => 5,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
@@ -462,13 +469,21 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
"fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
|
||||
"fill" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
|
||||
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
|
||||
if paint_none(v) {
|
||||
set_paints(doc.get_mut(id)?, true, vec![]);
|
||||
} else {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?;
|
||||
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
|
||||
}
|
||||
}
|
||||
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
|
||||
"stroke" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
|
||||
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
|
||||
if paint_none(v) {
|
||||
set_paints(doc.get_mut(id)?, false, vec![]);
|
||||
} else {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?;
|
||||
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
|
||||
}
|
||||
}
|
||||
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
|
||||
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
|
||||
@@ -533,8 +548,12 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
} else {
|
||||
v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s)))
|
||||
};
|
||||
let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
|
||||
if !st.is_none() && !crate::devinspect::has_status(doc, id) {
|
||||
return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one"));
|
||||
}
|
||||
let n = doc.get_mut(id)?;
|
||||
n.dev_status = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
|
||||
n.dev_status = st;
|
||||
// Marking a layer (again) starts from its current contents.
|
||||
n.dev_changed = false;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"dev.compare",
|
||||
"Compare changes",
|
||||
"{id? (default the first selected layer), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked (`snapshot` is false when that isn't known: a file from before snapshots were kept)",
|
||||
"{id? (default the first selected layer), version? (a version id from version.list: compare with that saved version instead; any top-level frame or section), show? (open the compare view in the app)} → {id, name, changed, snapshot, added, removed, modified, truncated, version: {id, name, time} | null, changes: [{id, name, type, change: added|removed|changed, depth, layers, properties?: [{section, property, before, after, text}]}]} what changed in a ready-for-dev frame or section since it was marked, or since the version (`snapshot` is false when that isn't known: a file from before snapshots were kept)",
|
||||
false,
|
||||
compare
|
||||
),
|
||||
@@ -104,14 +104,30 @@ fn resolve_change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "dev.compare";
|
||||
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame or section or pass `id`"))?;
|
||||
let doc = s.doc()?;
|
||||
let version = match get(p, "version") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(v)) => Some(v.clone()),
|
||||
Some(_) => return Err(bad(cmd, "version: a version id (version.list)")),
|
||||
};
|
||||
let ds = s.ds()?;
|
||||
let doc = &ds.doc;
|
||||
let n = doc.node(id).ok_or_else(|| bad(cmd, "no such layer"))?;
|
||||
if !has_status(doc, id) || n.dev_status != DevStatus::ReadyForDev {
|
||||
return Err(bad(cmd, "compare a frame or section marked ready for dev"));
|
||||
if !has_status(doc, id) {
|
||||
return Err(bad(cmd, "compare a top-level frame or a section"));
|
||||
}
|
||||
let v = serde_json::to_value(crate::devcompare::compare(doc, id)).unwrap_or(Value::Null);
|
||||
let (cmp, against) = match &version {
|
||||
Some(v) => {
|
||||
let ver = ds.versions.iter().find(|x| x.id == *v).ok_or_else(|| bad(cmd, format!("no version `{v}`")))?;
|
||||
let before = crate::cmd::file::read_version(ver)?;
|
||||
(crate::devcompare::compare_with(&before, doc, id), json!({"id": ver.id, "name": ver.name, "time": ver.time}))
|
||||
}
|
||||
None if n.dev_status != DevStatus::ReadyForDev => return Err(bad(cmd, "compare a frame or section marked ready for dev, or pass `version`")),
|
||||
None => (crate::devcompare::compare(doc, id), Value::Null),
|
||||
};
|
||||
let mut v = serde_json::to_value(cmp).unwrap_or(Value::Null);
|
||||
v["version"] = against;
|
||||
if boolean(p, "show") == Some(true) {
|
||||
s.request_ui("view.devCompare", json!({"id": id}));
|
||||
s.request_ui("view.devCompare", json!({"id": id, "version": version}));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
@@ -107,6 +107,20 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
"vector.fillRule",
|
||||
"Set fill rule",
|
||||
"{ids? | id?, region? (a filled area of a vector; every area when left out), rule: nonZero|evenOdd}",
|
||||
true,
|
||||
fill_rule
|
||||
),
|
||||
spec!(
|
||||
"vector.reverseLoop",
|
||||
"Reverse loop",
|
||||
"{ids? | id?, region, loop} (a loop of a filled area; its direction flips, which changes how the area fills)",
|
||||
true,
|
||||
reverse_loop
|
||||
),
|
||||
spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify),
|
||||
spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path),
|
||||
spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| {
|
||||
@@ -729,3 +743,146 @@ fn resize_to_fit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
Ok(json!({"ids": fitted}))
|
||||
}
|
||||
|
||||
/// The vector network of `id`, when it is a vector layer.
|
||||
fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> {
|
||||
match &mut doc.get_mut(id).ok()?.kind {
|
||||
NodeKind::Vector { network } => Some(network),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The id a fill rule is written as.
|
||||
fn rule_id(rule: WindingRule) -> &'static str {
|
||||
match rule {
|
||||
WindingRule::NonZero => "nonZero",
|
||||
WindingRule::EvenOdd => "evenOdd",
|
||||
}
|
||||
}
|
||||
|
||||
/// `region` as an index: absent (or null) means every area.
|
||||
fn area_index(cmd: &str, p: &Value) -> Result<Option<usize>> {
|
||||
match get(p, "region") {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => {
|
||||
let k = f64_of(v).ok_or_else(|| bad(cmd, "`region` must be a number"))?;
|
||||
if !(0.0..1e9).contains(&k) || k.fract() != 0.0 {
|
||||
return Err(bad(cmd, "`region` out of range"));
|
||||
}
|
||||
Ok(Some(k as usize))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Segments another one- or two-segment loop lists: a short loop takes its direction from the ends
|
||||
/// its segments are stored with, so re-storing one of those would turn that loop round too. A loop
|
||||
/// of three or more segments is left out: its ring is walked in list order, which the stored ends
|
||||
/// don't decide.
|
||||
fn short_loop_segments(net: &VectorNetwork, skip: (usize, usize)) -> std::collections::HashSet<usize> {
|
||||
let mut out = std::collections::HashSet::new();
|
||||
for (r, region) in net.regions.iter().enumerate() {
|
||||
for (l, lp) in region.loops.iter().enumerate() {
|
||||
if (r, l) != skip && lp.len() <= 2 {
|
||||
out.extend(lp.iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn fill_rule(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = req_targets("vector.fillRule", s, p)?;
|
||||
let rule = match string(p, "rule").as_deref().map(str::trim) {
|
||||
Some("evenOdd" | "evenodd" | "EVENODD") => WindingRule::EvenOdd,
|
||||
Some("nonZero" | "nonzero" | "NONZERO") => WindingRule::NonZero,
|
||||
_ => return Err(bad("vector.fillRule", "`rule` is nonZero or evenOdd")),
|
||||
};
|
||||
let only = area_index("vector.fillRule", p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut count = 0usize;
|
||||
for id in ids {
|
||||
let Some(net) = network_mut(doc, id) else { continue };
|
||||
match only {
|
||||
Some(r) => {
|
||||
if let Some(region) = net.regions.get_mut(r) {
|
||||
region.winding = rule;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for region in &mut net.regions {
|
||||
region.winding = rule;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
return Err(EngineError::Disabled("vector.fillRule".into(), "no filled area to set (select a vector)".into()));
|
||||
}
|
||||
Ok(json!({"regions": count, "rule": rule_id(rule)}))
|
||||
}
|
||||
|
||||
/// One loop of a vector, worked out but not yet written back: its segments in the new order and,
|
||||
/// for a loop of one or two segments, the one to store the other way.
|
||||
struct LoopFlip {
|
||||
id: NodeId,
|
||||
turned: Vec<usize>,
|
||||
store: Option<usize>,
|
||||
}
|
||||
|
||||
/// Turn one loop of a filled area round: its segments are walked the other way, so the area it
|
||||
/// bounds counts the other way in the winding (which turns a hole into a filled part and back).
|
||||
///
|
||||
/// How a loop is walked depends on how many segments it has:
|
||||
/// - three or more: the order they are listed in, each segment turned to continue from the point
|
||||
/// reached. Reversing the list is therefore enough, and the stored ends are left alone (a shared
|
||||
/// segment's ends belong to its neighbour too);
|
||||
/// - two: the same ring either way round, so one of the two has to be stored the other way instead;
|
||||
/// - one: only its stored ends say which way it runs, so they are swapped.
|
||||
///
|
||||
/// A short loop can only turn round by re-storing a segment, which is refused when another short
|
||||
/// loop lists that segment too: it would turn that area round as well.
|
||||
fn reverse_loop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "vector.reverseLoop";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let region = req_index(cmd, p, "region")?;
|
||||
let index = req_index(cmd, p, "loop")?;
|
||||
let doc = s.doc_mut()?;
|
||||
// Work every loop out before touching the document: one that can't turn round refuses the call
|
||||
// and the others stay as they were.
|
||||
let mut flips: Vec<LoopFlip> = Vec::new();
|
||||
for id in ids {
|
||||
let Some(NodeKind::Vector { network }) = doc.node(id).map(|n| &n.kind) else { continue };
|
||||
let Some(loop_) = network.regions.get(region).and_then(|r| r.loops.get(index)) else { continue };
|
||||
if loop_.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let mut store = None;
|
||||
if loop_.len() <= 2 {
|
||||
let busy = short_loop_segments(network, (region, index));
|
||||
let Some(pick) = loop_.iter().copied().filter(|s| !busy.contains(s)).min() else {
|
||||
return Err(EngineError::Disabled(
|
||||
cmd.into(),
|
||||
"the loop shares its segments with another one- or two-segment loop, so turning it round would turn that one round too".into(),
|
||||
));
|
||||
};
|
||||
store = Some(pick);
|
||||
}
|
||||
flips.push(LoopFlip { id, turned: loop_.iter().rev().copied().collect(), store });
|
||||
}
|
||||
if flips.is_empty() {
|
||||
return Err(EngineError::Disabled(cmd.into(), "no such loop (select a vector with filled areas)".into()));
|
||||
}
|
||||
for LoopFlip { id, turned, store } in flips {
|
||||
let Some(net) = network_mut(doc, id) else { continue };
|
||||
if let Some(seg) = store.and_then(|i| net.segments.get_mut(i)) {
|
||||
std::mem::swap(&mut seg.a, &mut seg.b);
|
||||
std::mem::swap(&mut seg.ta, &mut seg.tb);
|
||||
}
|
||||
if let Some(l) = net.regions.get_mut(region).and_then(|r| r.loops.get_mut(index)) {
|
||||
*l = turned;
|
||||
}
|
||||
}
|
||||
Ok(json!({"reversed": true}))
|
||||
}
|
||||
|
||||
@@ -804,10 +804,10 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
|
||||
/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the
|
||||
/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a
|
||||
/// typed colour keeps it. An opacity set while bound multiplies on top and stays.
|
||||
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the
|
||||
/// alpha a typed colour had (see `Paint::solid`) out of the opacity, so the variable's alpha applies
|
||||
/// once; an opacity set on its own, before or while bound, multiplies on top and stays. Unbinding
|
||||
/// moves the colour's alpha back into the opacity, where a typed colour keeps it.
|
||||
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
let Some((list, i, "color")) = list_binding(field) else { return };
|
||||
@@ -819,7 +819,14 @@ pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], fie
|
||||
if let Some(p) = paints.get_mut(i)
|
||||
&& let PaintKind::Solid { color } = &mut p.kind
|
||||
{
|
||||
p.opacity = if bind { 1.0 } else { p.opacity * color.a };
|
||||
if bind {
|
||||
// What's left is the opacity set on its own (unknown under a colour alpha of 0: 1).
|
||||
p.opacity = if p.color_alpha > 0.0 { (p.opacity / p.color_alpha).clamp(0.0, 1.0) } else { 1.0 };
|
||||
p.color_alpha = 1.0;
|
||||
} else {
|
||||
p.opacity *= color.a;
|
||||
p.color_alpha *= color.a;
|
||||
}
|
||||
color.a = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,7 @@ fn pref(s: &mut Session, p: &Value, f: fn(&mut crate::Prefs) -> &mut bool) -> Re
|
||||
Ok(json!({"on": *slot}))
|
||||
}
|
||||
|
||||
fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result<Value> {
|
||||
// A file you can't edit now (view only, or on its way to a server): moving around only.
|
||||
if let Some(why) = s.active_read_only().filter(|_| !matches!(t, ToolId::Move | ToolId::Hand)) {
|
||||
return Err(EngineError::Disabled(format!("tool.{}", t.id()), why.into()));
|
||||
@@ -465,11 +465,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"tool.set",
|
||||
"Choose tool",
|
||||
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder}",
|
||||
"{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}",
|
||||
false,
|
||||
|s, p| {
|
||||
let t = req_string("tool.set", p, "tool")?;
|
||||
let t = ToolId::from_id(&t).ok_or_else(|| bad("tool.set", format!("unknown tool `{t}`")))?;
|
||||
if t.on_boards() {
|
||||
crate::board::require(s, &format!("tool.{}", t.id()))?;
|
||||
}
|
||||
tool(s, t)
|
||||
}
|
||||
),
|
||||
@@ -534,7 +537,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)),
|
||||
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
|
||||
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
|
||||
spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)),
|
||||
// `apply: false` is for the UI colour picker: the eyedropper only samples (into
|
||||
// `ToolState::picked`) and the picker writes the colour to the paint it edits.
|
||||
spec!("edit.pickColor", "Pick color", "{apply?}", false, |s, p| {
|
||||
let r = tool(s, ToolId::Eyedropper)?;
|
||||
s.tool.pick_only = !boolean(p, "apply").unwrap_or(true);
|
||||
s.tool.picked = None;
|
||||
Ok(r)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -14,9 +14,9 @@ use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::codegen::CodeLang;
|
||||
use crate::codegen::{CodeLang, Ctx};
|
||||
use crate::component_codegen::{Component, Param, ParamKind, describe, instance_values, js_string, main_of, value_expr};
|
||||
use crate::components::variant_set;
|
||||
use crate::components::{slots_of, variant_set};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
@@ -70,14 +70,106 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer `id`'s code with `link`: the import line and the snippet (an instance's values, else the
|
||||
/// defaults). `None` when `id` isn't a component, set or instance.
|
||||
/// Layer `id`'s code with `link`: the import lines (its own and those of connected components its
|
||||
/// instance swaps place) and the snippet (an instance's values, else the defaults). `None` when
|
||||
/// `id` isn't a component, set or instance.
|
||||
pub fn connected_code(doc: &Document, id: NodeId, lang: CodeLang, link: &CodeLink) -> Option<String> {
|
||||
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
|
||||
let values = instance_values(doc, id, &c).unwrap_or_else(|| c.params.iter().map(|p| (p.prop.clone(), p.default.clone())).collect());
|
||||
let snippet = render(doc, &c, &values, link, lang, "");
|
||||
let import = import_line(link, lang);
|
||||
Some(if import.is_empty() { snippet } else { format!("{import}\n\n{snippet}") })
|
||||
let mut imports = Vec::new();
|
||||
snippet_imports(doc, &c, &values, link, lang, &mut imports);
|
||||
Some(with_imports(&imports, render(doc, &c, &values, link, lang, "")))
|
||||
}
|
||||
|
||||
/// `code` with `imports` at the top: joined to the import block it starts with, else apart.
|
||||
pub fn with_imports(imports: &[String], code: String) -> String {
|
||||
match imports.join("\n") {
|
||||
i if i.is_empty() => code,
|
||||
i if code.starts_with("import ") || code.starts_with("use ") => format!("{i}\n{code}"),
|
||||
i => format!("{i}\n\n{code}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds `line` to `out` unless it's empty or already there.
|
||||
fn add(out: &mut Vec<String>, line: String) {
|
||||
if !line.is_empty() && !out.contains(&line) {
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// The import lines a connected snippet needs: its own and those of the connected components its
|
||||
/// instance swaps place (the ones its template shows).
|
||||
fn snippet_imports(doc: &Document, c: &Component, values: &[(String, Value)], link: &CodeLink, lang: CodeLang, out: &mut Vec<String>) {
|
||||
add(out, import_line(link, lang));
|
||||
for piece in pieces(&link.template) {
|
||||
if let Piece::Value { name, .. } = piece
|
||||
&& let Some((p, v)) = find(c, values, name)
|
||||
&& p.kind == ParamKind::InstanceSwap
|
||||
&& let Some(l) = id_of(v).and_then(|i| self::link(doc, i, lang))
|
||||
{
|
||||
add(out, import_line(l, lang));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The import lines of the connected components the generated code of layer `id` calls
|
||||
/// (instances in its tree, in a component's definition and in the slots an instance fills), once
|
||||
/// each in tree order. A connected `id` shows its snippet ([`connected_code`]) instead.
|
||||
pub fn tree_imports(doc: &Document, id: NodeId, lang: CodeLang) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let Some(n) = doc.node(id) else { return out };
|
||||
let c = main_of(doc, id).and_then(|m| describe(doc, m));
|
||||
match (&n.kind, &c) {
|
||||
// Its call (the slots it fills), then its component's definition.
|
||||
(NodeKind::Instance { .. }, Some(c)) => {
|
||||
call_imports(doc, id, lang, 0, &mut out);
|
||||
tree(&Ctx { doc, root: c.comp, comp: Some(c), base: 0 }, c.comp, lang, 0, &mut out);
|
||||
}
|
||||
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => {
|
||||
tree(&Ctx { doc, root: c.comp, comp: Some(c), base: 0 }, c.comp, lang, 0, &mut out)
|
||||
}
|
||||
_ => tree(&Ctx::new(doc, id), id, lang, 0, &mut out),
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Imports under layer `id` as the code walk sees it: its visible children, instances as calls
|
||||
/// (one an instance swap parameter places is that parameter). shortcut: the depth cap counts from
|
||||
/// the tree's root, not from each slot's; align them if a deep slot tree shows a stray import.
|
||||
fn tree(ctx: &Ctx, id: NodeId, lang: CodeLang, depth: usize, out: &mut Vec<String>) {
|
||||
let Some(n) = ctx.doc.node(id) else { return };
|
||||
if ctx.too_deep(depth) {
|
||||
return;
|
||||
}
|
||||
for k in ctx.kids(n) {
|
||||
let Some(kn) = ctx.doc.node(k) else { continue };
|
||||
let call = matches!(kn.kind, NodeKind::Instance { .. }) && main_of(ctx.doc, k).is_some();
|
||||
match (call, ctx.param(kn, "mainComponent")) {
|
||||
(true, Some(_)) => {}
|
||||
(true, None) => call_imports(ctx.doc, k, lang, depth + 1, out),
|
||||
(false, _) => tree(ctx, k, lang, depth + 1, out),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The imports instance `id` placed as a call brings: a connected one's snippet's, else those of
|
||||
/// the content it puts in its slots.
|
||||
fn call_imports(doc: &Document, id: NodeId, lang: CodeLang, depth: usize, out: &mut Vec<String>) {
|
||||
let Some(c) = main_of(doc, id).and_then(|m| describe(doc, m)) else { return };
|
||||
let Some(values) = instance_values(doc, id, &c) else { return };
|
||||
if let Some(l) = link(doc, id, lang) {
|
||||
return snippet_imports(doc, &c, &values, l, lang, out);
|
||||
}
|
||||
let slots = slots_of(doc, id);
|
||||
for (p, (_, v)) in c.params.iter().zip(&values) {
|
||||
if p.kind == ParamKind::Slot
|
||||
&& v.as_bool() == Some(true)
|
||||
&& let Some((slot, _)) = slots.iter().find(|(_, name)| *name == p.prop)
|
||||
{
|
||||
// The same depth base as the slot's generated content, so its connected children count.
|
||||
tree(&Ctx { doc, root: *slot, comp: None, base: depth }, *slot, lang, depth + 1, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ templates
|
||||
@@ -180,14 +272,17 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Template `t` as pieces. One that doesn't parse, or that skipped `check` (a hand-edited file)
|
||||
/// and is far too long, stays as written: parsing rescans after each literal brace.
|
||||
fn pieces(t: &str) -> Vec<Piece<'_>> {
|
||||
let checkable = t.len() <= MAX_TEMPLATE * 4;
|
||||
checkable.then(|| parse(t).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(t)])
|
||||
}
|
||||
|
||||
/// `link`'s template with `values` (one per parameter of `c`), every line prefixed by `pad`.
|
||||
pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], link: &CodeLink, lang: CodeLang, pad: &str) -> String {
|
||||
// A template that skipped `check` (a hand-edited file) and is far too long stays as written:
|
||||
// parsing rescans after each literal brace.
|
||||
let checkable = link.template.len() <= MAX_TEMPLATE * 4;
|
||||
let pieces = checkable.then(|| parse(&link.template).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(&link.template)]);
|
||||
let mut out = String::new();
|
||||
for piece in pieces {
|
||||
for piece in pieces(&link.template) {
|
||||
match piece {
|
||||
Piece::Text(t) => out.push_str(t),
|
||||
Piece::Value { name, inner } => match find(c, values, name) {
|
||||
|
||||
@@ -231,7 +231,7 @@ impl<'a> Ctx<'a> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn too_deep(&self, depth: usize) -> bool {
|
||||
pub(crate) fn too_deep(&self, depth: usize) -> bool {
|
||||
depth > self.base + MAX_DEPTH
|
||||
}
|
||||
|
||||
|
||||
@@ -109,14 +109,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
/// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's
|
||||
/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it
|
||||
/// in a function component).
|
||||
/// in a function component), with the imports of the connected components it calls at the top.
|
||||
pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
let Some(n) = doc.node(id) else { return String::new() };
|
||||
if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) {
|
||||
return code;
|
||||
}
|
||||
let comp = main_of(doc, id).and_then(|m| describe(doc, m));
|
||||
match (&n.kind, comp) {
|
||||
let code = match (&n.kind, comp) {
|
||||
(NodeKind::Instance { .. }, Some(c)) => format!("{}\n{}", usage(doc, id, lang, 0).unwrap_or_default(), definition(doc, &c, lang)),
|
||||
(NodeKind::Component { .. } | NodeKind::ComponentSet { .. }, Some(c)) => definition(doc, &c, lang),
|
||||
_ => match lang {
|
||||
@@ -135,7 +135,8 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String {
|
||||
out
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
crate::code_connect::with_imports(&crate::code_connect::tree_imports(doc, id, lang), code)
|
||||
}
|
||||
|
||||
fn is_component(doc: &Document, id: NodeId) -> bool {
|
||||
@@ -521,8 +522,7 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option
|
||||
let c = main_of(doc, id).and_then(|m| describe(doc, m))?;
|
||||
let values = instance_values(doc, id, &c)?;
|
||||
let pad = if lang == CodeLang::React { " ".repeat(depth) } else { indent(depth) };
|
||||
// A connected component: its real snippet. shortcut: inside a layer tree it brings no import
|
||||
// line; collect them at the top of the tree's code when people ask for it.
|
||||
// A connected component: its real snippet (its import is at the top of the code: `code`).
|
||||
if let Some(link) = crate::code_connect::link(doc, id, lang) {
|
||||
return Some(crate::code_connect::render(doc, &c, &values, link, lang, &pad));
|
||||
}
|
||||
|
||||
+127
-11
@@ -12,9 +12,10 @@
|
||||
//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment`
|
||||
//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`,
|
||||
//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers
|
||||
//! inside an instance, `component` (a swapped nested instance), `props` (property values of a
|
||||
//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own
|
||||
//! export settings, interactions and resizing are its own, like its place. `position`
|
||||
//! inside an instance, `modes` (the variable modes a layer inside an instance chooses),
|
||||
//! `component` (a swapped nested instance), `props` (property values of a nested instance) and
|
||||
//! `slot` (a slot holding the instance's own content). An instance's own export settings,
|
||||
//! interactions, resizing and variable modes are its own, like its place. `position`
|
||||
//! overrides of layers inside an instance are still read and kept, but new ones are refused
|
||||
//! ([`check_override_edit`]): those layers take their place and constraints from the main.
|
||||
//!
|
||||
@@ -60,6 +61,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[
|
||||
"size",
|
||||
"resizing",
|
||||
"position",
|
||||
"modes",
|
||||
"component",
|
||||
"props",
|
||||
SLOT,
|
||||
@@ -114,6 +116,23 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
|
||||
enclosing_instance(doc, id).is_some()
|
||||
}
|
||||
|
||||
/// Mark the `modes` override on layers inside instances whose variable modes differ from their
|
||||
/// main layer's, for files from before `modes` was an override group. Copies follow their main
|
||||
/// layer's modes, so a layer on other modes chose them itself.
|
||||
pub fn mark_layer_modes(doc: &mut Document) {
|
||||
let chose: Vec<NodeId> = doc
|
||||
.nodes()
|
||||
.filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
|
||||
.map(|n| n.id)
|
||||
.filter(|id| inside_instance(doc, *id))
|
||||
.collect();
|
||||
for id in chose {
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.overrides.insert("modes".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The slot property frame `id` stands for (its name and preferred components): a frame bound to
|
||||
/// a slot property of its component, or an instance's copy of one (followed through its sources).
|
||||
pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> {
|
||||
@@ -335,6 +354,27 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) {
|
||||
}
|
||||
}
|
||||
|
||||
/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis)
|
||||
/// that is still `was`'s follows `new`; what the instance set itself stays in `own`.
|
||||
fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) {
|
||||
if own.sizing_h == was.sizing_h {
|
||||
own.sizing_h = new.sizing_h;
|
||||
}
|
||||
if own.sizing_v == was.sizing_v {
|
||||
own.sizing_v = new.sizing_v;
|
||||
}
|
||||
for (o, w, n) in [
|
||||
(&mut own.min_w, was.min_w, new.min_w),
|
||||
(&mut own.max_w, was.max_w, new.max_w),
|
||||
(&mut own.min_h, was.min_h, new.min_h),
|
||||
(&mut own.max_h, was.max_h, new.max_h),
|
||||
] {
|
||||
if *o == w {
|
||||
*o = n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them.
|
||||
pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
|
||||
if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) {
|
||||
@@ -415,6 +455,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
|
||||
(b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h);
|
||||
}
|
||||
"position" => to.transform = from.transform,
|
||||
"modes" => to.var_modes = from.var_modes.clone(),
|
||||
"component" => {
|
||||
if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) {
|
||||
*b = *a;
|
||||
@@ -779,6 +820,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
|
||||
if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) {
|
||||
let differs = diff_groups(prev, &comp);
|
||||
root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str()));
|
||||
follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout);
|
||||
}
|
||||
carry_overrides(&root, &mut fresh, Carry::Root);
|
||||
if let Some(r) = doc.node_mut(inst) {
|
||||
@@ -830,9 +872,10 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
|
||||
}
|
||||
// Nested instances showing another main than the main's copy (swapped): rebuild from it,
|
||||
// with the old layers below them this rebuild didn't reuse.
|
||||
let unused: BTreeMap<NodeId, std::sync::Arc<Node>> = old.into_iter().filter(|(i, _)| !used.contains(i) && !doc.contains(*i)).collect();
|
||||
let unused: BTreeMap<NodeId, std::sync::Arc<Node>> =
|
||||
old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
|
||||
for n in nested {
|
||||
let below: BTreeMap<NodeId, std::sync::Arc<Node>> = unused
|
||||
let mut below: BTreeMap<NodeId, std::sync::Arc<Node>> = unused
|
||||
.iter()
|
||||
.filter(|(_, x)| {
|
||||
let mut cur = x.parent;
|
||||
@@ -847,10 +890,77 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
|
||||
})
|
||||
.map(|(i, x)| (*i, x.clone()))
|
||||
.collect();
|
||||
take_placing_overrides(doc, n, &old, &mut below);
|
||||
sync_instance_depth(doc, n, depth + 1, below);
|
||||
}
|
||||
}
|
||||
|
||||
/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the
|
||||
/// copies just made of the outer main's, and `below`) takes the overrides held along the source
|
||||
/// chain it had before this rebuild (`before`), through layers inside instances in the mains that
|
||||
/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a
|
||||
/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main.
|
||||
/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value.
|
||||
fn take_placing_overrides(
|
||||
doc: &mut Document,
|
||||
n: NodeId,
|
||||
before: &BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
below: &mut BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
) {
|
||||
let placing = placing_mains(doc, n);
|
||||
for id in doc.subtree(n).into_iter().filter(|i| *i != n) {
|
||||
let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default();
|
||||
if let Some(layer) = doc.node_mut(id) {
|
||||
take(layer, &held);
|
||||
}
|
||||
}
|
||||
for layer in below.values_mut() {
|
||||
let held = held_along(doc, layer, &placing);
|
||||
if !held.is_empty() {
|
||||
take(std::sync::Arc::make_mut(layer), &held);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The mains placing nested instance `n`: those of the layers it mirrors, outward.
|
||||
fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet<NodeId> {
|
||||
let mut out = BTreeSet::new();
|
||||
let mut cur = doc.node(n).and_then(|x| x.source);
|
||||
for _ in 0..=MAX_DEPTH {
|
||||
let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break };
|
||||
out.extend(owning_component(doc, s.id));
|
||||
cur = s.source;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The overrides but geometry and slot content held along `layer`'s source chain while it runs
|
||||
/// through layers inside instances in `placing` mains, each with the nearest layer holding it.
|
||||
fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet<NodeId>) -> BTreeMap<String, std::sync::Arc<Node>> {
|
||||
let mut out = BTreeMap::new();
|
||||
let mut cur = layer.source;
|
||||
for _ in 0..=MAX_DEPTH {
|
||||
let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break };
|
||||
if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) {
|
||||
break;
|
||||
}
|
||||
for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) {
|
||||
out.entry(g.clone()).or_insert_with(|| s.clone());
|
||||
}
|
||||
cur = s.source;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Give `layer` the `held` groups it has no override of its own for.
|
||||
fn take(layer: &mut Node, held: &BTreeMap<String, std::sync::Arc<Node>>) {
|
||||
for (g, from) in held {
|
||||
if layer.overrides.insert(g.clone()) {
|
||||
copy_group(from, layer, g);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether main components `a` and `b` are two variants of one set.
|
||||
fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool {
|
||||
a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s))
|
||||
@@ -1049,6 +1159,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> {
|
||||
if a.interactions != b.interactions {
|
||||
out.push("interactions");
|
||||
}
|
||||
if a.var_modes != b.var_modes {
|
||||
out.push("modes");
|
||||
}
|
||||
if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) {
|
||||
if x != y {
|
||||
out.push("component");
|
||||
@@ -1146,10 +1259,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
|
||||
}
|
||||
let raw = diff_groups(b, n);
|
||||
let mut groups = raw.clone();
|
||||
// An instance's own place, resizing, export settings and interactions are its own, not
|
||||
// overrides.
|
||||
// An instance's own place, resizing, export settings, interactions and variable modes are
|
||||
// its own, not overrides.
|
||||
if top_root {
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions"));
|
||||
groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes"));
|
||||
}
|
||||
if groups.iter().any(|g| matches!(*g, "size" | "position")) {
|
||||
groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g));
|
||||
@@ -1969,7 +2082,7 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
let mut groups: Vec<String> = value_groups(m, n)
|
||||
.into_iter()
|
||||
.filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str()))
|
||||
.filter(|g| placed_source.is_some() || g != "resizing")
|
||||
.filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
|
||||
.collect();
|
||||
let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) };
|
||||
if named {
|
||||
@@ -1993,8 +2106,11 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) {
|
||||
}
|
||||
if let Some(n) = doc.node_mut(inst) {
|
||||
n.source = root_base;
|
||||
// A top-level instance's resizing is its own (a nested one's overrides its main layer's).
|
||||
n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing"));
|
||||
// A top-level instance's resizing and modes are its own (a nested one's override its main
|
||||
// layer's).
|
||||
n.overrides.retain(|g| {
|
||||
!matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes"))
|
||||
});
|
||||
n.overrides.extend(root_groups);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,6 +657,64 @@ fn switching_variants_keeps_overrides_where_the_variants_agreed() {
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
/// A "Layout" set: Row (horizontal, hugging, a 40 wide "A") and Stacked (vertical, fixed 300 wide,
|
||||
/// "A" filling it). Returns (set, row, stacked).
|
||||
fn layout_set(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||||
let mut variants = Vec::new();
|
||||
for (k, (name, props, child)) in [
|
||||
("Layout=Row", json!({"layout": "horizontal", "gap": 8, "sizingH": "hug", "sizingV": "hug", "fills": []}), json!({})),
|
||||
(
|
||||
"Layout=Stacked",
|
||||
json!({"layout": "vertical", "gap": 8, "sizingH": "fixed", "width": 300, "sizingV": "hug", "fills": []}),
|
||||
json!({"sizingH": "fill"}),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let c = idv(&ex(s, "node.create", json!({"type": "component", "name": name, "x": k as f64 * 400.0, "props": props})));
|
||||
ex(s, "node.create", json!({"type": "rectangle", "parent": c, "name": "A", "width": 40, "height": 20, "props": child}));
|
||||
variants.push(c);
|
||||
}
|
||||
let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()})));
|
||||
(set, variants[0], variants[1])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_variants_takes_the_new_variants_resizing_unless_the_instance_set_its_own() {
|
||||
let mut s = Session::new();
|
||||
let (set, row, stacked) = layout_set(&mut s);
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200})));
|
||||
assert_eq!(component_of(&s, i), row);
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Layout": "Stacked"}}));
|
||||
assert_eq!(component_of(&s, i), stacked);
|
||||
let direct = idv(&ex(&mut s, "instance.create", json!({"component": stacked, "x": 0, "y": 400})));
|
||||
let (n, d) = (node(&s, i), node(&s, direct));
|
||||
assert_eq!(n.child_layout.sizing_h, Sizing::Fixed, "Row's Hug was never the instance's own: it takes Stacked's Fixed");
|
||||
assert_eq!((n.width, n.child_layout.sizing_h), (d.width, d.child_layout.sizing_h), "the same as an instance made from Stacked");
|
||||
let a = layer(&s, i, "A").unwrap();
|
||||
assert_eq!((node(&s, a).child_layout.sizing_h, node(&s, a).width), (Sizing::Fill, 300.0), "A fills the 300 wide instance");
|
||||
// Filling an auto layout parent afterwards leaves A filling, with nothing turned into an override.
|
||||
let col = idv(&ex(
|
||||
&mut s,
|
||||
"node.create",
|
||||
json!({"type": "frame", "name": "Column", "x": 600, "y": 0, "width": 500, "props": {"layout": "vertical", "sizingH": "fixed", "sizingV": "hug", "fills": []}}),
|
||||
));
|
||||
ex(&mut s, "node.reparent", json!({"ids": [i], "parent": col}));
|
||||
ex(&mut s, "node.set", json!({"ids": [i], "props": {"sizingH": "fill"}}));
|
||||
let a = layer(&s, i, "A").unwrap();
|
||||
assert_eq!(node(&s, a).child_layout.sizing_h, Sizing::Fill, "A still fills");
|
||||
assert!(!node(&s, a).overrides.contains("resizing"), "A's resizing is still the main's");
|
||||
assert_eq!(node(&s, a).width, node(&s, i).width);
|
||||
// Resizing the instance set itself stays across a switch.
|
||||
let own = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600})));
|
||||
ex(&mut s, "node.set", json!({"ids": [own], "props": {"minWidth": 120}}));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [own], "properties": {"Layout": "Stacked"}}));
|
||||
assert_eq!(node(&s, own).child_layout.min_w, Some(120.0), "its own min width stays");
|
||||
assert_eq!(node(&s, own).child_layout.sizing_h, Sizing::Fixed, "its sizing, still Row's, follows Stacked");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_combinations_order_default_and_conflicts() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -201,6 +201,20 @@ fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() {
|
||||
assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_marks_a_nested_roots_own_mode_but_not_a_top_level_instances() {
|
||||
let mut s = scene();
|
||||
let modes = BTreeMap::from([("Tone".to_string(), "B".to_string())]);
|
||||
s.doc.node_mut(s.nested).unwrap().var_modes = modes.clone();
|
||||
both(&mut s);
|
||||
assert!(s.doc.node(s.nested).unwrap().overrides.contains("modes"), "its slot in Outer sets no mode");
|
||||
let page = s.doc.first_page().unwrap();
|
||||
let detached = mirror(&mut s.doc, s.nested, page);
|
||||
rebase_instance(&mut s.doc, detached);
|
||||
let n = s.doc.node(detached).unwrap();
|
||||
assert_eq!((&n.var_modes, n.overrides.contains("modes")), (&modes, false), "a top-level instance's modes are its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_does_not_preserve_existing_foreign_or_stale_sources() {
|
||||
let mut s = scene();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Dev Mode "Compare changes": what changed in a ready-for-dev frame or section since it was
|
||||
//! marked. The document keeps a snapshot of the layer from just before its first change
|
||||
//! ([`figcraft_doc::handoff`]); this compares it with the layer as it is now, layer by layer
|
||||
//! marked, or in any frame or section since a saved version. The document keeps a snapshot of a
|
||||
//! ready-for-dev layer from just before its first change ([`figcraft_doc::handoff`]); this compares
|
||||
//! it (or the layer in the version) with the layer as it is now, layer by layer
|
||||
//! (matched by id): layers added and removed, and for the others their properties formatted the
|
||||
//! way Inspect shows them (`width 120 → 136`, `fill #FF0000 → #E03030`).
|
||||
|
||||
@@ -93,20 +94,25 @@ pub struct Comparison {
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// The changes to layer `id` since it was marked ready for dev (empty when it hasn't changed).
|
||||
pub fn compare(doc: &Document, id: NodeId) -> Comparison {
|
||||
let n = doc.node(id);
|
||||
let mut out = Comparison {
|
||||
/// Nothing compared yet for layer `id` of `doc` (`snapshot` false: the before isn't known).
|
||||
pub fn empty(doc: &Document, id: NodeId) -> Comparison {
|
||||
Comparison {
|
||||
id,
|
||||
name: n.map(|n| n.name.clone()).unwrap_or_default(),
|
||||
changed: n.is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed),
|
||||
name: doc.node(id).map(|n| n.name.clone()).unwrap_or_default(),
|
||||
changed: false,
|
||||
snapshot: false,
|
||||
changes: Vec::new(),
|
||||
added: 0,
|
||||
removed: 0,
|
||||
modified: 0,
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// The changes to layer `id` since it was marked ready for dev (empty when it hasn't changed).
|
||||
pub fn compare(doc: &Document, id: NodeId) -> Comparison {
|
||||
let mut out = empty(doc, id);
|
||||
out.changed = doc.node(id).is_some_and(|n| n.dev_status == DevStatus::ReadyForDev && n.dev_changed);
|
||||
if !out.changed {
|
||||
return out;
|
||||
}
|
||||
@@ -117,6 +123,21 @@ pub fn compare(doc: &Document, id: NodeId) -> Comparison {
|
||||
out
|
||||
}
|
||||
|
||||
/// The changes to layer `id` of `now` since `before` (a saved version of the same file: layers
|
||||
/// match by id). A layer the version doesn't have was added as a whole.
|
||||
pub fn compare_with(before: &Document, now: &Document, id: NodeId) -> Comparison {
|
||||
let mut out = empty(now, id);
|
||||
out.snapshot = true;
|
||||
let mut w = Walk { before, now, now_ids: bounded_set(now, id), before_ids: bounded_set(before, id), out: &mut out };
|
||||
if before.contains(id) {
|
||||
w.layer(id, 0);
|
||||
} else {
|
||||
w.whole(now, id, 0, ChangeKind::Added);
|
||||
}
|
||||
out.changed = !out.changes.is_empty();
|
||||
out
|
||||
}
|
||||
|
||||
/// The before and after documents of layer `id` (the snapshot's copy and the document), for
|
||||
/// rendering the two side by side.
|
||||
pub fn before_doc(doc: &Document, id: NodeId) -> Option<Document> {
|
||||
@@ -127,17 +148,7 @@ pub fn before_doc(doc: &Document, id: NodeId) -> Option<Document> {
|
||||
/// Every layer that changed between two versions of a whole document, page by page (pages added
|
||||
/// or removed as a whole), for reviewing a branch.
|
||||
pub fn compare_docs(before: &Document, now: &Document) -> Comparison {
|
||||
let mut out = Comparison {
|
||||
id: NodeId(0),
|
||||
name: now.name.clone(),
|
||||
changed: false,
|
||||
snapshot: true,
|
||||
changes: Vec::new(),
|
||||
added: 0,
|
||||
removed: 0,
|
||||
modified: 0,
|
||||
truncated: false,
|
||||
};
|
||||
let mut out = Comparison { name: now.name.clone(), snapshot: true, ..empty(now, NodeId(0)) };
|
||||
let mut w = Walk { before, now, now_ids: now.ids().collect(), before_ids: before.ids().collect(), out: &mut out };
|
||||
for p in &now.pages {
|
||||
if before.contains(*p) {
|
||||
|
||||
@@ -99,8 +99,9 @@ fn nested_frames_stay_out_of_the_ready_list_and_its_navigation() {
|
||||
let mut s = Session::new();
|
||||
let screen = frame(&mut s, 0.0, "Screen");
|
||||
let card = idv(&ex(&mut s, "frame.create", json!({"parent": screen, "width": 100, "height": 50, "name": "Card"})));
|
||||
// A status a nested frame already stores (an older file, or set through node.set) is ignored.
|
||||
ex(&mut s, "node.set", json!({"id": card, "props": {"devStatus": "readyForDev"}}));
|
||||
// node.set refuses a status on a nested frame, and one an older file already stores is ignored.
|
||||
assert!(s.execute("node.set", &json!({"id": card, "props": {"devStatus": "readyForDev"}})).is_err());
|
||||
s.doc_mut().unwrap().node_mut(card).unwrap().dev_status = DevStatus::ReadyForDev;
|
||||
assert_eq!(ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))["items"], json!([]));
|
||||
assert!(s.execute("dev.focus", &json!({"direction": "next"})).is_err(), "nothing is ready");
|
||||
ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}}));
|
||||
|
||||
@@ -214,6 +214,21 @@ pub struct DevItem {
|
||||
pub name: String,
|
||||
pub page: NodeId,
|
||||
pub status: DevStatus,
|
||||
/// Ready for dev and changed since it was marked.
|
||||
pub changed: bool,
|
||||
}
|
||||
|
||||
/// Whether Dev Mode runs command `id` from the menus and shortcuts: commands that don't edit the
|
||||
/// document, and Dev Mode's own edits (annotations, statuses, dev resources, code connections,
|
||||
/// comments). Choosing a tool other than moving around and commenting is refused too, and so is
|
||||
/// Revert to saved, which isn't an undo step but throws the design's unsaved edits away.
|
||||
pub fn dev_mode_allows(id: &str) -> bool {
|
||||
const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."];
|
||||
match id.strip_prefix("tool.") {
|
||||
Some(t) => matches!(t, "move" | "hand" | "comment"),
|
||||
None if id == "file.revert" => false,
|
||||
None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history),
|
||||
}
|
||||
}
|
||||
|
||||
/// Most items listed.
|
||||
@@ -244,7 +259,8 @@ pub fn dev_items(doc: &Document, status: Option<DevStatus>, page: Option<NodeId>
|
||||
}
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if !n.dev_status.is_none() && status.is_none_or(|s| s == n.dev_status) && has_status(doc, id) {
|
||||
out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status });
|
||||
let changed = n.dev_status == DevStatus::ReadyForDev && n.dev_changed;
|
||||
out.push(DevItem { id, name: n.name.clone(), page: pg, status: n.dev_status, changed });
|
||||
}
|
||||
stack.extend(n.children.iter().rev().copied());
|
||||
}
|
||||
@@ -257,18 +273,21 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"inspect.readyForDev",
|
||||
"Ready for dev",
|
||||
"{status?: readyForDev|completed|any (default readyForDev), page? (page id; default every page)} → {items: [{id, name, page, status}]} frames and sections by handoff status (set it with `node.set {devStatus}`)",
|
||||
"{status?: readyForDev|changed|completed|any (default readyForDev; changed: ready for dev and changed since marked), page? (page id; default every page)} → {items: [{id, name, page, status, changed}]} frames and sections by handoff status (set it with `node.set {devStatus}`)",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "inspect.readyForDev";
|
||||
let status = match string(p, "status").as_deref() {
|
||||
let wanted = string(p, "status");
|
||||
let changed_only = wanted.as_deref().is_some_and(|v| v.eq_ignore_ascii_case("changed"));
|
||||
let status = match wanted.as_deref() {
|
||||
None => Some(DevStatus::ReadyForDev),
|
||||
_ if changed_only => Some(DevStatus::ReadyForDev),
|
||||
Some(v) if v.eq_ignore_ascii_case("any") || v.eq_ignore_ascii_case("all") => None,
|
||||
Some(v) => Some(
|
||||
DevStatus::ALL
|
||||
.into_iter()
|
||||
.find(|d| !d.is_none() && d.id().eq_ignore_ascii_case(v))
|
||||
.ok_or_else(|| crate::bad(cmd, "status: readyForDev|completed|any"))?,
|
||||
.ok_or_else(|| crate::bad(cmd, "status: readyForDev|changed|completed|any"))?,
|
||||
),
|
||||
};
|
||||
let doc = s.doc()?;
|
||||
@@ -276,7 +295,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
Some(v) => Some(id_of(v).filter(|pg| doc.pages.contains(pg)).ok_or_else(|| crate::bad(cmd, "page: a page id"))?),
|
||||
None => None,
|
||||
};
|
||||
Ok(json!({"items": serde_json::to_value(dev_items(doc, status, page)).unwrap_or(Value::Null)}))
|
||||
let mut items = dev_items(doc, status, page);
|
||||
items.retain(|i| i.changed || !changed_only);
|
||||
Ok(json!({"items": serde_json::to_value(items).unwrap_or(Value::Null)}))
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
|
||||
@@ -79,7 +79,7 @@ fn redlines_measure_on_hover_without_alt() {
|
||||
s.tool.overlay.guides.len()
|
||||
};
|
||||
assert_eq!(hover(&mut s), 0, "plain hover doesn't measure");
|
||||
s.tool.redlines = true;
|
||||
s.tool.dev_mode = true;
|
||||
assert!(hover(&mut s) > 0, "Inspect measures on hover");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Dev Mode behaviour suite, written from the public documentation of handoff tools (in our own
|
||||
//! words): the canvas inspects and never edits, what the menus and shortcuts may run there, the
|
||||
//! list of what's ready for development, statuses (ready, changed, completed) and the focus view.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, idv};
|
||||
use crate::devinspect::dev_mode_allows;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
use figcraft_doc::{DevStatus, NodeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent::at(kind, x, y, 1.0)
|
||||
}
|
||||
|
||||
fn drag(s: &mut Session, from: (f64, f64), to: (f64, f64)) {
|
||||
s.pointer(&ev(PointerKind::Down, from.0, from.1)).unwrap();
|
||||
for k in 1..=4 {
|
||||
let t = f64::from(k) / 4.0;
|
||||
s.pointer(&ev(PointerKind::Move, from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t)).unwrap();
|
||||
}
|
||||
s.pointer(&ev(PointerKind::Up, to.0, to.1)).unwrap();
|
||||
}
|
||||
|
||||
fn frame(s: &mut Session, x: f64, name: &str) -> NodeId {
|
||||
idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 200, "height": 200, "name": name})))
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, parent: NodeId, x: f64) -> NodeId {
|
||||
idv(&ex(s, "shape.rectangle", json!({"x": x, "y": 20, "width": 40, "height": 40, "parent": parent})))
|
||||
}
|
||||
|
||||
fn names(v: &Value) -> Vec<String> {
|
||||
v["items"].as_array().map(|a| a.iter().filter_map(|i| i["name"].as_str().map(String::from)).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn status(s: &mut Session, ids: &[NodeId], st: &str) {
|
||||
ex(s, "node.set", json!({"ids": ids, "props": {"devStatus": st}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_dev_mode_canvas_selects_and_pans_but_never_edits() {
|
||||
let mut s = Session::new();
|
||||
let f = frame(&mut s, 0.0, "Screen");
|
||||
let r = rect(&mut s, f, 20.0);
|
||||
let t = idv(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 100, "y": 120, "parent": f})));
|
||||
s.tool.dev_mode = true;
|
||||
let before = s.doc().unwrap().clone();
|
||||
let steps = s.undo_labels(100).len();
|
||||
// A press selects; dragging it moves nothing.
|
||||
drag(&mut s, (40.0, 40.0), (140.0, 90.0));
|
||||
assert_eq!(s.selection(), vec![r]);
|
||||
// Nor do its resize handles.
|
||||
drag(&mut s, (60.0, 60.0), (120.0, 120.0));
|
||||
// Drawing tools only select there.
|
||||
for tool in [ToolId::Rectangle, ToolId::Frame, ToolId::Text, ToolId::Pen, ToolId::Pencil, ToolId::Scale] {
|
||||
s.set_tool(tool);
|
||||
drag(&mut s, (300.0, 300.0), (360.0, 360.0));
|
||||
}
|
||||
s.set_tool(ToolId::Move);
|
||||
// A double-click on text doesn't start typing; on a frame's layer it selects into it.
|
||||
ex(&mut s, "select.set", json!({"ids": [t]}));
|
||||
s.pointer(&ev(PointerKind::DoubleClick, 110.0, 128.0)).unwrap();
|
||||
assert!(s.ds().unwrap().text_edit.is_none(), "no text editing");
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
s.pointer(&ev(PointerKind::DoubleClick, 40.0, 40.0)).unwrap();
|
||||
assert!(s.ds().unwrap().vector_edit.is_none(), "no vector editing");
|
||||
assert!(*s.doc().unwrap() == before, "the design is untouched");
|
||||
assert_eq!(s.undo_labels(100).len(), steps, "nothing to undo");
|
||||
// Keys that edit with a tool (a selected guide's Delete) go to the commands instead.
|
||||
assert!(!s.tool_key("Delete", false, false, false).unwrap());
|
||||
// The hand still pans; comments still start.
|
||||
s.set_tool(ToolId::Hand);
|
||||
let cx = s.ds().unwrap().view.cx;
|
||||
s.pointer(&ev(PointerKind::Down, 500.0, 500.0)).unwrap();
|
||||
s.pointer(&PointerEvent { sx: 550.0, ..ev(PointerKind::Move, 500.0, 500.0) }).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, 500.0, 500.0)).unwrap();
|
||||
assert_ne!(s.ds().unwrap().view.cx, cx, "panned");
|
||||
s.set_tool(ToolId::Comment);
|
||||
s.pointer(&ev(PointerKind::Down, 500.0, 500.0)).unwrap();
|
||||
assert!(s.ui_requests.iter().any(|u| u.kind == "comment.new"));
|
||||
// Back in the design: the same drag moves.
|
||||
s.tool.dev_mode = false;
|
||||
s.set_tool(ToolId::Move);
|
||||
drag(&mut s, (40.0, 40.0), (140.0, 90.0));
|
||||
assert!(s.doc().unwrap().node(r).is_some_and(|n| n.transform.as_coeffs()[4] > 100.0), "moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts() {
|
||||
for id in [
|
||||
"annotation.add",
|
||||
"annotation.edit",
|
||||
"dev.resolveChange",
|
||||
"dev.focus",
|
||||
"devResource.add",
|
||||
"codeConnect.set",
|
||||
"comment.add",
|
||||
"edit.undo",
|
||||
"edit.redo",
|
||||
"edit.copy",
|
||||
"edit.copyAsCss",
|
||||
"select.all",
|
||||
"view.zoomIn",
|
||||
"view.devMode",
|
||||
"file.export",
|
||||
"file.save",
|
||||
"tool.move",
|
||||
"tool.hand",
|
||||
"tool.comment",
|
||||
"inspect.code",
|
||||
] {
|
||||
assert!(dev_mode_allows(id), "{id} runs in Dev Mode");
|
||||
}
|
||||
for id in [
|
||||
"edit.delete",
|
||||
"edit.paste",
|
||||
"edit.cut",
|
||||
"edit.duplicate",
|
||||
"transform.nudge",
|
||||
"object.group",
|
||||
"tool.rectangle",
|
||||
"tool.text",
|
||||
"tool.place",
|
||||
"file.import",
|
||||
"file.revert",
|
||||
] {
|
||||
assert!(!dev_mode_allows(id), "{id} edits the design");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ready_list_shows_frames_and_sections_with_a_status_across_pages() {
|
||||
let mut s = Session::new();
|
||||
let a = frame(&mut s, 0.0, "Login");
|
||||
let b = frame(&mut s, 300.0, "Signup");
|
||||
let nested = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 50, "height": 50, "parent": a, "name": "Card"})));
|
||||
status(&mut s, &[a], "readyForDev");
|
||||
status(&mut s, &[b], "completed");
|
||||
// A layer inside a frame takes no status of its own (its frame's covers it).
|
||||
assert!(s.execute("node.set", &json!({"id": nested, "props": {"devStatus": "readyForDev"}})).is_err());
|
||||
assert!(s.execute("node.set", &json!({"ids": [a, nested], "props": {"devStatus": "completed"}})).is_err(), "all or nothing");
|
||||
assert_eq!(s.doc().unwrap().node(a).map(|n| n.dev_status), Some(DevStatus::ReadyForDev));
|
||||
// Clearing one is always fine.
|
||||
ex(&mut s, "node.set", json!({"id": nested, "props": {"devStatus": null}}));
|
||||
let pg2 = idv(&ex(&mut s, "page.add", json!({"name": "Settings"})));
|
||||
ex(&mut s, "page.select", json!({"id": pg2}));
|
||||
let c = frame(&mut s, 0.0, "Profile");
|
||||
status(&mut s, &[c], "readyForDev");
|
||||
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({}))), vec!["Login", "Profile"]);
|
||||
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "completed"}))), vec!["Signup"]);
|
||||
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({"status": "any"}))), vec!["Login", "Signup", "Profile"]);
|
||||
// Statuses are undoable.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(names(&ex(&mut s, "inspect.readyForDev", json!({}))), vec!["Login"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ready_frames_edited_after_marking_show_as_changed_until_resolved_or_completed() {
|
||||
let mut s = Session::new();
|
||||
let a = frame(&mut s, 0.0, "Login");
|
||||
let b = frame(&mut s, 300.0, "Signup");
|
||||
let r = rect(&mut s, a, 20.0);
|
||||
let outside = idv(&ex(&mut s, "shape.rectangle", json!({"x": 900, "y": 0, "width": 10, "height": 10})));
|
||||
status(&mut s, &[a, b], "readyForDev");
|
||||
let changed = |s: &mut Session| names(&ex(s, "inspect.readyForDev", json!({"status": "changed"})));
|
||||
assert!(changed(&mut s).is_empty(), "marking isn't a change");
|
||||
// Edits elsewhere don't count; an edit inside does.
|
||||
ex(&mut s, "node.set", json!({"id": outside, "props": {"width": 30}}));
|
||||
assert!(changed(&mut s).is_empty());
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"width": 60}}));
|
||||
assert_eq!(changed(&mut s), vec!["Login"]);
|
||||
let items = ex(&mut s, "inspect.readyForDev", json!({}));
|
||||
assert_eq!(items["items"][0]["changed"], true, "the list says which changed: {items}");
|
||||
assert_eq!(items["items"][1]["changed"], false);
|
||||
// Still ready for dev (the status itself stays).
|
||||
assert_eq!(names(&items), vec!["Login", "Signup"]);
|
||||
// Resolving goes back to plain ready; so does marking it again, or completing it.
|
||||
ex(&mut s, "dev.resolveChange", json!({"ids": [a]}));
|
||||
assert!(changed(&mut s).is_empty());
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"width": 70}}));
|
||||
status(&mut s, &[a], "completed");
|
||||
assert!(changed(&mut s).is_empty());
|
||||
let all = ex(&mut s, "inspect.readyForDev", json!({"status": "any"}));
|
||||
assert_eq!(all["items"][0]["changed"], false, "a completed frame isn't changed: {all}");
|
||||
// Completed frames don't turn changed when edited.
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"width": 80}}));
|
||||
assert!(!s.doc().unwrap().node(a).unwrap().dev_changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_focus_view_steps_through_what_is_ready_and_skips_completed_frames() {
|
||||
let mut s = Session::new();
|
||||
let a = frame(&mut s, 0.0, "Login");
|
||||
let b = frame(&mut s, 300.0, "Signup");
|
||||
let c = frame(&mut s, 600.0, "Profile");
|
||||
let r = rect(&mut s, c, 620.0);
|
||||
status(&mut s, &[a, c], "readyForDev");
|
||||
status(&mut s, &[b], "completed");
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"width": 60}}));
|
||||
let step = |s: &mut Session, d: &str| ex(s, "dev.focus", json!({"direction": d}));
|
||||
let v = ex(&mut s, "dev.focus", json!({"id": a}));
|
||||
assert_eq!((v["index"].clone(), v["count"].clone()), (json!(0), json!(2)));
|
||||
assert_eq!(step(&mut s, "next")["id"], json!(c), "completed ones are skipped, changed ones aren't");
|
||||
assert_eq!(step(&mut s, "previous")["id"], json!(a));
|
||||
// Any top-level frame opens in it, even one that's completed.
|
||||
assert_eq!(ex(&mut s, "dev.focus", json!({"id": b}))["index"], json!(null));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_drag_under_way_when_dev_mode_starts_moves_nothing() {
|
||||
let mut s = Session::new();
|
||||
let f = frame(&mut s, 0.0, "Screen");
|
||||
let r = rect(&mut s, f, 20.0);
|
||||
ex(&mut s, "select.set", json!({"ids": [r]}));
|
||||
let before = s.doc().unwrap().world_bounds(r);
|
||||
s.pointer(&ev(PointerKind::Down, 40.0, 40.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, 90.0, 40.0)).unwrap();
|
||||
s.drop_gesture().unwrap();
|
||||
s.tool.dev_mode = true;
|
||||
s.pointer(&ev(PointerKind::Move, 120.0, 40.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, 120.0, 40.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().world_bounds(r), before, "the drag was dropped, not committed");
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Dev Mode, the tail: import lines for the connected components a layer tree calls.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, idv};
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
fn code(s: &mut Session, id: NodeId, lang: &str) -> String {
|
||||
ex(s, "inspect.code", json!({"id": id, "language": lang}))["code"].as_str().unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// A component named `name` (an empty frame) at `x`.
|
||||
fn component(s: &mut Session, name: &str, x: f64) -> NodeId {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 1000, "width": 40, "height": 20, "name": name})));
|
||||
idv(&ex(s, "component.create", json!({"ids": [f]})))
|
||||
}
|
||||
|
||||
fn instance(s: &mut Session, c: NodeId, parent: NodeId) -> NodeId {
|
||||
idv(&ex(s, "instance.create", json!({"component": c, "x": 0, "y": 0, "parent": parent})))
|
||||
}
|
||||
|
||||
fn frame(s: &mut Session, name: &str, parent: Option<NodeId>) -> NodeId {
|
||||
idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300, "name": name, "parent": parent})))
|
||||
}
|
||||
|
||||
fn count(code: &str, line: &str) -> usize {
|
||||
code.lines().filter(|l| *l == line).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_trees_import_the_connected_components_they_call_once_at_the_top() {
|
||||
let mut s = Session::new();
|
||||
let badge = component(&mut s, "Badge", 0.0);
|
||||
let button = component(&mut s, "Button", 100.0);
|
||||
let toast = component(&mut s, "Toast", 200.0);
|
||||
let divider = component(&mut s, "Divider", 300.0);
|
||||
for (lang, path) in [("react", "@acme/ui"), ("swiftui", "AcmeUI"), ("leptos", "crate::ui"), ("compose", "com.acme.ui.Badge")] {
|
||||
ex(&mut s, "codeConnect.set", json!({"id": badge, "language": lang, "importPath": path}));
|
||||
}
|
||||
ex(&mut s, "codeConnect.set", json!({"id": button, "importPath": "@acme/buttons"}));
|
||||
ex(&mut s, "codeConnect.set", json!({"id": toast, "importPath": "@acme/toast"}));
|
||||
let screen = frame(&mut s, "Screen", None);
|
||||
let row = frame(&mut s, "Row", Some(screen));
|
||||
for (c, parent) in [(badge, screen), (button, screen), (badge, screen), (divider, screen), (badge, row)] {
|
||||
instance(&mut s, c, parent);
|
||||
}
|
||||
let hidden = instance(&mut s, toast, row);
|
||||
ex(&mut s, "node.set", json!({"id": hidden, "props": {"visible": false}}));
|
||||
|
||||
let react = code(&mut s, screen, "react");
|
||||
assert!(react.starts_with("import { Badge } from \"@acme/ui\";\nimport { Button } from \"@acme/buttons\";\nimport \"./"), "{react}");
|
||||
assert_eq!(count(&react, "import { Badge } from \"@acme/ui\";"), 1, "once each: {react}");
|
||||
assert!(!react.contains("@acme/toast"), "hidden layers aren't in the code: {react}");
|
||||
let swift = code(&mut s, screen, "swiftui");
|
||||
assert!(swift.starts_with("import AcmeUI\n\n") && count(&swift, "import AcmeUI") == 1, "per language: {swift}");
|
||||
assert!(!swift.contains("acme/buttons"), "{swift}");
|
||||
let leptos = code(&mut s, screen, "leptos");
|
||||
assert!(leptos.starts_with("use crate::ui::Badge;\nuse leptos::prelude::*;\n"), "joined to the code's own imports: {leptos}");
|
||||
assert!(code(&mut s, screen, "compose").starts_with("import com.acme.ui.Badge\n\n"));
|
||||
assert!(!code(&mut s, screen, "css").contains("import"), "CSS has no imports");
|
||||
// The Inspect panel shows inspect.code's text (the same function).
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(crate::codegen::code_for(doc, screen, crate::codegen::CodeLang::React), react);
|
||||
|
||||
// A connected layer itself: its import and snippet, unchanged.
|
||||
assert_eq!(code(&mut s, badge, "react"), "import { Badge } from \"@acme/ui\";\n\n<Badge />\n");
|
||||
|
||||
// A component's definition, and an instance of it (its call, then the definition).
|
||||
let card_frame = frame(&mut s, "Card", None);
|
||||
instance(&mut s, badge, card_frame);
|
||||
let card = idv(&ex(&mut s, "component.create", json!({"ids": [card_frame]})));
|
||||
assert!(
|
||||
code(&mut s, card, "react").starts_with("import { Badge } from \"@acme/ui\";\nimport \"./card.css\";"),
|
||||
"{}",
|
||||
code(&mut s, card, "react")
|
||||
);
|
||||
let page = frame(&mut s, "Page", None);
|
||||
let card_i = instance(&mut s, card, page);
|
||||
assert!(code(&mut s, card_i, "react").starts_with("import { Badge } from \"@acme/ui\";\n\n<Card />"), "{}", code(&mut s, card_i, "react"));
|
||||
assert!(code(&mut s, page, "react").starts_with("import \"./"), "an unconnected call's insides aren't in the code");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_and_instance_swaps_bring_their_connected_imports() {
|
||||
let mut s = Session::new();
|
||||
let badge = component(&mut s, "Badge", 0.0);
|
||||
ex(&mut s, "codeConnect.set", json!({"id": badge, "importPath": "@acme/ui"}));
|
||||
// A panel whose slot an instance fills with a badge.
|
||||
let f = frame(&mut s, "Panel", None);
|
||||
let content = frame(&mut s, "Content", Some(f));
|
||||
let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "component.makeSlot", json!({"id": content}));
|
||||
let screen = frame(&mut s, "Screen", None);
|
||||
let p = instance(&mut s, panel, screen);
|
||||
assert!(!code(&mut s, screen, "react").contains("@acme/ui"), "an empty slot");
|
||||
ex(&mut s, "instance.slotInsert", json!({"id": p, "slot": "Content", "component": badge, "replace": true}));
|
||||
assert!(code(&mut s, screen, "react").starts_with("import { Badge } from \"@acme/ui\";\n"), "{}", code(&mut s, screen, "react"));
|
||||
assert!(code(&mut s, p, "react").starts_with("import { Badge } from \"@acme/ui\";\n\n<Panel content={("), "{}", code(&mut s, p, "react"));
|
||||
|
||||
// A connected chip whose template places its icon: the swapped-in connected component's import.
|
||||
let heart = component(&mut s, "Heart", 300.0);
|
||||
let chip_frame = frame(&mut s, "Chip", None);
|
||||
let nested = instance(&mut s, heart, chip_frame);
|
||||
let chip = idv(&ex(&mut s, "component.create", json!({"ids": [chip_frame]})));
|
||||
ex(&mut s, "component.addProperty", json!({"id": chip, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested}));
|
||||
ex(&mut s, "codeConnect.set", json!({"id": chip, "importPath": "@acme/chip", "template": "<Chip icon={Icon} />"}));
|
||||
let c = instance(&mut s, chip, screen);
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [c], "properties": {"Icon": badge}}));
|
||||
assert_eq!(code(&mut s, c, "react"), "import { Chip } from \"@acme/chip\";\nimport { Badge } from \"@acme/ui\";\n\n<Chip icon={<Badge />} />\n");
|
||||
let tree = code(&mut s, screen, "react");
|
||||
assert!(tree.starts_with("import { Badge } from \"@acme/ui\";\nimport { Chip } from \"@acme/chip\";\nimport \"./"), "{tree}");
|
||||
// An unconnected swap target brings nothing; the default (Heart) isn't connected.
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [c], "properties": {"Icon": heart}}));
|
||||
assert_eq!(code(&mut s, c, "react"), "import { Chip } from \"@acme/chip\";\n\n<Chip icon={<Heart />} />\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_changes_takes_any_saved_version_as_the_before() {
|
||||
let mut s = Session::new();
|
||||
let screen = frame(&mut s, "Screen", None);
|
||||
let button =
|
||||
idv(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 120, "height": 40, "parent": screen, "props": {"name": "Button"}})));
|
||||
let old = idv(&ex(&mut s, "shape.ellipse", json!({"x": 200, "y": 20, "width": 20, "height": 20, "parent": screen, "props": {"name": "Dot"}})));
|
||||
let v1 = ex(&mut s, "file.saveVersion", json!({"name": "Kickoff"}))["id"].as_str().unwrap().to_string();
|
||||
ex(&mut s, "node.set", json!({"id": button, "props": {"width": 136}}));
|
||||
ex(&mut s, "edit.delete", json!({"ids": [old]}));
|
||||
let added = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 20, "y": 100, "parent": screen, "props": {"name": "Note"}})));
|
||||
let v2 = ex(&mut s, "file.saveVersion", json!({"name": "Review"}))["id"].as_str().unwrap().to_string();
|
||||
ex(&mut s, "node.set", json!({"id": button, "props": {"width": 150}}));
|
||||
|
||||
// Any frame, ready for dev or not.
|
||||
let c = ex(&mut s, "dev.compare", json!({"id": screen, "version": v1}));
|
||||
assert_eq!(c["version"]["name"], "Kickoff");
|
||||
assert_eq!(
|
||||
(c["changed"].as_bool(), c["added"].as_u64(), c["removed"].as_u64(), c["modified"].as_u64()),
|
||||
(Some(true), Some(1), Some(1), Some(1)),
|
||||
"{c}"
|
||||
);
|
||||
let change =
|
||||
|c: &serde_json::Value, id: NodeId| c["changes"].as_array().unwrap().iter().find(|l| l["id"] == json!(id)).cloned().unwrap_or_default();
|
||||
assert_eq!(change(&c, button)["properties"][0]["text"], "width 120 → 150");
|
||||
assert_eq!(change(&c, old)["change"], "removed");
|
||||
assert_eq!(change(&c, added)["change"], "added");
|
||||
// A later version: only what changed since then.
|
||||
let c = ex(&mut s, "dev.compare", json!({"id": screen, "version": v2}));
|
||||
assert_eq!(c["changes"].as_array().map(Vec::len), Some(1), "{c}");
|
||||
assert_eq!(change(&c, button)["properties"][0]["text"], "width 136 → 150");
|
||||
// A frame the version doesn't have was added as a whole.
|
||||
let fresh = frame(&mut s, "Settings", None);
|
||||
let c = ex(&mut s, "dev.compare", json!({"id": fresh, "version": v1}));
|
||||
assert_eq!(c["changes"][0]["change"], "added");
|
||||
// The snapshot from when it was marked stays the default, for ready-for-dev layers only.
|
||||
assert!(s.execute("dev.compare", &json!({"id": screen})).is_err(), "not ready for dev");
|
||||
ex(&mut s, "node.set", json!({"id": screen, "props": {"devStatus": "readyForDev"}}));
|
||||
let c = ex(&mut s, "dev.compare", json!({"id": screen}));
|
||||
assert_eq!((c["changed"].as_bool(), c["version"].is_null()), (Some(false), true), "{c}");
|
||||
for bad in [json!({"id": screen, "version": "nope"}), json!({"id": screen, "version": 5}), json!({"id": button, "version": v1})] {
|
||||
assert!(s.execute("dev.compare", &bad).is_err(), "{bad}");
|
||||
}
|
||||
// The app opens on the version asked for.
|
||||
ex(&mut s, "dev.compare", json!({"id": screen, "version": v2, "show": true}));
|
||||
assert!(s.ui_requests.iter().any(|u| u.kind == "view.devCompare" && u.data["version"] == json!(v2)));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! `props.fill` and `props.stroke` accept `"none"` (either case) and `null` as no paint, the
|
||||
//! same as an empty `fills`/`strokes` list (#1036): an outline-only shape in one call.
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn create_rect(s: &mut Session, fill: Value) -> NodeId {
|
||||
id(ex(
|
||||
s,
|
||||
"node.create",
|
||||
json!({
|
||||
"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10,
|
||||
"props": {"fill": fill, "stroke": "#000000", "strokeWeight": 1}
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_none_or_null_makes_an_outline_only_shape_in_one_call() {
|
||||
for none in [json!("none"), json!("None"), json!("NONE"), json!(null)] {
|
||||
let mut s = Session::new();
|
||||
let r = create_rect(&mut s, none.clone());
|
||||
let n = s.doc().unwrap().node(r).unwrap().clone();
|
||||
assert!(n.fills.is_empty(), "{none:?}: fills not empty");
|
||||
assert_eq!(n.strokes.len(), 1, "{none:?}: stroke kept");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fill_none_clears_an_existing_fill_as_one_undo_step() {
|
||||
let mut s = Session::new();
|
||||
let r = create_rect(&mut s, json!("#FF0000"));
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1);
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"fill": "none"}}));
|
||||
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_none_or_null_clears_the_strokes() {
|
||||
for none in [json!("none"), json!(null)] {
|
||||
let mut s = Session::new();
|
||||
let r = create_rect(&mut s, json!("#FF0000"));
|
||||
ex(&mut s, "node.set", json!({"id": r, "props": {"stroke": none}}));
|
||||
assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty(), "{none:?}");
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1, "{none:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_fill_none_gets_no_automatic_colour() {
|
||||
let mut s = Session::new();
|
||||
let t = id(ex(
|
||||
&mut s,
|
||||
"node.create",
|
||||
json!({
|
||||
"type": "text", "x": 0, "y": 0, "text": "Hi", "props": {"fill": "none"}
|
||||
}),
|
||||
));
|
||||
assert!(s.doc().unwrap().node(t).unwrap().fills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_string_still_names_the_key() {
|
||||
let mut s = Session::new();
|
||||
let e = s
|
||||
.execute(
|
||||
"node.create",
|
||||
&json!({
|
||||
"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10,
|
||||
"props": {"fill": "nope"}
|
||||
}),
|
||||
)
|
||||
.unwrap_err();
|
||||
let msg = e.to_string();
|
||||
assert!(msg.contains("`fill`") && msg.contains("none"), "{msg}");
|
||||
}
|
||||
@@ -53,6 +53,29 @@ fn the_eyedropper_picks_the_colour_seen_and_fills_the_selection() {
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_eyedropper_of_a_colour_picker_samples_without_filling() {
|
||||
// Opened from a colour picker (`apply: false`), the eyedropper hands the colour to the picker,
|
||||
// which writes it to its own paint (a stroke, an effect...): the fill stays as it was.
|
||||
let mut s = Session::new();
|
||||
s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap();
|
||||
rect(&mut s, None, (0.0, 0.0, 100.0, 100.0), "#FF0000");
|
||||
let b = rect(&mut s, None, (200.0, 0.0, 50.0, 50.0), "#00FF00");
|
||||
s.execute("select.set", &json!({"ids": [b]})).unwrap();
|
||||
let undo = s.undo_depth().0;
|
||||
s.execute("edit.pickColor", &json!({"apply": false})).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into()));
|
||||
assert_eq!(s.undo_depth().0, undo, "nothing to undo");
|
||||
assert_eq!(s.tool.picked.take().map(|c| c.to_hex()), Some("FF0000".into()));
|
||||
assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper");
|
||||
// The I key's eyedropper fills the selection again.
|
||||
s.execute("edit.pickColor", &json!({})).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into()));
|
||||
assert_eq!(s.tool.picked, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_modes_and_effects_through_commands() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -9,7 +9,7 @@ pub fn run<T>(cmd: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
Err(payload) => {
|
||||
let msg = payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "panic".to_string());
|
||||
log::error!("command `{cmd}` panicked: {msg}");
|
||||
|
||||
@@ -135,6 +135,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
if !n.exports.is_empty() {
|
||||
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
||||
}
|
||||
if let Some(b) = &n.board {
|
||||
o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.annotations.is_empty() {
|
||||
o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
|
||||
}
|
||||
@@ -312,6 +315,7 @@ pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
|
||||
doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect();
|
||||
json!({
|
||||
"name": doc.name,
|
||||
"kind": doc.kind.id(),
|
||||
"path": ds.path,
|
||||
"dirty": ds.is_dirty(),
|
||||
"currentPage": ds.page,
|
||||
|
||||
@@ -126,3 +126,71 @@ fn detaching_keeps_the_mode_it_took_from_its_main() {
|
||||
assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B");
|
||||
assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1);
|
||||
}
|
||||
|
||||
/// A "Button" holding an instance of a swatch "Icon" on mode A, and a "Modal" holding a Button
|
||||
/// instance whose icon copy was set to mode B: (the Button main, its icon layer, the copy in Modal).
|
||||
fn modal(s: &mut Session) -> (NodeId, NodeId, NodeId) {
|
||||
tone(s);
|
||||
let icon = swatch(s, "Icon", None);
|
||||
let frame = id(run(s, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"})));
|
||||
let button_icon = id(run(s, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": frame})));
|
||||
set_mode(s, button_icon, json!("A"));
|
||||
let button = id(run(s, "component.create", json!({"ids": [frame]})));
|
||||
let outer = id(run(s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"})));
|
||||
let close = id(run(s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": outer})));
|
||||
let nested = s.doc().unwrap().node(close).unwrap().children[0];
|
||||
set_mode(s, nested, json!("B"));
|
||||
run(s, "component.create", json!({"ids": [outer]}));
|
||||
(button, button_icon, nested)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mode_set_on_a_layer_inside_an_instance_is_an_override() {
|
||||
let mut s = Session::new();
|
||||
let (button, button_icon, nested) = modal(&mut s);
|
||||
assert_eq!(fill(&s, nested), "0000FF");
|
||||
// An edit the copies can't take as they are rebuilds the Button instance.
|
||||
run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"}));
|
||||
assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps the mode set on the copy");
|
||||
// The main's layer choosing another mode doesn't reach it either.
|
||||
set_mode(&mut s, button_icon, json!("B"));
|
||||
set_mode(&mut s, button_icon, Value::Null);
|
||||
assert_eq!(fill(&s, nested), "0000FF");
|
||||
run(&mut s, "component.resetOverride", json!({"id": nested, "group": "modes"}));
|
||||
assert_eq!(fill(&s, nested), "FF0000", "reset: the main's layer is on Auto (mode A)");
|
||||
assert!(s.doc().unwrap().node(nested).unwrap().var_modes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mode_set_on_an_icon_inside_a_placed_instance_survives_main_edits() {
|
||||
let mut s = Session::new();
|
||||
let (button, _, _) = modal(&mut s);
|
||||
// A Button placed on the page, its icon recoloured through the icon's mode.
|
||||
let placed = instance(&mut s, button);
|
||||
let icon = s.doc().unwrap().node(placed).unwrap().children[0];
|
||||
set_mode(&mut s, icon, json!("B"));
|
||||
assert!(s.doc().unwrap().node(icon).unwrap().overrides.contains("modes"), "it shows as an override");
|
||||
run(&mut s, "node.set", json!({"ids": [button], "layout": "vertical"}));
|
||||
assert_eq!(fill(&s, icon), "0000FF", "a rebuild of the Button keeps it");
|
||||
run(&mut s, "component.resetInstance", json!({"ids": [placed]}));
|
||||
assert_eq!(fill(&s, icon), "FF0000", "reset all brings back the main's mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_from_before_modes_were_overrides_keeps_the_modes_chosen_inside_its_instances() {
|
||||
let mut s = Session::new();
|
||||
let (button, _, nested) = modal(&mut s);
|
||||
// Saved before `modes` was an override group: the copy has its mode but no marker.
|
||||
s.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes");
|
||||
let path = std::env::temp_dir().join(format!("figcraft-instance-modes-{}.figcraft", std::process::id()));
|
||||
let p = path.to_string_lossy().into_owned();
|
||||
run(&mut s, "file.saveAs", json!({"path": p}));
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "file.open", json!({"path": p}));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(doc.node(nested).unwrap().overrides.contains("modes"), "opening it marks the mode the copy chose");
|
||||
assert!(doc.nodes().filter(|n| n.id != nested).all(|n| !n.overrides.contains("modes")), "layers on their main's modes stay unmarked");
|
||||
run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"}));
|
||||
assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps it");
|
||||
}
|
||||
|
||||
@@ -193,6 +193,15 @@ pub static KEYS: &[Binding] = &[
|
||||
k(Tools, "tool.text", &["T"]),
|
||||
k(Tools, "tool.comment", &["C"]),
|
||||
k(Tools, "tool.shapeBuilder", &["Shift+M"]),
|
||||
// Board tools (on boards only), on keys nothing else uses.
|
||||
k(Tools, "tool.sticky", &["Q"]),
|
||||
k(Tools, "tool.shape", &["B"]),
|
||||
k(Tools, "tool.connector", &["X"]),
|
||||
k(Tools, "tool.marker", &["M"]),
|
||||
k(Tools, "tool.highlighter", &["U"]),
|
||||
k(Tools, "tool.eraser", &["E"]),
|
||||
k(Tools, "tool.stamp", &["W"]),
|
||||
k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one").both(),
|
||||
// View
|
||||
// The documentation gives both keys for the pixel grid in different places.
|
||||
k(View, "view.pixelGrid", &["Shift+'", "Cmd+'"]),
|
||||
@@ -220,8 +229,10 @@ pub static KEYS: &[Binding] = &[
|
||||
k(View, "view.prevPage", &["PageUp"]),
|
||||
k(View, "view.nextPage", &["PageDown"]),
|
||||
// Zoom
|
||||
k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+="]),
|
||||
k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-"]),
|
||||
// Each zoom-in key has its zoom-out twin on the key beside it: `-` alone zooms out, so `=`
|
||||
// alone (the `+` key without Shift) zooms in (#973).
|
||||
k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+=", "="]),
|
||||
k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-", "Shift+Cmd+-"]),
|
||||
k(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"]),
|
||||
k(Zoom, "view.zoomFit", &["Shift+1"]),
|
||||
k(Zoom, "view.zoomSelection", &["Shift+2"]),
|
||||
|
||||
@@ -168,6 +168,25 @@ fn display_per_platform() {
|
||||
assert_eq!(primary("polygon.none", mac), None);
|
||||
}
|
||||
|
||||
/// Each way of zooming in has its zoom-out twin on the key beside it, as the keys arrive from a US
|
||||
/// keyboard (#973: `-` alone zoomed out but `=` alone did nothing, so zooming back in was stuck).
|
||||
#[test]
|
||||
fn zoom_keys_come_in_pairs() {
|
||||
let none = Mods::default();
|
||||
let shift = mods(true, false, false, false);
|
||||
let cmd = mods(false, false, true, false);
|
||||
let shift_cmd = mods(true, false, true, false);
|
||||
for p in PLATFORMS {
|
||||
for (zoom_in, zoom_out, m) in [("Equals", "Minus", none), ("Equals", "Minus", cmd), ("Plus", "Minus", shift_cmd), ("Plus", "Minus", shift)] {
|
||||
assert_eq!(id_for(p, Context::Canvas, zoom_in, m), Some("view.zoomIn"), "{zoom_in} {m:?} on {p:?}");
|
||||
assert_eq!(id_for(p, Context::Canvas, zoom_out, m), Some("view.zoomOut"), "{zoom_out} {m:?} on {p:?}");
|
||||
}
|
||||
// The menus still show Cmd/Ctrl and `+`.
|
||||
assert_eq!(primary("view.zoomIn", p), Some("Cmd+="));
|
||||
assert_eq!(primary("view.zoomOut", p), Some("Cmd+-"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_keys_come_from_the_keymap() {
|
||||
assert_eq!(ToolId::Move.shortcut(), "V");
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod annotations;
|
||||
pub mod arc_handles;
|
||||
pub mod background;
|
||||
pub mod batch;
|
||||
pub mod board;
|
||||
pub mod booleans;
|
||||
pub mod branches;
|
||||
pub mod catalog;
|
||||
@@ -374,6 +375,7 @@ impl DocState {
|
||||
}
|
||||
doc.link_layer_text_styles();
|
||||
cmd::styles::apply_paint_bindings_on_load(&mut doc);
|
||||
components::mark_layer_modes(&mut doc);
|
||||
// Initial import settlement belongs to the saved baseline, not the user's first edit.
|
||||
let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0));
|
||||
let doc = Arc::new(doc);
|
||||
@@ -897,6 +899,7 @@ impl Session {
|
||||
/// as it was.
|
||||
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
board::refuse_on_board(self, id)?;
|
||||
// Revert replaces the document's uid: refuse before ending previews or syncing branches.
|
||||
if id == "file.revert" && self.live().doc_uid.is_some() {
|
||||
return Err(EngineError::Disabled(id.into(), "leave the live session before reverting to the saved file".into()));
|
||||
@@ -1357,7 +1360,9 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
figcraft_layout::coherent_sizing(doc, id);
|
||||
}
|
||||
touched.extend(doc.take_dirty());
|
||||
// 1. Text auto-size.
|
||||
// 1. Text auto-size (a sticky's note first takes the size its length calls for).
|
||||
board::settle_text(doc, before, &touched);
|
||||
touched.extend(doc.take_dirty());
|
||||
for id in live(doc, &touched) {
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if let Some((w, h)) = figcraft_text::auto_size(n)
|
||||
@@ -1438,6 +1443,9 @@ pub fn normalize(doc: &mut Document, before: &Document) {
|
||||
}
|
||||
}
|
||||
touched.extend(doc.take_dirty());
|
||||
// 4b. Board connectors follow the layers they join; their labels sit at their middles.
|
||||
board::settle_routes(doc, before, &touched);
|
||||
touched.extend(doc.take_dirty());
|
||||
// 5. Ready-for-dev layers whose contents changed show "Changed".
|
||||
mark_dev_changes(doc, before, &touched);
|
||||
doc.take_dirty();
|
||||
@@ -1570,6 +1578,10 @@ mod batch_step_ref_tests;
|
||||
#[cfg(test)]
|
||||
mod binding_lists_tests;
|
||||
#[cfg(test)]
|
||||
mod board_tests;
|
||||
#[cfg(test)]
|
||||
mod board_tools_tests;
|
||||
#[cfg(test)]
|
||||
mod booleans_tests;
|
||||
#[cfg(test)]
|
||||
mod bound_alpha_tests;
|
||||
@@ -1616,6 +1628,10 @@ mod devfocus_tests;
|
||||
#[cfg(test)]
|
||||
mod devinspect_tests;
|
||||
#[cfg(test)]
|
||||
mod devmode_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod devmode_tail_tests;
|
||||
#[cfg(test)]
|
||||
mod doc_urls_tests;
|
||||
#[cfg(test)]
|
||||
mod double_click_tests;
|
||||
@@ -1638,6 +1654,8 @@ mod fig_export_tests;
|
||||
#[cfg(test)]
|
||||
mod fig_import_tests;
|
||||
#[cfg(test)]
|
||||
mod fill_none_tests;
|
||||
#[cfg(test)]
|
||||
mod finite_geometry_tests;
|
||||
#[cfg(test)]
|
||||
mod frame_guides_tests;
|
||||
@@ -1850,6 +1868,8 @@ mod variants_discovery_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_fill_rule_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_multi_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_offset_tests;
|
||||
|
||||
@@ -339,7 +339,7 @@ pub fn publish(doc: &Document, prev: Option<&PublishedLibrary>, sel: &Selection,
|
||||
.map(|v| v.id.as_str())
|
||||
.collect();
|
||||
let (bound, mode_colls) = used_variables(doc, &units);
|
||||
let var_ids = alias_closure(&doc.variables, offered_vars.iter().map(|v| v.to_string()).chain(bound));
|
||||
let var_ids = alias_closure(&doc.variables, offered_vars.iter().map(ToString::to_string).chain(bound));
|
||||
snap.variables.variables = doc.variables.variables.iter().filter(|v| var_ids.contains(&v.id)).cloned().collect();
|
||||
let coll_ids: BTreeSet<&str> = snap
|
||||
.variables
|
||||
@@ -491,6 +491,11 @@ impl Cache {
|
||||
None => Err(EngineError::Other(crate::library_server::NOT_FETCHED.into())),
|
||||
};
|
||||
}
|
||||
// The path comes from a document: a network share would be contacted (and on Windows get
|
||||
// the opener's credentials) by the stat below.
|
||||
if !craft_docpath::is_local(path) {
|
||||
return Err(EngineError::Other(format!("{path}: a library is opened only from this computer")));
|
||||
}
|
||||
let stamp = stat(path);
|
||||
// Checked before the cache: the limit is the caller's.
|
||||
if let Ok(st) = &stamp
|
||||
@@ -553,11 +558,18 @@ fn stat(_path: &str) -> std::result::Result<Stamp, String> {
|
||||
Err(NO_FILES.into())
|
||||
}
|
||||
|
||||
/// A library read from a file or a server, with the modes chosen inside its instances marked when
|
||||
/// it was published before they were overrides ([`crate::components::mark_layer_modes`]).
|
||||
pub(crate) fn settled(mut lib: PublishedLibrary) -> PublishedLibrary {
|
||||
crate::components::mark_layer_modes(Arc::make_mut(&mut lib.doc));
|
||||
lib
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_file(path: &str) -> std::result::Result<PublishedLibrary, String> {
|
||||
let f = crate::file_access::open(path).map_err(|e| e.to_string())?;
|
||||
match figcraft_format::read_library(std::io::BufReader::new(f)) {
|
||||
Ok(Some(lib)) => Ok(lib),
|
||||
Ok(Some(lib)) => Ok(settled(lib)),
|
||||
Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")),
|
||||
Err(e) => Err(format!("{path}: {e}")),
|
||||
}
|
||||
|
||||
@@ -28,3 +28,12 @@ fn a_local_library_path_is_still_followed() {
|
||||
let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist");
|
||||
assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_library_cache_refuses_a_path_off_this_computer() {
|
||||
let mut cache = crate::library::Cache::default();
|
||||
for path in [r"\\attacker.example\share\lib.figcraft", "//attacker.example/share/lib.figcraft"] {
|
||||
let e = cache.load(path).expect_err(&format!("{path} must be refused"));
|
||||
assert!(e.to_string().contains("only from this computer"), "{path}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn archive(lib: &PublishedLibrary) -> Result<Vec<u8>> {
|
||||
/// The library in an archive from a server (untrusted: read with the library file's limits).
|
||||
pub fn read_archive(bytes: &[u8]) -> std::result::Result<PublishedLibrary, String> {
|
||||
match figcraft_format::read_library(Cursor::new(bytes)) {
|
||||
Ok(Some(lib)) => Ok(lib),
|
||||
Ok(Some(lib)) => Ok(crate::library::settled(lib)),
|
||||
Ok(None) => Err("the server sent a file without a library".into()),
|
||||
Err(e) => Err(format!("the server sent a library that can't be read ({e})")),
|
||||
}
|
||||
|
||||
@@ -344,3 +344,67 @@ fn library_style_bindings_ignore_unrelated_local_variables() {
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// A library "Modal" holding a Button instance whose icon copy is on mode Dark: (the Button main,
|
||||
/// the icon copy in Modal).
|
||||
fn nested_mode_kit(lib: &mut Session) -> (NodeId, NodeId) {
|
||||
theme(lib);
|
||||
let f = idv(&ex(lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"})));
|
||||
ex(lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"}));
|
||||
let icon = idv(&ex(lib, "component.create", json!({"ids": [f]})));
|
||||
let b = idv(&ex(lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"})));
|
||||
ex(lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b}));
|
||||
let button = idv(&ex(lib, "component.create", json!({"ids": [b]})));
|
||||
let m = idv(&ex(lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"})));
|
||||
let close = idv(&ex(lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m})));
|
||||
let nested = lib.doc().unwrap().node(close).unwrap().children[0];
|
||||
ex(lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"}));
|
||||
ex(lib, "component.create", json!({"ids": [m]}));
|
||||
(button, nested)
|
||||
}
|
||||
|
||||
/// The icon inside the Button inside library Modal instance `inst`.
|
||||
fn modal_icon(s: &Session, inst: NodeId) -> NodeId {
|
||||
let doc = s.doc().unwrap();
|
||||
doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_set_inside_nested_instances_come_along() {
|
||||
let d = dir("nested-modes");
|
||||
let lib_path = path_str(&d.join("kit.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
let (button, _) = nested_mode_kit(&mut lib);
|
||||
let lid = publish_to(&mut lib, &lib_path);
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "library.add", json!({"path": lib_path}));
|
||||
let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0})));
|
||||
let copy = |s: &Session| modal_icon(s, inst);
|
||||
assert_eq!(fill(&s, copy(&s)), "FFFFFF", "the Dark mode set on the nested icon comes along");
|
||||
// An update that rebuilds the Button copies keeps it too.
|
||||
ex(&mut lib, "node.set", json!({"ids": [button], "layout": "horizontal"}));
|
||||
republish(&mut lib);
|
||||
ex(&mut s, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(fill(&s, copy(&s)), "FFFFFF");
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_nested_instances() {
|
||||
let d = dir("nested-modes-old");
|
||||
let lib_path = path_str(&d.join("kit.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
let (_, nested) = nested_mode_kit(&mut lib);
|
||||
// Published before `modes` was an override group: the copy has its mode but no marker.
|
||||
lib.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes");
|
||||
let lid = publish_to(&mut lib, &lib_path);
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "library.add", json!({"path": lib_path}));
|
||||
let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0})));
|
||||
assert_eq!(fill(&s, modal_icon(&s, inst)), "FFFFFF", "the Dark mode comes along");
|
||||
healthy(&s);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
@@ -198,3 +198,179 @@ fn copied_instances_keep_their_overrides() {
|
||||
assert!(node(&s, t).source.is_none() && node(&s, t).overrides.is_empty());
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
/// `button()` with the main's copy of the icon's Shape filled red, and an instance-swap property
|
||||
/// `Icon` on the nested icon: (icon, shape, button, nested).
|
||||
fn button_with_icon_override(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) {
|
||||
let (icon_main, shape, button, nested) = button(s);
|
||||
let main_shape = copy_of(s, nested, shape);
|
||||
ex(s, "node.set", json!({"id": main_shape, "props": {"fill": "#ff0000"}}));
|
||||
ex(s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": icon_main, "layer": nested}));
|
||||
(icon_main, shape, button, nested)
|
||||
}
|
||||
|
||||
/// Swap instance `i`'s icon to `to` by its instance-swap property `Icon`.
|
||||
fn swap_icon(s: &mut Session, i: NodeId, to: NodeId) {
|
||||
ex(s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": to}}));
|
||||
healthy(s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_main_override_carries_onto_a_swapped_nested_instance() {
|
||||
// Documented: overrides survive a swap when layer names match, and a swapped icon takes the
|
||||
// colour the main component set (forum thread with staff replies). Rows: by the instance-swap
|
||||
// property and by instance.swap, onto a matching and an unmatched name. The main's size
|
||||
// override is geometry and stays out; the instance's own override wins over the main's.
|
||||
let mut s = Session::new();
|
||||
let (_icon, shape, button, nested) = button_with_icon_override(&mut s);
|
||||
let main_shape = copy_of(&s, nested, shape);
|
||||
ex(&mut s, "node.set", json!({"id": main_shape, "props": {"width": 10}}));
|
||||
let other = icon(&mut s, 200.0, "Other", "Shape");
|
||||
let unmatched = icon(&mut s, 300.0, "Unmatched", "Glyph");
|
||||
for (by_property, to, want) in [(true, other, "FF0000"), (false, other, "FF0000"), (true, unmatched, "111111"), (false, unmatched, "111111")] {
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||||
if by_property {
|
||||
swap_icon(&mut s, i, to);
|
||||
} else {
|
||||
let inested = copy_of(&s, i, nested);
|
||||
ex(&mut s, "instance.swap", json!({"ids": [inested], "component": to}));
|
||||
healthy(&s);
|
||||
}
|
||||
let glyph = kids(&s, copy_of(&s, i, nested))[0];
|
||||
assert_eq!(fill(&s, glyph), want, "by property {by_property}, to {to}");
|
||||
assert_eq!(node(&s, glyph).width, 20.0, "the main's size override stays out");
|
||||
}
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 600})));
|
||||
let glyph = kids(&s, copy_of(&s, i, nested))[0];
|
||||
ex(&mut s, "node.set", json!({"id": glyph, "props": {"fill": "#0000ff"}}));
|
||||
swap_icon(&mut s, i, other);
|
||||
assert_eq!(fill(&s, kids(&s, copy_of(&s, i, nested))[0]), "0000FF", "the instance's own override wins");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_main_override_carries_onto_a_swap_one_level_deeper() {
|
||||
// As above for a Button instance inside a Toolbar main, swapped in a Toolbar instance.
|
||||
let mut s = Session::new();
|
||||
let (_icon, _shape, button, nested) = button_with_icon_override(&mut s);
|
||||
let other = icon(&mut s, 200.0, "Other", "Shape");
|
||||
let bar = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 60, "name": "Toolbar"})));
|
||||
let inner = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": bar})));
|
||||
let toolbar = idv(&ex(&mut s, "component.create", json!({"ids": [bar]})));
|
||||
let t = idv(&ex(&mut s, "instance.create", json!({"component": toolbar, "x": 0, "y": 800})));
|
||||
let icon_in_t = copy_of(&s, t, copy_of(&s, inner, nested));
|
||||
ex(&mut s, "instance.swap", json!({"ids": [icon_in_t], "component": other}));
|
||||
healthy(&s);
|
||||
assert_eq!(fill(&s, kids(&s, icon_in_t)[0]), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swapped_nested_instance_keeps_the_main_override_it_took() {
|
||||
// Documented (staff reply): later edits of the main's icon colour don't reach instances that
|
||||
// swapped, and do reach the rest. Undo and redo follow; resetting the override shows the icon
|
||||
// main's own fill.
|
||||
let mut s = Session::new();
|
||||
let (_icon, shape, button, nested) = button_with_icon_override(&mut s);
|
||||
let other = icon(&mut s, 200.0, "Other", "Shape");
|
||||
let swapped = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||||
let kept = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 500})));
|
||||
swap_icon(&mut s, swapped, other);
|
||||
let glyph = |s: &Session, i: NodeId| fill(s, kids(s, copy_of(s, i, nested))[0]);
|
||||
let main_shape = copy_of(&s, nested, shape);
|
||||
ex(&mut s, "node.set", json!({"id": main_shape, "props": {"fill": "#00ff00"}}));
|
||||
healthy(&s);
|
||||
assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into()));
|
||||
s.undo().unwrap();
|
||||
assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "FF0000".into()));
|
||||
s.redo().unwrap();
|
||||
assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into()));
|
||||
let swapped_glyph = kids(&s, copy_of(&s, swapped, nested))[0];
|
||||
ex(&mut s, "component.resetOverride", json!({"id": swapped_glyph, "group": "fills"}));
|
||||
healthy(&s);
|
||||
assert_eq!(glyph(&s, swapped), "111111");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swapped_in_icon_keeps_its_own_overrides_to_itself() {
|
||||
// Docs silent; pinned: what an icon main overrides inside itself stays that main's. After a
|
||||
// swap to it, its edits still reach the instance; a second swap doesn't carry them on.
|
||||
let mut s = Session::new();
|
||||
let (_icon, _shape, button, nested) = button_with_icon_override(&mut s);
|
||||
let dot = icon(&mut s, 200.0, "Dot", "Shape");
|
||||
let with_dot = |s: &mut Session, x: f64, name: &str, fill: Option<&str>| {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 300, "width": 24, "height": 24, "name": name})));
|
||||
let d = idv(&ex(s, "instance.create", json!({"component": dot, "x": 0, "y": 0, "parent": f})));
|
||||
if let Some(c) = fill {
|
||||
let shape = kids(s, d)[0];
|
||||
ex(s, "node.set", json!({"id": shape, "props": {"fill": c}}));
|
||||
}
|
||||
(idv(&ex(s, "component.create", json!({"ids": [f]}))), d)
|
||||
};
|
||||
let (other2, dot2) = with_dot(&mut s, 300.0, "Other2", Some("#0000ff"));
|
||||
let (other3, _) = with_dot(&mut s, 400.0, "Other3", None);
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400})));
|
||||
swap_icon(&mut s, i, other2);
|
||||
let shape_in = |s: &Session| kids(s, kids(s, copy_of(s, i, nested))[0])[0];
|
||||
assert_eq!(fill(&s, shape_in(&s)), "0000FF");
|
||||
let shape2 = kids(&s, dot2)[0];
|
||||
ex(&mut s, "node.set", json!({"id": shape2, "props": {"fill": "#00ffff"}}));
|
||||
healthy(&s);
|
||||
assert_eq!(fill(&s, shape_in(&s)), "00FFFF", "Other2's own edit reaches it");
|
||||
swap_icon(&mut s, i, other3);
|
||||
assert_eq!(fill(&s, shape_in(&s)), "111111", "Other2's override doesn't carry onto Other3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_swapped_out_icons_own_overrides_stay_with_it() {
|
||||
// Docs silent; pinned: what the Button's default icon main overrides inside itself isn't the
|
||||
// Button main's to carry. Icon A colours its Base's Shape green; swapping to icon B, also built
|
||||
// on Base, shows Base's own fill.
|
||||
let mut s = Session::new();
|
||||
let base = icon(&mut s, 0.0, "Base", "Shape");
|
||||
let on_base = |s: &mut Session, x: f64, name: &str| {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 500, "width": 24, "height": 24, "name": name})));
|
||||
let b = idv(&ex(s, "instance.create", json!({"component": base, "x": 0, "y": 0, "parent": f})));
|
||||
(idv(&ex(s, "component.create", json!({"ids": [f]}))), b)
|
||||
};
|
||||
let (a, a_base) = on_base(&mut s, 100.0, "A");
|
||||
let (b, _) = on_base(&mut s, 200.0, "B");
|
||||
let a_shape = kids(&s, a_base)[0];
|
||||
ex(&mut s, "node.set", json!({"id": a_shape, "props": {"fill": "#00ff00"}}));
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 700, "width": 40, "height": 40, "name": "Button"})));
|
||||
let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 4, "y": 4, "parent": f})));
|
||||
let button = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": a, "layer": nested}));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 0, "y": 800})));
|
||||
swap_icon(&mut s, i, b);
|
||||
let shape = kids(&s, kids(&s, copy_of(&s, i, nested))[0])[0];
|
||||
assert_eq!(fill(&s, shape), "111111");
|
||||
assert!(node(&s, shape).overrides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_slot_the_main_filled_shows_the_swapped_in_mains_content() {
|
||||
// Docs silent on slot content across a swap; pinned: a Chip the Panel main put in its card's
|
||||
// slot doesn't carry onto the swapped-in card, which shows its own default content.
|
||||
let mut s = Session::new();
|
||||
let card = |s: &mut Session, x: f64, name: &str| {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 60, "name": name})));
|
||||
let content = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 60, "parent": f, "name": "Content"})));
|
||||
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": content, "props": {"name": format!("{name} default")}}));
|
||||
let c = idv(&ex(s, "component.create", json!({"ids": [f]})));
|
||||
ex(s, "component.makeSlot", json!({"id": content}));
|
||||
c
|
||||
};
|
||||
let (a, b) = (card(&mut s, 0.0, "A"), card(&mut s, 200.0, "B"));
|
||||
let chip = icon(&mut s, 400.0, "Chip", "Dot");
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 200, "height": 100, "name": "Panel"})));
|
||||
let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 0, "parent": f})));
|
||||
let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
ex(&mut s, "instance.slotInsert", json!({"id": nested, "slot": "Content", "component": chip, "replace": true}));
|
||||
ex(&mut s, "component.addProperty", json!({"id": panel, "name": "Card", "type": "instanceSwap", "default": a, "layer": nested}));
|
||||
let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 0, "y": 700})));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Card": b}}));
|
||||
healthy(&s);
|
||||
let content = kids(&s, copy_of(&s, i, nested))[0];
|
||||
let names: Vec<String> = kids(&s, content).into_iter().map(|k| node(&s, k).name.clone()).collect();
|
||||
assert_eq!(names, ["B default"]);
|
||||
assert!(!node(&s, content).overrides.contains(SLOT));
|
||||
}
|
||||
|
||||
@@ -63,3 +63,24 @@ fn finishing_the_path_clears_its_handles_and_band() {
|
||||
let ov = &s.tool.overlay;
|
||||
assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_keeps_the_band_and_the_next_point_at_45_degree_steps() {
|
||||
// #970: with Shift the band shows where the point will land, at a 45° step from the last
|
||||
// anchor (the same length as the pointer is away), not under the pointer.
|
||||
let mut s = Session::new();
|
||||
s.set_tool(ToolId::Pen);
|
||||
at(&mut s, PointerKind::Down, 100.0, 100.0);
|
||||
at(&mut s, PointerKind::Up, 100.0, 100.0);
|
||||
let shifted = |kind, x, y| PointerEvent { shift: true, ..PointerEvent::at(kind, x, y, 1.0) };
|
||||
s.pointer(&shifted(PointerKind::Hover, 300.0, 120.0)).unwrap();
|
||||
let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap();
|
||||
assert_eq!([x0, y0, y1], [100.0, 100.0, 100.0], "level with the last anchor");
|
||||
assert!((x1 - (100.0 + 200f64.hypot(20.0))).abs() < 1e-9, "{x1}");
|
||||
s.pointer(&shifted(PointerKind::Down, 300.0, 120.0)).unwrap();
|
||||
s.pointer(&shifted(PointerKind::Up, 300.0, 120.0)).unwrap();
|
||||
s.pointer(&shifted(PointerKind::Hover, 400.0, 230.0)).unwrap();
|
||||
let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap();
|
||||
assert_eq!(y0, 100.0, "the point landed on the band");
|
||||
assert!((x1 - x0 - (y1 - y0)).abs() < 1e-9, "45° down to the right: {:?}", [x0, y0, x1, y1]);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user