mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 03:51:42 +00:00
Merge upstream/main into fix/control-arrows-pan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
08958b47ba
86 files changed
+2936
-621
No files matched your search
@@ -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
|
||||
@@ -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 }}
|
||||
|
||||
@@ -1,3 +1,56 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## 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.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## 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.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# 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 <port> [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).
|
||||
|
||||
|
||||
@@ -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 <port> [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.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file)
|
||||
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
|
||||
+6
-1
@@ -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 Line truncated
|
||||
> **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 Line truncated
|
||||
|
||||
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 |
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,3 +104,11 @@ real_name = "Oleksii Sukholita"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.denisg0mes]
|
||||
real_name = "Denis Gomes"
|
||||
display_name = "Denis Gomes"
|
||||
|
||||
[people.Hamza0101]
|
||||
real_name = "Hamza Naveed"
|
||||
display_name = "Hamza0101"
|
||||
@@ -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::<String>()})).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() }
|
||||
|
||||
@@ -391,7 +391,7 @@ impl<S: Socket> Driver<S> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -419,6 +419,8 @@ fn pump(d: &mut Driver<Loopback>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
|
||||
@@ -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<Cmd>, me: &Sender<C
|
||||
match after(outcome, cfg, was_live, &mut attempt, inbox) {
|
||||
Next::Stop(e) => {
|
||||
if let Some(e) = e {
|
||||
inbox.push(e);
|
||||
inbox.push(*e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<Admission, String> {
|
||||
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<Server, String> {
|
||||
@@ -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<Host> {
|
||||
// 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 })
|
||||
|
||||
@@ -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<Event>),
|
||||
/// 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<Box<Event>>),
|
||||
/// 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());
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<String, String> {
|
||||
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<IpAddr> {
|
||||
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<IpAddr> {
|
||||
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<SessionInfo, String> {
|
||||
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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -345,3 +345,16 @@ fn remove_all_auto_layout_strips_the_frame_and_every_frame_nested_in_it() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(laid_out(&s), [true, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_rejects_negative_and_fractional_indexes() {
|
||||
let mut s = Session::new();
|
||||
let f = frame(&mut s, None, 0.0, 0.0, 200.0, 200.0);
|
||||
let a = rect(&mut s, Some(f), 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, Some(f), 0.0, 0.0, 10.0, 10.0);
|
||||
let c = rect(&mut s, Some(f), 0.0, 0.0, 10.0, 10.0);
|
||||
for bad in [json!(-1), json!(0.5), json!("x")] {
|
||||
assert!(s.execute("node.reorder", &json!({"id": c, "index": bad})).is_err(), "{bad} accepted");
|
||||
assert_eq!(kids(&s, f), vec![a, b, c]);
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -184,14 +184,21 @@ fn edit_if(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit) -> bool
|
||||
let doc = ds.doc_mut();
|
||||
let n = doc.get_mut(te.id)?;
|
||||
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) };
|
||||
let derived_before = crate::cmd::props::text_layer_name(&t.characters);
|
||||
if !f(t, &mut te) {
|
||||
return Ok(false);
|
||||
}
|
||||
// Any edit ends a composition (a commit replaces it with the final text).
|
||||
te.preedit = None;
|
||||
let len = t.char_count();
|
||||
let derived_after = crate::cmd::props::text_layer_name(&t.characters);
|
||||
te.caret = te.caret.min(len);
|
||||
te.anchor = te.anchor.min(len);
|
||||
// The layer's name follows the characters while it is the name they derive, so the Layers
|
||||
// panel keeps up with what the canvas shows; a name the author typed is left alone.
|
||||
if crate::cmd::props::text_name_is_derived(&n.name, derived_before.as_ref()) {
|
||||
n.name = derived_after.unwrap_or_default();
|
||||
}
|
||||
if n.source.is_some() {
|
||||
n.overrides.insert("characters".into());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -186,28 +186,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn go_to_library_main(s: &mut Session, library: &str, name: &str, key: NodeId) -> Result<Value> {
|
||||
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<Value> {
|
||||
|
||||
@@ -302,7 +302,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
(w, h, offset) = (shape.width, shape.height, shape.offset);
|
||||
} else if let Some(Value::Array(pts)) = get(p, "points") {
|
||||
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
|
||||
let pts: Vec<Point> = pts
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let xy = v.as_array().filter(|a| a.len() == 2)?;
|
||||
Some(Point::new(f64_of(&xy[0])?, f64_of(&xy[1])?))
|
||||
})
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(|| bad(cmd, "`points` entries must be numeric [x, y] pairs"))?;
|
||||
if pts.len() < 2 {
|
||||
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| {
|
||||
let i = req_id("node.reorder", p, "id")?;
|
||||
let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize;
|
||||
let idx = req_index("node.reorder", p, "index")?;
|
||||
s.doc_mut()?.reorder(i, idx)?;
|
||||
ok()
|
||||
}),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -54,7 +54,7 @@ pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"files": files}))
|
||||
}
|
||||
|
||||
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let variables = variables_param("file.open", p)?;
|
||||
if string(p, "path").is_none()
|
||||
&& let Some(d) = string(p, "data")
|
||||
|
||||
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
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")));
|
||||
|
||||
@@ -41,6 +41,30 @@ pub(crate) fn normalize_line_endings(s: &str) -> String {
|
||||
s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n")
|
||||
}
|
||||
|
||||
/// The name a text layer derives from its characters: the first line, at most 64 characters, or
|
||||
/// None when that line is blank (a layer with no name of its own, which the Layers panel shows by
|
||||
/// its kind).
|
||||
///
|
||||
/// A layer whose name is this derived name is one nobody has renamed, so the editor keeps its name
|
||||
/// in step as the characters are typed ([`crate::textedit`]); any other name was chosen by the
|
||||
/// author and is kept. Commands never rename, so a layer named for its role keeps that name as a
|
||||
/// field key even when `characters` replace text that reads like it.
|
||||
pub(crate) fn text_layer_name(characters: &str) -> Option<String> {
|
||||
let first: String = characters.lines().next().unwrap_or("").chars().take(64).collect();
|
||||
(!first.trim().is_empty()).then_some(first)
|
||||
}
|
||||
|
||||
/// Whether `name` is one a text layer was given for its characters rather than by its author, so
|
||||
/// the editor may keep it in step with them. Blank text leaves a layer named `""` when the Text
|
||||
/// tool draws it and `Text` when `node.create` makes it, and both read as unnamed in the panel.
|
||||
pub(crate) fn text_name_is_derived(name: &str, derived: Option<&String>) -> bool {
|
||||
match derived {
|
||||
Some(d) => name == d,
|
||||
// `NodeKind::Text(_).label()`, which the panel falls back to for an unnamed layer.
|
||||
None => name.is_empty() || name == "Text",
|
||||
}
|
||||
}
|
||||
|
||||
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
|
||||
pub fn snake(v: &Value) -> Value {
|
||||
match v {
|
||||
|
||||
@@ -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<CommandSpec> {
|
||||
@@ -258,13 +258,13 @@ fn remove_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn move_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -378,3 +378,12 @@ fn the_text_on_a_path_tool_carries_a_fill_bound_through_fills() {
|
||||
let t = editing(&s).expect("typing starts on the path");
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().bound, bound, "the fill keeps following its variable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_point_is_rejected() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("vector.create", &json!({"points": [[0, 0], ["abc", 5], [100, 100]], "closed": false})).is_err());
|
||||
assert!(s.execute("vector.create", &json!({"points": [[0, 0], [1], [100, 100]]})).is_err());
|
||||
assert!(s.execute("vector.create", &json!({"points": [[0, 0], 7, [100, 100]]})).is_err());
|
||||
assert!(s.execute("vector.create", &json!({"points": [[0, 0], [50, 5], [100, 100]]})).is_ok());
|
||||
}
|
||||
@@ -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<Mutex<Script>>);
|
||||
|
||||
/// 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<SessionInfo, String> {
|
||||
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<Mutex<Script>>, 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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Value> {
|
||||
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<SessionId> {
|
||||
/// 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<SessionId> {
|
||||
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::<IpAddr>().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<Option<Access>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
"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<Value> {
|
||||
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<Value> {
|
||||
}
|
||||
};
|
||||
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<Shared>) {
|
||||
if let Some((server, file)) = s.live().server_file() {
|
||||
|
||||
@@ -162,6 +162,7 @@ fn spotlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"spotlighting": s.live().spotlighting}))
|
||||
}
|
||||
|
||||
/// The live session commands (`collab.*`).
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
@@ -174,7 +175,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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<CommandSpec> {
|
||||
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())
|
||||
),
|
||||
|
||||
@@ -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<Value> = 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)),
|
||||
|
||||
@@ -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<WayIn>,
|
||||
}
|
||||
|
||||
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<Document>,
|
||||
/// 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.
|
||||
|
||||
@@ -748,3 +748,71 @@ fn a_link_is_underlined_by_default_and_underline_takes_it_off() {
|
||||
s.execute("text.underline", &json!({"ids": [f]})).unwrap();
|
||||
assert_eq!(inner_style(&s).decoration, TextDecoration::None, "Underline on the frame reads the text inside it");
|
||||
}
|
||||
|
||||
fn layer_name(s: &Session, t: NodeId) -> String {
|
||||
s.doc().unwrap().node(t).unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// Select everything in the layer being edited and delete it.
|
||||
fn clear_edited_text(s: &mut Session, t: NodeId) {
|
||||
let len = s.doc().unwrap().node(t).unwrap().kind.text().unwrap().char_count();
|
||||
crate::textedit::set_caret(s, 0, false).unwrap();
|
||||
crate::textedit::set_caret(s, len, true).unwrap();
|
||||
crate::textedit::key(s, "Backspace", false, false, false).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it() {
|
||||
// The Layers panel shows a layer's name, so a layer still carrying the name its characters
|
||||
// gave it has to follow them while they are typed or the panel and the canvas disagree
|
||||
// (#1747). The target app's plugin API calls this `autoRename`: "Whether updating the
|
||||
// characters in the text node should update the name of the node… This is automatically reset
|
||||
// to false if `name` is modified in order to allow the node to keep the new name."
|
||||
let mut s = Session::new();
|
||||
|
||||
// Nobody renamed this layer, so typing in it keeps its name up to date.
|
||||
let t = text(&mut s, "hello", json!({}));
|
||||
assert_eq!(layer_name(&s, t), "hello", "a new layer is named after its first line");
|
||||
crate::textedit::begin(&mut s, t, Some(5), false).unwrap();
|
||||
crate::textedit::insert(&mut s, " there").unwrap();
|
||||
assert_eq!((chars(&s, t).as_str(), layer_name(&s, t).as_str()), ("hello there", "hello there"), "the name follows what is typed");
|
||||
|
||||
// A name the author typed is theirs: later typing leaves it alone.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"name": "Greeting"}})).unwrap();
|
||||
crate::textedit::begin(&mut s, t, Some(11), false).unwrap();
|
||||
crate::textedit::insert(&mut s, "!").unwrap();
|
||||
assert_eq!((chars(&s, t).as_str(), layer_name(&s, t).as_str()), ("hello there!", "Greeting"), "a chosen name is kept");
|
||||
|
||||
// Only the first line names the layer.
|
||||
let v = text(&mut s, "first", json!({}));
|
||||
crate::textedit::begin(&mut s, v, Some(5), false).unwrap();
|
||||
crate::textedit::insert(&mut s, "\nsecond").unwrap();
|
||||
assert_eq!((chars(&s, v).as_str(), layer_name(&s, v).as_str()), ("first\nsecond", "first"), "the first line alone names it");
|
||||
|
||||
// Emptied text reads as a new empty text layer does, not as the word that was deleted.
|
||||
clear_edited_text(&mut s, v);
|
||||
assert_eq!((chars(&s, v).as_str(), layer_name(&s, v).as_str()), ("", ""), "emptied text has no name of its own, so the panel shows its kind");
|
||||
|
||||
// The usual way a layer is made: the Text tool, a click, then typing into the empty layer.
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
s.set_tool(ToolId::Text);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap();
|
||||
let drawn = s.ds().unwrap().text_edit.as_ref().unwrap().id;
|
||||
assert_eq!(layer_name(&s, drawn), "", "a layer the tool drew has no name of its own yet");
|
||||
crate::textedit::insert(&mut s, "Pricing").unwrap();
|
||||
assert_eq!((chars(&s, drawn).as_str(), layer_name(&s, drawn).as_str()), ("Pricing", "Pricing"), "the first word typed names it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_characters_as_a_command_never_renames_the_layer() {
|
||||
// Only the editor follows the characters. Layer names are field keys for `data.fill`,
|
||||
// component property bindings and templates, which write `characters` into a layer named for
|
||||
// its role ("Title") and look it up by that name again, so a command must leave the name as it
|
||||
// is even when it reads like the text it replaces.
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "Title", json!({}));
|
||||
assert_eq!(layer_name(&s, t), "Title");
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "Site em 7 dias"}})).unwrap();
|
||||
assert_eq!((chars(&s, t).as_str(), layer_name(&s, t).as_str()), ("Site em 7 dias", "Title"), "the field keeps its name");
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String> { 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
|
||||
|
||||
@@ -75,6 +75,9 @@ const MAX_CHUNK: u64 = 512 * 1024 * 1024;
|
||||
/// Largest decompressed schema chunk: Figma's schema unpacks to well under 1 MB, so a schema that
|
||||
/// inflates past this is refused before its definitions are decoded.
|
||||
const MAX_SCHEMA: u64 = 16 * 1024 * 1024;
|
||||
/// Longest image hash (bytes) an image is found by: `images/` names and embedded images both
|
||||
/// stop at 64 bytes (128 hex digits).
|
||||
pub(crate) const MAX_IMAGE_HASH: usize = 64;
|
||||
/// Largest image member read.
|
||||
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
|
||||
/// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on
|
||||
@@ -141,10 +144,14 @@ fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result
|
||||
let max_nodes = layer_budget(packed);
|
||||
drop(message);
|
||||
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
|
||||
let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
|
||||
let (mut json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
|
||||
// The message goes before the layers are built, so the images it carries are copied out first.
|
||||
let embedded = embedded_copies(&msg);
|
||||
drop(msg);
|
||||
let aliases = embedded_aliases(&embedded, |hash| archive.has_image(hash));
|
||||
if !aliases.is_empty() {
|
||||
canonical_image_refs(&mut json, &aliases);
|
||||
}
|
||||
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
|
||||
let (mut doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
|
||||
let skipped = archive.image_budget.skipped.get();
|
||||
@@ -227,9 +234,53 @@ fn embedded_copies(msg: &Kv) -> BTreeMap<String, Arc<[u8]>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Embedded images ([`embedded_copies`]) whose blob an earlier hash already names, each with that
|
||||
/// hash. Hashes that also have an `images/` member keep their own name: the member is read first.
|
||||
/// Pointing every alias at one name has the import copy and hash each blob once, however many
|
||||
/// hashes a file points at it.
|
||||
fn embedded_aliases(embedded: &BTreeMap<String, Arc<[u8]>>, in_zip: impl Fn(&str) -> bool) -> BTreeMap<String, String> {
|
||||
let mut first: BTreeMap<usize, &String> = BTreeMap::new();
|
||||
let mut out = BTreeMap::new();
|
||||
for (hash, bytes) in embedded {
|
||||
if in_zip(hash) {
|
||||
continue;
|
||||
}
|
||||
// Hashes naming one blob share its copy, so the copy's address tells the blob.
|
||||
let canonical = *first.entry(bytes.as_ptr() as usize).or_insert(hash);
|
||||
if canonical != hash {
|
||||
out.insert(hash.clone(), canonical.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rename every `imageRef` in REST `json` found in `aliases` to the name it maps to.
|
||||
fn canonical_image_refs(json: &mut serde_json::Value, aliases: &BTreeMap<String, String>) {
|
||||
use serde_json::Value;
|
||||
let mut pending = vec![json];
|
||||
while let Some(value) = pending.pop() {
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
for (key, field) in fields.iter_mut() {
|
||||
match field {
|
||||
Value::String(r) if key == "imageRef" => {
|
||||
if let Some(canonical) = aliases.get(r.as_str()) {
|
||||
r.clone_from(canonical);
|
||||
}
|
||||
}
|
||||
_ => pending.push(field),
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => pending.extend(items.iter_mut()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hash and blob index of an `Image` value whose `dataBlob` points at a usable blob.
|
||||
fn embedded_image(image: &Kv, blobs: &[Kv]) -> Option<(String, usize)> {
|
||||
let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?;
|
||||
let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= MAX_IMAGE_HASH)?;
|
||||
let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None };
|
||||
let index = usize::try_from(*index).ok()?;
|
||||
let bytes = blob(blobs, index)?;
|
||||
@@ -275,10 +326,15 @@ impl<'b> Archive<'b> {
|
||||
Ok(Archive { canvas, file_name, zip: Some(z), image_budget })
|
||||
}
|
||||
|
||||
/// Whether `images/` has a member for image `hash`.
|
||||
fn has_image(&self, hash: &str) -> bool {
|
||||
self.zip.as_ref().is_some_and(|z| z.index_for_name(&format!("images/{hash}")).is_some())
|
||||
}
|
||||
|
||||
/// Bytes of image `hash` (lowercase hex) from `images/`.
|
||||
fn image(&self, hash: &str) -> Option<Vec<u8>> {
|
||||
let budget = &self.image_budget;
|
||||
if hash.is_empty() || hash.len() > 128 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
if hash.is_empty() || hash.len() > 2 * MAX_IMAGE_HASH || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
// A clone shares the parsed directory and the bytes; the resolver only has shared access.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Malformed image paint filters are skipped with a warning.
|
||||
//! Malformed image paint filters are skipped with a warning, and image hashes too long to name
|
||||
//! an image aren't spelled out.
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -25,3 +26,12 @@ fn boolean_filter_values_are_malformed_even_when_false() {
|
||||
assert!(warnings.iter().any(|warning| warning.contains(expected_warning)), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_image_hash_is_not_spelled_out() {
|
||||
let paint = |len: usize| Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("image", Kv::Object(vec![("hash", Kv::Bytes(vec![0xab; len]))]))]);
|
||||
let image_ref = |len: usize| super::paint(&paint(len), &mut |_| {}).and_then(|p| p.get("imageRef").cloned());
|
||||
assert_eq!(image_ref(20), Some(Value::String("ab".repeat(20))));
|
||||
assert_eq!(image_ref(crate::fig::MAX_IMAGE_HASH), Some(Value::String("ab".repeat(crate::fig::MAX_IMAGE_HASH))));
|
||||
assert_eq!(image_ref(crate::fig::MAX_IMAGE_HASH + 1), Some(Value::String(String::new())));
|
||||
}
|
||||
@@ -162,8 +162,17 @@ pub(crate) fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option<Value> {
|
||||
o.insert("gradientStops".into(), Value::Array(stops));
|
||||
}
|
||||
"IMAGE" => {
|
||||
let hash: String =
|
||||
p.get("image").and_then(|i| i.get("hash")).and_then(Kv::bytes).unwrap_or_default().iter().map(|b| format!("{b:02x}")).collect();
|
||||
// A hash longer than any the file's images can be named by resolves to nothing: it
|
||||
// isn't spelled out, which a shared style would repeat for every layer using it.
|
||||
let hash: String = p
|
||||
.get("image")
|
||||
.and_then(|i| i.get("hash"))
|
||||
.and_then(Kv::bytes)
|
||||
.filter(|h| h.len() <= crate::fig::MAX_IMAGE_HASH)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
o.insert("imageRef".into(), json!(hash));
|
||||
let mode = p.get("imageScaleMode").and_then(Kv::str).unwrap_or("FILL");
|
||||
o.insert("scaleMode".into(), json!(mode));
|
||||
|
||||
@@ -795,6 +795,44 @@ fn hashes_naming_one_blob_share_one_copy_of_it() {
|
||||
assert_eq!(*copies[&"63".repeat(20)], [7; 16]);
|
||||
}
|
||||
|
||||
/// Hashes naming one blob are imported as one image: every alias is renamed to the first hash
|
||||
/// before layers are built, so the blob is copied and hashed once, not once per hash.
|
||||
#[test]
|
||||
fn hashes_naming_one_blob_are_imported_once() {
|
||||
let rect = |i: u64, hash: u8, blob: u64| {
|
||||
let paint = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(vec![hash; 20])), ("dataBlob", u(blob))]))]);
|
||||
node(i, Some((1, &format!("{i:04}"))), "RECTANGLE", &format!("R{i}"), vec![("size", xy(10.0, 10.0)), ("fillPaints", arr(vec![paint]))])
|
||||
};
|
||||
let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])];
|
||||
nodes.extend((2..42).map(|i| rect(i, i as u8, 0)));
|
||||
let msg = o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(png()))])]))]);
|
||||
let copies = embedded_copies(&msg);
|
||||
let aliases = embedded_aliases(&copies, |_| false);
|
||||
assert_eq!(aliases.len(), 39);
|
||||
assert!(aliases.values().all(|c| *c == "02".repeat(20)));
|
||||
// A hash `images/` also holds keeps its own name.
|
||||
assert!(!embedded_aliases(&copies, |h| h == "05".repeat(20)).contains_key(&"05".repeat(20)));
|
||||
let (doc, warnings) = import(&canvas(&schema(), &msg), "x").unwrap();
|
||||
assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
assert_eq!(doc.images.len(), 1);
|
||||
let key = doc.images.keys().next().unwrap();
|
||||
for i in 2..42 {
|
||||
let figcraft_doc::color::PaintKind::Image(img) = &named(&doc, &format!("R{i}")).fills[0].kind else { panic!("not an image fill") };
|
||||
assert_eq!(&img.image, key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_refs_are_renamed_wherever_they_sit() {
|
||||
let aliases: BTreeMap<String, String> = [("b".to_string(), "a".to_string())].into();
|
||||
let mut json = serde_json::json!({"fills": [{"imageRef": "b"}, {"imageRef": "c"}], "styles": {"s": {"fills": [{"imageRef": "b"}]}}, "name": "b"});
|
||||
canonical_image_refs(&mut json, &aliases);
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({"fills": [{"imageRef": "a"}, {"imageRef": "c"}], "styles": {"s": {"fills": [{"imageRef": "a"}]}}, "name": "b"})
|
||||
);
|
||||
}
|
||||
|
||||
mod image_budget {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ pub fn effects(n: &Node, frame: EffectFrame<'_>) -> Option<Pixmap> {
|
||||
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() {
|
||||
|
||||
@@ -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<u32>, ys: std::ops::Range<u32>| {
|
||||
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());
|
||||
|
||||
+724
-102
File diff suppressed because it is too large.
Load diff
@@ -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<u8> = 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<u8> = 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");
|
||||
}
|
||||
|
||||
+65
-21
@@ -3,22 +3,25 @@
|
||||
//! `CRAFT_FONTS` is empty and native apps use system fonts. Required web releases enforce Inter
|
||||
//! Regular/SemiBold and Noto Sans Georgian Regular after filtering. Recipe: craft-fonts `docs/integration.md`.
|
||||
//! Noto Sans CJK only with `FIGCRAFT_EMBED_CJK` set (the web build's only way to show CJK UI text).
|
||||
//! `FIGCRAFT_WEB_FONTS` (comma-separated families) adds manifest families to the web build.
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Font build script: emit `craft_fonts.rs` from `input_fonts`, passing the
|
||||
/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target and `FIGCRAFT_EMBED_CJK` inputs;
|
||||
/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target, `FIGCRAFT_EMBED_CJK` and `FIGCRAFT_WEB_FONTS` inputs;
|
||||
/// report generation and write failures with `cargo::error`.
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
println!("cargo::rerun-if-env-changed=FIGCRAFT_EMBED_CJK");
|
||||
println!("cargo::rerun-if-env-changed=FIGCRAFT_WEB_FONTS");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from);
|
||||
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let embed_cjk = std::env::var_os("FIGCRAFT_EMBED_CJK").is_some();
|
||||
match input_fonts(dir.as_deref(), wasm, required, embed_cjk) {
|
||||
let web_fonts = web_fonts(&std::env::var("FIGCRAFT_WEB_FONTS").unwrap_or_default());
|
||||
match input_fonts(dir.as_deref(), wasm, required, embed_cjk, &web_fonts) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) => println!("cargo::error={e}"),
|
||||
}
|
||||
@@ -29,21 +32,37 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The families `FIGCRAFT_WEB_FONTS` names: comma-separated, trimmed, empty names dropped.
|
||||
fn web_fonts(list: &str) -> Vec<String> {
|
||||
list.split(',').map(str::trim).filter(|f| !f.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
/// The families in `web_fonts` that `faces` has no face of, in the order named.
|
||||
fn not_embedded<'a>(web_fonts: &'a [String], faces: &[(String, String)]) -> Vec<&'a str> {
|
||||
web_fonts.iter().map(String::as_str).filter(|w| !faces.iter().any(|(family, _)| family == w)).collect()
|
||||
}
|
||||
|
||||
/// Collect the effective catalog and enforce the required web release faces after filtering.
|
||||
fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_cjk: bool) -> Result<String, String> {
|
||||
/// Families named in `web_fonts` that nothing embedded get a warning (most likely misspelt).
|
||||
fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_cjk: bool, web_fonts: &[String]) -> Result<String, String> {
|
||||
let Some(dir) = dir else {
|
||||
for family in web_fonts {
|
||||
println!("cargo::warning=FIGCRAFT_WEB_FONTS: {family:?} isn't embedded: this build has no craft-fonts");
|
||||
}
|
||||
return if required { Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()) } else { Ok(String::new()) };
|
||||
};
|
||||
let mut out = String::new();
|
||||
let mut faces = Vec::new();
|
||||
let mut loaded = false;
|
||||
for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] {
|
||||
let path = dir.join(manifest);
|
||||
if manifest == "fonts/latin-manifest.txt" && !path.exists() {
|
||||
watch(&path);
|
||||
continue;
|
||||
}
|
||||
match craft_fonts(dir, manifest, wasm, embed_cjk) {
|
||||
match craft_fonts(dir, manifest, wasm, embed_cjk, web_fonts) {
|
||||
Ok(catalog) => {
|
||||
loaded = true;
|
||||
out.push_str(&catalog.entries);
|
||||
faces.extend(catalog.faces);
|
||||
}
|
||||
@@ -51,6 +70,11 @@ fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_
|
||||
Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
let why =
|
||||
if loaded { "no manifest row was kept for it (misspelt? CJK also needs FIGCRAFT_EMBED_CJK)" } else { "no craft-fonts manifest was read" };
|
||||
for family in not_embedded(web_fonts, &faces) {
|
||||
println!("cargo::warning=FIGCRAFT_WEB_FONTS: {family:?} isn't embedded: {why}");
|
||||
}
|
||||
if required && wasm {
|
||||
for (family, style) in [("Inter", "Regular"), ("Inter", "SemiBold"), ("Noto Sans Georgian", "Regular")] {
|
||||
if !faces.iter().any(|(f, s)| f == family && s == style) {
|
||||
@@ -82,9 +106,9 @@ struct Catalog {
|
||||
|
||||
/// Parse a font manifest of `family | style | file | scripts` rows into `CraftFont`
|
||||
/// entries: skip Adobe-authored families, skip CJK unless requested, keep the web catalog
|
||||
/// to its budgeted families, canonicalize and watch each face, and emit `include_bytes!`
|
||||
/// items.
|
||||
fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result<Catalog, String> {
|
||||
/// to its budgeted families and the ones `web_fonts` adds, canonicalize and watch each face, and
|
||||
/// emit `include_bytes!` items.
|
||||
fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool, web_fonts: &[String]) -> Result<Catalog, String> {
|
||||
let manifest = dir.join(manifest_rel);
|
||||
watch(&manifest);
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
@@ -105,9 +129,11 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk:
|
||||
continue;
|
||||
}
|
||||
// Web builds have a size budget: Inter, Noto Sans Georgian (small, for the Georgian UI), only the
|
||||
// SemiBold wordmark, and optionally Noto Sans CJK (every CJK script, for CJK UI text).
|
||||
// SemiBold wordmark, optionally Noto Sans CJK (every CJK script, for CJK UI text), and the
|
||||
// families a distribution names in `FIGCRAFT_WEB_FONTS` (a Thai face for Thai text, say).
|
||||
let wordmark = *family == "Archivo" && *style == "SemiBold";
|
||||
if wasm && !matches!(*family, "Inter" | "Noto Sans Georgian") && !wordmark && !cjk {
|
||||
let added = web_fonts.iter().any(|f| f == family);
|
||||
if wasm && !matches!(*family, "Inter" | "Noto Sans Georgian") && !wordmark && !cjk && !added {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
@@ -161,9 +187,9 @@ mod tests {
|
||||
fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() {
|
||||
let f = Fixture::new();
|
||||
std::fs::write(f.0.join("fonts/latin-manifest.txt"), include_str!("tests/fixtures/latin-families.txt")).unwrap();
|
||||
let native = craft_fonts(&f.0, "fonts/latin-manifest.txt", false, false).unwrap();
|
||||
let native = craft_fonts(&f.0, "fonts/latin-manifest.txt", false, false, &[]).unwrap();
|
||||
assert_eq!(native.faces.len(), 43, "all prior permitted presentation faces remain available");
|
||||
let web = craft_fonts(&f.0, "fonts/latin-manifest.txt", true, false).unwrap();
|
||||
let web = craft_fonts(&f.0, "fonts/latin-manifest.txt", true, false, &[]).unwrap();
|
||||
assert_eq!(web.faces.len(), 4, "the web catalog keeps only the four Inter faces");
|
||||
for catalog in [&native, &web] {
|
||||
assert!(!catalog.entries.contains("Source Sans 3"));
|
||||
@@ -171,16 +197,16 @@ mod tests {
|
||||
}
|
||||
assert!(native.faces.iter().any(|(family, _)| family == "Carlito"));
|
||||
assert!(native.faces.iter().any(|(family, _)| family == "Liberation Serif"));
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).is_ok());
|
||||
assert!(input_fonts(Some(&f.0), true, true, false, &[]).is_ok());
|
||||
}
|
||||
|
||||
/// Asserts required web input rejects a catalog missing Inter Regular or Inter SemiBold.
|
||||
#[test]
|
||||
fn required_web_rejects_core_only_and_partial_latin_catalogs() {
|
||||
let f = Fixture::new();
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter Regular"));
|
||||
assert!(input_fonts(Some(&f.0), true, true, false, &[]).unwrap_err().contains("Inter Regular"));
|
||||
f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Unrelated", "SemiBold")]);
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold"));
|
||||
assert!(input_fonts(Some(&f.0), true, true, false, &[]).unwrap_err().contains("Inter SemiBold"));
|
||||
}
|
||||
|
||||
/// Asserts required web input embeds the Inter and Georgian catalogs (three entries) and
|
||||
@@ -189,12 +215,12 @@ mod tests {
|
||||
fn required_web_embeds_both_catalogs_and_requires_georgian() {
|
||||
let f = Fixture::new();
|
||||
f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]);
|
||||
let entries = input_fonts(Some(&f.0), true, true, false).unwrap();
|
||||
let entries = input_fonts(Some(&f.0), true, true, false, &[]).unwrap();
|
||||
assert_eq!(entries.matches("CraftFont {").count(), 3);
|
||||
assert!(entries.contains("family: \"Inter\""));
|
||||
assert!(entries.contains("family: \"Noto Sans Georgian\""));
|
||||
f.manifest("manifest.txt", &[("Other", "Regular")]);
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular"));
|
||||
assert!(input_fonts(Some(&f.0), true, true, false, &[]).unwrap_err().contains("Noto Sans Georgian Regular"));
|
||||
}
|
||||
|
||||
/// Asserts absent font input is empty when optional and an error when required, while present
|
||||
@@ -202,10 +228,10 @@ mod tests {
|
||||
#[test]
|
||||
fn optional_and_native_inputs_keep_their_existing_policy() {
|
||||
let f = Fixture::new();
|
||||
assert!(input_fonts(None, true, false, false).unwrap().is_empty());
|
||||
assert!(input_fonts(None, true, true, false).unwrap_err().contains("CRAFT_FONTS_DIR is missing"));
|
||||
assert!(input_fonts(Some(&f.0), true, false, false).unwrap().contains("Noto Sans Georgian"));
|
||||
assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian"));
|
||||
assert!(input_fonts(None, true, false, false, &[]).unwrap().is_empty());
|
||||
assert!(input_fonts(None, true, true, false, &[]).unwrap_err().contains("CRAFT_FONTS_DIR is missing"));
|
||||
assert!(input_fonts(Some(&f.0), true, false, false, &[]).unwrap().contains("Noto Sans Georgian"));
|
||||
assert!(input_fonts(Some(&f.0), false, true, false, &[]).unwrap().contains("Noto Sans Georgian"));
|
||||
}
|
||||
|
||||
/// Asserts required web input rejects a manifest whose face file is missing, even when every
|
||||
@@ -215,6 +241,24 @@ mod tests {
|
||||
let f = Fixture::new();
|
||||
f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]);
|
||||
std::fs::remove_file(f.0.join("fonts/face.ttf")).unwrap();
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("fonts/face.ttf"));
|
||||
assert!(input_fonts(Some(&f.0), true, true, false, &[]).unwrap_err().contains("fonts/face.ttf"));
|
||||
}
|
||||
|
||||
/// Asserts `FIGCRAFT_WEB_FONTS` (split on commas, trimmed) adds the families it names to the web
|
||||
/// catalog only, leaves native and unset builds as they were, and reports a family nothing
|
||||
/// embedded.
|
||||
#[test]
|
||||
fn web_fonts_add_the_families_they_name_to_the_web_catalog() {
|
||||
let f = Fixture::new();
|
||||
f.manifest("manifest.txt", &[("Noto Sans Georgian", "Regular"), ("Noto Sans Thai", "Regular"), ("Noto Sans Hebrew", "Regular")]);
|
||||
let named = web_fonts(" Noto Sans Thai, ,Noto Sans Thia ");
|
||||
assert_eq!(named, ["Noto Sans Thai", "Noto Sans Thia"]);
|
||||
let families = |c: &Catalog| c.faces.iter().map(|(family, _)| family.clone()).collect::<Vec<_>>();
|
||||
let web = craft_fonts(&f.0, "fonts/manifest.txt", true, false, &named).unwrap();
|
||||
assert_eq!(families(&web), ["Noto Sans Georgian", "Noto Sans Thai"], "the named family joins; the others stay out");
|
||||
assert_eq!(families(&craft_fonts(&f.0, "fonts/manifest.txt", true, false, &[]).unwrap()), ["Noto Sans Georgian"], "unset: as before");
|
||||
assert_eq!(craft_fonts(&f.0, "fonts/manifest.txt", false, false, &[]).unwrap().faces.len(), 3, "native builds embed every family");
|
||||
assert_eq!(not_embedded(&named, &web.faces), ["Noto Sans Thia"], "a misspelt family is reported");
|
||||
assert!(input_fonts(Some(&f.0), true, false, false, &named).unwrap().contains("family: \"Noto Sans Thai\""));
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
|
||||
@@ -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<Node> {
|
||||
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<Node> {
|
||||
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<f32> = ["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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -187,13 +187,18 @@ fn setup() -> (Ui, NodeId, NodeId) {
|
||||
(u, a, b)
|
||||
}
|
||||
|
||||
/// The field named `name` in the Design panel: the middle of its value, and what it shows.
|
||||
/// The topmost field named `name` in the Design panel: its value centre and displayed text.
|
||||
/// Layer opacity precedes the equally named opacity fields in paint rows.
|
||||
fn field(u: &mut Ui, name: &str) -> (Pos2, String) {
|
||||
u.frame(vec![]);
|
||||
let out = u.frame_output(vec![]);
|
||||
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
|
||||
let n =
|
||||
nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::SpinButton && n.label() == Some(name)).unwrap_or_else(|| panic!("no field {name:?}"));
|
||||
let n = nodes
|
||||
.iter()
|
||||
.map(|(_, n)| n)
|
||||
.filter(|n| n.role() == Role::SpinButton && n.label() == Some(name))
|
||||
.min_by(|a, b| a.bounds().map_or(f64::INFINITY, |r| r.y0).total_cmp(&b.bounds().map_or(f64::INFINITY, |r| r.y0)))
|
||||
.unwrap_or_else(|| panic!("no field {name:?}"));
|
||||
let b = n.bounds().expect("bounds");
|
||||
(pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32), n.value().unwrap_or_default().to_string())
|
||||
}
|
||||
@@ -203,11 +208,26 @@ fn label(u: &mut Ui, name: &str) -> Pos2 {
|
||||
u.frame(vec![]);
|
||||
let out = u.frame_output(vec![]);
|
||||
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
|
||||
let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::SpinButton && n.label() == Some(name)).unwrap();
|
||||
let n = nodes
|
||||
.iter()
|
||||
.map(|(_, n)| n)
|
||||
.filter(|n| n.role() == Role::SpinButton && n.label() == Some(name))
|
||||
.min_by(|a, b| a.bounds().map_or(f64::INFINITY, |r| r.y0).total_cmp(&b.bounds().map_or(f64::INFINITY, |r| r.y0)))
|
||||
.unwrap();
|
||||
let b = n.bounds().unwrap();
|
||||
pos2(b.x0 as f32 - 12.0, ((b.y0 + b.y1) / 2.0) as f32)
|
||||
}
|
||||
|
||||
/// Layer and fill opacity are both named for assistive technology, even without a visible lead.
|
||||
#[test]
|
||||
fn paint_opacity_has_an_accessible_name() {
|
||||
let (mut u, _, _) = setup();
|
||||
let output = u.frame_output(vec![]);
|
||||
let nodes = output.platform_output.accesskit_update.unwrap().nodes;
|
||||
let opacity_fields = nodes.iter().filter(|(_, n)| n.role() == Role::SpinButton && n.label() == Some("Opacity")).count();
|
||||
assert_eq!(opacity_fields, 2, "both layer and fill opacity must have an accessible name");
|
||||
}
|
||||
|
||||
fn geom(u: &Ui, n: NodeId) -> (f64, f64, f64, f64) {
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let b = doc.world_bounds(n).unwrap();
|
||||
|
||||
@@ -29,6 +29,8 @@ pub use draw::{paint, paint_outlines};
|
||||
|
||||
icons! {
|
||||
Move,
|
||||
/// Select layers using a colour: selection corners around a colour swatch.
|
||||
SelectColor,
|
||||
Scale,
|
||||
FitContents,
|
||||
Hand,
|
||||
@@ -378,6 +380,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<String> = 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::<Vec<_>>())
|
||||
})
|
||||
.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)];
|
||||
|
||||
@@ -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();
|
||||
@@ -280,6 +285,13 @@ fn draw(p: &P, icon: Icon) {
|
||||
use Icon::*;
|
||||
match icon {
|
||||
Move => p.fill(&[(4.0, 2.0), (4.0, 13.6), (6.9, 10.9), (8.8, 14.8), (10.6, 14.0), (8.7, 10.2), (12.6, 10.2)]),
|
||||
SelectColor => {
|
||||
p.poly(&[(2.5, 5.5), (2.5, 2.5), (5.5, 2.5)], false);
|
||||
p.poly(&[(10.5, 2.5), (13.5, 2.5), (13.5, 5.5)], false);
|
||||
p.poly(&[(2.5, 10.5), (2.5, 13.5), (5.5, 13.5)], false);
|
||||
p.poly(&[(10.5, 13.5), (13.5, 13.5), (13.5, 10.5)], false);
|
||||
p.circle(8.0, 8.0, 2.5);
|
||||
}
|
||||
Scale => {
|
||||
p.rect(2.5, 7.5, 8.5, 13.5, 0.0);
|
||||
p.line(6.0, 10.0, 13.0, 3.0);
|
||||
@@ -386,11 +398,20 @@ fn draw(p: &P, icon: Icon) {
|
||||
ChevronUp => p.poly(&[(4.5, 10.0), (8.0, 6.5), (11.5, 10.0)], false),
|
||||
ChevronRight => p.poly(&[(6.0, 4.5), (9.5, 8.0), (6.0, 11.5)], false),
|
||||
ChevronLeft => p.poly(&[(10.0, 4.5), (6.5, 8.0), (10.0, 11.5)], false),
|
||||
Plus => {
|
||||
p.line(7.5, 2.0, 7.5, 13.0);
|
||||
p.line(2.0, 7.5, 13.0, 7.5);
|
||||
Plus | Minus => {
|
||||
// Snap the shared centre once, then give every arm the same pixel length.
|
||||
// Snapping each line's ends separately can offset its midpoint at small sizes.
|
||||
let c = p.sp(8.0, 8.0);
|
||||
let half = p.stroke.width / 2.0;
|
||||
let reach = (5.0 * p.s / p.px).round() * p.px + half;
|
||||
let mut mesh = Mesh::default();
|
||||
mesh.add_colored_rect(Rect::from_min_max(c - vec2(reach, half), c + vec2(reach, half)), p.color);
|
||||
if icon == Plus {
|
||||
mesh.add_colored_rect(Rect::from_min_max(c - vec2(half, reach), c + vec2(half, -half)), p.color);
|
||||
mesh.add_colored_rect(Rect::from_min_max(c + vec2(-half, half), c + vec2(half, reach)), p.color);
|
||||
}
|
||||
p.painter.add(Shape::mesh(mesh));
|
||||
}
|
||||
Minus => p.line(2.0, 7.5, 13.0, 7.5),
|
||||
Eye | EyeOff => {
|
||||
// An almond (two arcs meeting at the corners) with the pupil.
|
||||
let (r, cy, half) = (7.0f32, 7.5f32, 5.5f32);
|
||||
@@ -427,24 +448,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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -17,6 +17,7 @@ type Queue = Arc<Mutex<Vec<(u64, LiveEvent)>>>;
|
||||
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<SessionInfo, String> {
|
||||
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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<SessionInfo, String> {
|
||||
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.
|
||||
|
||||
@@ -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() }] }]);
|
||||
}
|
||||
|
||||
@@ -129,9 +129,6 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, 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<u8>, f64, f64)> {
|
||||
const MAX_PASTED_IMAGE_PIXELS: u64 = 100_000_000;
|
||||
|
||||
fn with_pixel_size(png: Vec<u8>) -> Option<(Vec<u8>, (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<Vec<u8>> {
|
||||
|
||||
@@ -48,6 +48,7 @@ type Shared = Arc<Mutex<Script>>;
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<figcraft_doc::NodeId>) {
|
||||
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");
|
||||
}
|
||||
@@ -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};
|
||||
@@ -89,12 +89,18 @@ impl FigApp {
|
||||
|
||||
/// Apply a field edit through any command: `params` builds the command's params from the value.
|
||||
pub fn field_run(&mut self, edit: Option<FieldEdit>, cmd: &str, params: impl Fn(f64) -> Value) {
|
||||
self.field_run_named(edit, "Edit", cmd, params);
|
||||
}
|
||||
|
||||
/// Apply a field edit with a specific undo label for scrubs; commit only on release.
|
||||
/// Typed edits use `run`; scrub errors are discarded as in `field_run`.
|
||||
pub(crate) fn field_run_named(&mut self, edit: Option<FieldEdit>, label: &str, cmd: &str, params: impl Fn(f64) -> Value) {
|
||||
match edit {
|
||||
Some(FieldEdit::Set(v)) => {
|
||||
self.run(cmd, params(v));
|
||||
}
|
||||
Some(FieldEdit::Scrub(v, done)) => {
|
||||
let _ = self.session.begin_interaction("Edit");
|
||||
let _ = self.session.begin_interaction(label);
|
||||
let _ = self.session.execute(cmd, ¶ms(v));
|
||||
if done {
|
||||
let _ = self.session.commit_interaction();
|
||||
@@ -483,18 +489,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();
|
||||
|
||||
@@ -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<crate::icons::Icon>, on: Option<bool>, 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()
|
||||
}
|
||||
@@ -446,78 +446,62 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
|
||||
let cmd = if fills { "fill.set" } else { "stroke.set" };
|
||||
let r = row_rect(ui, 32.0, |ui| {
|
||||
let w = ui.available_width() - 60.0;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
|
||||
let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));
|
||||
widgets::paint_swatch(ui.painter(), sw, p);
|
||||
let sresp = ui.interact(sw.expand(2.0), Id::unique(("swatch", fills, i)), Sense::click());
|
||||
app.picker_control(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, &sresp);
|
||||
let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
|
||||
// The variable bound to this paint's colour (its key on the layers: `fill` or `fills/<i>/color`).
|
||||
let bound = app.session.doc().ok().and_then(|d| {
|
||||
let key = ids.first().and_then(|id| d.node(*id)).and_then(|n| figcraft_engine::cmd::lists::paint_binding_key(n, fills, i))?;
|
||||
Some((key.to_string(), crate::variable_ui::bound_name(d, &ids, key)?))
|
||||
});
|
||||
match &p.kind {
|
||||
PaintKind::Solid { .. } if bound.is_some() => {
|
||||
let (key, name) = bound.clone().unwrap_or_default();
|
||||
let chip = Rect::from_min_max(pos2(text_r.min.x - 2.0, r.min.y + 3.0), pos2(text_r.max.x - 20.0, r.max.y - 3.0));
|
||||
child.painter().rect_filled(chip, CornerRadius::same(4), t.hover);
|
||||
child.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let short = name.rsplit('/').next().unwrap_or(&name).to_string();
|
||||
child.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).text(
|
||||
pos2(chip.min.x + 5.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
short,
|
||||
regular(11.0),
|
||||
t.text,
|
||||
);
|
||||
let _ = ui.interact(chip, Id::unique(("bound_paint", fills, i)), Sense::hover()).on_hover_text(&name);
|
||||
let dr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0));
|
||||
let dresp = ui.interact(dr, Id::unique(("detach_paint", fills, i)), Sense::click()).tip(tr("Detach variable"));
|
||||
icons::paint(ui.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary);
|
||||
if dresp.clicked() {
|
||||
app.run("variable.bind", json!({"ids": ids, "field": key, "variable": null}));
|
||||
let (sresp, e) = widgets::paint_field(ui, Id::unique(("swatch", fills, i)), Id::unique(("pop", fills, i)), p, w, |child, text_r| {
|
||||
let r = child.max_rect();
|
||||
// The variable bound to this paint's colour (its key on the layers: `fill` or `fills/<i>/color`).
|
||||
let bound = app.session.doc().ok().and_then(|d| {
|
||||
let key = ids.first().and_then(|id| d.node(*id)).and_then(|n| figcraft_engine::cmd::lists::paint_binding_key(n, fills, i))?;
|
||||
Some((key.to_string(), crate::variable_ui::bound_name(d, &ids, key)?))
|
||||
});
|
||||
match &p.kind {
|
||||
PaintKind::Solid { .. } if bound.is_some() => {
|
||||
let (key, name) = bound.clone().unwrap_or_default();
|
||||
let chip = Rect::from_min_max(pos2(text_r.min.x - 2.0, r.min.y + 3.0), pos2(text_r.max.x - 20.0, r.max.y - 3.0));
|
||||
child.painter().rect_filled(chip, CornerRadius::same(4), t.hover);
|
||||
child.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let short = name.rsplit('/').next().unwrap_or(&name).to_string();
|
||||
child.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).text(
|
||||
pos2(chip.min.x + 5.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
short,
|
||||
regular(11.0),
|
||||
t.text,
|
||||
);
|
||||
let _ = child.interact(chip, Id::unique(("bound_paint", fills, i)), Sense::hover()).on_hover_text(&name);
|
||||
let dr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0));
|
||||
let dresp = child.interact(dr, Id::unique(("detach_paint", fills, i)), Sense::click()).tip(tr("Detach variable"));
|
||||
icons::paint(child.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary);
|
||||
if dresp.clicked() {
|
||||
app.run("variable.bind", json!({"ids": ids, "field": key, "variable": null}));
|
||||
}
|
||||
}
|
||||
}
|
||||
PaintKind::Solid { color } => {
|
||||
if let Some(h) = widgets::text_field_inline(
|
||||
&mut child,
|
||||
Id::unique(("hex", fills, i)),
|
||||
&color.to_hex(),
|
||||
text_r.shrink2(vec2(0.0, 4.0)),
|
||||
regular(11.0),
|
||||
) && let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
|
||||
{
|
||||
app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()}));
|
||||
PaintKind::Solid { color } => {
|
||||
if let Some(h) = widgets::text_field_inline(
|
||||
child,
|
||||
Id::unique(("hex", fills, i)),
|
||||
&color.to_hex(),
|
||||
text_r.shrink2(vec2(0.0, 4.0)),
|
||||
regular(11.0),
|
||||
) && let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
|
||||
{
|
||||
app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text);
|
||||
// One layer's image fill: crop it on the canvas.
|
||||
if fills && ids.len() == 1 && matches!(p.kind, PaintKind::Image(_)) {
|
||||
let cr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0));
|
||||
let cresp = ui.interact(cr, Id::unique(("crop_image", i)), Sense::click()).tip(tr("Crop image"));
|
||||
icons::paint(ui.painter(), cr.shrink(3.0), Icon::Crop, if cresp.hovered() { t.icon } else { t.icon_secondary });
|
||||
if cresp.clicked() {
|
||||
app.run("image.crop", json!({"ids": ids, "index": i}));
|
||||
_ => {
|
||||
child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text);
|
||||
// One layer's image fill: crop it on the canvas.
|
||||
if fills && ids.len() == 1 && matches!(p.kind, PaintKind::Image(_)) {
|
||||
let cr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0));
|
||||
let cresp = child.interact(cr, Id::unique(("crop_image", i)), Sense::click()).tip(tr("Crop image"));
|
||||
icons::paint(child.painter(), cr.shrink(3.0), Icon::Crop, if cresp.hovered() { t.icon } else { t.icon_secondary });
|
||||
if cresp.clicked() {
|
||||
app.run("image.crop", json!({"ids": ids, "index": i}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max);
|
||||
ui.painter().line_segment([pos2(op_r.min.x, r.min.y + 4.0), pos2(op_r.min.x, r.max.y - 4.0)], Stroke::new(1.0, t.panel));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(op_r));
|
||||
let e = widgets::number_field(
|
||||
&mut child,
|
||||
Id::unique(("pop", fills, i)),
|
||||
Lead::None,
|
||||
Some((f64::from(p.opacity) * 100.0).round()),
|
||||
"%",
|
||||
48.0,
|
||||
1.0,
|
||||
);
|
||||
});
|
||||
app.picker_control(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, &sresp);
|
||||
match e {
|
||||
Some(FieldEdit::Set(v)) => {
|
||||
app.run(cmd, json!({"ids": ids, "index": i, "opacity": (v / 100.0).clamp(0.0, 1.0)}));
|
||||
|
||||
@@ -24,6 +24,12 @@ struct Cache {
|
||||
colors: Arc<Vec<SelectionColor>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OpacityScrub {
|
||||
targets: Vec<PaintTarget>,
|
||||
opacity: f32,
|
||||
}
|
||||
|
||||
/// Return the cached selection colours when the document and selection match. During an
|
||||
/// interaction reuse an existing matching cache, or return an empty list without discovering
|
||||
/// colours; otherwise discover and cache them.
|
||||
@@ -47,6 +53,7 @@ fn colors(ui: &Ui, document: u64, doc: &Arc<Document>, ids: &[NodeId], interacti
|
||||
colors
|
||||
}
|
||||
|
||||
/// Open or close the picker for frozen paint targets, anchored to the invoking control.
|
||||
pub(crate) fn toggle(app: &mut FigApp, targets: Vec<PaintTarget>, response: Option<&egui::Response>) {
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
let target = PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid };
|
||||
@@ -70,97 +77,139 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
|
||||
let t = Tokens::get(ui.ctx());
|
||||
widgets::section_header(ui, tr("Selection colors"), &[], false);
|
||||
// Bound the panel's height; a document with many distinct colors stays scrollable.
|
||||
ui.add_enabled_ui(!interacting, |ui| {
|
||||
let scrub_key = Id::unique("selection_color_scrub");
|
||||
if !interacting {
|
||||
ui.data_mut(|d| d.remove::<OpacityScrub>(scrub_key));
|
||||
}
|
||||
let scrubbing = ui.data(|d| d.get_temp::<OpacityScrub>(scrub_key));
|
||||
ui.add_enabled_ui(!interacting || scrubbing.is_some(), |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("selection_colors_rows").max_height(240.0).show_rows(ui, 32.0, colors.len(), |ui, range| {
|
||||
for group in &colors[range] {
|
||||
ui.push_id((&group.targets, "selection_color"), |ui| {
|
||||
crate::right::row(ui, 32.0, |ui| {
|
||||
let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour"));
|
||||
toggle(app, group.targets.clone(), Some(&swatch));
|
||||
let width = (ui.available_width() - 116.0).max(40.0);
|
||||
let linked_name = group
|
||||
.style
|
||||
.as_ref()
|
||||
.and_then(|id| doc.style(id).map(|s| s.name.as_str()))
|
||||
.or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get_by_id(id).map(|v| v.name.as_str())));
|
||||
if let Some(name) = linked_name {
|
||||
let response = widgets::dropdown(ui, name, width, None);
|
||||
toggle(app, group.targets.clone(), Some(&response));
|
||||
} else if let Some(color) = group.paint.solid_color() {
|
||||
if let Some(hex) = widgets::text_field_named(ui, ui.scope_id().with("hex"), &color.to_hex(), width, tr("Color")) {
|
||||
if let Some(color) = Color::from_hex(&hex) {
|
||||
let mut paint = group.paint.clone();
|
||||
paint.kind = figcraft_color::PaintKind::Solid { color: color.with_alpha(1.0) };
|
||||
if color.a < 1.0 {
|
||||
paint.opacity = color.a;
|
||||
}
|
||||
app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint}));
|
||||
} else {
|
||||
app.toast(tr("Invalid color"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None);
|
||||
toggle(app, group.targets.clone(), Some(&response));
|
||||
}
|
||||
let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0));
|
||||
if let Some(value) = widgets::text_field_named(ui, ui.scope_id().with("opacity"), &alpha, 44.0, tr("Opacity"))
|
||||
&& let Ok(value) = value.trim().trim_end_matches('%').parse::<f32>()
|
||||
&& value.is_finite()
|
||||
let width = ui.available_width() - 60.0;
|
||||
let mut paint = group.paint.clone();
|
||||
if let Some(scrub) = &scrubbing
|
||||
&& scrub.targets == group.targets
|
||||
{
|
||||
let mut paint = group.paint.clone();
|
||||
paint.opacity = (value / 100.0).clamp(0.0, 1.0);
|
||||
app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint}));
|
||||
paint.opacity = scrub.opacity;
|
||||
}
|
||||
if linked_name.is_some() {
|
||||
let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") };
|
||||
if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() {
|
||||
app.run("selection.detachColor", json!({"targets": group.targets}));
|
||||
}
|
||||
} else {
|
||||
let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles"));
|
||||
crate::right::menu_popup(&styles).show(|ui| {
|
||||
let mut any = false;
|
||||
for style in &doc.styles {
|
||||
if !matches!(style.kind, StyleKind::Paint { .. }) {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
if crate::right::menu_item(ui, &style.name, "") {
|
||||
app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id}));
|
||||
app.ui.picker = None;
|
||||
ui.close();
|
||||
let mut name_response = None;
|
||||
let (swatch, opacity) = widgets::paint_field(
|
||||
ui,
|
||||
ui.scope_id().with("paint_field"),
|
||||
ui.scope_id().with("opacity"),
|
||||
&paint,
|
||||
width,
|
||||
|child, rect| {
|
||||
if linked_name.is_some() || group.paint.solid_color().is_none() {
|
||||
let name = linked_name.map(str::to_owned).unwrap_or_else(|| widgets::paint_name(&group.paint));
|
||||
child.painter().with_clip_rect(rect).text(
|
||||
rect.left_center(),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&name,
|
||||
crate::theme::regular(11.0),
|
||||
t.text,
|
||||
);
|
||||
name_response = Some(child.interact(rect, child.scope_id().with("paint_name"), egui::Sense::click()).tip(name));
|
||||
} else if let Some(color) = group.paint.solid_color()
|
||||
&& let Some(hex) = widgets::text_field_inline(
|
||||
child,
|
||||
child.scope_id().with("hex"),
|
||||
&color.to_hex(),
|
||||
rect.shrink2(egui::vec2(0.0, 4.0)),
|
||||
crate::theme::regular(11.0),
|
||||
)
|
||||
{
|
||||
if let Some(color) = Color::from_hex_or_name(&hex) {
|
||||
let mut paint = group.paint.clone();
|
||||
paint.kind = figcraft_color::PaintKind::Solid { color: color.with_alpha(1.0) };
|
||||
if color.a < 1.0 {
|
||||
paint.opacity = color.a;
|
||||
}
|
||||
app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint}));
|
||||
} else {
|
||||
app.toast(tr("Invalid color"));
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
widgets::label(ui, tr("No color styles"), t.text_secondary);
|
||||
}
|
||||
let variables: Vec<_> = doc
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
v.kind == VariableType::Color
|
||||
&& bind_refusal(doc, &group.targets, &v.id).is_none()
|
||||
&& in_scope(doc, &group.targets, &v.id)
|
||||
})
|
||||
.collect();
|
||||
if !variables.is_empty() {
|
||||
ui.separator();
|
||||
widgets::label(ui, tr("Color variables"), t.text_secondary);
|
||||
}
|
||||
for v in variables {
|
||||
if crate::right::menu_item(ui, &v.name, "") {
|
||||
app.run("selection.bindColorVariable", json!({"targets": group.targets, "variable": v.id}));
|
||||
app.ui.picker = None;
|
||||
ui.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
toggle(app, group.targets.clone(), Some(&swatch));
|
||||
if let Some(response) = name_response {
|
||||
toggle(app, group.targets.clone(), Some(&response));
|
||||
}
|
||||
if let Some(edit) = opacity {
|
||||
let (value, scrub, done) = match edit {
|
||||
widgets::FieldEdit::Set(value) => (value, false, false),
|
||||
widgets::FieldEdit::Scrub(value, done) => (value, true, done),
|
||||
};
|
||||
let mut paint = group.paint.clone();
|
||||
paint.opacity = (value / 100.0).clamp(0.0, 1.0) as f32;
|
||||
ui.data_mut(|d| {
|
||||
if scrub && !done {
|
||||
d.insert_temp(scrub_key, OpacityScrub { targets: group.targets.clone(), opacity: paint.opacity });
|
||||
} else {
|
||||
d.remove::<OpacityScrub>(scrub_key);
|
||||
}
|
||||
});
|
||||
app.field_run_named(Some(edit), "Opacity", "selection.setPaint", |_| json!({"targets": group.targets, "paint": paint}));
|
||||
}
|
||||
if icons::button(ui, Icon::Move, 24.0, false, tr("Select layers with this color")).clicked() {
|
||||
app.run("selection.selectColor", json!({"targets": group.targets}));
|
||||
}
|
||||
widgets::tight_end(ui, |ui| {
|
||||
if linked_name.is_some() {
|
||||
let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") };
|
||||
if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() {
|
||||
app.run("selection.detachColor", json!({"targets": group.targets}));
|
||||
}
|
||||
} else {
|
||||
let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles"));
|
||||
crate::right::menu_popup(&styles).show(|ui| {
|
||||
let mut any = false;
|
||||
for style in &doc.styles {
|
||||
if !matches!(style.kind, StyleKind::Paint { .. }) {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
if crate::right::menu_item(ui, &style.name, "") {
|
||||
app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id}));
|
||||
app.ui.picker = None;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
widgets::label(ui, tr("No color styles"), t.text_secondary);
|
||||
}
|
||||
let variables: Vec<_> = doc
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
v.kind == VariableType::Color
|
||||
&& bind_refusal(doc, &group.targets, &v.id).is_none()
|
||||
&& in_scope(doc, &group.targets, &v.id)
|
||||
})
|
||||
.collect();
|
||||
if !variables.is_empty() {
|
||||
ui.separator();
|
||||
widgets::label(ui, tr("Color variables"), t.text_secondary);
|
||||
}
|
||||
for v in variables {
|
||||
if crate::right::menu_item(ui, &v.name, "") {
|
||||
app.run("selection.bindColorVariable", json!({"targets": group.targets, "variable": v.id}));
|
||||
app.ui.picker = None;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if icons::button(ui, Icon::SelectColor, 24.0, false, tr("Select layers with this color")).clicked() {
|
||||
app.run("selection.selectColor", json!({"targets": group.targets}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -181,6 +230,7 @@ mod tests {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
time: f64,
|
||||
modifiers: egui::Modifiers,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
@@ -192,7 +242,7 @@ mod tests {
|
||||
ids.push(NodeId(value["id"].as_u64().unwrap()));
|
||||
}
|
||||
s.execute("select.set", &json!({"ids":ids})).unwrap();
|
||||
let mut p = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 };
|
||||
let mut p = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0, modifiers: Default::default() };
|
||||
crate::theme::install_fonts(&p.ctx, false, 0);
|
||||
crate::theme::apply(&p.ctx, crate::theme::Tokens::light());
|
||||
for _ in 0..3 {
|
||||
@@ -200,7 +250,9 @@ mod tests {
|
||||
}
|
||||
(p, ids[0], ids[1])
|
||||
}
|
||||
fn frame(&mut self, events: Vec<Event>) -> Vec<(String, Pos2)> {
|
||||
/// Run one panel frame, retaining its shapes and accessibility output for assertions.
|
||||
fn output(&mut self, mut events: Vec<Event>) -> egui::FullOutput {
|
||||
events.insert(0, Event::ModifiersChanged(self.modifiers));
|
||||
self.time += 1.0 / 60.0;
|
||||
let input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 600.0))),
|
||||
@@ -215,6 +267,10 @@ mod tests {
|
||||
section(&mut self.app, ui, &doc, &ids);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
output
|
||||
}
|
||||
fn frame(&mut self, events: Vec<Event>) -> Vec<(String, Pos2)> {
|
||||
let output = self.output(events);
|
||||
fn texts(shape: &Shape, out: &mut Vec<(String, Pos2)>) {
|
||||
match shape {
|
||||
Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.size() / 2.0)),
|
||||
@@ -300,6 +356,127 @@ mod tests {
|
||||
assert!(p.app.ui.last_error.is_none());
|
||||
}
|
||||
|
||||
/// Stored variable ids must never resolve to another variable with that name.
|
||||
#[test]
|
||||
fn linked_variable_name_uses_its_stored_id_when_another_name_matches() {
|
||||
let (mut p, a, _) = Panel::new();
|
||||
p.app.run("variable.createCollection", json!({"name":"Theme"}));
|
||||
let shadow = p.app.run("variable.create", json!({"collection":"Theme","name":"Shadow","type":"color","value":"#ff0000"})).unwrap();
|
||||
let accent = p.app.run("variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#00ff00"})).unwrap();
|
||||
p.app.run("variable.bind", json!({"id":a,"field":"fill","variable":accent["id"]}));
|
||||
p.app.run("variable.update", json!({"variable":shadow["id"],"name":accent["id"]}));
|
||||
assert!(p.app.ui.last_error.is_none());
|
||||
let texts = p.frame(vec![]);
|
||||
assert!(texts.iter().any(|(text, _)| text == "Accent"));
|
||||
assert!(!texts.iter().any(|(text, _)| text == accent["id"].as_str().unwrap()));
|
||||
}
|
||||
|
||||
/// Shared swatches stay distinguishable by their paint values to assistive technology.
|
||||
#[test]
|
||||
fn shared_swatch_announces_its_colour_and_keeps_the_edit_label() {
|
||||
let (mut p, _, _) = Panel::new();
|
||||
p.ctx.enable_accesskit();
|
||||
let output = p.output(vec![]);
|
||||
let nodes = output.platform_output.accesskit_update.unwrap().nodes;
|
||||
let mut values: Vec<_> = nodes
|
||||
.iter()
|
||||
.filter_map(|(_, node)| {
|
||||
if node.role() == egui::accesskit::Role::ColorWell {
|
||||
assert_eq!(node.label(), Some("Edit colour"));
|
||||
Some(node.value().unwrap().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
values.sort();
|
||||
assert_eq!(values, ["0000FF", "FF0000"]);
|
||||
}
|
||||
|
||||
/// Consecutive fine scrubs retain fractional baselines and each get one undo step.
|
||||
#[test]
|
||||
fn fractional_opacity_is_displayed_and_accumulates_across_fine_scrubs() {
|
||||
let (mut p, a, _) = Panel::new();
|
||||
p.app.run("fill.set", json!({"id":a, "opacity":0.999}));
|
||||
let texts = p.frame(vec![]);
|
||||
assert!(texts.iter().any(|(text, _)| text == "99.9%"));
|
||||
let red = texts.into_iter().find(|(text, _)| text == "FF0000").unwrap().1;
|
||||
let start = Pos2::new(167.0, red.y);
|
||||
p.modifiers.alt = true;
|
||||
for expected in [0.989, 0.979] {
|
||||
p.frame(vec![
|
||||
Event::PointerMoved(start),
|
||||
Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: p.modifiers },
|
||||
]);
|
||||
for dx in [-4.0, -8.0, -10.0] {
|
||||
p.frame(vec![Event::PointerMoved(start + vec2(dx, 0.0))]);
|
||||
}
|
||||
p.frame(vec![Event::PointerButton {
|
||||
pos: start - vec2(10.0, 0.0),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: p.modifiers,
|
||||
}]);
|
||||
assert!(p.app.session.ds().unwrap().interaction.is_none());
|
||||
let opacity = p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity;
|
||||
assert!((opacity - expected).abs() < 1e-6, "fine scrub kept fractional baseline: {opacity}");
|
||||
}
|
||||
assert!(p.frame(vec![]).iter().any(|(text, _)| text == "97.9%"));
|
||||
for expected in [0.989, 0.999] {
|
||||
p.app.run("edit.undo", json!({}));
|
||||
let opacity = p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity;
|
||||
assert!((opacity - expected).abs() < 1e-6, "each fine scrub is one undo step: {opacity}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_color_field_edits_hex_and_opacity_and_undoes_each_edit() {
|
||||
let (mut p, a, _) = Panel::new();
|
||||
let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1;
|
||||
p.click(red);
|
||||
p.frame(vec![Event::Text("00FF00".into())]);
|
||||
let enter = Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
p.frame(vec![enter.clone()]);
|
||||
assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
p.frame(vec![]);
|
||||
p.click(Pos2::new(176.0, red.y));
|
||||
p.frame(vec![Event::Text("45".into())]);
|
||||
p.frame(vec![enter]);
|
||||
assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 0.45);
|
||||
p.app.run("edit.undo", json!({}));
|
||||
assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 1.0);
|
||||
p.app.run("edit.undo", json!({}));
|
||||
assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_opacity_field_keeps_dragging_until_release_as_one_undo_step() {
|
||||
let (mut p, a, _) = Panel::new();
|
||||
let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1;
|
||||
let start = Pos2::new(167.0, red.y);
|
||||
p.frame(vec![
|
||||
Event::PointerMoved(start),
|
||||
Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() },
|
||||
]);
|
||||
for dx in [-10.0, -20.0, -30.0] {
|
||||
p.frame(vec![Event::PointerMoved(start + vec2(dx, 0.0))]);
|
||||
}
|
||||
assert!(p.app.session.ds().unwrap().interaction.is_some(), "the scrub commits only on release");
|
||||
assert!(p.frame(vec![]).iter().any(|(text, _)| text == "70%"), "the live opacity stays visible while scrubbing");
|
||||
p.frame(vec![Event::PointerButton {
|
||||
pos: start - vec2(30.0, 0.0),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: Default::default(),
|
||||
}]);
|
||||
assert!(p.app.session.ds().unwrap().interaction.is_none());
|
||||
assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 0.7);
|
||||
assert_eq!(p.app.session.undo_labels(1), ["Opacity"], "the shared helper preserves the scrub's undo label");
|
||||
p.app.run("edit.undo", json!({}));
|
||||
assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 1.0);
|
||||
assert_eq!(p.app.session.selection().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_displays_the_linked_style_name_and_the_other_color() {
|
||||
let (mut p, a, b) = Panel::new();
|
||||
|
||||
@@ -423,6 +423,9 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let mut chosen: Option<Pick> = 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| {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -1041,6 +1041,35 @@ pub fn checker(painter: &egui::Painter, rect: Rect, cell: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared Fill/Stroke/Selection colors control: one background, a 16 pt swatch,
|
||||
/// caller-provided colour content, and a separated, scrubbable opacity field.
|
||||
/// The caller owns picker routing and commands; this widget only draws and reports edits.
|
||||
pub fn paint_field(
|
||||
ui: &mut Ui,
|
||||
id: Id,
|
||||
opacity_id: Id,
|
||||
paint: &figcraft_color::Paint,
|
||||
width: f32,
|
||||
content: impl FnOnce(&mut Ui, Rect),
|
||||
) -> (Response, Option<FieldEdit>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
|
||||
let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));
|
||||
paint_swatch(ui.painter(), sw, paint);
|
||||
let swatch = ui.interact(sw.expand(2.0), id, Sense::click()).tip(tr("Edit colour"));
|
||||
let value = if a11y::active(&swatch.ctx) { paint_name(paint) } else { String::new() };
|
||||
a11y::color(&swatch, tr("Edit colour"), &value);
|
||||
let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
|
||||
content(&mut child, text_r);
|
||||
let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max);
|
||||
ui.painter().line_segment([pos2(op_r.min.x, r.min.y + 4.0), pos2(op_r.min.x, r.max.y - 4.0)], Stroke::new(1.0, t.panel));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(op_r));
|
||||
let opacity = number_field(&mut child, opacity_id, Lead::Named(tr("Opacity")), Some(f64::from(paint.opacity) * 100.0), "%", 48.0, 1.0);
|
||||
(swatch, opacity)
|
||||
}
|
||||
|
||||
/// A paint swatch (with checkerboard under transparent colours).
|
||||
pub fn swatch(ui: &mut Ui, paint: &figcraft_color::Paint, size: f32) -> Response {
|
||||
swatch_named(ui, paint, size, tr("Color"))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-2
@@ -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).
|
||||
|
||||
@@ -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/<key>` 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 (`<web_url>#join=…`). A browser build served beside its server names it with `<meta name="figcraft-server">`, 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. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (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 (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 |
|
||||
@@ -78,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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/<i>/<lightAngle\|lightIntensity\|refraction\|depth\|dispersion\|radius\|splay>`, `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`. |
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-2
@@ -121,8 +121,11 @@ a missing required face, style or asset fails the required web build. Excluded A
|
||||
are skipped before resolving their asset paths.
|
||||
Native builds retain the permitted Latin presentation faces. Source Sans 3 and Source Serif 4
|
||||
are filtered on every target under FigCraft's asset policy. The web build keeps its smaller
|
||||
allowlist: Inter, Noto Sans Georgian and Archivo SemiBold when supplied. Packages carry each
|
||||
embedded font's licence as `OFL-<family>.txt`
|
||||
allowlist: Inter, Noto Sans Georgian and Archivo SemiBold when supplied. A distribution whose
|
||||
users write another script adds the manifest families it needs, comma-separated, with
|
||||
`FIGCRAFT_WEB_FONTS` (`FIGCRAFT_WEB_FONTS="Noto Sans Thai" trunk build --release`); a named family
|
||||
the build doesn't embed gets a warning, and each one adds its size to the wasm, which must stay
|
||||
under the size gate in `packaging/web/package.sh`. Packages carry each embedded font's licence as `OFL-<family>.txt`
|
||||
(`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app).
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -75,6 +75,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | patch | M4, #1747: a text layer still carrying the name its characters gave it is renamed as they are typed, so the Layers panel no longer shows the word a layer started with while the canvas shows another (`hello` typed into `hello there` read as `hello` in the panel). A name the author typed is kept, and only the editor follows the characters: commands leave the name alone, so the layer names `data.fill`, component property bindings and templates use as field keys stay put. Tests: `text_behaviour_tests::typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it`, `setting_characters_as_a_command_never_renames_the_layer`. Grade 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 |
|
||||
| 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged |
|
||||
| 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged |
|
||||
|
||||
@@ -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 Line truncated
|
||||
> **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 Line truncated
|
||||
|
||||
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,7 +650,11 @@ 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-03 | patch | M4, #1747: a text layer still carrying the name its characters gave it is renamed as they are typed, so the Layers panel no longer shows the word a layer started with while the canvas shows another (`hello` typed into `hello there` read as `hello` in the panel). A name the author typed is kept, and only the editor follows the characters: commands leave the name alone, so the layer names `data.fill`, component property bindings and templates use as field keys stay put. Tests: `text_behaviour_tests::typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it`, `setting_characters_as_a_command_never_renames_the_layer`. 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 |
|
||||
| 2026-11-01 | patch | M4: a link made with `text.link` (Cmd/Ctrl+K, Shift+Cmd/Ctrl+U) underlines the linked text unless it has a decoration of its own ("Links are styled with an underline by default. Use the keyboard shortcut to remove the underline", Add links to text); Underline (Cmd/Ctrl+U) takes the underline off and the link stays. Found by a documentation audit of the text rules. Test: `text_behaviour_tests::a_link_is_underlined_by_default_and_underline_takes_it_off`. Numbers unchanged |
|
||||
|
||||
+3
-2
@@ -1,6 +1,6 @@
|
||||
# UI and interaction parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); 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; paintLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-04 · **Change:** minor (with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); 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-ediLine truncated
|
||||
|
||||
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` |
|
||||
@@ -179,6 +179,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. |
|
||||
| 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`. |
|
||||
|
||||
Reference in new issue
Block a user