mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-09 20:12:49 +00:00
Merge main into #1997
This commit is contained in:
commit
01096b90f1
151 files changed
+5334
-790
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 }}
|
||||
@@ -48,3 +56,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
@@ -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
|
||||
Generated
+10
@@ -1157,6 +1157,15 @@ dependencies = [
|
||||
"zip 9.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -2121,6 +2130,7 @@ name = "figcraft-web"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"figcraft-collab-net",
|
||||
|
||||
+13
-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-10-30 · **Change:** minor (a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated statusLine truncated
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a layer's effect binding takes only a field its effect has; ; 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 eachLine 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.
|
||||
@@ -143,6 +144,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
- **2026-10-27** · Batch continue-on-error (M0.9, #1386): `command.batch` and MCP `batch` accept `onError: "continue"`, roll back failed steps individually and return one outcome per step. References to failed steps fail with both step numbers; successful edits stay one undo step, and the default abort mode is unchanged. Engine and MCP regression tests. Numbers unchanged.
|
||||
- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged.
|
||||
- **2026-10-25** · M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged.
|
||||
- **2026-10-26** · Web host bridge: `?host=parent` (a same-origin page embedding FigCraft in an iframe) or `data-host="page"` on the canvas (the page's own document, over non-bubbling `CustomEvent`s) opens the host's file like File ▸ Open (over the browser open limit of 512 MB refused) and takes every file the app would otherwise download (`figcraft:save` with the `saveAs` rule), plus `figcraft:dirty`, `figcraft:ready` and `figcraft:failed`; `?author=` names the user for comments and board notes; host mode starts with no document. Unit tests cover the opt-in rule (only the exact `page` value), the `saveAs` rule and the open-name fallback. Numbers unchanged.
|
||||
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
|
||||
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
|
||||
- **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)).
|
||||
@@ -357,6 +359,15 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Numbers unchanged |
|
||||
| 2026-11-06 | patch | M3: a layer's effect binding (`variable.bind`, `effects/<i>/<field>`) takes only a field its effect has, as an effect style's does: `effects/0/refraction` on a drop shadow, or a binding on an effect the layer doesn't have, is refused instead of being kept and doing nothing (a binding an older file has there still does nothing, and unbinds). Tests: `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`. Numbers unchanged |
|
||||
| 2026-11-06 | patch | M9, #2026: a branch merge counts one conflict per auto layout field changed both ways, not one for every size and place auto layout works out from it; values an instance takes from its main no longer conflict, and auto layout settings changed one way survive a conflict on another field. Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict`, `branch_tests::auto_layout_fields_changed_one_way_survive_a_conflict`, `branch_tests::sizes_auto_layout_works_out_never_conflict`. Numbers unchanged |
|
||||
| 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 |
|
||||
| 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 |
|
||||
@@ -492,6 +503,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
| 2026-10-27 | minor | Placed images keep their own size (M7, #1360): dropping, Place image, paste and `image.place` put an image at its pixel size and fit only a side past 4096 px (they fitted it into 1000 px); a PNG whose `pHYs` records a whole 2x–4x multiple of 72 dpi (macOS screenshots record 144 dpi) is placed at its point size, so an 800 × 600 px capture comes in at 400 × 300; other densities, such as 300 dpi scans, keep their pixel size; the image data stays at full resolution and an explicit width or height still wins. On macOS, paste reads the clipboard's PNG as is (its density and colour profile kept) and falls back to the decoded image. Numbers unchanged |
|
||||
| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged |
|
||||
| 2026-10-25 | minor | M6, #1143: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets guard untrusted input. Figma parity numbers unchanged. |
|
||||
| 2026-10-26 | minor | Web host bridge: `?host=parent` (a same-origin page embedding FigCraft in an iframe) or `data-host="page"` on the canvas (the page's own document, over non-bubbling `CustomEvent`s) opens the host's file like File ▸ Open and takes every file the app would otherwise download, with `figcraft:ready`, `figcraft:open`, `figcraft:save` (a `saveAs` rule), `figcraft:dirty` and `figcraft:failed`; `?author=` names the user for comments and board notes; documented in the hosting guide. Numbers unchanged |
|
||||
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
|
||||
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
|
||||
| 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) |
|
||||
|
||||
@@ -19,6 +19,8 @@ figcraft-format = { workspace = true }
|
||||
figcraft-ui-egui = { workspace = true }
|
||||
figcraft-collab-net = { workspace = true }
|
||||
figcraft-text = { workspace = true }
|
||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
base64 = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -30,4 +32,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "ClipboardEvent", "DataTransfer", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType", "Storage", "Event", "MessageEvent", "UrlSearchParams", "CustomEvent", "CustomEventInit"] }
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Host modes: a page that runs FigCraft (as an `<iframe>`, or in its own document) opens
|
||||
//! documents in it and stores what it saves instead of the browser downloading. The messages
|
||||
//! and the two transports are `craft-web-host`'s (storytold/craft-libs): the same contract as
|
||||
//! the other Craft apps' bridges. This module is FigCraft's part of it.
|
||||
//!
|
||||
//! - `?host=parent` (an `<iframe>`, such as a Nextcloud app) talks to the same-origin parent over
|
||||
//! `postMessage`; `data-host="page"` on the canvas (the page's own document) uses DOM
|
||||
//! `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present.
|
||||
//! - `figcraft:ready` (to the host) once the app is running and listening; send a file now.
|
||||
//! - `figcraft:open` (from the host): `{ name, bytes }`. It opens exactly as a file picked with
|
||||
//! File › Open or dropped on the window would (the app's own open queue, with its "Opening as
|
||||
//! a new file" card). A payload over the browser's own open limit
|
||||
//! ([`figcraft_ui_egui::browser_can_read`]'s 512 MB) is refused before its bytes are copied,
|
||||
//! and anything malformed is ignored. FigCraft reads whole files, so it says `file: false` in
|
||||
//! `ready` and a host sends `bytes`.
|
||||
//! - `figcraft:save` (to the host): `{ name, saveAs, bytes }` for every file the app hands its
|
||||
//! download service; in host mode nothing reaches the browser's downloads. `saveAs` says whether
|
||||
//! the file becomes the document's file ([`crate::hostmode::save_as`]):
|
||||
//! - A Save of the file the host opened, under its own name: `false`.
|
||||
//! - A document file (`.figcraft` or `.json`, the formats `file.save` and `file.saveAs` write)
|
||||
//! under a name the host didn't open — Save As, or a Save of a document the host never sent
|
||||
//! (an untitled one): `true`. It becomes the document's file; the next Save sends its name
|
||||
//! with `false`. Names compare without case.
|
||||
//! - Everything else is a copy or export that never becomes the document: `false` — rendered
|
||||
//! images, SVG and PDF, `.fig` copies, and the variables exports (a `.variables.json`
|
||||
//! sidecar's `.json` extension doesn't make it a document).
|
||||
//! - `figcraft:dirty` whenever unsaved changes appear or go away, `figcraft:failed` when the app
|
||||
//! couldn't start.
|
||||
//!
|
||||
//! In host mode the app starts with no document (the home screen) and the host sends the file to
|
||||
//! edit; `?author=` names the user for comments and board notes.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use figcraft_ui_egui::FigApp;
|
||||
|
||||
/// The app id in the host messages (`figcraft:ready`).
|
||||
const APP: &str = "figcraft";
|
||||
|
||||
/// The longest user name taken from `?author=`.
|
||||
const MAX_AUTHOR_CHARS: usize = 200;
|
||||
|
||||
/// Files the host sent, on their way to the app's own open queue ([`figcraft_ui_egui::pending_open`]):
|
||||
/// `(name, bytes)`.
|
||||
pub type OpenQueue = Rc<RefCell<Vec<(String, Vec<u8>)>>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
host: Rc<Host>,
|
||||
/// The file the host opened, and after a `saveAs` hand-over the document's file: the name a
|
||||
/// Save under (`saveAs: false`) is decided against.
|
||||
host_file: Rc<RefCell<Option<String>>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// The bridge for this page, or `None` when FigCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn connect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
Some(Self { host: Rc::new(Host::connect(APP, canvas)?), host_file: Rc::default() })
|
||||
}
|
||||
|
||||
/// Hear the files the host sends, and queue each one on `opens` for the app's own open path
|
||||
/// (the shell drains it into [`figcraft_ui_egui::pending_open`] every frame, so a host file
|
||||
/// opens like a dropped one).
|
||||
pub fn listen(&self, opens: OpenQueue, ctx: egui::Context) {
|
||||
let host_file = self.host_file.clone();
|
||||
self.host.listen(move |message| {
|
||||
if let Message::Open { name, data } = message {
|
||||
deliver_open(&opens, &ctx, &host_file, &name, &data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs comments and board notes.
|
||||
pub fn set_author(app: &mut FigApp, author: &str) {
|
||||
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
|
||||
if !author.is_empty() {
|
||||
app.session.prefs.user_name = author;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
/// The document file the host opened or the last Save As made, if any.
|
||||
pub fn host_file(&self) -> Option<String> {
|
||||
self.host_file.borrow().clone()
|
||||
}
|
||||
|
||||
/// Hand a file the app would otherwise download to the host; `false` when nothing was
|
||||
/// delivered. The `saveAs` rule is this module's documentation's.
|
||||
pub fn post_save(&self, path: &str, bytes: &[u8]) -> bool {
|
||||
let name = file_name(path);
|
||||
let host_file = self.host_file.borrow().clone();
|
||||
let save_as = crate::hostmode::save_as(&name, host_file.as_deref());
|
||||
let sent = self.host.post_save(&name, bytes, None, save_as).is_ok();
|
||||
// After a Save As hand-over the host has the new file: the next Save of it sends its name
|
||||
// with `saveAs: false`. A failed send changes nothing, so a retry is still a Save As.
|
||||
if sent && save_as {
|
||||
*self.host_file.borrow_mut() = Some(name);
|
||||
}
|
||||
sent
|
||||
}
|
||||
|
||||
/// Report unsaved changes when they appear or go away, not every frame (the crate does not
|
||||
/// repeat a state it already reported).
|
||||
pub fn set_dirty(&self, dirty: bool) {
|
||||
self.host.post_dirty(dirty);
|
||||
}
|
||||
}
|
||||
|
||||
/// The file an open message carries, queued for the app's own open path, or nothing when the
|
||||
/// payload isn't one (a `File` the app doesn't read, or over the open limit).
|
||||
fn deliver_open(opens: &OpenQueue, ctx: &egui::Context, host_file: &RefCell<Option<String>>, name: &str, data: &FileData) {
|
||||
let name = crate::hostmode::open_name(name);
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; FigCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
if figcraft_ui_egui::browser_can_read(&name, data.size()).is_err() {
|
||||
log::warn!("host file {name} is too large to open in the browser; not opened");
|
||||
return;
|
||||
}
|
||||
// The document's file is now the one the host sent: a Save of it under this name is
|
||||
// `saveAs: false`.
|
||||
*host_file.borrow_mut() = Some(name.clone());
|
||||
opens.borrow_mut().push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// The last component of `path`, or the whole of it when there is none.
|
||||
fn file_name(path: &str) -> String {
|
||||
std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string())
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Pure host-mode rules, shared by the bridge ([`crate::host`]) and testable without a browser
|
||||
//! (like [`crate::query`]): the name the bridge fills in when an open request has none, and whether
|
||||
//! a handed-over file becomes the document's file. Which pages host the app is `craft-web-host`'s.
|
||||
|
||||
/// The file name used when an open request carries no usable one.
|
||||
pub const DEFAULT_OPEN_NAME: &str = "Untitled.figcraft";
|
||||
|
||||
/// The extensions of files that can be a document's own file: the formats `file.save` and
|
||||
/// `file.saveAs` write (`file.open` opens both as native documents). A `.fig` file is an export
|
||||
/// copy instead, and so is everything else the app hands over (rendered images, SVG, PDF,
|
||||
/// variables).
|
||||
const DOC_EXTS: &[&str] = &["figcraft", "json"];
|
||||
|
||||
/// The suffix `variable.export`'s downloads carry: a `.json` that is a variables sidecar, not a
|
||||
/// document, so its extension alone can't tell the `saveAs` decision.
|
||||
const VARIABLES_SIDECAR: &str = ".variables.json";
|
||||
|
||||
/// The name `craft-web-host` gives an open request that carries no usable one (it cleans a host's
|
||||
/// name and falls back to this).
|
||||
const NAMELESS: &str = "document";
|
||||
|
||||
/// A usable file name for an open request: the cleaned name the crate hands over, or
|
||||
/// [`DEFAULT_OPEN_NAME`] when the host sent none.
|
||||
pub fn open_name(name: &str) -> String {
|
||||
if name == NAMELESS { DEFAULT_OPEN_NAME.to_string() } else { name.to_string() }
|
||||
}
|
||||
|
||||
/// Whether the file `name` becomes the document's own file when handed over (`saveAs: true`),
|
||||
/// given the file the host opened (`None`: the host never sent one). The rule:
|
||||
///
|
||||
/// - A document file (`.figcraft` or `.json`, [`DOC_EXTS`]'s formats) under a name the host didn't
|
||||
/// open — Save As, or a Save of a document the host never sent — is the document's new file:
|
||||
/// `true`. Names compare without case.
|
||||
/// - A Save of the host's own file under its name is not: `false`.
|
||||
/// - Everything else is a copy or export that never becomes the document — rendered images, SVG,
|
||||
/// PDF, `.fig` copies, variables — and the variables sidecar counts as one whatever its `.json`
|
||||
/// extension says: `false`.
|
||||
pub fn save_as(name: &str, host_file: Option<&str>) -> bool {
|
||||
if name.to_ascii_lowercase().ends_with(VARIABLES_SIDECAR) {
|
||||
return false;
|
||||
}
|
||||
let ext = std::path::Path::new(name).extension().and_then(std::ffi::OsStr::to_str);
|
||||
if !ext.is_some_and(|e| DOC_EXTS.iter().any(|x| x.eq_ignore_ascii_case(e))) {
|
||||
return false;
|
||||
}
|
||||
match host_file {
|
||||
Some(host) => !host.eq_ignore_ascii_case(name),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn open_names_fall_back_to_the_default_when_the_host_sent_none() {
|
||||
assert_eq!(open_name("Poster.figcraft"), "Poster.figcraft");
|
||||
assert_eq!(open_name("Poster 2.json"), "Poster 2.json");
|
||||
assert_eq!(open_name("document"), DEFAULT_OPEN_NAME, "what the crate says for a missing or blank name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_save_of_the_host_s_file_is_not_save_as() {
|
||||
assert!(!save_as("Poster.figcraft", Some("Poster.figcraft")));
|
||||
assert!(!save_as("poster.FIGCRAFT", Some("Poster.figcraft")), "names compare without case");
|
||||
assert!(!save_as("design.json", Some("design.json")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_document_file_is_save_as() {
|
||||
assert!(save_as("Poster 2.figcraft", Some("Poster.figcraft")));
|
||||
assert!(save_as("Poster.json", Some("Poster.figcraft")), "the other format a document's file takes");
|
||||
assert!(save_as("Untitled.figcraft", None), "the host never sent a file");
|
||||
assert!(save_as("Poster.figcraft", Some("design.json")), "the opened .json saved as .figcraft is a new file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copies_and_exports_never_become_the_document_s_file() {
|
||||
for name in ["Poster.png", "Poster@2x.jpg", "Poster.svg", "Poster.pdf", "Poster.fig", "Poster.csv", "Poster.variables.css"] {
|
||||
assert!(!save_as(name, Some("Poster.figcraft")), "{name} is a copy");
|
||||
assert!(!save_as(name, None), "{name} is a copy even without a host file");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_variables_sidecar_is_a_copy_whatever_its_extension() {
|
||||
assert!(!save_as("Poster.variables.json", Some("Poster.figcraft")));
|
||||
assert!(!save_as("Poster.variables.json", None));
|
||||
assert!(!save_as("design.variables.json", Some("design.variables.json")));
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,17 @@
|
||||
//! server…), but can't host a session. A page served over `https://` reaches only `wss://`
|
||||
//! servers and the same computer (browsers block `ws://` from secure pages); serve it over
|
||||
//! `http://` on a trusted network to reach plain `ws://` hosts on other machines.
|
||||
//! - host mode: `?host=parent` (a same-origin page embedding FigCraft in an `<iframe>`) or
|
||||
//! `data-host="page"` on the canvas (the page's own document) hands the page the app's files
|
||||
//! instead of downloading them and reports unsaved changes (`host`);
|
||||
//! - live sessions: it joins sessions a desktop app hosts (File ▸ Join live session…) but can't
|
||||
//! host one. A page served over `https://` can only reach sessions on the same computer
|
||||
//!
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs
|
||||
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it.
|
||||
//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it; `?host=parent`
|
||||
//! (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the document to open
|
||||
//! and stores what FigCraft saves. `&author=` names the user for comments and board notes in either
|
||||
//! host mode.
|
||||
//!
|
||||
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
|
||||
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
|
||||
@@ -30,6 +38,10 @@
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod hostmode;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod kept;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -32,6 +32,15 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing FigCraft, opens
|
||||
// documents in it and stores what it saves (`host`); unframed there is no parent, so that URL
|
||||
// on its own stays the normal app with downloads. `data-host="page"` on the canvas opts a
|
||||
// page in instead: it runs FigCraft in its own document and talks over DOM `CustomEvent`s on
|
||||
// the canvas.
|
||||
let bridge = crate::host::Bridge::connect(&canvas);
|
||||
let app_bridge = bridge.clone();
|
||||
// Files the host sends, on their way into the app's own open queue each frame.
|
||||
let opens: crate::host::OpenQueue = std::rc::Rc::default();
|
||||
let mut options = eframe::WebOptions::default();
|
||||
// `?webgl`: force the WebGL2 backend (troubleshooting, and headless browsers whose WebGPU
|
||||
// adapter request returns nothing).
|
||||
@@ -44,12 +53,15 @@ pub fn start() {
|
||||
.start(
|
||||
canvas,
|
||||
options,
|
||||
Box::new(|cc| {
|
||||
Box::new(move |cc| {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
log::info!("figcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
|
||||
}
|
||||
let reduce_motion = web_sys::window().and_then(|w| w.match_media("(prefers-reduced-motion: reduce)").ok().flatten());
|
||||
let mut session = Session::new();
|
||||
// Host mode: the host sends the file to edit, so start with no document (the
|
||||
// home screen), as the desktop app does without a file; otherwise the session
|
||||
// starts with one new document.
|
||||
let mut session = if app_bridge.is_some() { Session::empty() } else { Session::new() };
|
||||
// The host names its own server: the default for sharing, codes and the team list.
|
||||
if let Some(server) = page_meta("figcraft-server").as_deref().and_then(crate::query::page_server) {
|
||||
session.prefs.publish_server = server;
|
||||
@@ -60,11 +72,18 @@ pub fn start() {
|
||||
// whatever arrives on the socket wakes the page for a frame.
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
|
||||
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
|
||||
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone(), host: app_bridge.clone() }));
|
||||
fig.note_recovered(recovered);
|
||||
if let Err(e) = crate::pasted::install(cc.egui_ctx.clone()) {
|
||||
log::error!("couldn't listen for pasted images: {e:?}");
|
||||
}
|
||||
// `?author=`: the host's name for the user signs this visit's comments and board
|
||||
// notes (nothing is stored in the browser, so nothing needs keeping).
|
||||
if app_bridge.is_some()
|
||||
&& let Some(author) = query_param("author")
|
||||
{
|
||||
crate::host::Bridge::set_author(&mut fig, &author);
|
||||
}
|
||||
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
|
||||
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
|
||||
if let Some(link) = crate::query::join_link(&hash) {
|
||||
@@ -78,10 +97,21 @@ pub fn start() {
|
||||
log::error!("couldn't install window.figcraft: {e:?}");
|
||||
}
|
||||
}
|
||||
Ok(Box::new(WebShell { fig }))
|
||||
// The host's files arrive on the app's own open path (a dropped file's).
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.listen(opens.clone(), cc.egui_ctx.clone());
|
||||
}
|
||||
Ok(Box::new(WebShell { fig, host: app_bridge, opens }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Ready only once the runner is up: starting can still fail after the app is made.
|
||||
if let Some(bridge) = &bridge {
|
||||
match &result {
|
||||
Ok(()) => bridge.post_ready(),
|
||||
Err(e) => bridge.post_failed(&format!("{e:?}")),
|
||||
}
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
@@ -141,20 +171,45 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
struct WebShell {
|
||||
fig: FigApp,
|
||||
/// The host bridge, when the page hosts the app (`?host=parent` or `data-host="page"`).
|
||||
host: Option<crate::host::Bridge>,
|
||||
/// Files the host sent, waiting to join the app's own open queue.
|
||||
opens: crate::host::OpenQueue,
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
|
||||
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
|
||||
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
|
||||
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
|
||||
/// Run the browser shell's eframe logic callback: hand the host's files to the app's open
|
||||
/// queue, guard `background_tick` so hidden tabs still advance autosave, live and control
|
||||
/// deadlines without painting, and report unsaved changes to the host. `FigApp::ui` does
|
||||
/// not repeat it.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
// Every frame, hidden or not, starts here: what the last one left is what a panic keeps.
|
||||
UNSAVED.with(|u| u.try_borrow_mut().map(|mut u| u.follow(&self.fig.session))).ok();
|
||||
// Files the host sent join the app's own open queue (a dropped file's): the same
|
||||
// "Opening as a new file" card, and `file.open` with the contents once read.
|
||||
for (name, bytes) in std::mem::take(&mut *self.opens.borrow_mut()) {
|
||||
let read: figcraft_ui_egui::ReadFuture = Box::pin(std::future::ready(Ok(bytes)));
|
||||
self.fig.ui.pending_opens.push(figcraft_ui_egui::pending_open::PendingOpen {
|
||||
label: name.clone(),
|
||||
params: serde_json::json!({ "name": name }),
|
||||
command: "file.open",
|
||||
target: None,
|
||||
read: Some(read),
|
||||
});
|
||||
}
|
||||
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
|
||||
// The host hears when unsaved changes appear or go away (crate::host).
|
||||
if let Some(bridge) = &self.host {
|
||||
bridge.set_dirty(self.fig.session.docs.iter().any(|d| d.is_dirty()));
|
||||
}
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
|
||||
@@ -182,12 +237,15 @@ impl eframe::App for WebShell {
|
||||
|
||||
/// Browser services. Files are opened and imported through the browser's file picker, which hands
|
||||
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
|
||||
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
|
||||
/// paste events with their text or image (a page can't read the clipboard when it likes).
|
||||
/// saved as downloads, or handed to the host page in host mode. Copies go to the system clipboard
|
||||
/// through egui's output; pastes arrive as paste events with their text or image (a page can't
|
||||
/// read the clipboard when it likes).
|
||||
struct WebServices {
|
||||
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
|
||||
reduce_motion: Option<web_sys::MediaQueryList>,
|
||||
ctx: egui::Context,
|
||||
/// The host bridge in host mode: files are handed over instead of downloaded ([`crate::host`]).
|
||||
host: Option<crate::host::Bridge>,
|
||||
}
|
||||
|
||||
impl Services for WebServices {
|
||||
@@ -204,11 +262,15 @@ impl Services for WebServices {
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
/// A Blob behind a temporary `<a download>` link, clicked and removed.
|
||||
/// A Blob behind a temporary `<a download>` link, clicked and removed; in host mode the file
|
||||
/// goes to the host page instead ([`crate::host`]).
|
||||
fn download(&mut self, name: &str, bytes: &[u8]) -> bool {
|
||||
if crate::api::capture(name, bytes) {
|
||||
return true;
|
||||
}
|
||||
if let Some(bridge) = &self.host {
|
||||
return bridge.post_save(name, bytes);
|
||||
}
|
||||
match download(name, bytes) {
|
||||
Ok(()) => true,
|
||||
Err(e) => {
|
||||
@@ -217,6 +279,9 @@ impl Services for WebServices {
|
||||
}
|
||||
}
|
||||
}
|
||||
fn host_file(&self) -> Option<String> {
|
||||
self.host.as_ref()?.host_file()
|
||||
}
|
||||
fn clipboard_set_text(&mut self, s: &str) {
|
||||
figcraft_ui_egui::egui_copy_text(&self.ctx, s);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -101,3 +101,14 @@ sync_github_name = true
|
||||
|
||||
[people.aleksei-suholita]
|
||||
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() }
|
||||
|
||||
@@ -420,7 +420,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;
|
||||
}
|
||||
|
||||
@@ -591,6 +591,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(|| {}));
|
||||
@@ -600,6 +602,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(),
|
||||
|
||||
@@ -69,7 +69,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;
|
||||
}
|
||||
@@ -227,7 +227,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 })
|
||||
|
||||
@@ -148,8 +148,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),
|
||||
}
|
||||
@@ -182,7 +183,7 @@ pub(crate) fn final_refusal(reason: &str) -> bool {
|
||||
/// join that can't reach the host. A live connection that dropped or was turned away for now tries
|
||||
/// again until you leave: with growing pauses, and a longer one between rounds.
|
||||
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 }))));
|
||||
let outcome = match outcome {
|
||||
Outcome::Refused(why) if was_live && !final_refusal(&why) => Outcome::Retry(why),
|
||||
o => o,
|
||||
@@ -191,7 +192,7 @@ pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt
|
||||
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 = attempt.saturating_add(1);
|
||||
@@ -264,6 +265,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(),
|
||||
|
||||
+204
-13
@@ -9,16 +9,20 @@
|
||||
//! interactions, accessibility annotations, Dev Mode resources and measurements) merge item by
|
||||
//! item: both sides changing different items of the list (or one adding items at its end) is no
|
||||
//! conflict, only the same item changed both ways or the list shortened on a side while the other
|
||||
//! changed it. Nothing that conflicts is merged until it is resolved: each conflict is resolved for
|
||||
//! one side, and the other side's changes to it are left out. Everything else from both sides is
|
||||
//! kept.
|
||||
//! changed it. Auto layout settings (a frame's and a child's) merge field by field: each field
|
||||
//! changed both ways is a conflict of its own, and the other fields merge. Values a file works out
|
||||
//! for itself (a size or place auto layout gives a layer, text sizing to its characters, a value a
|
||||
//! layer in an instance takes from its main component) never conflict: the merged file works them
|
||||
//! out again from what they follow. Nothing that conflicts is merged until it is resolved: each
|
||||
//! conflict is resolved for one side, and the other side's changes to it are left out. Everything
|
||||
//! else from both sides is kept.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, Sizing, TextAutoResize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::change::{Change, DocProp, Entry, Json, Prop};
|
||||
use crate::replica::{Mode, Positions, Replica, create_change};
|
||||
@@ -33,6 +37,7 @@ pub enum Side {
|
||||
}
|
||||
|
||||
impl Side {
|
||||
/// The side that isn't `self`.
|
||||
pub fn other(self) -> Side {
|
||||
match self {
|
||||
Side::Branch => Side::Main,
|
||||
@@ -48,6 +53,7 @@ impl Side {
|
||||
}
|
||||
}
|
||||
|
||||
/// The side `"branch"` or `"main"` names (`None` for anything else).
|
||||
pub fn parse(s: &str) -> Option<Side> {
|
||||
match s {
|
||||
"branch" => Some(Side::Branch),
|
||||
@@ -62,6 +68,9 @@ impl Side {
|
||||
pub enum Key {
|
||||
/// A layer property. A layer's parent and position are one property (where it is).
|
||||
Node(NodeId, Prop),
|
||||
/// One field of a layer property that merges field by field ([`PER_FIELD`]): `padding` of
|
||||
/// `layout`, `sizing_h` of `childLayout`…
|
||||
Field(NodeId, Prop, String),
|
||||
/// A layer deleted on one side and edited (it or what's inside it) on the other.
|
||||
Layer(NodeId),
|
||||
/// A document-level property.
|
||||
@@ -78,11 +87,13 @@ fn camel<T: Serialize>(v: &T) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// `12/fills`, `12/layer`, `doc/styleOrder`, `style/S:3`, `comment/7`… (the keys resolutions use).
|
||||
/// `12/fills`, `12/layout.padding`, `12/layer`, `doc/styleOrder`, `style/S:3`, `comment/7`… (the
|
||||
/// keys resolutions use).
|
||||
impl fmt::Display for Key {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Key::Node(id, prop) => write!(f, "{id}/{}", camel(prop)),
|
||||
Key::Field(id, prop, field) => write!(f, "{id}/{}.{field}", camel(prop)),
|
||||
Key::Layer(id) => write!(f, "{id}/layer"),
|
||||
Key::Doc(prop) => write!(f, "doc/{}", camel(prop)),
|
||||
Key::Entry(e) => match e {
|
||||
@@ -129,10 +140,25 @@ pub struct Plan {
|
||||
/// The main file's changes since the base.
|
||||
pub main: Vec<Change>,
|
||||
pub conflicts: Vec<Conflict>,
|
||||
/// Layer properties changed both ways to values both files work out for themselves
|
||||
/// ([`computed`]): no conflict, and a merge leaves the file merged into its own value.
|
||||
derived: BTreeSet<Key>,
|
||||
/// Layer properties ([`Key::Node`]) merged field by field with fields in conflict.
|
||||
fields: BTreeMap<Key, Fields>,
|
||||
branch_positions: Positions,
|
||||
main_positions: Positions,
|
||||
}
|
||||
|
||||
/// A layer property merged field by field ([`merge_fields`]): the value with every field changed
|
||||
/// one way (or the same way on both sides) merged, and each side's values of the fields changed
|
||||
/// both ways (`Null` for a field a side left out).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
struct Fields {
|
||||
merged: Map<String, Value>,
|
||||
branch: Map<String, Value>,
|
||||
main: Map<String, Value>,
|
||||
}
|
||||
|
||||
/// Document-level properties that belong to each copy, not to the work: the file name (a branch
|
||||
/// is named after itself) and the page it opens on.
|
||||
fn merges(c: &Change) -> bool {
|
||||
@@ -152,6 +178,7 @@ fn key_of(c: &Change) -> Option<Key> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The changes (indexes) in `changes` grouped by what they write ([`key_of`]).
|
||||
fn by_key(changes: &[Change]) -> BTreeMap<Key, Vec<usize>> {
|
||||
let mut out: BTreeMap<Key, Vec<usize>> = BTreeMap::new();
|
||||
for (i, c) in changes.iter().enumerate() {
|
||||
@@ -193,6 +220,108 @@ fn merged_list(base: &Document, key: &Key, b: Option<&Change>, m: Option<&Change
|
||||
merge_items(o.0.as_array()?, bv.0.as_array()?, mv.0.as_array()?).map(|v| Json(Value::Array(v)))
|
||||
}
|
||||
|
||||
/// Layer properties whose fields merge one by one: auto layout settings, a frame's (`layout`) and
|
||||
/// a child's (`childLayout`). Changing the padding on one side and the gap on the other is no
|
||||
/// conflict, and each field changed both ways is a conflict of its own ([`Key::Field`]).
|
||||
const PER_FIELD: &[Prop] = &[Prop::Layout, Prop::ChildLayout];
|
||||
|
||||
/// Three-way merge of object `o` changed to `b` on one side and `m` on the other, field by field:
|
||||
/// a field changed on one side takes that side's value, and one changed both ways (differently)
|
||||
/// is in conflict. `None` when any of them isn't an object (auto layout added or removed).
|
||||
fn merge_fields(o: &Value, b: &Value, m: &Value) -> Option<Fields> {
|
||||
let (o, b, m) = (o.as_object()?, b.as_object()?, m.as_object()?);
|
||||
let names: BTreeSet<&String> = o.keys().chain(b.keys()).chain(m.keys()).collect();
|
||||
let mut out = Fields::default();
|
||||
for name in names {
|
||||
let (base, x, y) = (o.get(name), b.get(name), m.get(name));
|
||||
let kept = if x == base {
|
||||
y
|
||||
} else if y == base || x == y {
|
||||
x
|
||||
} else {
|
||||
out.branch.insert(name.clone(), x.cloned().unwrap_or(Value::Null));
|
||||
out.main.insert(name.clone(), y.cloned().unwrap_or(Value::Null));
|
||||
base
|
||||
};
|
||||
if let Some(v) = kept {
|
||||
out.merged.insert(name.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Both sides' writes `b` and `m` of property `key` (one of [`PER_FIELD`]) merged field by field
|
||||
/// against the base; `None` when `key` isn't such a property or a value isn't an object.
|
||||
fn merged_fields(base: &Document, key: &Key, b: Option<&Change>, m: Option<&Change>) -> Option<Fields> {
|
||||
let Key::Node(id, prop) = key else { return None };
|
||||
let (Some(Change::Set { value: bv, .. }), Some(Change::Set { value: mv, .. })) = (b, m) else { return None };
|
||||
if !PER_FIELD.contains(prop) {
|
||||
return None;
|
||||
}
|
||||
let o = prop.get(base.node(*id)?).ok()?;
|
||||
merge_fields(&o.0, &bv.0, &mv.0)
|
||||
}
|
||||
|
||||
/// Whether layer `n` of `doc` is placed by its parent's auto layout (not absolute).
|
||||
fn in_flow(doc: &Document, n: &Node) -> bool {
|
||||
!n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())
|
||||
}
|
||||
|
||||
/// Whether auto layout or text works out layer `n`'s width (`horizontal`) or height: an auto layout
|
||||
/// frame hugging its contents, a child filling its auto layout parent, or text sizing to its
|
||||
/// characters.
|
||||
fn sized_for_itself(doc: &Document, n: &Node, horizontal: bool) -> bool {
|
||||
let sizing = if horizontal { n.child_layout.sizing_h } else { n.child_layout.sizing_v };
|
||||
let by_layout = match sizing {
|
||||
Sizing::Hug => n.layout.is_some(),
|
||||
Sizing::Fill => in_flow(doc, n),
|
||||
Sizing::Fixed => false,
|
||||
};
|
||||
let by_text = match &n.kind {
|
||||
NodeKind::Text(t) => t.auto_resize == TextAutoResize::WidthAndHeight || (!horizontal && t.auto_resize == TextAutoResize::Height),
|
||||
_ => false,
|
||||
};
|
||||
by_layout || by_text
|
||||
}
|
||||
|
||||
/// Whether layer `n` of `doc` (an instance, or a layer inside one) has property `prop` as its main
|
||||
/// component's layer has it: it follows the main rather than overriding it.
|
||||
fn follows_main(doc: &Document, n: &Node, prop: Prop) -> bool {
|
||||
let main = match &n.kind {
|
||||
NodeKind::Instance { component, .. } => Some(*component),
|
||||
_ => n.source,
|
||||
};
|
||||
Prop::FIELDS.contains(&prop) && main.and_then(|m| doc.node(m)).is_some_and(|m| !prop.differs(n, m))
|
||||
}
|
||||
|
||||
/// Whether layer property `key` holds a value `doc` works out for itself rather than one set on
|
||||
/// the layer: a size auto layout or text gives it ([`sized_for_itself`]), its place in an auto
|
||||
/// layout parent, or a value it takes from its main component ([`follows_main`]).
|
||||
fn computed(doc: &Document, key: &Key) -> bool {
|
||||
let Key::Node(id, prop) = key else { return false };
|
||||
let Some(n) = doc.node(*id) else { return false };
|
||||
let by_layout = match prop {
|
||||
Prop::Width => sized_for_itself(doc, n, true),
|
||||
Prop::Height => sized_for_itself(doc, n, false),
|
||||
Prop::Transform => in_flow(doc, n),
|
||||
_ => false,
|
||||
};
|
||||
by_layout || follows_main(doc, n, *prop)
|
||||
}
|
||||
|
||||
/// Whether property `key`, changed both ways, is one both files work out for themselves
|
||||
/// ([`computed`]), so neither side's value is a choice to make. A place given by auto layout is
|
||||
/// only that when both sides also turn and scale the layer the same way (a rotation is set on the
|
||||
/// layer, not worked out).
|
||||
fn derived_on_both(branch: &Document, main: &Document, key: &Key) -> bool {
|
||||
if !(computed(branch, key) && computed(main, key)) {
|
||||
return false;
|
||||
}
|
||||
let Key::Node(id, Prop::Transform) = key else { return true };
|
||||
let linear = |doc: &Document| doc.node(*id).map(|n| n.transform.as_coeffs()).map(|c| [c[0], c[1], c[2], c[3]]);
|
||||
linear(branch) == linear(main)
|
||||
}
|
||||
|
||||
/// The change at the only index in `c`.
|
||||
fn only<'a>(c: &[usize], all: &'a [Change]) -> Option<&'a Change> {
|
||||
match c {
|
||||
@@ -265,6 +394,8 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result<Plan,
|
||||
let mut mc: Vec<Change> = m.changes.into_iter().filter(merges).collect();
|
||||
let mut conflicts = Vec::new();
|
||||
let mut lists = Vec::new();
|
||||
let mut derived = BTreeSet::new();
|
||||
let mut fields = BTreeMap::new();
|
||||
let km = by_key(&mc);
|
||||
for (k, bi) in by_key(&bc) {
|
||||
// Deletions conflict with edits (below); images are content-addressed, so never differ.
|
||||
@@ -279,13 +410,28 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result<Plan,
|
||||
if same {
|
||||
continue;
|
||||
}
|
||||
match merged_list(base, &k, only(&bi, &bc), only(mi, &mc)) {
|
||||
Some(value) => lists.push((bi, mi.clone(), value)),
|
||||
None => conflicts.push(Conflict { key: k, deleted_by: None, branch: bi, main: mi.clone() }),
|
||||
if derived_on_both(branch, main, &k) {
|
||||
derived.insert(k);
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = merged_list(base, &k, only(&bi, &bc), only(mi, &mc)) {
|
||||
lists.push((bi, mi.clone(), value));
|
||||
continue;
|
||||
}
|
||||
match (&k, merged_fields(base, &k, only(&bi, &bc), only(mi, &mc))) {
|
||||
(_, Some(f)) if f.branch.is_empty() => lists.push((bi, mi.clone(), Json(Value::Object(f.merged)))),
|
||||
(Key::Node(id, prop), Some(f)) => {
|
||||
for name in f.branch.keys() {
|
||||
let key = Key::Field(*id, *prop, name.clone());
|
||||
conflicts.push(Conflict { key, deleted_by: None, branch: bi.clone(), main: mi.clone() });
|
||||
}
|
||||
fields.insert(k, f);
|
||||
}
|
||||
_ => conflicts.push(Conflict { key: k, deleted_by: None, branch: bi, main: mi.clone() }),
|
||||
}
|
||||
}
|
||||
// Both sides' writes of a list merged item by item become the merged list, so merging either
|
||||
// way writes it.
|
||||
// Both sides' writes of a list merged item by item (or of auto layout settings merged field by
|
||||
// field) become the merged value, so merging either way writes it.
|
||||
for (bi, mi, value) in lists {
|
||||
for (changes, at) in [(&mut bc, bi), (&mut mc, mi)] {
|
||||
for i in at {
|
||||
@@ -313,6 +459,8 @@ pub fn plan(base: &Document, branch: &Document, main: &Document) -> Result<Plan,
|
||||
branch: bc,
|
||||
main: mc,
|
||||
conflicts,
|
||||
derived,
|
||||
fields,
|
||||
branch_positions: b.replica.positions().clone(),
|
||||
main_positions: m.replica.positions().clone(),
|
||||
})
|
||||
@@ -332,6 +480,42 @@ impl Plan {
|
||||
}
|
||||
}
|
||||
|
||||
/// Layer properties both sides changed to values both files work out for themselves (a size or
|
||||
/// place auto layout gives, a value an instance takes from its main): not conflicts, and
|
||||
/// merging leaves the file merged into its own value for the merged file to work out again.
|
||||
pub fn derived(&self) -> &BTreeSet<Key> {
|
||||
&self.derived
|
||||
}
|
||||
|
||||
/// The branch's and main's values of field conflict `key` ([`Key::Field`]); `None` for other
|
||||
/// keys.
|
||||
pub fn field_values(&self, key: &Key) -> Option<(&Value, &Value)> {
|
||||
let Key::Field(id, prop, name) = key else { return None };
|
||||
let f = self.fields.get(&Key::Node(*id, *prop))?;
|
||||
Some((f.branch.get(name)?, f.main.get(name)?))
|
||||
}
|
||||
|
||||
/// The side `pick` chose for each field in conflict of every property merged field by field,
|
||||
/// written into the value merged from the other fields: the writes that replace both sides'
|
||||
/// own writes of those properties.
|
||||
fn field_writes(&self, pick: &dyn Fn(&Key) -> Option<Side>) -> Vec<Change> {
|
||||
let mut out = Vec::new();
|
||||
for (key, f) in &self.fields {
|
||||
let Key::Node(id, prop) = key else { continue };
|
||||
let mut value = f.merged.clone();
|
||||
for name in f.branch.keys() {
|
||||
let won = if pick(&Key::Field(*id, *prop, name.clone())) == Some(Side::Branch) { &f.branch } else { &f.main };
|
||||
match won.get(name) {
|
||||
Some(Value::Null) | None => value.remove(name),
|
||||
Some(v) => value.insert(name.clone(), v.clone()),
|
||||
};
|
||||
}
|
||||
out.push(Change::Set { id: *id, prop: *prop, value: Json(Value::Object(value)) });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `side`'s positions (fractional keys) of the layers, for applying its changes elsewhere.
|
||||
fn positions(&self, side: Side) -> &Positions {
|
||||
match side {
|
||||
Side::Branch => &self.branch_positions,
|
||||
@@ -355,7 +539,10 @@ impl Plan {
|
||||
let mut skip: BTreeSet<usize> = BTreeSet::new();
|
||||
let mut batch = Vec::new();
|
||||
for c in &self.conflicts {
|
||||
if pick(&c.key) != Some(from) {
|
||||
if matches!(c.key, Key::Field(..)) {
|
||||
// Written whole below, each field from the side picked for it.
|
||||
skip.extend(c.changes(from));
|
||||
} else if pick(&c.key) != Some(from) {
|
||||
skip.extend(c.changes(from));
|
||||
} else if let (Key::Layer(id), Some(by)) = (&c.key, c.deleted_by)
|
||||
&& by != from
|
||||
@@ -372,7 +559,11 @@ impl Plan {
|
||||
// shortcut: images a side stopped using are kept (unused ones cost file size only); drop
|
||||
// them if files grow.
|
||||
let kept = |c: &Change| !matches!(c, Change::Remove { entry: Entry::Image(_) });
|
||||
batch.extend(self.changes(from).iter().enumerate().filter(|(i, c)| !skip.contains(i) && kept(c)).map(|(_, c)| c.clone()));
|
||||
// Values both files work out for themselves stay as `into` has them; normalising the merged
|
||||
// file works them out again from what changed.
|
||||
let own = |c: &Change| key_of(c).is_some_and(|k| self.derived.contains(&k));
|
||||
batch.extend(self.changes(from).iter().enumerate().filter(|(i, c)| !skip.contains(i) && kept(c) && !own(c)).map(|(_, c)| c.clone()));
|
||||
batch.extend(self.field_writes(pick));
|
||||
let mut r = Replica::from_parts(into.clone(), self.positions(from.other()).clone());
|
||||
let applied = r.apply(&batch, Mode::Strict)?;
|
||||
Ok((r.document().clone(), applied.touched))
|
||||
|
||||
@@ -28,9 +28,15 @@ impl RouteEnd {
|
||||
/// How far an elbow connector going around the objects it joins keeps from them.
|
||||
const ROOM: f64 = 24.0;
|
||||
|
||||
/// The points a connector runs through from `from` to `to`: from the middle of the side of each
|
||||
/// box facing the other end (or the side it names), straight, in right angles, or (curved) its two
|
||||
/// ends with the curve's two control points between them. `None` when a point isn't finite.
|
||||
/// How far outside the border a connector attached to a side starts and ends (#1916): the stroke's
|
||||
/// round cap never lies on the fill, and elbows leaving one side share their first run along the
|
||||
/// side's normal (curves leave tangent to it), as FigJam's connectors do.
|
||||
pub const END_GAP: f64 = 4.0;
|
||||
|
||||
/// The points a connector runs through from `from` to `to`: from [`END_GAP`] outside the middle of
|
||||
/// the side of each box facing the other end (or the side it names), straight, in right angles, or
|
||||
/// (curved) its two ends with the curve's two control points between them. `None` when a point isn't
|
||||
/// finite.
|
||||
pub fn route_points(from: RouteEnd, to: RouteEnd, routing: Routing) -> Option<Vec<Point>> {
|
||||
let (p0, s0) = attach(from, to.centre());
|
||||
let (p1, s1) = attach(to, from.centre());
|
||||
@@ -169,8 +175,9 @@ fn attach(e: RouteEnd, other: Point) -> (Point, Option<Side>) {
|
||||
RouteEnd::Point(p) => (p, None),
|
||||
RouteEnd::Box(b, side) => {
|
||||
let side = named_or_facing(b, side, other);
|
||||
(side.point(b), Some(side))
|
||||
(side.point(b) + side.normal() * END_GAP, Some(side))
|
||||
}
|
||||
// Placed by hand: the point is the user's, gap or not.
|
||||
RouteEnd::On(b, side, p) => (p, Some(named_or_facing(b, side, other))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Connector routes (`board_route`): where an end attached to a side starts (#1916).
|
||||
|
||||
use figcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::board::{Routing, Side};
|
||||
use crate::board_route::{END_GAP, RouteEnd, route_points};
|
||||
|
||||
fn shape(x: f64, y: f64) -> Rect {
|
||||
Rect::new(x, y, x + 100.0, y + 60.0)
|
||||
}
|
||||
|
||||
/// An end attached to a side starts `END_GAP` outside the middle of that side, not on the border,
|
||||
/// so the stroke never lies on the fill (#1916: FigJam starts the line a few pixels outside).
|
||||
#[test]
|
||||
fn an_attached_end_starts_outside_the_border() {
|
||||
let (a, b) = (shape(0.0, 0.0), shape(300.0, 0.0));
|
||||
for routing in [Routing::Straight, Routing::Elbow, Routing::Curved] {
|
||||
let pts = route_points(RouteEnd::Box(a, Side::Auto), RouteEnd::Box(b, Side::Auto), routing).unwrap();
|
||||
assert_eq!(pts.first().copied(), Some(Point::new(100.0 + END_GAP, 30.0)), "{routing:?}: leaves the right side");
|
||||
assert_eq!(pts.last().copied(), Some(Point::new(300.0 - END_GAP, 30.0)), "{routing:?}: arrives at the left side");
|
||||
}
|
||||
// A named side, and the gap along its own normal.
|
||||
let pts = route_points(RouteEnd::Box(a, Side::Bottom), RouteEnd::Point(Point::new(50.0, 200.0)), Routing::Straight).unwrap();
|
||||
assert_eq!(pts.first().copied(), Some(Point::new(50.0, 60.0 + END_GAP)));
|
||||
}
|
||||
|
||||
/// Elbows leaving one side share their first run along its normal, and curves leave tangent to it:
|
||||
/// the second point of each lies straight out of the side.
|
||||
#[test]
|
||||
fn connectors_from_one_side_leave_together() {
|
||||
let a = shape(0.0, 0.0);
|
||||
let (up, down) = (RouteEnd::Box(shape(300.0, -200.0), Side::Auto), RouteEnd::Box(shape(300.0, 200.0), Side::Auto));
|
||||
for routing in [Routing::Elbow, Routing::Curved] {
|
||||
let p = route_points(RouteEnd::Box(a, Side::Right), up, routing).unwrap();
|
||||
let q = route_points(RouteEnd::Box(a, Side::Right), down, routing).unwrap();
|
||||
assert_eq!(p[0], q[0], "{routing:?}: the same start");
|
||||
assert_eq!(p[1].y, p[0].y, "{routing:?}: out along the side's normal first");
|
||||
assert_eq!(q[1].y, q[0].y, "{routing:?}: out along the side's normal first");
|
||||
}
|
||||
}
|
||||
|
||||
/// A point placed by hand on the object (`RouteEnd::On`) is the user's: no gap is added.
|
||||
#[test]
|
||||
fn a_hand_placed_end_keeps_its_point() {
|
||||
let a = shape(0.0, 0.0);
|
||||
let on = Point::new(80.0, 60.0);
|
||||
let pts = route_points(RouteEnd::On(a, Side::Bottom, on), RouteEnd::Point(Point::new(80.0, 200.0)), Routing::Straight).unwrap();
|
||||
assert_eq!(pts.first().copied(), Some(on));
|
||||
}
|
||||
@@ -49,6 +49,65 @@ fn glass_values_are_clamped_and_reach_their_backdrop() {
|
||||
assert_eq!(hidden.backdrop_radius(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_frost_stays_within_the_reference_range() {
|
||||
// A file from before the 0–100 range draws its frost at 100, and reaches no further.
|
||||
let g = Glass { frost: 150.0, ..Glass::default() };
|
||||
assert_eq!(g.sanitized().frost, Glass::MAX_FROST);
|
||||
let e = Effect { kind: EffectKind::Glass(g), visible: true };
|
||||
let capped = Effect { kind: EffectKind::Glass(Glass { frost: Glass::MAX_FROST, ..Glass::default() }), visible: true };
|
||||
assert_eq!(e.backdrop_radius(), capped.backdrop_radius());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_fields_take_bound_numbers_as_the_panel_shows_them() {
|
||||
let mut g = Glass::default();
|
||||
for (field, x) in [
|
||||
("lightAngle", 30.0),
|
||||
("lightIntensity", 25.0),
|
||||
("refraction", 40.0),
|
||||
("depth", 12.0),
|
||||
("dispersion", 10.0),
|
||||
("radius", 8.0),
|
||||
("splay", 50.0),
|
||||
] {
|
||||
assert!(g.set_bound(field, x), "{field}");
|
||||
}
|
||||
assert_eq!((g.light_angle, g.light_intensity, g.refraction, g.depth, g.dispersion, g.frost, g.splay), (30.0, 0.25, 0.4, 12.0, 0.1, 8.0, 0.5));
|
||||
// Out of range: clamped to the field's range.
|
||||
for (field, x) in [("lightIntensity", 250.0), ("depth", 0.0), ("radius", 150.0), ("splay", -5.0)] {
|
||||
assert!(g.set_bound(field, x), "{field}");
|
||||
}
|
||||
assert_eq!((g.light_intensity, g.depth, g.frost, g.splay), (1.0, 1.0, Glass::MAX_FROST, 0.0));
|
||||
// A field glass doesn't have, or a value that isn't a number, changes nothing.
|
||||
let before = g.clone();
|
||||
assert!(!g.set_bound("spread", 3.0));
|
||||
assert!(!g.set_bound("splay", f64::NAN));
|
||||
assert!(!g.set_bound("depth", f64::INFINITY));
|
||||
assert_eq!(g, before);
|
||||
for field in Glass::BOUND_FIELDS {
|
||||
assert!(Glass::default().set_bound(field, 1.0), "{field}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_light_angle_turns_round_instead_of_stopping() {
|
||||
// An angle past ±180 is the same direction a turn away (270 is -90), not the end of the range.
|
||||
for (x, want) in [(270.0, -90.0), (-270.0, 90.0), (540.0, 180.0), (-180.0, 180.0), (180.0, 180.0), (720.5, 0.5), (-360.0, 0.0), (-45.0, -45.0)] {
|
||||
let mut g = Glass::default();
|
||||
assert!(g.set_bound("lightAngle", x), "{x}");
|
||||
assert_eq!(g.light_angle, want, "{x}");
|
||||
}
|
||||
// A whole turn back is 0, not -0, so a saved file says 0.
|
||||
let mut g = Glass::default();
|
||||
assert!(g.set_bound("lightAngle", -360.0));
|
||||
assert!(g.light_angle == 0.0 && g.light_angle.is_sign_positive());
|
||||
// A huge angle still lands in range.
|
||||
let mut g = Glass::default();
|
||||
assert!(g.set_bound("lightAngle", 1e300));
|
||||
assert!((-180.0..=180.0).contains(&g.light_angle) && g.light_angle != -180.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effect_colours() {
|
||||
let mut e = Effect::noise();
|
||||
|
||||
+10
-1
@@ -27,7 +27,7 @@ pub mod vector_faces;
|
||||
pub mod vector_regions;
|
||||
|
||||
pub use board::*;
|
||||
pub use board_route::{RouteEnd, attached_sides, path_handles, reshaped, route_along, route_points};
|
||||
pub use board_route::{END_GAP, RouteEnd, attached_sides, path_handles, reshaped, route_along, route_points};
|
||||
pub use colors::*;
|
||||
pub use comments::*;
|
||||
pub use facilitation::*;
|
||||
@@ -598,7 +598,14 @@ impl Document {
|
||||
let Some(n) = self.node(id) else { return Affine::IDENTITY };
|
||||
let mut t = n.transform;
|
||||
let mut parent = n.parent;
|
||||
// More hops than nodes means the parent chain loops (a restored snapshot can leave one):
|
||||
// stop rather than spin.
|
||||
let mut hops = 0;
|
||||
while let Some(p) = parent.and_then(|p| self.node(p)).filter(|p| p.kind.bounds_follow_children()) {
|
||||
hops += 1;
|
||||
if hops > self.nodes.len() {
|
||||
break;
|
||||
}
|
||||
t = p.transform * t;
|
||||
parent = p.parent;
|
||||
}
|
||||
@@ -1303,6 +1310,8 @@ fn remap_action(a: &mut proto::Action, map: &BTreeMap<NodeId, NodeId>) {
|
||||
#[cfg(test)]
|
||||
mod blur_tests;
|
||||
#[cfg(test)]
|
||||
mod board_route_tests;
|
||||
#[cfg(test)]
|
||||
mod board_shape_tests;
|
||||
#[cfg(test)]
|
||||
mod boolean_hit_tests;
|
||||
|
||||
+68
-5
@@ -660,9 +660,12 @@ pub struct Glass {
|
||||
pub depth: f64,
|
||||
/// Colour splitting along the curved edge (0–1).
|
||||
pub dispersion: f64,
|
||||
/// Blur of the backdrop (a blur radius, document units).
|
||||
/// 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,
|
||||
}
|
||||
|
||||
@@ -672,7 +675,43 @@ impl Default for Glass {
|
||||
}
|
||||
}
|
||||
|
||||
/// `deg` (finite) as the same direction within -180 (exclusive) to 180 degrees.
|
||||
fn wrap_degrees(deg: f64) -> f64 {
|
||||
// `+ 0.0`: -0 reads as 0, so a saved file says 0.
|
||||
let a = deg.rem_euclid(360.0) + 0.0;
|
||||
if a > 180.0 { a - 360.0 } else { a }
|
||||
}
|
||||
|
||||
impl Glass {
|
||||
/// The most frost a glass takes, as the reference tool's Frost field (0–100).
|
||||
pub const MAX_FROST: f64 = 100.0;
|
||||
|
||||
/// The fields a variable binds on a glass effect (`effects/<i>/<field>`), see [`Self::set_bound`].
|
||||
pub const BOUND_FIELDS: [&'static str; 7] = ["lightAngle", "lightIntensity", "refraction", "depth", "dispersion", "radius", "splay"];
|
||||
|
||||
/// Sets `field` (one of [`Self::BOUND_FIELDS`]) from a bound number variable, read as the
|
||||
/// Design panel's field shows it: intensity, refraction, dispersion and splay in percent, the
|
||||
/// frost as `radius`, each clamped to the field's range, and the light angle turned into
|
||||
/// -180–180 (270 is -90, the same way). False for a field glass doesn't have or a value that
|
||||
/// isn't finite (nothing changes).
|
||||
pub fn set_bound(&mut self, field: &str, x: f64) -> bool {
|
||||
if !x.is_finite() {
|
||||
return false;
|
||||
}
|
||||
let pct = (x / 100.0).clamp(0.0, 1.0);
|
||||
match field {
|
||||
"lightAngle" => self.light_angle = wrap_degrees(x),
|
||||
"lightIntensity" => self.light_intensity = pct,
|
||||
"refraction" => self.refraction = pct,
|
||||
"depth" => self.depth = x.clamp(1.0, 1000.0),
|
||||
"dispersion" => self.dispersion = pct,
|
||||
"radius" => self.frost = x.clamp(0.0, Self::MAX_FROST),
|
||||
"splay" => self.splay = pct,
|
||||
_ => return false,
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// The values clamped to their ranges (non-finite ones at their defaults).
|
||||
pub fn sanitized(&self) -> Glass {
|
||||
let d = Glass::default();
|
||||
@@ -683,15 +722,23 @@ impl Glass {
|
||||
refraction: ok(self.refraction, d.refraction, 0.0, 1.0),
|
||||
depth: ok(self.depth, d.depth, 1.0, 1000.0),
|
||||
dispersion: ok(self.dispersion, d.dispersion, 0.0, 1.0),
|
||||
frost: ok(self.frost, d.frost, 0.0, 1000.0),
|
||||
frost: ok(self.frost, d.frost, 0.0, Self::MAX_FROST),
|
||||
splay: ok(self.splay, d.splay, 0.0, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Furthest the curved edge moves the backdrop (document units).
|
||||
/// How far `dispersion` splits the colour channels, as a share of the bend: at full dispersion
|
||||
/// red moves up to this much further and blue as much less. Matched to the reference tool:
|
||||
/// across a black-to-white edge in the bend, red and blue part about 4.7 px at 100% and 2.4 px
|
||||
/// at 50% (at 2×, depth 20, refraction 80%).
|
||||
pub const DISPERSION_SPREAD: f64 = 0.16;
|
||||
|
||||
/// Furthest the curved edge moves the backdrop (document units): at most `depth` × `refraction`
|
||||
/// (all of it a bevel in from the edge), and red's dispersion steps up to
|
||||
/// [`Self::DISPERSION_SPREAD`] × `dispersion` times that again.
|
||||
pub fn max_bend(&self) -> f64 {
|
||||
let g = self.sanitized();
|
||||
g.refraction * g.depth * (1.0 + g.dispersion * 0.25)
|
||||
g.refraction * g.depth * (1.0 + Self::DISPERSION_SPREAD * g.dispersion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1481,6 +1528,9 @@ pub struct ExportSetting {
|
||||
/// Android Vector Drawable: what to do with what a drawable can't hold exactly.
|
||||
#[serde(skip_serializing_if = "VdCompat::is_default")]
|
||||
pub vd_compat: VdCompat,
|
||||
/// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]).
|
||||
#[serde(skip_serializing_if = "is_default_quality")]
|
||||
pub quality: u8,
|
||||
}
|
||||
|
||||
/// How an Android Vector Drawable export handles what the format can't draw exactly (layer
|
||||
@@ -1531,6 +1581,7 @@ impl Default for ExportSetting {
|
||||
svg_id_attribute: false,
|
||||
svg_simplify_stroke: true,
|
||||
vd_compat: VdCompat::BestEffort,
|
||||
quality: ExportSetting::DEFAULT_QUALITY,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1543,7 +1594,19 @@ fn is_false(b: &bool) -> bool {
|
||||
!*b
|
||||
}
|
||||
|
||||
fn is_default_quality(q: &u8) -> bool {
|
||||
*q == ExportSetting::DEFAULT_QUALITY
|
||||
}
|
||||
|
||||
impl ExportSetting {
|
||||
/// The JPG quality a setting has unless it sets one.
|
||||
pub const DEFAULT_QUALITY: u8 = 90;
|
||||
|
||||
/// The JPG quality made safe: within `1..=100`.
|
||||
pub fn quality(&self) -> u8 {
|
||||
self.quality.clamp(1, 100)
|
||||
}
|
||||
|
||||
/// The row adding a setting after `last` creates: 1x first, then one scale step up with a
|
||||
/// matching "@2x"-style suffix.
|
||||
pub fn next_after(last: Option<&ExportSetting>) -> ExportSetting {
|
||||
|
||||
@@ -861,6 +861,19 @@ fn the_colour_walker_visits_calculation_literals() {
|
||||
assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_relative_ends_on_a_looping_parent_chain() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let f = d.create(p, NodeKind::Group, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let g = d.create(f, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
// F's parent is its own child G (as a restored snapshot can leave things).
|
||||
v["nodes"][f.to_string()]["parent"] = serde_json::json!(g);
|
||||
let bad: Document = serde_json::from_value(v).unwrap();
|
||||
let _ = bad.frame_relative(g);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_children_removes_every_subtree_and_keeps_the_parent() {
|
||||
let mut doc = Document::new("T");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -29,12 +29,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
|
||||
};
|
||||
ok.then_some(Color)
|
||||
}
|
||||
(StyleKind::Effect { effects }, Some((BoundList::Effects, i, field))) => match (&effects.get(i)?.kind, field) {
|
||||
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "color") => Some(Color),
|
||||
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "radius" | "spread" | "offsetX" | "offsetY") => Some(Number),
|
||||
(EffectKind::LayerBlur(_) | EffectKind::BackgroundBlur(_), "radius" | "startRadius") => Some(Number),
|
||||
_ => None,
|
||||
},
|
||||
(StyleKind::Effect { effects }, Some((BoundList::Effects, i, field))) => effect_field_type(&effects.get(i)?.kind, field),
|
||||
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, "size" | "count" | "gutter" | "offset"))) => grids.get(i).map(|_| Number),
|
||||
(StyleKind::Text { .. }, None) => match key {
|
||||
"fontSize" | "lineHeight" | "letterSpacing" => Some(Number),
|
||||
@@ -45,6 +40,19 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The type of variable an effect of `kind` takes on `field` (the last part of an
|
||||
/// `effects/<i>/<field>` key), on a layer or in an effect style; None when it has no such field.
|
||||
pub fn effect_field_type(kind: &EffectKind, field: &str) -> Option<VariableType> {
|
||||
use VariableType::{Color, Number};
|
||||
match (kind, field) {
|
||||
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "color") => Some(Color),
|
||||
(EffectKind::DropShadow(_) | EffectKind::InnerShadow(_), "radius" | "spread" | "offsetX" | "offsetY") => Some(Number),
|
||||
(EffectKind::LayerBlur(_) | EffectKind::BackgroundBlur(_), "radius" | "startRadius") => Some(Number),
|
||||
(EffectKind::Glass(_), f) if figcraft_doc::Glass::BOUND_FIELDS.contains(&f) => Some(Number),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The variable scopes a picker offers for `key` of a style of `kind`: a variable scoped to any one
|
||||
/// of them fits. A paint style's colors take fill and stroke variables, an effect style's fields
|
||||
/// take effect variables, and a text style's fields take the variables of their font property.
|
||||
|
||||
@@ -98,6 +98,75 @@ fn effect_moves_and_removals_reject_junk() {
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
|
||||
}
|
||||
|
||||
fn glass(s: &Session, r: NodeId) -> figcraft_doc::Glass {
|
||||
match s.doc().unwrap().node(r).unwrap().effects.first().map(|e| &e.kind) {
|
||||
Some(EffectKind::Glass(g)) => g.clone(),
|
||||
k => panic!("{k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_fields_take_number_variables() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "effect.add", json!({"ids": [r], "type": "glass"}));
|
||||
// Percent fields read the variable as the Design panel shows them; `radius` is the frost.
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/splay", "variable": "large"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/radius", "variable": "small"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/depth", "variable": "large"}));
|
||||
let g = glass(&s, r);
|
||||
assert_eq!((g.splay, g.frost, g.depth), (0.3, 2.0, 30.0));
|
||||
// A new value follows, clamped to the field's range (frost stops at 100).
|
||||
ex(&mut s, "variable.set", json!({"variable": "small", "value": 150}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "large", "value": 75}));
|
||||
let g = glass(&s, r);
|
||||
assert_eq!((g.splay, g.frost, g.depth), (0.75, figcraft_doc::Glass::MAX_FROST, 75.0));
|
||||
// A colour variable doesn't fit a glass field.
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [r], "field": "effects/0/refraction", "variable": "brand"})).is_err());
|
||||
// An effect style binds them too, and every layer using it follows.
|
||||
let fx = ex_value(&mut s, "style.create", json!({"type": "effect", "name": "Pane", "from": r}))["id"].as_str().unwrap().to_string();
|
||||
ex(&mut s, "style.bind", json!({"style": fx, "field": "effects/0/refraction", "variable": "large"}));
|
||||
assert_eq!(glass(&s, r).refraction, 0.75);
|
||||
assert!(s.execute("style.bind", &json!({"style": fx, "field": "effects/0/spread", "variable": "large"})).is_err(), "glass has no spread");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_binds_only_fields_its_effects_have() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "effect.add", json!({"ids": [r], "type": "dropShadow"}));
|
||||
ex(&mut s, "effect.add", json!({"ids": [r], "type": "glass"}));
|
||||
// As an effect style does: a drop shadow has no refraction, glass no spread, there is no
|
||||
// effect 5, and a key that names no effect's field names nothing. Nothing is bound.
|
||||
for (field, var, says) in [
|
||||
("effects/0/refraction", "large", "no field"),
|
||||
("effects/1/spread", "large", "no field"),
|
||||
("effects/5/radius", "large", "no field"),
|
||||
("effects/x/radius", "large", "no field"),
|
||||
("effects/0", "large", "no field"),
|
||||
("effects", "large", "no field"),
|
||||
("effects/1/lightAngle", "brand", "doesn't take a color variable"),
|
||||
("effects/0/color", "large", "doesn't take a number variable"),
|
||||
] {
|
||||
let e = s.execute("variable.bind", &json!({"ids": [r], "field": field, "variable": var})).unwrap_err().to_string();
|
||||
assert!(e.contains(says), "{field} ← {var}: {e}");
|
||||
}
|
||||
assert!(s.doc().unwrap().node(r).unwrap().bound.is_empty());
|
||||
// The fields each effect has still bind.
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/spread", "variable": "small"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/1/refraction", "variable": "large"}));
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().bound.len(), 2);
|
||||
// A binding an older file already has on a field its effect lacks does nothing, and unbinds.
|
||||
let large = s.doc().unwrap().variables.get("large").unwrap().id.clone();
|
||||
s.doc_mut().unwrap().get_mut(r).unwrap().bound.insert("effects/0/refraction".into(), large);
|
||||
ex(&mut s, "variable.set", json!({"variable": "large", "value": 40}));
|
||||
assert!(matches!(&s.doc().unwrap().node(r).unwrap().effects[0].kind, EffectKind::DropShadow(_)));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "effects/0/refraction"}));
|
||||
assert!(!s.doc().unwrap().node(r).unwrap().bound.contains_key("effects/0/refraction"));
|
||||
}
|
||||
|
||||
fn ex_value(s: &mut Session, cmd: &str, p: serde_json::Value) -> serde_json::Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||||
}
|
||||
|
||||
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
|
||||
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! command and a drag of the selected connector's end), curved connectors, and elbows that go
|
||||
//! around the objects they join.
|
||||
|
||||
use figcraft_doc::{BoardItem, End, NodeKind, Routing, Side};
|
||||
use figcraft_doc::{BoardItem, END_GAP, End, NodeKind, Routing, Side};
|
||||
use figcraft_geom::{Point, Rect};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -27,7 +27,7 @@ fn reattach_moves_one_end_and_undoes() {
|
||||
s.execute("board.reattach", &json!({"id": k, "end": "to", "target": c, "side": "top"})).unwrap();
|
||||
assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top, at: None });
|
||||
assert_eq!(connector(&s, k).from.node(), Some(a));
|
||||
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 0.0)), "arrives on c's top: {:?}", drawn(&s, k));
|
||||
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, -END_GAP)), "arrives END_GAP above c's top: {:?}", drawn(&s, k));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(connector(&s, k).to.node(), Some(b), "one undo step");
|
||||
// Off into the canvas: the end stays there, free. The selection is the default connector.
|
||||
@@ -107,9 +107,9 @@ fn curved_connectors_leave_and_arrive_along_the_sides() {
|
||||
let NodeKind::Vector { network } = &n.kind else { panic!("a vector") };
|
||||
assert_eq!((network.vertices.len(), network.segments.len()), (2, 1));
|
||||
assert!(!network.segments[0].is_straight(), "one curve");
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]);
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(104.0, 50.0), Point::new(296.0, 50.0)]);
|
||||
// Side to side in a line: the curve's control points lie on that line, so its box is flat.
|
||||
assert!(n.height.abs() < 1e-9 && (n.width - 200.0).abs() < 1e-9, "{} × {}", n.width, n.height);
|
||||
assert!(n.height.abs() < 1e-9 && (n.width - 192.0).abs() < 1e-9, "{} × {}", n.width, n.height);
|
||||
// From a's bottom to c's top: out downwards and in from above; the box holds the whole curve.
|
||||
let k2 = id(
|
||||
&s.execute("board.connector", &json!({"from": a, "to": c, "fromSide": "bottom", "toSide": "top", "style": "curved", "label": "x"})).unwrap(),
|
||||
@@ -146,18 +146,18 @@ fn elbows_go_around_the_objects_they_join() {
|
||||
let pts = drawn(&s, k2);
|
||||
let (ra, rb) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(300.0, 0.0, 400.0, 100.0));
|
||||
assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}");
|
||||
assert_eq!(pts.first().copied(), Some(Point::new(0.0, 50.0)));
|
||||
assert_eq!(pts.last().copied(), Some(Point::new(400.0, 50.0)));
|
||||
assert_eq!(pts.first().copied(), Some(Point::new(-END_GAP, 50.0)));
|
||||
assert_eq!(pts.last().copied(), Some(Point::new(400.0 + END_GAP, 50.0)));
|
||||
assert_eq!(pts.len(), 6, "out, over, down and in: {pts:?}");
|
||||
assert!(pts.iter().all(|p| p.y <= 50.0), "over the top, the nearer way round: {pts:?}");
|
||||
assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles only");
|
||||
// Facing each other, the short way stays.
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]);
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(104.0, 50.0), Point::new(296.0, 50.0)]);
|
||||
// An end at the bottom of b leaving towards a above it: around b's side and up.
|
||||
s.execute("board.reattach", &json!({"id": k2, "end": "to", "target": b, "side": "bottom"})).unwrap();
|
||||
let pts = drawn(&s, k2);
|
||||
assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}");
|
||||
assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0)));
|
||||
assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0 + END_GAP)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -202,8 +202,12 @@ fn the_connector_being_drawn_is_the_one_made() {
|
||||
// Over b: its four attach points, the one the end is at lit, and b outlined.
|
||||
assert_eq!(ov.drop_target, Some(b));
|
||||
let lit: Vec<[f64; 2]> = ov.attach.iter().filter(|a| a.1).map(|a| a.0).collect();
|
||||
// The lit attach point is the side's middle, on the border; the route's end sits
|
||||
// END_GAP outside it along the side's normal (#1916).
|
||||
let end = *drawn(&s, drawing.id).last().unwrap();
|
||||
assert_eq!((ov.attach.len(), lit), (4, vec![[end.x, end.y]]), "{style}");
|
||||
assert_eq!(ov.attach.len(), 4, "{style}");
|
||||
let [lit] = lit.as_slice() else { panic!("{style}: one lit attach point, got {lit:?}") };
|
||||
assert!(((lit[0] - end.x).abs() + (lit[1] - end.y).abs() - END_GAP).abs() < 1e-9, "{style}: {lit:?} is END_GAP inside {end:?}");
|
||||
} else {
|
||||
assert!(ov.attach.is_empty() && ov.drop_target.is_none(), "a free end");
|
||||
}
|
||||
@@ -259,7 +263,7 @@ fn an_end_dropped_along_a_side_attaches_to_that_side() {
|
||||
s.pointer(&ev(PointerKind::Up, drop)).unwrap();
|
||||
assert_eq!(connector(&s, k).from, End::Node { node: a, side, at: None }, "{drop:?}");
|
||||
if side != Side::Auto {
|
||||
assert_eq!(drawn(&s, k)[0], side.point(bounds), "the route leaves from that side");
|
||||
assert_eq!(drawn(&s, k)[0], side.point(bounds) + side.normal() * END_GAP, "the route leaves from that side, END_GAP out");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,5 +311,5 @@ fn a_connector_end_snaps_to_the_attach_point_under_the_pointer() {
|
||||
(connector(&s, k).from, connector(&s, k).to),
|
||||
(End::Node { node: a, side: Side::Right, at: None }, End::Node { node: b, side: Side::Top, at: None })
|
||||
);
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(350.0, 200.0)]);
|
||||
assert_eq!(drawn(&s, k), vec![Point::new(104.0, 50.0), Point::new(350.0, 196.0)]);
|
||||
}
|
||||
@@ -11,8 +11,8 @@ use crate::board::{route, selected_connector_handles};
|
||||
use crate::board_tests::{board, connector, drawn, id, near};
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
|
||||
const A: Point = Point::new(100.0, 50.0);
|
||||
const B: Point = Point::new(300.0, 250.0);
|
||||
const A: Point = Point::new(104.0, 50.0);
|
||||
const B: Point = Point::new(296.0, 250.0);
|
||||
|
||||
/// Shapes a (at the origin) and b (down and to the right), 100 × 100, joined side to side.
|
||||
fn joined(style: &str) -> (Session, NodeId, NodeId, NodeId) {
|
||||
@@ -80,11 +80,11 @@ fn a_reshaped_connector_keeps_the_sides_it_left_by() {
|
||||
fn the_first_and_last_runs_gain_a_run_and_stay_on_their_ends() {
|
||||
let (mut s, _, _, k) = joined("elbow");
|
||||
reshape(&mut s, k, 0.0, 150.0, 120.0).unwrap();
|
||||
assert_eq!(drawn(&s, k), [A, pt(124.0, 50.0), pt(124.0, 120.0), pt(200.0, 120.0), pt(200.0, 250.0), B]);
|
||||
assert_eq!(drawn(&s, k), [A, pt(128.0, 50.0), pt(128.0, 120.0), pt(200.0, 120.0), pt(200.0, 250.0), B]);
|
||||
let last = drawn(&s, k).len() - 2;
|
||||
reshape(&mut s, k, last as f64, 250.0, 200.0).unwrap();
|
||||
let pts = drawn(&s, k);
|
||||
assert_eq!(pts, [A, pt(124.0, 50.0), pt(124.0, 120.0), pt(200.0, 120.0), pt(200.0, 200.0), pt(276.0, 200.0), pt(276.0, 250.0), B]);
|
||||
assert_eq!(pts, [A, pt(128.0, 50.0), pt(128.0, 120.0), pt(200.0, 120.0), pt(200.0, 200.0), pt(272.0, 200.0), pt(272.0, 250.0), B]);
|
||||
assert!(right_angled(&pts));
|
||||
}
|
||||
|
||||
@@ -97,8 +97,8 @@ fn a_lone_run_gains_a_run_at_each_end() {
|
||||
let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap(), "id");
|
||||
assert_eq!(drawn(&s, k).len(), 2);
|
||||
reshape(&mut s, k, 0.0, 200.0, -40.0).unwrap();
|
||||
let (l, r) = (pt(100.0, 50.0), pt(300.0, 50.0));
|
||||
assert_eq!(drawn(&s, k), [l, pt(124.0, 50.0), pt(124.0, -40.0), pt(276.0, -40.0), pt(276.0, 50.0), r]);
|
||||
let (l, r) = (pt(104.0, 50.0), pt(296.0, 50.0));
|
||||
assert_eq!(drawn(&s, k), [l, pt(128.0, 50.0), pt(128.0, -40.0), pt(272.0, -40.0), pt(272.0, 50.0), r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -168,7 +168,7 @@ fn connectors_follow_what_they_join_and_undo_with_it() {
|
||||
let b = id(&s.execute("board.sticky", &json!({"x": 400, "y": 0})).unwrap(), "id");
|
||||
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "then"})).unwrap(), "id");
|
||||
let pts = drawn(&s, c);
|
||||
assert!(near(pts[0], Point::new(200.0, 100.0)) && near(pts[1], Point::new(400.0, 100.0)), "{pts:?}");
|
||||
assert!(near(pts[0], Point::new(204.0, 100.0)) && near(pts[1], Point::new(396.0, 100.0)), "{pts:?}");
|
||||
assert_eq!(node(&s, c).stroke.cap_end, StrokeCap::TriangleArrow);
|
||||
// Its label sits at the middle.
|
||||
let label = crate::board::label_of(s.doc().unwrap(), c).unwrap();
|
||||
@@ -178,19 +178,19 @@ fn connectors_follow_what_they_join_and_undo_with_it() {
|
||||
s.execute("board.setStyle", &json!({"ids": [c], "style": "elbow"})).unwrap();
|
||||
s.execute("transform.move", &json!({"ids": [b], "dx": 0, "dy": 300})).unwrap();
|
||||
let pts = drawn(&s, c);
|
||||
assert!(near(pts[0], Point::new(200.0, 100.0)) && near(*pts.last().unwrap(), Point::new(400.0, 400.0)), "{pts:?}");
|
||||
assert!(near(pts[0], Point::new(204.0, 100.0)) && near(*pts.last().unwrap(), Point::new(396.0, 400.0)), "{pts:?}");
|
||||
assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles: {pts:?}");
|
||||
// Undo takes the move back and the route with it.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0)));
|
||||
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(396.0, 100.0)));
|
||||
// A deleted end stays where it was, as a point.
|
||||
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
|
||||
assert!(matches!(connector(&s, c).to, End::Point { .. }));
|
||||
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(400.0, 100.0)));
|
||||
assert!(near(*drawn(&s, c).last().unwrap(), Point::new(396.0, 100.0)));
|
||||
// A connector moved on its own takes its free end along; the attached one stays.
|
||||
s.execute("transform.move", &json!({"ids": [c], "dx": 0, "dy": 50})).unwrap();
|
||||
assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 400.0 && y == 150.0), "{:?}", connector(&s, c));
|
||||
assert!(near(drawn(&s, c)[0], Point::new(200.0, 100.0)));
|
||||
assert!(matches!(connector(&s, c).to, End::Point { x, y } if x == 396.0 && y == 150.0), "{:?}", connector(&s, c));
|
||||
assert!(near(drawn(&s, c)[0], Point::new(204.0, 100.0)));
|
||||
// Deleting the connector takes its label.
|
||||
s.execute("edit.delete", &json!({"ids": [c]})).unwrap();
|
||||
assert!(!s.doc().unwrap().contains(label));
|
||||
@@ -210,8 +210,8 @@ fn routes_are_the_same_for_the_same_document() {
|
||||
let doc = s.doc().unwrap();
|
||||
let r = route(doc, &c).unwrap();
|
||||
assert_eq!(r, route(&doc.clone(), &c).unwrap());
|
||||
// Down out of the bottom, across halfway, down into the point.
|
||||
assert_eq!(r, vec![Point::new(80.0, 160.0), Point::new(80.0, 330.0), Point::new(300.0, 330.0), Point::new(300.0, 500.0)]);
|
||||
// Down out of the bottom (END_GAP below it), across halfway, down into the point.
|
||||
assert_eq!(r, vec![Point::new(80.0, 164.0), Point::new(80.0, 332.0), Point::new(300.0, 332.0), Point::new(300.0, 500.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -338,7 +338,7 @@ fn boards_sync_in_live_sessions() {
|
||||
let doc = room.server.document();
|
||||
let NodeKind::Vector { network } = &doc.node(c).unwrap().kind else { panic!("not a vector") };
|
||||
let end = doc.world_transform(c) * network.vertices.last().unwrap().point();
|
||||
assert_eq!(end, Point::new(400.0, 350.0), "to the moved sticky's facing side");
|
||||
assert_eq!(end, Point::new(396.0, 350.0), "to END_GAP before the moved sticky's facing side");
|
||||
assert_eq!(doc.node(a).unwrap().fills[0].solid_color(), board_color("violet"));
|
||||
}
|
||||
|
||||
@@ -410,7 +410,9 @@ fn a_press_on_a_stickys_background_picks_and_drags_it() {
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 100.0, 450.0, 1.0)).unwrap();
|
||||
assert_eq!(s.selection(), vec![a]);
|
||||
assert_eq!(node(&s, a).transform.translation().y, 300.0);
|
||||
assert!(near(drawn(&s, c)[0], Point::new(200.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c));
|
||||
// Its own connector's start, END_GAP outside its edge, is no snap target for it (#1916).
|
||||
assert_eq!(node(&s, a).transform.translation().x, 0.0, "not nudged sideways by its own connector");
|
||||
assert!(near(drawn(&s, c)[0], Point::new(204.0, 400.0)), "the connector followed the drag: {:?}", drawn(&s, c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -100,18 +100,18 @@ fn routes_take_named_sides_and_free_points() {
|
||||
let side = |n, side| End::Node { node: n, side, at: None };
|
||||
let p = |x, y| End::Point { x, y };
|
||||
let pt = Point::new;
|
||||
// Side to side: out, across halfway, in.
|
||||
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(100.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(300.0, 350.0)]);
|
||||
// Side to side: out (from END_GAP outside each side), across halfway, in.
|
||||
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Left)), vec![pt(104.0, 50.0), pt(200.0, 50.0), pt(200.0, 350.0), pt(296.0, 350.0)]);
|
||||
// A side and a top or bottom: one turn.
|
||||
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(100.0, 50.0), pt(350.0, 50.0), pt(350.0, 300.0)]);
|
||||
assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 100.0), pt(50.0, 350.0), pt(300.0, 350.0)]);
|
||||
assert_eq!(elbow(side(a, Side::Right), side(b, Side::Top)), vec![pt(104.0, 50.0), pt(350.0, 50.0), pt(350.0, 296.0)]);
|
||||
assert_eq!(elbow(side(a, Side::Bottom), side(b, Side::Left)), vec![pt(50.0, 104.0), pt(50.0, 350.0), pt(296.0, 350.0)]);
|
||||
// Two free points: the longer way first; in a line, no turns.
|
||||
assert_eq!(elbow(p(0.0, 0.0), p(100.0, 40.0)), vec![pt(0.0, 0.0), pt(50.0, 0.0), pt(50.0, 40.0), pt(100.0, 40.0)]);
|
||||
assert_eq!(elbow(p(0.0, 0.0), p(100.0, 0.0)), vec![pt(0.0, 0.0), pt(100.0, 0.0)]);
|
||||
// An automatic side faces the other end.
|
||||
let straight =
|
||||
route(doc, &figcraft_doc::Connector { from: side(a, Side::Auto), to: p(50.0, 500.0), routing: figcraft_doc::Routing::Straight, path: None });
|
||||
assert_eq!(straight.unwrap(), vec![pt(50.0, 100.0), pt(50.0, 500.0)]);
|
||||
assert_eq!(straight.unwrap(), vec![pt(50.0, 104.0), pt(50.0, 500.0)]);
|
||||
assert_eq!(Side::from_id("TOP"), Some(Side::Top));
|
||||
assert_eq!(Side::from_id("inside"), None);
|
||||
// The same place twice is refused, as are a side that doesn't exist and a point without numbers.
|
||||
@@ -128,10 +128,10 @@ fn a_connector_follows_a_layer_whose_section_moves_and_ignores_unrelated_edits()
|
||||
let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id");
|
||||
let b = id(&s.execute("board.stamp", &json!({"x": 800, "y": 150})).unwrap(), "id");
|
||||
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight"})).unwrap(), "id");
|
||||
assert!(near(drawn(&s, c)[0], Point::new(250.0, 150.0)));
|
||||
assert!(near(drawn(&s, c)[0], Point::new(254.0, 150.0)));
|
||||
// Only the section is edited; the sticky inside it moves with it, and so does the connector.
|
||||
s.execute("transform.move", &json!({"ids": [sec], "dx": 0, "dy": 100})).unwrap();
|
||||
assert!(near(drawn(&s, c)[0], Point::new(250.0, 250.0)), "{:?}", drawn(&s, c));
|
||||
assert!(near(drawn(&s, c)[0], Point::new(254.0, 250.0)), "{:?}", drawn(&s, c));
|
||||
// An edit that touches neither end leaves the connector as it was.
|
||||
let before = s.doc().unwrap().node_arc(c).cloned().unwrap();
|
||||
s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap();
|
||||
@@ -276,3 +276,29 @@ fn a_board_tool_left_on_presses_as_move_in_a_design_file() {
|
||||
assert_eq!(s.tool.tool, ToolId::Move);
|
||||
assert_eq!(s.selection(), vec![r], "the press selected as Move does");
|
||||
}
|
||||
|
||||
/// #1916: a connector's start, END_GAP outside the layer it leaves, is a snap line for other layers
|
||||
/// but not for that layer, nor for a section holding it (both carry the connector along), and nor
|
||||
/// is the connector's label.
|
||||
#[test]
|
||||
fn a_connector_joined_to_a_moving_layer_or_its_section_is_no_snap_target() {
|
||||
let mut s = board();
|
||||
let sec = id(&s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 300})).unwrap(), "id");
|
||||
let a = id(&s.execute("board.sticky", &json!({"x": 50, "y": 50, "parent": sec})).unwrap(), "id");
|
||||
let b = id(&s.execute("board.stamp", &json!({"x": 800, "y": 150})).unwrap(), "id");
|
||||
let c = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "straight", "label": "to"})).unwrap(), "id");
|
||||
// A layer the connector has nothing to do with.
|
||||
let d = id(&s.execute("board.sticky", &json!({"x": 0, "y": 900})).unwrap(), "id");
|
||||
let page = s.page().unwrap();
|
||||
let start = drawn(&s, c)[0].x;
|
||||
let label = crate::board::label_of(s.doc().unwrap(), c).unwrap();
|
||||
let lx = s.doc().unwrap().world_bounds(label).unwrap().x0;
|
||||
// A box whose left edge is 1 past a line snaps back by 1 when the line is a target.
|
||||
let pull = |s: &Session, moving: &[NodeId], x: f64| {
|
||||
let t = crate::snap::Targets::gather(s.doc().unwrap(), page, moving);
|
||||
t.snap_rect(figcraft_geom::Rect::new(x + 1.0, 600.0, x + 9.0, 610.0), 4.0).0
|
||||
};
|
||||
assert_eq!((pull(&s, &[d], start), pull(&s, &[d], lx)), (-1.0, -1.0), "for an unrelated layer, the connector and its label snap");
|
||||
assert_eq!(pull(&s, &[b], start), 0.0, "not for the layer it arrives at");
|
||||
assert_eq!((pull(&s, &[sec], start), pull(&s, &[sec], lx)), (0.0, 0.0), "not for the section the connector leaves from");
|
||||
}
|
||||
@@ -133,13 +133,19 @@ impl Session {
|
||||
(to.node().is_none() || to.node() != from.node()).then_some(to)
|
||||
}
|
||||
|
||||
/// The object `end` is on (if any) outlined, with its attach points; the one the end is at
|
||||
/// (`at`, else its named side's) lit.
|
||||
/// The object `end` is on (if any) outlined, with its attach points (the side middles, on the
|
||||
/// border); the one the end is at lit: the nearest to `at` (a route's end sits `END_GAP` outside
|
||||
/// its side's middle, #1916), else its named side's.
|
||||
fn show_attach(&mut self, end: Option<End>, at: Option<Point>) {
|
||||
let attach = match end {
|
||||
Some(End::Node { node, side, .. }) => self.doc().ok().and_then(|d| d.world_bounds(node)).map(|b| {
|
||||
let on = at.or_else(|| (side != Side::Auto).then(|| side.point(b)));
|
||||
SIDES.map(|s| s.point(b)).map(|p| ([p.x, p.y], on.is_some_and(|o| (o - p).hypot() < 1e-6))).to_vec()
|
||||
let pts = SIDES.map(|s| s.point(b));
|
||||
let lit = on.and_then(|o| {
|
||||
let near = pts.iter().enumerate().map(|(i, p)| (i, (o - *p).hypot())).filter(|(_, d)| *d <= figcraft_doc::END_GAP + 1e-6);
|
||||
near.min_by(|x, y| x.1.total_cmp(&y.1)).map(|(i, _)| i)
|
||||
});
|
||||
pts.iter().enumerate().map(|(i, p)| ([p.x, p.y], lit == Some(i))).collect()
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -193,6 +193,109 @@ fn branch_commands_refuse_what_makes_no_sense() {
|
||||
assert!(s.execute("branch.merge", &json!({"id": "nope"})).is_err());
|
||||
}
|
||||
|
||||
/// Node id `v` of a command's answer (`{"id": …}` or a bare id).
|
||||
fn idv(v: &Value) -> NodeId {
|
||||
NodeId(v.get("id").unwrap_or(v).as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// The issue #2026 file, with a branch of it made and shown: a component `Card` (horizontal auto
|
||||
/// layout hugging its contents, padding 12, one 40×20 rectangle) and an instance of it in a
|
||||
/// vertical auto layout frame hugging its contents. Returns (session, branch id, card, instance,
|
||||
/// frame, rectangle).
|
||||
fn card_file() -> (Session, String, NodeId, NodeId, NodeId, NodeId) {
|
||||
let mut s = Session::with_document(Document::new("Cards"));
|
||||
let props = json!({"layout": "horizontal", "padding": 12, "sizingH": "hug", "sizingV": "hug"});
|
||||
let frame = idv(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "name": "Card", "props": props})));
|
||||
let rect = idv(&ok(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": frame})));
|
||||
let card = idv(&ok(&mut s, "component.create", json!({"ids": [frame]})));
|
||||
let props = json!({"layout": "vertical", "padding": 0, "sizingH": "hug", "sizingV": "hug"});
|
||||
let stack = idv(&ok(&mut s, "frame.create", json!({"x": 0, "y": 200, "width": 100, "height": 100, "name": "Stack", "props": props})));
|
||||
let inst = idv(&ok(&mut s, "instance.create", json!({"component": card, "parent": stack})));
|
||||
let b = ok(&mut s, "branch.create", json!({"name": "padding 8"}));
|
||||
(s, b["id"].as_str().unwrap().to_string(), card, inst, stack, rect)
|
||||
}
|
||||
|
||||
/// Size and place of every layer of `doc` (what auto layout works out).
|
||||
fn geometry(doc: &Document) -> Vec<(NodeId, [f64; 8])> {
|
||||
doc.nodes()
|
||||
.map(|n| {
|
||||
let c = n.transform.as_coeffs();
|
||||
(n.id, [n.width, n.height, c[0], c[1], c[2], c[3], c[4], c[5]])
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Issue #2026: one auto layout edit on each side is one conflict (the field changed both ways),
|
||||
/// not one for every size and place auto layout works out from it; the merged file lays out
|
||||
/// from the padding picked.
|
||||
#[test]
|
||||
fn one_conflicting_auto_layout_edit_is_one_conflict() {
|
||||
for (pick, padding) in [("branch", 8.0), ("main", 16.0)] {
|
||||
let (mut s, id, card, inst, _, _) = card_file();
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 8}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 16}}));
|
||||
// What main looks like with the picked padding set directly.
|
||||
let mut direct = Session::with_document(s.doc().unwrap().clone());
|
||||
ok(&mut direct, "node.set", json!({"id": card, "props": {"padding": padding}}));
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
let conflicts = review["conflicts"].as_array().unwrap();
|
||||
assert_eq!(conflicts.len(), 1, "{review}");
|
||||
let key = format!("{card}/layout.padding");
|
||||
assert_eq!(conflicts[0]["key"], key.as_str());
|
||||
assert_eq!(
|
||||
(&conflicts[0]["property"], &conflicts[0]["branch"], &conflicts[0]["main"]),
|
||||
(&json!("auto layout padding"), &json!("8, 8, 8, 8"), &json!("16, 16, 16, 16"))
|
||||
);
|
||||
let err = s.execute("branch.merge", &json!({"id": id})).unwrap_err().to_string();
|
||||
assert!(err.contains(&key) && !err.contains("width"), "{err}");
|
||||
ok(&mut s, "branch.merge", json!({"id": id, "resolutions": {&key: pick}}));
|
||||
let doc = s.doc().unwrap();
|
||||
for n in [card, inst] {
|
||||
assert_eq!(doc.node(n).unwrap().layout.as_ref().unwrap().padding, [padding; 4], "{pick} wins");
|
||||
}
|
||||
assert_eq!(geometry(doc), geometry(direct.doc().unwrap()), "{pick} wins: laid out from the padding picked");
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto layout merges field by field: a gap changed only on the branch is kept whichever side's
|
||||
/// padding wins, both when merging and when updating the branch from main.
|
||||
#[test]
|
||||
fn auto_layout_fields_changed_one_way_survive_a_conflict() {
|
||||
for (cmd, pick, padding) in [("branch.merge", "main", 16.0), ("branch.merge", "branch", 8.0), ("branch.updateFromMain", "main", 16.0)] {
|
||||
let (mut s, id, card, _, _, _) = card_file();
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 8, "gap": 30}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 16, "mainAlign": "center"}}));
|
||||
let key = format!("{card}/layout.padding");
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
assert_eq!(review["conflicts"].as_array().unwrap().iter().map(|c| c["key"].clone()).collect::<Vec<_>>(), vec![json!(key)], "{review}");
|
||||
ok(&mut s, cmd, json!({"id": id, "resolutions": {&key: pick}}));
|
||||
if cmd == "branch.updateFromMain" {
|
||||
ok(&mut s, "doc.switch", json!({"index": 1}));
|
||||
}
|
||||
let layout = s.doc().unwrap().node(card).unwrap().layout.clone().unwrap();
|
||||
assert_eq!((layout.padding, layout.gap, layout.main_align), ([padding; 4], 30.0, figcraft_doc::MainAlign::Center), "{cmd}, {pick} wins");
|
||||
}
|
||||
}
|
||||
|
||||
/// Auto layout on one side and a hugging frame's size on both: the sizes auto layout works out
|
||||
/// on each side are no conflict, and merging leaves no stale size behind.
|
||||
#[test]
|
||||
fn sizes_auto_layout_works_out_never_conflict() {
|
||||
let (mut s, id, card, _, stack, rect) = card_file();
|
||||
ok(&mut s, "node.set", json!({"id": rect, "props": {"width": 60}}));
|
||||
ok(&mut s, "doc.switch", json!({"index": 0}));
|
||||
ok(&mut s, "node.set", json!({"id": card, "props": {"padding": 4}}));
|
||||
let review = ok(&mut s, "branch.review", json!({"id": id}));
|
||||
assert_eq!(review["conflicts"], json!([]), "{review}");
|
||||
let mut direct = Session::with_document(s.doc().unwrap().clone());
|
||||
ok(&mut direct, "node.set", json!({"id": rect, "props": {"width": 60}}));
|
||||
ok(&mut s, "branch.merge", json!({"id": id}));
|
||||
assert_eq!(s.doc().unwrap().node(stack).unwrap().width, 60.0 + 8.0);
|
||||
assert_eq!(geometry(s.doc().unwrap()), geometry(direct.doc().unwrap()));
|
||||
}
|
||||
|
||||
/// Every change a side made (that the other side didn't overwrite) shows in `doc`.
|
||||
fn shows(changes: &[Change], doc: &Document) -> Result<(), String> {
|
||||
let r = Replica::new(doc.clone());
|
||||
@@ -282,16 +385,25 @@ fn random_edits_on_both_sides_merge() {
|
||||
let none = |_: &figcraft_collab::merge::Key| None;
|
||||
let (into_main, _) = p.merge(&m, Side::Branch, &none).unwrap_or_else(|e| panic!("seed {seed}: {e}"));
|
||||
let (into_branch, _) = p.merge(&b, Side::Main, &none).unwrap_or_else(|e| panic!("seed {seed}: {e}"));
|
||||
// Values both sides work out for themselves (sizes and places auto layout gives) stay
|
||||
// as the file merged into has them.
|
||||
let chosen = |changes: &[Change]| -> Vec<Change> {
|
||||
changes
|
||||
.iter()
|
||||
.filter(|c| !matches!(c, Change::Set { id, prop, .. } if p.derived().contains(&Key::Node(*id, *prop))))
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
let mut both = Replica::new(start.clone());
|
||||
both.apply(&p.main, Mode::Strict).unwrap();
|
||||
both.apply(&p.branch, Mode::Strict).unwrap();
|
||||
both.apply(&chosen(&p.branch), Mode::Strict).unwrap();
|
||||
let mut want = both.document().clone();
|
||||
(want.name, want.current_page) = (m.name.clone(), m.current_page);
|
||||
assert_eq!(into_main, want, "seed {seed}");
|
||||
for doc in [&into_main, &into_branch] {
|
||||
assert!(doc.validate().is_empty(), "seed {seed}: {:?}", doc.validate());
|
||||
for side in [&p.branch, &p.main] {
|
||||
shows(side, doc).unwrap_or_else(|c| panic!("seed {seed}: lost {c}"));
|
||||
shows(&chosen(side), doc).unwrap_or_else(|c| panic!("seed {seed}: lost {c}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! guide lines (with spacing labels) for the canvas to draw in red. Vector points also snap to
|
||||
//! other points (see `vectoredit::snap`); landing on one shows a marker (a zero-length guide).
|
||||
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_doc::{BoardItem, Document, NodeId};
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2, transform_rect_bbox};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -94,10 +94,23 @@ impl Targets {
|
||||
t.ys.push((y, b.x0, b.x1));
|
||||
}
|
||||
}
|
||||
// Siblings and, at the top level, all top-level layers.
|
||||
// Siblings and, at the top level, all top-level layers. A board connector joined to a
|
||||
// moving layer, or to one inside it (a section carries its stickies), re-routes with it, as
|
||||
// does its label: no snapping to where its own route starts (#1916).
|
||||
let carried = |e: NodeId| moving.contains(&e) || doc.ancestors(e).iter().any(|a| moving.contains(a));
|
||||
let follows = |id: NodeId| match doc.node(id).and_then(|n| n.board.as_ref()) {
|
||||
Some(BoardItem::Connector(k)) => [k.from.node(), k.to.node()].into_iter().flatten().any(carried),
|
||||
_ => false,
|
||||
};
|
||||
if let Some(p) = doc.node(parent) {
|
||||
for c in &p.children {
|
||||
if moving.contains(c) || !doc.node(*c).is_some_and(|n| n.visible) {
|
||||
let Some(n) = doc.node(*c).filter(|n| n.visible) else { continue };
|
||||
let joined = match n.board.as_ref() {
|
||||
Some(BoardItem::Connector(_)) => follows(*c),
|
||||
Some(BoardItem::Label { connector }) => follows(*connector),
|
||||
_ => false,
|
||||
};
|
||||
if moving.contains(c) || joined {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = bbox(*c) {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -544,6 +544,8 @@ pub struct PenPath {
|
||||
pub closed: bool,
|
||||
/// The path ends connected to this anchor of its own (a vector network).
|
||||
pub join: Option<usize>,
|
||||
/// Anchors taken back by undo, the last one first to come back on redo.
|
||||
pub undone: Vec<(Point, Vec2, Vec2)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -118,10 +118,47 @@ impl Session {
|
||||
p = at_45_degrees(*last, p);
|
||||
}
|
||||
pen.points.push((p, Vec2::ZERO, Vec2::ZERO));
|
||||
pen.undone.clear();
|
||||
self.tool.gesture = Gesture::PenDrag { anchor: p };
|
||||
self.pen_update()
|
||||
}
|
||||
|
||||
/// Undo while the pen draws (#1745): its last anchor goes (before two anchors, with the layer
|
||||
/// they made; with the only one, the whole path). Returns whether there was a path.
|
||||
pub(crate) fn pen_undo(&mut self) -> Result<bool> {
|
||||
let Some(pen) = self.tool.pen.as_mut() else { return Ok(false) };
|
||||
let Some(last) = pen.points.pop() else {
|
||||
self.tool.pen = None;
|
||||
return Ok(false);
|
||||
};
|
||||
pen.undone.push(last);
|
||||
let drop_layer = pen.points.len() < 2 && pen.id.take().is_some();
|
||||
let empty = pen.points.is_empty();
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.tool.overlay.rubber = None;
|
||||
self.tool.overlay.rubber_handle = None;
|
||||
if drop_layer {
|
||||
self.cancel_interaction()?;
|
||||
}
|
||||
if empty {
|
||||
self.pen_finish()?;
|
||||
return Ok(true);
|
||||
}
|
||||
self.pen_update()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Redo while the pen draws: the last anchor undo took back returns. Returns whether there was
|
||||
/// one.
|
||||
pub(crate) fn pen_redo(&mut self) -> Result<bool> {
|
||||
let Some(pen) = self.tool.pen.as_mut() else { return Ok(false) };
|
||||
let Some(point) = pen.undone.pop() else { return Ok(false) };
|
||||
pen.points.push(point);
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.pen_update()?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Rebuild the pen's vector node from its anchors.
|
||||
pub(super) fn pen_update(&mut self) -> Result<()> {
|
||||
let Some(pen) = self.tool.pen.clone() else { return Ok(()) };
|
||||
|
||||
@@ -465,7 +465,8 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
|
||||
}
|
||||
EffectKind::Glass(g) => {
|
||||
g.depth *= k;
|
||||
g.frost *= k;
|
||||
// From the frost it draws with (an older file's past 100 draws at 100).
|
||||
g.frost = (g.sanitized().frost * k).min(figcraft_doc::Glass::MAX_FROST);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
@@ -418,7 +418,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"export.add",
|
||||
"Add export setting",
|
||||
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
|
||||
"{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100, default 90)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "export.add";
|
||||
@@ -436,7 +436,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"export.update",
|
||||
"Edit export setting",
|
||||
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
|
||||
"{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore), quality? (JPG: 1-100), contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "export.update";
|
||||
|
||||
@@ -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};
|
||||
@@ -118,7 +119,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"file.export",
|
||||
"Export",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}",
|
||||
"{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file; or penpot: the whole file as a Penpot 2.x archive, with warnings for unsupported content), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), contentsOnly? (default true; false also draws the layers that show in their area), includeBoundingBox? (true: only the layers' own boxes, cutting off shadows and outside strokes), svgOutlineText? (default true), svgSimplifyStroke? (default true), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings)), quality? (JPG: 1-100, default 90)}",
|
||||
false,
|
||||
export
|
||||
),
|
||||
@@ -408,7 +409,8 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal
|
||||
_ => {
|
||||
let pix = figcraft_render::render_area(doc, &drawn, area, scale)
|
||||
.ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?;
|
||||
Ok((figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other)?, Vec::new()))
|
||||
let bytes = figcraft_render::encode_export_with(&pix, setting.format, doc.color_profile, setting.quality());
|
||||
Ok((bytes.map_err(EngineError::Other)?, Vec::new()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::load::{ext, write};
|
||||
use super::save::thumbnail;
|
||||
use super::*;
|
||||
use crate::params::f64_of;
|
||||
|
||||
/// Encode `ids` (or the page) in `format` at `scale`.
|
||||
pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result<Vec<u8>> {
|
||||
@@ -159,7 +160,8 @@ pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) ->
|
||||
export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height()))
|
||||
}
|
||||
|
||||
/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix` and `format` that `p` sets.
|
||||
/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix`, `format` and the
|
||||
/// format options (`quality`, `vdCompat`, PDF print options, the SVG flags) that `p` sets.
|
||||
pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Result<ExportSetting> {
|
||||
if let Some(size) = size_param(cmd, p)? {
|
||||
e.size = size;
|
||||
@@ -173,6 +175,9 @@ pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value)
|
||||
if let Some(c) = vd_compat_param(cmd, p)? {
|
||||
e.vd_compat = c;
|
||||
}
|
||||
if let Some(q) = quality_param(cmd, p)? {
|
||||
e.quality = q;
|
||||
}
|
||||
e.print = print_param(cmd, p, e.print)?;
|
||||
for (key, slot) in [
|
||||
("contentsOnly", &mut e.contents_only),
|
||||
@@ -189,6 +194,16 @@ pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value)
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
/// The JPG `quality` `p` passes: a whole number from 1 to 100 (anything else is an error).
|
||||
fn quality_param(cmd: &str, p: &Value) -> Result<Option<u8>> {
|
||||
let Some(v) = get(p, "quality") else { return Ok(None) };
|
||||
let q = if v.is_boolean() { None } else { f64_of(v) };
|
||||
match q.filter(|q| q.fract() == 0.0 && (1.0..=100.0).contains(q)) {
|
||||
Some(q) => Ok(Some(q as u8)),
|
||||
None => Err(bad(cmd, "quality: a whole number from 1 to 100 (JPG)")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p` sets any PDF print option.
|
||||
fn has_print_param(p: &Value) -> bool {
|
||||
["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null()))
|
||||
|
||||
@@ -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 {
|
||||
@@ -196,6 +220,9 @@ fn set_operand_op(doc: &mut Document, id: NodeId, v: &Value, cmd: &str) -> Resul
|
||||
|
||||
pub(crate) const WIDTH_PROFILE_HELP: &str = "widthProfile: a preset (uniform|wedge|taper|eye|rounded|pinch), points [[t (0-1 along the path), scale (× stroke weight)] | [t, left, right] (one-sided: each side's scale, looking along the path), ...] or [{t, scale} | {t, left, right}], {preset} | {points}, or null (uniform)";
|
||||
|
||||
/// The stroke caps `strokeCap`, `startCap` and `endCap` take (every [`StrokeCap`]).
|
||||
pub(crate) const STROKE_CAP_HELP: &str = "none|round|square|lineArrow|triangleArrow|reversedTriangle|circleFilled|diamondFilled";
|
||||
|
||||
pub(crate) const BRUSH_HELP: &str = "brush: a preset (taper|ink|ribbon|bristle: stretched; dots|stars|confetti|leaves: scattered), {preset, spacing? (× weight), sizeJitter? (0-1), rotationJitter? (0-1), scatter? (0-4 × weight), seed?}, or null (a plain stroke)";
|
||||
|
||||
/// A brush from a preset id, `{preset, spacing?, sizeJitter?, rotationJitter?, scatter?, seed?}`
|
||||
@@ -611,13 +638,13 @@ fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) ->
|
||||
st.sides = Some(sides);
|
||||
}
|
||||
"strokeCap" => {
|
||||
let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?;
|
||||
let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, format!("strokeCap: {STROKE_CAP_HELP}")))?;
|
||||
let n = doc.get_mut(id)?;
|
||||
n.stroke.cap_start = c;
|
||||
n.stroke.cap_end = c;
|
||||
}
|
||||
"startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
|
||||
"endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
|
||||
"startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, format!("startCap: {STROKE_CAP_HELP}")))?,
|
||||
"endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, format!("endCap: {STROKE_CAP_HELP}")))?,
|
||||
"strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?,
|
||||
"miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0),
|
||||
"miterAngle" => {
|
||||
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"style.bind",
|
||||
"Bind variable in style",
|
||||
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>`, a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
|
||||
"{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills/<i>/color` (or `fill`: its first paint) and `fills/<i>/stops/<j>/color`, an effect style's `effects/<i>/<color|radius|spread|offsetX|offsetY|startRadius>` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids/<i>/<size|count|gutter|offset>`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes",
|
||||
true,
|
||||
bind_style
|
||||
),
|
||||
@@ -196,7 +196,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.bind",
|
||||
"Bind variable",
|
||||
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|fills/<i>/stops/<j>/color (gradient stops)|effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color>|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties; variant properties take string variables naming a value)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
|
||||
"{ids?, field (fill|stroke (first paint)|fills/<i>/color|strokes/<i>/color|fills/<i>/stops/<j>/color (gradient stops)|effects/<i>/<offsetX|offsetY|radius|startRadius|spread|color|lightAngle|lightIntensity|refraction|depth|dispersion|splay> (glass: `radius` is its frost; intensity, refraction, dispersion and splay in percent)|layoutGrids/<i>/<size|count|gutter|offset>|componentProperties/<name> (instances: boolean/text properties; variant properties take string variables naming a value)|width|height|opacity (percent)|cornerRadius|gap|paddingHorizontal|characters|visible|fontSize…; list indices bottom first), variable (null unbinds)}",
|
||||
true,
|
||||
bind
|
||||
),
|
||||
@@ -788,6 +788,9 @@ pub(crate) fn bind_variable(cmd: &str, doc: &mut Document, ids: &[NodeId], field
|
||||
for i in ids {
|
||||
match field.strip_prefix(crate::components::PROP_BINDING) {
|
||||
Some(name) => super::component::check_prop_binding(doc, *i, name, v.kind).map_err(|e| bad(cmd, e))?,
|
||||
None if !super::variables::has_field(doc, *i, field) => {
|
||||
return Err(bad(cmd, format!("the layer has no field `{field}` to bind")));
|
||||
}
|
||||
None if !super::variables::field_takes(doc, *i, field, v.kind) => {
|
||||
return Err(bad(cmd, format!("`{field}` doesn't take a {} variable", v.kind.id())));
|
||||
}
|
||||
@@ -1304,6 +1307,9 @@ pub(crate) fn set_effect_field(effects: &mut [Effect], i: usize, prop: &str, val
|
||||
(EffectKind::DropShadow(s) | EffectKind::InnerShadow(s), "color", _, VariableValue::Color(c)) => s.color = *c,
|
||||
(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "radius", Some(x), _) => b.radius = x.max(0.0),
|
||||
(EffectKind::LayerBlur(b) | EffectKind::BackgroundBlur(b), "startRadius", Some(x), _) => b.start_radius = x.max(0.0),
|
||||
(EffectKind::Glass(g), field, Some(x), _) => {
|
||||
g.set_bound(field, x);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -654,10 +654,29 @@ pub fn field_scope(doc: &Document, node: NodeId, field: &str) -> Option<&'static
|
||||
})
|
||||
}
|
||||
|
||||
/// The type of variable effect `i` of `node` takes on `field`, as an effect style's does: None when
|
||||
/// the layer has no effect `i` or that effect has no such field.
|
||||
fn effect_field(doc: &Document, node: NodeId, i: usize, field: &str) -> Option<VariableType> {
|
||||
crate::style_vars::effect_field_type(&doc.node(node)?.effects.get(i)?.kind, field)
|
||||
}
|
||||
|
||||
/// Whether `node` has the field a binding key names. Only effect fields are checked: a layer has
|
||||
/// `effects/<i>/<field>` when its effect `i` has that field (a drop shadow has no `refraction`),
|
||||
/// and an `effects…` key naming no effect's field (`effects/x/radius`, `effects/0`) has none.
|
||||
pub fn has_field(doc: &Document, node: NodeId, field: &str) -> bool {
|
||||
match super::lists::list_binding(field) {
|
||||
Some((BoundList::Effects, i, f)) => effect_field(doc, node, i, f).is_some(),
|
||||
_ => field.split('/').next() != Some(BoundList::Effects.prefix()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether property `field` of `node` takes a variable of type `kind`: the type its scope belongs
|
||||
/// to (number variables also fill text content), booleans for visibility, numbers for layout grid
|
||||
/// fields; fields without a known type take any.
|
||||
/// fields, an effect's field the type its effect takes there; fields without a known type take any.
|
||||
pub fn field_takes(doc: &Document, node: NodeId, field: &str, kind: VariableType) -> bool {
|
||||
if let Some((BoundList::Effects, i, f)) = super::lists::list_binding(field) {
|
||||
return effect_field(doc, node, i, f) == Some(kind);
|
||||
}
|
||||
match (field, field_scope(doc, node, field)) {
|
||||
("visible", _) => kind == VariableType::Boolean,
|
||||
(_, Some(scope)) => scope_known(kind, scope),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -46,7 +46,7 @@ fn without_cmd_an_end_keeps_snapping_to_a_side() {
|
||||
let end = *drawn(&s, k).last().unwrap();
|
||||
drag_end(&mut s, end, Point::new(650.0, 25.0), false);
|
||||
assert_eq!(connector(&s, k).to, End::Node { node: b, side: Side::Right, at: None });
|
||||
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(700.0, 50.0)));
|
||||
assert!(near(*drawn(&s, k).last().unwrap(), Point::new(700.0 + figcraft_doc::END_GAP, 50.0)), "END_GAP outside the side");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -475,7 +475,7 @@ pub fn properties_with(doc: &Document, id: NodeId, fmt: ColorFormat) -> Vec<Sect
|
||||
pct(g.refraction),
|
||||
num(g.depth),
|
||||
pct(g.dispersion),
|
||||
num(g.frost),
|
||||
num(g.sanitized().frost),
|
||||
pct(g.splay)
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -95,6 +95,32 @@ fn scaling_scales_grain_and_glass() {
|
||||
assert_eq!((g.refraction, nz.density), (d.refraction, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_a_frost_from_an_older_file_starts_from_what_it_draws() {
|
||||
// A frost of 150, saved before the 0–100 range, draws at 100: halved, it draws at 50.
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": "glass"})).unwrap();
|
||||
s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "frost": 150}})).unwrap();
|
||||
s.execute("transform.scale", &json!({"ids": [r], "factor": 0.5})).unwrap();
|
||||
let EffectKind::Glass(g) = &s.doc().unwrap().node(r).unwrap().effects[0].kind else { panic!() };
|
||||
assert_eq!(g.frost, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_shows_a_frost_from_an_older_file_as_it_draws() {
|
||||
// A frost of 150, saved before the 0–100 range, draws at 100: Dev Mode says 100 as CSS does.
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": "glass"})).unwrap();
|
||||
s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "frost": 150}})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let sections = crate::devinspect::properties(doc, r);
|
||||
let glass = §ions.iter().find(|s| s.title == "Effects").unwrap().rows[0];
|
||||
assert!(glass.value.contains(" frost 100 "), "{}", glass.value);
|
||||
assert!(code_for(doc, r, CodeLang::Css).contains("backdrop-filter: blur(50px);"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_and_css_show_the_new_effects() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! JPG exports: the quality an export or export setting asks for, and the colour profile every
|
||||
//! JPG embeds (#2004).
|
||||
|
||||
use image::ImageDecoder;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_color::ColorProfile;
|
||||
use figcraft_doc::{ExportSetting, NodeId};
|
||||
|
||||
/// A rectangle with a gradient, so the quality shows in the file's size.
|
||||
fn gradient_rect(s: &mut Session) -> NodeId {
|
||||
let r = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()["id"]).unwrap();
|
||||
let fill = json!({"type": "linearGradient", "stops": ["#FF0000", "#00FF88", "#2040FF"]});
|
||||
s.execute("node.set", &json!({"ids": [r], "props": {"fills": [fill]}})).unwrap();
|
||||
r
|
||||
}
|
||||
|
||||
fn bytes(v: &Value) -> Vec<u8> {
|
||||
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, v["data"].as_str().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
fn jpg(s: &mut Session, r: NodeId, extra: Value) -> Vec<u8> {
|
||||
let mut p = json!({"ids": [r], "format": "jpg"});
|
||||
if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) {
|
||||
m.extend(e);
|
||||
}
|
||||
bytes(&s.execute("file.export", &p).unwrap())
|
||||
}
|
||||
|
||||
fn icc(jpg: &[u8]) -> Option<Vec<u8>> {
|
||||
image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(jpg)).unwrap().icc_profile().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jpg_quality_is_taken_and_defaults_to_90() {
|
||||
let mut s = Session::new();
|
||||
let r = gradient_rect(&mut s);
|
||||
let default = jpg(&mut s, r, json!({}));
|
||||
assert_eq!(default, jpg(&mut s, r, json!({"quality": 90})), "90 is the default");
|
||||
let low = jpg(&mut s, r, json!({"quality": 20}));
|
||||
let high = jpg(&mut s, r, json!({"quality": 100}));
|
||||
assert!(low.len() < default.len() && default.len() < high.len(), "{} {} {}", low.len(), default.len(), high.len());
|
||||
// Agents send numbers as strings too.
|
||||
assert_eq!(jpg(&mut s, r, json!({"quality": "20"})), low);
|
||||
image::load_from_memory(&low).unwrap();
|
||||
// The other formats ignore it.
|
||||
let png = |s: &mut Session, extra: Value| {
|
||||
let mut p = json!({"ids": [r], "format": "png"});
|
||||
if let (Value::Object(m), Value::Object(e)) = (&mut p, extra) {
|
||||
m.extend(e);
|
||||
}
|
||||
bytes(&s.execute("file.export", &p).unwrap())
|
||||
};
|
||||
assert_eq!(png(&mut s, json!({"quality": 20})), png(&mut s, json!({})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_qualities_are_refused() {
|
||||
let mut s = Session::new();
|
||||
let r = gradient_rect(&mut s);
|
||||
s.execute("export.add", &json!({"ids": [r], "setting": {"format": "jpg"}})).unwrap();
|
||||
for q in [json!(0), json!(101), json!(-5), json!(50.5), json!("high"), json!(true), json!([80]), json!(1e300)] {
|
||||
let e = s.execute("file.export", &json!({"ids": [r], "format": "jpg", "quality": q})).unwrap_err();
|
||||
assert!(e.to_string().contains("quality: a whole number from 1 to 100"), "{q}: {e}");
|
||||
assert!(s.execute("export.add", &json!({"ids": [r], "setting": {"format": "jpg", "quality": q}})).is_err(), "{q}");
|
||||
assert!(s.execute("export.update", &json!({"ids": [r], "index": 0, "quality": q})).is_err(), "{q}");
|
||||
}
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!(n.exports.len(), 1);
|
||||
assert_eq!(n.exports[0].quality, ExportSetting::DEFAULT_QUALITY);
|
||||
// null leaves the setting as it is.
|
||||
s.execute("export.update", &json!({"ids": [r], "index": 0, "quality": 40})).unwrap();
|
||||
s.execute("export.update", &json!({"ids": [r], "index": 0, "quality": null})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[0].quality, 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_quality_is_refused_whatever_the_format() {
|
||||
// As `vdCompat`, `bleed` and the SVG flags are: a value is checked for every format, so a
|
||||
// setting that becomes a JPG later never holds one the encoder can't take.
|
||||
let mut s = Session::new();
|
||||
let r = gradient_rect(&mut s);
|
||||
assert!(s.execute("file.export", &json!({"ids": [r], "format": "png", "vdCompat": "junk"})).is_err());
|
||||
let png = std::env::temp_dir().join(format!("figcraft-bad-quality-{}.png", std::process::id()));
|
||||
for mut p in [json!({"format": "png"}), json!({"format": "webp"}), json!({"format": "svg"}), json!({"path": png.to_string_lossy()})] {
|
||||
p["ids"] = json!([r]);
|
||||
p["quality"] = json!(0);
|
||||
let e = s.execute("file.export", &p).unwrap_err();
|
||||
assert!(e.to_string().contains("quality: a whole number from 1 to 100"), "{p}: {e}");
|
||||
}
|
||||
assert!(!png.exists(), "the refused export wrote nothing");
|
||||
// A valid quality on a PNG setting is kept, and used once the setting is a JPG.
|
||||
s.execute("export.add", &json!({"ids": [r], "setting": {"format": "png", "quality": 40}})).unwrap();
|
||||
s.execute("export.update", &json!({"ids": [r], "index": 0, "format": "jpg"})).unwrap();
|
||||
let files = s.execute("file.exportLayers", &json!({"ids": [r]})).unwrap()["files"].as_array().unwrap().clone();
|
||||
assert_eq!(bytes(&files[0]), jpg(&mut s, r, json!({"quality": 40})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_settings_keep_their_quality() {
|
||||
let mut s = Session::new();
|
||||
let r = gradient_rect(&mut s);
|
||||
s.execute("export.add", &json!({"ids": [r], "setting": {"format": "jpg", "quality": 30}})).unwrap();
|
||||
s.execute("export.add", &json!({"ids": [r], "setting": {"format": "jpg", "suffix": "-hq"}})).unwrap();
|
||||
s.execute("export.update", &json!({"ids": [r], "index": 1, "quality": 95})).unwrap();
|
||||
let exports = s.doc().unwrap().node(r).unwrap().exports.clone();
|
||||
assert_eq!((exports[0].quality, exports[1].quality), (30, 95));
|
||||
// `file.exportLayers` encodes each setting at its own quality.
|
||||
let files = s.execute("file.exportLayers", &json!({"ids": [r]})).unwrap()["files"].as_array().unwrap().clone();
|
||||
assert_eq!(bytes(&files[0]), jpg(&mut s, r, json!({"quality": 30})));
|
||||
assert_eq!(bytes(&files[1]), jpg(&mut s, r, json!({"quality": 95})));
|
||||
// Saved only when it isn't the default, so older files and settings read as 90.
|
||||
assert_eq!(serde_json::to_value(&exports[0]).unwrap()["quality"], 30);
|
||||
assert!(serde_json::to_value(ExportSetting::default()).unwrap().get("quality").is_none());
|
||||
let old: ExportSetting = serde_json::from_value(json!({"format": "jpg"})).unwrap();
|
||||
assert_eq!(old.quality, ExportSetting::DEFAULT_QUALITY);
|
||||
// A value out of range in a file is clamped when it encodes.
|
||||
let odd: ExportSetting = serde_json::from_value(json!({"format": "jpg", "quality": 0})).unwrap();
|
||||
assert_eq!(odd.quality(), 1);
|
||||
// Undo takes the setting's quality back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().exports[1].quality, ExportSetting::DEFAULT_QUALITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_jpg_embeds_the_file_colour_profile() {
|
||||
for profile in ColorProfile::ALL {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.colorProfile", &json!({"profile": profile.label(), "convert": false})).unwrap();
|
||||
let r = gradient_rect(&mut s);
|
||||
for quality in [json!({}), json!({"quality": 10})] {
|
||||
assert_eq!(icc(&jpg(&mut s, r, quality)), Some(profile.icc()), "{profile:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,6 +383,8 @@ mod invite_links_tests;
|
||||
#[cfg(test)]
|
||||
mod jam_open_tests;
|
||||
#[cfg(test)]
|
||||
mod jpg_export_tests;
|
||||
#[cfg(test)]
|
||||
mod keymap_tests;
|
||||
#[cfg(test)]
|
||||
mod library_beacon_tests;
|
||||
@@ -457,6 +459,8 @@ mod pen_end_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_overlay_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_undo_tests;
|
||||
#[cfg(test)]
|
||||
mod pen_vector_edit_tests;
|
||||
#[cfg(test)]
|
||||
mod penpot_export_tests;
|
||||
@@ -545,6 +549,8 @@ mod slot_tests;
|
||||
#[cfg(test)]
|
||||
mod stored_ids_tests;
|
||||
#[cfg(test)]
|
||||
mod stroke_cap_error_tests;
|
||||
#[cfg(test)]
|
||||
mod stroke_style_tests;
|
||||
#[cfg(test)]
|
||||
mod style_tests;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -96,6 +96,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,
|
||||
@@ -109,6 +110,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();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Undo while the pen draws (#1745): each undo takes back the path's last anchor, not the whole
|
||||
//! path (nor the step before it); redo puts the anchors back.
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
use figcraft_doc::NodeKind;
|
||||
use serde_json::json;
|
||||
|
||||
fn click(s: &mut Session, x: f64, y: f64) {
|
||||
for kind in [PointerKind::Down, PointerKind::Up] {
|
||||
s.pointer(&PointerEvent::at(kind, x, y, 1.0)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn layers(s: &Session) -> usize {
|
||||
s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len()
|
||||
}
|
||||
|
||||
fn pen_points(s: &Session) -> Option<usize> {
|
||||
s.tool.pen.as_ref().map(|p| p.points.len())
|
||||
}
|
||||
|
||||
/// (vertices, segments) of the pen's vector, if it has one.
|
||||
fn vector(s: &Session) -> Option<(usize, usize)> {
|
||||
let id = s.tool.pen.as_ref().and_then(|p| p.id)?;
|
||||
match &s.doc().unwrap().node(id)?.kind {
|
||||
NodeKind::Vector { network } => Some((network.vertices.len(), network.segments.len())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn undo(s: &mut Session) -> bool {
|
||||
s.execute("edit.undo", &json!({})).unwrap()["undone"] == true
|
||||
}
|
||||
|
||||
fn redo(s: &mut Session) {
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_while_drawing_takes_back_one_anchor() {
|
||||
let mut s = Session::new();
|
||||
s.execute("shape.rectangle", &json!({"x": 400, "y": 400, "width": 50, "height": 50})).unwrap();
|
||||
assert_eq!(layers(&s), 1);
|
||||
s.set_tool(ToolId::Pen);
|
||||
click(&mut s, 100.0, 100.0);
|
||||
click(&mut s, 200.0, 100.0);
|
||||
click(&mut s, 200.0, 200.0);
|
||||
assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(3), Some((3, 2)), 2));
|
||||
|
||||
assert!(undo(&mut s));
|
||||
assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(2), Some((2, 1)), 2), "only the last anchor goes");
|
||||
assert!(undo(&mut s));
|
||||
assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(1), None, 1), "one anchor, no layer yet");
|
||||
|
||||
// Redo puts the anchors back in order.
|
||||
redo(&mut s);
|
||||
assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(2), Some((2, 1)), 2));
|
||||
redo(&mut s);
|
||||
assert_eq!((pen_points(&s), vector(&s), layers(&s)), (Some(3), Some((3, 2)), 2));
|
||||
let pts: Vec<_> = s.tool.pen.as_ref().unwrap().points.iter().map(|(p, _, _)| (p.x, p.y)).collect();
|
||||
assert_eq!(pts, vec![(100.0, 100.0), (200.0, 100.0), (200.0, 200.0)]);
|
||||
|
||||
for _ in 0..3 {
|
||||
assert!(undo(&mut s));
|
||||
}
|
||||
assert_eq!((pen_points(&s), layers(&s)), (None, 1), "the path is gone, the rectangle stays");
|
||||
assert!(undo(&mut s));
|
||||
assert_eq!(layers(&s), 0, "the next undo takes the rectangle");
|
||||
}
|
||||
@@ -114,6 +114,10 @@ impl Session {
|
||||
|
||||
pub fn undo(&mut self) -> Result<bool> {
|
||||
textpreview::end(self);
|
||||
// While the pen draws, undo works on the anchors of its path (#1745).
|
||||
if self.ds().is_ok_and(|d| d.preview.is_none()) && self.pen_undo()? {
|
||||
return Ok(true);
|
||||
}
|
||||
let live = self.ds().is_ok_and(|d| self.live_doc(d.uid));
|
||||
let ds = self.ds_mut()?;
|
||||
if ds.preview.is_some() {
|
||||
@@ -152,6 +156,10 @@ impl Session {
|
||||
|
||||
pub fn redo(&mut self) -> Result<bool> {
|
||||
textpreview::end(self);
|
||||
// While the pen draws, redo works on the anchors of its path (#1745).
|
||||
if self.ds().is_ok_and(|d| d.preview.is_none()) && self.pen_redo()? {
|
||||
return Ok(true);
|
||||
}
|
||||
let live = self.ds().is_ok_and(|d| self.live_doc(d.uid));
|
||||
let ds = self.ds_mut()?;
|
||||
if ds.preview.is_some() {
|
||||
|
||||
@@ -611,6 +611,21 @@ fn doc_change(plan: &Plan, c: &Change) -> Option<DocChange> {
|
||||
Some(DocChange { what, change: entry_change(plan, c) })
|
||||
}
|
||||
|
||||
/// One side's value of an auto layout field in conflict, in words: `16`, `8, 8, 8, 8`, `min`,
|
||||
/// `on`, `none`.
|
||||
fn field_text(v: &Value) -> String {
|
||||
match v {
|
||||
Value::Null => "none".to_string(),
|
||||
Value::Bool(on) => if *on { "on" } else { "off" }.to_string(),
|
||||
Value::Number(n) => n.as_f64().map_or_else(|| n.to_string(), |x| x.to_string()),
|
||||
Value::String(s) => words(s),
|
||||
Value::Array(items) => items.iter().map(field_text).collect::<Vec<_>>().join(", "),
|
||||
Value::Object(_) => v.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The review's row for conflict `c`: the layer (or file entry) and property, and what each side
|
||||
/// changed it to.
|
||||
fn conflict_row(plan: &Plan, c: &Conflict, base: &Document, head: &Document, main: &Document) -> ConflictRow {
|
||||
let layer = |id: NodeId| main.node(id).or(head.node(id)).or(base.node(id)).map_or_else(|| id.to_string(), |n| n.name.clone());
|
||||
let text = |side: Side| match c.changes(side).first().and_then(|i| plan.changes(side).get(*i)) {
|
||||
@@ -627,6 +642,11 @@ fn conflict_row(plan: &Plan, c: &Conflict, base: &Document, head: &Document, mai
|
||||
let property = if *prop == Prop::Parent { "place in layers".to_string() } else { words(prop) };
|
||||
(Some(*id), layer(*id), property, b, m)
|
||||
}
|
||||
Key::Field(id, prop, field) => {
|
||||
let (b, m) = plan.field_values(&c.key).map_or_else(|| ("changed".into(), "changed".into()), |(b, m)| (field_text(b), field_text(m)));
|
||||
let owner = if *prop == Prop::Layout { "auto layout".to_string() } else { words(prop) };
|
||||
(Some(*id), layer(*id), format!("{owner} {}", field.replace('_', " ")), b, m)
|
||||
}
|
||||
Key::Layer(id) => {
|
||||
let (b, m) = if c.deleted_by == Some(Side::Branch) { ("deleted", "edited") } else { ("edited", "deleted") };
|
||||
(Some(*id), layer(*id), "layer".to_string(), b.to_string(), m.to_string())
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -159,6 +159,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() {
|
||||
|
||||
@@ -188,6 +188,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!(
|
||||
@@ -200,7 +201,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
|
||||
),
|
||||
@@ -263,7 +264,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())
|
||||
),
|
||||
|
||||
@@ -290,6 +290,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()
|
||||
@@ -302,7 +303,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(),
|
||||
})
|
||||
})
|
||||
@@ -320,8 +321,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.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! An unknown stroke cap is refused with the caps there are, as `strokeAlign` and `strokeJoin`
|
||||
//! list theirs (#2005).
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::props::STROKE_CAP_HELP;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::StrokeCap;
|
||||
|
||||
#[test]
|
||||
fn an_unknown_cap_lists_every_cap() {
|
||||
let names: Vec<String> = StrokeCap::ALL.iter().map(|c| serde_json::to_value(c).unwrap().as_str().unwrap().to_string()).collect();
|
||||
assert_eq!(STROKE_CAP_HELP.split('|').collect::<Vec<_>>(), names, "the list is every cap, in order");
|
||||
let mut s = Session::new();
|
||||
let v = id_of(&s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 0, "y": 0})).unwrap()["id"]).unwrap();
|
||||
for key in ["strokeCap", "startCap", "endCap"] {
|
||||
for junk in [json!("butt"), json!(3), json!("")] {
|
||||
let e = s.execute("node.set", &json!({"id": v, "props": {key: junk}})).unwrap_err().to_string();
|
||||
assert!(e.contains(&format!("{key}: {STROKE_CAP_HELP}")), "{key} {junk}: {e}");
|
||||
}
|
||||
// Every name the error lists is taken.
|
||||
for name in &names {
|
||||
s.execute("node.set", &json!({"id": v, "props": {key: name}})).unwrap_or_else(|e| panic!("{key} {name}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -172,7 +172,8 @@ fn effects_follow_their_variables() {
|
||||
bind(&mut s, "effects/0/offsetY", "y");
|
||||
bind(&mut s, "effects/0/color", "shadow");
|
||||
bind(&mut s, "effects/1/radius", "blur");
|
||||
bind(&mut s, "effects/9/radius", "blur");
|
||||
// There is no effect 9 to bind (see `binding_lists_tests::a_layer_binds_only_fields_its_effects_have`).
|
||||
assert!(s.execute("variable.bind", &json!({"ids": [r], "field": "effects/9/radius", "variable": "blur"})).is_err());
|
||||
let fx = |s: &Session| s.doc().unwrap().node(r).unwrap().effects.clone();
|
||||
match &fx(&s)[0].kind {
|
||||
figcraft_doc::EffectKind::DropShadow(sh) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -234,7 +234,7 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
EffectKind::Glass(g) => {
|
||||
self.warn("glass effects keep only their frost");
|
||||
f.extend([("type", e("GLASS")), ("radius", fl(g.frost))]);
|
||||
f.extend([("type", e("GLASS")), ("radius", fl(g.sanitized().frost))]);
|
||||
}
|
||||
}
|
||||
o(f)
|
||||
|
||||
@@ -56,16 +56,24 @@ impl<'d> Exporter<'d> {
|
||||
}
|
||||
let spans = td.spans();
|
||||
let mut table: Vec<&TextStyle> = Vec::new();
|
||||
// Each distinct style once, found by its serialized form rather than by comparing it
|
||||
// with every earlier one (quadratic in the number of distinct styles).
|
||||
let mut index: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
|
||||
let mut span_ids = Vec::with_capacity(spans.len());
|
||||
for (_, _, st) in &spans {
|
||||
span_ids.push(if **st == td.style {
|
||||
0
|
||||
} else {
|
||||
let i = table.iter().position(|t| *t == *st).unwrap_or_else(|| {
|
||||
table.push(st);
|
||||
table.len() - 1
|
||||
});
|
||||
i as u32 + 1
|
||||
match serde_json::to_string(st) {
|
||||
Ok(key) => *index.entry(key).or_insert_with(|| {
|
||||
table.push(st);
|
||||
table.len() as u32
|
||||
}),
|
||||
Err(_) => {
|
||||
table.push(st);
|
||||
table.len() as u32
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut chars = String::new();
|
||||
|
||||
@@ -1243,6 +1243,25 @@ fn local_samples_round_trip() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_glass_frost_is_written_as_it_draws() {
|
||||
// A frost saved before the 0–100 range (or not a number) is written at what it draws: 100, or
|
||||
// the default.
|
||||
for (frost, written) in [(12.0, 12.0), (500.0, Glass::MAX_FROST), (f64::NAN, Glass::default().frost)] {
|
||||
let mut doc = Document::new("glass");
|
||||
let page = doc.first_page().unwrap();
|
||||
let r = doc.create(page, NodeKind::Rectangle, "Pane", 0.0, 0.0, 24.0, 24.0).unwrap();
|
||||
doc.get_mut(r).unwrap().effects = vec![Effect { kind: EffectKind::Glass(Glass { frost, ..Glass::default() }), visible: true }];
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
let archive = Archive::open(&bytes).unwrap();
|
||||
let (schema, message) = decode_canvas(&archive.canvas).unwrap();
|
||||
let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap();
|
||||
let pane = msg.get("nodeChanges").unwrap().list().iter().find(|nc| nc.get("name").and_then(Kv::str) == Some("Pane")).unwrap();
|
||||
let radius = pane.get("effects").unwrap().list()[0].get("radius").and_then(Kv::f64).unwrap();
|
||||
assert_eq!(radius, written, "frost {frost}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vector_drawable_export_setting_is_written_as_svg_with_a_warning() {
|
||||
let mut doc = Document::new("vd");
|
||||
@@ -1612,6 +1631,22 @@ fn a_whole_ellipse_starting_anywhere_comes_back_whole() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_with_thousands_of_distinct_styles_exports_in_linear_time() {
|
||||
// The style table used to be searched end to end for every span (quadratic in distinct styles).
|
||||
let mut doc = Document::new("styles");
|
||||
let p = doc.first_page().unwrap();
|
||||
let mut td = TextData::new("x".repeat(20_000));
|
||||
td.runs = (0..20_000)
|
||||
.map(|i| StyleRun { start: i, end: i + 1, style: TextStyle { font_size: 8.0 + i as f64 / 100.0, ..TextStyle::default() } })
|
||||
.collect();
|
||||
doc.create(p, NodeKind::Text(td), "T", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let (bytes, _) = export(&doc, None).unwrap();
|
||||
assert!(!bytes.is_empty());
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
}
|
||||
|
||||
/// An ellipse whose start has gone round many times (a start typed in degrees past a few hundred
|
||||
/// turns) comes back drawn the same: the same sweep, and a start the same way round.
|
||||
#[test]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -619,7 +619,7 @@ fn other_layer_kinds_import_as_the_closest_layers() {
|
||||
let world = doc.world_transform(link.id);
|
||||
let ends: Vec<(f64, f64)> =
|
||||
network_of(&doc, "Link").vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
|
||||
assert_eq!(ends, [(0.0, 300.0), (200.0, 300.0), (200.0, 50.0), (400.0, 50.0)]);
|
||||
assert_eq!(ends, [(0.0, 300.0), (198.0, 300.0), (198.0, 50.0), (396.0, 50.0)]);
|
||||
assert_eq!(link.stroke.cap_end, StrokeCap::TriangleArrow);
|
||||
// A table: a frame.
|
||||
assert!(matches!(named(&doc, "Grid").kind, NodeKind::Frame));
|
||||
@@ -656,9 +656,9 @@ fn connectors_without_a_saved_path_follow_their_line_style() {
|
||||
// Curved: one curve from A's right side to B's left, leaving and arriving level.
|
||||
let curve = network_of(&doc, "Curve");
|
||||
let ends: Vec<_> = curve.vertices.iter().map(|v| world("Curve") * figcraft_doc::geom::Point::new(v.x, v.y)).collect();
|
||||
assert_eq!(ends.iter().map(|p| (p.x, p.y)).collect::<Vec<_>>(), [(100.0, 650.0), (300.0, 650.0)]);
|
||||
assert_eq!(ends.iter().map(|p| (p.x, p.y)).collect::<Vec<_>>(), [(104.0, 650.0), (296.0, 650.0)], "END_GAP outside the sides");
|
||||
assert_eq!(curve.segments.len(), 1);
|
||||
assert_eq!((curve.segments[0].ta, curve.segments[0].tb), ((100.0, 0.0), (-100.0, 0.0)), "the curve leaves and arrives along the sides");
|
||||
assert_eq!((curve.segments[0].ta, curve.segments[0].tb), ((96.0, 0.0), (-96.0, 0.0)), "the curve leaves and arrives along the sides");
|
||||
// Straight: unchanged, to the points the file gives on the layers.
|
||||
let line: Vec<_> =
|
||||
network_of(&doc, "Line").vertices.iter().map(|v| world("Line") * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -2155,7 +2155,10 @@ fn import_themed_tokens(
|
||||
if set_names.is_empty() {
|
||||
return false;
|
||||
}
|
||||
set_names.sort_by_key(|name| order.iter().position(|o| o == name).unwrap_or(usize::MAX));
|
||||
// Each set's place in the order once: a position search per comparison was quadratic in
|
||||
// the order's length for every one of the sort's comparisons.
|
||||
let place: BTreeMap<&str, usize> = order.iter().enumerate().rev().map(|(i, o)| (*o, i)).collect();
|
||||
set_names.sort_by_key(|name| place.get(name).copied().unwrap_or(usize::MAX));
|
||||
let set_names: Vec<&str> = set_names.into_iter().take(512).collect();
|
||||
let mut tokens: BTreeMap<&str, BTreeMap<String, &Value>> = BTreeMap::new();
|
||||
for name in &set_names {
|
||||
|
||||
@@ -427,6 +427,9 @@ pub(crate) struct Ctx<'a> {
|
||||
component_meta: BTreeMap<&'a str, &'a Value>,
|
||||
set_meta: BTreeMap<&'a str, &'a Value>,
|
||||
style_meta: BTreeMap<&'a str, &'a Value>,
|
||||
/// The ids of the styles imported so far (`doc.styles` is a Vec: a lookup per reference
|
||||
/// would be quadratic in a document with many distinct references).
|
||||
style_ids: BTreeSet<String>,
|
||||
/// Instances (imported as frames until their main is resolved) with their JSON.
|
||||
instances: Vec<(NodeId, &'a Value)>,
|
||||
/// `SLOT` layers (imported as frames until their slot property is resolved) with their JSON.
|
||||
@@ -468,6 +471,7 @@ impl<'a> Ctx<'a> {
|
||||
component_meta: BTreeMap::new(),
|
||||
set_meta: BTreeMap::new(),
|
||||
style_meta: BTreeMap::new(),
|
||||
style_ids: BTreeSet::new(),
|
||||
instances: Vec::new(),
|
||||
slots: Vec::new(),
|
||||
slot_defaults: BTreeMap::new(),
|
||||
@@ -918,7 +922,7 @@ impl<'a> Ctx<'a> {
|
||||
fn style_definitions(&mut self, v: &'a Value) {
|
||||
for d in list(v, "styleDefinitions").iter().take(MAX_STYLES) {
|
||||
let (Some(id), Some(node)) = (text(d, "id").filter(|i| !i.is_empty()).map(clip_str), d.get("node")) else { continue };
|
||||
if self.doc.styles.iter().any(|s| s.id == id) {
|
||||
if self.style_ids.contains(id) {
|
||||
continue;
|
||||
}
|
||||
let kind = match text(d, "styleType") {
|
||||
@@ -953,6 +957,7 @@ impl<'a> Ctx<'a> {
|
||||
};
|
||||
self.bindings(&mut sample, node);
|
||||
let bound = sample.bound.iter().filter_map(|(k, v)| Some((style_binding(slot, k)?, v.clone()))).collect();
|
||||
self.style_ids.insert(id.to_string());
|
||||
self.doc.styles.push(Style { id: id.to_string(), name, description, kind, bound });
|
||||
}
|
||||
}
|
||||
@@ -973,13 +978,14 @@ impl<'a> Ctx<'a> {
|
||||
_ => continue,
|
||||
};
|
||||
*target = Some(sid.to_string());
|
||||
if self.doc.styles.iter().any(|s| s.id == sid) {
|
||||
if self.style_ids.contains(sid) {
|
||||
continue;
|
||||
}
|
||||
let meta = self.style_meta.get(sid);
|
||||
let name =
|
||||
meta.and_then(|m| text(m, "name")).filter(|s| !s.is_empty()).map_or_else(|| format!("Style {sid}"), |s| clip_str(s).to_string());
|
||||
let description = meta.and_then(|m| text(m, "description")).map(|s| clip_str(s).to_string()).unwrap_or_default();
|
||||
self.style_ids.insert(sid.to_string());
|
||||
self.doc.styles.push(Style { id: sid.to_string(), name, description, kind, bound: BTreeMap::new() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,3 +75,21 @@ fn stroke_colour_variable_on_outlined_vector_is_kept() {
|
||||
icon.bound
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thousands_of_distinct_style_references_import_in_linear_time() {
|
||||
// Each reference used to search every style imported so far (quadratic in their number).
|
||||
let fill = json!({"type": "SOLID", "color": {"r": 1, "g": 0, "b": 0, "a": 1}});
|
||||
let n = 20_000;
|
||||
let children: Vec<Value> = (0..n)
|
||||
.map(|i| {
|
||||
json!({"id": format!("1:{i}"), "name": "R", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 10, "y": 10},
|
||||
"fills": [fill], "styles": {"fill": format!("S:{i}")}})
|
||||
})
|
||||
.collect();
|
||||
let v = file(Value::Array(children));
|
||||
let start = std::time::Instant::now();
|
||||
let (doc, _) = import(&v, &no_images).unwrap();
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
assert_eq!(doc.styles.len(), n);
|
||||
}
|
||||
@@ -280,6 +280,7 @@ fn tools() -> Vec<Value> {
|
||||
"svgIdAttribute": boolean("SVG: layer names as unique `id` attributes (default false)"),
|
||||
"svgSimplifyStroke": boolean("SVG: strokes as `stroke` attributes where exact (default true); false writes outlines"),
|
||||
"vdCompat": {"type": "string", "enum": ["bestEffort", "exact", "ignore"], "description": "XML: what a Vector Drawable can't draw exactly is approximated with a warning (bestEffort, default), left out with a warning (exact), or written as far as it maps without warnings (ignore: overlapping layer opacity dropped, even-odd fills kept as fillType=evenOdd, diamond gradients left out)"},
|
||||
"quality": {"type": "integer", "minimum": 1, "maximum": 100, "description": "JPG: the encoder's quality, 1 (smallest file) to 100; default 90"},
|
||||
}),
|
||||
&[],
|
||||
),
|
||||
@@ -1059,6 +1060,7 @@ fn export(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, Str
|
||||
"svgIdAttribute",
|
||||
"svgSimplifyStroke",
|
||||
"vdCompat",
|
||||
"quality",
|
||||
] {
|
||||
if let Some(v) = a.get(k).filter(|v| !v.is_null()) {
|
||||
params.insert(k.into(), v.clone());
|
||||
|
||||
@@ -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());
|
||||
|
||||
+884
-104
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");
|
||||
}
|
||||
|
||||
@@ -465,16 +465,22 @@ pub fn encode_webp(p: &Pixmap) -> Result<Vec<u8>, String> {
|
||||
|
||||
/// Encode an export in `format` (PNG, JPEG over white, WebP) tagged with the document's colour
|
||||
/// `profile`. An sRGB PNG gets an `sRGB` chunk; a Display P3 PNG gets the profile as `iCCP` plus
|
||||
/// `cICP` code points; Display P3 JPEG and WebP files embed the ICC profile (untagged files are read
|
||||
/// as sRGB). SVG, PDF and Vector Drawable are not pixel formats: an error.
|
||||
/// `cICP` code points; a JPEG embeds the ICC profile, sRGB too (JPEG has no small sRGB marker);
|
||||
/// a Display P3 WebP embeds it (an untagged WebP is read as sRGB). JPEG at quality 90. SVG, PDF and
|
||||
/// Vector Drawable are not pixel formats: an error.
|
||||
pub fn encode_export(p: &Pixmap, format: ExportFormat, profile: ColorProfile) -> Result<Vec<u8>, String> {
|
||||
encode_export_with(p, format, profile, figcraft_doc::ExportSetting::DEFAULT_QUALITY)
|
||||
}
|
||||
|
||||
/// [`encode_export`] with the JPEG `quality` (1-100); the other formats ignore it.
|
||||
pub fn encode_export_with(p: &Pixmap, format: ExportFormat, profile: ColorProfile, quality: u8) -> Result<Vec<u8>, String> {
|
||||
let icc = (!profile.is_srgb()).then(|| profile.icc());
|
||||
match format {
|
||||
ExportFormat::Png => {
|
||||
let png = png_with(p, icc)?;
|
||||
Ok(if profile.is_srgb() { png_insert_chunk(png, b"sRGB", &[0]) } else { png_insert_chunk(png, b"cICP", &profile.cicp()) })
|
||||
}
|
||||
ExportFormat::Jpg => jpeg_with(p, Color::WHITE, 90, icc),
|
||||
ExportFormat::Jpg => jpeg_with(p, Color::WHITE, quality, Some(profile.icc())),
|
||||
ExportFormat::Webp => webp_with(p, icc),
|
||||
ExportFormat::Svg | ExportFormat::Pdf | ExportFormat::VectorDrawable => Err(format!("{} is not a pixel format", format.label())),
|
||||
}
|
||||
|
||||
@@ -496,9 +496,16 @@ fn exports_carry_the_colour_profile() {
|
||||
let icc = dec.icc_profile().unwrap();
|
||||
assert_eq!(icc.is_some(), !profile.is_srgb(), "{profile:?}");
|
||||
assert_eq!(image::load_from_memory(&png).unwrap().to_rgba8().get_pixel(5, 5).0, [0, 255, 0, 255]);
|
||||
for f in [ExportFormat::Jpg, ExportFormat::Webp] {
|
||||
assert!(!encode_export(&pix, f, profile).unwrap().is_empty());
|
||||
}
|
||||
// A JPEG always embeds the profile (it has no small sRGB marker); an sRGB WebP is untagged.
|
||||
let jpg = encode_export(&pix, ExportFormat::Jpg, profile).unwrap();
|
||||
let icc = image::codecs::jpeg::JpegDecoder::new(std::io::Cursor::new(&jpg)).unwrap().icc_profile().unwrap();
|
||||
assert_eq!(icc, Some(profile.icc()), "{profile:?}");
|
||||
assert_eq!(jpg, encode_export_with(&pix, ExportFormat::Jpg, profile, 90).unwrap(), "quality 90 by default");
|
||||
assert_ne!(jpg, encode_export_with(&pix, ExportFormat::Jpg, profile, 30).unwrap());
|
||||
let webp = encode_export(&pix, ExportFormat::Webp, profile).unwrap();
|
||||
let icc = image::codecs::webp::WebPDecoder::new(std::io::Cursor::new(&webp)).unwrap().icc_profile().unwrap();
|
||||
assert_eq!(icc.is_some(), !profile.is_srgb(), "{profile:?}");
|
||||
assert_eq!(webp, encode_export_with(&pix, ExportFormat::Webp, profile, 30).unwrap(), "WebP takes no quality");
|
||||
assert!(encode_export(&pix, ExportFormat::Svg, profile).is_err());
|
||||
}
|
||||
assert_eq!(png_insert_chunk(b"not a png".to_vec(), b"sRGB", &[0]), b"not a png");
|
||||
|
||||
+48
-12
@@ -397,8 +397,11 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) {
|
||||
let local = to_bez(p.data());
|
||||
let (fill, stroke) = (p.fill(), p.stroke());
|
||||
let shaped = shape::recognize(&local, stroke.is_none()).and_then(|prim| primitive(prim, world));
|
||||
let (kind, transform, w, h, radius) = match shaped {
|
||||
Some(s) => s,
|
||||
let (kind, transform, w, h, radius, closed_outline) = match shaped {
|
||||
Some((kind, transform, w, h, radius)) => {
|
||||
let closed_outline = !matches!(kind, NodeKind::Line);
|
||||
(kind, transform, w, h, radius, closed_outline)
|
||||
}
|
||||
None => {
|
||||
let mut path = local;
|
||||
path.apply_affine(world);
|
||||
@@ -429,7 +432,10 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) {
|
||||
let fill_only =
|
||||
Node::new(cx.doc.alloc_id(), vector(&closed, true), format!("{} fill", layer_name(p.id(), "Vector")), bb.width(), bb.height());
|
||||
let stroke_only = Node::new(cx.doc.alloc_id(), vector(&snapped, false), layer_name(p.id(), "Vector"), bb.width(), bb.height());
|
||||
let mut layers = [paint_path(cx, p, fill_only, at, world, true, false), paint_path(cx, p, stroke_only, at, world, false, true)];
|
||||
let mut layers = [
|
||||
paint_path(cx, p, fill_only, at, world, Paints { fill: true, stroke: false, closed: true }),
|
||||
paint_path(cx, p, stroke_only, at, world, Paints { fill: false, stroke: true, closed: every_subpath_closed(&snapped) }),
|
||||
];
|
||||
if p.paint_order() == usvg::PaintOrder::StrokeAndFill {
|
||||
layers.reverse();
|
||||
}
|
||||
@@ -438,7 +444,8 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
(vector(if fill.is_some() { &closed } else { &path }, fill.is_some()), at, bb.width(), bb.height(), 0.0)
|
||||
let outline = if fill.is_some() { &closed } else { &path };
|
||||
(vector(outline, fill.is_some()), at, bb.width(), bb.height(), 0.0, every_subpath_closed(outline))
|
||||
}
|
||||
};
|
||||
let fallback = match kind {
|
||||
@@ -449,19 +456,28 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) {
|
||||
};
|
||||
let mut n = Node::new(cx.doc.alloc_id(), kind, layer_name(p.id(), fallback), w, h);
|
||||
n.corners.radii = [radius; 4];
|
||||
let n = paint_path(cx, p, n, transform, world, true, true);
|
||||
let n = paint_path(cx, p, n, transform, world, Paints { fill: true, stroke: true, closed: closed_outline });
|
||||
cx.add(n, parent);
|
||||
}
|
||||
|
||||
/// Which of a path's paints a layer takes, and whether its stroked outline closes on itself (see
|
||||
/// [`stroke_props`]).
|
||||
#[derive(Clone, Copy)]
|
||||
struct Paints {
|
||||
fill: bool,
|
||||
stroke: bool,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
/// Layer `n` of path `p` placed at `transform`, with the path's fill and / or stroke.
|
||||
fn paint_path(cx: &mut Ctx, p: &usvg::Path, mut n: Node, transform: Affine, world: Affine, with_fill: bool, with_stroke: bool) -> Node {
|
||||
fn paint_path(cx: &mut Ctx, p: &usvg::Path, mut n: Node, transform: Affine, world: Affine, paints: Paints) -> Node {
|
||||
n.transform = transform;
|
||||
let target = Target { to_node: transform.inverse() * world, size: (n.width, n.height) };
|
||||
n.fills = p.fill().filter(|_| with_fill).and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect();
|
||||
n.fills = p.fill().filter(|_| paints.fill).and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect();
|
||||
n.strokes.clear();
|
||||
if let Some(s) = p.stroke().filter(|_| with_stroke) {
|
||||
if let Some(s) = p.stroke().filter(|_| paints.stroke) {
|
||||
n.strokes = paint::paint(cx, s.paint(), s.opacity().get(), target).into_iter().collect();
|
||||
n.stroke = stroke_props(s, length_scale(world));
|
||||
n.stroke = stroke_props(s, length_scale(world), paints.closed);
|
||||
// A stroke painted under the fill shows only its outer half.
|
||||
if p.paint_order() == usvg::PaintOrder::StrokeAndFill && !n.fills.is_empty() && figcraft_doc::geometry::is_closed(&n) {
|
||||
n.stroke.align = StrokeAlign::Outside;
|
||||
@@ -544,7 +560,27 @@ fn primitive(prim: Prim, world: Affine) -> Option<(NodeKind, Affine, f64, f64, f
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stroke_props(s: &usvg::Stroke, k: f64) -> figcraft_doc::StrokeProps {
|
||||
/// Whether every subpath of `path` closes on itself (and a dash pattern on it runs from its start,
|
||||
/// as on any closed outline; an open one starts half a dash in).
|
||||
fn every_subpath_closed(path: &BezPath) -> bool {
|
||||
use figcraft_geom::kurbo::PathEl;
|
||||
let els = path.elements();
|
||||
let mut closed = true;
|
||||
for e in els {
|
||||
match e {
|
||||
PathEl::MoveTo(_) if !closed => return false,
|
||||
PathEl::ClosePath => closed = true,
|
||||
_ => closed = false,
|
||||
}
|
||||
}
|
||||
!els.is_empty() && closed
|
||||
}
|
||||
|
||||
/// The stroke of `s` on a layer whose lengths `world` scales by `k`; `closed` says whether its
|
||||
/// outline closes on itself: a closed outline runs a dash pattern from its start, so an offset into
|
||||
/// the pattern rotates it, while an open line starts half a dash in by itself (and export writes
|
||||
/// that offset), so that one is already there.
|
||||
pub(crate) fn stroke_props(s: &usvg::Stroke, k: f64, closed: bool) -> figcraft_doc::StrokeProps {
|
||||
let cap = match s.linecap() {
|
||||
usvg::LineCap::Round => StrokeCap::Round,
|
||||
usvg::LineCap::Square => StrokeCap::Square,
|
||||
@@ -553,8 +589,8 @@ pub(crate) fn stroke_props(s: &usvg::Stroke, k: f64) -> figcraft_doc::StrokeProp
|
||||
let mut dashes: Vec<f64> = s.dasharray().unwrap_or(&[]).iter().map(|v| f64::from(*v) * k).collect();
|
||||
if !dashes.is_empty() {
|
||||
let offset = f64::from(s.dashoffset()) * k;
|
||||
// Half a dash in is how a line starts by itself (and how export writes one).
|
||||
if dashes.first().is_none_or(|d| (d / 2.0 - offset).abs() > 1e-3) {
|
||||
let half_dash_in = !closed && dashes.first().is_some_and(|d| (d / 2.0 - offset).abs() <= 1e-3);
|
||||
if !half_dash_in {
|
||||
dashes = dash_offset(&dashes, offset, cap == StrokeCap::None).unwrap_or(dashes);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +129,69 @@ fn strokes_scale_with_their_layer() {
|
||||
assert_eq!((l.width, l.stroke.weight, l.stroke.dashes.clone()), (30.0, 6.0, vec![12.0, 6.0]));
|
||||
}
|
||||
|
||||
/// `stroke-dashoffset` is how far into the pattern the stroke starts. A closed outline runs the
|
||||
/// pattern from its start, so the offset rotates the pattern; an open line starts half a dash in by
|
||||
/// itself (see `figcraft_pathops::width::line_dashes`), so an offset of half the first dash is
|
||||
/// already there and only that one is left out.
|
||||
#[test]
|
||||
fn a_closed_outline_keeps_a_dash_offset_of_half_a_dash() {
|
||||
let (d, f) = import(
|
||||
r##"<rect id="r" width="40" height="40" fill="none" stroke="#000" stroke-dasharray="4 2" stroke-dashoffset="2"/><path id="p" d="M0 60 L40 60 L20 100 Z" fill="none" stroke="#000" stroke-dasharray="4 2" stroke-dashoffset="2"/><circle id="c" cx="100" cy="100" r="20" fill="none" stroke="#000" stroke-dasharray="4 2" stroke-dashoffset="2"/><line id="l" y1="120" y2="120" x2="40" stroke="#000" stroke-dasharray="4 2" stroke-dashoffset="2"/>"##,
|
||||
);
|
||||
// Two in: a dash of 2 is left, then the gap, then the pattern as it is (`dash_offset`).
|
||||
let rotated = vec![2.0, 2.0, 2.0, 0.0];
|
||||
assert_eq!(node(&d, f, "r").stroke.dashes, rotated, "rectangle");
|
||||
assert_eq!(node(&d, f, "p").stroke.dashes, rotated, "closed path");
|
||||
assert_eq!(node(&d, f, "c").stroke.dashes, rotated, "circle");
|
||||
assert_eq!(node(&d, f, "l").stroke.dashes, vec![4.0, 2.0], "an open line starts half a dash in anyway");
|
||||
}
|
||||
|
||||
/// An open path is a line in this respect: half a dash in is where it starts anyway, so that
|
||||
/// offset is left out, and any other is applied; an outline with an open subpath among closed
|
||||
/// ones is open.
|
||||
#[test]
|
||||
fn an_open_path_starts_half_a_dash_in_and_takes_other_offsets() {
|
||||
let (d, f) = import(
|
||||
r##"<path id="half" d="M0 0 L40 0 L40 40" fill="none" stroke="#000" stroke-dasharray="4 2" stroke-dashoffset="2"/><path id="one" d="M0 60 L40 60" fill="none" stroke="#000" stroke-dasharray="4 2" stroke-dashoffset="1"/><path id="mixed" d="M0 100 L40 100 L20 140 Z M60 100 L100 100" fill="none" stroke="#000" stroke-dasharray="4 2" stroke-dashoffset="2"/>"##,
|
||||
);
|
||||
assert_eq!(node(&d, f, "half").stroke.dashes, vec![4.0, 2.0], "half a dash in is where an open path starts");
|
||||
assert_eq!(node(&d, f, "one").stroke.dashes, vec![3.0, 2.0, 1.0, 0.0], "one in: three of the first dash are left");
|
||||
assert_eq!(node(&d, f, "mixed").stroke.dashes, vec![4.0, 2.0], "an open subpath makes the outline open");
|
||||
}
|
||||
|
||||
/// A filled and stroked path with an open subpath becomes a fill layer and a stroke layer: the
|
||||
/// stroke layer is open, so it starts half a dash in like any open path and that offset is left
|
||||
/// out; a filled and stroked path that closes stays one layer whose offset is applied.
|
||||
#[test]
|
||||
fn a_filled_and_stroked_open_path_splits_and_its_stroke_layer_is_open() {
|
||||
let (d, f) = import(
|
||||
r##"<path id="both" d="M0 0 L40 0 L40 40" fill="#F00" stroke="#000" stroke-dasharray="4 2" stroke-dashoffset="2"/><path id="bothmixed" d="M0 60 L40 60 L20 100 Z M60 60 L100 60 L100 100" fill="#F00" stroke="#000" stroke-dasharray="4 2" stroke-dashoffset="2"/><path id="bothclosed" d="M0 120 L40 120 L20 160 Z" fill="#F00" stroke="#000" stroke-dasharray="4 2" stroke-dashoffset="2"/>"##,
|
||||
);
|
||||
for id in ["both", "bothmixed"] {
|
||||
let stroke_layer = node(&d, f, id);
|
||||
assert!(stroke_layer.fills.is_empty() && !stroke_layer.strokes.is_empty(), "{id}: the stroke layer carries the stroke alone");
|
||||
assert_eq!(stroke_layer.stroke.dashes, vec![4.0, 2.0], "{id}: an open stroke layer starts half a dash in by itself");
|
||||
let fill_layer = node(&d, f, &format!("{id} fill"));
|
||||
assert!(!fill_layer.fills.is_empty() && fill_layer.strokes.is_empty(), "{id}: the fill layer carries the fill alone");
|
||||
}
|
||||
let one = node(&d, f, "bothclosed");
|
||||
assert!(!one.fills.is_empty() && !one.strokes.is_empty(), "a closed path keeps fill and stroke on one layer");
|
||||
assert_eq!(one.stroke.dashes, vec![2.0, 2.0, 2.0, 0.0], "and its offset rotates the pattern");
|
||||
assert!(d.subtree(f).into_iter().filter_map(|i| d.node(i)).all(|n| n.name != "bothclosed fill"), "no split for a closed path");
|
||||
}
|
||||
|
||||
/// A text stroke runs along glyph contours, which close, so its offset is applied as on a shape.
|
||||
#[test]
|
||||
fn a_text_stroke_keeps_a_dash_offset_of_half_a_dash() {
|
||||
if figcraft_text::fontdb::find_face("Inter", "Regular").is_none() {
|
||||
return;
|
||||
}
|
||||
let (d, f) = import(
|
||||
r##"<text id="t" x="10" y="30" font-family="Inter" font-size="20" fill="none" stroke="#000" stroke-dasharray="4 2" stroke-dashoffset="2">T</text>"##,
|
||||
);
|
||||
assert_eq!(node(&d, f, "t").stroke.dashes, vec![2.0, 2.0, 2.0, 0.0]);
|
||||
}
|
||||
|
||||
/// `preserveAspectRatio="none"` stretches a picture into its box edge to edge. usvg sizes the
|
||||
/// box to the picture for `meet` and `slice` (the fit is in the transform), so a box of another
|
||||
/// shape is a stretch: a Fill, which keeps the picture's shape and crops it, would show the
|
||||
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user