diff --git a/.github/workflows/house-rules.yml b/.github/workflows/house-rules.yml new file mode 100644 index 000000000..de64ad3ee --- /dev/null +++ b/.github/workflows/house-rules.yml @@ -0,0 +1,28 @@ +name: House rules + +on: + push: + branches: [main] + paths: + - AGENTS.md + - CLAUDE.md + - .github/workflows/house-rules.yml + pull_request: + paths: + - AGENTS.md + - CLAUDE.md + - .github/workflows/house-rules.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: house-rules-${{ github.ref }} + cancel-in-progress: true + +jobs: + house-rules: + uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main + with: + rust: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 975d54114..d686da692 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,14 @@ concurrency: cancel-in-progress: false jobs: + # Regenerates contributors/contributors.json for the release commit; every build bundles it. + credits: + permissions: + contents: read + pull-requests: read + uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main release: + needs: credits # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/release.yml@main with: @@ -33,6 +40,7 @@ jobs: # crates/text/build.rs keeps the web allowlist small (Inter and Georgian). web-craft-fonts: true version: ${{ inputs.version }} + contributors-artifact: ${{ needs.credits.outputs.artifact-name }} secrets: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} diff --git a/AGENTS.md b/AGENTS.md index 4059e4579..e63e92c7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,56 @@ + +## House rules + +These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy. + +1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments. +2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish. +3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location. +4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix. +5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true. +6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why. +7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready. +8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception. +9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop. +10. **Keep secrets and personal details out** of the repository, logs and requests. +11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there. +12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it. +13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it. +14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit. +15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that. + + +## Rust Craft repositories + +These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib). + +1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix. +2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it. +3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository. +4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build. +5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md). +6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`. + +## Licence allowlist + +Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules. + +- `ISC` +- `MIT` +- `BSD-2-Clause` +- `BSD-3-Clause` +- `Apache-2.0` +- `OFL-1.1` +- `Ubuntu-font-1.0` +- `Bitstream-Vera` +- `CC0-1.0` +- `CC-BY-4.0` +- `CC-BY-SA-4.0` +- `LicenseRef-Public-Domain` + +`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so. + + # FigCraft — instructions for agents FigCraft is a clean-room, open-source, pure-Rust (egui) reimplementation of the Figma design workflow: @@ -12,11 +65,10 @@ Repos don't share code. 1. Read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, estimates) and [`docs/gaps.md`](docs/gaps.md) (the ranked work list). Unless the user gives you a task, pick work from the top of `docs/gaps.md`. -2. Read `plan/STATUS.md` and `plan/execution-plan.md` (local, gitignored) if present; behaviour - reference notes are in `plan/figma/*.md`. -3. Orient → plan → implement + test → verify (screenshot) → record (ROADMAP, devlog) → commit. +2. Orient → plan → implement + test → verify (screenshot) → record (ROADMAP, devlog) → commit. -`plan/` is gitignored (local only). +`plan/` is an optional gitignored notebook. Behaviour reference notes may be in `plan/figma/*.md`. +Do not ask for it, and do not stop when it is absent. ## Non-negotiables @@ -59,6 +111,9 @@ Repos don't share code. - No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code (workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes. +- New workspace crates include `[lints] workspace = true` in `Cargo.toml`. Production crate roots + declare the crate-level lint attributes before modules or other crate items; crate-level `//!` + documentation may precede them. - `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are safety nets, not substitutes for `Result`. @@ -78,13 +133,11 @@ Repos don't share 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`). +- **Never break wasm** (`cargo xtask wasm`). ### Quality gates (before every commit) `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). One task per commit with a -milestone id in the message (`M2.1: auto layout wrap`). Commit after every landed arc of work; push -to `main`. +milestone id in the message (`M2.1: auto layout wrap`). Commit after every landed arc of work. ## Running and looking at the app - `cargo run -p figcraft -- --control [file.figcraft|file.svg|image]`. Pick a free port @@ -98,7 +151,6 @@ to `main`. incumbent side by side where the owner allows (`plan/figma/`). - Headless: `figcraft-cli` (`info`, `export`, `run`, `commands`, `mcp`). MCP: `figcraft-cli mcp` (see `docs/mcp.md`). -- Shell gotcha on the owner's machine: `mv`/`cp` may be aliased interactive — use `/bin/mv -f`. - Parallel agents: separate `CARGO_TARGET_DIR` per agent, edit only the crates/files you own, write manifests atomically, delete your target dir when done (disk is limited). diff --git a/CLAUDE.md b/CLAUDE.md index d12e64e3b..43c994c2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,133 +1 @@ -# FigCraft — instructions for agents - -FigCraft is a clean-room, open-source, pure-Rust (egui) reimplementation of the Figma design workflow: -interface design, auto layout, components and variants, styles and variables, prototyping and -inspection. It runs natively on macOS, Windows, Linux and FreeBSD, and in the browser via WASM. -Siblings: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop-class), `../designcraft` -(InDesign-class) and the other Crafting Apps; same conventions. - -Repos don't share code. - -## Start every session here -1. Read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, estimates) and - [`docs/gaps.md`](docs/gaps.md) (the ranked work list). Unless the user gives you a task, pick - work from the top of `docs/gaps.md`. -2. Read `plan/STATUS.md` and `plan/execution-plan.md` (local, gitignored) if present; behaviour - reference notes are in `plan/figma/*.md`. -3. Orient → plan → implement + test → verify (screenshot) → record (ROADMAP, devlog) → commit. - -`plan/` is gitignored (local only). - -## Non-negotiables - -### Clean-room -- Behaviour comes from public documentation and black-box observation of the running incumbent app - with synthetic documents only. Never read, disassemble or copy anything inside the incumbent's - application bundle, web bundle or network traffic (names and public listings only). -- Never copy GPL/AGPL code. -- Reference screenshots of the incumbent stay local under the gitignored `plan/figma/screenshots/` - and are never committed, published or used as assets. - -### Assets: no third-party product iconography or images — ever (absolute rule from the project owner) -- **Never add, copy, trace, redraw-from, embed or ship any icon, image, illustration, cursor, logo, - template, UI kit, preset library, font file or screenshot from Figma, Adobe, Avid, Autodesk or any - other product, or from any source whose licence doesn't allow it.** This includes "lookalike" - redraws of their icons. Breaking this rule is the worst thing you can do in this repo. -- Every image, icon, font or other asset must be one of: - - original work created for FigCraft by a contributor (licensed MIT OR Apache-2.0); - - OSI open source; - - public domain / CC0; - - Creative Commons with redistribution allowed. -- **Every asset file must have a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (path, author, source, - licence, notes). `cargo xtask assets` (part of `cargo xtask ci`) fails on any unlisted file under - `assets/`, `docs/brand/` or `docs/images/`. -- FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and - are original. Keep it that way: new icons are new code, designed from scratch. -- The app icon is the owner's chameleon drawing, vectorised (`assets/app-icon/README.md`); `cargo xtask icon` - renders the PNGs and `.ico` from `assets/app-icon/figcraft.svg`. -- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this - repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input - (`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs` - embeds it). Everything must build, test and run without it (system fonts are scanned at runtime). -- Demo and test content is synthetic or public domain; never personal photos or data. -- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The - ArtCraft logos in `docs/brand/` are trademarks, not open source, under - `docs/brand/LICENSE-brand.txt`: never modify them or use them outside FigCraft. -- If the provenance of an asset is unclear, don't add it. - -### Never crash -- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code - (workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for - data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes. -- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them. -- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document - is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are - safety nets, not substitutes for `Result`. -- `unsafe_code = "forbid"` workspace-wide. -- Every crash fix comes with a regression test. The engine has a junk-parameter sweep over every - command (`crates/engine/src/tests.rs`): extend fuzz/sweep tests when you add parsers or commands. - -### Architecture -- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*` - (`spec!(id, label, params doc, undoable, run)`) + tests. The menus, shortcuts, Actions palette, - CLI, control channel and MCP server all dispatch the same command ids. Command ids follow the - incumbent's menu catalog in `crates/engine/src/catalog.rs` (the parity metric: `cargo xtask parity`). -- **Programmatic calls never open dialogs.** Commands only *request* UI (`Session::request_ui`); the - control channel drops dialog requests. -- **Layering** is enforced by `cargo xtask layers`: geom/color (L0) → doc (L1) → layout/text/pathops/plugin/collab - (L2) → render/format (L3) → svg/pdf (L4) → engine (L5) → ui-egui/mcp (L6) → apps. Nothing below - 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) -`cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). One task per commit with a -milestone id in the message (`M2.1: auto layout wrap`). Commit after every landed arc of work; push -to `main`. - -## Running and looking at the app -- `cargo run -p figcraft -- --control [file.figcraft|file.svg|image]`. Pick a free port - (`python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])'`); - sibling apps use the control channel too. -- Drive it with JSON lines, e.g. - `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` - then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods are documented - in `crates/ui-egui/src/control.rs` and `docs/control-protocol.md`. -- **For UI work, look at the result** (take a screenshot and read the PNG). Compare with the - incumbent side by side where the owner allows (`plan/figma/`). -- Headless: `figcraft-cli` (`info`, `export`, `run`, `commands`, `mcp`). MCP: `figcraft-cli mcp` - (see `docs/mcp.md`). -- Shell gotcha on the owner's machine: `mv`/`cp` may be aliased interactive — use `/bin/mv -f`. -- Parallel agents: separate `CARGO_TARGET_DIR` per agent, edit only the crates/files you own, - write manifests atomically, delete your target dir when done (disk is limited). - -## Roadmap -Progress docs: [`ROADMAP.md`](ROADMAP.md) (one-page -summary and stage), [`docs/target-app-parity.md`](docs/target-app-parity.md) (the authoritative -assessment), [`docs/gaps.md`](docs/gaps.md) (ranked work list), [`docs/roadmap.md`](docs/roadmap.md) -(milestones), [`docs/architecture.md`](docs/architecture.md), the per-area `docs/*-parity.md` files -and the generated [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`). -When a task lands, update the affected rows and the status line and revision history in the same -commit. Grade by behaviour against the incumbent, not by whether a menu item exists; err low. - -## Contributor credits (About window) - -- About ▸ Contributors are compiled into the binary from `contributors/contributors.json` - (commit stats; never hand-edit) and `contributors/people.toml` (names people chose for - themselves). -- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub - username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once - whether they want to be credited by more than their username: a real name, a display name, and/or - their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry - (copy the template at the top of the file) - and include it in their PR, committed as them. If no, change nothing: they are credited as - `@username` anyway. -- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or - GitHub profiles). Never hand-edit `contributors.json`. - -## CI workflows -- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here. -- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time. -- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it. +@AGENTS.md diff --git a/ROADMAP.md b/ROADMAP.md index 1a9258b9c..e3476b5d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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-11-02 · **Change:** minor (glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-11-05** · M1, #2003: the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code from rectangles and polygons (two offset squares, the result filled at reduced alpha); each entry still runs its command. - **2026-10-30**: M8, #1125: damaged `trak` size-table offsets are checked instead of wrapping on 32-bit targets, and an incomplete normal track is dropped entirely, never returned in part. Font indexing makes its existing bounds explicit, preserving later faces and sparse name-table reads. Synthetic regression tests in `fontdb_tests.rs`. Numbers unchanged. - **2026-10-30** · Text style dedupe is O(spans) (M8, #1102): `shape_text` deduped spans into styles by a scan over the styles already collected — O(spans × styles), so a styled text's dedupe grew with the square of its runs (a synthetic 50k-run text with 100 alternating styles spent 7.7 ms in it). Now the last two styles again (spans run in blocks), then buckets keyed by a cheap fingerprint (family and style bytes, size bits with −0.0 folded, the enums, fill count and solid colours), each bucket resolved with the full equality: the result is the scan's, never worse than it, and O(spans) when names and sizes differ (the same synthetic text: 2.6 ms). The font-db write lock is untouched: `find()` loads faces, which needs the write. `layout_dedupe_tests`. Numbers unchanged. - **2026-10-29** · M10: correct nine German labels in `de.toml`: gradient stops use Verlaufsstopp, OpenType ss01–ss05 use Stilsatz, and the Home-screen preference names the Startbildschirm consistently in the UI and Actions palette. This is a partial terminology pass; German remains a draft awaiting a full native-speaker review and maintainer approval. Numbers unchanged. @@ -358,6 +359,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-11-06 | patch | M8: glass takes one backdrop sample instead of its 18-sample dispersion window where the colours split under 1/256 px (the flat end of the curve, where the move is short), within one level of the window over any backdrop: a large pane renders about 2–5% faster, with the same pixels in the renders compared. Tests: `glass::tests::a_split_too_narrow_to_show_takes_one_sample`. Numbers unchanged | +| 2026-11-02 | patch | M8, #1885: a glass whose shape is too big to keep whole at the zoom (past `MAX_SHAPE_PX` with its core's margin) bends along its edge normals instead of working out a core, so its fields never cover more pixels than a kept shape's, and the core's feature transform holds 16 bytes a pixel. The edge normals are box-blurred twice again and the bend directions once, as documented. Test: `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`. Numbers unchanged | +| 2026-11-02 | minor | M3, #1885: glass bends the backdrop towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly instead of meeting along the diagonal, and a part thinner than twice that (a capsule, a letter's stroke) bends across itself towards its own middle (pixels about as deep as any within 2 px). Rectangles, frames, components, instances and sections whose corners aren't smoothed work their glass out in closed form, with nothing to rasterise, transform or keep: a zoom frame draws in 40 ms instead of 54 and a resize frame in 46 instead of 67 (`glass_render_timings`, release, 2×). Other shapes keep their fields, with the bend directions for the last two depths asked for beside them, so dragging the Depth slider works out only those again. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::neighbouring_strokes_bend_towards_their_own_middles`, `glass::tests::a_rounded_rectangle_in_closed_form_matches_its_distance_transform`, `glass_cut_tests::what_the_shape_depends_on_works_it_out_again_and_nothing_else_does`. Numbers unchanged | +| 2026-11-02 | minor | M3, #1885: glass's light is a line on its 1-unit bevel added to the backdrop, as the reference tool's: fully where the edge faces the light, none below 0.35 of facing it and 0.6 as much on the opposite side, with a faint gradient across the curve, 5% darker facing the light and lighter opposite (it was a glow mixed towards white, as deep as `splay` made it). `splay` turns the bend from along the edge's normal towards the way away from the layer's centre, as the reference tool's does, and no longer widens the light: files with a splay above 0 look different. The direction to the centre is worked out in the shape's own frame, so cut renders match. Tests: `glass::tests::the_light_lies_on_the_bevel_and_the_gradient_across_the_curve`, `backdrop_tests::splay_turns_the_bend_towards_the_centre`, `glass_cut_tests::the_light_is_a_line_on_the_bevel`. Numbers unchanged | | 2026-11-02 | minor | M3, #1885: glass bends the backdrop with the reference tool's thick-lens profile: most a 1-unit bevel in from the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further in, and a fifth as much on the bevel itself (it fell off as the square of the way in from the very edge). Dispersion splits the channels over 8 steps with a triangular window, red moving furthest and blue least, as far apart as the reference tool's (at 100% red and blue move 0.16 of the bend apart; it was three samples, blue furthest), and `Glass::max_bend` bounds red's furthest step, so the backdrop read around a pane covers every sample. Tests: `glass::tests::the_bend_follows_the_thick_lens_profile`, `glass::tests::dispersion_splits_the_channels_along_the_bend`. Numbers unchanged | | 2026-11-02 | minor | M3, #1885: glass effects take variables (light angle, intensity, refraction, depth, dispersion, frost and splay, on layers and in effect styles, and from the Design panel's glass fields), and a glass's frost stays within 0–100, the reference tool's range: a larger frost from an older file draws at 100, and the field, a bound variable and scaling stop there. Numbers unchanged | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | diff --git a/apps/figcraft/src/recovery_online_tests.rs b/apps/figcraft/src/recovery_online_tests.rs index f877a0d4b..29b821f81 100644 --- a/apps/figcraft/src/recovery_online_tests.rs +++ b/apps/figcraft/src/recovery_online_tests.rs @@ -77,6 +77,7 @@ fn root(name: &str) -> PathBuf { r } +/// What the scripted link says about the session it is in. fn info() -> SessionInfo { SessionInfo { role: Role::Guest, @@ -90,6 +91,7 @@ fn info() -> SessionInfo { address: SERVER.into(), network: true, access: Access::Edit, + view: None, } } diff --git a/contributors/people.toml b/contributors/people.toml index f7ee0420e..745bb84c3 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -104,3 +104,7 @@ real_name = "Oleksii Sukholita" [people.helenkwok] real_name = "Helen Kwok" + +[people.denisg0mes] +real_name = "Denis Gomes" +display_name = "Denis Gomes" diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index 7f074f476..eafb63763 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -1,9 +1,9 @@ -//! Join codes over the network: a file shared on the local network joined by its code (two -//! engine sessions over loopback), what the browser's driver does with an online code, a server +//! Join codes over the network: a file shared on the local network joined by its code, and by +//! its view-only code to look without editing (engine sessions over loopback), what the browser's driver does with an online code, a server //! older than join codes, and the links that name a code. (A `figcraft-server` handing codes out //! and resolving them is tested in its own crate, with the same link.) -use std::net::{Ipv4Addr, TcpListener}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -18,8 +18,9 @@ use crate::driver::{Driver, Socket}; use crate::guest::GuestConfig; use crate::inbox::Inbox; use crate::invite::{Endpoint, Invite, Lookup}; +use crate::link::reachable; use crate::loopback_tests::{fast_limits, quick}; -use crate::{NetLink, manage}; +use crate::{NetLink, lan_ip, manage}; fn session(name: &str) -> Session { let mut s = Session::new(); @@ -53,6 +54,7 @@ fn layers(s: &Session) -> usize { s.doc().map_or(0, |d| d.node_count()) } +/// A local network share's code joins its session from another app, and edits sync both ways. #[test] fn a_local_share_is_joined_by_its_code_and_edits_sync() { let (mut host, mut guest) = (session("Hana"), session("Ada")); @@ -65,7 +67,13 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() { assert_eq!(local.ip, Ipv4Addr::LOCALHOST); assert_eq!(shared["address"], format!("127.0.0.1:{}", local.port)); assert_eq!(shared["link"], local.link(), "the code and the invite link name the same session"); - assert!(shared["share"]["viewCode"].is_null(), "everyone edits on the local network"); + // A view-only code too, with its own secret, and no comment code. + let view = shared["share"]["viewCode"].as_str().unwrap(); + let Code::Local(viewing) = code::parse(view).unwrap() else { panic!("not a local code") }; + assert_eq!((viewing.ip, viewing.port), (local.ip, local.port), "the same session"); + assert_ne!(viewing.secret, local.secret, "a secret of its own"); + assert_eq!(shared["share"]["viewLink"], viewing.link()); + assert!(shared["share"]["commentCode"].is_null()); // Typed the way people type: lower case, spaces for the dashes. let typed = shown.to_lowercase().replace('-', " "); assert_eq!(guest.execute("collab.join", &json!({"code": typed})).unwrap()["status"], "connecting"); @@ -93,6 +101,8 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() { assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}"); } +/// A well-formed code with the wrong secret is refused by the host, a mistyped one never connects, +/// and there is no comment code on the local network. #[test] fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() { let (mut host, mut guest) = (session("Hana"), session("Ada")); @@ -112,11 +122,120 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() { *last = if *last == 'A' { 'B' } else { 'A' }; let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::()})).unwrap_err().to_string(); assert!(e.contains("typing mistake"), "{e}"); - // A view-only code doesn't exist here, and says where it does. - let e = host.execute("collab.share", &json!({"access": "view"})).unwrap_err().to_string(); + // A comment code doesn't exist here, and says where it does. + let e = host.execute("collab.share", &json!({"access": "comment"})).unwrap_err().to_string(); assert!(e.contains("share it online"), "{e}"); } +/// A local network share started for looking only shows its view code: whoever joins with it sees +/// every change and can't make one, the edit code (picked later) still edits, and stopping ends +/// both. +#[test] +fn a_local_view_code_lets_people_look_and_not_edit() { + let (mut host, mut viewer, mut editor) = (session("Hana"), session("Ada"), session("Bo")); + let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local", "access": "view"})).unwrap(); + assert_eq!(shared["share"]["access"], "view"); + let (shown, edit) = (shared["code"].as_str().unwrap().to_string(), shared["share"]["editCode"].as_str().unwrap().to_string()); + assert_eq!(shared["share"]["viewCode"], shown, "the code shown is the view code"); + assert_ne!(shown, edit); + viewer.execute("collab.join", &json!({"code": shown})).unwrap(); + pump(&mut [&mut host, &mut viewer], "the viewer's join", |s| live(s[1]) && s[0].live().peers.len() == 1); + let summary = viewer.live_summary(); + assert_eq!((&summary["access"], &summary["readOnly"]), (&json!("view"), &json!(true))); + // The host's edit reaches the viewer; the viewer's is refused before it leaves. + let before = layers(&host); + host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + pump(&mut [&mut host, &mut viewer], "the host's edit", |s| layers(s[1]) == before + 1); + let e = viewer.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap_err().to_string(); + assert!(e.contains("View only"), "{e}"); + // The edit code, picked now, still edits, and the viewer sees it. + assert_eq!(host.execute("collab.share", &json!({"access": "edit"})).unwrap()["code"], edit); + editor.execute("collab.join", &json!({"code": edit})).unwrap(); + pump(&mut [&mut host, &mut viewer, &mut editor], "the editor's join", |s| live(s[2]) && s[0].live().peers.len() == 2); + assert_eq!(editor.live_summary()["access"], "edit"); + editor.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap(); + pump(&mut [&mut host, &mut viewer, &mut editor], "the editor's edit", |s| layers(s[0]) == before + 2 && layers(s[1]) == before + 2); + assert_eq!(viewer.live_summary()["readOnly"], true, "picking the edit code changes nothing for who joined to look"); + assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true); + pump(&mut [&mut host, &mut viewer, &mut editor], "the end", |s| !s[1].live().status.active() && !s[2].live().status.active()); +} + +/// A local network share's `link` and `url` are the ones that go with the code shown: started for +/// looking only, whoever joins through either can't edit, and the session's line in `sessions` +/// says the same. +#[test] +fn a_local_view_share_s_link_and_url_only_let_people_look() { + let mut host = session("Hana"); + let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local", "access": "view"})).unwrap(); + for key in ["link", "url"] { + let way = shared[key].as_str().unwrap().to_string(); + let mut viewer = session("Ada"); + viewer.execute("collab.join", &json!({"link": way})).unwrap(); + pump(&mut [&mut host, &mut viewer], "the join", |s| live(s[1]) && s[0].live().peers.len() == 1); + let summary = viewer.live_summary(); + assert_eq!((&summary["access"], &summary["readOnly"]), (&json!("view"), &json!(true)), "joined with {key} {way}"); + let before = layers(&host); + let e = viewer.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap_err().to_string(); + assert!(e.contains("View only"), "{key}: {e}"); + host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + pump(&mut [&mut host, &mut viewer], "the host's edit", |s| layers(s[1]) == before + 1); + assert_eq!(layers(&host), before + 1, "nothing of the viewer's reached the host ({key})"); + viewer.execute("collab.leave", &json!({})).unwrap(); + pump(&mut [&mut host, &mut viewer], "the leave", |s| s[0].live().peers.is_empty()); + } + // The link, the URL and the code name the same way in, wherever the session is listed. + let Code::Local(local) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") }; + assert_eq!((&shared["link"], &shared["share"]["viewLink"]), (&json!(local.link()), &json!(local.link()))); + let url = shared["url"].as_str().unwrap(); + assert!(url.starts_with("ws://") && url.ends_with(&format!("&token={}", local.token())), "{url}"); + assert_eq!(shared["sessions"][0]["link"], shared["link"]); + // Picking the edit code makes them the edit ones too. + let edit = host.execute("collab.share", &json!({"access": "edit"})).unwrap(); + assert_eq!((&edit["link"], &edit["sessions"][0]["link"]), (&edit["share"]["editLink"], &edit["share"]["editLink"])); + let edit_token = edit["link"].as_str().unwrap().rsplit("token=").next().unwrap(); + assert!(edit["url"].as_str().unwrap().ends_with(&format!("&token={edit_token}")), "{}", edit["url"]); + assert_ne!((&edit["link"], &edit["url"]), (&shared["link"], &shared["url"])); + assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true); +} + +/// A share listening on every interface (`bind` 0.0.0.0) names where others reach it, never +/// 0.0.0.0: this machine's local network address (loopback without one) in its codes, links, URL +/// and address, and its view code and edit link both join it. +#[test] +fn a_share_on_every_interface_names_an_address_others_reach() { + let (mut host, mut viewer, mut editor) = (session("Hana"), session("Ada"), session("Bo")); + let shared = host.execute("collab.share", &json!({"via": "local", "bind": "0.0.0.0", "access": "view"})).unwrap(); + let Code::Local(viewing) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") }; + let at = lan_ip().unwrap_or(Ipv4Addr::LOCALHOST.into()); + assert_eq!(IpAddr::from(viewing.ip), at); + let address = format!("{at}:{}", viewing.port); + assert_eq!(shared["address"], address); + let Code::Local(editing) = code::parse(shared["share"]["editCode"].as_str().unwrap()).unwrap() else { panic!("not a local code") }; + assert_eq!((editing.ip, editing.port), (viewing.ip, viewing.port)); + for way in [&shared["link"], &shared["url"], &shared["share"]["viewLink"], &shared["share"]["editLink"], &shared["sessions"][0]["link"]] { + let way = way.as_str().unwrap_or_default(); + assert!(way.contains(&address) && !way.contains("0.0.0.0"), "{way}"); + } + viewer.execute("collab.join", &json!({"code": shared["code"]})).unwrap(); + editor.execute("collab.join", &json!({"link": shared["share"]["editLink"]})).unwrap(); + pump(&mut [&mut host, &mut viewer, &mut editor], "the joins", |s| live(s[1]) && live(s[2]) && s[0].live().peers.len() == 2); + assert_eq!((&viewer.live_summary()["access"], &editor.live_summary()["access"]), (&json!("view"), &json!("edit"))); + assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true); +} + +/// A listener on one address is reached at it; one on every interface at an address of its own +/// family, on its port. +#[test] +fn a_listener_on_every_interface_is_reached_at_an_address_of_its_family() { + let one: SocketAddr = "192.168.1.20:47900".parse().unwrap(); + assert_eq!(reachable(one), one); + for any in ["0.0.0.0:47900", "[::]:47901"] { + let any: SocketAddr = any.parse().unwrap(); + let at = reachable(any); + assert!(!at.ip().is_unspecified() && at.is_ipv4() == any.is_ipv4() && at.port() == any.port(), "{any}: {at}"); + } +} + /// An invite naming only `server` (what a join code is asked at). fn code_at(server: &Endpoint) -> Invite { Invite { server: server.clone(), file: String::new(), token: String::new() } diff --git a/crates/collab-net/src/driver.rs b/crates/collab-net/src/driver.rs index 3ddd3b3c1..001497c6d 100644 --- a/crates/collab-net/src/driver.rs +++ b/crates/collab-net/src/driver.rs @@ -391,7 +391,7 @@ impl Driver { match after(outcome, &self.cfg, self.was_live, &mut self.attempt, &self.inbox) { Next::Stop(e) => { if let Some(e) = e { - self.inbox.push(e); + self.inbox.push(*e); } self.done = true; } diff --git a/crates/collab-net/src/driver_tests.rs b/crates/collab-net/src/driver_tests.rs index ea7900745..465bf4ff6 100644 --- a/crates/collab-net/src/driver_tests.rs +++ b/crates/collab-net/src/driver_tests.rs @@ -419,6 +419,8 @@ fn pump(d: &mut Driver) { } } +/// The browser's driver joins a real host over loopback, edits with it, sends presence and hears +/// the session end. #[test] fn joins_a_real_host_and_edits_with_it() { let host_probe = Inbox::new(Arc::new(|| {})); @@ -428,6 +430,7 @@ fn joins_a_real_host_and_edits_with_it() { any_port_if_taken: false, file: FILE.into(), token: TOKEN.into(), + view_token: None, doc: shared(), name: "Hana".into(), limits: fast_limits(), diff --git a/crates/collab-net/src/guest.rs b/crates/collab-net/src/guest.rs index bdb5b98b2..79412e61e 100644 --- a/crates/collab-net/src/guest.rs +++ b/crates/collab-net/src/guest.rs @@ -67,7 +67,7 @@ impl Guest { }); if let Err(o) = asked.and_then(|(file, token)| resolved(&mut cfg, file, token)) { if let Next::Stop(Some(e)) = after(unresolved(&cfg, o), &cfg, false, &mut 0, &inbox) { - inbox.push(e); + inbox.push(*e); } return; } @@ -217,7 +217,7 @@ fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver, me: &Sender { if let Some(e) = e { - inbox.push(e); + inbox.push(*e); } return; } diff --git a/crates/collab-net/src/host.rs b/crates/collab-net/src/host.rs index 24be22d3e..c7180708c 100644 --- a/crates/collab-net/src/host.rs +++ b/crates/collab-net/src/host.rs @@ -1,7 +1,8 @@ //! The session host the app embeds: the shared [hub](crate::hub) serving one file, the one the -//! app shares, with the session's single token. The person hosting is in it without a socket: -//! their presence goes in with [`Host::presence`], their edits with [`Host::send`], what others do -//! comes out through their [`Inbox`]. When the host stops, the session ends for everyone. +//! app shares, with the session's edit token and, for a share that has one, its view token. The +//! person hosting is in it without a socket: their presence goes in with [`Host::presence`], their +//! edits with [`Host::send`], what others do comes out through their [`Inbox`]. When the host +//! stops, the session ends for everyone. use std::io; use std::net::{SocketAddr, TcpListener}; @@ -26,6 +27,9 @@ pub struct HostConfig { /// The file's session id and secret (both in the invite). pub file: String, pub token: String, + /// A second secret that lets its holders look and not edit (`None`: everyone who gets in + /// edits). It must differ from `token`. + pub view_token: Option, /// The shared document (the session's authoritative copy starts from it). pub doc: Document, /// The host's own name (empty: "Host"). @@ -40,21 +44,28 @@ pub struct Host { me: Peer, } -/// The app's one file and its token: everyone with the invite edits. +/// The app's one file and its tokens: whoever has the edit token edits, whoever has the view +/// token only looks (the server refuses their changes). struct OneFile { file: String, token: String, + view: Option, } impl Backend for OneFile { + /// Let in a hello for `file` with `token`, with what its token allows. fn admit(&mut self, file: &str, token: &str) -> Result { if file != self.file { return Err("this invite is for another file or an earlier session".into()); } - if !same_secret(token, &self.token) { - return Err("wrong session token".into()); + // Both tokens are compared, whichever matches, so the time taken doesn't tell them apart. + let edit = same_secret(token, &self.token); + let view = self.view.as_deref().is_some_and(|v| same_secret(token, v)); + match (edit, view) { + (true, _) => Ok(Admission { access: Access::Edit, link: None }), + (false, true) => Ok(Admission { access: Access::View, link: None }), + (false, false) => Err("wrong session token".into()), } - Ok(Admission { access: Access::Edit, link: None }) } fn open(&mut self, _: &str) -> Result { @@ -69,6 +80,10 @@ impl Backend for OneFile { impl Host { /// Listen and start the hub; the host joins its own session as the first person. pub fn start(cfg: HostConfig, inbox: Inbox) -> io::Result { + // A view token equal to the edit token would let its holders edit: nobody gets in with it. + if cfg.view_token.as_deref().is_some_and(|v| same_secret(v, &cfg.token)) { + return Err(io::Error::other("the view-only secret is the same as the edit secret")); + } let listener = match TcpListener::bind(cfg.addr) { Ok(l) => l, Err(e) if e.kind() == io::ErrorKind::AddrInUse && cfg.any_port_if_taken => TcpListener::bind(SocketAddr::new(cfg.addr.ip(), 0))?, @@ -99,7 +114,7 @@ impl Host { } } let me = server.peers().find(|p| p.client_id == me).cloned().ok_or_else(|| io::Error::other("the host didn't join its own session"))?; - let backend = OneFile { file: cfg.file.clone(), token: cfg.token }; + let backend = OneFile { file: cfg.file.clone(), token: cfg.token, view: cfg.view_token }; let local = Local { file: cfg.file, server, id: me.client_id, inbox }; let hosting = Hosting::start(listener, backend, cfg.limits, Some(local))?; Ok(Host { hosting, me }) diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index 033186af2..f86409b40 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -137,8 +137,9 @@ pub(crate) fn closed(code: u16, reason: &str) -> Outcome { /// What a guest does after a connection ended. pub(crate) enum Next { - /// Done: tell the UI this (nothing when you left). - Stop(Option), + /// Done: tell the UI this (nothing when you left). Boxed: an event can be large (a welcome), + /// and this one is only ever a status or the end of the session. + Stop(Option>), /// Connect again after this pause (the UI has been told: `Reconnecting`). Reconnect(Duration), } @@ -147,12 +148,12 @@ pub(crate) enum Next { /// can't reach the host; a live connection that dropped tries again with growing pauses until the /// attempts run out. pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt: &mut u32, inbox: &Inbox) -> Next { - let failed = |reason: String| Next::Stop(Some(Event::Status(Status::Failed { reason }))); + let failed = |reason: String| Next::Stop(Some(Box::new(Event::Status(Status::Failed { reason })))); match outcome { Outcome::Left => Next::Stop(None), Outcome::Refused(why) => failed(format!("The host refused: {why}")), Outcome::Failed(why) => failed(why), - Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })), + Outcome::Ended(reason) => Next::Stop(Some(Box::new(Event::Ended { reason }))), Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")), Outcome::Retry(why) => { *attempt += 1; @@ -228,6 +229,7 @@ pub(crate) fn take(mut m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mu address: inv.server.display(), network: !inv.loopback(), access, + view: None, }; seen.peers = peers.clone(); seen.info = Some(info.clone()); diff --git a/crates/collab-net/src/link.rs b/crates/collab-net/src/link.rs index fdcba5b97..66d90bc22 100644 --- a/crates/collab-net/src/link.rs +++ b/crates/collab-net/src/link.rs @@ -1,6 +1,6 @@ //! [`NetLink`]: the engine's [`LiveLink`] over the network. -use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; @@ -9,7 +9,7 @@ use figcraft_collab::code; use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq}; use figcraft_engine::live::{ Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Resume, Role, - SessionId, SessionInfo, Shared, + SessionId, SessionInfo, Shared, WayIn, }; use crate::guest::{Guest, GuestConfig, Publish, upload_file}; @@ -97,24 +97,62 @@ impl NetLink { } } +/// A new secret for a hosted session: one a join code can carry (`short`: 44 bits, see +/// `figcraft_collab::code`), else a full one. +fn session_secret(short: bool) -> Result { + match short { + true => Ok(code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token()), + false => token::secret(), + } +} + +/// A view-only secret for a session whose edit secret is `edit`: of the same kind, never the same. +fn view_secret(short: bool, edit: &str) -> Result { + for _ in 0..3 { + let view = session_secret(short)?; + if view != edit { + return Ok(view); + } + } + Err("couldn't make a view-only secret".into()) +} + /// This machine's address on the local network: the interface the default route leaves by (no /// packet is sent). pub fn lan_ip() -> Option { - let sock = UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))).ok()?; // Documentation and private ranges: only the route matters. - for probe in ["192.0.2.1:9", "10.255.255.255:9"] { - if sock.connect(probe).is_ok() - && let Ok(a) = sock.local_addr() - && !a.ip().is_loopback() - && !a.ip().is_unspecified() - { - return Some(a.ip()); - } - } - None + route_ip(Ipv4Addr::UNSPECIFIED.into(), &["192.0.2.1:9", "10.255.255.255:9"]) +} + +/// The address of `any`'s family that the route to the first of `probes` that has one leaves by +/// (no packet is sent): never a loopback, unspecified or IPv6 link-local one, which others can't +/// reach this machine at. +fn route_ip(any: IpAddr, probes: &[&str]) -> Option { + let sock = UdpSocket::bind(SocketAddr::new(any, 0)).ok()?; + probes.iter().find_map(|probe| { + sock.connect(probe).ok()?; + let ip = sock.local_addr().ok()?.ip(); + let link_local = matches!(ip, IpAddr::V6(v6) if v6.is_unicast_link_local()); + (!ip.is_loopback() && !ip.is_unspecified() && !link_local).then_some(ip) + }) +} + +/// Where others reach a session listening on `addr`: `addr` itself, or for a listener on every +/// interface (`0.0.0.0`, `::`) this machine's local network address of the same family (its +/// loopback without one), on the same port. Links name it; the listener keeps every interface. +pub(crate) fn reachable(addr: SocketAddr) -> SocketAddr { + let ip = match addr.ip() { + ip if !ip.is_unspecified() => ip, + IpAddr::V4(_) => lan_ip().unwrap_or(Ipv4Addr::LOCALHOST.into()), + // Documentation and unique local ranges, as for IPv4. + IpAddr::V6(_) => route_ip(Ipv6Addr::UNSPECIFIED.into(), &["[2001:db8::1]:9", "[fd00::1]:9"]).unwrap_or(Ipv6Addr::LOCALHOST.into()), + }; + SocketAddr::new(ip, addr.port()) } impl LiveLink for NetLink { + /// Host the open file on `o.bind`: a join code's short secret when `o.short`, and a second, + /// view-only secret with its invite link when `o.view`. fn host(&mut self, session: SessionId, o: HostOptions) -> Result { let ip = match o.bind { Bind::Local => IpAddr::V4(Ipv4Addr::LOCALHOST), @@ -123,11 +161,10 @@ impl LiveLink for NetLink { }; // A session shared with a join code: its secret is short enough to be typed with the // address (see `figcraft_collab::code`); the code and the invite link name the same session. - let (file, token) = if o.short { - (code::LOCAL_FILE.to_string(), code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token()) - } else { - (token::file_id()?, token::secret()?) - }; + let file = if o.short { code::LOCAL_FILE.to_string() } else { token::file_id()? }; + let token = session_secret(o.short)?; + // A way in for looking only: a second secret of the same kind, never the edit one. + let view_token = o.view.then(|| view_secret(o.short, &token)).transpose()?; let inbox = self.sessions.inbox(); let doc = (*o.doc).clone(); let pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect(); @@ -137,13 +174,17 @@ impl LiveLink for NetLink { any_port_if_taken: o.port.is_none(), file: file.clone(), token: token.clone(), + view_token: view_token.clone(), doc, name: o.name, limits: self.limits, core: figcraft_collab::Limits::default(), }; let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?; - let invite = Invite::new(host.addr().to_string(), file, token); + // Listening on every interface (`0.0.0.0`, `::`), the links name an address others reach. + let at = reachable(host.addr()).to_string(); + let view = view_token.map(|v| Invite::new(at.clone(), file.clone(), v)).map(|v| WayIn { link: v.link(), url: v.url() }); + let invite = Invite::new(at.clone(), file, token); let info = SessionInfo { role: Role::Host, me: host.me().client_id, @@ -153,9 +194,10 @@ impl LiveLink for NetLink { pages, link: invite.link(), url: invite.url(), - address: host.addr().to_string(), + address: at, network: !ip.is_loopback(), access: Access::Edit, + view, }; self.started(session, Active::Host(host), inbox); Ok(info) diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index 30f77bcb3..dadda9ef4 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -7,7 +7,7 @@ use std::net::{SocketAddr, TcpStream}; use std::sync::Arc; use std::time::{Duration, Instant}; -use figcraft_collab::message::{self, CatchUp, ClientMsg, Hello, Presence, ServerMsg, Signal}; +use figcraft_collab::message::{self, Access, CatchUp, ClientMsg, Hello, Presence, ServerMsg, Signal}; use figcraft_collab::{Client, Limits}; use figcraft_doc::{Document, NodeId}; use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN}; @@ -78,6 +78,7 @@ pub(crate) fn fast_limits() -> NetLimits { NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() } } +/// A host on a free loopback port with these limits, a probe of what it is told, and its invite. fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) { let probe = Probe::new(); let mut doc = Document::new("Shared file"); @@ -87,6 +88,7 @@ fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) { any_port_if_taken: false, file: FILE.into(), token: TOKEN.into(), + view_token: None, doc, name: "Hana".into(), limits, @@ -214,6 +216,7 @@ fn guests_join_and_see_each_other() { assert_eq!(host.connections(), 0); } +/// A guest with the wrong token or for another file is refused, and the secret never shows. #[test] fn a_wrong_token_or_file_is_refused() { let (mut host, mut hp, invite) = start(); @@ -236,6 +239,57 @@ fn a_wrong_token_or_file_is_refused() { host.stop(); } +/// Join `addr` with `token` over a bare socket and make an edit: what the welcome allowed, and +/// whether the host took the edit (`Changes`) or rejected it (`Reject`). +fn join_and_edit(addr: SocketAddr, token: &str) -> (Access, ServerMsg) { + let mut raw = Raw::hello(addr, token); + let mut welcome = std::iter::from_fn(|| raw.next()).find(|m| matches!(m, ServerMsg::Welcome { .. })).expect("a welcome"); + message::unpack(&mut welcome, 64 << 20).unwrap(); + let ServerMsg::Welcome { access, .. } = welcome else { panic!("not a welcome: {welcome:?}") }; + let mut client = Client::join(welcome).unwrap(); + let mut doc = client.document().clone(); + doc.add_page("Mine"); + assert!(raw.send(&client.edit(&doc).unwrap().unwrap())); + let answer = std::iter::from_fn(|| raw.next()).find(|m| matches!(m, ServerMsg::Changes(_) | ServerMsg::Reject { .. })).expect("an answer"); + (access, answer) +} + +/// A session hosted with a view token as well (a local network share's view-only code): whoever +/// joins with it is welcomed as a viewer and their edits are rejected, the edit token still edits, +/// any other token is still refused, and a view token equal to the edit token isn't hosted. +#[test] +fn a_view_token_lets_people_look_and_not_edit() { + const VIEW: &str = "ViewOnlyViewOnlyView00"; + let probe = Probe::new(); + let cfg = |view: &str| { + let mut doc = Document::new("Shared file"); + doc.repair(); + HostConfig { + addr: SocketAddr::from(([127, 0, 0, 1], 0)), + any_port_if_taken: false, + file: FILE.into(), + token: TOKEN.into(), + view_token: Some(view.into()), + doc, + name: "Hana".into(), + limits: fast_limits(), + core: Limits::default(), + } + }; + let same = Host::start(cfg(TOKEN), probe.inbox.clone()).err().expect("a view token equal to the edit token is refused"); + assert!(same.to_string().contains("view-only"), "{same}"); + let mut host = Host::start(cfg(VIEW), probe.inbox.clone()).unwrap(); + let (access, answer) = join_and_edit(host.addr(), VIEW); + assert_eq!(access, Access::View); + assert!(matches!(&answer, ServerMsg::Reject { reason, .. } if reason.contains("view")), "{answer:?}"); + let (access, answer) = join_and_edit(host.addr(), TOKEN); + assert_eq!(access, Access::Edit); + assert!(matches!(answer, ServerMsg::Changes(_)), "{answer:?}"); + let refusal = Raw::hello(host.addr(), "WrongWrongWrongWrong00").refusal(); + assert!(refusal.as_deref().is_some_and(|r| r.contains("token")), "{refusal:?}"); + host.stop(); +} + #[test] fn unreachable_hosts_fail_at_once() { // A port nothing listens on. @@ -677,6 +731,7 @@ fn the_host_removes_someone_and_they_cant_come_back() { assert_eq!(host.connections(), 0); } +/// A welcome larger than the guest takes says the file is too large to join. #[test] fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { let mut doc = Document::new("B".repeat(1 << 20)); @@ -687,6 +742,7 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { any_port_if_taken: false, file: FILE.into(), token: TOKEN.into(), + view_token: None, doc, name: "Hana".into(), limits: fast_limits(), diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 13d1878aa..f3726033e 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -662,7 +662,10 @@ pub struct Glass { pub dispersion: f64, /// Blur of the backdrop (a blur radius, document units, 0–[`Self::MAX_FROST`]). pub frost: f64, - /// How widely the light spreads over the glass (0–1). + /// Which way the curved edge bends the backdrop: towards the shape's core (0; along the edge's + /// normal for a shape too big to work one out) to away from the centre of the layer's bounds + /// (1; on a ring or an L, whose centre lies outside the shape, an inner edge bends towards the + /// hole, as the reference tool's does). pub splay: f64, } diff --git a/crates/engine/src/assets/library.rs b/crates/engine/src/assets/library.rs index 01e8d3244..d9107ad63 100644 --- a/crates/engine/src/assets/library.rs +++ b/crates/engine/src/assets/library.rs @@ -1352,6 +1352,15 @@ pub fn library_of(doc: &Document, id: NodeId) -> Option<&str> { doc.libraries.import_of(root).map(|r| r.library.as_str()) } +pub fn library_source(doc: &Document, id: NodeId) -> Option<(&str, NodeId)> { + if !doc.is_remote(id) { + return None; + } + let record = doc.libraries.import_of(asset_root(doc, id)?)?; + let key = record.ids.iter().find(|(_, local)| **local == id).map_or(record.key, |(key, _)| *key); + Some((record.library.as_str(), key)) +} + /// The display name of library `id` in `doc` (its link's name, else the id). pub fn library_name<'a>(doc: &'a Document, id: &'a str) -> &'a str { doc.libraries.link(id).map_or(id, |l| l.name.as_str()) diff --git a/crates/engine/src/assets/library/tests.rs b/crates/engine/src/assets/library/tests.rs index bb65f8885..733c3ba8c 100644 --- a/crates/engine/src/assets/library/tests.rs +++ b/crates/engine/src/assets/library/tests.rs @@ -189,8 +189,6 @@ fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) { let style = s.doc().unwrap().styles[0].id.clone(); assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err()); assert!(s.execute("style.delete", &json!({"style": style})).is_err()); - let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err(); - assert!(e.to_string().contains("library"), "{e}"); assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected"); assert!(s.doc().unwrap().pages.contains(&s.page().unwrap())); assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err()); @@ -399,6 +397,75 @@ fn add_place_and_update_keep_overrides() { let _ = std::fs::remove_dir_all(&d); } +fn go_to_main(s: &mut Session, id: NodeId) -> Value { + s.active = 0; + ex(s, "component.goToMain", json!({"id": id})) +} + +#[test] +fn going_to_a_library_main_opens_the_library_file() { + let d = dir("go-to-main"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + let k = kit(&mut lib); + let mut chips = Vec::new(); + for (i, color) in ["#ff0000", "#00ff00"].iter().enumerate() { + let f = idv(&ex(&mut lib, "frame.create", json!({"x": 400.0 + i as f64 * 100.0, "y": 0, "width": 60, "height": 20, "name": "Chip"}))); + ex(&mut lib, "node.set", json!({"id": f, "props": {"fill": color}})); + chips.push(idv(&ex(&mut lib, "component.create", json!({"ids": [f]})))); + } + ex(&mut lib, "component.combineVariants", json!({"ids": chips})); + let kit_page = lib.doc().unwrap().page_of(k.card).unwrap(); + ex(&mut lib, "page.add", json!({"name": "Notes"})); + let lid = publish_to(&mut lib, &lib_path); + + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let card = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Card", "x": 0, "y": 0}))); + let nested_button = node(&s, card).children[0]; + let chip = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Chip", "x": 0, "y": 200}))); + ex(&mut s, "instance.setProperties", json!({"ids": [chip], "properties": {"Property 1": "Variant2"}})); + s.ds_mut().unwrap().view.vw = 900.0; + s.ds_mut().unwrap().view.vh = 500.0; + let using = s.doc().unwrap().clone(); + + let r = go_to_main(&mut s, card); + assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(k.card), Some(1)), "{r}"); + let ds = s.ds().unwrap(); + assert_eq!(ds.path.as_deref(), Some(lib_path.as_str())); + assert_eq!((ds.selection.clone(), ds.page), (vec![k.card], kit_page)); + assert_eq!((ds.view.vw, ds.view.vh), (900.0, 500.0), "the canvas it opens on is the one shown, so the fit stands"); + let shown = ds.doc.world_bounds(k.card).unwrap().center(); + assert_eq!((ds.view.cx, ds.view.cy), (shown.x, shown.y)); + + for (layer, main) in [(nested_button, k.button), (chip, chips[1])] { + let r = go_to_main(&mut s, layer); + assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(main), Some(1)), "{r}"); + assert_eq!(s.ds().unwrap().selection, vec![main]); + } + assert_eq!(s.docs.len(), 2, "the open library file comes to the front"); + assert_eq!(s.docs[0].doc.as_ref(), &using, "the file going there is unchanged"); + + s.active = 1; + ex(&mut s, "edit.delete", json!({"ids": [k.card]})); + let r = go_to_main(&mut s, card); + assert!(r["id"].is_null() && r["index"] == 1, "{r}"); + assert!(s.message.take().is_some_and(|m| m.contains("isn't in this file any more"))); + + ex(&mut s, "file.close", json!({"index": 1, "force": true})); + std::fs::rename(&lib_path, d.join("moved.figcraft")).unwrap(); + s.active = 0; + let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err(); + assert!(e.to_string().contains("isn't at"), "{e}"); + assert_eq!(s.docs.len(), 1); + + let NodeKind::Instance { component: copy, .. } = node(&s, card).kind else { panic!("not an instance") }; + s.doc_mut().unwrap().libraries_mut().components.retain(|r| r.root != copy); + let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err(); + assert!(e.to_string().contains("no link to its library"), "an imported copy without its record: {e}"); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn library_styles_apply_and_import_with_pages() { let d = dir("styles"); diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index 194a90fe2..64d48bc86 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -218,15 +218,16 @@ impl Session { // Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping. let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; let mut guides = Vec::new(); + if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) { + // Keep the box's origin on whole pixels (a rotated parent frame's, inside one); + // snapping to layers comes after, so it wins on the axes it snaps. + d = targets.round_move(d); + } if snap_on { let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6)); d += c; guides = g2; } - if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) { - // Keep the box's origin on whole pixels (a rotated parent frame's, inside one). - d = targets.round_move(d); - } let ids = ids.clone(); let dup = *duplicate; let page = self.page()?; @@ -475,20 +476,21 @@ impl Session { let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None }; let mut p = ev.p(); let mut guides = Vec::new(); + let _ = start; + // In the handle box's own axes (one layer: its local space). + let (frame, w0, h0) = r.frame; + // The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool + // scales by the factor as dragged). Snapping to layers comes after, so it wins on the axes + // it snaps. + if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) { + p = Point::new(p.x.round(), p.y.round()); + } if let Some(t) = &targets { let (dx, dy, g) = t.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); p.x += dx; p.y += dy; guides = g; } - let _ = start; - // In the handle box's own axes (one layer: its local space). - let (frame, w0, h0) = r.frame; - // The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool - // scales by the factor as dragged). - if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) { - p = Point::new(p.x.round(), p.y.round()); - } let q = frame.inverse() * p; let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false); if scale_tool { diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs index d59a2b904..e3cbe7787 100644 --- a/crates/engine/src/canvas/tools/draw.rs +++ b/crates/engine/src/canvas/tools/draw.rs @@ -29,20 +29,23 @@ impl Session { let tool = self.tool.tool; let mut p = ev.p(); let mut guides = Vec::new(); + let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true); + // The pixel grid first: snapping to layers comes after, so it wins on the axes it snaps. + if pixel { + p = Point::new(p.x.round(), p.y.round()); + } if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl { let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); p.x += dx; p.y += dy; guides = g; } - let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true); let mut created = id; let page = self.page()?; self.preview(|doc, sel| { - let (mut a, mut b) = (start, p); + let (mut a, b) = (start, p); if pixel { a = Point::new(a.x.round(), a.y.round()); - b = Point::new(b.x.round(), b.y.round()); } let pw = doc.world_transform(parent); let (kind, base) = match tool { diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 50ce61500..e0b2e02f4 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -186,28 +186,42 @@ pub fn specs() -> Vec { true, reset_override ), - spec!("component.goToMain", "Go to main component", "{id?}", false, |s, p| { - let cmd = "component.goToMain"; - let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; - let doc = s.doc()?; - let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; - let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { - return Err(bad(cmd, "not an instance")); - }; - if !doc.contains(component) { - return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); + spec!( + "component.goToMain", + "Go to main component", + "{id?} selects the instance's main component; a library component's main is in its library file, which opens in a tab (or comes to the front) with the main selected. Returns {id} (the main in the file now shown), and `index` (the tab) when it went to a library file", + false, + |s, p| { + let cmd = "component.goToMain"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; + let doc = s.doc()?; + let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; + let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { + return Err(bad(cmd, "not an instance")); + }; + if !doc.contains(component) { + return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); + } + if let Some((library, key)) = crate::library::library_source(doc, component) { + let name = crate::library::library_name(doc, library).to_string(); + let library = library.to_string(); + return go_to_library_main(s, &library, &name, key); + } + if doc.is_remote(component) { + return Err(EngineError::Disabled( + cmd.into(), + "the main component is a library copy with no link to its library (it came with an imported file): enable that library to reach it" + .into(), + )); + } + super::edit::select_and_page(s, vec![component])?; + let ds = s.ds_mut()?; + if let Some(b) = ds.doc.world_bounds(component) { + ds.view.fit(b, 2.0); + } + Ok(json!({"id": component})) } - if let Some(lib) = crate::library::library_of(doc, component) { - let name = crate::library::library_name(doc, lib); - return Err(EngineError::Disabled(cmd.into(), format!("the main component is in the library \"{name}\": open that file to edit it"))); - } - super::edit::select_and_page(s, vec![component])?; - let ds = s.ds_mut()?; - if let Some(b) = ds.doc.world_bounds(component) { - ds.view.fit(b, 2.0); - } - Ok(json!({"id": component})) - }), + ), spec!("component.pushChanges", "Push changes to main component", "{id?}", true, push_changes), spec!( "component.restoreMain", @@ -283,6 +297,43 @@ pub fn specs() -> Vec { ] } +fn go_to_library_main(s: &mut Session, library: &str, name: &str, key: NodeId) -> Result { + let cmd = "component.goToMain"; + let refuse = |why: String| EngineError::Disabled(cmd.into(), why); + let path = s + .doc()? + .libraries + .link(library) + .map(|l| l.path.clone()) + .ok_or_else(|| refuse(format!("the library \"{name}\" was removed from this file")))?; + if crate::library_server::is_server_link(&path) { + return Err(refuse(format!("the main component is in the team library \"{name}\": open its file from the server to edit it"))); + } + if !craft_docpath::is_local(&path) { + return Err(refuse(format!("{path}: a library is opened only from this computer"))); + } + if !crate::file_access::exists(&path) { + return Err(refuse(format!("the library file of \"{name}\" isn't at {path} any more"))); + } + let viewport = s.ds().map(|d| (d.view.vw, d.view.vh))?; + let opened = super::file::open(s, &json!({"path": path}))?; + let ds = s.ds_mut()?; + (ds.view.vw, ds.view.vh) = viewport; + let doc = &ds.doc; + let same_library = doc.published.as_ref().is_some_and(|p| p.manifest.id == library); + let main = doc.node(key).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })); + if !same_library || !main || doc.is_remote(key) { + s.message = Some(format!("{name}: the main component isn't in this file any more")); + return Ok(json!({"id": null, "index": opened["index"]})); + } + super::edit::select_and_page(s, vec![key])?; + let ds = s.ds_mut()?; + if let Some(b) = ds.doc.world_bounds(key) { + ds.view.fit(b, 2.0); + } + Ok(json!({"id": key, "index": opened["index"]})) +} + /// Place an instance of `component` centred on page point (`x`, `y`), in the frame or auto layout /// slot under it (`instance.place`). pub(crate) fn place(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 0cfdd1ffa..637d0d4a2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -37,7 +37,8 @@ pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variable pub use open::MAX_RECENT; #[cfg(test)] use open::append_pages; -use open::{import, open, open_recent, revert}; +pub(crate) use open::open; +use open::{import, open_recent, revert}; pub(crate) use save::save_ends_preview; pub use save::{FileSnapshot, file_bytes, thumbnail}; use save::{color_profile, mark_saved, save, save_as}; diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs index 59afad98d..4114b773e 100644 --- a/crates/engine/src/cmd/file/open.rs +++ b/crates/engine/src/cmd/file/open.rs @@ -54,7 +54,7 @@ pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result { Ok(json!({"files": files})) } -pub(super) fn open(s: &mut Session, p: &Value) -> Result { +pub(crate) fn open(s: &mut Session, p: &Value) -> Result { let variables = variables_param("file.open", p)?; if string(p, "path").is_none() && let Some(d) = string(p, "data") diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 2835deff3..458e7572c 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -129,7 +129,7 @@ pub fn specs() -> Vec { }), spec!("page.move", "Move page", "{id?, index}", true, |s, p| { let id = id(p, "id").unwrap_or(s.page()?); - let k = req_num("page.move", p, "index")?.max(0.0) as usize; + let k = req_index("page.move", p, "index")?; let doc = s.doc_mut()?; if !doc.pages.contains(&id) { return Err(bad("page.move", format!("{id} is not a page"))); diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 59ad46ced..75607ae64 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -12,7 +12,7 @@ use serde_json::{Value, json}; use super::lists::BoundList; use super::{CommandSpec, ok}; -use crate::params::{boolean, get, num, req_string, string}; +use crate::params::{boolean, get, req_index, req_string, string}; use crate::{Result, Session, bad, spec}; pub fn specs() -> Vec { @@ -258,13 +258,13 @@ fn remove_mode(s: &mut Session, p: &Value) -> Result { fn move_mode(s: &mut Session, p: &Value) -> Result { let cmd = "variable.moveMode"; - let to = num(p, "to").filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, "`to` must be a number"))?; + let to = req_index(cmd, p, "to")?; let doc = s.doc_mut()?; let (ci, mode) = mode_of(doc, cmd, p)?; let coll = doc.variables.collections.get_mut(ci).ok_or_else(|| bad(cmd, "no such collection"))?; let from = coll.modes.iter().position(|m| m.id == mode).ok_or_else(|| bad(cmd, "no such mode"))?; let m = coll.modes.remove(from); - let at = (to.max(0.0) as usize).min(coll.modes.len()); + let at = to.min(coll.modes.len()); coll.modes.insert(at, m); ok() } diff --git a/crates/engine/src/comment_link_tests.rs b/crates/engine/src/comment_link_tests.rs index 6c59e96f5..6a29eff2a 100644 --- a/crates/engine/src/comment_link_tests.rs +++ b/crates/engine/src/comment_link_tests.rs @@ -81,11 +81,12 @@ fn only_a_comment_link_leaves_the_comment_tool() { assert!(!tool_left(crate::live::PUBLISHING, ToolId::Comment)); } +/// A local network share has edit and view-only codes, not a comment code, and says where one is. #[test] fn a_local_share_has_no_comment_code() { let (mut s, _) = session(); let e = s.execute("collab.share", &json!({"via": "local", "access": "comment"})).unwrap_err().to_string(); - assert!(e.contains("can edit it"), "{e}"); + assert!(e.contains("share it online for a comment code"), "{e}"); let e = s.execute("collab.share", &json!({"access": "owner"})).unwrap_err().to_string(); assert!(e.contains("\"comment\""), "{e}"); } diff --git a/crates/engine/src/live_share_tests.rs b/crates/engine/src/live_share_tests.rs index 19afba621..9c86e0900 100644 --- a/crates/engine/src/live_share_tests.rs +++ b/crates/engine/src/live_share_tests.rs @@ -13,7 +13,7 @@ use serde_json::{Value, json}; use crate::live::{ Access, Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Role, SessionInfo, Shared, SharedLink, Status, - join_target, + WayIn, join_target, }; use crate::{EngineError, Session}; @@ -36,13 +36,23 @@ struct Link(Arc>); /// Where the scripted host listens, and its session's secret. const HOST: Local = Local { ip: Ipv4Addr::new(192, 168, 1, 20), port: 47_900, secret: 0x123_4567_89AB }; +/// The same session's view-only secret. +const VIEW: Local = Local { secret: 0xBA9_8765_4321, ..HOST }; + +/// The WebSocket URL the scripted link gives with an invite `link` (the same secret). +fn mock_url(link: &str) -> String { + link.replace("figcraft://join?host=", "ws://") +} impl LiveLink for Link { + /// Host as the network link does: a code's short secret when shared with a code, and the + /// view-only link when asked for one. fn host(&mut self, session: u64, opts: HostOptions) -> Result { let mut s = self.0.lock().unwrap(); s.session = session; // As the network link does: a short secret when the session is shared with a code. let link = if opts.short { HOST.link() } else { "figcraft://join?host=192.168.1.20:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv".to_string() }; + let view = opts.view.then(|| WayIn { link: VIEW.link(), url: mock_url(&VIEW.link()) }); s.hosted.push(opts); Ok(SessionInfo { role: Role::Host, @@ -51,11 +61,12 @@ impl LiveLink for Link { color: "#0D99FF".into(), file_name: "Shared".into(), pages: vec![], - url: link.replace("figcraft://join?host=", "ws://"), + url: mock_url(&link), link, address: "192.168.1.20:47900".into(), network: true, access: Access::Edit, + view, }) } fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> { @@ -146,6 +157,7 @@ fn on_the_server(s: &mut Session, script: &Arc>, codes: Option<(&s address: "wss://share.example:443/".into(), network: true, access: Access::Edit, + view: None, }; let events = vec![Event::Published(published), Event::Shared(Ok(links(0, codes))), Event::Welcome { info, peers: vec![] }]; script.lock().unwrap().events.extend(events); @@ -352,6 +364,8 @@ fn online_sharing_is_off_without_a_server() { assert!(matches!(bare.execute("collab.join", &json!({"code": HOST.code()})), Err(EngineError::Disabled(..)))); } +/// A local network share shows the code that carries the host's address, has a view-only code +/// for the same session, switches which shows without hosting again, and listens where told. #[test] fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() { let (mut s, script) = session(); @@ -360,30 +374,48 @@ fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() { let shown = r["code"].as_str().unwrap(); assert_eq!(code::parse(shown), Ok(code::Code::Local(HOST)), "{shown}"); assert_eq!(shown, code::grouped(&HOST.code())); - assert_eq!((&r["link"], &r["share"]["viewCode"]), (&json!(HOST.link()), &Value::Null)); + assert_eq!(r["link"], json!(HOST.link())); + // A view-only code too, for the same session with its own secret; no comment code. + let view = code::grouped(&VIEW.code()); + assert_eq!((&r["share"]["viewCode"], &r["share"]["viewLink"]), (&json!(view), &json!(VIEW.link()))); + assert_eq!((&r["share"]["editLink"], &r["share"]["commentCode"]), (&json!(HOST.link()), &Value::Null)); { let sc = script.lock().unwrap(); let [h] = sc.hosted.as_slice() else { panic!("hosted {}", sc.hosted.len()) }; - assert!(h.short && h.bind == Bind::Lan && h.port.is_none(), "the local network unless told otherwise"); + assert!(h.short && h.view && h.bind == Bind::Lan && h.port.is_none(), "the local network unless told otherwise"); } - // Everyone edits there. - assert!(err(&mut s, "collab.share", json!({"access": "view"})).contains("share it online")); + // Which code shows: the view code, then the edit code again (nothing is hosted twice). + // The link goes with the code: a view-only code's link doesn't edit. + let r = ex(&mut s, "collab.share", json!({"access": "view"})); + assert_eq!((&r["code"], &r["share"]["access"]), (&json!(view), &json!("view"))); + assert_eq!((&r["link"], &r["url"], &r["sessions"][0]["link"]), (&json!(VIEW.link()), &json!(mock_url(&VIEW.link())), &json!(VIEW.link()))); + let r = ex(&mut s, "collab.share", json!({"access": "edit"})); + assert_eq!((&r["code"], &r["link"], &r["url"]), (&json!(code::grouped(&HOST.code())), &json!(HOST.link()), &json!(mock_url(&HOST.link())))); + assert!(err(&mut s, "collab.share", json!({"access": "comment"})).contains("share it online")); + assert_eq!(script.lock().unwrap().hosted.len(), 1); assert_eq!(ex(&mut s, "collab.stop", json!({}))["ended"], true); assert!(script.lock().unwrap().asked.is_empty(), "no server to tell"); - assert!(err(&mut s, "collab.share", json!({"via": "local", "access": "view"})).contains("share it online")); + // Shared for looking only from the start: the edit code isn't the one shown. + let r = ex(&mut s, "collab.share", json!({"via": "local", "access": "view"})); + assert_eq!((&r["code"], &r["share"]["access"]), (&json!(view), &json!("view"))); + assert_eq!((&r["link"], &r["url"]), (&json!(VIEW.link()), &json!(mock_url(&VIEW.link()))), "the edit link and URL aren't handed out"); + assert_eq!(r["sessions"][0]["link"], json!(VIEW.link())); + ex(&mut s, "collab.stop", json!({})); + assert!(err(&mut s, "collab.share", json!({"via": "local", "access": "comment"})).contains("share it online")); // Where to listen is the advanced part. ex(&mut s, "collab.share", json!({"via": "local", "bind": "local", "port": 5000})); let sc = script.lock().unwrap(); - assert!(sc.hosted[1].bind == Bind::Local && sc.hosted[1].port == Some(5000)); + assert!(sc.hosted[2].bind == Bind::Local && sc.hosted[2].port == Some(5000)); drop(sc); ex(&mut s, "collab.stop", json!({})); for bad in [json!({"via": "post"}), json!({"via": "local", "port": 70000}), json!({"via": "local", "bind": "everywhere"})] { assert!(matches!(s.execute("collab.share", &bad), Err(EngineError::BadParams { .. })), "{bad}"); } - // A plain `collab.host` session has an invite link, not a code. + // A plain `collab.host` session has an invite link, not a code, and everyone in it edits. let r = ex(&mut s, "collab.host", json!({})); assert_eq!((&r["code"], &r["share"]), (&Value::Null, &Value::Null)); - assert!(!script.lock().unwrap().hosted[2].short); + let h = &script.lock().unwrap().hosted[3]; + assert!(!h.short && !h.view); } #[test] diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index 1bae1b106..c9863ae74 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -90,6 +90,7 @@ impl LiveLink for Mock { } } +/// A session you are in as `role`, as client `me`, everyone in it editing. pub(crate) fn info(role: Role, me: u32) -> SessionInfo { SessionInfo { role, @@ -103,6 +104,7 @@ pub(crate) fn info(role: Role, me: u32) -> SessionInfo { address: "127.0.0.1:47900".into(), network: false, access: crate::live::Access::Edit, + view: None, } } diff --git a/crates/engine/src/online_tests.rs b/crates/engine/src/online_tests.rs index af13db8e7..98d655f06 100644 --- a/crates/engine/src/online_tests.rs +++ b/crates/engine/src/online_tests.rs @@ -136,6 +136,7 @@ fn edit_link(server: &str, file: &str) -> String { format!("figcraft://join?server={server}&file={file}&token={EDIT_TOKEN}") } +/// A session you are in as `role` under `name`, invited with `link`. fn info(role: Role, name: &str, link: &str) -> SessionInfo { SessionInfo { role, @@ -149,6 +150,7 @@ fn info(role: Role, name: &str, link: &str) -> SessionInfo { address: SERVER.into(), network: true, access: Access::Edit, + view: None, } } diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index 0d9266f74..585fc3e08 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -398,6 +398,20 @@ fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() { } } +#[test] +fn page_move_refuses_negative_or_fractional_indexes() { + let mut s = Session::new(); + ex(&mut s, "page.add", json!({})); + let p3 = id(&ex(&mut s, "page.add", json!({}))); + let before = pages(&mut s); + for bad_index in [json!(-1), json!(1.5)] { + assert!(s.execute("page.move", &json!({"id": p3, "index": bad_index})).is_err(), "page.move index {bad_index} must fail"); + assert_eq!(pages(&mut s), before, "page.move index {bad_index} leaves the order alone"); + } + ex(&mut s, "page.move", json!({"id": p3, "index": 0})); + assert_eq!(pages(&mut s)[0]["id"], json!(p3), "a valid index still moves the page"); +} + #[test] fn assets_panel_lists_files_and_drops_by_the_documented_rules() { let mut s = Session::new(); diff --git a/crates/engine/src/sharing/live.rs b/crates/engine/src/sharing/live.rs index 45d56776c..6e5b0515a 100644 --- a/crates/engine/src/sharing/live.rs +++ b/crates/engine/src/sharing/live.rs @@ -72,7 +72,7 @@ pub(crate) use join::start_join; pub use join::{can_remove_people, specs}; pub use types::{ Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, MAX_NAME, MAX_PEERS, MAX_SELECTION, NO_HOSTING, NO_SESSION, NetReply, NetRequest, ONLINE_OFF, - PEOPLE, PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via, + PEOPLE, PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via, WayIn, join_target, parse_color, }; @@ -147,6 +147,18 @@ impl Live { self.info.as_ref().map(|i| i.role) } + /// The session's invite link and WebSocket URL as `collab.peers` gives them. A file shared + /// on the local network gives the ones that go with the code shown: the session's own link + /// and URL are the edit way in, and a view-only share must not hand them out. Otherwise the + /// session's own (online, the owner's: `share` has the links for others). + pub fn invite(&self) -> (Option<&str>, Option<&str>) { + let info = self.info.as_ref(); + match &self.share { + Some(sh) if sh.via == Via::Local => info.and_then(|i| i.way_in(sh.access)).unzip(), + _ => (info.map(|i| i.link.as_str()), info.map(|i| i.url.as_str())), + } + } + /// Stop following. False when you weren't following anyone. fn unfollow(&mut self) -> bool { self.followed = None; diff --git a/crates/engine/src/sharing/live/host.rs b/crates/engine/src/sharing/live/host.rs index e4fc2d994..916da6949 100644 --- a/crates/engine/src/sharing/live/host.rs +++ b/crates/engine/src/sharing/live/host.rs @@ -36,14 +36,16 @@ pub(super) fn unavailable(cmd: &str) -> EngineError { ) } +/// `collab.host`: host the open file (on this machine unless `p` says where) with an invite link. pub(super) fn host(s: &mut Session, p: &Value) -> Result { - start_host(s, "collab.host", p, Bind::Local, false)?; + start_host(s, "collab.host", p, Bind::Local, false, false)?; Ok(s.live_summary()) } /// Start hosting the open file for command `cmd` (on `bind` unless `p` says where; `short`: with -/// a secret a join code can carry): the new session. -fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) -> Result { +/// a secret a join code can carry; `view`: with a second secret for looking only): the new +/// session. +fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool, view: bool) -> Result { let port = match get(p, "port") { None => None, Some(v) => Some(v.as_u64().and_then(|n| u16::try_from(n).ok()).ok_or_else(|| bad(cmd, "`port` must be a whole number from 0 to 65535"))?), @@ -55,7 +57,7 @@ fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) -> Some(other) => other.parse::().map(Bind::Addr).map_err(|_| bad(cmd, "`bind` must be \"local\", \"lan\" or an IP address"))?, }; let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?; - let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short }; + let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short, view }; let (id, info) = s.live.start(cmd, |l, id| l.host(id, opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}"))))?; s.live.open(Live { id, status: Status::Live, info: Some(info), doc_uid: Some(uid), tab: Some(uid), ..Live::default() }); Ok(id) @@ -82,8 +84,9 @@ fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: boo Ok(id) } -/// Why a local network share has no view-only or comment code. -const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only or comment code"; +/// Why a local network share has no comment code. +const LOCAL_COMMENTS: &str = + "a file shared on the local network has an edit code and a view-only code, not a comment code: share it online for a comment code"; /// Why a share has no comment code. const NO_COMMENT_LINK: &str = @@ -94,6 +97,8 @@ fn access_param(cmd: &str, p: &Value) -> Result> { string(p, "access").map(|a| Access::parse(&a).ok_or_else(|| bad(cmd, "`access` must be \"edit\", \"comment\" or \"view\""))).transpose() } +/// `collab.share`: share the open file with join codes, online or on the local network, or (shared +/// already) switch which code shows. pub(super) fn share(s: &mut Session, p: &Value) -> Result { const CMD: &str = "collab.share"; let via = match string(p, "via").as_deref().map(str::trim) { @@ -112,8 +117,8 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result { "this file is shared the other way already: stop sharing it first (collab.stop), or leave its session (collab.leave)", )); } - if access.is_some_and(|a| a != Access::Edit) && shared == Via::Local { - return Err(bad(CMD, LOCAL_EDITS)); + if access == Some(Access::Comment) && shared == Via::Local { + return Err(bad(CMD, LOCAL_COMMENTS)); } if let Some(req) = share_request(s, Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) { let links = share_answer(s, req)?.ok_or_else(|| EngineError::Other("the server made no links".into()))?; @@ -130,8 +135,8 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result { let access = access.unwrap_or_default(); let mut server = String::new(); let id = match via { - Via::Local if access != Access::Edit => return Err(bad(CMD, LOCAL_EDITS)), - Via::Local => start_host(s, CMD, p, Bind::Lan, true)?, + Via::Local if access == Access::Comment => return Err(bad(CMD, LOCAL_COMMENTS)), + Via::Local => start_host(s, CMD, p, Bind::Lan, true, true)?, Via::Online => { server = string(p, "server").unwrap_or_else(|| s.prefs.publish_server.clone()).trim().to_string(); if server.is_empty() { @@ -141,17 +146,25 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result { } }; if let Some(live) = s.live.sessions.get_mut(&id) { - // On the local network the session's own link is what others join with, and the code says - // what the link says: where the session is and its secret. (Online they arrive with the file.) - let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| { - let code = code::Local::from_link(&i.link).map(|l| l.code()); - Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, ..Shared::default() } + // On the local network the session's own links are what others join with (its view link + // only lets them look), and each code says what its link says: where the session is and + // that link's secret. (Online they arrive with the file.) + let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| Shared { + edit: local_link(&i.link), + view: i.view.as_ref().map(|v| local_link(&v.link)), + comment: None, }); live.share = Some(Share { via, server, access, links }); } Ok(s.live_summary()) } +/// A way into a session this app hosts: its invite link and the join code that stands for it +/// (none when the link can't be put in one: a host without an IPv4 address). +fn local_link(link: &str) -> SharedLink { + SharedLink { id: String::new(), link: link.to_string(), code: code::Local::from_link(link).map(|l| l.code()) } +} + /// What others join the open file's server file with changed: in its session and its record. pub(super) fn shared_now(s: &mut Session, links: Option) { if let Some((server, file)) = s.live().server_file() { diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs index 836267b2a..9e7d07902 100644 --- a/crates/engine/src/sharing/live/join.rs +++ b/crates/engine/src/sharing/live/join.rs @@ -162,6 +162,7 @@ fn spotlight(s: &mut Session, p: &Value) -> Result { Ok(json!({"spotlighting": s.live().spotlighting})) } +/// The live session commands (`collab.*`). pub fn specs() -> Vec { vec![ spec!( @@ -174,7 +175,7 @@ pub fn specs() -> Vec { spec!( "collab.share", "Share with a join code", - "{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing", + "{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (look only: their edits are refused): which code `code` is, server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` the invite link to give with it and `url` its WebSocket URL (local: the ones that go with `code`, so a view-only code's link and URL let people look only; online: your own link to the file, which edits: `share` has the link that goes with each code). Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols), and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: the edit and view-only ways in, each with its own secret (commentCode and commentLink null). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing", false, share ), @@ -230,7 +231,7 @@ pub fn specs() -> Vec { spec!( "collab.peers", "People in this file", - "{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view|comment (view: a server's view link, edits are refused; comment: its comment link, edits other than to comment threads are refused), readOnly (the open file can't be edited now: view only, comment only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, commentCode, editLink, viewLink, commentLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}", + "{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view|comment (view: a view link or code, a server's or a local network share's, edits are refused; comment: a server's comment link, edits other than to comment threads are refused), readOnly (the open file can't be edited now: view only, comment only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, commentCode, editLink, viewLink, commentLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}", false, |s, _| Ok(s.live_summary()) ), diff --git a/crates/engine/src/sharing/live/sync.rs b/crates/engine/src/sharing/live/sync.rs index 79f693731..0dcf5eed2 100644 --- a/crates/engine/src/sharing/live/sync.rs +++ b/crates/engine/src/sharing/live/sync.rs @@ -283,6 +283,7 @@ impl Session { }) .collect(); let mine = synced.and_then(|d| d.text_edit.as_ref()).map(|t| editing(&editing_of(t))); + let (link, url) = l.invite(); let sessions: Vec = self .live .iter() @@ -295,7 +296,7 @@ impl Session { "reason": reason(o), "role": o.role(), "fileName": info.map(|i| i.file_name.as_str()), - "link": info.map(|i| i.link.as_str()), + "link": o.invite().0, "peers": o.peers.len(), }) }) @@ -313,8 +314,8 @@ impl Session { "color": info.map(|i| i.color.as_str()), "fileName": info.map(|i| i.file_name.as_str()), "pages": info.map(|i| &i.pages), - "link": info.map(|i| i.link.as_str()), - "url": info.map(|i| i.url.as_str()), + "link": link, + "url": url, "address": info.map(|i| i.address.as_str()), "network": info.map(|i| i.network), "access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)), diff --git a/crates/engine/src/sharing/live/types.rs b/crates/engine/src/sharing/live/types.rs index 93455c0d8..431934d56 100644 --- a/crates/engine/src/sharing/live/types.rs +++ b/crates/engine/src/sharing/live/types.rs @@ -119,9 +119,33 @@ pub struct SessionInfo { pub address: String, /// Reachable from other machines (the host listens on a network address, not loopback). pub network: bool, - /// What you may do: edit, only view (a server's view link), or view and comment (its comment - /// link). + /// What you may do: edit, only view (a view link or code), or view and comment (a server's + /// comment link). pub access: Access, + /// A session this app hosts with a way in for looking only ([`HostOptions::view`]): its + /// invite link and WebSocket URL, which carry the view secret (`None`: everyone who joins + /// edits, or you joined). + pub view: Option, +} + +impl SessionInfo { + /// The invite link and WebSocket URL that let people into a session this app hosts with + /// `access` (its own link and URL edit). `None`: no way in gives that. + pub fn way_in(&self, access: Access) -> Option<(&str, &str)> { + match access { + Access::Edit => Some((&self.link, &self.url)), + Access::View => self.view.as_ref().map(|v| (v.link.as_str(), v.url.as_str())), + Access::Comment => None, + } + } +} + +/// A way into a session this app hosts: an invite link and the WebSocket URL it connects to, +/// which carry the same secret. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct WayIn { + pub link: String, + pub url: String, } /// A file you published to a server: where, and its first share links (they carry their tokens: @@ -390,6 +414,9 @@ pub struct HostOptions { pub doc: Arc, /// Shared with a join code (`collab.share`): the session's secret is short enough to type. pub short: bool, + /// Also make a second secret whose holders only look: they see every change, and the session + /// refuses theirs ([`SessionInfo::view`]). + pub view: bool, } /// What [`LiveLink::publish`] needs to put the open file on a server. diff --git a/crates/engine/src/transform_behaviour_tests.rs b/crates/engine/src/transform_behaviour_tests.rs index 3e724244e..e22eb0418 100644 --- a/crates/engine/src/transform_behaviour_tests.rs +++ b/crates/engine/src/transform_behaviour_tests.rs @@ -625,6 +625,33 @@ fn smart_guides_snap_to_edges_centres_and_equal_spacing() { assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0)); } +#[test] +fn snapping_to_layers_wins_over_the_pixel_grid() { + // Both on (the defaults): a layer's centre at x = 5.5 is still a target. + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, 11.0, 11.0, None); + let b = rect(&mut s, 200.0, 300.0, 100.0, 100.0, None); + let _ = a; + ex(&mut s, "select.set", json!({"ids": [b]})); + // Moving: B's centre (grabbed) snaps to A's; the free axis stays on whole pixels. + s.pointer(&ev(PointerKind::Down, Point::new(250.0, 350.0), NONE)).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(6.0, 352.4), NONE)).unwrap(); + assert!(s.tool.overlay.guides.iter().any(|g| near(g.x0, 5.5) && near(g.x1, 5.5)), "{:?}", s.tool.overlay.guides); + s.pointer(&ev(PointerKind::Up, Point::new(6.0, 352.4), NONE)).unwrap(); + assert_eq!(bounds(&s, b).origin(), Point::new(-44.5, 302.0), "moved onto A's centre"); + ex(&mut s, "edit.undo", json!({})); + // Out of reach of any layer, a move lands on whole pixels. + drag(&mut s, Point::new(250.0, 350.0), Point::new(260.3, 360.6), NONE); + assert_eq!(bounds(&s, b).origin(), Point::new(210.0, 311.0)); + ex(&mut s, "edit.undo", json!({})); + // Resizing: the left handle snaps to A's centre; the bottom one lands on whole pixels. + drag(&mut s, Point::new(200.0, 350.0), Point::new(6.0, 350.0), NONE); + assert!(near(bounds(&s, b).x0, 5.5), "{:?}", bounds(&s, b)); + ex(&mut s, "edit.undo", json!({})); + drag(&mut s, Point::new(250.0, 400.0), Point::new(250.0, 450.6), NONE); + assert_eq!(size(&s, b), (100.0, 151.0)); +} + #[test] fn corner_cursors_stay_diagonal_whatever_the_proportions() { let mut s = session(); diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 306a8fba9..2717726f8 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -68,6 +68,20 @@ fn types_and_their_starting_values() { assert!(s.execute("variable.create", &json!({"collection": "C", "name": "y", "type": "number", "value": "wide"})).is_err()); } +#[test] +fn move_mode_rejects_negative_and_fractional_indexes() { + let mut s = Session::new(); + theme(&mut s); + run(&mut s, "variable.addMode", json!({"collection": "Theme", "name": "Dim"})); + let order = + |s: &Session| -> Vec { s.doc().unwrap().variables.collection("Theme").unwrap().modes.iter().map(|m| m.name.clone()).collect() }; + let before = order(&s); + for to in [json!(-1), json!(1.9)] { + assert!(s.execute("variable.moveMode", &json!({"collection": "Theme", "mode": "Dim", "to": to})).is_err(), "{to}"); + assert_eq!(order(&s), before, "{to}"); + } +} + #[test] fn modes_new_duplicated_reordered_and_the_default() { // Documented: a new mode starts with the default mode's values and a duplicated one with its diff --git a/crates/render/src/backdrop.rs b/crates/render/src/backdrop.rs index 031046c18..97aec946b 100644 --- a/crates/render/src/backdrop.rs +++ b/crates/render/src/backdrop.rs @@ -160,7 +160,7 @@ pub fn effects(n: &Node, frame: EffectFrame<'_>) -> Option { fx::mask_by(&mut l, cov.as_ref()?, false); l } - EffectKind::Glass(g) => glass::glass(g, frame)?, + EffectKind::Glass(g) => glass::glass(g, frame, glass::RoundedRect::of(n))?, _ => continue, }; match out.as_mut() { diff --git a/crates/render/src/backdrop_tests.rs b/crates/render/src/backdrop_tests.rs index 33d2c0d12..32fb32c4f 100644 --- a/crates/render/src/backdrop_tests.rs +++ b/crates/render/src/backdrop_tests.rs @@ -254,16 +254,65 @@ fn glass_edges_bend_split_and_catch_the_light() { let (rim, middle) = changed(&split, &bent); assert!(rim > 0 && middle == 0, "dispersion: rim {rim}, middle {middle}"); - // The light (from the top left by default) brightens the top-left rim more than the - // bottom-right one, and only near the edge. + // The light (from the top left by default) is a line on the 1-unit bevel: the top-left corner + // catches it all, the bottom-right one (facing away) 0.6 as much, the top side (45° from it) + // 0.574 and the bottom side 0.6 × 0.574, and the top-right corner (side on) none. Further in + // only the faint gradient across the curve shows, and nothing past `depth`. let (d, f, _) = glass_scene(Glass { light_intensity: 1.0, ..plain }); let lit = render_node(&d, f, 1.0).unwrap(); let gain = |x: u32, y: u32| i32::from(px(&lit, x, y)[1]) - i32::from(px(&flat, x, y)[1]); - assert!(gain(82, 42) > gain(197, 117), "{} vs {}", gain(82, 42), gain(197, 117)); - assert!(gain(82, 42) > 40, "{}", gain(82, 42)); + let (top_left, bottom_right, top, bottom, top_right) = (gain(80, 40), gain(199, 119), gain(140, 40), gain(140, 119), gain(199, 40)); + assert!( + top_left > bottom_right && bottom_right > top && top > bottom && bottom > top_right.abs(), + "{top_left} {bottom_right} {top} {bottom} {top_right}" + ); + assert!(top_left > 120 && top_right.abs() <= 2, "{top_left} {top_right}"); + assert!(gain(140, 44).abs() <= 13, "4 px in: the gradient only, {}", gain(140, 44)); assert_eq!(gain(140, 80), 0); } +#[test] +fn splay_turns_the_bend_towards_the_centre() { + // Refraction only, so the bend alone moves the stripes' edges. + let bend = Glass { frost: 0.0, refraction: 1.0, dispersion: 0.0, light_intensity: 0.0, depth: 16.0, ..Glass::default() }; + let (d, f, _) = glass_scene(bend.clone()); + let edge = render_node(&d, f, 1.0).unwrap(); + let (d, f, _) = glass_scene(Glass { splay: 1.0, ..bend }); + let centre = render_node(&d, f, 1.0).unwrap(); + let diff = |xs: std::ops::Range, ys: std::ops::Range| { + let mut most = 0; + for y in ys { + for x in xs.clone() { + most = most.max(px(&edge, x, y).iter().zip(px(¢re, x, y)).map(|(a, b)| a.abs_diff(b)).max().unwrap()); + } + } + most + }; + // Halfway along the left side the edge's normal and the way from the centre (140, 80) agree: + // nearly the same pixels. In the top-left corner they part, and so does what shows there. + assert!(diff(80..96, 78..82) <= 8, "mid-left: {}", diff(80..96, 78..82)); + assert!(diff(80..96, 40..56) > 40, "corner: {}", diff(80..96, 40..56)); +} + +/// Glass with nothing under it (an export of the pane alone): the light on the bevel still shows, +/// the alpha rising with it, and the flat inside and the outside stay clear. +#[test] +fn glass_over_nothing_still_catches_the_light() { + let mut d = Document::new("glass"); + let p = d.first_page().unwrap(); + let pane = d.create(p, NodeKind::Rectangle, "Glass", 40.0, 30.0, 120.0, 80.0).unwrap(); + let n = d.get_mut(pane).unwrap(); + n.fills.clear(); + n.effects = vec![Effect { kind: EffectKind::Glass(Glass { light_intensity: 1.0, ..Glass::default() }), visible: true }]; + let out = render_area(&d, &[pane], Rect::new(0.0, 0.0, 200.0, 140.0), 1.0).unwrap(); + // The top-left corner faces the light (from the top left): white, as opaque as it is bright. + let corner = px(&out, 40, 30); + assert!(corner[3] > 100 && corner.iter().all(|v| v.abs_diff(corner[3]) <= 2), "{corner:?}"); + assert!(out.data().chunks_exact(4).all(|q| q[0] <= q[3] && q[1] <= q[3] && q[2] <= q[3]), "premultiplied"); + assert_eq!(px(&out, 100, 70), [0; 4]); + assert_eq!(px(&out, 10, 10), [0; 4]); +} + #[test] fn glass_and_backdrop_blur_survive_bands_and_partial_renders() { let (d, _, _) = glass_scene(Glass::default()); diff --git a/crates/render/src/glass.rs b/crates/render/src/glass.rs index da998bf0d..e97783d16 100644 --- a/crates/render/src/glass.rs +++ b/crates/render/src/glass.rs @@ -3,26 +3,32 @@ //! Within `depth` of the outline the surface curves like a thick lens: the backdrop there is bent //! towards the inside, most a [`BEVEL`] in from the edge (`refraction` × `depth` far), falling off //! as the 3.5th power of the way out of the curve and gone `depth` further in, with a fifth as -//! much on the bevel itself ([`bend_at`]). Each colour channel bends by a different amount -//! (`dispersion`, a rainbow fringe: red furthest, blue least, each smeared along the bend), and the -//! rim catches the light: brightest where it faces `light_angle`, half as bright on the opposite -//! side, spread wider by `splay`. -//! `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean +//! much on the bevel itself ([`bend_at`]). `splay` turns which way it bends, from towards the +//! shape's core (0; along the edge's normal for a shape too big to keep, see [`MAX_SHAPE_PX`]) to +//! away from the centre of the layer's bounds (1), as the reference tool's: on a ring or an L, +//! whose centre lies outside the shape, an inner edge bends towards the hole. Each colour channel +//! bends by a different amount (`dispersion`, a rainbow fringe: red furthest, blue least, each +//! smeared along the bend). The bevel catches the light as a thin line added to the backdrop +//! ([`rim_light_at`]): fully where the edge faces `light_angle`, none below 0.35 of facing it, 0.6 +//! as much on the opposite side; and a faint gradient runs across the curve, 5% darker where it +//! faces the light and lighter opposite. `frost` blurs the backdrop first. Distances to the outline come from an exact Euclidean //! distance transform of the shape's coverage. //! //! The shape's coverage, distances and normals depend only on the shape and the zoom. Once a //! later render asks for the same shape at the same zoom they are worked out for the whole shape, //! in its own pixel frame, and kept in a cache of at most [`MAX_CACHED_BYTES`]: moving or editing //! what lies under the glass, dragging its sliders, panning and patching the canvas reuse them. -//! Zooming doesn't (each zoom is new). +//! Zooming doesn't (each zoom is new). The bend directions towards the core depend on the depth +//! too: they are kept beside them for the last two depths asked for, so dragging the Depth slider +//! works out only those again. use std::collections::HashMap; use std::hash::{DefaultHasher, Hash, Hasher}; use std::sync::{Arc, Mutex, OnceLock}; -use figcraft_doc::Glass; -use figcraft_geom::Affine; +use figcraft_doc::{Glass, Node, NodeKind}; use figcraft_geom::kurbo::Shape as _; +use figcraft_geom::{Affine, Point}; use tiny_skia::Pixmap; use crate::backdrop::EffectFrame; @@ -31,8 +37,9 @@ use crate::shadow::{Outline, spread_alpha}; /// Squared distance transform of one row or column (Felzenszwalb & Huttenlocher): `f` holds 0 at /// sources and a large value elsewhere; `d` gets each sample's squared distance to the nearest -/// source. `v` and `z` are scratch space (`f.len()` and `f.len() + 1` long). -fn edt_1d(f: &[f64], d: &mut [f64], v: &mut [usize], z: &mut [f64]) { +/// source, and `src`, when given, that source's index. `v` and `z` are scratch space (`f.len()` and +/// `f.len() + 1` long). +fn edt_1d(f: &[f64], d: &mut [f64], v: &mut [usize], z: &mut [f64], mut src: Option<&mut [usize]>) { let n = f.len(); // Every index below stays under these lengths (k <= q < n), checked once here. if n == 0 || d.len() < n || v.len() < n || z.len() < n + 1 { @@ -73,6 +80,9 @@ fn edt_1d(f: &[f64], d: &mut [f64], v: &mut [usize], z: &mut [f64]) { } let dv = qf - v[k] as f64; d[q] = dv * dv + f[v[k]]; + if let Some(o) = src.as_deref_mut().and_then(|s| s.get_mut(q)) { + *o = v[k]; + } } } @@ -90,7 +100,7 @@ pub fn inside_distance(cov: &Pixmap) -> Vec { *fy = *gv; } } - edt_1d(f.get(..h).unwrap_or(&[]), d.get_mut(..h).unwrap_or(&mut []), &mut v, &mut z); + edt_1d(f.get(..h).unwrap_or(&[]), d.get_mut(..h).unwrap_or(&mut []), &mut v, &mut z, None); for y in 0..h { if let (Some(gv), Some(dy)) = (g.get_mut(y * w + x), d.get(y)) { *gv = *dy; @@ -102,7 +112,7 @@ pub fn inside_distance(cov: &Pixmap) -> Vec { if let (Some(src), Some(dst)) = (g.get(row..row + w), f.get_mut(..w)) { dst.copy_from_slice(src); } - edt_1d(f.get(..w).unwrap_or(&[]), d.get_mut(..w).unwrap_or(&mut []), &mut v, &mut z); + edt_1d(f.get(..w).unwrap_or(&[]), d.get_mut(..w).unwrap_or(&mut []), &mut v, &mut z, None); if let (Some(dst), Some(src)) = (g.get_mut(row..row + w), d.get(..w)) { dst.copy_from_slice(src); } @@ -111,6 +121,133 @@ pub fn inside_distance(cov: &Pixmap) -> Vec { g.iter().map(|s| if *s > 0.0 { (s.sqrt() - 0.5).max(0.0) as f32 } else { 0.0 }).collect() } +/// [`nearest_sites`]' answer for a pixel with no site. +const NO_SITE: u32 = u32::MAX; + +/// For each pixel of a `w` × `h` grid, the nearest pixel (centre to centre) where `site` is true, as +/// its index, or [`NO_SITE`] when there is none: the exact Euclidean feature transform (the distance +/// transform above, keeping where each distance comes from). 16 bytes a pixel at most. +fn nearest_sites(site: &[bool], w: usize, h: usize) -> Vec { + const FAR: f64 = 1e20; + let n = w.max(h); + let (mut f, mut d, mut v, mut z, mut src) = (vec![0f64; n], vec![0f64; n], vec![0usize; n], vec![0f64; n + 1], vec![0usize; n]); + // Down each column: the squared distance to its nearest site, and that site's row. + let (mut col_d, mut col_row) = (vec![FAR; w * h], vec![NO_SITE; w * h]); + for x in 0..w { + for y in 0..h { + if let Some(fy) = f.get_mut(y) { + *fy = if site.get(y * w + x).copied().unwrap_or(false) { 0.0 } else { FAR }; + } + } + edt_1d(f.get(..h).unwrap_or(&[]), d.get_mut(..h).unwrap_or(&mut []), &mut v, &mut z, src.get_mut(..h)); + for y in 0..h { + if let (Some(cd), Some(cr), Some(dy), Some(sy)) = (col_d.get_mut(y * w + x), col_row.get_mut(y * w + x), d.get(y), src.get(y)) { + (*cd, *cr) = (*dy, u32::try_from(*sy).unwrap_or(NO_SITE)); + } + } + } + // Along each row: the nearest of those, by the column it is in. + let mut out = vec![NO_SITE; w * h]; + for y in 0..h { + let row = y * w; + if let (Some(dst), Some(from)) = (f.get_mut(..w), col_d.get(row..row + w)) { + dst.copy_from_slice(from); + } + edt_1d(f.get(..w).unwrap_or(&[]), d.get_mut(..w).unwrap_or(&mut []), &mut v, &mut z, src.get_mut(..w)); + for x in 0..w { + if let (Some(dx), Some(sx)) = (d.get(x), src.get(x)) + && *dx < FAR / 2.0 + && let (Some(o), Some(&sy)) = (out.get_mut(row + x), col_row.get(row + *sx)) + && sy != NO_SITE + { + *o = u32::try_from(sy as usize * w + sx).unwrap_or(NO_SITE); + } + } + } + out +} + +/// The largest of `src` (`w` × `h`) within `r` pixels across and down of each pixel (a square +/// window): running maxima along the rows, then the columns. +fn max_within(src: &[f32], w: usize, h: usize, r: usize) -> Vec { + let (mut rows, mut out) = (vec![0f32; w * h], vec![0f32; w * h]); + max_pass(src, &mut rows, w, h, r, true); + max_pass(&rows, &mut out, w, h, r, false); + out +} + +/// One pass of [`max_within`] along rows (`rows`) or columns: a queue holds the window's candidates +/// for its largest, falling from the front, so each value enters and leaves it once. +fn max_pass(src: &[f32], dst: &mut [f32], w: usize, h: usize, r: usize, rows: bool) { + let (lines, len, step, across) = if rows { (h, w, 1, w) } else { (w, h, w, 1) }; + let mut queue = std::collections::VecDeque::new(); + for l in 0..lines { + let base = l * across; + let at = |i: usize| src.get(base + i * step).copied().unwrap_or(0.0); + queue.clear(); + let mut next = 0; + for i in 0..len { + while next < len && next <= i + r { + let v = at(next); + while queue.back().is_some_and(|&b| at(b) <= v) { + queue.pop_back(); + } + queue.push_back(next); + next += 1; + } + while queue.front().is_some_and(|&f| f + r < i) { + queue.pop_front(); + } + if let (Some(o), Some(&f)) = (dst.get_mut(base + i * step), queue.front()) { + *o = at(f); + } + } + } +} + +/// How far around (pixels) a pixel counts as the middle of a thin part: no deeper than the pixels +/// within this many across and down, give or take [`RIDGE_SLACK`]. +const RIDGE_PX: usize = 2; + +/// How much shallower than the deepest pixel within [`RIDGE_PX`] a pixel can be and still count as +/// a thin part's middle: half a pixel per pixel, so a gently tapering stroke keeps its middle while +/// the diagonal running into a corner (depth rising by 0.7 px per pixel, more in the window) and +/// the band along an edge (1 px per pixel) do not. +const RIDGE_SLACK: f32 = 0.5 * RIDGE_PX as f32; + +/// Unit inward directions from each pixel of a shape (`dist`, its inside distance field, `w` × `h`) +/// to the nearest point of its core, which the glass bends the backdrop towards: where the shape +/// is `reach` pixels deep or more, and the middle of each part thinner than that (a capsule, a +/// letter's stroke: pixels about as deep as any within [`RIDGE_PX`]). Bending towards the core +/// rather than along the edge's normal rounds a sharp corner's bend (no seam along its diagonal) +/// and makes a thin part bend across itself, each part towards its own middle. Box-blurred once +/// over `r` pixels (the normals twice: these are smooth already, but turn round at a thin part's +/// middle); zero on the core and outside. +fn bend_directions(dist: &[f32], w: usize, h: usize, reach: f32, r: usize) -> Vec<(f32, f32)> { + // The maxima and the core are dropped before the directions are made, keeping the peak down. + let near = { + let around = max_within(dist, w, h, RIDGE_PX); + let core: Vec = dist.iter().zip(&around).map(|(d, m)| *d > 0.0 && (*d >= reach - 1e-3 || *d >= m - RIDGE_SLACK - 1e-3)).collect(); + nearest_sites(&core, w, h) + }; + let (mut gx, mut gy) = (vec![0f32; w * h], vec![0f32; w * h]); + for (i, &s) in near.iter().enumerate() { + if s != NO_SITE + && let Some(d) = dist.get(i) + && *d > 0.0 + && let (Some(a), Some(b)) = (gx.get_mut(i), gy.get_mut(i)) + { + let s = s as usize; + let (dx, dy) = ((s % w) as f32 - (i % w) as f32, (s / w) as f32 - (i / w) as f32); + let len = (dx * dx + dy * dy).sqrt(); + if len > 1e-6 { + (*a, *b) = (dx / len, dy / len); + } + } + } + smoothed_units(gx, gy, w, h, r, 1) +} + /// Running-sum box blur of a `w` × `h` field along rows (`stride` 1) or columns (`stride` `w`), /// radius `r`, zero outside. fn box_pass(src: &[f32], dst: &mut [f32], w: usize, h: usize, r: usize, rows: bool) { @@ -149,9 +286,15 @@ fn smooth_normals(dist: &[f32], w: usize, h: usize, r: usize) -> Vec<(f32, f32)> } } } + smoothed_units(gx, gy, w, h, r, 2) +} + +/// The vectors (`gx`, `gy`) of a `w` × `h` field box-blurred `times` over `r` pixels and made unit +/// length; zero where they cancel out (no direction). +fn smoothed_units(mut gx: Vec, mut gy: Vec, w: usize, h: usize, r: usize, times: usize) -> Vec<(f32, f32)> { let mut tmp = vec![0f32; w * h]; for g in [&mut gx, &mut gy] { - for _ in 0..2 { + for _ in 0..times { box_pass(g, &mut tmp, w, h, r, true); box_pass(&tmp, g, w, h, r, false); } @@ -181,11 +324,45 @@ const RIM_BEND: f32 = 0.2; /// by a triangle (the two end steps weigh nothing and are skipped). const DISPERSION_STEPS: usize = 8; +/// The least an edge faces the light (the cosine between its outward normal and the light) and +/// still catches some. +const HL_LO: f32 = 0.35; + +/// How much of the light the side opposite it catches. +const HL_SECONDARY: f32 = 0.6; + +/// The gradient across the curve at its strongest, a share of the light's intensity. +const BROAD: f32 = 0.05; + /// `x` from `a` to `b` as 0 to 1, clamped (a span under 1e-5 counts as 1e-5). fn lin(a: f32, b: f32, x: f32) -> f32 { ((x - a) / (b - a).max(1e-5)).clamp(0.0, 1.0) } +/// `x` from `a` to `b` as a smooth 0-to-1 step (Hermite), clamped. +fn smoothstep(a: f32, b: f32, x: f32) -> f32 { + let t = ((x - a) / (b - a).max(1e-5)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// How much of the rim light falls `x` pixels in from the edge, for a `bevel` pixels deep: all +/// of it within a fifth of the bevel, none a bevel in. +pub(crate) fn rim_light_at(x: f32, bevel: f32) -> f32 { + smoothstep(-bevel - 0.001, -0.2 * bevel, -x) +} + +/// How much light an edge catches, `facing` being the cosine between its outward normal and the +/// light: from [`HL_LO`] to fully facing it, and [`HL_SECONDARY`] as much facing away. +fn catches(facing: f32) -> f32 { + smoothstep(HL_LO, 1.0, facing) + HL_SECONDARY * smoothstep(HL_LO, 1.0, -facing) +} + +/// How strongly the gradient across the curve shows `x` pixels in from the edge (`depth` and +/// `bevel` in pixels): fully on the bevel, falling as the square of the way in to none at `depth`. +pub(crate) fn sheen_at(x: f32, depth: f32, bevel: f32) -> f32 { + lin(-depth, -bevel + 1e-4, -x).powi(2) +} + /// How far the backdrop moves at `x` pixels in from the edge, for a glass `depth` and [`BEVEL`] /// `bevel` deep (pixels) and `refraction` (0–1): a thick lens's profile, the most a bevel in from /// the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further @@ -204,15 +381,27 @@ pub(crate) fn bend_at(x: f32, depth: f32, bevel: f32, refraction: f32) -> f32 { core + (rim - core) * u } +/// Below this split (pixels: the move's length × `k`) the dispersion window takes one sample: its +/// steps lie within 1/256 px of the moved point, so one sample is within one level (of 255) of the +/// window even across the hardest edge, from black to white or opaque to clear in a pixel. +const ONE_SAMPLE_SPLIT_PX: f32 = 1.0 / 256.0; + /// The backdrop seen at pixel coordinates (`x`, `y`) moved by (`dx`, `dy`), its channels split by /// `k` (dispersion, 0–1): red moves up to `k` further, blue up to `k` less, each smeared along the -/// move by a triangular window of [`DISPERSION_STEPS`]. One sample when `k` is too small to show. +/// move by a triangular window of [`DISPERSION_STEPS`]. One sample when the split is too small to +/// show: `k` under 0.001, or the channels under [`ONE_SAMPLE_SPLIT_PX`] apart (where the curve +/// flattens out and the move is short). fn dispersed(frosted: &Pixmap, x: f32, y: f32, dx: f32, dy: f32, k: f32) -> [f32; 4] { - let at = |s: f32| sample(frosted, x + dx * s, y + dy * s); - if k < 0.001 { - let s = at(1.0); + if k < 0.001 || (dx * dx + dy * dy).sqrt() * k < ONE_SAMPLE_SPLIT_PX { + let s = sample(frosted, x + dx, y + dy); return [s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]]; } + dispersion_window(frosted, x, y, dx, dy, k) +} + +/// [`dispersed`]'s triangular window, every step sampled. +fn dispersion_window(frosted: &Pixmap, x: f32, y: f32, dx: f32, dy: f32, k: f32) -> [f32; 4] { + let at = |s: f32| sample(frosted, x + dx * s, y + dy * s); let last = (DISPERSION_STEPS - 1) as f32; let (mut acc, mut sum) = ([0.0f32; 4], 0.0f32); for i in 1..DISPERSION_STEPS - 1 { @@ -229,6 +418,17 @@ fn dispersed(frosted: &Pixmap, x: f32, y: f32, dx: f32, dy: f32, k: f32) -> [f32 [(acc[0] / sum).min(a), (acc[1] / sum).min(a), (acc[2] / sum).min(a), a] } +/// `base`, a premultiplied backdrop pixel (channels 0–255), with `add` (levels, below 0 darkens) +/// added to its colour, at coverage `c`. Where that is more than the backdrop's alpha holds (all of +/// it over a transparent backdrop, an export with nothing under the glass), the alpha rises with +/// it, so the light shows there too. +fn lit(base: [f32; 4], add: f32, c: f32) -> [u8; 4] { + let [r, g, b, a] = base; + let (r, g, b) = (r + add, g + add, b + add); + let a = a.max(0.0).max(r).max(g).max(b).min(255.0); + [r, g, b, a].map(|v| (v.clamp(0.0, a) * c).round().clamp(0.0, 255.0) as u8) +} + /// Bilinear sample of premultiplied `p` at pixel coordinates `(x, y)` (centres at +0.5), each /// channel 0..=255; transparent outside. fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] { @@ -256,20 +456,10 @@ fn sample(p: &Pixmap, x: f32, y: f32) -> [f32; 4] { }) } -/// The furthest in (pixels) the rim light is worth half a level or more: where -/// `255 × intensity × e^(−d / band)` falls to 0.5, at most [`MAX_LIGHT_PX`] (0 for no light). -fn light_reach(band: f64, intensity: f64) -> f64 { - let k = 255.0 * intensity / 0.5; - if k > 1.0 && band.is_finite() { (band * k.ln()).min(MAX_LIGHT_PX) } else { 0.0 } -} - -/// The light reaches no further in than this many pixels (a wide light on a deep pane at a high -/// zoom would otherwise need a huge margin of coverage around every cut). -const MAX_LIGHT_PX: f64 = 512.0; - -/// A shape's coverage (alpha), distance to its edge and smoothed inward normals over a window of -/// pixels whose top-left pixel is (`x0`, `y0`). -struct Fields { +/// A shape's coverage (alpha), distance to its edge and smoothed inward normals (which the light +/// reads) over a window of pixels whose top-left pixel is (`x0`, `y0`): what the shape and the zoom +/// alone decide, so it is kept whatever the depth ([`whole_shape`]). +struct EdgeFields { x0: i64, y0: i64, w: usize, @@ -277,36 +467,101 @@ struct Fields { cov: Vec, dist: Vec, normals: Vec<(f32, f32)>, + /// How far the normals and the bend directions are smoothed (pixels). + r: usize, + /// The bend directions for the last [`KEPT_BENDS`] core depths asked for, by their bits, the + /// most recent last ([`EdgeFields::bends()`]). + bends: Mutex>, } -/// Bytes a field pixel holds: coverage, distance and a normal. -const FIELD_PX_BYTES: u64 = 1 + 4 + 8; +/// How many depths' bend directions a kept shape holds: two glass layers of the same shape with +/// different depths don't take turns working theirs out. +const KEPT_BENDS: usize = 2; -impl Fields { +/// Inward bend directions ([`bend_directions`]) over an [`EdgeFields`] window. +type Bends = Arc>; + +/// The fields `glass` reads: a shape's [`EdgeFields`] and its bend directions for one depth. +struct Fields { + edge: Arc, + /// `None` for a shape too big to work out a core ([`fits_whole`]): it bends along its normals. + bends: Option, +} + +/// Bytes a field pixel holds: coverage, distance, a normal and [`KEPT_BENDS`] bend directions. +const FIELD_PX_BYTES: u64 = 1 + 4 + 8 + 8 * KEPT_BENDS as u64; + +/// A pixel of [`Fields`]: coverage (0–1), distance to the edge, inward normal, inward bend direction. +type FieldPx = (f32, f32, (f32, f32), (f32, f32)); + +impl EdgeFields { /// The fields of `cov`, the shape's coverage on the window at (`x0`, `y0`), normals smoothed /// over `r` pixels. - fn new(cov: &Pixmap, x0: i64, y0: i64, r: usize) -> Fields { + fn new(cov: &Pixmap, x0: i64, y0: i64, r: usize) -> EdgeFields { FIELDS_COMPUTED.with(|n| n.set(n.get() + 1)); let dist = inside_distance(cov); let (w, h) = (cov.width() as usize, cov.height() as usize); let normals = smooth_normals(&dist, w, h, r); let cov = cov.data().chunks_exact(4).map(|p| p.get(3).copied().unwrap_or(0)).collect(); - Fields { x0, y0, w, h, cov, dist, normals } + EdgeFields { x0, y0, w, h, cov, dist, normals, r, bends: Mutex::new(Vec::new()) } } - /// Coverage (0–1), distance and inward normal at pixel (`x`, `y`); `None` outside the window. - fn at(&self, x: i64, y: i64) -> Option<(f32, f32, (f32, f32))> { - let (fx, fy) = (x.checked_sub(self.x0)?, y.checked_sub(self.y0)?); - if fx < 0 || fy < 0 || fx >= self.w as i64 || fy >= self.h as i64 { + /// The bend directions towards the core `reach` pixels in: kept from a recent render at the + /// same depth, else worked out and kept in place of the least recent (bands of one render + /// asking at once wait for the first). + fn bends(&self, reach: f32) -> Bends { + let bits = reach.to_bits(); + let mut kept = self.bends.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(i) = kept.iter().position(|(b, _)| *b == bits) { + let hit = kept.remove(i); + let bends = hit.1.clone(); + kept.push(hit); + return bends; + } + BENDS_COMPUTED.with(|n| n.set(n.get() + 1)); + let bends = Arc::new(bend_directions(&self.dist, self.w, self.h, reach, self.r)); + if kept.len() >= KEPT_BENDS { + kept.remove(0); + } + kept.push((bits, bends.clone())); + bends + } +} + +impl Fields { + /// The fields of `cov`, the shape's coverage on the window at (`x0`, `y0`), normals and bend + /// directions smoothed over `r` pixels, bending towards the core `reach` pixels in (along the + /// normals without a `reach`). + fn new(cov: &Pixmap, x0: i64, y0: i64, r: usize, reach: Option) -> Fields { + Fields::of(Arc::new(EdgeFields::new(cov, x0, y0, r)), reach) + } + + /// `edge`'s fields bending towards the core `reach` pixels in (along the normals without one). + fn of(edge: Arc, reach: Option) -> Fields { + let bends = reach.map(|reach| edge.bends(reach)); + Fields { edge, bends } + } + + /// The fields at pixel (`x`, `y`); `None` outside the window. + fn at(&self, x: i64, y: i64) -> Option { + let e = &*self.edge; + let (fx, fy) = (x.checked_sub(e.x0)?, y.checked_sub(e.y0)?); + if fx < 0 || fy < 0 || fx >= e.w as i64 || fy >= e.h as i64 { return None; } - let i = fy as usize * self.w + fx as usize; - Some((f32::from(*self.cov.get(i)?) / 255.0, *self.dist.get(i)?, *self.normals.get(i)?)) + let i = fy as usize * e.w + fx as usize; + let normal = *e.normals.get(i)?; + let bend = match &self.bends { + Some(bends) => *bends.get(i)?, + None => normal, + }; + Some((f32::from(*e.cov.get(i)?) / 255.0, *e.dist.get(i)?, normal, bend)) } } thread_local! { static FIELDS_COMPUTED: std::cell::Cell = const { std::cell::Cell::new(0) }; + static BENDS_COMPUTED: std::cell::Cell = const { std::cell::Cell::new(0) }; } /// How many times this thread has worked out a glass shape's fields: what a cache miss costs. For @@ -315,9 +570,16 @@ pub fn glass_fields_computed() -> u64 { FIELDS_COMPUTED.with(std::cell::Cell::get) } -/// What a glass shape's fields depend on, with the transform's translation reduced to its fraction -/// of a pixel: the same shape at the same zoom has the same key wherever it is and however a render -/// cuts it. +/// How many times this thread has worked out a glass shape's bend directions towards its core: a +/// new shape, or a kept one at a new depth. For tests and benchmarks. +pub fn glass_bends_computed() -> u64 { + BENDS_COMPUTED.with(std::cell::Cell::get) +} + +/// What a glass shape's [`EdgeFields`] depend on, with the transform's translation reduced to its +/// fraction of a pixel: the same shape at the same zoom has the same key wherever it is and however +/// a render cuts it. Not the depth (beyond the margin and smoothing): the bend directions that +/// depend on it are worked out from the kept fields ([`EdgeFields::bends()`]). #[derive(Clone, Debug, PartialEq, Eq, Hash)] struct ShapeKey { path: u64, @@ -327,8 +589,11 @@ struct ShapeKey { r: usize, } -/// The cached fields of a shape whose window holds at most this many pixels; bigger shapes (a -/// pane far larger than the view) are worked out for the part a render shows each time. +/// The cached fields of a shape whose window holds at most this many pixels: 4M on the desktop, 1M +/// on the web. Bigger shapes (a pane far larger than the view) are worked out for the part a render +/// shows each time, with no core: they bend along their edge normals. So a large glass text or +/// vector can bend differently once zoomed into, in a 2× export than on the canvas, and on the web +/// than on the desktop. A rounded rectangle works out its core in closed form at any size. #[cfg(not(target_arch = "wasm32"))] const MAX_SHAPE_PX: u64 = 4 << 20; #[cfg(target_arch = "wasm32")] @@ -340,7 +605,7 @@ const MAX_CACHED_BYTES: u64 = 128 << 20; const MAX_CACHED_BYTES: u64 = 32 << 20; /// A shape's fields once worked out; parallel bands wanting the same shape wait for one of them. -type Slot = Arc>>>; +type Slot = Arc>>>; /// The shapes' fields by key, at most `budget` bytes; the least recently used go first. A shape is /// kept from the second render that asks for it: one asked for once (each step of a zoom, a pane @@ -404,25 +669,48 @@ fn shape_cache() -> &'static Mutex { C.get_or_init(|| Mutex::new(ShapeCache::new(MAX_CACHED_BYTES))) } +/// Whether `shape` (through `xf`) fits [`MAX_SHAPE_PX`] whole with a margin `m` round it: the +/// window a kept shape's fields cover ([`whole_shape`]). Depends on the shape and the zoom alone, so +/// every band and patch of a render answers the same. +fn fits_whole(shape: &Outline, xf: Affine, m: u32) -> bool { + let Some(own) = own_frame(xf) else { return false }; + let bb = (own.xf * shape.0.clone()).bounding_box(); + let margin = f64::from(m.div_ceil(32).saturating_mul(32)); + let (w, h) = (bb.x1.ceil() - bb.x0.floor() + 2.0 + 2.0 * margin, bb.y1.ceil() - bb.y0.floor() + 2.0 + 2.0 * margin); + let px = w * h; + px.is_finite() && px <= MAX_SHAPE_PX as f64 +} + /// The fields `glass` reads for a `w` × `h` layer whose `shape` maps through `xf` (local → layer -/// pixels), exact for distances up to the margin `m`, normals smoothed over `r` pixels, and the -/// shift from layer pixels to the fields' pixels. +/// pixels), exact for distances up to the margin `m`, normals and bend directions smoothed over +/// `r` pixels, bending towards the core `reach` pixels in, and the shift from layer pixels to the +/// fields' pixels. /// /// A shape asked for again in a later render, whose window fits [`MAX_SHAPE_PX`], is worked out /// whole, in its own frame (the translation's fraction of a pixel), and kept: a band, a canvas /// patch or the next frame of a drag reads the same fields. Otherwise it is worked out for the part /// shown plus the margin. -fn shape_fields(shape: &Outline, xf: Affine, w: u32, h: u32, m: u32, r: usize) -> Option<(Arc, (i64, i64))> { - if let Some(whole) = whole_shape(shape, xf, m, r) { - return Some(whole); +fn shape_fields(shape: &Outline, xf: Affine, w: u32, h: u32, m: u32, r: usize, reach: Option) -> Option<(Fields, (i64, i64))> { + if let Some((edge, shift)) = whole_shape(shape, xf, m, r) { + return Some((Fields::of(edge, reach), shift)); } let cov = spread_alpha(std::slice::from_ref(shape), 0.0, Affine::translate((f64::from(m), f64::from(m))) * xf, w + 2 * m, h + 2 * m)?; - Some((Arc::new(Fields::new(&cov, -i64::from(m), -i64::from(m), r)), (0, 0))) + Some((Fields::new(&cov, -i64::from(m), -i64::from(m), r, reach), (0, 0))) } -/// [`shape_fields`] for a whole shape, from the cache; `None` when it isn't kept (first asked for -/// in this render, too big, or its transform too far out). -fn whole_shape(shape: &Outline, xf: Affine, m: u32, r: usize) -> Option<(Arc, (i64, i64))> { +/// A layer transform split so the same shape at the same zoom lands on the same pixels of its own +/// frame wherever a render puts it or cuts it (see [`own_frame`]). +struct OwnFrame { + /// The whole pixels of the translation: the shift from layer pixels to the shape's own frame. + shift: (i64, i64), + /// The translation's fraction of a pixel, in steps of 1/65536. + frac: (i64, i64), + /// Local coordinates → the shape's own frame: the linear part and that fraction. + xf: Affine, +} + +/// `xf` (local → layer pixels) split into its [`OwnFrame`]; `None` for a translation too far out. +fn own_frame(xf: Affine) -> Option { let [a, b, c, d, e, f] = xf.as_coeffs(); // The translation in steps of 1/65536 px (far finer than the rasteriser's 1/64, coarse enough // that rounding errors in where a render starts land on the same step), its whole pixels (the @@ -435,7 +723,14 @@ fn whole_shape(shape: &Outline, xf: Affine, m: u32, r: usize) -> Option<(Arc Option<(Arc, (i64, i64))> { + let [a, b, c, d, _, _] = xf.as_coeffs(); + let OwnFrame { shift, frac, xf: own } = own_frame(xf)?; let bb = (own * shape.0.clone()).bounding_box(); let (x0, y0, x1, y1) = (bb.x0.floor(), bb.y0.floor(), bb.x1.ceil(), bb.y1.ceil()); if ![x0, y0, x1, y1].iter().all(|v| v.is_finite() && v.abs() < 1e9) { @@ -465,73 +760,230 @@ fn whole_shape(shape: &Outline, xf: Affine, m: u32, r: usize) -> Option<(Arc Option { + let rect_like = matches!( + n.kind, + NodeKind::Rectangle + | NodeKind::Frame + | NodeKind::Component { .. } + | NodeKind::ComponentSet { .. } + | NodeKind::Instance { .. } + | NodeKind::Section { .. } + ); + let sized = n.width.is_finite() && n.height.is_finite() && n.width > 0.0 && n.height > 0.0; + (rect_like && sized && n.corners.smoothing == 0.0).then(|| { + // Clamped as `rounded_rect` clamps them: each radius to half the shorter side. + let most = n.width.min(n.height) / 2.0; + let radii = n.corners.radii.map(|r| if r.is_finite() { r.clamp(0.0, most) } else { 0.0 }); + RoundedRect { width: n.width, height: n.height, radii } + }) + } +} + +/// Signed distance from `q` (relative to the centre) to a rectangle reaching `half` each way with +/// corner radius `r` (negative inside), and its outward gradient: the unit normal along the sides, +/// pointing away from the corner's centre round the corners. +fn rounded_rect_sd(q: (f64, f64), half: (f64, f64), r: f64) -> (f64, (f64, f64)) { + let (sx, sy) = (if q.0 < 0.0 { -1.0 } else { 1.0 }, if q.1 < 0.0 { -1.0 } else { 1.0 }); + let k = (q.0.abs() - half.0 + r, q.1.abs() - half.1 + r); + if k.0 > 0.0 && k.1 > 0.0 { + let len = (k.0 * k.0 + k.1 * k.1).sqrt(); + (len - r, (sx * k.0 / len, sy * k.1 / len)) + } else if k.0 > k.1 { + (k.0 - r, (sx, 0.0)) + } else { + (k.1 - r, (0.0, sy)) + } +} + +/// The radius of the corner of `radii` (top-left, top-right, bottom-right, bottom-left) whose +/// quarter `q` (relative to the centre, y down) lies in. +fn corner_of(radii: [f64; 4], q: (f64, f64)) -> f64 { + let i = match (q.0 < 0.0, q.1 < 0.0) { + (true, true) => 0, + (false, true) => 1, + (false, false) => 2, + (true, false) => 3, + }; + radii.get(i).copied().unwrap_or(0.0) +} + +/// A rounded rectangle's glass fields worked out pixel by pixel, in closed form: no coverage to +/// rasterise, no distance transform, nothing to cache. The same fields as [`Fields`] (the bend +/// directions round the corners towards the rectangle shrunk by the core's depth, the middle line +/// of one thinner than twice that), exact rather than from pixels, and the same wherever a render +/// cuts it: each pixel is placed in the layer's own frame. +struct AnalyticRect { + /// Layer pixels → the shape's own frame. + shift: (i64, i64), + /// The shape's own frame → local coordinates. + inv: Affine, + /// Local → device directions (the transform's linear part), and its scale. + linear: [f64; 4], + scale: f64, + half: (f64, f64), + radii: [f64; 4], + /// The corners the light's normals round to: each radius at least the bevel (a sharp corner's + /// bevel catches the light along its diagonal), at most the half side. + bevels: [f64; 4], + /// The corners the bend rounds to: each radius at least the core's depth, at most the half + /// side ([`bend_directions`]). + rounds: [f64; 4], +} + +impl AnalyticRect { + /// `rect`'s fields for a layer whose `xf` maps local coordinates to its pixels, with a `bevel` + /// and bending towards the core `reach` pixels in; `None` when `xf` isn't a rotation, flip and + /// even scale (skewed or stretched rectangles go through the distance transform) or its + /// translation is too far out. + fn new(rect: RoundedRect, xf: Affine, bevel: f64, reach: f64) -> Option { + let own = own_frame(xf)?; + let [a, b, c, d, _, _] = own.xf.as_coeffs(); + let (sq_x, sq_y) = (a * a + b * b, c * c + d * d); + let even = (sq_x - sq_y).abs() <= 1e-9 * sq_x && (a * c + b * d).abs() <= 1e-9 * sq_x; + let scale = sq_x.sqrt(); + if !(even && scale.is_finite() && scale > 1e-9) { + return None; + } + let half = (rect.width / 2.0, rect.height / 2.0); + let at_least = |least: f64| rect.radii.map(|r| r.max(least / scale).min(half.0.min(half.1))); + let (bevels, rounds) = (at_least(bevel), at_least(reach)); + Some(AnalyticRect { shift: own.shift, inv: own.xf.inverse(), linear: [a, b, c, d], scale, half, radii: rect.radii, bevels, rounds }) + } + + /// The fields at layer pixel (`x`, `y`): coverage (box-filtered from the signed distance), + /// distance in, inward normal and inward bend direction. + fn at(&self, x: i64, y: i64) -> FieldPx { + let p = self.inv * Point::new((x - self.shift.0) as f64 + 0.5, (y - self.shift.1) as f64 + 0.5); + let q = (p.x - self.half.0, p.y - self.half.1); + let (sd, _) = rounded_rect_sd(q, self.half, corner_of(self.radii, q)); + let (_, normal) = rounded_rect_sd(q, self.half, corner_of(self.bevels, q)); + let (_, round) = rounded_rect_sd(q, self.half, corner_of(self.rounds, q)); + let [a, b, c, d] = self.linear; + let inward = |n: (f64, f64)| (-((a * n.0 + c * n.1) / self.scale) as f32, -((b * n.0 + d * n.1) / self.scale) as f32); + let sd = sd * self.scale; + ((0.5 - sd).clamp(0.0, 1.0) as f32, (-sd).max(0.0) as f32, inward(normal), inward(round)) + } +} + +/// Where a glass reads its fields: worked out in closed form for a rounded rectangle, from its +/// coverage otherwise. +enum Geometry { + Rect(AnalyticRect), + Fields(Fields, (i64, i64)), +} + +impl Geometry { + /// The fields at layer pixel (`x`, `y`); `None` where there are none. + fn at(&self, x: i64, y: i64) -> Option { + match self { + Geometry::Rect(r) => Some(r.at(x, y)), + Geometry::Fields(f, shift) => f.at(x - shift.0, y - shift.1), + } + } +} + /// The glass of a `w` × `h` layer whose `shape` maps through `xf` (local → layer pixels), from -/// `back`, its backdrop padded by `pad` pixels each way, at `scale` device pixels per unit. -pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option { +/// `back`, its backdrop padded by `pad` pixels each way, at `scale` device pixels per unit. `rect`: +/// the rounded rectangle the shape is, when it is one, so its fields are worked out in closed form. +pub fn glass(g: &Glass, frame: EffectFrame<'_>, rect: Option) -> Option { let EffectFrame { shape, back, pad, size: (w, h), xf, scale } = frame; let g = g.sanitized(); let depth = (g.depth * scale).clamp(1.0, 1024.0); let bevel = (BEVEL * scale).min(1024.0); - let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0); - let reach = light_reach(band, g.light_intensity); let r = (depth * 0.1).clamp(1.0, 8.0) as usize; - // Coverage with a margin, so wherever the layer is cut every distance the pixels read is - // exact: the bend's (within `depth` + `bevel`), the light's (within `reach`) and those the - // normals are smoothed from (2r + 1 further). - let m = (depth + bevel).max(reach).ceil() as u32 + 4 * r as u32 + 4; - let (fields, shift) = shape_fields(shape, xf, w, h, m, r)?; + // The core the backdrop bends towards lies `reach` in from the edge ([`bend_directions`]). + let reach = (depth + bevel) as f32; + // Coverage with a margin, so wherever the layer is cut every field the pixels read is the + // whole render's: the distances the bend and the gradient read (within `depth` + `bevel`; the + // light lies on the bevel), the core they bend towards (its nearest pixel up to √2 × `reach` + // further, round a sharp corner, so 1.5 × `reach`; its depths exact within `reach` and + // [`RIDGE_PX`] of it), and those the normals and bends are smoothed from (2r + 1 further). + let plain = (depth + bevel).ceil() as u32 + 4 * r as u32 + 4; + let cored = plain + (1.5 * reach).ceil() as u32 + RIDGE_PX as u32; + // A core takes a wider margin and more bytes a pixel, so only a shape whose whole window fits + // [`MAX_SHAPE_PX`] works one out: a bigger one bends along its edge normals, so no render + // allocates a core's fields over more pixels than a kept shape holds. + let core = fits_whole(shape, xf, cored).then_some(reach); + let m = if core.is_some() { cored } else { plain }; + let geometry = match rect.and_then(|rect| AnalyticRect::new(rect, xf, bevel, f64::from(reach))) { + Some(analytic) => Geometry::Rect(analytic), + None => { + let (fields, shift) = shape_fields(shape, xf, w, h, m, r, core)?; + Geometry::Fields(fields, shift) + } + }; let mut frosted = back.clone(); fx::blur(&mut frosted, g.frost / 2.0 * scale); let ang = g.light_angle.to_radians(); let light = (ang.sin() as f32, -ang.cos() as f32); + // The centre of the layer's bounds in its own frame (see `own_frame`), which `splay` bends away + // from: the direction to it is the same wherever a render puts or cuts the layer. + let local_centre = shape.0.bounding_box().center(); + let ((sx, sy), centre) = match own_frame(xf) { + Some(own) => (own.shift, own.xf * local_centre), + None => ((0, 0), xf * local_centre), + }; let (refraction, dispersion, bevel) = (g.refraction as f32, (g.dispersion * Glass::DISPERSION_SPREAD) as f32, bevel as f32); - let power = (6.0 - 5.0 * g.splay) as f32; - let (band, reach) = (band as f32, reach as f32); - // The falloff, lowered by its value at `reach` and scaled back to 1 at the edge, so the light - // fades out to 0 at `reach` instead of stopping there. - let floor = (-reach / band).exp(); - let fall = 1.0 - floor; - let intensity = g.light_intensity as f32; - let depth = depth as f32; + let (splay, intensity, depth) = (g.splay as f32, g.light_intensity as f32, depth as f32); let mut out = Pixmap::new(w, h)?; let dst = out.data_mut(); for y in 0..h as usize { for x in 0..w as usize { - let Some((c, d, inward)) = fields.at(x as i64 - shift.0, y as i64 - shift.1) else { continue }; + let Some((c, d, inward, toward)) = geometry.at(x as i64, y as i64) else { continue }; if c <= 0.0 { continue; } - let bend = bend_at(d, depth, bevel, refraction); let (bx, by) = (x as f32 + pad as f32 + 0.5, y as f32 + pad as f32 + 0.5); - let base = if bend == 0.0 { - // Nothing bends (past `depth` + `bevel`, or no refraction): the channels' samples - // are at the same point, so one sample. - let s = sample(&frosted, bx, by); - [s[0].min(s[3]), s[1].min(s[3]), s[2].min(s[3]), s[3]] + let (base, add) = if d >= depth + bevel { + // Past the curve nothing bends and no light falls (the rim light and the gradient + // are 0 there): the channels' samples are at the same point, so one sample. + (dispersed(&frosted, bx, by, 0.0, 0.0, 0.0), 0.0) } else { - dispersed(&frosted, bx, by, inward.0 * bend, inward.1 * bend, dispersion) - }; - // The rim catches the light where it faces it, and half as much opposite. It fades out - // by `reach` and is none past it, wherever the layer is cut. - let hl = if d < reach && fall > 0.0 { + // The curve's outward direction: away from its core, turned by `splay` towards the + // way away from the centre (not renormalised: where the two part, the bend is less). + let away = if splay > 0.0 { + let px = ((x as i64 - sx) as f64 + 0.5 - centre.x) as f32; + let py = ((y as i64 - sy) as f64 + 0.5 - centre.y) as f32; + let len = (px * px + py * py).sqrt(); + if len > 1e-6 { (px / len, py / len) } else { (0.0, 0.0) } + } else { + (0.0, 0.0) + }; + let out_dir = (-toward.0 * (1.0 - splay) + away.0 * splay, -toward.1 * (1.0 - splay) + away.1 * splay); + // No refraction: nothing bends, so one sample (`dispersed` with no move). + let bend = bend_at(d, depth, bevel, refraction); + let k = if bend == 0.0 { 0.0 } else { dispersion }; + let base = dispersed(&frosted, bx, by, -out_dir.0 * bend, -out_dir.1 * bend, k); + // The bevel catches the light as a line added to the backdrop, by how much the edge + // faces it; across the curve a faint gradient, darker on the side facing it. let facing = -(inward.0 * light.0 + inward.1 * light.1); - let spec = facing.max(0.0).powf(power) + 0.5 * (-facing).max(0.0).powf(power); - (intensity * spec * ((-d / band).exp() - floor) / fall).clamp(0.0, 1.0) - } else { - 0.0 + let rim = rim_light_at(d, bevel) * catches(facing); + let sheen = -(out_dir.0 * light.0 + out_dir.1 * light.1) * sheen_at(d, depth, bevel) * BROAD; + (base, 255.0 * intensity * (rim + sheen)) }; let i = (y * w as usize + x) * 4; if let Some(p) = dst.get_mut(i..i + 4) { - for (o, v) in p.iter_mut().zip(base) { - *o = ((v * (1.0 - hl) + 255.0 * hl) * c).round().clamp(0.0, 255.0) as u8; - } + p.copy_from_slice(&lit(base, add, c)); } } } @@ -613,6 +1065,158 @@ mod tests { assert_eq!(spread[2], 0.0); } + /// Where the channels split under [`ONE_SAMPLE_SPLIT_PX`], one sample stands in for the window + /// within one level, even over the hardest backdrops: 1 px columns of black and white, and of + /// white and nothing. + #[test] + fn a_split_too_narrow_to_show_takes_one_sample() { + for clear in [false, true] { + let mut p = Pixmap::new(64, 4).unwrap(); + for (i, px) in p.data_mut().chunks_exact_mut(4).enumerate() { + let on = i % 2 == 1; + let v = if on { 255 } else { 0 }; + px.copy_from_slice(&[v, v, v, if clear { v } else { 255 }]); + } + let mut worst = 0.0f32; + for step in 0..400 { + let x = 20.0 + step as f32 / 100.0; + // Along the columns' rows and across them, and out past the backdrop's last row. + for (dx, dy) in [(2.0f32, 0.0f32), (-2.0, 0.0), (1.2, 1.6), (0.0, 2.0), (40.0, 0.0)] { + let k = ONE_SAMPLE_SPLIT_PX * 0.999 / (dx * dx + dy * dy).sqrt(); + let one = dispersed(&p, x, 2.0, dx, dy, k); + assert_eq!(one, dispersed(&p, x, 2.0, dx, dy, 0.0), "one sample at {x}"); + let window = dispersion_window(&p, x, 2.0, dx, dy, k); + worst = one.iter().zip(window).fold(worst, |m, (a, b)| m.max((a - b).abs())); + } + } + assert!(worst <= 1.0, "clear {clear}: {worst}"); + } + // A wider split still takes the window. + let mut p = Pixmap::new(64, 4).unwrap(); + p.data_mut().chunks_exact_mut(4).step_by(2).for_each(|px| px.copy_from_slice(&[255; 4])); + let k = ONE_SAMPLE_SPLIT_PX * 2.0 / 2.0; + assert_eq!(dispersed(&p, 20.3, 2.0, 2.0, 0.0, k), dispersion_window(&p, 20.3, 2.0, 2.0, 0.0, k)); + assert_ne!(dispersed(&p, 20.3, 2.0, 2.0, 0.0, k), dispersed(&p, 20.3, 2.0, 2.0, 0.0, 0.0)); + } + + /// A `w` × `h` coverage with the rectangles `rects` (x, y, width, height) filled. + fn coverage(w: u32, h: u32, rects: &[(f32, f32, f32, f32)]) -> Pixmap { + let mut p = Pixmap::new(w, h).unwrap(); + let paint = tiny_skia::Paint { shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::WHITE), ..Default::default() }; + for (x, y, rw, rh) in rects { + p.fill_rect(tiny_skia::Rect::from_xywh(*x, *y, *rw, *rh).unwrap(), &paint, tiny_skia::Transform::identity(), None); + } + p + } + + fn bend_dir(f: &Fields, x: i64, y: i64) -> (f32, f32) { + f.at(x, y).unwrap().3 + } + + fn close(a: (f32, f32), b: (f32, f32), tol: f32) -> bool { + (a.0 - b.0).abs() <= tol && (a.1 - b.1).abs() <= tol + } + + #[test] + fn a_sharp_corner_bends_round_towards_the_core() { + // A 120 × 80 rectangle at (20, 20), bending towards its core 20 px in. + let f = Fields::new(&coverage(160, 120, &[(20.0, 20.0, 120.0, 80.0)]), 0, 0, 1, Some(20.0)); + let s = std::f32::consts::FRAC_1_SQRT_2; + // Halfway down the left side: straight in. In the corner: diagonally, towards the core's own + // corner 20 px in. + assert!(close(bend_dir(&f, 21, 60), (1.0, 0.0), 0.02), "{:?}", bend_dir(&f, 21, 60)); + assert!(close(bend_dir(&f, 22, 22), (s, s), 0.05), "{:?}", bend_dir(&f, 22, 22)); + // Mirror images across the corner's diagonal, with no seam along it: neighbours agree. + let (a, b) = (bend_dir(&f, 24, 29), bend_dir(&f, 29, 24)); + assert!(close(a, (b.1, b.0), 1e-4), "{a:?} vs {b:?}"); + let (on, off) = (bend_dir(&f, 26, 26), bend_dir(&f, 27, 26)); + assert!(close(on, off, 0.1), "{on:?} vs {off:?}"); + } + + #[test] + fn a_shape_too_big_to_keep_bends_along_its_normals() { + // A small shape fits whole with a core's margin; one wider than the kept-shape limit + // doesn't, at this zoom or with this margin, wherever it sits. + let shape = |w: f64, h: f64| -> Outline { (figcraft_geom::kurbo::Rect::new(0.0, 0.0, w, h).to_path(0.1), tiny_skia::FillRule::Winding) }; + assert!(fits_whole(&shape(200.0, 120.0), Affine::translate((10.5, 3.0)), 64)); + assert!(!fits_whole(&shape(4000.0, 3000.0), Affine::translate((10.5, 3.0)), 64)); + assert!(!fits_whole(&shape(200.0, 120.0), Affine::scale(40.0), 64)); + // Without a core the fields hold no bend directions, and bend along the normals. + let f = Fields::new(&coverage(160, 120, &[(20.0, 20.0, 120.0, 80.0)]), 0, 0, 1, None); + assert!(f.bends.is_none()); + for (x, y) in [(21, 60), (24, 24), (80, 21)] { + let (_, _, normal, bend) = f.at(x, y).unwrap(); + assert_eq!(normal, bend, "({x}, {y})"); + } + } + + #[test] + fn a_thin_part_bends_across_itself() { + // A 200 × 12 bar, thinner than twice the reach: its core is its middle line, and each side + // bends towards it. + let f = Fields::new(&coverage(240, 40, &[(20.0, 14.0, 200.0, 12.0)]), 0, 0, 1, Some(20.0)); + assert!(close(bend_dir(&f, 120, 14), (0.0, 1.0), 0.02), "top: {:?}", bend_dir(&f, 120, 14)); + assert!(close(bend_dir(&f, 120, 25), (0.0, -1.0), 0.02), "bottom: {:?}", bend_dir(&f, 120, 25)); + assert!(f.bends.as_ref().unwrap().iter().all(|(x, y)| x.is_finite() && y.is_finite())); + } + + #[test] + fn neighbouring_strokes_bend_towards_their_own_middles() { + // Two 8 px strokes 2 px apart, like letters: the facing edges bend away from each other, + // each into its own stroke. + let f = Fields::new(&coverage(60, 60, &[(10.0, 10.0, 8.0, 40.0), (20.0, 10.0, 8.0, 40.0)]), 0, 0, 1, Some(20.0)); + assert!(bend_dir(&f, 17, 30).0 < -0.9, "{:?}", bend_dir(&f, 17, 30)); + assert!(bend_dir(&f, 20, 30).0 > 0.9, "{:?}", bend_dir(&f, 20, 30)); + } + + #[test] + fn rounded_rectangle_distances_and_normals() { + let half = (60.0, 40.0); + // Halfway along the left side, 3 in: 3 deep, the normal straight out. + let (sd, n) = rounded_rect_sd((-57.0, 0.0), half, 10.0); + assert!((sd + 3.0).abs() < 1e-9 && n == (-1.0, 0.0), "{sd} {n:?}"); + // Round a 10-unit corner: the distance to its arc, the normal away from its centre. + let (sd, n) = rounded_rect_sd((-57.0, -37.0), half, 10.0); + let (cx, cy) = (-50.0, -30.0); + let len = ((-57.0f64 - cx).powi(2) + (-37.0f64 - cy).powi(2)).sqrt(); + assert!((sd - (len - 10.0)).abs() < 1e-9, "{sd}"); + assert!((n.0 + 7.0 / len).abs() < 1e-9 && (n.1 + 7.0 / len).abs() < 1e-9, "{n:?}"); + // Outside, past the right side. + assert!((rounded_rect_sd((65.0, 0.0), half, 10.0).0 - 5.0).abs() < 1e-9); + assert_eq!([(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)].map(|q| corner_of([1.0, 2.0, 3.0, 4.0], q)), [1.0, 2.0, 3.0, 4.0]); + } + + #[test] + fn a_rounded_rectangle_in_closed_form_matches_its_distance_transform() { + // A 120 × 80 rectangle at (20, 20), bending towards its core 21 px in, both ways. + let fields = Fields::new(&coverage(160, 120, &[(20.0, 20.0, 120.0, 80.0)]), 0, 0, 2, Some(21.0)); + let rect = RoundedRect { width: 120.0, height: 80.0, radii: [0.0; 4] }; + let closed = AnalyticRect::new(rect, Affine::translate((20.0, 20.0)), 1.0, 21.0).unwrap(); + for (x, y) in [(21, 60), (60, 21), (138, 60), (80, 98), (24, 24), (30, 26), (135, 95), (80, 60)] { + let (a, b) = (fields.at(x, y).unwrap(), closed.at(x, y)); + assert!((a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3, "({x}, {y}) coverage and depth: {a:?} vs {b:?}"); + assert!(a.1 >= 21.0 || close(a.3, b.3, 0.08), "({x}, {y}) bend: {a:?} vs {b:?}"); + } + // Rotated a quarter turn, the fields turn with it; skewed or stretched, no closed form. + let turned = AnalyticRect::new(rect, Affine::translate((100.0, 20.0)) * Affine::rotate(std::f64::consts::FRAC_PI_2), 1.0, 21.0).unwrap(); + assert!(close(turned.at(98, 60).2, (-1.0, 0.0), 1e-6), "{:?}", turned.at(98, 60)); + assert!(AnalyticRect::new(rect, Affine::new([1.0, 0.0, 0.3, 1.0, 0.0, 0.0]), 1.0, 21.0).is_none()); + assert!(AnalyticRect::new(rect, Affine::scale_non_uniform(2.0, 1.0), 1.0, 21.0).is_none()); + } + + #[test] + fn nearest_sites_and_running_maxima() { + // Sites at (1, 1) and (8, 3) on a 10 × 5 grid. + let mut site = vec![false; 50]; + site[11] = true; + site[38] = true; + let near = nearest_sites(&site, 10, 5); + assert_eq!((near[0], near[4], near[49], near[37]), (11, 11, 38, 38)); + assert!(nearest_sites(&[false; 6], 3, 2).iter().all(|s| *s == NO_SITE)); + let m = max_within(&[1.0, 5.0, 2.0, 0.0, 0.0, 3.0], 6, 1, 1); + assert_eq!(m, [5.0, 5.0, 5.0, 2.0, 3.0, 3.0]); + } + /// At full dispersion red's furthest step stays within `Glass::max_bend` of the pixel, all /// along the bend, so the backdrop read around a pane covers it: one white pixel further out /// than that is never seen. @@ -673,14 +1277,32 @@ mod tests { assert!(c.held <= 100 && !c.map.contains_key(&key(4))); } + /// The rim light and the gradient across the curve at depth 20 and a 1-unit bevel, x units in + /// from the edge: the reference profile's values. #[test] - fn light_reach_is_where_the_light_falls_under_half_a_level() { - let r = light_reach(10.0, 1.0); - assert!((255.0 * (-r / 10.0).exp() - 0.5).abs() < 1e-9, "{r}"); - // No light, too faint to show, or a band that isn't a number: it reaches nowhere. - for (band, intensity) in [(10.0, 0.0), (10.0, 0.5 / 255.0), (f64::NAN, 1.0), (f64::INFINITY, 1.0)] { - assert_eq!(light_reach(band, intensity), 0.0, "{band} {intensity}"); + fn the_light_lies_on_the_bevel_and_the_gradient_across_the_curve() { + for (x, rim, sheen) in [ + (0.10, 1.0, 1.0), + (0.25, 0.9888, 1.0), + (0.50, 0.6843, 1.0), + (0.75, 0.2330, 1.0), + (1.00, 0.0, 1.0), + (2.0, 0.0, 0.8975), + (4.0, 0.0, 0.7091), + (6.0, 0.0, 0.5429), + (8.0, 0.0, 0.3989), + (16.0, 0.0, 0.0443), + (20.0, 0.0, 0.0), + (30.0, 0.0, 0.0), + ] { + assert!((rim_light_at(x, 1.0) - rim).abs() < 1e-3, "rim at {x}: {}", rim_light_at(x, 1.0)); + assert!((sheen_at(x, 20.0, 1.0) - sheen).abs() < 1e-3, "gradient at {x}: {}", sheen_at(x, 20.0, 1.0)); } - assert_eq!(light_reach(1e6, 1.0), MAX_LIGHT_PX); + // Facing the light fully, at 45° (cos 0.707) and side on; the opposite side 0.6 as much. + assert!((catches(1.0) - 1.0).abs() < 1e-6); + assert!((catches(std::f32::consts::FRAC_1_SQRT_2) - 0.574).abs() < 1e-3, "{}", catches(std::f32::consts::FRAC_1_SQRT_2)); + assert_eq!(catches(0.0), 0.0); + assert!((catches(-1.0) - 0.6).abs() < 1e-6); + assert!((catches(-std::f32::consts::FRAC_1_SQRT_2) - 0.6 * 0.574).abs() < 1e-3); } } diff --git a/crates/render/src/glass_cut_tests.rs b/crates/render/src/glass_cut_tests.rs index 931243776..bac1e2af6 100644 --- a/crates/render/src/glass_cut_tests.rs +++ b/crates/render/src/glass_cut_tests.rs @@ -59,25 +59,31 @@ fn cut_diffs(d: &Document, p: NodeId) -> Vec<(u32, (u8, usize))> { .collect() } -/// A pane deeper than the light may reach, over black and lit from the left: across its middle row -/// each pixel is the light alone. It fades out smoothly where it stops, with no ring. +/// A deep pane over black, lit from the left, at 2× (its 1-unit bevel 2 px wide): across its middle +/// row the left rim catches the light, the right rim 0.6 as much, and in between only the faint +/// gradient across the curve shows (black can't darken, so the lit half stays black), changing +/// smoothly, with no ring. #[test] -fn the_light_fades_out_where_it_stops() { +fn the_light_is_a_line_on_the_bevel() { let mut d = Document::new("glass"); let p = d.first_page().unwrap(); let back = d.create(p, NodeKind::Rectangle, "Back", 0.0, 0.0, 700.0, 700.0).unwrap(); d.get_mut(back).unwrap().fills = vec![Paint::solid(Color::BLACK)]; let pane = d.create(p, NodeKind::Rectangle, "Glass", 50.0, 50.0, 600.0, 600.0).unwrap(); - let g = Glass { depth: 300.0, splay: 1.0, light_angle: -90.0, light_intensity: 1.0, frost: 0.0, refraction: 0.0, ..Glass::default() }; + let g = Glass { depth: 300.0, light_angle: -90.0, light_intensity: 1.0, frost: 0.0, refraction: 0.0, ..Glass::default() }; let n = d.get_mut(pane).unwrap(); n.fills.clear(); n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }]; let full = render_view_single(&d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 1400, height: 1400 }, &Options::default()).unwrap(); let row = &full.data()[700 * 1400 * 4..][..1400 * 4]; - let red: Vec = row.chunks(4).map(|px| px[0]).skip(101).take(598).collect(); - let step = red.windows(2).map(|w| w[0].abs_diff(w[1])).max().unwrap(); - assert!(step <= 2, "the light jumps {step} levels between neighbouring pixels"); - assert!(red[0] > 200 && red[597] < red[0], "lit at the edge, fading in: {} … {}", red[0], red[597]); + let red: Vec = row.chunks(4).map(|px| px[0]).collect(); + // The pane covers pixels 100..1300 of the row. + let (left, right) = (red[100], red[1299]); + assert!(left > 230, "the left rim catches the light, less the gradient: {left}"); + assert!((140..=175).contains(&right), "the right rim 0.6 as much, plus the gradient: {right}"); + assert!(red[106] <= 14 && red[1293] <= 14, "3 units in, the gradient only: {} {}", red[106], red[1293]); + let step = red[104..1296].windows(2).map(|w| w[0].abs_diff(w[1])).max().unwrap(); + assert!(step <= 2, "the gradient jumps {step} levels between neighbouring pixels"); } /// Digests of glass renders over a spread of settings, scales and corners: compare two builds' @@ -115,30 +121,39 @@ fn glass_digests() { #[test] fn a_cut_through_the_pane_matches_the_whole_render() { - // The last look is deep enough that the light is held to `MAX_LIGHT_PX`. + // The last two looks are deep, so the curve reaches far in from every cut: 60 units (at 2× a + // core 122 px in, which takes the widest margin a core gets) and 300 (too big for a core). let looks = [ wide_light(), Glass::default(), Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() }, + // Splay with the bend and dispersion on: the dispersed samples follow the turned bend. + Glass { splay: 1.0, frost: 0.0, ..Glass::default() }, + Glass { depth: 60.0, ..Glass::default() }, Glass { depth: 300.0, ..wide_light() }, ]; // Rounded corners without the pane's fill: a fill's curved edge rasters 1–2 levels apart in a // cut, and that is the fill, not the glass. - for radius in [0.0, 40.0] { + // Plain corners work out in closed form; smoothed ones (sharp ones too: any smoothing counts) + // from the coverage, the deepest look too big for a core and bending along its normals. + for (radius, smoothing) in [(0.0, 0.0), (40.0, 0.0), (0.0, 0.6), (40.0, 0.6)] { for g in &looks { let (mut d, p, pane) = pane_over_stripes(g.clone(), radius); + d.get_mut(pane).unwrap().corners.smoothing = smoothing; if radius > 0.0 { d.get_mut(pane).unwrap().fills.clear(); } for (cut, diff) in cut_diffs(&d, p) { - assert_eq!(diff, (0, 0), "cut at {cut} px, radius {radius}, with {g:?}"); + assert_eq!(diff, (0, 0), "cut at {cut} px, radius {radius}, smoothing {smoothing}, with {g:?}"); } } } } /// A `w` × `h` pane of glass `g` at (`x`, `y`) over stripes, alone on its page: (document, page, -/// pane). Each test gives its own size, so tests running at once don't share cached shapes. +/// pane). Each test gives its own size, so tests running at once don't share cached shapes. Its +/// corners are smoothed, so its fields come from its coverage and are kept (a plain rounded +/// rectangle's are worked out in closed form, with nothing to keep). fn pane(g: Glass, x: f64, y: f64, w: f64, h: f64) -> (Document, NodeId, NodeId) { let mut d = Document::new("glass"); let p = d.first_page().unwrap(); @@ -150,6 +165,7 @@ fn pane(g: Glass, x: f64, y: f64, w: f64, h: f64) -> (Document, NodeId, NodeId) let n = d.get_mut(pane).unwrap(); n.transform = Affine::translate((x, y)); n.corners.radii = [12.0; 4]; + n.corners.smoothing = 0.6; n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }]; (d, p, pane) } @@ -165,6 +181,14 @@ fn computed(f: impl FnOnce()) -> u64 { crate::glass::glass_fields_computed() - before } +/// How many glass shapes, and how many shapes' bend directions towards their core, `f` works out +/// on this thread. +fn computed_and_bent(f: impl FnOnce()) -> (u64, u64) { + let before = (crate::glass::glass_fields_computed(), crate::glass::glass_bends_computed()); + f(); + (crate::glass::glass_fields_computed() - before.0, crate::glass::glass_bends_computed() - before.1) +} + #[test] fn kept_fields_render_the_same_pixels_as_fresh_ones() { let (d, p, _) = pane(Glass::default(), 40.0, 30.0, 211.0, 137.0); @@ -203,6 +227,13 @@ fn what_the_shape_depends_on_works_it_out_again_and_nothing_else_does() { assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "part of a pixel"); d.get_mut(pane).unwrap().width = 230.0; assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "shape"); + let _ = render(&d, p, (0.0, 0.0), 1.0); + // A depth a unit more keeps the same margin: the kept shape is reused, and only the bend + // towards the core, which moves with the depth, is worked out again, once (a Depth slider + // dragged works out nothing else). + set(&mut d, Glass { depth: 21.0, ..Glass::default() }); + assert_eq!(computed_and_bent(|| drop(render(&d, p, (0.0, 0.0), 1.0))), (0, 1), "depth, within the same margin"); + assert_eq!(computed_and_bent(|| drop(render(&d, p, (0.0, 0.0), 1.0))), (0, 0), "the same depth again"); set(&mut d, Glass { depth: 70.0, ..Glass::default() }); assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "depth"); } diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 11f0bb4ac..23fd4bc72 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -127,6 +127,7 @@ name = "English" "Another server, or one that needs its key. The usual server is set in Preferences." = "Another server, or one that needs its key. The usual server is set in Preferences." "Any status" = "Any status" "Anyone on your network with this code can edit the file, while this app stays open." = "Anyone on your network with this code can edit the file, while this app stays open." +"Anyone on your network with this code can look at the file, not change it, while this app stays open." = "Anyone on your network with this code can look at the file, not change it, while this app stays open." "Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." "Anyone with this code can edit the file." = "Anyone with this code can edit the file." "Anyone with this code can look at the file, not change it." = "Anyone with this code can look at the file, not change it." diff --git a/crates/ui-egui/src/bool_menu_icon_tests.rs b/crates/ui-egui/src/bool_menu_icon_tests.rs new file mode 100644 index 000000000..8d3ac84c9 --- /dev/null +++ b/crates/ui-egui/src/bool_menu_icon_tests.rs @@ -0,0 +1,140 @@ +//! The Design panel's boolean operation menu leads each entry with its operation's icon (#2003), +//! and each entry still runs its command: drawn headless. + +use egui::accesskit::Node; +use egui::{Event, PointerButton, Pos2, Rect, Shape, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::icons::Icon; +use crate::layers_drag_tests::Ui; + +const OPS: [(&str, Icon, &str); 4] = [ + ("Union", Icon::Union, "bool.union"), + ("Subtract", Icon::Subtract, "bool.subtract"), + ("Intersect", Icon::Intersect, "bool.intersect"), + ("Exclude", Icon::Exclude, "bool.exclude"), +]; + +fn id(v: serde_json::Value) -> NodeId { + serde_json::from_value(v["id"].clone()).unwrap() +} + +/// Two overlapping layers selected, headless. +fn two_layers() -> Ui { + let mut s = Session::new(); + let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + let b = id(s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap()); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + u +} + +fn nodes(u: &mut Ui) -> Vec { + u.ctx.enable_accesskit(); + u.frame(vec![]); + u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect() +} + +fn bounds(n: &Node) -> Rect { + let b = n.bounds().unwrap(); + Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32)) +} + +fn click(u: &mut Ui, at: Pos2) { + for pressed in [true, false] { + u.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, + ]); + } +} + +/// The menu open: the nodes on screen. +fn open_menu(u: &mut Ui) -> Vec { + let all = nodes(u); + let button = all.iter().find(|n| n.label() == Some("Boolean groups")).expect("the Design panel's Boolean groups button"); + click(u, bounds(button).center()); + for _ in 0..10 { + u.frame(vec![]); + } + nodes(u) +} + +/// How many strokes and fills sit in the left 40 points of `row` (where the icon is). +fn marks_in_lead(shapes: &[egui::epaint::ClippedShape], row: Rect) -> usize { + fn walk(s: &Shape, area: Rect, n: &mut usize) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| walk(s, area, n)), + Shape::Path(_) | Shape::Mesh(_) | Shape::Rect(_) | Shape::LineSegment { .. } if area.contains_rect(s.visual_bounding_rect()) => *n += 1, + _ => {} + } + } + let area = Rect::from_min_max(row.min, pos2(row.min.x + 40.0, row.max.y)); + let mut n = 0; + shapes.iter().for_each(|c| walk(&c.shape, area, &mut n)); + n +} + +#[test] +fn the_boolean_menu_leads_every_operation_with_its_icon() { + let mut u = two_layers(); + let all = open_menu(&mut u); + let out = u.frame_output(vec![]); + for (name, _, _) in OPS { + let row = all.iter().find(|n| n.label() == Some(name)).unwrap_or_else(|| panic!("{name} in the menu")); + let n = marks_in_lead(&out.shapes, bounds(row)); + assert!(n >= 2, "{name}'s icon painted at the start of its row ({n} marks)"); + } +} + +#[test] +fn every_boolean_menu_entry_still_runs_its_command() { + for (name, icon, _) in OPS { + let mut u = two_layers(); + let all = open_menu(&mut u); + let row = all.iter().find(|n| n.label() == Some(name)).unwrap_or_else(|| panic!("{name} in the menu")); + click(&mut u, bounds(row).center()); + for _ in 0..3 { + u.frame(vec![]); + } + let sel = u.app.session.selection(); + assert_eq!(sel.len(), 1, "{name} made one boolean group"); + assert_eq!(crate::layers::node_icon(u.app.session.doc().unwrap(), sel[0]), icon, "{name}'s group shows {icon:?}"); + } +} + +/// The x every label (checked or not, with an icon or without, Flatten) starts at. +fn label_xs(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, f32)> { + fn walk(s: &Shape, out: &mut Vec<(String, f32)>) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + Shape::Text(t) => out.push((t.galley.text().to_owned(), t.pos.x)), + _ => {} + } + } + let mut out = Vec::new(); + shapes.iter().for_each(|c| walk(&c.shape, &mut out)); + out +} + +#[test] +fn every_boolean_menu_label_starts_at_the_same_x() { + for checked in [false, true] { + let mut u = two_layers(); + if checked { + u.app.run("bool.union", json!({})); + u.frame(vec![]); + } + open_menu(&mut u); + let out = u.frame_output(vec![]); + let xs = label_xs(&out.shapes); + let at: Vec = ["Union", "Subtract", "Intersect", "Exclude", "Flatten"] + .iter() + .map(|n| xs.iter().rev().find(|(t, _)| t == n).unwrap_or_else(|| panic!("{n} label (checked: {checked})")).1) + .collect(); + assert!(at.iter().all(|x| (x - at[0]).abs() < 0.01), "labels line up (checked: {checked}): {at:?}"); + } +} diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5ccfe5d46..684294c44 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -195,21 +195,37 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers /// labels, handles, comments, versions/branch/dev/live UI and the cursor. pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let rect = ui.max_rect(); - app.canvas.rect = rect; + let old_rect = std::mem::replace(&mut app.canvas.rect, rect); let ctx = ui.ctx().clone(); let ppp = ctx.pixels_per_point(); let resp = ui.interact(rect, ui.scope_id().with("canvas"), Sense::click_and_drag()); let key_box = app.ui.key_box; crate::a11y::canvas(&mut app.ui.nav, &resp, || key_box.map_or_else(|| describe(&app.session), describe_box)); - if let Ok(ds) = app.session.ds_mut() - && ((ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5) - { - // Keep the centre; the first sizing after opening re-fits. + if let Ok(ds) = app.session.ds_mut() { + let resized = (ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5; + // The first sizing after opening re-fits. let first = ds.view.vw == 1200.0 && ds.view.vh == 800.0; - ds.view.vw = f64::from(rect.width()); - ds.view.vh = f64::from(rect.height()); - if first { - ds.zoom_to_fit(); + // While a button is held that went down on the canvas (a click that selects shows the + // right panel, minimized, and a click that deselects hides it; the left panel and rail + // can appear too) the page point under a still pointer stays where it is: the gesture's + // own pointer events would otherwise map to another page point and the move handling + // would take that for a drag. `to_page` is `origin + (p - rect.min) / z` with + // `origin = c - vw / 2 / z`, so the centre moves by half the size change plus the + // rect's own move, over the zoom. + let held = resp.is_pointer_button_down_on() && !first && old_rect.is_finite(); + let z = ds.view.zoom.max(1e-9); + let (dw, dh) = if resized { (f64::from(rect.width()) - ds.view.vw, f64::from(rect.height()) - ds.view.vh) } else { (0.0, 0.0) }; + if resized { + // Otherwise keep the centre. + ds.view.vw = f64::from(rect.width()); + ds.view.vh = f64::from(rect.height()); + if first { + ds.zoom_to_fit(); + } + } + if held { + ds.view.cx += (dw / 2.0 + f64::from(rect.min.x - old_rect.min.x)) / z; + ds.view.cy += (dh / 2.0 + f64::from(rect.min.y - old_rect.min.y)) / z; } } let own_cursor = input(app, ui, &resp); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f8480127f..e93e557f0 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -378,6 +378,30 @@ mod tests { } } + /// The four boolean operation icons each paint shapes and no two paint the same ones, so the + /// buttons can be told apart (#2003). + #[test] + fn the_boolean_operation_icons_paint_and_differ() { + let ctx = egui::Context::default(); + let ops = [Icon::Union, Icon::Subtract, Icon::Intersect, Icon::Exclude]; + let painted: Vec = ops + .iter() + .map(|&icon| { + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(16.0)), icon, Color32::WHITE); + }); + out.textures_delta.clear(); + assert!(!out.shapes.is_empty(), "{icon:?} paints at least one shape"); + format!("{:?}", out.shapes.iter().map(|c| &c.shape).collect::>()) + }) + .collect(); + for i in 0..ops.len() { + for j in (i + 1)..ops.len() { + assert_ne!(painted[i], painted[j], "{:?} and {:?} paint different shapes", ops[i], ops[j]); + } + } + } + #[test] fn concave_polygons_triangulate() { let arrow = [pos2(4.0, 2.0), pos2(4.0, 13.6), pos2(6.9, 10.9), pos2(8.8, 14.8), pos2(10.6, 14.0), pos2(8.7, 10.2), pos2(12.6, 10.2)]; diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs index 1d3925507..5a05e44cc 100644 --- a/crates/ui-egui/src/icons/draw.rs +++ b/crates/ui-egui/src/icons/draw.rs @@ -88,6 +88,11 @@ impl P<'_> { // A hairline around the mesh gives it the same soft edge as the convex fills. self.painter.add(Shape::closed_line(v, Stroke::new(self.px, self.color))); } + /// A polygon filled in the icon colour at reduced alpha, so the strokes over it stay readable. + fn fill_soft(&self, pts: &[(f32, f32)]) { + let soft = P { painter: self.painter, o: self.o, s: self.s, px: self.px, stroke: self.stroke, color: self.color.gamma_multiply(0.4) }; + soft.fill(pts); + } /// Triangles `tris` over `verts` (x, y, alpha): the colour fades between the vertices. fn shade(&self, verts: &[(f32, f32, f32)], tris: &[u32]) { let mut mesh = Mesh::default(); @@ -427,24 +432,28 @@ fn draw(p: &P, icon: Icon) { p.fill_rect(11.5, 2.0, 14.5, 5.0, 0.0); } Boolean | Union => { - p.rect(2.5, 2.5, 9.5, 9.5, 0.0); - p.rect(6.5, 6.5, 13.5, 13.5, 0.0); + // Two squares offset diagonally: the outline of both together, filled. + p.fill_soft(&[(2.5, 2.5), (9.5, 2.5), (9.5, 6.5), (13.5, 6.5), (13.5, 13.5), (6.5, 13.5), (6.5, 9.5), (2.5, 9.5)]); + p.poly(&[(2.5, 2.5), (9.5, 2.5), (9.5, 6.5), (13.5, 6.5), (13.5, 13.5), (6.5, 13.5), (6.5, 9.5), (2.5, 9.5)], true); } Subtract => { - p.fill_rect(2.0, 2.0, 10.0, 10.0, 0.0); + // The first square less the overlap, filled; both squares outlined. + p.fill_soft(&[(2.5, 2.5), (9.5, 2.5), (9.5, 6.5), (6.5, 6.5), (6.5, 9.5), (2.5, 9.5)]); + p.rect(2.5, 2.5, 9.5, 9.5, 0.0); p.rect(6.5, 6.5, 13.5, 13.5, 0.0); } Intersect => { + // Only the overlap filled; both squares outlined. + p.fill_soft(&[(6.5, 6.5), (9.5, 6.5), (9.5, 9.5), (6.5, 9.5)]); p.rect(2.5, 2.5, 9.5, 9.5, 0.0); p.rect(6.5, 6.5, 13.5, 13.5, 0.0); - p.fill_rect(6.0, 6.0, 10.0, 10.0, 0.0); } Exclude => { - p.fill_rect(2.0, 2.0, 10.0, 6.0, 0.0); - p.fill_rect(2.0, 6.0, 6.0, 10.0, 0.0); - p.fill_rect(10.0, 6.0, 14.0, 14.0, 0.0); - p.fill_rect(6.0, 10.0, 10.0, 14.0, 0.0); - p.rect(6.5, 6.5, 9.5, 9.5, 0.0); + // Both squares less the overlap, filled; both squares outlined. + p.fill_soft(&[(2.5, 2.5), (9.5, 2.5), (9.5, 6.5), (6.5, 6.5), (6.5, 9.5), (2.5, 9.5)]); + p.fill_soft(&[(13.5, 6.5), (13.5, 13.5), (6.5, 13.5), (6.5, 9.5), (9.5, 9.5), (9.5, 6.5)]); + p.rect(2.5, 2.5, 9.5, 9.5, 0.0); + p.rect(6.5, 6.5, 13.5, 13.5, 0.0); } Page => { p.poly(&[(3.5, 2.5), (9.5, 2.5), (12.5, 5.5), (12.5, 13.5), (3.5, 13.5)], true); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index a7397cee4..5a84e5a41 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -54,6 +54,8 @@ mod async_dialog_tests; #[cfg(test)] mod blend_preview_tests; #[cfg(test)] +mod bool_menu_icon_tests; +#[cfg(test)] mod branch_ui_tests; #[cfg(test)] mod canvas_double_click_tests; @@ -253,6 +255,8 @@ mod ui_params_tests; #[cfg(test)] mod ui_scale_tests; #[cfg(test)] +mod variable_picker_tests; +#[cfg(test)] mod variables_export_ui_tests; #[cfg(test)] mod variables_rename_tests; diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index efa64617c..e48ff2017 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -108,6 +108,9 @@ pub struct LiveUi { focus_join: bool, /// Share on the local network (else online, through the server in Preferences). pub local: bool, + /// What the code a local network share starts with lets people do: edit, or only look (the + /// edit code then doesn't show until it is picked). + pub local_access: Access, /// The Share popover's Advanced part shows; what it sets for a local network share: only /// this computer can join, and the port (empty: the usual one). pub advanced: bool, @@ -852,6 +855,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { } if local { widgets::label(ui, tr("People on your network join with a code. Nothing goes through a server."), t.text_secondary); + local_access_choice(app, ui); } else { online_note(app, ui, t, &server); } @@ -861,10 +865,24 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { join_and_advanced(app, ui, t, |app, ui, t| share_advanced(app, ui, t, local, &server)); } +/// What the code of a local network share lets people do, picked before it starts: edit the file +/// or only look at it. +fn local_access_choice(app: &mut FigApp, ui: &mut Ui) { + let levels: Vec<(Access, &str)> = LEVELS.into_iter().filter(|(a, _)| *a != Access::Comment).collect(); + let labels: Vec<&str> = levels.iter().map(|(_, l)| tr(l)).collect(); + let at = levels.iter().position(|(a, _)| *a == app.ui.live.local_access).unwrap_or_default(); + if let Some((a, _)) = widgets::segmented_text(ui, &labels, at).and_then(|i| levels.get(i)) { + app.ui.live.local_access = *a; + } +} + /// Start sharing the open file on the local network, or online on `server` (the one in /// Preferences). fn start_sharing(app: &mut FigApp, local: bool, server: &str) { let mut params = json!({"via": if local { "local" } else { "online" }}); + if local { + params["access"] = json!(app.ui.live.local_access.id()); + } if local && app.ui.live.this_computer { params["bind"] = json!("local"); } @@ -1068,6 +1086,7 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) { (true, Access::View) => tr("Anyone with this code can look at the file, not change it."), (true, Access::Comment) => tr("Anyone with this code can look at the file and comment on it, not change it."), (true, Access::Edit) => tr("Anyone with this code can edit the file."), + (false, Access::View) => tr("Anyone on your network with this code can look at the file, not change it, while this app stays open."), (false, _) => tr("Anyone on your network with this code can edit the file, while this app stays open."), }; widgets::label(ui, who, t.text_secondary); @@ -1081,7 +1100,8 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) { } /// What the code shown lets people do, to pick from the levels the share has links for: a server -/// has links (and codes) for looking only and for commenting; a session this app hosts doesn't. +/// has links (and codes) for looking only and for commenting; a session this app hosts on the +/// local network has one for looking only. fn access_choice(app: &mut FigApp, ui: &mut Ui, share: &Share) { let levels: Vec<(Access, &str)> = LEVELS.into_iter().filter(|(a, _)| share.links.as_ref().is_some_and(|l| l.of(*a).is_some())).collect(); if levels.len() < 2 { diff --git a/crates/ui-egui/src/live_docs_tests.rs b/crates/ui-egui/src/live_docs_tests.rs index 7ac04e82b..bc1f25f30 100644 --- a/crates/ui-egui/src/live_docs_tests.rs +++ b/crates/ui-egui/src/live_docs_tests.rs @@ -17,6 +17,7 @@ type Queue = Arc>>; struct Hosting(Queue); impl LiveLink for Hosting { + /// Host at once, with an invite link and nobody else in the session. fn host(&mut self, session: u64, _: HostOptions) -> Result { let address = format!("127.0.0.1:{}", 47_899 + session); Ok(SessionInfo { @@ -31,6 +32,7 @@ impl LiveLink for Hosting { address, network: false, access: Access::Edit, + view: None, }) } fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> { diff --git a/crates/ui-egui/src/live_server_tests.rs b/crates/ui-egui/src/live_server_tests.rs index e2f2a0506..745a8e93d 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -44,6 +44,7 @@ pub(crate) fn joined(h: &mut Harness, access: Access) { address: "127.0.0.1:47900".into(), network: false, access, + view: None, }; h.send(vec![LiveEvent::Welcome { info, peers: vec![] }, LiveEvent::Sync(w)]); h.frames(2); diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index 563cd2442..8ef118249 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -1,7 +1,7 @@ //! Sharing with a join code in the window: the Share popover not sharing (online or the local //! network, one Start button, the rest under Advanced), sharing online (the code large, copying, //! what it lets people do, who is here, stop: the file stays on the server, not shared, and can -//! be shared again or removed), sharing on the local network, a server without +//! be shared again or removed), sharing on the local network (to edit, or to look only), a server without //! codes, joining with the one join field (the popover, the home screen, the Join dialog), its //! errors, the server preference, and an idle popover asking for no frames. @@ -13,7 +13,7 @@ use figcraft_engine::figcraft_collab::code::{self, Local}; use figcraft_engine::figcraft_collab::message::ClientMsg; use figcraft_engine::live::{ Access, DEFAULT_SERVER, Event as LiveEvent, HostOptions, LiveLink, NetReply, NetRequest, Peer, Presence, PublishOptions, Published, Role, - SessionInfo, Shared as SharedLinks, SharedLink, Status, + SessionInfo, Shared as SharedLinks, SharedLink, Status, WayIn, }; use serde_json::json; @@ -53,9 +53,12 @@ struct Link(Shared); /// Where the scripted host listens, and its session's secret. const HOST: Local = Local { ip: std::net::Ipv4Addr::new(192, 168, 1, 20), port: 47_900, secret: 0x123_4567_89AB }; +/// The same session's view-only secret. +const VIEW: Local = Local { secret: 0xBA9_8765_4321, ..HOST }; /// A link without a code in it (an address a code can't carry). const PLAIN: &str = "figcraft://join?host=[fd00::1]:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv"; +/// What the scripted link says about a session you are in as `role`, invited with `link`. fn info(role: Role, link: &str) -> SessionInfo { SessionInfo { role, @@ -69,16 +72,20 @@ fn info(role: Role, link: &str) -> SessionInfo { address: "192.168.1.20:47900".into(), network: true, access: Access::Edit, + view: None, } } impl LiveLink for Link { + /// Host as the network link does: port 6 stands for an address a code can't carry, and a + /// share for looking only gets a view link too. fn host(&mut self, session: u64, opts: HostOptions) -> Result { let mut s = self.0.lock().unwrap(); s.session = session; - let link = if opts.port == Some(6) { PLAIN.to_string() } else { HOST.link() }; + let (link, view) = if opts.port == Some(6) { (PLAIN.to_string(), PLAIN.replace("AbCd", "View")) } else { (HOST.link(), VIEW.link()) }; + let view = opts.view.then(|| WayIn { url: view.replace("figcraft://join?host=", "ws://"), link: view }); s.hosted.push(opts); - Ok(info(Role::Host, &link)) + Ok(SessionInfo { view, ..info(Role::Host, &link) }) } fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> { let mut s = self.0.lock().unwrap(); @@ -208,6 +215,8 @@ fn document_arrives(h: &mut Harness, script: &Shared) { h.frames(3); } +/// Not sharing: one way to start (online or the local network, which also offers Can edit / Can +/// view) and one to join, with addresses, keys and ports under Advanced. #[test] fn not_sharing_the_popover_offers_one_way_to_start_and_one_to_join() { let (mut h, script) = harness(); @@ -223,9 +232,11 @@ fn not_sharing_the_popover_offers_one_way_to_start_and_one_to_join() { } let shapes = click(&mut h, &shapes, "Advanced"); assert!(has_text(&shapes, "Publish to another server…") && !has_text(&shapes, "Port")); - // The local network: what it means, and its own advanced part. + assert!(!has_text(&shapes, "Can view"), "online, what the code lets people do is picked once shared"); + // The local network: what it means, what the code lets people do, and its own advanced part. let shapes = click(&mut h, &shapes, "Local network"); assert!(has_text(&shapes, "People on your network join with a code. Nothing goes through a server.")); + assert!(has_text(&shapes, "Can edit") && has_text(&shapes, "Can view") && !has_text(&shapes, "Can comment")); assert!(has_text(&shapes, "This computer only") && has_text(&shapes, "Port") && !has_text(&shapes, "Publish to another server…")); assert!(!has_text(&shapes, "About this server")); assert_eq!(crate::live::inspect(&h.app)["local"], true); @@ -377,6 +388,8 @@ fn a_file_that_is_not_shared_can_be_removed_from_the_server_after_asking() { assert!(has_text(&shapes, "Share this file") && has_text(&shapes, "Removed from the server")); } +/// Sharing on the local network shows the longer code, copies it and its link, and Can view +/// shows the view-only code; Advanced picks this computer only and a port. #[test] fn sharing_on_the_local_network_shows_its_longer_code() { let (mut h, script) = harness(); @@ -388,15 +401,24 @@ fn sharing_on_the_local_network_shows_its_longer_code() { assert!(has_text(&shapes, t), "sharing shows {t:?}"); } assert!(has_text(&shapes, "Anyone on your network with this code can edit the file, while this app stays open.")); - assert!(!has_text(&shapes, "Can view"), "everyone edits on the local network"); + assert!(has_text(&shapes, "Can view") && !has_text(&shapes, "Can comment"), "a view-only code too, no comment code"); { let s = script.lock().unwrap(); - assert!(s.hosted[0].short && s.hosted[0].bind == figcraft_engine::live::Bind::Lan && s.hosted[0].port.is_none()); + assert!(s.hosted[0].short && s.hosted[0].view && s.hosted[0].bind == figcraft_engine::live::Bind::Lan && s.hosted[0].port.is_none()); assert!(s.published.is_empty(), "nothing goes to a server"); } let shapes = click(&mut h, &shapes, "Copy code"); let shapes = click(&mut h, &shapes, "Copy link"); - assert_eq!(script.lock().unwrap().copied, [shown, HOST.link()]); + assert_eq!(script.lock().unwrap().copied, [shown.clone(), HOST.link()]); + // Can view shows the view-only code and link, and says what they let people do. + let shapes = click(&mut h, &shapes, "Can view"); + let view = code::grouped(&VIEW.code()); + assert!(has_text(&shapes, &view) && !has_text(&shapes, &shown)); + assert!(has_text(&shapes, "Anyone on your network with this code can look at the file, not change it, while this app stays open.")); + let shapes = click(&mut h, &shapes, "Copy code"); + let shapes = click(&mut h, &shapes, "Copy link"); + assert_eq!(script.lock().unwrap().copied[2..], [view, VIEW.link()]); + assert_eq!(script.lock().unwrap().hosted.len(), 1, "the same session"); // Advanced: this computer only, on a port of your choosing. let shapes = click(&mut h, &shapes, "Stop sharing"); let shapes = click(&mut h, &shapes, "Advanced"); @@ -407,6 +429,27 @@ fn sharing_on_the_local_network_shows_its_longer_code() { assert!(s.hosted[1].bind == figcraft_engine::live::Bind::Local && s.hosted[1].port == Some(6)); } +/// Can view picked before a local network share starts: the view-only code is the one shown, so +/// the edit code isn't on the screen until Can edit is picked. +#[test] +fn a_local_share_can_start_for_looking_only() { + let (mut h, script) = harness(); + let shapes = open(&mut h); + let shapes = click(&mut h, &shapes, "Local network"); + let shapes = click(&mut h, &shapes, "Can view"); + assert_eq!(h.app.ui.live.local_access, Access::View); + assert_eq!(h.app.session.live().status, Status::Off, "picking starts nothing"); + let shapes = click(&mut h, &shapes, "Start sharing"); + let (view, edit) = (code::grouped(&VIEW.code()), code::grouped(&HOST.code())); + assert!(has_text(&shapes, &view) && !has_text(&shapes, &edit)); + assert!(has_text(&shapes, "Anyone on your network with this code can look at the file, not change it, while this app stays open.")); + assert_eq!(crate::live::inspect(&h.app)["share"]["access"], "view"); + assert!(script.lock().unwrap().hosted[0].view); + // Can edit shows the edit code. + let shapes = click(&mut h, &shapes, "Can edit"); + assert!(has_text(&shapes, &edit) && has_text(&shapes, "Anyone on your network with this code can edit the file, while this app stays open.")); +} + #[test] fn without_a_code_the_link_is_shared_instead() { // A server from before join codes. diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index cd8454afa..5fc809959 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -131,6 +131,7 @@ impl Harness { address: "127.0.0.1:47900".into(), network: false, access: figcraft_engine::live::Access::Edit, + view: None, }; self.send(vec![LiveEvent::Welcome { info, peers: vec![Peer { client_id: 1, name: "Hana".into(), color: "#9747FF".into() }] }]); } diff --git a/crates/ui-egui/src/menus/clipboard.rs b/crates/ui-egui/src/menus/clipboard.rs index ff065a48f..bae7aaca9 100644 --- a/crates/ui-egui/src/menus/clipboard.rs +++ b/crates/ui-egui/src/menus/clipboard.rs @@ -129,9 +129,6 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, f64, f64)> { Some(readable) => readable, None => with_pixel_size(encoded_clipboard_image(app)?)?, }; - if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS { - return None; - } let density = figcraft_format::pixel_density(&png); let (w, h) = figcraft_format::placed_size(w, h, density, figcraft_engine::cmd::create::PLACED_IMAGE_MAX_SIDE); Some((png, w, h)) @@ -140,8 +137,14 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, f64, f64)> { const MAX_PASTED_IMAGE_PIXELS: u64 = 100_000_000; fn with_pixel_size(png: Vec) -> Option<(Vec, (u32, u32))> { - let size = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_dimensions().ok()?; - Some((png, size)) + use image::ImageDecoder as _; + let decoder = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_decoder().ok()?; + let (w, h) = decoder.dimensions(); + if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS { + return None; + } + image::DynamicImage::from_decoder(decoder).ok()?; + Some((png, (w, h))) } fn encoded_clipboard_image(app: &mut FigApp) -> Option> { diff --git a/crates/ui-egui/src/online_tests.rs b/crates/ui-egui/src/online_tests.rs index d6968366a..9d1ad163a 100644 --- a/crates/ui-egui/src/online_tests.rs +++ b/crates/ui-egui/src/online_tests.rs @@ -48,6 +48,7 @@ type Shared = Arc>; struct Link(Shared); +/// A session you are in as `role` under `name`. fn info(role: Role, name: &str) -> SessionInfo { SessionInfo { role, @@ -61,6 +62,7 @@ fn info(role: Role, name: &str) -> SessionInfo { address: "192.168.1.20:47900".into(), network: true, access: Access::Edit, + view: None, } } diff --git a/crates/ui-egui/src/panel_width_tests.rs b/crates/ui-egui/src/panel_width_tests.rs index 357b13669..1bc17e7a1 100644 --- a/crates/ui-egui/src/panel_width_tests.rs +++ b/crates/ui-egui/src/panel_width_tests.rs @@ -346,3 +346,130 @@ fn a_narrow_window_shows_one_side_panel_at_a_time() { let wide = canvas_width(&mut app, vec2(1440.0, 900.0)); assert!(wide > 600.0 && wide < 1100.0, "a wide window shows both panels beside the canvas ({wide})"); } + +/// Five frames side by side at 46% zoom with the interface minimized and nothing selected. +fn minimized_row() -> (Ui, Vec) { + let mut s = Session::new(); + let mut ids = Vec::new(); + for k in 0..5 { + let r = s.execute("frame.create", &json!({"x": k * 470, "y": 0, "width": 400, "height": 600})).unwrap(); + ids.push(figcraft_engine::params::id_of(&r["id"]).unwrap()); + } + s.execute("select.set", &json!({"ids": []})).unwrap(); + let mut u = Ui::new(s); + u.app.run("view.minimize", json!({"on": true})); + u.app.run("view.set", json!({"zoom": 0.46, "cx": 1500, "cy": 300})); + for _ in 0..4 { + u.frame(vec![]); + } + (u, ids) +} + +fn frame_x(u: &Ui, id: figcraft_doc::NodeId) -> f64 { + u.app.session.doc().unwrap().world_bounds(id).unwrap().x0 +} + +/// A click on a frame while minimized selects it and shows the right panel (the canvas gets +/// narrower): the frame stays where it was, and no step is added to the history. +#[test] +fn a_click_that_shows_the_right_panel_does_not_move_the_frame() { + let (mut u, ids) = minimized_row(); + assert!(u.app.ui.right_panel_rect.is_none(), "nothing selected: no right panel"); + let wide = u.app.canvas.rect.width(); + let steps = u.app.session.ds().unwrap().history.undo.len(); + let p = u.app.to_screen(1880.0 + 200.0, 300.0); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]); + u.frame(vec![]); + u.frame(vec![]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]); + for _ in 0..4 { + u.frame(vec![]); + } + assert_eq!(u.app.session.selection(), vec![ids[4]], "the click selects the frame"); + assert!(u.app.ui.right_panel_rect.is_some(), "the right panel shows"); + assert!(u.app.canvas.rect.width() < wide - 100.0, "the canvas is narrower"); + assert_eq!(frame_x(&u, ids[4]), 1880.0, "a click did not move the frame"); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps, "no history step"); +} + +/// A real drag still moves the frame, by the pointer's screen movement over the zoom, in one step. +#[test] +fn a_drag_after_the_right_panel_shows_moves_the_frame_by_the_pointer() { + let (mut u, ids) = minimized_row(); + let steps = u.app.session.ds().unwrap().history.undo.len(); + let p = u.app.to_screen(1880.0 + 200.0, 300.0); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]); + u.frame(vec![]); + u.frame(vec![Event::PointerMoved(p + vec2(50.0, 0.0))]); + u.frame(vec![Event::PointerMoved(p + vec2(100.0, 0.0))]); + u.frame(vec![Event::PointerButton { + pos: p + vec2(100.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }]); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.right_panel_rect.is_some(), "the right panel shows"); + let moved = frame_x(&u, ids[4]) - 1880.0; + assert!((moved - 100.0 / 0.46).abs() < 6.0, "moved by 100 points over the zoom, got {moved}"); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps + 1, "one step"); +} + +/// Clicking empty canvas deselects and hides the right panel again; the marquee that starts there +/// has no offset: nothing moves and nothing is selected. +#[test] +fn a_click_that_hides_the_right_panel_does_not_move_the_frame() { + let (mut u, ids) = minimized_row(); + u.app.run("select.set", json!({"ids": [ids[4]]})); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.right_panel_rect.is_some()); + let p = u.app.to_screen(1880.0 + 200.0, 1000.0); + let steps = u.app.session.ds().unwrap().history.undo.len(); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]); + u.frame(vec![]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.right_panel_rect.is_none(), "deselected: no right panel"); + assert_eq!(frame_x(&u, ids[4]), 1880.0); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps); +} + +/// The left sidebar and rail appearing while a press is held (Minimize toggled off mid-press) +/// moves the canvas's left edge: a still pointer keeps its page point, so the release moves nothing. +#[test] +fn a_held_press_keeps_its_page_point_when_the_canvas_moves() { + let (mut u, ids) = minimized_row(); + u.app.run("select.set", json!({"ids": [ids[3]]})); + for _ in 0..3 { + u.frame(vec![]); + } + let steps = u.app.session.ds().unwrap().history.undo.len(); + let left = u.app.canvas.rect.min.x; + let p = u.app.to_screen(1880.0 + 200.0, 300.0); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]); + u.frame(vec![]); + let before = u.app.to_page(p); + u.app.run("view.minimize", json!({"on": false})); + u.frame(vec![]); + u.frame(vec![]); + assert!(u.app.canvas.rect.min.x > left + 100.0, "the left sidebar and rail moved the canvas's edge"); + let after = u.app.to_page(p); + assert!((after.x - before.x).abs() < 0.5 && (after.y - before.y).abs() < 0.5, "the pointer's page point moved: {before:?} to {after:?}"); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(frame_x(&u, ids[4]), 1880.0, "the frame did not move"); + assert_eq!(frame_x(&u, ids[3]), 1410.0); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps, "no history step"); +} diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 6dde13136..2454e5f67 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -35,7 +35,7 @@ pub(crate) use inspect::{inspect_section, prop_row}; pub(crate) use menu::menu_row_width; #[cfg(test)] pub(crate) use menu::preview_blend; -pub use menu::{blend_menu, context_popup, menu_check, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row}; +pub use menu::{blend_menu, context_popup, menu_check, menu_icon_item, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row}; pub(crate) use menu::{block, menu_row_lit, menu_style, row, row_rect}; use paints::{appearance, paints, variable_modes}; pub(crate) use stroke::{CAP_ART_W, cap_art, join_label}; @@ -483,18 +483,17 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { (BoolOp::Intersect, "bool.intersect"), (BoolOp::Exclude, "bool.exclude"), ] { + // Each entry leads with its operation's icon (the result it previews). let key = crate::shortcuts::shortcut(c).unwrap_or(""); - let picked = match current { - Some(cur) => menu_check(ui, cur == op, tr(op.label()), key), - None => menu_item(ui, tr(op.label()), key), - }; + let icon = crate::layers::op_icon(op); + let picked = menu_icon_item(ui, Some(icon), current.map(|cur| cur == op), tr(op.label()), key); if picked { app.run(c, json!({})); ui.close(); } } let key = crate::shortcuts::shortcut("vector.flatten").unwrap_or(""); - let flatten = if current.is_some() { menu_item_beside_checks(ui, tr("Flatten"), key) } else { menu_item(ui, tr("Flatten"), key) }; + let flatten = menu_icon_item(ui, None, None, tr("Flatten"), key); if flatten { app.run("vector.flatten", json!({})); ui.close(); diff --git a/crates/ui-egui/src/panels/right/menu.rs b/crates/ui-egui/src/panels/right/menu.rs index 790c8a630..9b80ab8d8 100644 --- a/crates/ui-egui/src/panels/right/menu.rs +++ b/crates/ui-egui/src/panels/right/menu.rs @@ -81,22 +81,30 @@ pub(crate) fn menu_row_width(label_w: f32, trail: f32) -> f32 { /// A menu row (see [`menu_item_with`]); its response tells hovering too. It spans the menu and /// is at least as wide as its label, shortcut and art. pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response { - lit_row(ui, label, shortcut, art_w, art, false) + lit_row(ui, label, shortcut, art_w, 0.0, art, false) } /// A plain menu row highlighted when `lit`, as if hovered: the row Up and Down are on in a menu /// whose search field keeps the keyboard. pub(crate) fn menu_row_lit(ui: &mut Ui, label: &str, lit: bool) -> egui::Response { - lit_row(ui, label, "", 0.0, |_, _, _| {}, lit) + lit_row(ui, label, "", 0.0, 0.0, |_, _, _| {}, lit) } -fn lit_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32), lit: bool) -> egui::Response { +fn lit_row( + ui: &mut Ui, + label: &str, + shortcut: &str, + art_w: f32, + lead: f32, + art: impl FnOnce(&egui::Painter, Rect, Color32), + lit: bool, +) -> egui::Response { let t = Tokens::get(ui.ctx()); let text = ui.painter().layout_no_wrap(label.to_owned(), regular(11.5), Color32::PLACEHOLDER); let keys = (!shortcut.is_empty()).then(|| ui.painter().layout_no_wrap(crate::toolbar::pretty_shortcut(shortcut), regular(11.0), Color32::PLACEHOLDER)); let trail = keys.as_ref().map_or(0.0, |k| k.size().x + MENU_PAD).max(art_w); - let w = ui.available_width().max(menu_row_width(text.size().x, trail).ceil()); + let w = ui.available_width().max(menu_row_width(lead + text.size().x, trail).ceil()); let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); a11y::menu_item(&resp, label, shortcut, None); // The keyboard's item is highlighted like the hovered one. @@ -108,7 +116,7 @@ fn lit_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnc // These menus sit on the theme's popup fill (white in the light theme), not on the dark // `t.menu` of the menu bar: the theme's text, white only on the green highlight (#972). let col = if hovered { t.menu_text } else { t.text }; - ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col); + ui.painter().galley(pos2(r.min.x + MENU_PAD + lead, r.center().y - text.size().y / 2.0), text, col); if let Some(k) = keys { let at = pos2(r.max.x - MENU_PAD - k.size().x, r.center().y - k.size().y / 2.0); ui.painter().galley(at, k, if hovered { t.menu_text } else { t.text_secondary }); @@ -154,3 +162,26 @@ pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui) add(&mut child); r } + +/// Width of the check column of [`menu_icon_item`] (the mark, or nothing, so labels line up). +const CHECK_COL: f32 = 14.0; + +/// A menu item in a menu whose entries lead with a 16 px icon (in the row's text colour): the +/// boolean operations in the Design panel. `icon` is `None` for an entry without one (Flatten): +/// its slot stays empty, so every label starts at the same x. The check mark (`on` is +/// `Some(true)`) is drawn in its own column ahead of the icon, so a checked entry's label does +/// not move against the unchecked ones. +pub fn menu_icon_item(ui: &mut Ui, icon: Option, on: Option, label: &str, shortcut: &str) -> bool { + let paint = |p: &egui::Painter, r: Rect, col: Color32| { + if on == Some(true) { + let mark = p.layout_no_wrap("✓".to_owned(), regular(11.5), col); + p.galley(pos2(r.min.x + MENU_PAD, r.center().y - mark.size().y / 2.0), mark, col); + } + if let Some(icon) = icon { + crate::icons::paint(p, Rect::from_min_size(pos2(r.min.x + MENU_PAD + CHECK_COL, r.center().y - 8.0), vec2(16.0, 16.0)), icon, col); + } + }; + let resp = lit_row(ui, label, shortcut, 0.0, CHECK_COL + 22.0, paint, false); + a11y::menu_item(&resp, label, shortcut, on); + resp.clicked() +} diff --git a/crates/ui-egui/src/panels/variable_ui.rs b/crates/ui-egui/src/panels/variable_ui.rs index 93bf6ee9f..53327440f 100644 --- a/crates/ui-egui/src/panels/variable_ui.rs +++ b/crates/ui-egui/src/panels/variable_ui.rs @@ -423,6 +423,9 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { let mut chosen: Option = None; let mut close = false; let area = egui::Area::new(Id::unique("variable_picker_area")).order(egui::Order::Foreground).fixed_pos(p.at + vec2(0.0, 4.0)).show(ctx, |ui| { + // The whole screen's height, not last frame's, so the list grows back after a search (#2043); + // egui keeps the area on the screen. + ui.set_max_height(crate::popover::max_height(ctx.content_rect())); egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| { ui.set_width(240.0); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/paste_image_tests.rs b/crates/ui-egui/src/paste_image_tests.rs index e70ea0968..756c93067 100644 --- a/crates/ui-egui/src/paste_image_tests.rs +++ b/crates/ui-egui/src/paste_image_tests.rs @@ -549,3 +549,18 @@ fn a_clipboard_png_past_the_byte_limit_falls_back_to_the_decoded_image() { let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image"); assert_eq!((pasted.width, pasted.height), (2.0, 2.0)); } + +#[test] +fn a_clipboard_png_with_a_cut_off_body_falls_back_to_the_decoded_image() { + let mut w = Win::new(); + let mut png = std::io::Cursor::new(Vec::new()); + image::RgbaImage::new(4, 4).write_to(&mut png, image::ImageFormat::Png).unwrap(); + let png = png.into_inner(); + let cut_off = png[..png.len() - 20].to_vec(); + assert_eq!(image::ImageReader::new(std::io::Cursor::new(&cut_off)).with_guessed_format().unwrap().into_dimensions().unwrap(), (4, 4)); + *w.clip.borrow_mut() = Clip { png: Some(cut_off), image: Some((2, 2, vec![255; 16])), ..Clip::default() }; + w.cmd_v(); + let doc = w.app.session.doc().unwrap(); + let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image"); + assert_eq!((pasted.width, pasted.height), (2.0, 2.0)); +} diff --git a/crates/ui-egui/src/popover.rs b/crates/ui-egui/src/popover.rs index 14d1e133f..6c0fafab7 100644 --- a/crates/ui-egui/src/popover.rs +++ b/crates/ui-egui/src/popover.rs @@ -177,6 +177,7 @@ impl Popover { ui.set_invisible(); ctx.request_repaint(); } + ui.set_max_height(max_height(screen)); // The panel is painted into a place kept before the content: the shadow and the fill // go under it, though its size is only known once the slot is laid out. let bg = ui.painter().add(egui::Shape::Noop); @@ -276,6 +277,16 @@ fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 { pos2(anchor.x - size.x, y) } +/// The height a popover's content is offered: the screen's, less [`EDGE`] above and below. +/// +/// egui offers an area the size it had last frame, so content that fills what it is offered (a +/// scrolling list) could only ever shrink: a search that narrowed a picker's list left it a few +/// rows high after the search was cleared (#2043). The list takes what it needs of this, and the +/// placement keeps the panel on the screen. +pub(crate) fn max_height(screen: Rect) -> f32 { + (screen.height() - 2.0 * EDGE).max(HEADER_H) +} + /// Keeps the panel inside the screen: whole when it fits, its top-left corner when it doesn't. fn clamp(pos: Pos2, size: Vec2, screen: Rect) -> Pos2 { let (x0, y0) = (screen.min.x + EDGE, screen.min.y + EDGE); diff --git a/crates/ui-egui/src/variable_picker_tests.rs b/crates/ui-egui/src/variable_picker_tests.rs new file mode 100644 index 000000000..c351b025c --- /dev/null +++ b/crates/ui-egui/src/variable_picker_tests.rs @@ -0,0 +1,67 @@ +//! The variable picker ("Select variable" on every bindable field, aliases, styles and prototype +//! actions) drawn headless: its list follows its rows while it is open — a search that narrows it +//! shrinks the picker, clearing the search grows it back — and it stays on the screen (#2043: egui +//! offers an area the size it had last frame, so the list used to stay a few rows high). + +use egui::{Id, Pos2, RawInput, Rect, pos2, vec2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// A selected rectangle and 30 number variables. +fn scene() -> (Ui, NodeId) { + let mut s = Session::new(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap()); + s.execute("variable.createCollection", &json!({"name": "Sizes"})).unwrap(); + for i in 0..30 { + s.execute("variable.create", &json!({"collection": "Sizes", "name": format!("w-{}", 100 + 10 * i), "type": "number", "value": i})).unwrap(); + } + s.execute("select.set", &json!({"ids": [r.0]})).unwrap(); + (Ui::new(s), r) +} + +/// Opens the picker for `r`'s stroke weight under `at`. +fn open(u: &mut Ui, r: NodeId, at: Pos2) { + let screen = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() }; + let _ = u.ctx.run_ui(screen, |ui| crate::variable_ui::open(ui, &[r], "strokeWeight", figcraft_doc::VariableType::Number, at)); +} + +/// The picker's rect after a few frames. +fn settled(u: &mut Ui) -> Rect { + for _ in 0..5 { + u.frame(vec![]); + } + crate::variable_ui::picker_rect(&u.ctx).expect("the variable picker shows") +} + +fn search(u: &mut Ui, q: &str) { + u.ctx.data_mut(|d| d.insert_temp(Id::unique("variable_picker_query"), q.to_string())); +} + +#[test] +fn the_variable_list_grows_back_after_a_search_narrows_it() { + let (mut u, r) = scene(); + open(&mut u, r, pos2(900.0, 200.0)); + let open = settled(&mut u); + assert!(open.height() > 300.0, "the picker lists the variables: {open:?}"); + search(&mut u, "w-100"); + let narrowed = settled(&mut u); + assert!(narrowed.height() < open.height() - 100.0, "a search for one variable shrinks it: {open:?} → {narrowed:?}"); + search(&mut u, ""); + let again = settled(&mut u); + assert!((again.height() - open.height()).abs() < 1.0, "clearing the search grows it back: {open:?} → {again:?}"); +} + +#[test] +fn the_variable_picker_opened_near_the_bottom_shows_its_whole_list_on_the_screen() { + let (mut u, r) = scene(); + open(&mut u, r, pos2(900.0, 200.0)); + let full = settled(&mut u); + let (mut u, r) = scene(); + open(&mut u, r, pos2(900.0, 880.0)); + let low = settled(&mut u); + assert!((low.height() - full.height()).abs() < 1.0, "as tall as higher up: {full:?} vs {low:?}"); + assert!(low.max.y <= 900.0 && low.min.y >= 0.0, "on the screen: {low:?}"); +} diff --git a/crates/ui-egui/src/widgets/style_picker/tests.rs b/crates/ui-egui/src/widgets/style_picker/tests.rs index 0f3392e48..9d14ac44a 100644 --- a/crates/ui-egui/src/widgets/style_picker/tests.rs +++ b/crates/ui-egui/src/widgets/style_picker/tests.rs @@ -194,6 +194,68 @@ fn a_click_in_the_colour_picker_keeps_the_style_editor_open() { ); } +/// #2043: the list follows its rows while the picker is open. A search that narrows it shrinks +/// the popover, and clearing the search grows it back to the full list (egui offers an area the +/// size it had last frame, so the list used to stay a few rows high). +#[test] +fn the_style_list_grows_back_after_a_search_narrows_it() { + use crate::layers_drag_tests::Ui; + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + for i in 0..30 { + let name = format!("Blue/{}", 100 + 10 * i); + s.execute("style.create", &json!({"type": "paint", "name": name, "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + } + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + open_for_selection(&mut u.app, StyleSlot::Fill, None); + let height = |u: &mut Ui| { + for _ in 0..5 { + u.frame(vec![]); + } + u.app.ui.style_picker_popover.as_ref().expect("the style picker shows").rect.height() + }; + let open = height(&mut u); + assert!(open > 300.0, "the picker lists the styles: {open}"); + u.app.ui.style_picker.as_mut().expect("open").query = "Blue/100".into(); + let narrowed = height(&mut u); + assert!(narrowed < open - 100.0, "a search for one style shrinks it: {open} → {narrowed}"); + u.app.ui.style_picker.as_mut().expect("open").query.clear(); + let again = height(&mut u); + assert!((again - open).abs() < 1.0, "clearing the search grows it back: {open} → {again}"); +} + +/// #2043: egui keeps an area's size after it closes, so a picker opened once on a short list (the +/// effect styles, none yet) opened again on the colour styles a few rows high. +#[test] +fn the_style_list_opened_after_a_short_one_shows_every_style_it_fits() { + use crate::layers_drag_tests::Ui; + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + for i in 0..30 { + let name = format!("Blue/{}", 100 + 10 * i); + s.execute("style.create", &json!({"type": "paint", "name": name, "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + } + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = Ui::new(s); + let height = |u: &mut Ui, slot: StyleSlot| { + open_for_selection(&mut u.app, slot, None); + for _ in 0..5 { + u.frame(vec![]); + } + let h = u.app.ui.style_picker_popover.as_ref().expect("the style picker shows").rect.height(); + u.app.ui.style_picker = None; + u.app.ui.style_picker_popover = None; + u.frame(vec![]); + h + }; + let first = height(&mut u, StyleSlot::Fill); + let short = height(&mut u, StyleSlot::Effect); + assert!(short < first - 100.0, "no effect styles: {short}"); + let again = height(&mut u, StyleSlot::Fill); + assert!((again - first).abs() < 1.0, "the colour styles as tall as the first time: {first} → {again}"); +} + #[test] fn local_style_drops_move_into_the_folder_above() { let st = |id: &str, name: &str| Style { diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index b4b459afb..03d57c84e 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -597,8 +597,16 @@ resuming with its offline edits, and leaving the session going back to snapshot `local`), so a local share is protected by 44 bits, not 128: a code is guessed over the network at the rate the host accepts connections, which makes 44 bits years of trying, but treat it as a secret for the local network, not for the internet. The invite link names the same session. - Whoever sees the code learns the host's address. Everyone who joins edits; a host without an - IPv4 address gets no code and shares its link. + Whoever sees the code learns the host's address. The session has two secrets, so two codes: + the **edit code** and a **view-only code** (the same address and port, a 44-bit secret of its + own). The host welcomes whoever joins with the view secret as a viewer (`Access::View`): they + see every change and everyone's presence, the app makes the file read-only for them, and the + session refuses any batch they send anyway, as a server does for its view links. The Share + popover picks which code shows (Can edit / Can view, also before sharing starts, so the edit + code need never be on screen; `collab.share {access}`, whose `link` and `url` are the ones that + go with the code shown, so a view-only share never hands out the edit secret); picking one later changes + nothing for who already joined. There is no comment code on the local network. A host without an IPv4 + address gets no codes and shares its links. - **In the browser** a code joins like a link (the page asks the server over its own WebSocket, then opens the session's); it shares online through the server in Preferences as the desktop app does, and not on the local network (a page can't listen). diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index fb54a8d55..ef541f3bf 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,6 +1,6 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a file shared on the local network can be shared to look at only: a view-only code next to the edit code, picked in the Share popover before or while sharing) Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model, @@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | | Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | -| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 | +| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address, with an edit code and a view-only code, picked before or while sharing, whose holders see every change and can't make one; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | | Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — | | Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 | @@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Date | Change | Summary | |---|---|---| +| 2026-11-06 | minor | M9: a file shared on the local network can be shared read-only. The session the app hosts has a second secret, so a view-only code next to the edit code (same address and port, a 44-bit secret of its own); whoever joins with it is welcomed as a viewer, sees every change and presence, gets the "View only" badge and only the moving tools, and the session refuses their edits. The Share popover offers Can edit / Can view under Local network before sharing starts (the edit code then never shows) and while sharing; `collab.share {via: "local", access: "view"}` does the same (its `link` and `url`, like its `code`, are the view-only way in), and `share` in `collab.peers` lists both codes and links. No comment code on the local network. Numbers unchanged. | | 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. | | 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`#join=…`). A browser build served beside its server names it with ``, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged | | 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. | diff --git a/docs/components-parity.md b/docs/components-parity.md index 2c026e8b1..2589e9dea 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -50,6 +50,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. |---|---|---| | Publish a file's components, sets, styles and variables (per-asset versions) | done | as local library files | | Enable a library in a file; read-only linked copies | done | | +| Go to main component from an instance of a library component | done | opens the library file in a tab, or brings it to the front, on the page holding the main, selected and zoomed to; a variant goes to that variant. Team libraries on a server are refused with a note; no Return to instance yet | | Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) | | Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) | | Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; insertions and detachments week by week for a year (Show usage: the past 30 days, 90 days or year), for components; the layers and files using each style and variable, without insertions, detachments or modes; no chart | @@ -79,6 +80,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| | 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. | +| 2026-11-06 | minor | M3: Go to main component (context menu, Design panel, Ctrl+Alt+Cmd+K) on an instance of a library component opens the library file, as the help center describes for mains kept in another file ("Edit main components"); it used to refuse. The file opens in a tab, or an open tab comes to the front, on the main's page with the main selected and zoomed to: a nested instance goes to its own main, a variant to that variant. A main deleted from the library file since it was published leaves the file open with a note; a library file that moved, a team library on a server and a path off this computer are refused with a message. Test: `library::tests::going_to_a_library_main_opens_the_library_file`. Numbers unchanged | | 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects//`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. | | 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged | | 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. | diff --git a/docs/contributors.md b/docs/contributors.md index 1f18de9a3..2499d6248 100644 --- a/docs/contributors.md +++ b/docs/contributors.md @@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers. - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from disk or the network at run time; the web build has them too): - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - username; never hand-edit. + username; never hand-edit. Release builds regenerate it for the release commit + (`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated. - [`contributors/people.toml`](../contributors/people.toml): the names people chose for themselves. **Each contributor adds only their own entry**, in their own PR. - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 312c7e0ac..c8ed94702 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); 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; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; 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; 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%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -596,7 +596,9 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th that sees the whole backdrop, inside clipping frames, faded groups and masks too; both blurs uniform or progressive (the radius ramps between two points, dragged on the canvas); noise in mono, duo and multi colour with grain size, density and blend mode; texture with clip to shape; - glass with light angle and intensity, refraction, depth, dispersion, frost and splay; the same in + glass with light angle and intensity, refraction, depth, dispersion, frost and splay (a thick lens + bending towards the shape's core, its light a line on the bevel, matched to the reference tool on + synthetic files); the same in PNG, PDF and SVG exports, SVG embedding renders of noise grains, texture, progressive and background blur and glass); corner radii with smoothing; constraints; auto layout (horizontal, vertical, wrap, grid; hug/fill/fixed; min/max; @@ -648,6 +650,9 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-11-06 | patch | M3: glass's docs say which centre `splay` bends away from: the centre of the layer's bounds, as the reference tool's, checked side by side on a ring and an L at Splay 0% and 100% (an inner edge bends towards the hole in both). And where a glass shape gets no core: past 4M px on the desktop and 1M on the web (`MAX_SHAPE_PX`) it bends along its edge normals, so a large glass text or vector can bend differently once zoomed into, in a 2× export, or on the web. Docs only. Grade unchanged. | +| 2026-11-02 | minor | M3, #1885: glass bends towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly, and a thin part (a capsule, a letter's stroke) bends across itself, checked side by side on a capsule 44 units tall. Rounded rectangles work it out in closed form. A shape too big to keep whole at the zoom bends along its edge normals instead, so no render works out a core over more pixels than a kept shape holds. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`, `glass_cut_tests::a_cut_through_the_pane_matches_the_whole_render`. Grade unchanged. | +| 2026-11-02 | minor | M3, #1885: glass's light is a line on its 1-unit bevel added to the backdrop, as the reference tool's: fully where the edge faces the light, none below 0.35 of facing it, 0.6 as much on the opposite side, with a faint gradient across the curve. `splay` turns the bend from along the edge's normal towards the way away from the layer's centre and no longer widens the light, as in the reference tool. Checked side by side on synthetic files: a splay of 1 bends the backdrop towards the centre in both. Tests: `glass::tests::the_light_lies_on_the_bevel_and_the_gradient_across_the_curve`, `backdrop_tests::splay_turns_the_bend_towards_the_centre`, `glass_cut_tests::the_light_is_a_line_on_the_bevel`. Grade unchanged. | | 2026-11-02 | minor | M3, #1885: glass bends the backdrop with the reference tool's thick-lens profile (most a 1-unit bevel in from the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further in) and splits its colours over 8 steps, red and blue 0.16 of the bend apart at 100% dispersion. Measured against the reference tool on synthetic files at 2×: the black band a pane pulls to its edge ends within 3 px of the reference tool's, and red and blue part 2.27 px at 50% dispersion and 4.66 px at 100% (2.38 and 4.69 there). Tests: `glass::tests::the_bend_follows_the_thick_lens_profile`, `glass::tests::dispersion_splits_the_channels_along_the_bend`. Grade unchanged. | | 2026-11-01 | patch | M4: bulleted lists draw the same bullet at every level of indentation ("Figma bullets will keep the same styling at all levels of indentation", Create bulleted and numbered lists); the deeper levels drew a white bullet and a small square, pinned as silent before. Numbered levels (1., a., i.) are unchanged. Found by a documentation audit of the text rules. Test: `tail_tests::list_numbering_by_level` (the bullets row). Numbers unchanged | | 2026-11-01 | patch | M4: typing `1)` followed by a space at the start of a plain paragraph starts a numbered list, as `1.` already did ("Enter one of the creation characters below followed by a Space: 1. 1)", Create bulleted and numbered lists). Found by a documentation audit of the text rules. Test: `text_tail_tests::typing_a_creation_character_and_a_space_starts_a_list`. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index d98a6ed73..8911898cd 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** patch (a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -88,7 +88,7 @@ interaction feel not yet compared. | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); Enter or a double-click ends the path (at the double-click's point, added once) and edits the vector with the Move tool, a double-click with the idle pen edits the layer under it, and in edit mode the pen's Enter or double-click takes the Move tool (#1430, `pen_vector_edit_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | | Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`); a Bend click on a sharp point between two segments curves it (handles along the line between its neighbours, a third of the way to each, `ops::auto_smooth`), on a curved one sharpens it, a drag sets the handles by hand (#1875). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | -| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104) | +| Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | a group past the path operations' caps (250 000 segments, 2 million possible crossings, 4 million segments swept over a fold) draws without its result, with a warning, and Flatten refuses it (#1104); the Design panel's Boolean groups menu leads Union, Subtract, Intersect and Exclude with their own icons, two offset squares with the result filled at reduced alpha (#2003) | | Text: point, auto height, fixed; caret, words, lines, IME | done | Backspace or Delete at the start of a list item takes its marker and keeps its level, the paragraph starting where the item's text did ("delete the counter, but keep the same level of indentation", Create bulleted and numbered lists; `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests`); max lines on auto-width text keep it auto width, hugging up to that many lines and growing with its longest line ("Text resizing is set to auto height or auto width", Explore text properties; `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines`); Alt+Enter while editing opens the link at the caret, web and mail links only (`text.openLink`; "press ⌥Option Return/Alt Enter to activate a focused link", Accessibility at Figma; `text_link_tests`); the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Text: wrap style (Auto, Balance, Pretty) | done | per text layer (`wrapStyle` on `node.set`, `text.wrapStyle`, the Type settings control under Vertical trim, inspect, CSS `text-wrap: balance` or `pretty`): Auto breaks greedily; Balance narrows each paragraph as far as it goes without another line, so its lines come out close to the same length (what browsers do for `text-wrap: balance`); Pretty keeps lines full and, when the last line is one word, narrows one of the last four lines just enough to give a word to the lines after it (Explore text properties: "Auto: Figma fits as many words as possible on each line before wrapping to the next", "Balance: Figma distributes text as evenly as possible across every line, so each line is close to the same length", "Pretty: Figma keeps each line full, but adjusts the last four lines to avoid an orphan"). Saved line breaks from a file still win. Not yet included: per-paragraph settings, text-style support and the `.fig` import/export mapping (#1827). Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented` | | Text: superscript and subscript | done | `sups` / `subs` use the font's own glyphs where it has the feature and every letter or digit of the run gets one; otherwise the standard glyphs are drawn shrunk and raised or lowered by the font's OS/2 superscript and subscript metrics (0.65 em at 0.35 em up or 0.14 em down without them), and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says ("Figma draws a synthesized version by using the font's standard version of the character, shrinking it, and positioning it accordingly"; mixed glyphs "all update to the synthesized form for consistency"), #1828; tests `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them` | @@ -178,6 +178,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. | | 2026-11-04 | patch | M1, #1852: a press on one of the open gradient's handles or stops on the canvas is the colour picker's own, not a press outside it (Figma keeps the picker and the handles up while a handle or stop is dragged; the stops are made to be dragged: "You can click and drag a stop to change its placement on the gradient", Use gradients as a fill or stroke). The canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once; the handles vanished with it and the drag went on without an undo step of its own. Now the picker stays while a gradient handle or stop drags, and the drag is one undo step; a press elsewhere on the canvas still closes it. A handle drag whose button goes up off the canvas (over a panel or the picker) ends there too, instead of waiting, uncommitted, to move the handle to the next press on the canvas. Tests: `colorpicker_selection_tests::dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open`, `colorpicker_selection_tests::a_gradient_drag_released_off_the_canvas_ends_there`. | | 2026-11-02 | patch | M8, #1875: a click with the Bend tool on a sharp point between two segments gives it handles along the line between its neighbours, a third of the way to each ("Click on a point or path where you want to add a curve", Edit vector layers); a click on a curved point still makes it sharp, a drag still sets the handles by hand, an endpoint is left alone. Between a handle's end and a point both within reach of the pointer, the nearer one on the page is hit (the handle on a tie), so a short path's point (its handles a few pixels long) can be clicked again. Before, only a drag curved a point, and a short one left two tiny handles in its direction. Tests: `vectoredit::tests::short_handles_leave_their_point_clickable`, `vectoredit::tests::a_bend_click_curves_a_sharp_point_between_its_neighbours`, `bend_tool_smooths_and_sharpens_points`. Numbers unchanged. | | 2026-11-02 | minor | M4, #1827: wrap style on a text layer (`WrapStyle`: Auto, Balance, Pretty): Balance re-breaks each paragraph at the narrowest width that keeps its line count (bisection over the greedy breaker, as browsers balance), Pretty narrows one of the last four lines just enough so the last line isn't one word; `text.wrapStyle`, `node.set wrapStyle` (`textWrap` too), inspect `wrapStyle`, Dev Mode CSS `text-wrap` (Tailwind `text-balance` / `text-pretty`), the Type settings control under Vertical trim. Per-paragraph settings, text-style support and the `.fig` import/export mapping stay open. Test: `text_behaviour_tests::balance_and_pretty_wrap_styles_break_lines_as_documented`. |