mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-07 12:13:14 +00:00
Merge main into #1729
This commit is contained in:
@@ -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
|
||||
@@ -12,13 +12,21 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
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:
|
||||
@@ -32,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 }}
|
||||
@@ -47,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
+44
-22
@@ -1157,6 +1157,26 @@ dependencies = [
|
||||
"zip 9.1.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-api"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-window"
|
||||
version = "0.1.0"
|
||||
@@ -1763,7 +1783,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-menu",
|
||||
@@ -1795,7 +1815,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -1813,7 +1833,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -1831,7 +1851,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1854,7 +1874,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1863,7 +1883,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1876,7 +1896,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
@@ -1910,7 +1930,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-layout",
|
||||
@@ -1927,7 +1947,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1935,7 +1955,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1943,7 +1963,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1959,7 +1979,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1968,7 +1988,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1985,7 +2005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1998,7 +2018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -2017,7 +2037,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"crc32fast",
|
||||
@@ -2042,7 +2062,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -2058,7 +2078,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"craft-font-catalog",
|
||||
@@ -2076,7 +2096,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -2104,7 +2124,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-vectordrawable"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -2118,9 +2138,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"eframe",
|
||||
"egui",
|
||||
"figcraft-collab-net",
|
||||
@@ -6739,7 +6761,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
|
||||
@@ -314,14 +314,22 @@ stand on its own.
|
||||
|
||||
| | App | What it's for | Code | Learn more |
|
||||
|:-:|---|---|---|---|
|
||||
| <img src="assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | **Interface design, components and prototypes · you are here** | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
|
||||
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
|
||||
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
|
||||
|
||||
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
|
||||
|
||||
|
||||
+28
-6
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[path = "../src/args.rs"]
|
||||
#[expect(dead_code, reason = "Generation uses clap's command metadata, not the parsed fields or runtime parser.")]
|
||||
#[cfg_attr(not(test), expect(dead_code, reason = "Generation uses clap's command metadata, not the parsed fields or runtime parser."))]
|
||||
mod args;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
@@ -498,7 +498,8 @@ fn measure_components(table: &mut Table, col: usize, instances: usize) -> CliRes
|
||||
static RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let run = RUNS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-perf-{}-{run}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).map_err(failed)?;
|
||||
// `create_dir`, not `create_dir_all`: a directory or link already at this predictable name is refused.
|
||||
std::fs::create_dir(&dir).map_err(failed)?;
|
||||
let path = dir.join("design-system.figcraft").to_string_lossy().into_owned();
|
||||
let (lib_doc, lib_ds) = design_system_document(&spec).map_err(failed)?;
|
||||
let mut lib_s = Session::with_document(lib_doc);
|
||||
|
||||
@@ -75,3 +75,11 @@ fn codes_are_found_through_the_index_as_before() {
|
||||
assert_eq!(store.resolve_code(who(105), &codes[2]).unwrap_err(), CODE_GONE);
|
||||
assert_eq!(store.resolve_code(who(106), &codes[3]).unwrap().0, files[3].0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_taken_from_the_file_is_cleaned_like_a_given_one() {
|
||||
let dir = TempDir::new("embedded-name");
|
||||
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
|
||||
let (file, ..) = store.publish("", &snapshot("Bad\u{1b}[2Jname\u{202E}")).unwrap();
|
||||
assert_eq!(file.name, "Bad[2Jname");
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
});
|
||||
for guest in [&b, &c] {
|
||||
assert!(guest.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", guest.message);
|
||||
assert_eq!(guest.live().status, Status::Off, "over, not failed: nothing retries");
|
||||
assert!(guest.live().rejoinable() && !guest.live().status.active(), "over: nothing retries, and the tab says so");
|
||||
}
|
||||
assert_eq!(layers(&b), before + 2, "Bo keeps the copy");
|
||||
let folder = dir.path().join("files").join(&p.file);
|
||||
|
||||
@@ -160,7 +160,7 @@ fn revoked_links_stop_working_at_once() {
|
||||
assert_eq!((files.len(), files[0].people), (1, 3));
|
||||
// Revoked: Cy is out at once, the others stay.
|
||||
assert_eq!(ask(Admin::Revoke { file: file.clone(), link: review.link.id.clone() }), AdminReply::Done);
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().status == Status::Off && s[0].live().peers.len() == 1);
|
||||
pump(&mut [&mut a, &mut b, &mut c], "Cy out", |s| s[2].live().rejoinable() && s[0].live().peers.len() == 1);
|
||||
// Told as when a host stops: the session is over for Cy, whose copy stays open (no retries).
|
||||
assert!(c.message.as_deref().unwrap_or_default().contains("Sharing was stopped"), "{:?}", c.message);
|
||||
assert!(live(&b));
|
||||
|
||||
@@ -68,7 +68,9 @@ impl Backend for Store {
|
||||
Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"),
|
||||
}
|
||||
}
|
||||
log.entries += journal.len();
|
||||
// A refusal changes nothing in the document: it counts toward the log's size (bytes), but
|
||||
// not toward the entries that make a snapshot rewrite worthwhile.
|
||||
log.entries += journal.iter().filter(|j| !matches!(j, figcraft_collab::Journal::Refused { .. })).count();
|
||||
log.dirty = true;
|
||||
e.meta.updated = now();
|
||||
if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes {
|
||||
|
||||
@@ -95,8 +95,9 @@ impl Store {
|
||||
return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes));
|
||||
}
|
||||
let mut doc = replica.document().clone();
|
||||
// The name the file keeps, whichever one it comes from, is cleaned.
|
||||
let name = match clean_name(name) {
|
||||
n if n.is_empty() => doc.name.clone(),
|
||||
n if n.is_empty() => clean_name(&doc.name),
|
||||
n => n,
|
||||
};
|
||||
doc.name = name.clone();
|
||||
|
||||
@@ -155,6 +155,24 @@ fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_viewers_refused_batches_do_not_rewrite_the_snapshot() {
|
||||
let dir = TempDir::new("refusals");
|
||||
let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) };
|
||||
let mut store = Store::open(dir.path(), c).unwrap();
|
||||
let (id, _, _) = publish(&mut store, "Busy");
|
||||
let mut server = store.open(&id).unwrap();
|
||||
let mut v = join(&mut server, Access::View);
|
||||
store.changed(&id, &mut server);
|
||||
let generation = || disk::check_snapshot(&fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap()).unwrap();
|
||||
assert_eq!(generation(), 1);
|
||||
for i in 0..10 {
|
||||
rename(&mut server, &mut v, &format!("P{i}"));
|
||||
store.changed(&id, &mut server);
|
||||
}
|
||||
assert_eq!(generation(), 1, "refusals are logged, not compacted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_log_is_compacted_while_open() {
|
||||
let dir = TempDir::new("compact");
|
||||
|
||||
@@ -19,6 +19,10 @@ 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.
|
||||
# The page command API (storytold/craft-libs#413): the request plumbing behind window.figcraft.
|
||||
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||
base64 = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
@@ -30,4 +34,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"] }
|
||||
|
||||
+103
-63
@@ -1,36 +1,54 @@
|
||||
//! `window.figcraft`: the control channel for the page, as promises.
|
||||
//! `window.figcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) plus FigCraft's
|
||||
//! own file functions.
|
||||
//!
|
||||
//! The same JSON methods as the desktop control server (`docs/control-protocol.md`), so an agent
|
||||
//! driving FigCraft in a browser tab (or an `<iframe>` in another app) uses the same commands as
|
||||
//! the CLI and the MCP server:
|
||||
//! The crate installs `request`, `execute`, `commands`, `docInspect`, `uiInspect` and `screenshot`
|
||||
//! over the same JSON methods as the desktop control server (`docs/control-protocol.md`), as
|
||||
//! promises with a timeout, so an agent driving FigCraft in a browser tab (or an `<iframe>` in
|
||||
//! another app) uses the same commands as the CLI and the MCP server:
|
||||
//!
|
||||
//! ```js
|
||||
//! await figcraft.execute("node.create", {kind: "frame", name: "Home"}) // engine command → its result
|
||||
//! await figcraft.execute("node.create", {type: "frame", name: "Home"}) // engine command → its result
|
||||
//! await figcraft.request("document.inspect") // control method → its result
|
||||
//! await figcraft.commands() // command list
|
||||
//! const {data} = await figcraft.screenshot() // the canvas as PNG (base64)
|
||||
//! ```
|
||||
//!
|
||||
//! FigCraft adds, on the same object:
|
||||
//!
|
||||
//! ```js
|
||||
//! await figcraft.openFile("home.fig", bytes) // `file.open` with the bytes
|
||||
//! figcraft.captureDownloads(true) // saves stay in the page…
|
||||
//! figcraft.files(); await figcraft.readFile("home.figcraft") // …to be read back
|
||||
//! await figcraft.inspect() // `document.inspect`
|
||||
//! ```
|
||||
//!
|
||||
//! Results resolve with the method's `result`; errors reject with an `Error`.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::rc::Rc;
|
||||
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_ui_egui::{ControlReply, ControlRequest};
|
||||
use craft_web_api::methods::Methods;
|
||||
use craft_web_api::page::{PageApi, Request};
|
||||
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate};
|
||||
use serde_json::{Value, json};
|
||||
use wasm_bindgen::JsCast;
|
||||
use wasm_bindgen::prelude::*;
|
||||
use wasm_bindgen_futures::{JsFuture, future_to_promise};
|
||||
|
||||
use crate::saved_files::SavedFiles;
|
||||
|
||||
type Waiting = (Receiver<ControlReply>, js_sys::Function, js_sys::Function);
|
||||
/// The app id in `window.<app>`.
|
||||
const APP: &str = "figcraft";
|
||||
|
||||
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||
const POLL_MS: i32 = 16;
|
||||
|
||||
thread_local! {
|
||||
static QUEUE: RefCell<Vec<ControlRequest>> = const { RefCell::new(Vec::new()) };
|
||||
static WAITING: RefCell<Vec<Waiting>> = const { RefCell::new(Vec::new()) };
|
||||
static NEXT_ID: Cell<u64> = const { Cell::new(0) };
|
||||
static CTX: RefCell<Option<egui::Context>> = const { RefCell::new(None) };
|
||||
static CAPTURE: Cell<bool> = const { Cell::new(false) };
|
||||
@@ -59,88 +77,112 @@ fn to_js(v: &Value) -> JsValue {
|
||||
js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL)
|
||||
}
|
||||
|
||||
fn from_js(v: &JsValue) -> Value {
|
||||
if v.is_undefined() || v.is_null() {
|
||||
return json!({});
|
||||
}
|
||||
js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({}))
|
||||
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
|
||||
/// timeout fired) cancels the request if the app has not started it: the page was told it failed,
|
||||
/// so the app must not run it late.
|
||||
struct Waiting {
|
||||
reply: Receiver<ControlReply>,
|
||||
gate: RequestGate,
|
||||
answered: bool,
|
||||
}
|
||||
|
||||
/// Settle the promises of answered requests (called every frame).
|
||||
pub fn poll_replies() {
|
||||
WAITING.with(|w| {
|
||||
w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() {
|
||||
Ok(ControlReply { result: Ok(v), .. }) => {
|
||||
let _ = resolve.call1(&JsValue::NULL, &to_js(&v));
|
||||
false
|
||||
impl Waiting {
|
||||
/// The reply as the protocol's object once the app has answered or gone away; `None` while the
|
||||
/// request is still waiting.
|
||||
fn poll(&mut self) -> Option<Value> {
|
||||
match self.reply.try_recv() {
|
||||
Ok(ControlReply { result, .. }) => {
|
||||
self.answered = true;
|
||||
Some(match result {
|
||||
Ok(value) => json!({"ok": true, "result": value}),
|
||||
Err(error) => json!({"ok": false, "error": error}),
|
||||
})
|
||||
}
|
||||
Ok(ControlReply { result: Err(e), .. }) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&e));
|
||||
false
|
||||
}
|
||||
Err(TryRecvError::Empty) => true,
|
||||
Err(TryRecvError::Empty) => None,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped"));
|
||||
false
|
||||
self.answered = true;
|
||||
Some(json!({"ok": false, "error": "request dropped"}))
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn request(method: &str, params: Value) -> js_sys::Promise {
|
||||
let method = method.to_string();
|
||||
let p = js_sys::Promise::new(&mut |resolve, reject| {
|
||||
let (reply, rx) = std::sync::mpsc::channel();
|
||||
let id = NEXT_ID.with(|n| {
|
||||
n.set(n.get() + 1);
|
||||
n.get()
|
||||
});
|
||||
let req = ControlRequest { id: json!(id), method: method.clone(), params: params.clone(), reply, state: Default::default() };
|
||||
QUEUE.with(|q| q.borrow_mut().push(req));
|
||||
WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject)));
|
||||
impl Drop for Waiting {
|
||||
fn drop(&mut self) {
|
||||
if !self.answered {
|
||||
self.gate.cancel_if_open();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue a control request for the next frame and wait for the app's reply, as the protocol's
|
||||
/// `{ok, result}` / `{ok: false, error}` object.
|
||||
fn send(method: &str, params: Value) -> Pin<Box<dyn Future<Output = Value>>> {
|
||||
let (reply, rx) = std::sync::mpsc::channel();
|
||||
let id = NEXT_ID.with(|n| {
|
||||
n.set(n.get() + 1);
|
||||
n.get()
|
||||
});
|
||||
let gate = RequestGate::new();
|
||||
QUEUE.with(|q| q.borrow_mut().push(ControlRequest { id: json!(id), method: method.to_string(), params, reply, state: gate.clone() }));
|
||||
CTX.with(|c| {
|
||||
if let Some(ctx) = c.borrow().as_ref() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
p
|
||||
let mut waiting = Waiting { reply: rx, gate, answered: false };
|
||||
Box::pin(async move {
|
||||
loop {
|
||||
if let Some(reply) = waiting.poll() {
|
||||
return reply;
|
||||
}
|
||||
sleep(POLL_MS).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn sleep(ms: i32) {
|
||||
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||
}
|
||||
});
|
||||
let _ = JsFuture::from(promise).await;
|
||||
}
|
||||
|
||||
/// A reply as a promise outcome: the `result`, or an `Error` carrying the `error`.
|
||||
fn outcome(reply: &Value) -> Result<JsValue, JsValue> {
|
||||
if reply.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(to_js(reply.get("result").unwrap_or(&Value::Null)))
|
||||
} else {
|
||||
Err(js_sys::Error::new(reply.get("error").and_then(Value::as_str).unwrap_or("error")).into())
|
||||
}
|
||||
}
|
||||
|
||||
fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> {
|
||||
js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ())
|
||||
}
|
||||
|
||||
/// Install `window.figcraft`.
|
||||
/// Install `window.figcraft`: the crate's functions, then FigCraft's own.
|
||||
pub fn install() -> Result<(), JsValue> {
|
||||
let obj = js_sys::Object::new();
|
||||
let request: Request = Rc::new(|method, params| send(&method, params));
|
||||
Rc::new(PageApi::new(APP, Methods::default(), request)).install()?;
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let obj: js_sys::Object = js_sys::Reflect::get(&window, &APP.into())?.unchecked_into();
|
||||
set_fn(
|
||||
&obj,
|
||||
"request",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(¶ms)))
|
||||
"inspect",
|
||||
Closure::<dyn Fn() -> js_sys::Promise>::new(|| future_to_promise(async { outcome(&send("document.inspect", json!({})).await) }))
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"execute",
|
||||
Closure::<dyn Fn(String, JsValue) -> js_sys::Promise>::new(|command: String, params: JsValue| {
|
||||
request("engine.execute", json!({"command": command, "params": from_js(¶ms)}))
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
set_fn(&obj, "commands", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?;
|
||||
set_fn(&obj, "inspect", Closure::<dyn Fn() -> js_sys::Promise>::new(|| request("document.inspect", json!({}))).into_js_value())?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"screenshot",
|
||||
Closure::<dyn Fn(JsValue) -> js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(¶ms))).into_js_value(),
|
||||
)?;
|
||||
set_fn(
|
||||
&obj,
|
||||
"openFile",
|
||||
Closure::<dyn Fn(String, js_sys::Uint8Array) -> js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| {
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec());
|
||||
request("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}}))
|
||||
future_to_promise(async move {
|
||||
outcome(&send("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}})).await)
|
||||
})
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
@@ -166,7 +208,5 @@ pub fn install() -> Result<(), JsValue> {
|
||||
})
|
||||
.into_js_value(),
|
||||
)?;
|
||||
let w = web_sys::window().ok_or("no window")?;
|
||||
js_sys::Reflect::set(&w, &"figcraft".into(), &obj)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -170,7 +225,6 @@ impl eframe::App for WebShell {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.fig.control_queue.extend(crate::api::take_requests());
|
||||
self.fig.ui(ui);
|
||||
crate::api::poll_replies();
|
||||
// The fonts on this computer that text asked for this frame are read; once one is in,
|
||||
// auto-sizing text measured in a fallback fits it.
|
||||
crate::local_fonts::load_wanted(ui.ctx());
|
||||
@@ -182,12 +236,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 +261,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 +278,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);
|
||||
}
|
||||
|
||||
@@ -217,7 +217,8 @@ impl Recovery {
|
||||
return false;
|
||||
}
|
||||
let _ = match online {
|
||||
Some(text) => std::fs::write(¬e, text),
|
||||
// The note holds the secret the client resumes with: owner-only, as the copy.
|
||||
Some(text) => write_private(¬e, text.as_bytes()),
|
||||
None => std::fs::remove_file(¬e),
|
||||
};
|
||||
true
|
||||
@@ -326,24 +327,29 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
|
||||
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
|
||||
let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false };
|
||||
let tmp = path.with_extension("tmp");
|
||||
if write_private(&tmp, &bytes).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return false;
|
||||
}
|
||||
std::fs::rename(&tmp, path).is_ok()
|
||||
}
|
||||
|
||||
/// Write `bytes` to `path`, created (or cut back) readable by its owner only on Unix.
|
||||
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true).mode(0o600);
|
||||
let Ok(mut f) = opts.open(&tmp) else { return false };
|
||||
if std::io::Write::write_all(&mut f, &bytes).is_err() {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return false;
|
||||
}
|
||||
let mut f = opts.open(path)?;
|
||||
// `mode` applies to a new file only: one that was there keeps its mode unless set.
|
||||
f.set_permissions(std::fs::Permissions::from_mode(0o600))?;
|
||||
std::io::Write::write_all(&mut f, bytes)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
if std::fs::write(&tmp, bytes).is_err() {
|
||||
return false;
|
||||
}
|
||||
std::fs::write(path, bytes)
|
||||
}
|
||||
std::fs::rename(&tmp, path).is_ok()
|
||||
}
|
||||
|
||||
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -98,3 +98,20 @@ sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
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"
|
||||
|
||||
[people.ashokram-si]
|
||||
real_name = "Ashok Ram"
|
||||
|
||||
@@ -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() }
|
||||
|
||||
@@ -28,7 +28,7 @@ use crate::invite::Endpoint;
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, ask, closed, hello, resolved, take, too_large, unresolved};
|
||||
use crate::limits::NetLimits;
|
||||
use crate::requests::Publish;
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Paced, Throttle, earliest};
|
||||
|
||||
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
|
||||
/// driver's event methods.
|
||||
@@ -79,6 +79,8 @@ pub(crate) struct Driver<S: Socket> {
|
||||
/// Your latest presence while not welcomed (sent with the welcome).
|
||||
pending: Option<Presence>,
|
||||
throttle: Throttle<Presence>,
|
||||
/// Sync messages waiting their turn (a long queue from while offline).
|
||||
paced: Paced<ClientMsg>,
|
||||
reconnect_at: Option<Instant>,
|
||||
/// What this connection asks a server (a managing one), and what follows its answers.
|
||||
asking: Option<(Box<Exchange>, Then)>,
|
||||
@@ -86,7 +88,7 @@ pub(crate) struct Driver<S: Socket> {
|
||||
}
|
||||
|
||||
impl<S: Socket> Driver<S> {
|
||||
fn new(sock: S, cfg: GuestConfig, inbox: Inbox) -> Self {
|
||||
fn new(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self {
|
||||
Self {
|
||||
sock,
|
||||
cfg,
|
||||
@@ -97,6 +99,7 @@ impl<S: Socket> Driver<S> {
|
||||
resume: Resuming::default(),
|
||||
pending: None,
|
||||
throttle: Throttle::new(PRESENCE_INTERVAL),
|
||||
paced: Paced::new(now),
|
||||
reconnect_at: None,
|
||||
asking: None,
|
||||
done: false,
|
||||
@@ -105,7 +108,7 @@ impl<S: Socket> Driver<S> {
|
||||
|
||||
/// Start joining (progress arrives in `inbox`).
|
||||
pub(crate) fn start(sock: S, cfg: GuestConfig, inbox: Inbox, now: Instant) -> Self {
|
||||
let mut d = Self::new(sock, cfg, inbox);
|
||||
let mut d = Self::new(sock, cfg, inbox, now);
|
||||
d.inbox.push(Event::Status(Status::Connecting));
|
||||
d.connect(now);
|
||||
d
|
||||
@@ -114,7 +117,7 @@ impl<S: Socket> Driver<S> {
|
||||
/// Start with what `next` asks a server (a background request): the events it ends in go to
|
||||
/// `inbox`, then the join it ends in, if any, as `cfg` says.
|
||||
pub(crate) fn asking(sock: S, cfg: GuestConfig, next: exchange::Next, inbox: Inbox, now: Instant) -> Self {
|
||||
let mut d = Self::new(sock, cfg, inbox);
|
||||
let mut d = Self::new(sock, cfg, inbox, now);
|
||||
d.follow(next, now);
|
||||
d
|
||||
}
|
||||
@@ -159,6 +162,8 @@ impl<S: Socket> Driver<S> {
|
||||
|
||||
fn connect(&mut self, now: Instant) {
|
||||
self.reconnect_at = None;
|
||||
// A server counts per connection, and these were made for the last one.
|
||||
self.paced = Paced::new(now);
|
||||
self.conn = Some(Conn::new(now));
|
||||
if let Err(e) = self.sock.open(&self.server().url()) {
|
||||
self.dropped(e, now);
|
||||
@@ -303,13 +308,35 @@ impl<S: Socket> Driver<S> {
|
||||
if !self.conn.as_ref().is_some_and(|c| c.welcomed) {
|
||||
return;
|
||||
}
|
||||
if !self.write(&msg) {
|
||||
self.paced.push(msg);
|
||||
if !self.send_paced(now) {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send the sync messages whose turn has come; false when one couldn't go.
|
||||
fn send_paced(&mut self, now: Instant) -> bool {
|
||||
while let Some(msg) = self.paced.take_due(now) {
|
||||
if !self.write(&msg) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Reconnect now, if it is waiting to: the pauses grow from the beginning again.
|
||||
pub(crate) fn retry(&mut self, now: Instant) {
|
||||
if !self.done && self.reconnect_at.is_some() {
|
||||
self.attempt = 0;
|
||||
self.connect(now);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave the session.
|
||||
pub(crate) fn leave(&mut self, now: Instant) {
|
||||
// What may go now still reaches the others. The rest would get the connection refused, and
|
||||
// a page can't wait: the sync client says so when you leave.
|
||||
let _ = self.send_paced(now);
|
||||
self.end(Outcome::Left, now);
|
||||
}
|
||||
|
||||
@@ -327,7 +354,7 @@ impl<S: Socket> Driver<S> {
|
||||
[Some(c.last_ping + l.keepalive), Some(c.last_heard + c.silence_limit(l)), if c.welcomed { self.throttle.due() } else { None }]
|
||||
}
|
||||
});
|
||||
earliest(conn.into_iter().flatten().chain([self.reconnect_at]))
|
||||
earliest(conn.into_iter().flatten().chain([self.reconnect_at, self.paced.due()]))
|
||||
}
|
||||
|
||||
/// Do what's due at `now`: reconnect, send held presence or a ping, give up on a connection
|
||||
@@ -357,7 +384,7 @@ impl<S: Socket> Driver<S> {
|
||||
if now >= last_heard + silence {
|
||||
return self.end(Outcome::Retry("the host stopped answering".into()), now);
|
||||
}
|
||||
let ok = self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
|
||||
let ok = self.send_paced(now) && self.throttle.take_due(now).is_none_or(|p| self.write(&ClientMsg::Presence(p)));
|
||||
let ok = ok && (now < last_ping + l.keepalive || self.ping(now));
|
||||
if !ok {
|
||||
self.end(Outcome::Retry("the connection dropped".into()), now);
|
||||
@@ -388,10 +415,12 @@ impl<S: Socket> Driver<S> {
|
||||
self.sock.close();
|
||||
}
|
||||
self.throttle = Throttle::new(PRESENCE_INTERVAL);
|
||||
// Made for this connection: the sync client sends again what the server didn't get.
|
||||
self.paced = Paced::new(now);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ fn a_silent_host_counts_as_dropped_and_the_client_reconnects() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconnects_back_off_then_give_up() {
|
||||
fn a_live_connection_reconnects_in_rounds_and_never_gives_up() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
@@ -260,7 +260,7 @@ fn reconnects_back_off_then_give_up() {
|
||||
let mut now = t0;
|
||||
let mut pauses = Vec::new();
|
||||
d.closed(1006, "", now);
|
||||
while !d.done() {
|
||||
for _ in 0..26 {
|
||||
let next = d.deadline().unwrap();
|
||||
pauses.push(next - now);
|
||||
now = next;
|
||||
@@ -269,11 +269,183 @@ fn reconnects_back_off_then_give_up() {
|
||||
d.closed(1006, "", now);
|
||||
}
|
||||
let secs: Vec<f64> = pauses.iter().map(Duration::as_secs_f64).collect();
|
||||
assert_eq!(secs, [0.5, 1.0, 2.0, 4.0, 8.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0]);
|
||||
let round = [1.0, 2.0, 4.0, 8.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0, 10.0];
|
||||
assert_eq!(secs[0], 0.5);
|
||||
assert_eq!(secs[1..12], round);
|
||||
assert_eq!(secs[12], 60.0, "a longer pause after a round");
|
||||
assert_eq!(secs[13..24], round, "then as many again");
|
||||
assert_eq!(secs[24], 60.0);
|
||||
assert!(!d.done());
|
||||
let st = statuses(&inbox.drain());
|
||||
assert_eq!(st.len(), 14, "connecting, 12 reconnects, then failed");
|
||||
assert!(matches!(st.last(), Some(Status::Failed { reason }) if reason.starts_with("Lost the connection to the host")), "{st:?}");
|
||||
assert_eq!(d.deadline(), None);
|
||||
assert!(!st.iter().any(|s| matches!(s, Status::Failed { .. })), "{st:?}");
|
||||
assert!(matches!(st.last(), Some(Status::Reconnecting { attempt: 27 })), "{st:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_now_skips_the_pause_and_starts_the_round_over() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
d.closed(1006, "", t0);
|
||||
let mut now = t0;
|
||||
for _ in 0..12 {
|
||||
now = d.deadline().unwrap();
|
||||
d.tick(now);
|
||||
d.closed(1006, "", now);
|
||||
}
|
||||
assert_eq!(d.deadline().unwrap() - now, Duration::from_secs(60));
|
||||
let opened = d.sock.opened.len();
|
||||
d.retry(now);
|
||||
assert_eq!(d.sock.opened.len(), opened + 1, "connects at once");
|
||||
inbox.drain();
|
||||
d.closed(1006, "", now);
|
||||
assert_eq!(statuses(&inbox.drain()), vec![Status::Reconnecting { attempt: 1 }]);
|
||||
assert_eq!(d.deadline().unwrap() - now, ms(500), "the pauses grow from the beginning again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_was_live_only_stops_for_a_refusal_that_is_for_good() {
|
||||
let t0 = Instant::now();
|
||||
// The words servers turn a session away with for now.
|
||||
for reason in [
|
||||
"too many messages",
|
||||
"the server is busy: try again in a moment",
|
||||
"the session is full",
|
||||
"no hello in time",
|
||||
"this file can't be opened on the server (its data is damaged); tell the server's owner",
|
||||
"too many connections from your address lately: wait a minute and try again",
|
||||
] {
|
||||
for frame in [false, true] {
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
inbox.drain();
|
||||
if frame {
|
||||
d.closed(1008, reason, t0);
|
||||
} else {
|
||||
d.message(&encode(&ServerMsg::Refused { reason: reason.into() }), t0);
|
||||
}
|
||||
assert!(!d.done(), "{reason}");
|
||||
assert_eq!(statuses(&inbox.drain()), vec![Status::Reconnecting { attempt: 1 }], "{reason}");
|
||||
}
|
||||
}
|
||||
// And the words for good.
|
||||
for reason in [
|
||||
"this link isn't valid (it may have been revoked)",
|
||||
"this file isn't on the server (it may have been removed)",
|
||||
"this file was removed from the server",
|
||||
"you were removed from this session",
|
||||
"this server no longer accepts connections from your address",
|
||||
"this server doesn't accept connections from your address",
|
||||
"wrong session token",
|
||||
"protocol 9 isn't supported",
|
||||
"message too large: this server takes at most 64 MiB at once (a change that big can't be kept here)",
|
||||
] {
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
d.closed(1008, reason, t0);
|
||||
assert!(d.done(), "{reason}");
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason: r }) if r.contains(reason)), "{reason}");
|
||||
}
|
||||
// What this end finds it can't talk to: a text frame, or a message it can't read.
|
||||
for text in [true, false] {
|
||||
let (mut d, inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
if text {
|
||||
d.text(t0);
|
||||
} else {
|
||||
d.message(&[0xFF, 1, 2, 3], t0);
|
||||
}
|
||||
assert!(d.done(), "text: {text}");
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { .. })));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_queue_goes_out_paced_and_in_order() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
let before = d.sock.sent.len();
|
||||
for seq in 0..500 {
|
||||
d.send(ClientMsg::Ack { seq }, t0);
|
||||
}
|
||||
let at_once = d.sock.sent.len() - before;
|
||||
assert!((1..=200).contains(&at_once), "{at_once} at once: a server drops a connection past 400");
|
||||
let mut now = t0;
|
||||
while d.sock.sent.iter().filter(|m| matches!(m, ClientMsg::Ack { .. })).count() < 500 {
|
||||
now = d.deadline().unwrap();
|
||||
d.tick(now);
|
||||
}
|
||||
let seqs: Vec<u64> = d.sock.sent.iter().filter_map(|m| if let ClientMsg::Ack { seq } = m { Some(*seq) } else { None }).collect();
|
||||
assert_eq!(seqs, (0..500).collect::<Vec<u64>>());
|
||||
assert!(now - t0 >= Duration::from_secs(4), "the rest take their time: {:?}", now - t0);
|
||||
// Unsent ones were made for this connection: the sync client sends them again.
|
||||
for seq in 0..500 {
|
||||
d.send(ClientMsg::Ack { seq }, now);
|
||||
}
|
||||
d.closed(1006, "", now);
|
||||
assert_eq!(d.deadline().unwrap() - now, ms(500), "nothing left to send but the reconnect");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_held_up_by_a_stall_dont_leave_together() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
for seq in 0..700 {
|
||||
d.send(ClientMsg::Ack { seq }, t0);
|
||||
}
|
||||
let acks = |d: &Driver<Script>| d.sock.sent.iter().filter(|m| matches!(m, ClientMsg::Ack { .. })).count();
|
||||
let first = acks(&d);
|
||||
// Nothing ran for 8 s.
|
||||
d.tick(t0 + Duration::from_secs(8));
|
||||
assert!(acks(&d) - first <= 200, "{} at once: a server drops a connection past 400", acks(&d) - first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_connection_starts_with_a_fresh_pace() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
for seq in 0..500 {
|
||||
d.send(ClientMsg::Ack { seq }, t0);
|
||||
}
|
||||
d.closed(1006, "", t0);
|
||||
let back = d.deadline().unwrap();
|
||||
d.tick(back);
|
||||
d.opened(back);
|
||||
d.message(&welcome(), back);
|
||||
d.send(ClientMsg::Ack { seq: 900 }, back);
|
||||
assert!(d.sock.sent.iter().any(|m| matches!(m, ClientMsg::Ack { seq: 900 })), "sent at once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_sends_what_may_go_at_its_pace() {
|
||||
let t0 = Instant::now();
|
||||
let (mut d, _inbox) = start(Script::default(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&welcome(), t0);
|
||||
for seq in 0..150 {
|
||||
d.send(ClientMsg::Ack { seq }, t0);
|
||||
}
|
||||
let later = t0 + Duration::from_secs(1);
|
||||
for seq in 150..600 {
|
||||
d.send(ClientMsg::Ack { seq }, later);
|
||||
}
|
||||
let before = d.sock.sent.iter().filter(|m| matches!(m, ClientMsg::Ack { .. })).count();
|
||||
d.leave(later + Duration::from_secs(1));
|
||||
let seqs: Vec<u64> = d.sock.sent.iter().filter_map(|m| if let ClientMsg::Ack { seq } = m { Some(*seq) } else { None }).collect();
|
||||
assert!(seqs.len() > before, "more goes on leaving");
|
||||
assert!(seqs.len() - before <= 200, "{} at once: a server drops a connection past 400", seqs.len() - before);
|
||||
assert_eq!(seqs, (0..seqs.len() as u64).collect::<Vec<u64>>(), "in order");
|
||||
assert!(d.done());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -419,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(|| {}));
|
||||
@@ -428,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(),
|
||||
|
||||
@@ -31,7 +31,7 @@ use crate::manage;
|
||||
use crate::net::{Duplex, NetLimits, config, open, shutdown};
|
||||
use crate::requests;
|
||||
pub use crate::requests::Publish;
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Paced, Throttle, earliest};
|
||||
|
||||
/// A joined (or joining) session. Dropping it leaves.
|
||||
pub struct Guest {
|
||||
@@ -44,6 +44,8 @@ enum Cmd {
|
||||
/// For the server, in order (document changes).
|
||||
Send(ClientMsg),
|
||||
Leave,
|
||||
/// Try now rather than after the pause under way (reconnecting).
|
||||
Retry,
|
||||
/// The reader of connection `epoch` got its welcome.
|
||||
Welcomed(u64),
|
||||
/// Connection `epoch` ended.
|
||||
@@ -67,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;
|
||||
}
|
||||
@@ -133,6 +135,11 @@ impl Guest {
|
||||
let _ = self.cmd.send(Cmd::Send(msg));
|
||||
}
|
||||
|
||||
/// Reconnect now, if it is waiting to.
|
||||
pub fn retry(&self) {
|
||||
let _ = self.cmd.send(Cmd::Retry);
|
||||
}
|
||||
|
||||
/// Leave (returns at once: a connection attempt in progress ends on its own).
|
||||
pub fn leave(&mut self) {
|
||||
let _ = self.cmd.send(Cmd::Leave);
|
||||
@@ -155,6 +162,9 @@ impl Drop for Guest {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long leaving keeps sending what was waiting its turn.
|
||||
const LEAVE_DRAIN: Duration = Duration::from_secs(10);
|
||||
|
||||
/// A connected socket: reader and writer ends.
|
||||
struct Conn {
|
||||
reader: WebSocket<Duplex>,
|
||||
@@ -217,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;
|
||||
}
|
||||
@@ -227,6 +237,11 @@ fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<C
|
||||
match rx.recv_timeout(until.saturating_duration_since(Instant::now())) {
|
||||
Ok(Cmd::Leave) | Err(RecvTimeoutError::Disconnected) => return,
|
||||
Ok(Cmd::Presence(p)) => pending = Some(p),
|
||||
// A fresh start: the pauses grow from the beginning again.
|
||||
Ok(Cmd::Retry) => {
|
||||
attempt = 0;
|
||||
break;
|
||||
}
|
||||
// From the old connection's client: the next welcome starts over.
|
||||
Ok(_) => {}
|
||||
Err(RecvTimeoutError::Timeout) => break,
|
||||
@@ -267,14 +282,15 @@ fn session(
|
||||
let hello = hello(cfg, resume);
|
||||
let mut welcomed = false;
|
||||
let mut throttle = Throttle::new(PRESENCE_INTERVAL);
|
||||
// Sync messages waiting their turn (a long queue from while offline).
|
||||
let mut paced: Paced<ClientMsg> = Paced::new(Instant::now());
|
||||
let outcome = if send(&mut writer, &hello) {
|
||||
loop {
|
||||
let ping_at = last_ping + cfg.limits.keepalive;
|
||||
let wake = earliest([throttle.due(), Some(ping_at)]).unwrap_or(ping_at);
|
||||
let wake = earliest([throttle.due(), Some(ping_at), paced.due()]).unwrap_or(ping_at);
|
||||
let mut out = None;
|
||||
let mut now_send = Vec::new();
|
||||
match rx.recv_timeout(wake.saturating_duration_since(Instant::now())) {
|
||||
Ok(Cmd::Send(m)) if welcomed => now_send.push(m),
|
||||
Ok(Cmd::Send(m)) if welcomed => paced.push(m),
|
||||
// Made for the previous connection (the sync client sends again what the server
|
||||
// didn't get once it has caught up with this connection's welcome).
|
||||
Ok(Cmd::Send(_)) => {}
|
||||
@@ -288,17 +304,22 @@ fn session(
|
||||
}
|
||||
Ok(Cmd::Lost(e, o)) if e == epoch => break o,
|
||||
Ok(Cmd::Leave) | Err(RecvTimeoutError::Disconnected) => {
|
||||
leave_paced(&mut writer, &mut paced);
|
||||
let _ = writer.close(Some(CloseFrame { code: CloseCode::Normal, reason: "".into() }));
|
||||
break Outcome::Left;
|
||||
}
|
||||
// From an earlier connection.
|
||||
Ok(Cmd::Welcomed(_) | Cmd::Lost(..)) => {}
|
||||
Ok(Cmd::Welcomed(_) | Cmd::Lost(..) | Cmd::Retry) => {}
|
||||
Err(RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
if !now_send.iter().all(|m| send(&mut writer, m)) {
|
||||
let now = Instant::now();
|
||||
let mut sent = true;
|
||||
while sent && let Some(m) = paced.take_due(now) {
|
||||
sent = send(&mut writer, &m);
|
||||
}
|
||||
if !sent {
|
||||
break Outcome::Retry("the connection dropped".into());
|
||||
}
|
||||
let now = Instant::now();
|
||||
let out = out.or_else(|| throttle.take_due(now));
|
||||
let ok = out.is_none_or(|p| send(&mut writer, &ClientMsg::Presence(p)));
|
||||
let ok = ok
|
||||
@@ -318,6 +339,28 @@ fn session(
|
||||
outcome
|
||||
}
|
||||
|
||||
/// Send what was waiting its turn before leaving, at its pace, for at most [`LEAVE_DRAIN`]: nothing
|
||||
/// waits for this thread (the app has moved on), and going faster would get the rest refused. What
|
||||
/// doesn't go is left to the sync client, which says so when you leave.
|
||||
fn leave_paced(writer: &mut WebSocket<Duplex>, paced: &mut Paced<ClientMsg>) {
|
||||
let give_up = Instant::now() + LEAVE_DRAIN;
|
||||
loop {
|
||||
let now = Instant::now();
|
||||
if now >= give_up {
|
||||
return;
|
||||
}
|
||||
if let Some(m) = paced.take_due(now) {
|
||||
if !send(writer, &m) {
|
||||
return;
|
||||
}
|
||||
} else if let Some(at) = paced.due().filter(|at| *at < give_up) {
|
||||
std::thread::sleep(at.saturating_duration_since(now));
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one message; false when the connection is gone.
|
||||
fn send(ws: &mut WebSocket<Duplex>, msg: &ClientMsg) -> bool {
|
||||
message::encode(msg).is_ok_and(|b| ws.send(Message::Binary(Bytes::from(b))).is_ok())
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -737,6 +737,9 @@ impl<B: Backend> Hub<B> {
|
||||
continue;
|
||||
}
|
||||
let Some(&conn) = room.ids.get(&id) else { continue };
|
||||
if matches!(msg, ServerMsg::Presence { .. }) && self.conns.get(&conn).is_some_and(|c| !c.out.takes_presence()) {
|
||||
continue;
|
||||
}
|
||||
let Some(b) = bytes.get_or_insert_with(|| encode(&msg)).clone() else { continue };
|
||||
if !deliver(&self.conns, conn, &b, &mut held, &mut stuck) {
|
||||
stuck.push(conn);
|
||||
|
||||
@@ -74,6 +74,8 @@ fn a_kicked_connection_with_a_full_queue_is_shut() {
|
||||
let out = Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
waiting: Arc::default(),
|
||||
max_presence: 32,
|
||||
block: Arc::new(AtomicUsize::new(0)),
|
||||
all: Arc::new(AtomicUsize::new(0)),
|
||||
max: 1 << 20,
|
||||
|
||||
@@ -51,6 +51,12 @@ fn health_check(stream: &mut TcpStream, until: Instant) -> bool {
|
||||
let mut req = Vec::new();
|
||||
let mut chunk = [0u8; 1024];
|
||||
while !req.windows(4).any(|w| w == b"\r\n\r\n") && req.len() < 16 << 10 {
|
||||
// The same deadline as the request line: a peer dripping header bytes would otherwise
|
||||
// hold this thread and its connection slot for as long as it liked.
|
||||
let left = until.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() || stream.set_read_timeout(Some(left)).is_err() {
|
||||
break;
|
||||
}
|
||||
match stream.read(&mut chunk) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => req.extend_from_slice(chunk.get(..n).unwrap_or_default()),
|
||||
@@ -235,6 +241,8 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender<HubIn>, limits: NetLimit
|
||||
let out = Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
waiting: Arc::default(),
|
||||
max_presence: limits.queue.max(4) / 2,
|
||||
block: block.queued.clone(),
|
||||
all: shared.queued.clone(),
|
||||
max: limits.max_queued_bytes,
|
||||
@@ -407,3 +415,31 @@ fn linger(ws: &mut WebSocket<Duplex>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod health_check_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_health_check_dripping_headers_ends_at_the_deadline() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.write_all(b"GET /healthz HTTP/1.1\r\n").unwrap();
|
||||
// One header byte every 20 ms, far past the deadline, until the server hangs up.
|
||||
for _ in 0..200 {
|
||||
if c.write_all(b"X").is_err() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
});
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let start = Instant::now();
|
||||
assert!(health_check(&mut stream, start + Duration::from_millis(300)));
|
||||
assert!(start.elapsed() < Duration::from_secs(2), "{:?}", start.elapsed());
|
||||
drop(stream);
|
||||
client.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ pub(crate) struct Charges {
|
||||
pub(super) struct Charge {
|
||||
n: usize,
|
||||
mine: Arc<AtomicUsize>,
|
||||
waiting: Arc<AtomicUsize>,
|
||||
_all: Arc<Held>,
|
||||
_block: Option<Arc<Held>>,
|
||||
}
|
||||
@@ -55,6 +56,7 @@ pub(super) struct Charge {
|
||||
impl Drop for Charge {
|
||||
fn drop(&mut self) {
|
||||
self.mine.fetch_sub(self.n, Ordering::Relaxed);
|
||||
self.waiting.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +78,10 @@ pub(crate) enum Push {
|
||||
pub(crate) struct Line {
|
||||
pub(crate) tx: SyncSender<Out>,
|
||||
pub(crate) queued: Arc<AtomicUsize>,
|
||||
/// Messages waiting in the queue.
|
||||
pub(crate) waiting: Arc<AtomicUsize>,
|
||||
/// With this many waiting, presence is left out (see [`Line::takes_presence`]).
|
||||
pub(crate) max_presence: usize,
|
||||
pub(crate) block: Arc<AtomicUsize>,
|
||||
pub(crate) all: Arc<AtomicUsize>,
|
||||
pub(crate) max: usize,
|
||||
@@ -84,6 +90,15 @@ pub(crate) struct Line {
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// Whether presence goes in the queue now. A client still taking in a long message (the welcome
|
||||
/// of a large file on a slow link) would otherwise have its queue filled by everyone's cursors
|
||||
/// and be dropped, again on every try. Presence is latest-wins, so what's left out is made up
|
||||
/// for by the next update, and this keeps room for what can't be: other people's edits. A client
|
||||
/// that reads nothing at all still ends at the write timeout.
|
||||
pub(crate) fn takes_presence(&self) -> bool {
|
||||
self.waiting.load(Ordering::Relaxed) < self.max_presence
|
||||
}
|
||||
|
||||
/// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file
|
||||
/// gets out). `charges` is what the message counts against, shared by its recipients;
|
||||
/// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer
|
||||
@@ -118,7 +133,8 @@ impl Line {
|
||||
}
|
||||
};
|
||||
self.queued.fetch_add(n, Ordering::Relaxed);
|
||||
let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block };
|
||||
self.waiting.fetch_add(1, Ordering::Relaxed);
|
||||
let charge = Charge { n, mine: self.queued.clone(), waiting: self.waiting.clone(), _all: all, _block: block };
|
||||
self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e {
|
||||
TrySendError::Full(_) => Push::Full,
|
||||
TrySendError::Disconnected(_) => Push::Gone,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The hub's send queues (no writers: nothing is ever sent, so what is queued stays queued): a
|
||||
//! broadcast counts once against the server's budget however many copies are queued, a block of
|
||||
//! addresses can't take more than its share, and when the server's budget is used up the
|
||||
//! connection holding the most is shed, not the next client that needs a little.
|
||||
//! connection holding the most is shed, not the next client that needs a little. Presence stays out
|
||||
//! of a busy queue.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, TcpListener, TcpStream};
|
||||
@@ -16,7 +17,20 @@ use super::{BlockUse, Blocks, Charges, Conn, Kind, Line, Out, Push, deliver};
|
||||
/// A line with its own queue counter, the given block counter and the server's.
|
||||
fn line(block: &Arc<AtomicUsize>, all: &Arc<AtomicUsize>, max_block: usize, max_all: usize) -> (Line, Receiver<Out>) {
|
||||
let (tx, rx) = sync_channel(64);
|
||||
(Line { tx, queued: Arc::default(), block: block.clone(), all: all.clone(), max: 1 << 20, max_block, max_all }, rx)
|
||||
(
|
||||
Line {
|
||||
tx,
|
||||
queued: Arc::default(),
|
||||
waiting: Arc::default(),
|
||||
max_presence: 32,
|
||||
block: block.clone(),
|
||||
all: all.clone(),
|
||||
max: 1 << 20,
|
||||
max_block,
|
||||
max_all,
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
fn socket() -> TcpStream {
|
||||
@@ -120,3 +134,25 @@ fn blocks_nobody_uses_are_forgotten() {
|
||||
let _c = blocks.get("203.0.113.3".parse().unwrap());
|
||||
assert_eq!(blocks.len(), 2, "203.0.113.1 went");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_busy_queue_leaves_presence_out_and_keeps_room_for_edits() {
|
||||
let (l, rx) = line(&Arc::default(), &Arc::default(), usize::MAX, usize::MAX);
|
||||
let msg = Bytes::from_static(b"x");
|
||||
for _ in 0..32 {
|
||||
assert!(l.takes_presence());
|
||||
l.push(&msg, &mut Charges::default(), false).unwrap();
|
||||
}
|
||||
assert!(!l.takes_presence(), "half full");
|
||||
// Edits still fit in the half kept for them.
|
||||
for _ in 0..32 {
|
||||
l.push(&msg, &mut Charges::default(), false).unwrap();
|
||||
}
|
||||
assert_eq!(l.push(&msg, &mut Charges::default(), false), Err(Push::Full), "a client that takes in nothing still ends");
|
||||
// It catches up: presence goes in again.
|
||||
for _ in 0..33 {
|
||||
drop(rx.recv().unwrap());
|
||||
}
|
||||
assert!(l.takes_presence());
|
||||
assert_eq!(l.waiting.load(Ordering::Relaxed), 31);
|
||||
}
|
||||
|
||||
@@ -39,8 +39,10 @@ pub struct GuestConfig {
|
||||
/// The pause before the first reconnect; it doubles each time up to `max_backoff`.
|
||||
pub backoff: Duration,
|
||||
pub max_backoff: Duration,
|
||||
/// Reconnect attempts before giving up.
|
||||
/// Reconnect attempts in a row before the longer pause (`round_pause`), after which it tries
|
||||
/// as many again: a connection that was live is never given up on by itself.
|
||||
pub attempts: u32,
|
||||
pub round_pause: Duration,
|
||||
/// Join as the client you were (an app that starts over after a crash): the first hello
|
||||
/// carries it, and a server that still knows the id gives it back.
|
||||
pub resume: Option<Resume>,
|
||||
@@ -57,6 +59,7 @@ impl GuestConfig {
|
||||
backoff: Duration::from_millis(500),
|
||||
max_backoff: Duration::from_secs(10),
|
||||
attempts: 12,
|
||||
round_pause: Duration::from_secs(60),
|
||||
resume: None,
|
||||
}
|
||||
}
|
||||
@@ -65,6 +68,14 @@ impl GuestConfig {
|
||||
pub(crate) fn backoff(&self, attempt: u32) -> Duration {
|
||||
self.backoff.saturating_mul(1u32 << attempt.saturating_sub(1).min(16)).min(self.max_backoff)
|
||||
}
|
||||
|
||||
/// The pause before reconnect attempt `attempt` (from 1): growing within a round of
|
||||
/// [`attempts`](Self::attempts), and [`round_pause`](Self::round_pause) between rounds.
|
||||
pub(crate) fn pause(&self, attempt: u32) -> Duration {
|
||||
let round = self.attempts.max(1);
|
||||
let into_round = attempt.saturating_sub(1) % round;
|
||||
if into_round == 0 && attempt > round { self.round_pause } else { self.backoff(into_round + 1) }
|
||||
}
|
||||
}
|
||||
|
||||
/// How a connection ended.
|
||||
@@ -137,31 +148,57 @@ 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),
|
||||
}
|
||||
|
||||
/// After `outcome`: a refusal or the end of the session is final, and so is a first join that
|
||||
/// can't reach the host; a live connection that dropped tries again with growing pauses until the
|
||||
/// attempts run out.
|
||||
/// What a server refuses for good, whenever you ask: the link, the file or your place in it is
|
||||
/// gone, or the two versions can't talk. Anything else a server refuses a session with (it is
|
||||
/// busy, a limit was hit, the file couldn't be read just now) may pass, and servers of any version
|
||||
/// say so in these words, so a reason not listed here means try again.
|
||||
const FINAL_REFUSALS: [&str; 12] = [
|
||||
"isn't valid",
|
||||
"isn't on the server",
|
||||
"was removed from the server",
|
||||
"were removed from this session",
|
||||
"no longer accepts connections",
|
||||
"doesn't accept connections",
|
||||
"wrong session token",
|
||||
"isn't supported",
|
||||
"couldn't be read",
|
||||
"message too large",
|
||||
"this version can't read",
|
||||
"sent a text message",
|
||||
];
|
||||
|
||||
/// Whether `reason`, from a server refusing a session that was live, is for good.
|
||||
pub(crate) fn final_refusal(reason: &str) -> bool {
|
||||
FINAL_REFUSALS.iter().any(|f| reason.contains(f))
|
||||
}
|
||||
|
||||
/// After `outcome`: the end of the session is final, and so are a refusal for good and a first
|
||||
/// 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,
|
||||
};
|
||||
match outcome {
|
||||
Outcome::Left => Next::Stop(None),
|
||||
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
|
||||
Outcome::Failed(why) => failed(why),
|
||||
Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })),
|
||||
Outcome::Ended(reason) => Next::Stop(Some(Box::new(Event::Ended { reason }))),
|
||||
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
|
||||
Outcome::Retry(why) => {
|
||||
*attempt += 1;
|
||||
if *attempt > cfg.attempts {
|
||||
return failed(format!("Lost the connection to the host ({why})"));
|
||||
}
|
||||
*attempt = attempt.saturating_add(1);
|
||||
log::info!("live session: connection lost ({why}); reconnecting, attempt {attempt}");
|
||||
inbox.push(Event::Status(Status::Reconnecting { attempt: *attempt }));
|
||||
Next::Reconnect(cfg.backoff(*attempt))
|
||||
Next::Reconnect(cfg.pause(*attempt))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -228,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)
|
||||
@@ -266,6 +308,12 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
}
|
||||
|
||||
fn retry(&mut self, session: SessionId) {
|
||||
if let Some(Active::Guest(g)) = self.sessions.get(session) {
|
||||
g.retry();
|
||||
}
|
||||
}
|
||||
|
||||
fn presence(&mut self, session: SessionId, p: &Presence) {
|
||||
match self.sessions.get(session) {
|
||||
Some(Active::Host(h)) => h.presence(p.clone()),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -206,7 +206,17 @@ fn send(ws: &mut Ws, msg: &ClientMsg) -> Result<(), String> {
|
||||
|
||||
/// The next answer `pick` takes, or why there is none.
|
||||
fn reply<T>(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option<T>) -> Result<T, String> {
|
||||
// One deadline for the whole answer: pings, pongs and a message trickling in each renew the
|
||||
// read timeout, so without it a server could keep a request (and the refresh permit the
|
||||
// caller holds) waiting for as long as it liked.
|
||||
// The request-reply bound, not the session idle one: a publish uploads a whole file and can
|
||||
// legitimately take minutes to be answered (see `connect`).
|
||||
let until = std::time::Instant::now() + limits.request_reply_timeout;
|
||||
ws.get_mut().read_until(Some(until));
|
||||
loop {
|
||||
if std::time::Instant::now() >= until {
|
||||
return Err("no answer from the server (it took too long)".into());
|
||||
}
|
||||
return match ws.read() {
|
||||
Ok(Message::Binary(b)) => match message::decode::<ServerMsg>(&b, limits.max_server_message) {
|
||||
Ok(ServerMsg::Refused { reason }) => Err(format!("{REFUSED}{reason}")),
|
||||
@@ -251,6 +261,27 @@ mod tests {
|
||||
rx.recv_timeout(Duration::from_secs(3)).is_ok()
|
||||
}
|
||||
|
||||
/// A server that keeps pinging and never answers ends the wait at the request-reply bound.
|
||||
#[test]
|
||||
fn reply_gives_up_at_the_request_reply_deadline_against_a_pinging_server() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
let (s, _) = listener.accept().unwrap();
|
||||
if let Ok(mut ws) = tungstenite::accept(s) {
|
||||
while ws.send(Message::Ping(Bytes::new())).is_ok() {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
}
|
||||
}
|
||||
});
|
||||
let limits = NetLimits { request_reply_timeout: Duration::from_millis(400), ..NetLimits::default() };
|
||||
let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false };
|
||||
let mut ws = connect(&server, &limits, Duration::from_secs(2)).unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
assert!(reply::<()>(&mut ws, &limits, |_| None).is_err());
|
||||
assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
|
||||
}
|
||||
|
||||
/// A server that keeps pinging and never answers the close must not pin `close` forever.
|
||||
#[test]
|
||||
fn close_returns_against_a_server_that_keeps_pinging() {
|
||||
|
||||
@@ -124,8 +124,9 @@ fn following_a_viewport_until_you_move() {
|
||||
fn the_host_stopping_ends_the_session_for_guests() {
|
||||
let (mut host, mut guest, _, _) = host_and_join();
|
||||
host.execute("collab.stop", &json!({})).unwrap();
|
||||
pump(&mut host, &mut guest, "the end", |_, g| g.live().status == Status::Off);
|
||||
assert!(guest.live().peers.is_empty());
|
||||
pump(&mut host, &mut guest, "the end", |_, g| g.live().rejoinable());
|
||||
assert!(guest.live().peers.is_empty() && !guest.live().syncing());
|
||||
assert!(guest.live().disconnected(), "the tab says so");
|
||||
assert_eq!(guest.message.as_deref(), Some(crate::host::ENDED));
|
||||
}
|
||||
|
||||
|
||||
@@ -158,8 +158,8 @@ fn two_files_are_hosted_and_joined_independently() {
|
||||
assert!(host.live_of(hb).presence.get(&ada(&host, hb)).is_none_or(|p| p.cursor.is_none()), "no cursor in the file that isn't in front");
|
||||
// The host closes B: its session ends for everyone, A's goes on.
|
||||
host.execute("file.close", &json!({"index": 1, "force": true})).unwrap();
|
||||
pump(&mut [&mut host, &mut guest], "B's session ending", |s| s[0].live.iter().count() == 1 && s[1].live.iter().count() == 1);
|
||||
assert_eq!(guest.live_of(gb).status, Status::Off);
|
||||
pump(&mut [&mut host, &mut guest], "B's session ending", |s| s[0].live.iter().count() == 1 && s[1].live_of(gb).rejoinable());
|
||||
assert!(guest.live_of(gb).disconnected(), "the guest's tab says so");
|
||||
assert_eq!(guest.docs.len(), 3, "the guest keeps its copy of B");
|
||||
assert_eq!((host.live_of(ha).status.clone(), host.live_of(ha).peers.len()), (Status::Live, 1));
|
||||
assert_eq!(guest.live_of(ga).status, Status::Live);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! (replacing any older waiting value) until the interval has passed, so the last one is never
|
||||
//! lost. Used for presence on both ends (about 30 updates a second).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::time::Duration;
|
||||
|
||||
// `std::time::Instant` on native builds; the browser's clock in the web build.
|
||||
@@ -10,6 +11,54 @@ use web_time::Instant;
|
||||
/// How often presence goes out at most.
|
||||
pub const PRESENCE_INTERVAL: Duration = Duration::from_millis(33);
|
||||
|
||||
/// How many messages other than presence go out at once, and per second after that: with presence
|
||||
/// and pings that stays under what a server takes from a connection before it drops it.
|
||||
const PACE_BURST: f64 = 200.0;
|
||||
const PACE_RATE: f64 = 60.0;
|
||||
|
||||
/// Messages that must all go out, in order, at a pace (everything queued while offline leaves at
|
||||
/// once when the connection comes back). The pace is counted when they go, so messages held up by
|
||||
/// a stalled connection don't leave together afterwards.
|
||||
#[derive(Debug)]
|
||||
pub struct Paced<T> {
|
||||
tokens: f64,
|
||||
last: Instant,
|
||||
queue: VecDeque<T>,
|
||||
}
|
||||
|
||||
impl<T> Paced<T> {
|
||||
pub fn new(now: Instant) -> Self {
|
||||
Self { tokens: PACE_BURST, last: now, queue: VecDeque::new() }
|
||||
}
|
||||
|
||||
pub fn push(&mut self, v: T) {
|
||||
self.queue.push_back(v);
|
||||
}
|
||||
|
||||
/// The next message, if its turn has come at `now`.
|
||||
pub fn take_due(&mut self, now: Instant) -> Option<T> {
|
||||
if self.queue.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.tokens = (self.tokens + now.saturating_duration_since(self.last).as_secs_f64() * PACE_RATE).min(PACE_BURST);
|
||||
self.last = now;
|
||||
// Within rounding of a whole turn counts as one: the time `due` gave has come.
|
||||
if self.tokens < 1.0 - 1e-6 {
|
||||
return None;
|
||||
}
|
||||
self.tokens -= 1.0;
|
||||
self.queue.pop_front()
|
||||
}
|
||||
|
||||
/// When the next message's turn comes (`None`: there's none).
|
||||
pub fn due(&self) -> Option<Instant> {
|
||||
self.queue.front()?;
|
||||
// Rounded up to whole microseconds, so a timer set for this always finds the turn come.
|
||||
let wait = ((1.0 - self.tokens).max(0.0) / PACE_RATE * 1e6).ceil() as u64;
|
||||
Some(self.last + Duration::from_micros(wait))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Throttle<T> {
|
||||
interval: Duration,
|
||||
|
||||
@@ -242,6 +242,10 @@ impl LiveLink for NetLink {
|
||||
}
|
||||
}
|
||||
|
||||
fn retry(&mut self, session: SessionId) {
|
||||
self.with(session, |d, now| d.retry(now));
|
||||
}
|
||||
|
||||
fn presence(&mut self, session: SessionId, p: &Presence) {
|
||||
self.with(session, |d, now| d.presence(p.clone(), now));
|
||||
}
|
||||
|
||||
@@ -333,6 +333,17 @@ impl Change {
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The synced list this entry belongs to (`None` for entries kept in maps).
|
||||
pub(crate) fn family(&self) -> Option<Family> {
|
||||
match self {
|
||||
Entry::Style(_) => Some(Family::Styles),
|
||||
Entry::Comment(_) => Some(Family::Comments),
|
||||
Entry::Collection(_) => Some(Family::Collections),
|
||||
Entry::Variable(_) => Some(Family::Variables),
|
||||
Entry::DevSnapshot(_) | Entry::Image(_) | Entry::Ballot(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live
|
||||
/// session carry their creator's client id, ids from before and ids of other forms are in
|
||||
/// space 0. `None` for entries keyed by something else (dev snapshots by layer, images by
|
||||
|
||||
@@ -95,7 +95,8 @@ pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
|
||||
let mut changes = fields;
|
||||
let new: BTreeSet<NodeId> = added.iter().copied().collect();
|
||||
// Creations, parents before children.
|
||||
added.sort_by_cached_key(|id| (depth(to, *id), *id));
|
||||
let mut memo = BTreeMap::new();
|
||||
added.sort_by_cached_key(|id| (depth(to, *id, &mut memo), *id));
|
||||
for id in &added {
|
||||
if let Some(n) = to.node(*id) {
|
||||
changes.push(create_change(n, replica.key(*id))?);
|
||||
@@ -121,18 +122,30 @@ pub fn diff(from: &Replica, to: &Document) -> Result<Diff, Error> {
|
||||
Ok(Diff { changes, replica })
|
||||
}
|
||||
|
||||
/// Depth of `id` in `doc` (pages are 0).
|
||||
fn depth(doc: &Document, id: NodeId) -> usize {
|
||||
let mut d = 0;
|
||||
/// Depth of `id` in `doc` (pages are 0). `memo` holds the depths found so far, so a deep chain of
|
||||
/// new layers is walked once rather than once per layer.
|
||||
fn depth(doc: &Document, id: NodeId, memo: &mut BTreeMap<NodeId, usize>) -> usize {
|
||||
let mut chain = vec![id];
|
||||
let mut base = 0;
|
||||
let mut cur = doc.node(id).and_then(|n| n.parent);
|
||||
while let Some(p) = cur {
|
||||
d += 1;
|
||||
if d > 100_000 {
|
||||
if let Some(d) = memo.get(&p) {
|
||||
base = *d + 1;
|
||||
break;
|
||||
}
|
||||
// An acyclic chain is no longer than the document: this only stops a cycle.
|
||||
if chain.len() > doc.node_count() {
|
||||
break;
|
||||
}
|
||||
chain.push(p);
|
||||
cur = doc.node(p).and_then(|n| n.parent);
|
||||
}
|
||||
d
|
||||
// `chain` runs from `id` up; the last entry's depth is `base` when it ends at a root or a memo hit.
|
||||
let top = chain.len();
|
||||
for (i, c) in chain.iter().enumerate() {
|
||||
memo.insert(*c, base + (top - 1 - i));
|
||||
}
|
||||
memo.get(&id).copied().unwrap_or(0)
|
||||
}
|
||||
|
||||
/// New keys for the children `kids` (in their new order) whose `old` key no longer fits: the
|
||||
@@ -196,3 +209,21 @@ fn increasing_run(keys: &[Option<&str>]) -> Vec<bool> {
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod depth_tests {
|
||||
use super::*;
|
||||
use figcraft_doc::NodeKind;
|
||||
|
||||
#[test]
|
||||
fn a_chain_longer_than_a_fixed_bound_is_walked_whole() {
|
||||
let mut doc = Document::new("File");
|
||||
let mut parent = doc.first_page().unwrap();
|
||||
let mut last = parent;
|
||||
for i in 0..100_050 {
|
||||
last = doc.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 1.0, 1.0).unwrap();
|
||||
parent = last;
|
||||
}
|
||||
assert_eq!(depth(&doc, last, &mut BTreeMap::new()), 100_050);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,3 +310,14 @@ fn many_sibling_placements_keep_the_order_of_counting_them() {
|
||||
assert_eq!(r.document().node(p).unwrap().children, kids[&p], "children of {p}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_chain_of_new_nested_layers_is_created_parents_first() {
|
||||
let (a, _, frame, _) = setup();
|
||||
let mut b = a.document().clone();
|
||||
let mut parent = frame;
|
||||
for i in 0..400 {
|
||||
parent = b.create(parent, NodeKind::Frame, &format!("F{i}"), 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
}
|
||||
assert_eq!(round_trip(&a, &b).iter().filter(|c| matches!(c, Change::Create { .. })).count(), 400);
|
||||
}
|
||||
|
||||
+123
-29
@@ -54,18 +54,71 @@ struct List<T: Keyed> {
|
||||
entry: fn(T::Key) -> Entry,
|
||||
get: fn(&Document) -> &Vec<T>,
|
||||
get_mut: fn(&mut Document) -> &mut Vec<T>,
|
||||
/// The list's slot in an [`Index`].
|
||||
index: fn(&mut Index) -> &mut Option<KeyIndex<T::Key>>,
|
||||
}
|
||||
|
||||
const STYLES: List<Style> =
|
||||
List { whole: DocProp::Styles, order: DocProp::StyleOrder, entry: Entry::Style, get: |d| &d.styles, get_mut: |d| &mut d.styles };
|
||||
const COMMENTS: List<Comment> =
|
||||
List { whole: DocProp::Comments, order: DocProp::CommentOrder, entry: Entry::Comment, get: |d| &d.comments, get_mut: |d| &mut d.comments };
|
||||
/// Where the entries of each keyed list sit, kept while one batch is applied: a batch can put
|
||||
/// hundreds of thousands of new entries, and finding each one's place by a scan of the growing
|
||||
/// list would cost the square of that on the thread every session shares.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Index {
|
||||
styles: Option<KeyIndex<String>>,
|
||||
comments: Option<KeyIndex<u64>>,
|
||||
collections: Option<KeyIndex<String>>,
|
||||
variables: Option<KeyIndex<String>>,
|
||||
}
|
||||
|
||||
/// The position of the first entry with each key in one list, and the list's length when it was
|
||||
/// last brought up to date: a list whose length changed since is indexed again.
|
||||
struct KeyIndex<K> {
|
||||
at: BTreeMap<K, usize>,
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl<K: Ord> KeyIndex<K> {
|
||||
fn of<T: Keyed<Key = K>>(list: &[T]) -> Self {
|
||||
let mut at = BTreeMap::new();
|
||||
for (i, e) in list.iter().enumerate() {
|
||||
at.entry(e.key()).or_insert(i);
|
||||
}
|
||||
KeyIndex { at, len: list.len() }
|
||||
}
|
||||
|
||||
/// The index in `slot`, built again from `list` unless its length and its place for `key`
|
||||
/// still match the list, so a list changed some other way is never trusted.
|
||||
fn current<'a, T: Keyed<Key = K>>(slot: &'a mut Option<Self>, list: &[T], key: &K) -> &'a mut Self {
|
||||
let stale = |ix: &Self| ix.len != list.len() || ix.at.get(key).is_some_and(|&i| list.get(i).is_none_or(|e| e.key() != *key));
|
||||
if slot.as_ref().is_none_or(stale) {
|
||||
*slot = Some(KeyIndex::of(list));
|
||||
}
|
||||
slot.get_or_insert_with(|| KeyIndex::of(list))
|
||||
}
|
||||
}
|
||||
|
||||
const STYLES: List<Style> = List {
|
||||
whole: DocProp::Styles,
|
||||
order: DocProp::StyleOrder,
|
||||
entry: Entry::Style,
|
||||
get: |d| &d.styles,
|
||||
get_mut: |d| &mut d.styles,
|
||||
index: |i| &mut i.styles,
|
||||
};
|
||||
const COMMENTS: List<Comment> = List {
|
||||
whole: DocProp::Comments,
|
||||
order: DocProp::CommentOrder,
|
||||
entry: Entry::Comment,
|
||||
get: |d| &d.comments,
|
||||
get_mut: |d| &mut d.comments,
|
||||
index: |i| &mut i.comments,
|
||||
};
|
||||
const COLLECTIONS: List<Collection> = List {
|
||||
whole: DocProp::Collections,
|
||||
order: DocProp::CollectionOrder,
|
||||
entry: Entry::Collection,
|
||||
get: |d| &d.variables.collections,
|
||||
get_mut: |d| &mut d.variables.collections,
|
||||
index: |i| &mut i.collections,
|
||||
};
|
||||
const VARIABLES: List<Variable> = List {
|
||||
whole: DocProp::Variables,
|
||||
@@ -73,6 +126,7 @@ const VARIABLES: List<Variable> = List {
|
||||
entry: Entry::Variable,
|
||||
get: |d| &d.variables.variables,
|
||||
get_mut: |d| &mut d.variables.variables,
|
||||
index: |i| &mut i.variables,
|
||||
};
|
||||
|
||||
impl<T: Keyed> List<T> {
|
||||
@@ -111,25 +165,53 @@ impl<T: Keyed> List<T> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn put(&self, doc: &mut Document, key: T::Key, value: &Json) -> Result<(), Error> {
|
||||
fn put(&self, doc: &mut Document, key: T::Key, value: &Json, index: &mut Index) -> Result<(), Error> {
|
||||
let t: T = value.read("entry")?;
|
||||
if t.key() != key {
|
||||
return Err(Error::Invalid("an entry's id must match the entry it is put at".into()));
|
||||
}
|
||||
let list = (self.get_mut)(doc);
|
||||
match list.iter_mut().find(|e| e.key() == key) {
|
||||
let ix = KeyIndex::current((self.index)(index), list, &key);
|
||||
match ix.at.get(&key).copied().and_then(|i| list.get_mut(i)) {
|
||||
Some(e) => *e = t,
|
||||
None => list.push(t),
|
||||
None => {
|
||||
list.push(t);
|
||||
ix.at.insert(key, list.len() - 1);
|
||||
ix.len = list.len();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove(&self, doc: &mut Document, key: &T::Key) {
|
||||
(self.get_mut)(doc).retain(|e| e.key() != *key);
|
||||
fn remove(&self, doc: &mut Document, key: &T::Key, index: &mut Index) {
|
||||
let list = (self.get_mut)(doc);
|
||||
let slot = (self.index)(index);
|
||||
let ix = KeyIndex::current(slot, list, key);
|
||||
// An absent entry leaves the list as it is, without a scan of it.
|
||||
let Some(at) = ix.at.get(key).copied() else {
|
||||
return;
|
||||
};
|
||||
let before = list.len();
|
||||
list.retain(|e| e.key() != *key);
|
||||
// One entry gone at `at`: the index is kept current by shifting the entries after it down,
|
||||
// so the next removal in the batch need not index the list again. Duplicates of the key
|
||||
// (more than one entry gone) leave it to be rebuilt.
|
||||
if list.len() + 1 == before {
|
||||
ix.at.remove(key);
|
||||
for i in ix.at.values_mut() {
|
||||
if *i > at {
|
||||
*i -= 1;
|
||||
}
|
||||
}
|
||||
ix.len = list.len();
|
||||
} else {
|
||||
*slot = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries in `keys` order first, the rest after in their current order.
|
||||
fn reorder(&self, doc: &mut Document, value: &Json) -> Result<(), Error> {
|
||||
fn reorder(&self, doc: &mut Document, value: &Json, index: &mut Index) -> Result<(), Error> {
|
||||
*(self.index)(index) = None;
|
||||
let keys: Vec<T::Key> = value.read("order")?;
|
||||
let list = (self.get_mut)(doc);
|
||||
let mut slots: Vec<Option<T>> = std::mem::take(list).into_iter().map(Some).collect();
|
||||
@@ -150,7 +232,8 @@ impl<T: Keyed> List<T> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_whole(&self, doc: &mut Document, value: &Json) -> Result<(), Error> {
|
||||
fn set_whole(&self, doc: &mut Document, value: &Json, index: &mut Index) -> Result<(), Error> {
|
||||
*(self.index)(index) = None;
|
||||
*(self.get_mut)(doc) = value.read("list")?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -313,31 +396,38 @@ pub(crate) fn diff(a: &Document, b: &Document, out: &mut Vec<Change>) -> Result<
|
||||
diff_map(&a.votes, &b.votes, |x, y| x == y, |k| Entry::Ballot(k.clone()), Json::of, out)
|
||||
}
|
||||
|
||||
/// Apply a document-level change. Returns whether it can change which pages are listed (the
|
||||
/// library store page is never listed).
|
||||
/// [`apply_indexed`] for one change on its own.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error> {
|
||||
apply_indexed(doc, change, &mut Index::default())
|
||||
}
|
||||
|
||||
/// Apply a document-level change, with the keyed lists' positions kept in `index` between the
|
||||
/// changes of one batch. Returns whether it can change which pages are listed (the library store
|
||||
/// page is never listed).
|
||||
pub(crate) fn apply_indexed(doc: &mut Document, change: &Change, index: &mut Index) -> Result<bool, Error> {
|
||||
match change {
|
||||
Change::SetDoc { prop, value } => {
|
||||
if set_scalar(doc, *prop, value)? {
|
||||
return Ok(*prop == DocProp::Libraries);
|
||||
}
|
||||
match prop {
|
||||
DocProp::Styles => STYLES.set_whole(doc, value),
|
||||
DocProp::StyleOrder => STYLES.reorder(doc, value),
|
||||
DocProp::Comments => COMMENTS.set_whole(doc, value),
|
||||
DocProp::CommentOrder => COMMENTS.reorder(doc, value),
|
||||
DocProp::Collections => COLLECTIONS.set_whole(doc, value),
|
||||
DocProp::CollectionOrder => COLLECTIONS.reorder(doc, value),
|
||||
DocProp::Variables => VARIABLES.set_whole(doc, value),
|
||||
DocProp::VariableOrder => VARIABLES.reorder(doc, value),
|
||||
DocProp::Styles => STYLES.set_whole(doc, value, index),
|
||||
DocProp::StyleOrder => STYLES.reorder(doc, value, index),
|
||||
DocProp::Comments => COMMENTS.set_whole(doc, value, index),
|
||||
DocProp::CommentOrder => COMMENTS.reorder(doc, value, index),
|
||||
DocProp::Collections => COLLECTIONS.set_whole(doc, value, index),
|
||||
DocProp::CollectionOrder => COLLECTIONS.reorder(doc, value, index),
|
||||
DocProp::Variables => VARIABLES.set_whole(doc, value, index),
|
||||
DocProp::VariableOrder => VARIABLES.reorder(doc, value, index),
|
||||
_ => Err(Error::Invalid(format!("{prop:?} is not a list"))),
|
||||
}?;
|
||||
}
|
||||
Change::Put { entry, value } => match entry {
|
||||
Entry::Style(k) => STYLES.put(doc, k.clone(), value)?,
|
||||
Entry::Comment(k) => COMMENTS.put(doc, *k, value)?,
|
||||
Entry::Collection(k) => COLLECTIONS.put(doc, k.clone(), value)?,
|
||||
Entry::Variable(k) => VARIABLES.put(doc, k.clone(), value)?,
|
||||
Entry::Style(k) => STYLES.put(doc, k.clone(), value, index)?,
|
||||
Entry::Comment(k) => COMMENTS.put(doc, *k, value, index)?,
|
||||
Entry::Collection(k) => COLLECTIONS.put(doc, k.clone(), value, index)?,
|
||||
Entry::Variable(k) => VARIABLES.put(doc, k.clone(), value, index)?,
|
||||
Entry::DevSnapshot(k) => {
|
||||
let snap: DevSnapshot = value.read("dev snapshot")?;
|
||||
doc.dev_snapshots.insert(*k, Arc::new(snap));
|
||||
@@ -351,14 +441,18 @@ pub(crate) fn apply(doc: &mut Document, change: &Change) -> Result<bool, Error>
|
||||
}
|
||||
},
|
||||
Change::Remove { entry } => match entry {
|
||||
Entry::Style(k) => STYLES.remove(doc, k),
|
||||
Entry::Comment(k) => COMMENTS.remove(doc, k),
|
||||
Entry::Collection(k) => COLLECTIONS.remove(doc, k),
|
||||
Entry::Variable(k) => VARIABLES.remove(doc, k),
|
||||
Entry::Style(k) => STYLES.remove(doc, k, index),
|
||||
Entry::Comment(k) => COMMENTS.remove(doc, k, index),
|
||||
Entry::Collection(k) => COLLECTIONS.remove(doc, k, index),
|
||||
Entry::Variable(k) => VARIABLES.remove(doc, k, index),
|
||||
Entry::DevSnapshot(k) => {
|
||||
doc.dev_snapshots.remove(k);
|
||||
}
|
||||
Entry::Image(k) => {
|
||||
// Nothing to remove: no need to look through the document for what shows it.
|
||||
if !doc.images.contains_key(k) {
|
||||
return Ok(false);
|
||||
}
|
||||
// An image a layer or style shows stays: removing it would let other bytes go
|
||||
// under its key, swapping the picture that layer shows ([`image_for`]).
|
||||
if figcraft_doc::used_images(doc).contains(k) {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Keyed document lists (comments, styles, variables, collections) applied in large batches: a
|
||||
//! batch of new entries is placed through an index, not a scan of the growing list per entry, and
|
||||
//! puts, removes and reorders in one batch still leave the list a scan would.
|
||||
|
||||
use figcraft_doc::{Comment, Document, Message};
|
||||
|
||||
use crate::change::{Change, DocProp, Entry, Json};
|
||||
use crate::{Mode, Replica};
|
||||
|
||||
fn comment(id: u64, text: &str, doc: &Document) -> Comment {
|
||||
let page = doc.first_page().unwrap();
|
||||
Comment { id, page, x: 1.0, y: 2.0, anchor: None, first: Message::new("A", text, 0), resolved: false, replies: Vec::new(), read_by: None }
|
||||
}
|
||||
|
||||
fn put(c: &Comment) -> Change {
|
||||
Change::Put { entry: Entry::Comment(c.id), value: Json::of(c).unwrap() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_large_batch_of_new_comments_lands_whole_and_in_order() {
|
||||
let doc = Document::new("t");
|
||||
let changes: Vec<Change> = (1..=100_000).map(|i| put(&comment(i, "hi", &doc))).collect();
|
||||
let mut r = Replica::new(doc);
|
||||
// A scan per put took minutes here; indexed it takes well under a second, even unoptimised.
|
||||
r.apply(&changes, Mode::Strict).unwrap();
|
||||
assert_eq!(r.document().comments.len(), 100_000);
|
||||
assert!(r.document().comments.iter().zip(1..).all(|(c, i)| c.id == i));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puts_removes_and_reorders_in_one_batch_match_one_by_one() {
|
||||
let doc = Document::new("t");
|
||||
let c = |i, t| comment(i, t, &doc);
|
||||
let batch = vec![
|
||||
put(&c(1, "a")),
|
||||
put(&c(2, "b")),
|
||||
put(&c(3, "c")),
|
||||
// Replaced in place.
|
||||
put(&c(2, "B")),
|
||||
Change::Remove { entry: Entry::Comment(1) },
|
||||
// Absent: nothing changes.
|
||||
Change::Remove { entry: Entry::Comment(9) },
|
||||
put(&c(4, "d")),
|
||||
put(&c(6, "f")),
|
||||
// Two removals in a row: the second goes through the index the first kept current.
|
||||
Change::Remove { entry: Entry::Comment(2) },
|
||||
Change::Remove { entry: Entry::Comment(6) },
|
||||
put(&c(2, "B")),
|
||||
put(&c(3, "C")),
|
||||
Change::SetDoc { prop: DocProp::CommentOrder, value: Json::of(&[4u64, 3, 2]).unwrap() },
|
||||
put(&c(2, "BB")),
|
||||
put(&c(1, "again")),
|
||||
Change::SetDoc { prop: DocProp::Comments, value: Json::of(&vec![c(7, "x"), c(8, "y")]).unwrap() },
|
||||
put(&c(8, "Y")),
|
||||
put(&c(5, "e")),
|
||||
];
|
||||
let mut together = Replica::new(doc.clone());
|
||||
together.apply(&batch, Mode::Strict).unwrap();
|
||||
let mut one_by_one = Replica::new(doc.clone());
|
||||
for ch in &batch {
|
||||
one_by_one.apply(std::slice::from_ref(ch), Mode::Strict).unwrap();
|
||||
}
|
||||
assert_eq!(together.document().comments, one_by_one.document().comments);
|
||||
let got: Vec<(u64, &str)> = together.document().comments.iter().map(|c| (c.id, c.first.text.as_str())).collect();
|
||||
assert_eq!(got, vec![(7, "x"), (8, "Y"), (5, "e")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_an_absent_image_does_not_fail() {
|
||||
let mut doc = Document::new("t");
|
||||
let remove = Change::Remove { entry: Entry::Image("missing".into()) };
|
||||
assert!(!crate::docprops::apply(&mut doc, &remove).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removals_in_one_batch_keep_the_index_current() {
|
||||
let doc = Document::new("t");
|
||||
let c = |i, t| comment(i, t, &doc);
|
||||
let batch = vec![
|
||||
put(&c(1, "a")),
|
||||
put(&c(2, "b")),
|
||||
put(&c(3, "c")),
|
||||
put(&c(4, "d")),
|
||||
put(&c(5, "e")),
|
||||
Change::Remove { entry: Entry::Comment(2) },
|
||||
Change::Remove { entry: Entry::Comment(9) },
|
||||
Change::Remove { entry: Entry::Comment(4) },
|
||||
// Replaced where the shifted index says they now are.
|
||||
put(&c(5, "E")),
|
||||
put(&c(3, "C")),
|
||||
put(&c(1, "A")),
|
||||
Change::Remove { entry: Entry::Comment(1) },
|
||||
put(&c(2, "B")),
|
||||
put(&c(5, "EE")),
|
||||
];
|
||||
let mut together = Replica::new(doc.clone());
|
||||
together.apply(&batch, Mode::Strict).unwrap();
|
||||
let mut one_by_one = Replica::new(doc.clone());
|
||||
for ch in &batch {
|
||||
one_by_one.apply(std::slice::from_ref(ch), Mode::Strict).unwrap();
|
||||
}
|
||||
assert_eq!(together.document().comments, one_by_one.document().comments);
|
||||
let got: Vec<(u64, &str)> = together.document().comments.iter().map(|c| (c.id, c.first.text.as_str())).collect();
|
||||
assert_eq!(got, vec![(3, "C"), (5, "EE"), (2, "B")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keys_from_a_whole_list_match_one_by_one() {
|
||||
let doc = Document::new("t");
|
||||
let c = |i, t| comment(i, t, &doc);
|
||||
let batch = vec![
|
||||
Change::SetDoc { prop: DocProp::Comments, value: Json::of(&vec![c(1, "a"), c(2, "b"), c(2, "b2"), c(3, "c"), c(4, "d")]).unwrap() },
|
||||
// The first entry with the key is the one replaced, as a scan finds it.
|
||||
put(&c(2, "B")),
|
||||
put(&c(4, "D")),
|
||||
// Both entries with the key go: the index is built again for what follows.
|
||||
Change::Remove { entry: Entry::Comment(2) },
|
||||
put(&c(3, "C")),
|
||||
Change::Remove { entry: Entry::Comment(1) },
|
||||
put(&c(2, "again")),
|
||||
put(&c(4, "DD")),
|
||||
];
|
||||
let mut together = Replica::new(doc.clone());
|
||||
together.apply(&batch, Mode::Lenient).unwrap();
|
||||
let mut one_by_one = Replica::new(doc.clone());
|
||||
for ch in &batch {
|
||||
one_by_one.apply(std::slice::from_ref(ch), Mode::Lenient).unwrap();
|
||||
}
|
||||
assert_eq!(together.document().comments, one_by_one.document().comments);
|
||||
let got: Vec<(u64, &str)> = together.document().comments.iter().map(|c| (c.id, c.first.text.as_str())).collect();
|
||||
assert_eq!(got, vec![(3, "C"), (4, "DD"), (2, "again")]);
|
||||
}
|
||||
@@ -148,6 +148,8 @@ mod image_tests;
|
||||
#[cfg(test)]
|
||||
mod journal_tests;
|
||||
#[cfg(test)]
|
||||
mod keyed_list_tests;
|
||||
#[cfg(test)]
|
||||
mod merge_list_tests;
|
||||
#[cfg(test)]
|
||||
mod merge_tests;
|
||||
|
||||
+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))
|
||||
|
||||
@@ -192,8 +192,9 @@ impl Replica {
|
||||
self.doc.take_dirty();
|
||||
let mut out = Applied::default();
|
||||
let (mut relist, mut relist_all) = (BTreeSet::new(), false);
|
||||
let mut index = docprops::Index::default();
|
||||
for c in changes {
|
||||
match self.apply_one(c, &mut out) {
|
||||
match self.apply_one(c, &mut out, &mut index) {
|
||||
Ok(Relist::No) => {}
|
||||
Ok(Relist::Page(p)) => {
|
||||
relist.insert(p);
|
||||
@@ -214,7 +215,7 @@ impl Replica {
|
||||
}
|
||||
|
||||
/// Apply one change. Returns what of the page list needs rebuilding.
|
||||
fn apply_one(&mut self, c: &Change, out: &mut Applied) -> Result<Relist, Error> {
|
||||
fn apply_one(&mut self, c: &Change, out: &mut Applied, index: &mut docprops::Index) -> Result<Relist, Error> {
|
||||
match c {
|
||||
Change::Create { id, kind, parent, position, props } => {
|
||||
frac::validate(position).map_err(|e| Error::Invalid(e.to_string()))?;
|
||||
@@ -235,7 +236,9 @@ impl Replica {
|
||||
self.doc.insert_page(node).map_err(doc_err)?;
|
||||
}
|
||||
(false, Some(p)) => {
|
||||
let at = self.slot(&self.doc.get(*p).map_err(doc_err)?.children, *id, position);
|
||||
// A new id sits in no sibling list yet: no scan for it, just the binary search.
|
||||
let siblings = &self.doc.get(*p).map_err(doc_err)?.children;
|
||||
let at = siblings.partition_point(|c| (self.key(*c), *c) < (position.as_str(), *id));
|
||||
self.doc.insert(node, *p, Some(at)).map_err(doc_err)?;
|
||||
}
|
||||
_ => return Err(Error::Invalid("pages, and only pages, have no parent".into())),
|
||||
@@ -297,7 +300,7 @@ impl Replica {
|
||||
Ok(if page { Relist::Page(*id) } else { Relist::No })
|
||||
}
|
||||
Change::SetDoc { .. } | Change::Put { .. } | Change::Remove { .. } => {
|
||||
Ok(if docprops::apply(&mut self.doc, c)? { Relist::All } else { Relist::No })
|
||||
Ok(if docprops::apply_indexed(&mut self.doc, c, index)? { Relist::All } else { Relist::No })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::change::{Change, Entry, Prop, Target};
|
||||
use crate::change::{Change, Entry, Family, Prop, Target};
|
||||
use crate::message::{
|
||||
Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_comments, reads_signals,
|
||||
};
|
||||
@@ -413,10 +413,14 @@ impl Server {
|
||||
// client learned what happened to it from the catch-up.
|
||||
ClientMsg::Changes { batch_id, .. } if self.issued.get(&from).is_some_and(|p| batch_id <= p.last_batch) => Vec::new(),
|
||||
ClientMsg::Changes { base_seq, batch_id, changes } => {
|
||||
let mut denied = true;
|
||||
let result = match self.limited.get(&from) {
|
||||
Some(Access::View) => Err(Error::Invalid(VIEW_ONLY.into())),
|
||||
Some(Access::Comment) if !changes.iter().all(Change::is_comment) => Err(Error::Invalid(COMMENT_ONLY.into())),
|
||||
_ => self.accept(from, base_seq, &changes),
|
||||
_ => {
|
||||
denied = false;
|
||||
self.accept(from, base_seq, &changes)
|
||||
}
|
||||
};
|
||||
if let Some(p) = self.issued.get_mut(&from) {
|
||||
p.note(batch_id, result.is_err());
|
||||
@@ -430,7 +434,9 @@ impl Server {
|
||||
}
|
||||
Err(e) => {
|
||||
self.note(Journal::Refused { client: from, batch_id });
|
||||
let fixes = self.fixes(&changes).unwrap_or_default();
|
||||
// A refusal for lack of permission costs no repair: the client's confirmed copy
|
||||
// already is the server's state, so the fixes would only repeat it.
|
||||
let fixes = if denied { Vec::new() } else { self.fixes(&changes).unwrap_or_default() };
|
||||
vec![Outgoing::To(from, ServerMsg::Reject { batch_id, reason: reason(&e), fixes })]
|
||||
}
|
||||
}
|
||||
@@ -557,6 +563,7 @@ impl Server {
|
||||
fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result<Vec<(Entry, bool)>, Error> {
|
||||
let before = self.state.document();
|
||||
let mut touched: BTreeSet<Entry> = BTreeSet::new();
|
||||
let mut listed: BTreeMap<Family, (BTreeSet<Entry>, BTreeSet<Entry>)> = BTreeMap::new();
|
||||
for c in changes {
|
||||
match c {
|
||||
Change::Put { entry, .. } | Change::Remove { entry } if entry.id_space().is_some() => {
|
||||
@@ -565,8 +572,10 @@ impl Server {
|
||||
// A whole list replaced (the fallback for lists whose ids aren't unique).
|
||||
Change::SetDoc { prop, .. } => {
|
||||
if let Some(Ok(family)) = prop.list() {
|
||||
touched.extend(docprops::entries(before, family));
|
||||
touched.extend(docprops::entries(after, family));
|
||||
let (b, a) = (docprops::entries(before, family), docprops::entries(after, family));
|
||||
touched.extend(b.iter().chain(&a).cloned());
|
||||
// Looked up in sets below: a linear search per entry would be quadratic.
|
||||
listed.entry(family).or_insert_with(|| (b.into_iter().collect(), a.into_iter().collect()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -574,7 +583,10 @@ impl Server {
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for entry in touched {
|
||||
let (was, now) = (docprops::has(before, &entry), docprops::has(after, &entry));
|
||||
let (was, now) = match entry.family().and_then(|f| listed.get(&f)) {
|
||||
Some((b, a)) => (b.contains(&entry), a.contains(&entry)),
|
||||
None => (docprops::has(before, &entry), docprops::has(after, &entry)),
|
||||
};
|
||||
if was == now {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -182,6 +182,26 @@ fn deleted_nodes_can_come_back_but_ids_are_never_handed_out_twice() {
|
||||
assert_eq!(s.document(), gone.document());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_viewers_refused_deletion_gets_no_repair() {
|
||||
let (d, _, frame) = doc();
|
||||
let mut s = Server::new(d, "tok", Limits::default());
|
||||
let (id, out) = s.join_as(hello("tok"), crate::message::Access::View).unwrap();
|
||||
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!() };
|
||||
let mut v = Client::join(welcome).unwrap();
|
||||
let del = edit(&mut v, |d| d.remove(frame).unwrap());
|
||||
let (reason, fixes) = rejected(
|
||||
&mut s,
|
||||
id,
|
||||
match del {
|
||||
ClientMsg::Changes { changes, .. } => changes,
|
||||
_ => panic!(),
|
||||
},
|
||||
);
|
||||
assert_eq!(reason, crate::VIEW_ONLY);
|
||||
assert!(fixes.is_empty(), "{fixes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixes_roll_a_rejected_client_back_to_the_server_state() {
|
||||
let (d, _, frame) = doc();
|
||||
|
||||
@@ -73,7 +73,10 @@ fn css_hue_units_and_none() {
|
||||
assert_eq!(Color::parse_css(&format!("oklch(0.6 0.2 {h})")).unwrap().to_hex(), blue.to_hex(), "{h}");
|
||||
}
|
||||
assert_eq!(Color::parse_css("lch(50 none none)").unwrap().to_hex(), Color::parse_css("lch(50 0 0)").unwrap().to_hex());
|
||||
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 255]);
|
||||
// A missing alpha is zero like any other missing component (CSS Color 4 §4.4), so it is
|
||||
// transparent; only an *omitted* alpha defaults to opaque.
|
||||
assert_eq!(Color::parse_css("oklch(none 0 0 / none)").unwrap().to_rgba8(), [0, 0, 0, 0]);
|
||||
assert_eq!(Color::parse_css("oklch(none 0 0)").unwrap().to_rgba8(), [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+79
-42
@@ -158,14 +158,9 @@ impl Color {
|
||||
}
|
||||
if let Some(inner) = t.strip_prefix("rgba(").or_else(|| t.strip_prefix("rgb(")).and_then(|r| r.strip_suffix(')')) {
|
||||
let parts = css_args(inner)?;
|
||||
let num = |i: usize| -> Option<f32> {
|
||||
let p = parts.get(i)?;
|
||||
if let Some(pc) = p.strip_suffix('%') {
|
||||
pc.trim().parse::<f32>().ok().map(|v| v / 100.0)
|
||||
} else {
|
||||
p.parse::<f32>().ok().map(|v| v / 255.0)
|
||||
}
|
||||
};
|
||||
// A channel is a number of 255, a percentage or `none`; out-of-range values (a number
|
||||
// too big for f32 included) clamp in `Color::new`, as CSS Color 4 §5.1 asks.
|
||||
let num = |i: usize| css_fraction(parts.get(i)?, 255.0);
|
||||
let alpha = match parts.get(3) {
|
||||
Some(a) => parse_alpha(a)?,
|
||||
None => 1.0,
|
||||
@@ -177,10 +172,12 @@ impl Color {
|
||||
}
|
||||
if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) {
|
||||
let parts = css_args(inner)?;
|
||||
let h = parts.first()?.trim_end_matches("deg").parse::<f32>().ok()?;
|
||||
let s = parts.get(1)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
|
||||
let l = parts.get(2)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
|
||||
if !(h.is_finite() && s.is_finite() && l.is_finite()) {
|
||||
// The hue takes every CSS angle unit; saturation and lightness are percentages, numbers
|
||||
// read as percentages (CSS Color 4 §7) or `none`.
|
||||
let h = css_hue(parts.first()?)?;
|
||||
let s = css_fraction(parts.get(1)?, 100.0)?;
|
||||
let l = css_fraction(parts.get(2)?, 100.0)?;
|
||||
if !(s.is_finite() && l.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let alpha = match parts.get(3) {
|
||||
@@ -209,14 +206,23 @@ impl Color {
|
||||
|
||||
/// CSS `rgba(...)` / hex text for copy-as-CSS.
|
||||
pub fn to_css(self) -> String {
|
||||
let [r, g, b, _] = self.to_rgba8();
|
||||
if self.a >= 0.999 {
|
||||
let [r, g, b, a8] = self.to_rgba8();
|
||||
let a = clamp01(self.a);
|
||||
if a >= 0.999 {
|
||||
return self.to_css_hex();
|
||||
}
|
||||
// Two decimals when they keep the 8-bit alpha, three otherwise (near-transparent colours).
|
||||
let a8 = |a: f32| (a.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
let short = format!("{:.2}", self.a);
|
||||
let alpha = if short.parse::<f32>().is_ok_and(|v| a8(v) == a8(self.a)) { short } else { format!("{:.3}", self.a) };
|
||||
// The fewest decimals that read back as the same 8-bit alpha: two, else three, else three
|
||||
// of the 8-bit value itself (within half a step of it, so it always reads back).
|
||||
let keeps = |s: &str| s.parse::<f32>().is_ok_and(|v| (v * 255.0).round() as u8 == a8);
|
||||
let two = format!("{a:.2}");
|
||||
let three = format!("{a:.3}");
|
||||
let alpha = if keeps(&two) {
|
||||
two
|
||||
} else if keeps(&three) {
|
||||
three
|
||||
} else {
|
||||
format!("{:.3}", f32::from(a8) / 255.0)
|
||||
};
|
||||
format!("rgba({r}, {g}, {b}, {alpha})")
|
||||
}
|
||||
|
||||
@@ -268,19 +274,42 @@ fn css_polar(inner: &str, l_full: f32, c_full: f32) -> Option<([f32; 3], f32)> {
|
||||
let l = css_number(parts.first()?, l_full)?;
|
||||
let c = css_number(parts.get(1)?, c_full)?;
|
||||
let h = css_hue(parts.get(2)?)?;
|
||||
let alpha = match parts.get(3).map(String::as_str) {
|
||||
None | Some("none") => 1.0,
|
||||
let alpha = match parts.get(3) {
|
||||
None => 1.0,
|
||||
Some(a) => parse_alpha(a)?,
|
||||
};
|
||||
Some(([l, c, h], alpha))
|
||||
}
|
||||
|
||||
/// A CSS `<number>` as f32: a sign, digits, a decimal point and an exponent only. Rust's float
|
||||
/// parser also reads `inf`, `infinity` and `nan`, which are identifiers in CSS, not numbers. A
|
||||
/// value too big for f32 comes back as an infinity for the caller to clamp or refuse.
|
||||
fn css_f32(s: &str) -> Option<f32> {
|
||||
if !s.bytes().all(|b| b.is_ascii_digit() || matches!(b, b'+' | b'-' | b'.' | b'e' | b'E')) {
|
||||
return None;
|
||||
}
|
||||
s.parse::<f32>().ok()
|
||||
}
|
||||
|
||||
/// A CSS number, percentage or `none` as a fraction of one: a percentage is of 100, a bare number
|
||||
/// of `number_full` (255 for an rgb() channel, 100 for hsl() saturation and lightness, 1 for
|
||||
/// alpha) and `none` is 0 (CSS Color 4 §4.4).
|
||||
fn css_fraction(p: &str, number_full: f32) -> Option<f32> {
|
||||
if p == "none" {
|
||||
return Some(0.0);
|
||||
}
|
||||
match p.strip_suffix('%') {
|
||||
Some(pc) => Some(css_f32(pc)? / 100.0),
|
||||
None => Some(css_f32(p)? / number_full),
|
||||
}
|
||||
}
|
||||
|
||||
/// A CSS number, a percentage of `full` or `none` (0); finite only.
|
||||
fn css_number(p: &str, full: f32) -> Option<f32> {
|
||||
let v = match p.strip_suffix('%') {
|
||||
_ if p == "none" => 0.0,
|
||||
Some(pc) => pc.trim().parse::<f32>().ok()? / 100.0 * full,
|
||||
None => p.parse::<f32>().ok()?,
|
||||
Some(pc) => css_f32(pc)? / 100.0 * full,
|
||||
None => css_f32(p)?,
|
||||
};
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
@@ -294,12 +323,14 @@ fn css_hue(p: &str) -> Option<f32> {
|
||||
.into_iter()
|
||||
.find_map(|(unit, k)| p.strip_suffix(unit).map(|n| (n, k)))
|
||||
.unwrap_or((p, 1.0));
|
||||
let v = num.parse::<f32>().ok()? / per_degree;
|
||||
let v = css_f32(num)? / per_degree;
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
/// A CSS `<alpha-value>`: a number, a percentage or `none` (0: a missing alpha is transparent, CSS
|
||||
/// Color 4 §4.4); finite only. An *omitted* alpha is the caller's default of 1.
|
||||
fn parse_alpha(a: &str) -> Option<f32> {
|
||||
let v = if let Some(pc) = a.strip_suffix('%') { pc.parse::<f32>().ok().map(|v| v / 100.0) } else { a.parse::<f32>().ok() }?;
|
||||
let v = css_fraction(a, 1.0)?;
|
||||
v.is_finite().then_some(v)
|
||||
}
|
||||
|
||||
@@ -376,26 +407,32 @@ impl Gradient {
|
||||
s
|
||||
}
|
||||
|
||||
/// The colour at `t` (0..1) along the gradient.
|
||||
/// The colour at `t` (0..1) along the gradient. Sorts the stops on every call: to sample one
|
||||
/// gradient many times, sort once with [`Self::sorted_stops`] and use [`sample_sorted`].
|
||||
pub fn sample(&self, t: f32) -> Color {
|
||||
let stops = self.sorted_stops();
|
||||
let t = clamp01(t);
|
||||
let mut prev = match stops.first() {
|
||||
Some(s) => *s,
|
||||
None => return Color::BLACK,
|
||||
};
|
||||
if t <= prev.position {
|
||||
return prev.color;
|
||||
sample_sorted(&self.sorted_stops(), t)
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour at `t` (0..1) along stops already sorted by [`Gradient::sorted_stops`]: found by a
|
||||
/// binary search, so a renderer sampling a file's gradient per pixel doesn't scan or sort its
|
||||
/// stops each time.
|
||||
pub fn sample_sorted(stops: &[ColorStop], t: f32) -> Color {
|
||||
let t = clamp01(t);
|
||||
let Some(first) = stops.first() else { return Color::BLACK };
|
||||
if t <= first.position {
|
||||
return first.color;
|
||||
}
|
||||
// The first stop after the first one at or past `t`, and the stop before it.
|
||||
let rest = stops.get(1..).unwrap_or_default();
|
||||
let i = rest.partition_point(|s| s.position < t);
|
||||
match (rest.get(i), stops.get(i)) {
|
||||
(Some(s), Some(prev)) => {
|
||||
let span = s.position - prev.position;
|
||||
let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 };
|
||||
prev.color.lerp(s.color, f)
|
||||
}
|
||||
for s in stops.iter().skip(1) {
|
||||
if t <= s.position {
|
||||
let span = s.position - prev.position;
|
||||
let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 };
|
||||
return prev.color.lerp(s.color, f);
|
||||
}
|
||||
prev = *s;
|
||||
}
|
||||
prev.color
|
||||
_ => stops.last().map_or(Color::BLACK, |s| s.color),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,44 @@ fn gradient_sampling() {
|
||||
assert_eq!(g.sample(0.5).to_hex(), "808080");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampling_sorted_stops_matches_a_scan() {
|
||||
// The scan `sample` used to make: the first stop at or past `t`, mixed with the one before.
|
||||
fn scan(stops: &[ColorStop], t: f32) -> Color {
|
||||
let t = if t.is_nan() { 0.0 } else { t.clamp(0.0, 1.0) };
|
||||
let mut prev = stops[0];
|
||||
if t <= prev.position {
|
||||
return prev.color;
|
||||
}
|
||||
for s in &stops[1..] {
|
||||
if t <= s.position {
|
||||
let span = s.position - prev.position;
|
||||
let f = if span > 1e-6 { (t - prev.position) / span } else { 1.0 };
|
||||
return prev.color.lerp(s.color, f);
|
||||
}
|
||||
prev = *s;
|
||||
}
|
||||
prev.color
|
||||
}
|
||||
let grey = |v: f32| Color { r: v, g: v, b: v, a: 1.0 };
|
||||
let positions = [0.3, 0.0, 0.3, 1.0, 0.7, f32::NAN, 0.5, 0.5, 0.9];
|
||||
let g = Gradient {
|
||||
kind: GradientKind::Diamond,
|
||||
stops: positions.iter().enumerate().map(|(i, p)| ColorStop { position: *p, color: grey(i as f32 / 10.0) }).collect(),
|
||||
from: (0.5, 0.5),
|
||||
to: (1.0, 0.5),
|
||||
width: (0.5, 1.0),
|
||||
};
|
||||
let sorted = g.sorted_stops();
|
||||
for i in -10..=110 {
|
||||
let t = i as f32 / 100.0;
|
||||
assert_eq!(sample_sorted(&sorted, t), scan(&sorted, t), "t = {t}");
|
||||
assert_eq!(g.sample(t), scan(&sorted, t), "t = {t}");
|
||||
}
|
||||
assert_eq!(sample_sorted(&sorted, f32::NAN), scan(&sorted, f32::NAN));
|
||||
assert_eq!(sample_sorted(&[], 0.5), Color::BLACK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_serde_shape() {
|
||||
let p = Paint::solid(Color::from_hex("FF0000").unwrap());
|
||||
@@ -157,3 +195,102 @@ fn css_rejects_empty_arguments() {
|
||||
assert!(Color::parse_css("rgb(255 0 0 / 50%)").is_some());
|
||||
assert!(Color::parse_css("hsl(120deg 50% 50%)").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_rgb_rejects_non_numeric_channels() {
|
||||
// `nan`, `inf` and `infinity` are identifiers, not CSS numbers; the hsl(), lch() and oklch()
|
||||
// branches and the alpha already refuse them, and so must rgb() channels (which previously read
|
||||
// `rgb(nan, 0, 0)` as black and `rgb(inf, 0, 0)` as red).
|
||||
for s in ["rgb(nan, 0, 0)", "rgb(inf, 0, 0)", "rgb(0, 0, infinity)", "rgb(-inf, 0, 0)", "rgb(nan%, 0, 0)", "rgb(NaN 0 0)", "rgba(0, 0, 0, inf)"] {
|
||||
assert_eq!(Color::parse_css(s), None, "{s}");
|
||||
}
|
||||
// A genuine number that overflows f32 is still a number and clamps (CSS Color 4 §5.1).
|
||||
assert_eq!(Color::parse_css("rgb(1e40, -1e40, 0)").unwrap().to_rgba8(), [255, 0, 0, 255]);
|
||||
assert_eq!(Color::parse_css("rgb(+255, .5%, 1E2)").unwrap().to_rgba8(), [255, 1, 100, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_hsl_takes_every_hue_unit() {
|
||||
// CSS Color 4 §4.3: `<hue> = <number> | <angle>`, so turn, grad and rad are as valid as deg.
|
||||
let cyan = Color::parse_css("hsl(180, 100%, 50%)").unwrap();
|
||||
assert_eq!(cyan.to_hex(), "00FFFF");
|
||||
let units =
|
||||
["hsl(0.5turn, 100%, 50%)", "hsl(200grad 100% 50%)", "hsl(3.1415927rad 100% 50%)", "hsl(180deg 100% 50%)", "hsla(-0.5turn, 100%, 50%, 1)"];
|
||||
for s in units {
|
||||
assert_eq!(Color::parse_css(s).map(|c| c.to_hex()), Some(cyan.to_hex()), "{s}");
|
||||
}
|
||||
// One unit only: a repeated or stray unit is not a dimension.
|
||||
for s in ["hsl(120degdeg, 100%, 50%)", "hsl(120, 100%%, 50%)", "hsl(0.5turndeg 100% 50%)", "hsl(deg 100% 50%)"] {
|
||||
assert_eq!(Color::parse_css(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_none_is_zero_in_every_function() {
|
||||
// CSS Color 4 §4.4: every modern-syntax component may be `none`, and outside interpolation a
|
||||
// missing component "behaves as a zero value" -- including a missing alpha, which renders
|
||||
// fully transparent.
|
||||
assert_eq!(Color::parse_css("rgb(none 0 0)").unwrap().to_rgba8(), [0, 0, 0, 255]);
|
||||
assert_eq!(Color::parse_css("rgb(255 none none)").unwrap().to_rgba8(), [255, 0, 0, 255]);
|
||||
assert_eq!(Color::parse_css("rgb(255 0 0 / none)").unwrap().to_rgba8(), [255, 0, 0, 0]);
|
||||
assert_eq!(Color::parse_css("hsl(none 100% 50%)").unwrap().to_hex(), "FF0000");
|
||||
assert_eq!(Color::parse_css("hsl(120 none 50%)").unwrap().to_hex(), "808080");
|
||||
assert_eq!(Color::parse_css("hsl(120 100% none)").unwrap().to_hex(), "000000");
|
||||
assert_eq!(Color::parse_css("hsl(120 100% 50% / none)").unwrap().to_rgba8(), [0, 255, 0, 0]);
|
||||
assert_eq!(Color::parse_css("oklch(0.5 0.1 20 / none)").unwrap().a, 0.0);
|
||||
assert_eq!(Color::parse_css("lch(50 50 50 / none)").unwrap().a, 0.0);
|
||||
// An omitted alpha still defaults to opaque.
|
||||
assert_eq!(Color::parse_css("rgb(none none none)").unwrap().a, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_css_keeps_the_8_bit_alpha_for_any_alpha() {
|
||||
// Three decimals of the alpha can still round across an 8-bit step (0.0055 -> "0.006" reads
|
||||
// back as 2/255, not 1/255); the text must always read back to the same 8-bit alpha.
|
||||
for i in 0..=10_000u32 {
|
||||
let a = i as f32 / 10_000.0;
|
||||
let c = Color::new(0.1, 0.2, 0.3, a);
|
||||
let css = c.to_css();
|
||||
assert_eq!(Color::parse_css(&css).map(|b| b.to_rgba8()), Some(c.to_rgba8()), "alpha {a}: {css}");
|
||||
}
|
||||
let c = Color::new(0.1, 0.2, 0.3, 0.0055);
|
||||
assert_eq!(Color::parse_css(&c.to_css()).unwrap().to_rgba8()[3], 1, "{}", c.to_css());
|
||||
// Still the short forms where they are exact.
|
||||
assert_eq!(Color::new(1.0, 0.0, 0.0, 0.5).to_css(), "rgba(255, 0, 0, 0.50)");
|
||||
assert_eq!(Color::from_rgba8(255, 0, 0, 85).to_css(), "rgba(255, 0, 0, 0.333)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_css_sanitizes_an_unclamped_alpha() {
|
||||
// The RGB channels go through `to_rgba8` and are sanitized; the alpha must be too, or a colour
|
||||
// built field-by-field prints `rgba(0, 0, 0, NaN)` / `rgba(0, 0, 0, -0.50)`, which is not CSS.
|
||||
for a in [f32::NAN, -0.5, f32::NEG_INFINITY] {
|
||||
let c = Color { r: 0.0, g: 0.0, b: 0.0, a };
|
||||
let css = c.to_css();
|
||||
assert_eq!(Color::parse_css(&css).map(|b| b.to_rgba8()), Some([0, 0, 0, 0]), "alpha {a}: {css}");
|
||||
}
|
||||
assert_eq!(Color { r: 1.0, g: 0.0, b: 0.0, a: f32::INFINITY }.to_css(), "#FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_hue_wraps_a_whole_turn_and_calc_is_not_a_number() {
|
||||
// A hue is an angle (CSS Color 4 §4.3): a whole turn (360, 1turn, 400grad, 2 pi rad) is 0 and a
|
||||
// negative hue counts back from it, in hsl() and in the polar spaces alike. `calc()` is not
|
||||
// read, so `calc(NaN)` and `calc(infinity)` are refused like any other non-number rather than
|
||||
// read as 0 or clamped.
|
||||
let red = Color::parse_css("hsl(0 100% 50%)").unwrap().to_rgba8();
|
||||
for s in ["hsl(360 100% 50%)", "hsl(1turn 100% 50%)", "hsl(400grad 100% 50%)", "hsl(6.2831855rad 100% 50%)", "hsl(-1turn 100% 50%)"] {
|
||||
assert_eq!(Color::parse_css(s).map(|c| c.to_rgba8()), Some(red), "{s}");
|
||||
}
|
||||
let violet = Color::parse_css("hsl(270 100% 50%)").unwrap().to_rgba8();
|
||||
for s in ["hsl(-90deg 100% 50%)", "hsl(-0.25turn 100% 50%)", "hsl(-100grad 100% 50%)", "hsl(630 100% 50%)"] {
|
||||
assert_eq!(Color::parse_css(s).map(|c| c.to_rgba8()), Some(violet), "{s}");
|
||||
}
|
||||
assert_eq!(Color::parse_css("oklch(0.5 0.1 1turn)").map(|c| c.to_rgba8()), Color::parse_css("oklch(0.5 0.1 0)").map(|c| c.to_rgba8()));
|
||||
assert_eq!(Color::parse_css("lch(50 50 400grad)").map(|c| c.to_rgba8()), Color::parse_css("lch(50 50 0)").map(|c| c.to_rgba8()));
|
||||
assert_eq!(Color::parse_css("oklch(0.5 0.1 -90deg)").map(|c| c.to_rgba8()), Color::parse_css("oklch(0.5 0.1 270)").map(|c| c.to_rgba8()));
|
||||
for s in ["hsl(calc(NaN) 100% 50%)", "rgb(calc(infinity) 0 0)", "rgb(0 0 0 / calc(1))", "oklch(0.5 0.1 calc(20deg))", "hsl(calc(1turn) 100% 50%)"]
|
||||
{
|
||||
assert_eq!(Color::parse_css(s), None, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
@@ -66,6 +72,12 @@ pub fn attached_sides(from: RouteEnd, to: RouteEnd) -> (Option<Side>, Option<Sid
|
||||
(attach(from, to.centre()).1, attach(to, from.centre()).1)
|
||||
}
|
||||
|
||||
/// Where a connector's ends attach, as routed now: the points [`ConnectorPath::Runs`] is measured
|
||||
/// from the middle of.
|
||||
pub fn attach_points(from: RouteEnd, to: RouteEnd) -> (Point, Point) {
|
||||
(attach(from, to.centre()).0, attach(to, from.centre()).0)
|
||||
}
|
||||
|
||||
/// The route's path handles: one for each run of an elbow and one for a curve, with the portion
|
||||
/// of the path each moves and where it sits.
|
||||
pub fn path_handles(pts: &[Point], routing: Routing) -> Vec<(usize, Point)> {
|
||||
@@ -90,8 +102,9 @@ pub fn reshaped(from: RouteEnd, to: RouteEnd, routing: Routing, path: Option<&Co
|
||||
}
|
||||
}
|
||||
|
||||
/// A path of more runs than this can't be made by hand: [`reshaped`] refuses another.
|
||||
const MAX_RUNS: usize = 64;
|
||||
/// A path of more runs than this can't be made by hand: [`reshaped`] refuses another, and an
|
||||
/// importer leaves a saved one to the router.
|
||||
pub const MAX_RUNS: usize = 64;
|
||||
|
||||
/// The runs of the right-angled route `pts` with run `portion` moved to `p`.
|
||||
fn reshaped_runs(pts: &[Point], portion: usize, p: Point) -> Option<ConnectorPath> {
|
||||
@@ -169,8 +182,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));
|
||||
}
|
||||
@@ -79,7 +79,7 @@ impl Style {
|
||||
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
|
||||
match &mut self.kind {
|
||||
StyleKind::Paint { paints } => paints.iter_mut().for_each(|p| p.for_each_color_mut(f)),
|
||||
StyleKind::Text { style } => style.for_each_color_mut(f),
|
||||
StyleKind::Text { style, .. } => style.for_each_color_mut(f),
|
||||
StyleKind::Effect { effects } => effects.iter_mut().for_each(|e| e.for_each_color_mut(f)),
|
||||
StyleKind::Grid { grids } => grids.iter_mut().for_each(|g| f(&mut g.color)),
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -28,8 +28,9 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
|
||||
p
|
||||
}
|
||||
NodeKind::Vector { network } => {
|
||||
let f = network.fill_path();
|
||||
if f.elements().is_empty() { network.stroke_path() } else { f }
|
||||
let net = drawn_network(network, c.smoothing);
|
||||
let f = net.fill_path();
|
||||
if f.elements().is_empty() { net.stroke_path() } else { f }
|
||||
}
|
||||
NodeKind::Text(_) => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0),
|
||||
NodeKind::Page { .. } | NodeKind::Group | NodeKind::BooleanOp { .. } => return None,
|
||||
@@ -39,11 +40,21 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
|
||||
/// The open/closed path strokes follow (vector networks stroke every segment, filled or not).
|
||||
pub fn stroke_geometry(n: &Node) -> Option<BezPath> {
|
||||
match &n.kind {
|
||||
NodeKind::Vector { network } => Some(network.stroke_path()),
|
||||
NodeKind::Vector { network } => Some(drawn_network(network, n.corners.smoothing).stroke_path()),
|
||||
_ => shape_path(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// A vector's `network` as the layer draws it: rounded points drawn in with the layer's corner
|
||||
/// `smoothing` (see [`VectorNetwork::rounded_smoothed`](crate::VectorNetwork::rounded_smoothed)),
|
||||
/// or the network itself when no point is rounded.
|
||||
pub fn drawn_network(network: &crate::VectorNetwork, smoothing: f64) -> std::borrow::Cow<'_, crate::VectorNetwork> {
|
||||
match network.rounded_smoothed(smoothing) {
|
||||
Some(r) => std::borrow::Cow::Owned(r),
|
||||
None => std::borrow::Cow::Borrowed(network),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the shape's path is closed (fills apply).
|
||||
pub fn is_closed(n: &Node) -> bool {
|
||||
match &n.kind {
|
||||
@@ -75,8 +86,8 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
|
||||
n.local_rect().inflate(tol_l, tol_l).contains(lp)
|
||||
}
|
||||
NodeKind::Vector { network } if network.regions.iter().any(|r| r.fills.is_some() || r.paint_space.is_some()) => {
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
let drawn = drawn_network(network, n.corners.smoothing);
|
||||
let net = drawn.as_ref();
|
||||
if !outline {
|
||||
for (i, region) in net.regions.iter().enumerate().rev() {
|
||||
let paints = region.fills.as_deref().unwrap_or(&n.fills);
|
||||
@@ -96,10 +107,11 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o
|
||||
}
|
||||
|
||||
/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of
|
||||
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it).
|
||||
/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains) on the network as drawn: even-odd when any
|
||||
/// region asks for it).
|
||||
fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool {
|
||||
match &n.kind {
|
||||
NodeKind::Vector { network } => network.fill_contains(lp),
|
||||
NodeKind::Vector { network } => drawn_network(network, n.corners.smoothing).fill_contains(lp),
|
||||
_ => path.winding(lp) != 0,
|
||||
}
|
||||
}
|
||||
|
||||
+23
-4
@@ -14,6 +14,8 @@
|
||||
//! space 0. The session server accepts a new object only with an id in its creator's space or
|
||||
//! space 0, so two people never make the same id.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{CollectionId, Document, ModeId, NodeId, StyleId, VariableId};
|
||||
|
||||
/// What style ids start with.
|
||||
@@ -74,25 +76,42 @@ impl Document {
|
||||
|
||||
/// A new style id.
|
||||
pub fn new_style_id(&mut self) -> StyleId {
|
||||
let taken: Vec<u64> = self.styles.iter().filter_map(|s| id_number(&s.id, STYLE_ID_PREFIX)).collect();
|
||||
self.new_style_id_avoiding(&BTreeSet::new())
|
||||
}
|
||||
|
||||
/// A new style id that is none of `avoid` either: ids of styles about to come in with a file
|
||||
/// being imported.
|
||||
pub fn new_style_id_avoiding(&mut self, avoid: &BTreeSet<StyleId>) -> StyleId {
|
||||
let taken: Vec<u64> = self.styles.iter().map(|s| &s.id).chain(avoid).filter_map(|id| id_number(id, STYLE_ID_PREFIX)).collect();
|
||||
if let Some(n) = self.session_number(taken) {
|
||||
return format!("{STYLE_ID_PREFIX}{n}");
|
||||
}
|
||||
(self.styles.len() + 1..)
|
||||
.map(|k| format!("{STYLE_ID_PREFIX}{k}"))
|
||||
.find(|id| self.style(id).is_none())
|
||||
.find(|id| self.style(id).is_none() && !avoid.contains(id))
|
||||
.unwrap_or_else(|| format!("{STYLE_ID_PREFIX}0"))
|
||||
}
|
||||
|
||||
/// A new variable id.
|
||||
pub fn new_variable_id(&mut self) -> VariableId {
|
||||
let taken: Vec<u64> = self.variables.variables.iter().filter_map(|v| id_number(&v.id, VARIABLE_ID_PREFIX)).collect();
|
||||
self.new_variable_id_avoiding(&BTreeSet::new())
|
||||
}
|
||||
|
||||
/// A new variable id that is none of `avoid` either (see [`Self::new_style_id_avoiding`]).
|
||||
pub fn new_variable_id_avoiding(&mut self, avoid: &BTreeSet<VariableId>) -> VariableId {
|
||||
let taken = self.variables.variables.iter().map(|v| &v.id).chain(avoid).filter_map(|id| id_number(id, VARIABLE_ID_PREFIX)).collect();
|
||||
self.new_numbered_id(VARIABLE_ID_PREFIX, taken)
|
||||
}
|
||||
|
||||
/// A new variable collection id.
|
||||
pub fn new_collection_id(&mut self) -> CollectionId {
|
||||
let taken: Vec<u64> = self.variables.collections.iter().filter_map(|c| id_number(&c.id, COLLECTION_ID_PREFIX)).collect();
|
||||
self.new_collection_id_avoiding(&BTreeSet::new())
|
||||
}
|
||||
|
||||
/// A new variable collection id that is none of `avoid` either (see
|
||||
/// [`Self::new_style_id_avoiding`]).
|
||||
pub fn new_collection_id_avoiding(&mut self, avoid: &BTreeSet<CollectionId>) -> CollectionId {
|
||||
let taken = self.variables.collections.iter().map(|c| &c.id).chain(avoid).filter_map(|id| id_number(id, COLLECTION_ID_PREFIX)).collect();
|
||||
self.new_numbered_id(COLLECTION_ID_PREFIX, taken)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Ids of styles, variables, collections, modes and comments: unchanged outside a live session,
|
||||
//! from the client's own space during one.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::ids::{COLLECTION_ID_PREFIX, id_number, id_space, mode_number, space_of};
|
||||
use crate::{Collection, Comment, Document, Message, Mode, NodeId, NodeKind, Style, StyleKind};
|
||||
|
||||
@@ -93,6 +95,20 @@ fn ids_of_the_space_already_in_the_document_stay_taken() {
|
||||
assert_eq!(e.new_style_id(), format!("S:{}", C3 | 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_ids_can_avoid_ids_about_to_come_in() {
|
||||
let mut d = Document::new("a");
|
||||
d.styles.push(style("S:1"));
|
||||
let avoid: BTreeSet<String> = ["S:2", "S:3", "VariableID:4", "VariableCollectionId:2", "lib:VariableID:9"].map(String::from).into();
|
||||
assert_eq!(d.new_style_id_avoiding(&avoid), "S:4");
|
||||
assert_eq!(d.new_variable_id_avoiding(&avoid), "VariableID:5");
|
||||
assert_eq!(d.new_collection_id_avoiding(&avoid), "VariableCollectionId:3");
|
||||
// In a session the numbers come from the client's space, above those of it to avoid.
|
||||
let avoid: BTreeSet<String> = [format!("VariableID:{}", C3 | 7)].into();
|
||||
d.set_client_id(3);
|
||||
assert_eq!(d.new_variable_id_avoiding(&avoid), format!("VariableID:{}", C3 | 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_highest_space_counts_every_kind_of_id() {
|
||||
let mut d = Document::new("a");
|
||||
|
||||
+41
-11
@@ -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, MAX_RUNS, RouteEnd, attach_points, attached_sides, path_handles, reshaped, route_along, route_points};
|
||||
pub use colors::*;
|
||||
pub use comments::*;
|
||||
pub use facilitation::*;
|
||||
@@ -574,6 +574,19 @@ impl Document {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove every child of `id` with its subtree, in one pass over `id`'s child list (removing
|
||||
/// them one by one would search that list once per child).
|
||||
pub fn remove_children(&mut self, id: NodeId) -> DocResult<()> {
|
||||
let kids = std::mem::take(&mut self.get_mut(id)?.children);
|
||||
for c in kids {
|
||||
for d in self.subtree(c) {
|
||||
self.nodes.remove(&d);
|
||||
self.dirty.touch(d);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether sections may go in `parent`: a page or another section (never a frame or a group).
|
||||
pub fn takes_sections(&self, parent: NodeId) -> bool {
|
||||
self.node(parent).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
|
||||
@@ -585,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;
|
||||
}
|
||||
@@ -747,16 +767,20 @@ impl Document {
|
||||
/// children only within their mask). A group, or an unclipped frame past its box, is what its
|
||||
/// children show grown by its effects, which apply to all of it.
|
||||
pub fn world_render_bounds(&self, id: NodeId) -> Option<Rect> {
|
||||
self.render_bounds_at(id, 0)
|
||||
// A tree visits each node once; a child list deserialized with repeats (a graph) is cut off
|
||||
// after twice the document's node count rather than walked in full.
|
||||
self.render_bounds_at(id, 0, &std::cell::Cell::new(self.nodes.len().saturating_mul(2).saturating_add(8)))
|
||||
}
|
||||
|
||||
fn render_bounds_at(&self, id: NodeId, depth: usize) -> Option<Rect> {
|
||||
fn render_bounds_at(&self, id: NodeId, depth: usize, left: &std::cell::Cell<usize>) -> Option<Rect> {
|
||||
let n = self.node(id)?;
|
||||
let own = n.own_paint_bounds(self.world_transform(id));
|
||||
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH {
|
||||
let visit = left.get();
|
||||
left.set(visit.saturating_sub(1));
|
||||
if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH || visit == 0 {
|
||||
return Some(own);
|
||||
}
|
||||
Some(match (self.children_render_bounds(n, depth + 1), &n.kind) {
|
||||
Some(match (self.children_render_bounds(n, depth + 1, left), &n.kind) {
|
||||
(Some(kids), NodeKind::Group) => n.grow_by_effects(kids),
|
||||
// An unclipped frame's effects apply to its children too, past its box included.
|
||||
(Some(kids), _) => own.union(n.grow_by_effects(kids)),
|
||||
@@ -765,15 +789,15 @@ impl Document {
|
||||
}
|
||||
|
||||
/// What `n`'s visible children show, together: each mask's run within the mask's bounds.
|
||||
fn children_render_bounds(&self, n: &Node, depth: usize) -> Option<Rect> {
|
||||
fn children_render_bounds(&self, n: &Node, depth: usize, left: &std::cell::Cell<usize>) -> Option<Rect> {
|
||||
let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask);
|
||||
let mut out: Option<Rect> = None;
|
||||
for run in self.paint_runs(n) {
|
||||
let ids = n.children.get(run).unwrap_or(&[]);
|
||||
let mask = ids.first().copied().filter(|m| self.node(*m).is_some_and(|c| c.is_mask && c.visible));
|
||||
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth)).reduce(|a, b| a.union(b));
|
||||
let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth, left)).reduce(|a, b| a.union(b));
|
||||
let r = match mask {
|
||||
Some(m) => r.zip(self.render_bounds_at(m, depth)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
|
||||
Some(m) => r.zip(self.render_bounds_at(m, depth, left)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0),
|
||||
None => r,
|
||||
};
|
||||
out = match (out, r) {
|
||||
@@ -1085,6 +1109,9 @@ impl Document {
|
||||
// lookups: a wide parent or a long page list doesn't make validation quadratic.
|
||||
let edges: std::collections::BTreeSet<(NodeId, NodeId)> = self.nodes.iter().flat_map(|(p, n)| n.children.iter().map(|c| (*p, *c))).collect();
|
||||
let page_ids: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
|
||||
if page_ids.len() != self.pages.len() {
|
||||
note(&mut issues, || "a page is listed twice".to_string());
|
||||
}
|
||||
for (id, n) in self.nodes.iter() {
|
||||
if n.id != *id {
|
||||
note(&mut issues, || format!("node key {id} has id {}", n.id));
|
||||
@@ -1178,9 +1205,10 @@ impl Document {
|
||||
if store != self.libraries.store {
|
||||
Arc::make_mut(&mut self.libraries).store = store;
|
||||
}
|
||||
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store);
|
||||
// A page listed twice would be imported, exported and walked once per listing.
|
||||
let mut listed = std::collections::BTreeSet::new();
|
||||
self.pages.retain(|p| self.nodes.get(p).is_some_and(|n| n.is_page()) && Some(*p) != store && listed.insert(*p));
|
||||
// Unlisted pages join the list; a set keeps many pages from making this quadratic (#1078).
|
||||
let mut listed: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
|
||||
for (k, n) in self.nodes.iter() {
|
||||
if n.is_page() && Some(*k) != store && listed.insert(*k) {
|
||||
self.pages.push(*k);
|
||||
@@ -1206,7 +1234,7 @@ impl Document {
|
||||
.styles
|
||||
.iter()
|
||||
.filter_map(|s| match &s.kind {
|
||||
StyleKind::Text { style } => Some((s.id.clone(), style.clone())),
|
||||
StyleKind::Text { style, .. } => Some((s.id.clone(), style.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
@@ -1286,6 +1314,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;
|
||||
|
||||
+34
-9
@@ -416,9 +416,13 @@ impl NodeKind {
|
||||
self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. })
|
||||
}
|
||||
|
||||
/// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it).
|
||||
/// Kinds whose rounded corners are drawn with corner smoothing: a vector only when its network
|
||||
/// has a closed region (an open path's rounded points are drawn without it).
|
||||
pub fn has_corner_smoothing(&self) -> bool {
|
||||
self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. })
|
||||
match self {
|
||||
NodeKind::Vector { network } => !network.regions.is_empty(),
|
||||
_ => self.has_corner_radius(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text(&self) -> Option<&TextData> {
|
||||
@@ -942,10 +946,14 @@ impl Node {
|
||||
matches!(&self.kind, NodeKind::Vector { network } if network.vertices.iter().any(|v| v.cap.is_some_and(StrokeCap::is_arrow)));
|
||||
let caps =
|
||||
if self.stroke.cap_start.is_arrow() || self.stroke.cap_end.is_arrow() || point_arrows { w.max(self.stroke.weight) * 3.0 } else { w };
|
||||
// Right-angled or smooth outlines (boxes, ellipses) end their mitres on the grown box.
|
||||
let boxy = matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like();
|
||||
let miter = if boxy { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
|
||||
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * miter
|
||||
// Right-angled or smooth outlines (boxes, whole ellipses) end their mitres on the grown
|
||||
// box; an arc's corners don't.
|
||||
let miter = if self.outline_mitres_end_on_its_box() { 1.0 } else { self.stroke.miter_limit.clamp(1.0, 4.0) };
|
||||
// A dash's square end stands out along the path as far as the band does across it:
|
||||
// its corners reach √2 times as far from the path, past the box round a curve.
|
||||
let square_dashes = !self.stroke.dashes.is_empty() && self.stroke.band_caps().contains(&StrokeCap::Square);
|
||||
let corners = if square_dashes { miter.max(std::f64::consts::SQRT_2) } else { miter };
|
||||
base.max(if matches!(self.kind, NodeKind::Line | NodeKind::Vector { .. }) { caps } else { 0.0 }) * corners
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -960,14 +968,31 @@ impl Node {
|
||||
!(matches!(self.kind, NodeKind::Group) || (matches!(self.kind, NodeKind::Text(_)) && self.has_visible_stroke()))
|
||||
}
|
||||
|
||||
/// Whether a spread grows this node's outline by exactly the spread (no sharp corners).
|
||||
/// Whether this node's outline has no sharp corners (right-angled ones aside), so mitres along
|
||||
/// it end on its box grown by as much: a box or a whole ellipse. An arc (an ellipse that doesn't
|
||||
/// go all the way round) has corners where its ends meet its sides.
|
||||
fn outline_mitres_end_on_its_box(&self) -> bool {
|
||||
match self.kind {
|
||||
NodeKind::Rectangle | NodeKind::Section { .. } => true,
|
||||
NodeKind::Ellipse { sweep, .. } => figcraft_geom::full_sweep(sweep),
|
||||
_ => self.kind.is_frame_like(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a spread grows this node by exactly the spread: its outline mitres end on its box
|
||||
/// (see [`Node::outline_mitres_end_on_its_box`]), and so does its stroke. A dashed stroke is
|
||||
/// cut into dashes with corners wherever they fall round the outline, and a width profile or a
|
||||
/// brush can give the stroke corners of its own, which the spread mitres out.
|
||||
fn grows_by_exactly_its_spread(&self) -> bool {
|
||||
matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like()
|
||||
let s = &self.stroke;
|
||||
let cornered = self.has_visible_stroke() && (!s.dashes.is_empty() || s.profile().is_some() || s.brush.is_some());
|
||||
!cornered && self.outline_mitres_end_on_its_box()
|
||||
}
|
||||
|
||||
/// How much further than its spread a drop shadow can reach at the outline's sharp corners:
|
||||
/// spread moves the edges out in parallel and they meet in a mitre (see
|
||||
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and ellipses grow by exactly the spread.
|
||||
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and whole ellipses grow by exactly the spread,
|
||||
/// unless their stroke has corners (see [`Node::grows_by_exactly_its_spread`]).
|
||||
fn spread_miter_reach(&self) -> f64 {
|
||||
if self.grows_by_exactly_its_spread() {
|
||||
return 0.0;
|
||||
|
||||
+126
-10
@@ -1,6 +1,6 @@
|
||||
//! Node property types: strokes, effects, corners, constraints, auto layout, grids, exports.
|
||||
|
||||
use figcraft_color::{BlendMode, Color};
|
||||
use figcraft_color::{BlendMode, Color, ColorProfile};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Where a stroke sits relative to the shape edge.
|
||||
@@ -146,10 +146,16 @@ impl Brush {
|
||||
}
|
||||
}
|
||||
|
||||
/// How much wider than the stroke weight the brush can paint (larger copies, strays).
|
||||
pub fn reach_factor(&self) -> f64 {
|
||||
/// The farthest a built-in brush shape reaches from the path, in stroke weights: the ink
|
||||
/// brush's ragged edges stand a little past half the weight.
|
||||
pub const SHAPE_REACH: f64 = 0.54;
|
||||
|
||||
/// How much wider than the stroke weight the brush can paint where a width profile scales it
|
||||
/// at most `scale`: its shapes' edges and larger copies, which the profile scales, and strays,
|
||||
/// which it doesn't.
|
||||
pub fn reach_factor(&self, scale: f64) -> f64 {
|
||||
let b = self.sanitized();
|
||||
1.0 + b.size_jitter + 2.0 * b.scatter
|
||||
2.0 * Self::SHAPE_REACH * (1.0 + b.size_jitter) * scale + 2.0 * b.scatter
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,7 +194,8 @@ impl StrokeProps {
|
||||
/// The widest the stroke gets anywhere along the path (the weight, scaled by a width profile
|
||||
/// and widened by a brush's reach).
|
||||
pub fn max_weight(&self) -> f64 {
|
||||
self.profile().map_or(self.weight, |p| self.weight * p.max_scale()) * self.brush.as_ref().map_or(1.0, Brush::reach_factor)
|
||||
let scale = self.profile().map_or(1.0, WidthProfile::max_scale);
|
||||
self.weight * self.brush.as_ref().map_or(scale, |b| b.reach_factor(scale))
|
||||
}
|
||||
|
||||
/// The miter limit as the angle (degrees) below which joins bevel: limit = 1 / sin(angle / 2),
|
||||
@@ -660,9 +667,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 +682,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 +729,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 +1535,54 @@ 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,
|
||||
/// PNG, JPG and WebP: the color profile the file is written in. SVG, PDF and XML are always
|
||||
/// sRGB, whatever this says.
|
||||
#[serde(skip_serializing_if = "ExportColorProfile::is_default")]
|
||||
pub color_profile: ExportColorProfile,
|
||||
/// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]).
|
||||
#[serde(skip_serializing_if = "is_default_quality")]
|
||||
pub quality: u8,
|
||||
}
|
||||
|
||||
/// The color profile an export setting writes PNG, JPG and WebP files in.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ExportColorProfile {
|
||||
/// The document's own profile at the time of the export.
|
||||
#[default]
|
||||
SameAsFile,
|
||||
Srgb,
|
||||
DisplayP3,
|
||||
}
|
||||
|
||||
impl ExportColorProfile {
|
||||
pub const ALL: [ExportColorProfile; 3] = [ExportColorProfile::SameAsFile, ExportColorProfile::Srgb, ExportColorProfile::DisplayP3];
|
||||
|
||||
fn is_default(&self) -> bool {
|
||||
*self == ExportColorProfile::SameAsFile
|
||||
}
|
||||
|
||||
/// The name commands and files use ("sameAsFile", "srgb", "displayP3").
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
ExportColorProfile::SameAsFile => "sameAsFile",
|
||||
ExportColorProfile::Srgb => "srgb",
|
||||
ExportColorProfile::DisplayP3 => "displayP3",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_key(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|c| c.key() == s)
|
||||
}
|
||||
|
||||
/// The profile the file is written in when the document is in `file`.
|
||||
pub fn resolve(self, file: ColorProfile) -> ColorProfile {
|
||||
match self {
|
||||
ExportColorProfile::SameAsFile => file,
|
||||
ExportColorProfile::Srgb => ColorProfile::Srgb,
|
||||
ExportColorProfile::DisplayP3 => ColorProfile::DisplayP3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How an Android Vector Drawable export handles what the format can't draw exactly (layer
|
||||
@@ -1531,6 +1633,8 @@ impl Default for ExportSetting {
|
||||
svg_id_attribute: false,
|
||||
svg_simplify_stroke: true,
|
||||
vd_compat: VdCompat::BestEffort,
|
||||
color_profile: ExportColorProfile::SameAsFile,
|
||||
quality: ExportSetting::DEFAULT_QUALITY,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1543,7 +1647,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 {
|
||||
|
||||
@@ -12,6 +12,8 @@ use crate::{CollectionId, ModeId, VariableValue, Variables};
|
||||
pub const MAX_EXPR_DEPTH: usize = 32;
|
||||
/// Longest string an expression builds (characters).
|
||||
const MAX_STRING: usize = 10_000;
|
||||
/// The most string bytes one operation's operands may hold together (generous: results cap at `MAX_STRING`).
|
||||
const MAX_OPERAND_BYTES: usize = 1 << 20;
|
||||
|
||||
/// An expression over variables.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@@ -115,7 +117,19 @@ impl Expr {
|
||||
}
|
||||
Expr::Op { op, args } => (*op, args),
|
||||
};
|
||||
let vals: Vec<VariableValue> = args.iter().map(|a| a.eval_at(vars, modes, depth + 1)).collect::<Option<_>>()?;
|
||||
// Retained operands are bounded by bytes, so repeated references to one long string can't multiply it.
|
||||
let mut text_bytes = 0usize;
|
||||
let mut vals = Vec::with_capacity(args.len());
|
||||
for a in args {
|
||||
let v = a.eval_at(vars, modes, depth + 1)?;
|
||||
if let VariableValue::String(s) = &v {
|
||||
text_bytes = text_bytes.saturating_add(s.len());
|
||||
if text_bytes > MAX_OPERAND_BYTES {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
vals.push(v);
|
||||
}
|
||||
apply(op, &vals)
|
||||
}
|
||||
|
||||
@@ -259,10 +273,18 @@ fn apply(op: ExprOp, vals: &[VariableValue]) -> Option<VariableValue> {
|
||||
match op {
|
||||
Add if vals.iter().any(|v| matches!(v, VariableValue::String(_))) => {
|
||||
let mut s = String::new();
|
||||
let mut chars = 0;
|
||||
for v in vals {
|
||||
s.push_str(&as_text(v)?);
|
||||
// Every operand is checked (a colour makes the whole expression None); only the
|
||||
// characters it adds are counted, and appending stops at the cap.
|
||||
let t = as_text(v)?;
|
||||
if chars < MAX_STRING {
|
||||
let before = s.len();
|
||||
s.extend(t.chars().take(MAX_STRING - chars));
|
||||
chars += s[before..].chars().count();
|
||||
}
|
||||
}
|
||||
Some(VariableValue::String(s.chars().take(MAX_STRING).collect()))
|
||||
Some(VariableValue::String(s))
|
||||
}
|
||||
Add => fold(|a, b| Some(a + b)),
|
||||
Subtract => fold(|a, b| Some(a - b)),
|
||||
|
||||
@@ -124,6 +124,13 @@ fn deep_and_hostile_expressions_are_bounded() {
|
||||
let joined = Expr::op(ExprOp::Add, vec![s(&long), s(&long)]);
|
||||
let Some(VariableValue::String(t)) = eval(&joined) else { panic!() };
|
||||
assert_eq!(t.chars().count(), 10_000, "capped");
|
||||
// Past the cap a colour operand still fails the expression, and empty operands add nothing.
|
||||
let color = Expr::Value(VariableValue::Color(Color::BLACK));
|
||||
let mut ops = vec![s(&"x".repeat(10_000))];
|
||||
ops.extend((0..50).map(|_| s("")));
|
||||
assert!(matches!(eval(&Expr::op(ExprOp::Add, ops.clone())), Some(VariableValue::String(t)) if t.chars().count() == 10_000));
|
||||
ops.push(color);
|
||||
assert_eq!(eval(&Expr::op(ExprOp::Add, ops)), None, "a colour past the cap is still a type error");
|
||||
}
|
||||
|
||||
fn nav(id: u64) -> Action {
|
||||
@@ -199,3 +206,12 @@ fn serde_round_trip_and_old_files() {
|
||||
let old: Action = serde_json::from_str(r#"{"type":"setVariable","variable":"n","value":2}"#).unwrap_or(Action::Back);
|
||||
assert_eq!(old, Action::SetVariable { variable: "n".into(), value: serde_json::json!(2), expr: None });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operands_holding_too_many_string_bytes_are_refused() {
|
||||
let long = "x".repeat(300_000);
|
||||
let few = Expr::op(ExprOp::Add, vec![s(&long), s(&long)]);
|
||||
assert!(eval(&few).is_some());
|
||||
let many = Expr::op(ExprOp::Add, (0..8).map(|_| s(&long)).collect());
|
||||
assert_eq!(eval(&many), None);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Shared styles and variables.
|
||||
|
||||
use crate::props::{Effect, LayoutGrid};
|
||||
use crate::text::TextStyle;
|
||||
use crate::text::{ParagraphStyle, TextStyle};
|
||||
use figcraft_color::{Color, Paint};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -14,10 +14,22 @@ pub type ModeId = String;
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum StyleKind {
|
||||
Paint { paints: Vec<Paint> },
|
||||
Text { style: TextStyle },
|
||||
Effect { effects: Vec<Effect> },
|
||||
Grid { grids: Vec<LayoutGrid> },
|
||||
Paint {
|
||||
paints: Vec<Paint>,
|
||||
},
|
||||
Text {
|
||||
style: TextStyle,
|
||||
/// What the style sets at the paragraph and layer level (#1829); boxed to keep the enum
|
||||
/// small.
|
||||
#[serde(default, skip_serializing_if = "crate::node::is_default")]
|
||||
paragraph: Box<ParagraphStyle>,
|
||||
},
|
||||
Effect {
|
||||
effects: Vec<Effect>,
|
||||
},
|
||||
Grid {
|
||||
grids: Vec<LayoutGrid>,
|
||||
},
|
||||
}
|
||||
|
||||
impl StyleKind {
|
||||
|
||||
@@ -223,6 +223,7 @@ fn text_case() {
|
||||
assert_eq!(style_weight("Semi Bold Italic"), 600);
|
||||
assert_eq!(style_weight("Black"), 900);
|
||||
assert_eq!(style_weight("Regular"), 400);
|
||||
assert_eq!(style_weight("Hairline"), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -348,6 +349,20 @@ fn repair_drops_cycles_and_repeated_children() {
|
||||
assert_eq!(bad.subtree(p), vec![p, r]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_lists_a_page_once_however_often_the_file_lists_it() {
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let q = d.add_page("Two");
|
||||
let mut v = serde_json::to_value(&d).unwrap();
|
||||
v["pages"] = serde_json::json!([p, q, p, q, p]);
|
||||
let mut bad: Document = serde_json::from_value(v).unwrap();
|
||||
assert!(bad.validate().iter().any(|i| i.contains("listed twice")), "{:?}", bad.validate());
|
||||
bad.repair();
|
||||
assert!(bad.validate().is_empty(), "{:?}", bad.validate());
|
||||
assert_eq!(bad.pages, vec![p, q]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_caps_the_issue_list() {
|
||||
let mut d = Document::new("x");
|
||||
@@ -582,6 +597,22 @@ fn is_ancestor_holds_for_very_deep_chains() {
|
||||
assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bounds_of_a_doubled_child_list_end_at_once() {
|
||||
// A file can list a child twice at every level: 2^40 visits if each is walked.
|
||||
let mut d = Document::new("x");
|
||||
let mut parent = page(&d);
|
||||
let mut groups = Vec::new();
|
||||
for _ in 0..40 {
|
||||
parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
groups.push(parent);
|
||||
}
|
||||
for w in groups.windows(2) {
|
||||
d.node_mut(w[0]).unwrap().children.push(w[1]);
|
||||
}
|
||||
assert!(d.world_render_bounds(groups[0]).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() {
|
||||
// A crafted file nests groups far deeper than anything draws (#1062). The leaf counts while
|
||||
@@ -665,6 +696,210 @@ fn two_segment_closed_loop_rounded_corners() {
|
||||
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
|
||||
let path = net.fill_path();
|
||||
assert!(!path.elements().is_empty());
|
||||
// Both points sit next to the curved segment, so smoothing keeps their plain arcs.
|
||||
assert_eq!(net.rounded_smoothed(0.6), net.rounded(), "a corner next to a curve keeps the plain arc");
|
||||
}
|
||||
|
||||
/// A closed 100 × 100 square whose corners (top left, top right, bottom right, bottom left) have
|
||||
/// radii `radii`.
|
||||
fn rounded_square(radii: [f64; 4]) -> VectorNetwork {
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(100.0, 0.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
|
||||
let mut net = VectorNetwork::from_points(&pts, true);
|
||||
for (v, r) in net.vertices.iter_mut().zip(radii) {
|
||||
v.radius = r;
|
||||
}
|
||||
net
|
||||
}
|
||||
|
||||
/// The area a closed path encloses, whichever way it runs.
|
||||
fn area_of(p: &figcraft_geom::BezPath) -> f64 {
|
||||
use figcraft_geom::Shape;
|
||||
p.area().abs()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_vectors_smooth_each_rounded_point_with_its_own_radius() {
|
||||
use figcraft_geom::Shape;
|
||||
use figcraft_geom::kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest};
|
||||
// (point radii, smoothing, derivation): a closed square draws the smoothed rectangle's corners,
|
||||
// each point keeping its own radius and a point without one staying sharp.
|
||||
let rows: Vec<([f64; 4], f64, &str)> = vec![
|
||||
([20.0; 4], 0.6, "one radius on every point"),
|
||||
([30.0, 0.0, 10.0, 0.0], 0.6, "each point keeps its own radius; points without one stay sharp"),
|
||||
([12.0; 4], 1.0, "full smoothing"),
|
||||
([40.0; 4], 0.6, "a corner too large for its edges loses smoothing first"),
|
||||
];
|
||||
for (radii, s, why) in rows {
|
||||
let net = rounded_square(radii);
|
||||
let smoothed = net.rounded_smoothed(s).unwrap().fill_path();
|
||||
// The rectangle's corners take [top left, top right, bottom right, bottom left] radii.
|
||||
let want = area_of(&figcraft_geom::rounded_rect(100.0, 100.0, radii, s));
|
||||
let got = area_of(&smoothed);
|
||||
assert!((got - want).abs() < 0.1, "{why}: {got} vs the smoothed rectangle's {want}");
|
||||
assert!((got - area_of(&net.fill_path())).abs() > 1.0, "{why}: smoothing changes the outline");
|
||||
let b = smoothed.bounding_box();
|
||||
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}");
|
||||
}
|
||||
// The sharp points stay on the outline, and the outline has no kinks at the rounded points.
|
||||
let smoothed = rounded_square([30.0, 0.0, 10.0, 0.0]).rounded_smoothed(0.6).unwrap();
|
||||
let path = smoothed.fill_path();
|
||||
for p in [Point::new(100.0, 0.0), Point::new(0.0, 100.0)] {
|
||||
let d = path.segments().map(|seg| seg.nearest(p, 1e-9).distance_sq).fold(f64::INFINITY, f64::min);
|
||||
assert!(d < 1e-12, "the sharp point {p:?} stays a corner");
|
||||
}
|
||||
let segs: Vec<_> = path.segments().filter(|seg| seg.arclen(1e-6) > 1e-6).collect();
|
||||
let mut kinks = 0;
|
||||
for (i, seg) in segs.iter().enumerate() {
|
||||
let next = segs[(i + 1) % segs.len()];
|
||||
let out = (seg.eval(1.0) - seg.eval(1.0 - 1e-6)).normalize();
|
||||
let into = (next.eval(1e-6) - next.eval(0.0)).normalize();
|
||||
if out.dot(into) < 0.999 {
|
||||
kinks += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(kinks, 2, "only the two sharp points turn without a curve");
|
||||
assert_eq!(smoothed.regions[0].loops[0].len(), smoothed.segments.len(), "the loop walks every curve once");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_smoothing_leaves_open_paths_and_zero_alone() {
|
||||
let square = rounded_square([20.0; 4]);
|
||||
assert_eq!(square.rounded_smoothed(0.0), square.rounded(), "no smoothing draws the plain rounded points");
|
||||
assert_eq!(square.rounded_smoothed(f64::NAN), square.rounded(), "an unusable smoothing draws no smoothing");
|
||||
// An open path (no closed region) keeps its plain arcs.
|
||||
let pts = [Point::new(200.0, 0.0), Point::new(250.0, 0.0), Point::new(250.0, 50.0)];
|
||||
let mut open = VectorNetwork::from_points(&pts, false);
|
||||
for v in &mut open.vertices {
|
||||
v.radius = 20.0;
|
||||
}
|
||||
assert!(open.regions.is_empty());
|
||||
assert_eq!(open.rounded_smoothed(0.6), open.rounded(), "an open path takes no smoothing");
|
||||
// In a network with a closed square and a separate open branch, only the square smooths.
|
||||
let mut mixed = square.clone();
|
||||
let base = mixed.vertices.len();
|
||||
mixed.vertices.extend(open.vertices.iter().copied());
|
||||
mixed.segments.extend(open.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s }));
|
||||
let runs = mixed.rounded_smoothed(0.6).unwrap().stroke_runs();
|
||||
let open_run = runs.iter().find(|r| !r.closed).expect("the open branch");
|
||||
assert_eq!(open_run.path, open.rounded().unwrap().stroke_runs()[0].path, "the open branch keeps its plain arc");
|
||||
let closed_run = runs.iter().find(|r| r.closed).expect("the square");
|
||||
assert_eq!(closed_run.path, square.rounded_smoothed(0.6).unwrap().stroke_runs()[0].path, "the square smooths");
|
||||
assert_ne!(closed_run.path, square.rounded().unwrap().stroke_runs()[0].path, "the square's smoothed corners differ from its arcs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoothed_vector_corners_stay_finite_on_awkward_networks() {
|
||||
use figcraft_geom::Shape;
|
||||
// A concave L, a square with a square hole, points with unusable radii and a loop that names a
|
||||
// missing segment.
|
||||
let l = [(0.0, 0.0), (100.0, 0.0), (100.0, 40.0), (40.0, 40.0), (40.0, 100.0), (0.0, 100.0)];
|
||||
let mut concave = VectorNetwork::from_points(&l.map(|(x, y)| Point::new(x, y)), true);
|
||||
for v in &mut concave.vertices {
|
||||
v.radius = 1000.0;
|
||||
}
|
||||
let mut holed = rounded_square([10.0; 4]);
|
||||
let hole = rounded_square([5.0; 4]);
|
||||
let (base, seg_base) = (holed.vertices.len(), holed.segments.len());
|
||||
holed.vertices.extend(hole.vertices.iter().map(|v| Vertex { x: 25.0 + v.x / 2.0, y: 25.0 + v.y / 2.0, ..*v }));
|
||||
holed.segments.extend(hole.segments.iter().map(|s| Segment { a: s.a + base, b: s.b + base, ..*s }));
|
||||
holed.regions[0].loops.push((seg_base..seg_base + hole.segments.len()).collect());
|
||||
holed.regions[0].winding = WindingRule::EvenOdd;
|
||||
let mut odd = rounded_square([f64::INFINITY, 10.0, f64::NAN, 10.0]);
|
||||
odd.regions[0].loops[0].push(99);
|
||||
for (net, s, why) in [(&concave, 1.0, "concave"), (&holed, 0.6, "holed"), (&odd, 0.6, "odd radii")] {
|
||||
let drawn = net.rounded_smoothed(s).unwrap();
|
||||
let b = drawn.fill_path().bounding_box();
|
||||
assert!([b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()), "{why}: {b:?}");
|
||||
assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 100.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "{why}: {b:?}");
|
||||
assert!(!drawn.stroke_path().elements().is_empty(), "{why}");
|
||||
}
|
||||
let concave_path = concave.rounded_smoothed(1.0).unwrap().fill_path();
|
||||
assert_ne!(concave_path, concave.fill_path(), "the concave L smooths");
|
||||
assert!(concave_path.winding(Point::new(20.0, 60.0)) != 0, "the L stays filled");
|
||||
let holed_net = holed.rounded_smoothed(0.6).unwrap();
|
||||
assert!(!holed_net.fill_contains(Point::new(50.0, 50.0)) && holed_net.fill_contains(Point::new(12.0, 50.0)), "the hole stays a hole");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_on_a_smoothed_vector_corner_follow_the_drawn_fill() {
|
||||
// A closed square with rounded points and smoothing: a click between the plain arc and the
|
||||
// smoothed corner selects the layer only where the smoothed fill is drawn.
|
||||
let pts = [Point::new(0.0, 0.0), Point::new(1000.0, 0.0), Point::new(1000.0, 1000.0), Point::new(0.0, 1000.0)];
|
||||
let mut net = VectorNetwork::from_points(&pts, true);
|
||||
for v in &mut net.vertices {
|
||||
v.radius = 200.0;
|
||||
}
|
||||
let smoothed = net.rounded_smoothed(1.0).unwrap();
|
||||
let mut d = Document::new("x");
|
||||
let p = page(&d);
|
||||
let v = d.create(p, NodeKind::Vector { network: net.clone() }, "V", 0.0, 0.0, 1000.0, 1000.0).unwrap();
|
||||
let n = d.node_mut(v).unwrap();
|
||||
n.corners.smoothing = 1.0;
|
||||
n.strokes.clear();
|
||||
// Walk the row 20 units below the top edge inward to where each outline starts covering it.
|
||||
let first_inside = |inside: &dyn Fn(Point) -> bool| (0..5_000).map(|i| f64::from(i) * 0.1).find(|&x| inside(Point::new(x, 20.0)));
|
||||
let plain = first_inside(&|pt| net.fill_contains(pt)).unwrap();
|
||||
let drawn = first_inside(&|pt| smoothed.fill_contains(pt)).unwrap();
|
||||
assert!((plain - drawn).abs() > 5.0, "smoothing moves the corner: plain {plain}, smoothed {drawn}");
|
||||
let pt = Point::new((plain + drawn) / 2.0, 20.0);
|
||||
assert_ne!(net.fill_contains(pt), smoothed.fill_contains(pt));
|
||||
assert_eq!(d.hit_test(p, pt, 0.0, None).is_some(), smoothed.fill_contains(pt), "the click follows the smoothed corner at {pt:?}");
|
||||
assert_eq!(d.hit_test(p, Point::new(500.0, 500.0), 0.0, None), Some(v));
|
||||
}
|
||||
|
||||
/// A rectangle whose top-right corner was cut off in the vector editor (a diagonal between a point
|
||||
/// on the top edge and one on the right edge), then rounded: the fill, which the fill and the
|
||||
/// selection outline draw, is cut by an arc at every point just as the stroke is, however the loop
|
||||
/// lists its segments and whichever way each one runs (#1951, #1506).
|
||||
#[test]
|
||||
fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() {
|
||||
use figcraft_geom::BezPath;
|
||||
use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest};
|
||||
let corners = [Point::new(0.0, 0.0), Point::new(50.0, 0.0), Point::new(100.0, 50.0), Point::new(100.0, 100.0), Point::new(0.0, 100.0)];
|
||||
let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min);
|
||||
let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::<f64>();
|
||||
for first in 0..corners.len() {
|
||||
for backwards in [false, true] {
|
||||
for flipped in 0..(1u32 << corners.len()) {
|
||||
let mut net = VectorNetwork::from_points(&corners, true);
|
||||
for v in &mut net.vertices {
|
||||
v.radius = 43.0;
|
||||
}
|
||||
for (i, s) in net.segments.iter_mut().enumerate() {
|
||||
if flipped & (1 << i) != 0 {
|
||||
std::mem::swap(&mut s.a, &mut s.b);
|
||||
std::mem::swap(&mut s.ta, &mut s.tb);
|
||||
}
|
||||
}
|
||||
let lp = &mut net.regions[0].loops[0];
|
||||
lp.rotate_left(first);
|
||||
if backwards {
|
||||
lp.reverse();
|
||||
}
|
||||
let why = format!("loop from segment {first}{}, segments flipped {flipped:05b}", if backwards { " backwards" } else { "" });
|
||||
let stroke = net.stroke_path();
|
||||
let fill = net.fill_path();
|
||||
let region = net.rounded().unwrap().region_path(0).unwrap();
|
||||
for (i, p) in corners.iter().enumerate() {
|
||||
assert!(keeps_off(&stroke, *p) > 2.0, "{why}: the stroke is cut at point {i} ({:.2} from it)", keeps_off(&stroke, *p));
|
||||
assert!(keeps_off(&fill, *p) > 2.0, "{why}: the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p));
|
||||
assert!(keeps_off(®ion, *p) > 2.0, "{why}: the region runs into point {i} ({:.2} from it)", keeps_off(®ion, *p));
|
||||
}
|
||||
assert!(
|
||||
(length(&fill) - length(&stroke)).abs() < 1e-6,
|
||||
"{why}: fill {} and stroke {} trace the same outline",
|
||||
length(&fill),
|
||||
length(&stroke)
|
||||
);
|
||||
assert!(
|
||||
(length(®ion) - length(&stroke)).abs() < 1e-6,
|
||||
"{why}: region {} and stroke {} trace the same outline",
|
||||
length(®ion),
|
||||
length(&stroke)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -846,3 +1081,30 @@ fn the_colour_walker_visits_calculation_literals() {
|
||||
let ColorOperand::Literal { literal } = from else { panic!("literal operand") };
|
||||
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");
|
||||
let page = doc.first_page().unwrap();
|
||||
let frame = doc.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap();
|
||||
let inner = doc.create(frame, NodeKind::Frame, "Inner", 0.0, 0.0, 10.0, 10.0).unwrap();
|
||||
let leaf = doc.create(inner, NodeKind::Rectangle, "Leaf", 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
let other = doc.create(page, NodeKind::Rectangle, "Other", 0.0, 0.0, 5.0, 5.0).unwrap();
|
||||
doc.remove_children(frame).unwrap();
|
||||
assert!(doc.contains(frame) && doc.contains(other));
|
||||
assert!(!doc.contains(inner) && !doc.contains(leaf));
|
||||
assert!(doc.get(frame).unwrap().children.is_empty());
|
||||
}
|
||||
|
||||
+163
-13
@@ -234,6 +234,22 @@ pub enum LeadingTrim {
|
||||
CapHeight,
|
||||
}
|
||||
|
||||
/// Where lines break when text wraps to more than one line ("Wrap style controls where lines
|
||||
/// break when text wraps to more than one line", Explore text properties).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WrapStyle {
|
||||
/// "Figma fits as many words as possible on each line before wrapping to the next."
|
||||
#[default]
|
||||
Auto,
|
||||
/// "Figma distributes text as evenly as possible across every line, so each line is close to
|
||||
/// the same length."
|
||||
Balance,
|
||||
/// "Figma keeps each line full, but adjusts the last four lines to avoid an orphan -- a single
|
||||
/// word stranded alone on the last line."
|
||||
Pretty,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ListStyle {
|
||||
@@ -243,20 +259,40 @@ pub enum ListStyle {
|
||||
Numbered,
|
||||
}
|
||||
|
||||
/// A paragraph's base direction: by its letters, or forced ("controls are available to override
|
||||
/// text direction per paragraph", Add right-to-left text).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextDirection {
|
||||
/// The first letter with a direction decides (Unicode rules P2 and P3).
|
||||
#[default]
|
||||
Auto,
|
||||
/// Left to right, whatever the letters.
|
||||
Ltr,
|
||||
/// Right to left, whatever the letters.
|
||||
Rtl,
|
||||
}
|
||||
|
||||
/// Deepest list nesting (levels 0..=MAX_LIST_INDENT).
|
||||
pub const MAX_LIST_INDENT: u8 = 8;
|
||||
|
||||
/// One paragraph's list settings.
|
||||
/// One paragraph's list settings and base direction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ParaList {
|
||||
pub list: ListStyle,
|
||||
/// Nesting level (0 = outermost; plain paragraphs are always 0).
|
||||
/// Nesting level (0 = outermost). A plain paragraph is 0, except one that left its list by
|
||||
/// Backspace or Delete at its start ([`TextData::unlist_keeping_levels`]): it keeps the level
|
||||
/// it had and starts where that level's item text does ("delete the counter, but keep the
|
||||
/// same level of indentation").
|
||||
pub indent: u8,
|
||||
/// A numbered list that starts counting at this paragraph counts from `start_offset + 1`
|
||||
/// (items that go on counting ignore it; other paragraphs are always 0).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub start_offset: u32,
|
||||
/// The paragraph's base direction (Text ▸ Text direction); `Auto` follows its letters.
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub direction: TextDirection,
|
||||
}
|
||||
|
||||
/// Character style. In runs, every field is an override; `None` means "inherit the base".
|
||||
@@ -288,6 +324,23 @@ pub struct TextStyle {
|
||||
pub style_id: Option<String>,
|
||||
}
|
||||
|
||||
/// What a text style sets at the paragraph and layer level, beside its character style
|
||||
/// ([`TextStyle`]): paragraph spacing and indent, the list style and the OpenType features
|
||||
/// ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType
|
||||
/// features", Create and apply text styles).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct ParagraphStyle {
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub paragraph_spacing: f64,
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub paragraph_indent: f64,
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub list: ListStyle,
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub features: std::collections::BTreeMap<String, bool>,
|
||||
}
|
||||
|
||||
/// The font new text starts in. A build can name another (`FIGCRAFT_DEFAULT_FONT="Noto Sans Thai"`,
|
||||
/// for a distribution whose users mostly write a script Inter lacks); empty means Inter.
|
||||
pub const DEFAULT_FONT: &str = match option_env!("FIGCRAFT_DEFAULT_FONT") {
|
||||
@@ -386,8 +439,9 @@ pub fn style_weight(style: &str) -> u16 {
|
||||
{
|
||||
return n;
|
||||
}
|
||||
let table: [(&str, u16); 10] = [
|
||||
let table: [(&str, u16); 11] = [
|
||||
("thin", 100),
|
||||
("hairline", 100),
|
||||
("extralight", 200),
|
||||
("ultralight", 200),
|
||||
("light", 300),
|
||||
@@ -463,6 +517,10 @@ pub struct TextData {
|
||||
/// as fit).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub max_lines: u32,
|
||||
/// Where lines break when the text wraps: greedily (Auto), as evenly as the words allow
|
||||
/// (Balance), or full with no one word alone on the last line (Pretty).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub wrap_style: WrapStyle,
|
||||
/// OpenType feature toggles (e.g. "liga": false).
|
||||
#[serde(skip_serializing_if = "crate::node::is_default")]
|
||||
pub features: std::collections::BTreeMap<String, bool>,
|
||||
@@ -510,7 +568,7 @@ impl TextPath {
|
||||
|
||||
/// The path (empty when `d` doesn't parse).
|
||||
pub fn path(&self) -> figcraft_geom::BezPath {
|
||||
figcraft_geom::BezPath::from_svg(&self.d).unwrap_or_default()
|
||||
figcraft_geom::svg_path(&self.d).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The start offset clamped into 0..1 (NaN: 0).
|
||||
@@ -606,12 +664,14 @@ impl TextData {
|
||||
}
|
||||
|
||||
/// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height
|
||||
/// with a line limit (the box hugs up to `max_lines` lines).
|
||||
/// or auto width with a line limit (the box hugs up to `max_lines` lines: "Max lines is
|
||||
/// available only if truncate text is enabled, and: Text resizing is set to auto height or
|
||||
/// auto width").
|
||||
pub fn truncates(&self) -> bool {
|
||||
match self.auto_resize {
|
||||
TextAutoResize::Truncate => true,
|
||||
TextAutoResize::Height => self.max_lines > 0,
|
||||
TextAutoResize::WidthAndHeight | TextAutoResize::None => false,
|
||||
TextAutoResize::Height | TextAutoResize::WidthAndHeight => self.max_lines > 0,
|
||||
TextAutoResize::None => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -798,11 +858,27 @@ impl TextData {
|
||||
/// paragraphs stay at level 0, only numbered ones keep a start, levels stop at
|
||||
/// [`MAX_LIST_INDENT`]); one style for all with no nesting or start folds back into `list`.
|
||||
pub fn set_para_lists(&mut self, start: usize, end: usize, f: impl Fn(&mut ParaList)) {
|
||||
self.set_para_lists_with(start, end, false, f);
|
||||
}
|
||||
|
||||
/// Take the list items among the paragraphs chars `start..end` touch out of their list at the
|
||||
/// level they had, as Backspace or Delete at an item's start does: "delete the counter, but
|
||||
/// keep the same level of indentation" (Create bulleted and numbered lists).
|
||||
pub fn unlist_keeping_levels(&mut self, start: usize, end: usize) {
|
||||
self.set_para_lists_with(start, end, true, |pl| pl.list = ListStyle::None);
|
||||
}
|
||||
|
||||
fn set_para_lists_with(&mut self, start: usize, end: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) {
|
||||
let (a, b) = self.para_span(start, end);
|
||||
self.set_para_lists_between(a, b, keep_levels, f);
|
||||
}
|
||||
|
||||
/// As [`set_para_lists`](Self::set_para_lists), for paragraphs `a..=b` addressed directly.
|
||||
fn set_para_lists_between(&mut self, a: usize, b: usize, keep_levels: bool, f: impl Fn(&mut ParaList)) {
|
||||
self.fill_paragraphs();
|
||||
for pl in self.paragraphs.get_mut(a..=b).unwrap_or(&mut []) {
|
||||
f(pl);
|
||||
if pl.list == ListStyle::None {
|
||||
if pl.list == ListStyle::None && !keep_levels {
|
||||
pl.indent = 0;
|
||||
}
|
||||
if pl.list != ListStyle::Numbered {
|
||||
@@ -827,6 +903,61 @@ impl TextData {
|
||||
(a..=b).all(|p| self.para_list(p).list == first.list && self.para_list(p).indent == first.indent).then_some(first)
|
||||
}
|
||||
|
||||
/// What a text style made from this text sets at the paragraph and layer level: the paragraph
|
||||
/// spacing and indent, the list style (the one every paragraph shares; the base one when they
|
||||
/// differ) and the OpenType features.
|
||||
pub fn paragraph_style(&self) -> ParagraphStyle {
|
||||
let list = match self.paragraphs.first() {
|
||||
Some(first) if self.paragraphs.iter().all(|p| p.list == first.list) => first.list,
|
||||
_ => self.list,
|
||||
};
|
||||
ParagraphStyle { paragraph_spacing: self.paragraph_spacing, paragraph_indent: self.paragraph_indent, list, features: self.features.clone() }
|
||||
}
|
||||
|
||||
/// Take what text style `ps` sets at the paragraph and layer level: the spacing, indent and
|
||||
/// features on the layer, the list style on every paragraph.
|
||||
pub fn take_paragraph_style(&mut self, ps: &ParagraphStyle) {
|
||||
self.paragraph_spacing = ps.paragraph_spacing;
|
||||
self.paragraph_indent = ps.paragraph_indent;
|
||||
self.features.clone_from(&ps.features);
|
||||
// Every paragraph by index: `para_span` leaves out an empty one after a final newline.
|
||||
let last = self.paragraph_count() - 1;
|
||||
self.set_para_lists_between(0, last, false, |pl| pl.list = ps.list);
|
||||
}
|
||||
|
||||
/// As [`take_paragraph_style`](Self::take_paragraph_style), with the list style going to the
|
||||
/// paragraphs chars `start..end` touch only; the spacing, indent and features are the
|
||||
/// layer's, as in Figma.
|
||||
pub fn take_paragraph_style_in(&mut self, start: usize, end: usize, ps: &ParagraphStyle) {
|
||||
self.paragraph_spacing = ps.paragraph_spacing;
|
||||
self.paragraph_indent = ps.paragraph_indent;
|
||||
self.features.clone_from(&ps.features);
|
||||
self.set_para_lists(start, end, |pl| pl.list = ps.list);
|
||||
}
|
||||
|
||||
/// Follow text style `id`'s paragraph-level definition wherever chars link to it.
|
||||
pub fn update_paragraph_style(&mut self, id: &str, ps: &ParagraphStyle) {
|
||||
let linked: Vec<(usize, usize)> =
|
||||
self.spans().into_iter().filter(|(_, _, s)| s.style_id.as_deref() == Some(id)).map(|(a, b, _)| (a, b)).collect();
|
||||
for (a, b) in linked {
|
||||
self.take_paragraph_style_in(a, b, ps);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether chars `start..end` show what text style `ps` sets at the paragraph and layer
|
||||
/// level: the layer's spacing, indent and features, and their paragraphs' list style.
|
||||
pub fn shows_paragraph_style_in(&self, start: usize, end: usize, ps: &ParagraphStyle) -> bool {
|
||||
self.paragraph_spacing == ps.paragraph_spacing
|
||||
&& self.paragraph_indent == ps.paragraph_indent
|
||||
&& self.features == ps.features
|
||||
&& self.common_para_list(start, end).is_some_and(|pl| pl.list == ps.list)
|
||||
}
|
||||
|
||||
/// Whether the whole text shows what text style `ps` sets at the paragraph and layer level.
|
||||
pub fn shows_paragraph_style(&self, ps: &ParagraphStyle) -> bool {
|
||||
self.shows_paragraph_style_in(0, self.char_count(), ps)
|
||||
}
|
||||
|
||||
/// Link chars `start..end` (all of them when the range is empty or the whole text) to text
|
||||
/// style `id`, defined as `def`.
|
||||
pub fn link_text_style(&mut self, start: usize, end: usize, def: &TextStyle, id: &str) {
|
||||
@@ -955,20 +1086,20 @@ mod case_tests {
|
||||
// Files written before start numbers load with none, and write the same bytes.
|
||||
let old = r#"{"list":"numbered","indent":1}"#;
|
||||
let pl: ParaList = serde_json::from_str(old).unwrap();
|
||||
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0 });
|
||||
assert_eq!(pl, ParaList { list: N, indent: 1, start_offset: 0, ..ParaList::default() });
|
||||
assert_eq!(serde_json::to_string(&pl).unwrap(), old);
|
||||
// One numbered paragraph with a start keeps it (it doesn't fold into `list`).
|
||||
let mut t = TextData { characters: "Next".into(), ..Default::default() };
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1 });
|
||||
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1 });
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() });
|
||||
assert_eq!(t.para_list(0), ParaList { list: N, indent: 0, start_offset: 1, ..ParaList::default() });
|
||||
// Enter copies it to the new item; different starts don't make the list mixed.
|
||||
t.replace_range(4, 4, "\nMore");
|
||||
assert_eq!(t.para_list(1).start_offset, 1);
|
||||
t.set_para_lists(5, 5, |p| p.start_offset = 0);
|
||||
assert!(t.common_para_list(0, t.char_count()).is_some_and(|p| p.list == N));
|
||||
// Bullets have no number, so no start.
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4 });
|
||||
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0 });
|
||||
t.set_para_lists(0, 0, |p| *p = ParaList { list: B, indent: 1, start_offset: 4, ..ParaList::default() });
|
||||
assert_eq!(t.para_list(0), ParaList { list: B, indent: 1, start_offset: 0, ..ParaList::default() });
|
||||
// A plain paragraph has none.
|
||||
t.set_para_lists(0, 0, |p| p.list = P);
|
||||
assert_eq!(t.para_list(0), ParaList::default());
|
||||
@@ -1003,3 +1134,22 @@ mod title_case_tests {
|
||||
assert_eq!(TextCase::Title.apply("cafe\u{301}'s"), "Cafe\u{301}'s");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod whole_text_paragraph_style_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_text_style_on_the_whole_layer_reaches_a_trailing_empty_paragraph() {
|
||||
let ps = ParagraphStyle { list: ListStyle::Bulleted, ..Default::default() };
|
||||
let mut t = TextData::new("a\nb\n");
|
||||
t.take_paragraph_style(&ps);
|
||||
assert_eq!(t.paragraph_count(), 3);
|
||||
assert_eq!(t.para_list(2).list, ListStyle::Bulleted);
|
||||
assert_eq!(t.list, ListStyle::Bulleted);
|
||||
assert!(t.paragraphs.is_empty());
|
||||
let mut plain = TextData::new("a\nb");
|
||||
plain.take_paragraph_style(&ps);
|
||||
assert_eq!((plain.list, plain.paragraphs.len()), (t.list, t.paragraphs.len()));
|
||||
}
|
||||
}
|
||||
|
||||
+76
-25
@@ -129,9 +129,10 @@ impl Region {
|
||||
}
|
||||
}
|
||||
|
||||
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), segment `s2` starts again
|
||||
/// at `b`, and an arc with handles `ta`/`tb` joins them. `cut1`/`cut2`: where each segment is cut,
|
||||
/// in its own parameter (curved segments keep the rest of their curve).
|
||||
/// A rounded point as drawn: point `v` moves to `a` (on segment `s1`), and the curves of `chain`
|
||||
/// lead from there to where segment `s2` starts again (one arc, or a smoothed corner's easing
|
||||
/// curves and arc). `cut1`/`cut2`: where each segment is cut, in its own parameter (curved
|
||||
/// segments keep the rest of their curve).
|
||||
struct Corner {
|
||||
v: usize,
|
||||
s1: usize,
|
||||
@@ -139,13 +140,19 @@ struct Corner {
|
||||
s2: usize,
|
||||
s2_at_a: bool,
|
||||
a: Point,
|
||||
b: Point,
|
||||
ta: (f64, f64),
|
||||
tb: (f64, f64),
|
||||
chain: Vec<CornerCurve>,
|
||||
cut1: f64,
|
||||
cut2: f64,
|
||||
}
|
||||
|
||||
/// One curve of a rounded point: its handles at its start (`ta`) and its end (`tb`), relative to
|
||||
/// those ends, and the point it ends at.
|
||||
struct CornerCurve {
|
||||
ta: (f64, f64),
|
||||
tb: (f64, f64),
|
||||
end: Point,
|
||||
}
|
||||
|
||||
/// Accuracy of arc lengths along curved segments (local units).
|
||||
const ARCLEN_ACC: f64 = 1e-6;
|
||||
|
||||
@@ -382,9 +389,19 @@ impl VectorNetwork {
|
||||
/// the rest of its curve, the arc leaving along its direction there. `None` when no point is
|
||||
/// rounded.
|
||||
pub fn rounded(&self) -> Option<VectorNetwork> {
|
||||
self.rounded_smoothed(0.0)
|
||||
}
|
||||
|
||||
/// [`Self::rounded`] with the layer's corner smoothing (0..=1): a rounded point between two
|
||||
/// straight segments that both belong to a region's loops gets a smoothed corner, drawn with
|
||||
/// the profile of a smoothed polygon's corners (`figcraft_geom::smoothed_corner`). Other
|
||||
/// rounded points (on open branches, or next to a curved segment) keep the plain arc, and a
|
||||
/// point without a radius stays sharp. Without smoothing this is [`Self::rounded`].
|
||||
pub fn rounded_smoothed(&self, smoothing: f64) -> Option<VectorNetwork> {
|
||||
if !self.vertices.iter().any(|v| v.radius > 0.0) {
|
||||
return None;
|
||||
}
|
||||
let smoothing = if smoothing.is_finite() { smoothing.clamp(0.0, 1.0) } else { 0.0 };
|
||||
let mut incident: Vec<Vec<(usize, bool)>> = vec![Vec::new(); self.vertices.len()];
|
||||
for (i, s) in self.segments.iter().enumerate() {
|
||||
for (v, at_a) in [(s.a, true), (s.b, false)] {
|
||||
@@ -393,7 +410,14 @@ impl VectorNetwork {
|
||||
}
|
||||
}
|
||||
}
|
||||
let corners: Vec<Corner> = (0..self.vertices.len()).filter_map(|v| self.corner(v, incident.get(v)?)).collect();
|
||||
// The segments that bound a closed region, where corners take smoothing.
|
||||
let mut on_loop = vec![false; if smoothing > 0.0 { self.segments.len() } else { 0 }];
|
||||
for &i in self.regions.iter().flat_map(|r| r.loops.iter().flatten()) {
|
||||
if let Some(x) = on_loop.get_mut(i) {
|
||||
*x = true;
|
||||
}
|
||||
}
|
||||
let corners: Vec<Corner> = (0..self.vertices.len()).filter_map(|v| self.corner(v, incident.get(v)?, smoothing, &on_loop)).collect();
|
||||
let mut out = self.clone();
|
||||
for v in &mut out.vertices {
|
||||
v.radius = 0.0;
|
||||
@@ -420,26 +444,33 @@ impl VectorNetwork {
|
||||
s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y);
|
||||
s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y);
|
||||
}
|
||||
let mut by_vertex: std::collections::BTreeMap<usize, usize> = std::collections::BTreeMap::new();
|
||||
// Each rounded point's curves in order from `s1`'s side, by point.
|
||||
let mut by_vertex: std::collections::BTreeMap<usize, (usize, Vec<usize>)> = std::collections::BTreeMap::new();
|
||||
for c in corners {
|
||||
if let Some(v) = out.vertices.get_mut(c.v) {
|
||||
(v.x, v.y) = (c.a.x, c.a.y);
|
||||
}
|
||||
out.vertices.push(Vertex::new(c.b.x, c.b.y));
|
||||
let nb = out.vertices.len() - 1;
|
||||
let mut from = c.v;
|
||||
let mut arcs = Vec::with_capacity(c.chain.len());
|
||||
for CornerCurve { ta, tb, end } in c.chain {
|
||||
out.vertices.push(Vertex::new(end.x, end.y));
|
||||
let to = out.vertices.len() - 1;
|
||||
out.segments.push(Segment { a: from, b: to, ta, tb });
|
||||
arcs.push(out.segments.len() - 1);
|
||||
from = to;
|
||||
}
|
||||
if let Some(s) = out.segments.get_mut(c.s2) {
|
||||
if c.s2_at_a {
|
||||
s.a = nb;
|
||||
s.a = from;
|
||||
} else {
|
||||
s.b = nb;
|
||||
s.b = from;
|
||||
}
|
||||
}
|
||||
out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb });
|
||||
let arc = out.segments.len() - 1;
|
||||
by_vertex.insert(c.v, arc);
|
||||
by_vertex.insert(c.v, (c.s1, arcs));
|
||||
}
|
||||
// The arc goes between the two segments wherever a loop walks through the point. Each loop
|
||||
// is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic.
|
||||
// The curves go between the two segments wherever a loop walks through the point, in the
|
||||
// order the loop meets them. Each loop is rebuilt in one pass, so a polygon with many
|
||||
// rounded corners isn't quadratic.
|
||||
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
|
||||
let Some(&first_idx) = lp.first() else { continue };
|
||||
let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) {
|
||||
@@ -453,8 +484,12 @@ impl VectorNetwork {
|
||||
rebuilt.push(x);
|
||||
let Some(s) = self.segments.get(x) else { continue };
|
||||
cur = if s.a == cur { s.b } else { s.a };
|
||||
if let Some(&arc) = by_vertex.get(&cur) {
|
||||
rebuilt.push(arc);
|
||||
if let Some((s1, arcs)) = by_vertex.get(&cur) {
|
||||
if x == *s1 {
|
||||
rebuilt.extend(arcs.iter().copied());
|
||||
} else {
|
||||
rebuilt.extend(arcs.iter().rev().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
*lp = rebuilt;
|
||||
@@ -463,8 +498,9 @@ impl VectorNetwork {
|
||||
}
|
||||
|
||||
/// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments
|
||||
/// run on smoothly or fold back.
|
||||
fn corner(&self, v: usize, incident: &[(usize, bool)]) -> Option<Corner> {
|
||||
/// run on smoothly or fold back. With `smoothing`, a point between two straight segments that
|
||||
/// are both `on_loop` gets a smoothed corner.
|
||||
fn corner(&self, v: usize, incident: &[(usize, bool)], smoothing: f64, on_loop: &[bool]) -> Option<Corner> {
|
||||
let r = self.vertices.get(v)?.radius;
|
||||
let [(s1, a1), (s2, a2)] = *incident else { return None };
|
||||
if !(r > 0.0 && r.is_finite()) || s1 == s2 {
|
||||
@@ -480,6 +516,24 @@ impl VectorNetwork {
|
||||
if !(l1 > 1e-9 && l2 > 1e-9) {
|
||||
return None;
|
||||
}
|
||||
let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t };
|
||||
let closed = |s: usize| on_loop.get(s).copied().unwrap_or(false);
|
||||
if smoothing > 0.0
|
||||
&& is_line(&c1)
|
||||
&& is_line(&c2)
|
||||
&& closed(s1)
|
||||
&& closed(s2)
|
||||
&& let Some(curves) = figcraft_geom::smoothed_corner(c1.p0, u1, u2, l1.min(l2) / 2.0, r, smoothing)
|
||||
&& let (Some(first), Some(last)) = (curves.first(), curves.last())
|
||||
{
|
||||
let (a, b) = (first.p0, last.p3);
|
||||
let chain = curves
|
||||
.iter()
|
||||
.map(|q| CornerCurve { ta: (q.p1.x - q.p0.x, q.p1.y - q.p0.y), tb: (q.p2.x - q.p3.x, q.p2.y - q.p3.y), end: q.p3 })
|
||||
.collect();
|
||||
let (cut1, cut2) = ((a - c1.p0).hypot() / l1, (b - c2.p0).hypot() / l2);
|
||||
return Some(Corner { v, s1, s1_at_a: a1, s2, s2_at_a: a2, a, chain, cut1: own(a1, cut1), cut2: own(a2, cut2) });
|
||||
}
|
||||
let t = (r / (angle / 2.0).tan()).min(l1.min(l2) / 2.0);
|
||||
let ((a, d1, k1), (b, d2, k2)) = (trim(&c1, t, l1), trim(&c2, t, l2));
|
||||
// A cubic arc: handles of 4/3·tan(φ/4)·ρ along each end's direction, φ the turn between
|
||||
@@ -490,7 +544,6 @@ impl VectorNetwork {
|
||||
if !(k.is_finite() && a.x.is_finite() && a.y.is_finite() && b.x.is_finite() && b.y.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let own = |at_a: bool, t: f64| if at_a { t } else { 1.0 - t };
|
||||
Some(Corner {
|
||||
v,
|
||||
s1,
|
||||
@@ -498,9 +551,7 @@ impl VectorNetwork {
|
||||
s2,
|
||||
s2_at_a: a2,
|
||||
a,
|
||||
b,
|
||||
ta: (d1.x * k, d1.y * k),
|
||||
tb: (d2.x * k, d2.y * k),
|
||||
chain: vec![CornerCurve { ta: (d1.x * k, d1.y * k), tb: (d2.x * k, d2.y * k), end: b }],
|
||||
cut1: own(a1, k1),
|
||||
cut2: own(a2, k2),
|
||||
})
|
||||
|
||||
@@ -72,6 +72,52 @@ fn fill_on_a_child_turns_the_hugging_parent_fixed() {
|
||||
assert_eq!(sizing(&s, f), (Sizing::Hug, Sizing::Hug));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_resized_to_the_full_available_space_becomes_fill() {
|
||||
// Documented: "Child objects of an auto layout frame will also be set to Fill container if
|
||||
// they are manually resized to the full available space of the parent frame."
|
||||
let mut s = Session::new();
|
||||
let f = al_frame(&mut s, json!({"padding": 10, "gap": 10})); // 200 × 100, fixed
|
||||
let a = rect(&mut s, f, 50.0, 20.0, "#FF0000");
|
||||
// Along the flow, alone in the frame: its width less the padding (200 - 20).
|
||||
s.execute("transform.resize", &json!({"ids": [a], "width": 180})).unwrap();
|
||||
assert_eq!(sizing(&s, a), (Sizing::Fill, Sizing::Fixed));
|
||||
assert_eq!(size(&s, a), (180.0, 20.0));
|
||||
s.undo().unwrap();
|
||||
assert_eq!(sizing(&s, a), (Sizing::Fixed, Sizing::Fixed), "undone as part of the edit");
|
||||
// Across the flow, beside a sibling too: the frame's height less its padding.
|
||||
let b = rect(&mut s, f, 50.0, 20.0, "#0000FF");
|
||||
set(&mut s, b, json!({"height": 80}));
|
||||
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fill));
|
||||
assert_eq!(size(&s, b), (50.0, 80.0));
|
||||
// Short of it: fixed, as before.
|
||||
set(&mut s, b, json!({"height": 70}));
|
||||
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fixed));
|
||||
// Along the flow beside a sibling, what is left is not "the full available space": a width
|
||||
// that happens to match it (180 - 10 - 50) stays fixed.
|
||||
s.execute("transform.resize", &json!({"ids": [b], "width": 120})).unwrap();
|
||||
assert_eq!(sizing(&s, b), (Sizing::Fixed, Sizing::Fixed));
|
||||
// A hugging frame has no full space to offer: the child stays fixed.
|
||||
let g = al_frame(&mut s, json!({"sizingH": "hug", "sizingV": "hug", "padding": 10}));
|
||||
let c = rect(&mut s, g, 50.0, 20.0, "#00FF00");
|
||||
set(&mut s, c, json!({"width": 180}));
|
||||
assert_eq!(sizing(&s, c), (Sizing::Fixed, Sizing::Fixed));
|
||||
// An inside stroke the layout includes takes room like the padding: the full space is what
|
||||
// layout leaves the child (200 - 2 × 10 - 2 × 4), not the frame less its padding alone.
|
||||
let h = al_frame(&mut s, json!({"padding": 10, "strokesIncluded": true, "stroke": "#000000", "strokeWeight": 4, "strokeAlign": "inside"}));
|
||||
let d = rect(&mut s, h, 50.0, 20.0, "#FF0000");
|
||||
set(&mut s, d, json!({"width": 180}));
|
||||
assert_eq!(sizing(&s, d), (Sizing::Fixed, Sizing::Fixed), "wider than the room the stroke leaves");
|
||||
assert_eq!(size(&s, d), (180.0, 20.0), "and kept as typed");
|
||||
set(&mut s, d, json!({"width": 172}));
|
||||
assert_eq!(sizing(&s, d), (Sizing::Fill, Sizing::Fixed));
|
||||
assert_eq!(size(&s, d), (172.0, 20.0));
|
||||
// Excluded from the layout, the stroke takes no room: the frame less its padding again.
|
||||
set(&mut s, h, json!({"strokesIncluded": false}));
|
||||
set(&mut s, d, json!({"width": 172}));
|
||||
assert_eq!(sizing(&s, d), (Sizing::Fixed, Sizing::Fixed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hug_and_fill_only_where_they_apply() {
|
||||
// Documented: Hug applies to auto layout frames (and text); Fill to children of auto layout,
|
||||
@@ -232,6 +278,20 @@ fn a_size_given_with_a_new_path_fixes_that_axis() {
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30}));
|
||||
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
|
||||
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
// The axis is judged by the size the path is given, not the one before it: the frame's full
|
||||
// height (100) given with a path fills; a smaller height given with the next path is fixed,
|
||||
// though the layer filled the frame until then.
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "height": 100}));
|
||||
assert_eq!(sizing(&s, v).1, Sizing::Fill);
|
||||
assert!((size(&s, v).1 - 100.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
set(&mut s, v, json!({"height": 40, "path": "M0 0 L20 5"}));
|
||||
assert_eq!(sizing(&s, v).1, Sizing::Fixed);
|
||||
assert!((size(&s, v).1 - 40.0).abs() < 1e-6, "{:?}", size(&s, v));
|
||||
// Rotated, the layer's local size isn't the room layout gives it: a height matching the
|
||||
// frame's stays fixed rather than have Fill squash the path.
|
||||
set(&mut s, v, json!({"rotation": 90}));
|
||||
set(&mut s, v, json!({"path": "M0 0 L20 5", "height": 100}));
|
||||
assert_eq!(sizing(&s, v).1, Sizing::Fixed);
|
||||
}
|
||||
|
||||
/// What one call's auto layout keys give, whichever way they are spelled: the mode (a string, or
|
||||
|
||||
@@ -318,3 +318,43 @@ fn fill_text_in_a_hugging_card_sets_the_height_in_one_step() {
|
||||
// Whatever the fonts, the card hugs the wrapped text exactly.
|
||||
assert!((cb.height() - (tb.height() + 20.0)).abs() < 1e-6, "card {cb:?} text {tb:?}");
|
||||
}
|
||||
|
||||
/// "To remove auto layout from a frame and all of its nested objects at once, right-click on the
|
||||
/// frame and go to More layout options > Remove all auto layout." (Toggle on auto layout in designs)
|
||||
#[test]
|
||||
fn remove_all_auto_layout_strips_the_frame_and_every_frame_nested_in_it() {
|
||||
let mut s = Session::new();
|
||||
let outer = frame(&mut s, None, 0.0, 0.0, 300.0, 100.0);
|
||||
let inner = frame(&mut s, Some(outer), 0.0, 0.0, 100.0, 50.0);
|
||||
let deep = frame(&mut s, Some(inner), 0.0, 0.0, 40.0, 20.0);
|
||||
rect(&mut s, Some(deep), 0.0, 0.0, 10.0, 10.0);
|
||||
for (f, dir) in [(outer, "horizontal"), (inner, "vertical"), (deep, "horizontal")] {
|
||||
s.execute("node.set", &json!({"id": f, "props": {"layout": dir}})).unwrap();
|
||||
}
|
||||
let laid_out = |s: &Session| [outer, inner, deep].map(|n| s.doc().unwrap().node(n).unwrap().layout.is_some());
|
||||
// Remove auto layout takes it off the frame alone.
|
||||
s.execute("layout.remove", &json!({"ids": [outer]})).unwrap();
|
||||
assert_eq!(laid_out(&s), [false, true, true]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(laid_out(&s), [true, true, true]);
|
||||
let steps = s.ds().unwrap().history.undo.len();
|
||||
let r = s.execute("layout.removeAll", &json!({"ids": [outer]})).unwrap();
|
||||
assert_eq!(laid_out(&s), [false, false, false], "every nested frame too");
|
||||
assert_eq!(r["removed"].as_array().map(Vec::len), Some(3));
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), steps + 1, "one undo step");
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,10 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool {
|
||||
/// main layer's, for files from before `modes` was an override group. Copies follow their main
|
||||
/// layer's modes, so a layer on other modes chose them itself.
|
||||
pub fn mark_layer_modes(doc: &mut Document) {
|
||||
// Without an instance nothing can be inside one: skip the per-layer ancestry lookups.
|
||||
if !doc.nodes().any(|n| matches!(n.kind, NodeKind::Instance { .. })) {
|
||||
return;
|
||||
}
|
||||
let chose: Vec<NodeId> = doc
|
||||
.nodes()
|
||||
.filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes))
|
||||
|
||||
@@ -132,6 +132,7 @@ fn text_style_differs(x: &TextData, y: &TextData) -> bool {
|
||||
|| x.hanging_list != y.hanging_list
|
||||
|| x.max_lines != y.max_lines
|
||||
|| x.features != y.features
|
||||
|| x.wrap_style != y.wrap_style
|
||||
}
|
||||
|
||||
/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was
|
||||
|
||||
@@ -160,6 +160,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) {
|
||||
b.hanging_list = a.hanging_list;
|
||||
b.max_lines = a.max_lines;
|
||||
b.features = a.features.clone();
|
||||
b.wrap_style = a.wrap_style;
|
||||
}
|
||||
to.styles.text = from.styles.text.clone();
|
||||
}
|
||||
|
||||
@@ -144,7 +144,6 @@ fn instances_refuse_structure_edits() {
|
||||
let before = s.doc().unwrap().clone();
|
||||
let undo = s.ds().unwrap().history.undo.len();
|
||||
let refused = [
|
||||
("edit.delete", json!({"ids": [ibg]})),
|
||||
("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5, "parent": i})),
|
||||
("node.reorder", json!({"id": idot, "index": 0})),
|
||||
("object.group", json!({"ids": [ibg, idot]})),
|
||||
@@ -158,6 +157,11 @@ fn instances_refuse_structure_edits() {
|
||||
assert_eq!(*s.doc().unwrap(), before, "{cmd} rolled back");
|
||||
}
|
||||
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
|
||||
// Delete on a layer inside hides it instead (an override), as the reference tool does.
|
||||
ex(&mut s, "edit.delete", json!({"ids": [ibg]}));
|
||||
assert!(s.doc().unwrap().node(ibg).is_some_and(|n| !n.visible), "hidden, not removed");
|
||||
assert_eq!(s.doc().unwrap().node(i).unwrap().children, before.node(i).unwrap().children, "the structure is intact");
|
||||
healthy(&s);
|
||||
// Deleting the whole instance is fine.
|
||||
ex(&mut s, "edit.delete", json!({"ids": [i]}));
|
||||
healthy(&s);
|
||||
|
||||
@@ -277,8 +277,16 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele
|
||||
fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>) -> BTreeSet<VariableId> {
|
||||
let mut out = BTreeSet::new();
|
||||
let mut stack: Vec<VariableId> = seeds.into_iter().collect();
|
||||
if stack.is_empty() {
|
||||
return out;
|
||||
}
|
||||
// Indexed once (the first of equal ids, as `get_by_id` finds it): a scan per step is quadratic on a long alias chain.
|
||||
let mut by_id = std::collections::HashMap::new();
|
||||
for v in &vars.variables {
|
||||
by_id.entry(v.id.as_str()).or_insert(v);
|
||||
}
|
||||
while let Some(cur) = stack.pop() {
|
||||
let Some(v) = vars.get_by_id(&cur) else { continue };
|
||||
let Some(v) = by_id.get(cur.as_str()).copied() else { continue };
|
||||
if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) {
|
||||
continue;
|
||||
}
|
||||
@@ -1344,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");
|
||||
|
||||
@@ -79,10 +79,13 @@ pub fn usage(doc: &Document, library: &str) -> Usage {
|
||||
let records: std::collections::BTreeMap<NodeId, NodeId> =
|
||||
doc.libraries.components.iter().filter(|r| r.library == library).map(|r| (r.root, r.key)).collect();
|
||||
if !records.is_empty() {
|
||||
// One component's asset root is looked up once, however many instances use it.
|
||||
let mut roots = std::collections::BTreeMap::<NodeId, Option<NodeId>>::new();
|
||||
for n in doc.nodes() {
|
||||
if let NodeKind::Instance { component, .. } = &n.kind
|
||||
&& store.is_none_or(|st| doc.page_of(n.id) != Some(st))
|
||||
&& let Some(key) = crate::library::asset_root(doc, *component).and_then(|r| records.get(&r))
|
||||
&& let Some(key) =
|
||||
roots.entry(*component).or_insert_with(|| crate::library::asset_root(doc, *component)).and_then(|r| records.get(&r))
|
||||
{
|
||||
let c = counts.entry(*key).or_default();
|
||||
*c = c.saturating_add(1);
|
||||
|
||||
@@ -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.
|
||||
@@ -113,7 +121,7 @@ fn resolved(vars: &Variables, st: &Style, modes: &Modes) -> StyleKind {
|
||||
(StyleKind::Grid { grids }, Some((BoundList::Grids, i, field)), VariableValue::Number(x)) => {
|
||||
crate::cmd::lists::set_grid_field(grids, i, field, *x);
|
||||
}
|
||||
(StyleKind::Text { style }, None, _) => set_text_field(style, key, &val),
|
||||
(StyleKind::Text { style, .. }, None, _) => set_text_field(style, key, &val),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -206,7 +214,7 @@ pub fn refresh(doc: &mut Document, before: Option<&Document>, only: Option<&BTre
|
||||
&& was.is_none_or(|w| w.kind == n.kind)
|
||||
{
|
||||
for st in doc.styles.iter().filter(|s| !s.bound.is_empty()) {
|
||||
if let StyleKind::Text { style } = kind_for(doc, st, id).as_ref() {
|
||||
if let StyleKind::Text { style, .. } = kind_for(doc, st, id).as_ref() {
|
||||
t.update_text_style(&st.id, style);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,14 +156,20 @@ impl BatchScope {
|
||||
lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))
|
||||
}
|
||||
|
||||
/// Most step names a missing-reference error lists.
|
||||
const NAME_HINTS: usize = 12;
|
||||
|
||||
fn known_names(&self) -> String {
|
||||
let mut names: Vec<_> = self.names.iter().collect();
|
||||
names.sort_by_key(|(_, i)| **i);
|
||||
if names.is_empty() {
|
||||
return "no step has an `as` name".into();
|
||||
}
|
||||
let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
|
||||
list.join(", ")
|
||||
// A few of them: the list is built for every missing reference and, with `onError:
|
||||
// continue`, kept for each, so a long table would cost its length per failed step.
|
||||
let list: Vec<_> = names.iter().take(Self::NAME_HINTS).map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
|
||||
let more = names.len().saturating_sub(Self::NAME_HINTS);
|
||||
if more > 0 { format!("{}, {more} more", list.join(", ")) } else { list.join(", ") }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ pub fn openable(url: &str) -> bool {
|
||||
if url.is_empty() || url.chars().any(char::is_control) {
|
||||
return false;
|
||||
}
|
||||
// Never valid in a URL unencoded, and read as syntax by the shells some openers go through
|
||||
// (webbrowser on WSL hands the address to `cmd.exe /c start`, escaping only `&` and `^`).
|
||||
if url.chars().any(|c| c.is_whitespace() || matches!(c, '|' | '<' | '>' | '"' | '`' | '\\' | '^')) {
|
||||
return false;
|
||||
}
|
||||
let Some((scheme, rest)) = url.split_once(':') else { return false };
|
||||
// An RFC 3986 scheme, so a stray colon in text cannot look like one.
|
||||
let mut chars = scheme.chars();
|
||||
@@ -44,5 +49,11 @@ pub fn openable(url: &str) -> bool {
|
||||
// `http` and `https` name a host, so the authority is required: `http:x` is not a web link,
|
||||
// and `http:///p` has no host.
|
||||
let Some(after) = rest.strip_prefix("//") else { return false };
|
||||
!after.is_empty() && !after.starts_with('/')
|
||||
if after.is_empty() || after.starts_with('/') {
|
||||
return false;
|
||||
}
|
||||
// The authority is made of host characters only (an opener that can't parse the address may
|
||||
// fall back to treating it as a file path).
|
||||
let authority = after.split(['/', '?', '#']).next().unwrap_or_default();
|
||||
authority.chars().all(|c| !c.is_ascii() || c.is_ascii_alphanumeric() || "-._~:@[]%!$&'()*+,;=".contains(c))
|
||||
}
|
||||
|
||||
@@ -277,6 +277,14 @@ pub fn read_capped(path: impl AsRef<Path>, max: u64) -> Result<Vec<u8>> {
|
||||
return Err(refuse(format!("is {}, more than the {} allowed here", size(meta.len()), size(max))));
|
||||
}
|
||||
let f = std::fs::File::open(p).map_err(io(path))?;
|
||||
// What was opened, not what was looked at: the path could have been swapped in between.
|
||||
let meta = f.metadata().map_err(io(path))?;
|
||||
if !meta.is_file() {
|
||||
return Err(refuse("isn't a regular file (a device or a pipe?), so it isn't read".into()));
|
||||
}
|
||||
if meta.len() > max {
|
||||
return Err(refuse(format!("is {}, more than the {} allowed here", size(meta.len()), size(max))));
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
|
||||
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(io(path))?;
|
||||
// It grew after it was looked at.
|
||||
@@ -292,6 +300,12 @@ pub fn open(path: impl AsRef<Path>) -> Result<std::fs::File> {
|
||||
std::fs::File::open(allow(path, Access::Read)?).map_err(io(path))
|
||||
}
|
||||
|
||||
/// What the file system says about `path` itself (a link is not followed).
|
||||
pub fn link_metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {
|
||||
let path = path.as_ref();
|
||||
std::fs::symlink_metadata(allow(path, Access::Read)?).map_err(io(path))
|
||||
}
|
||||
|
||||
/// What the file system says about `path` (links followed).
|
||||
pub fn metadata(path: impl AsRef<Path>) -> Result<std::fs::Metadata> {
|
||||
let path = path.as_ref();
|
||||
|
||||
@@ -53,6 +53,7 @@ pub const NODE_KEYS: &[&str] = &[
|
||||
"layout",
|
||||
"leadingTrim",
|
||||
"letterSpacing",
|
||||
"lineDirections",
|
||||
"lineHeight",
|
||||
"lineIndentations",
|
||||
"lineTypes",
|
||||
@@ -96,6 +97,7 @@ pub const NODE_KEYS: &[&str] = &[
|
||||
"textDecorationSkipInk",
|
||||
"textDecorationStyle",
|
||||
"textDecorationThickness",
|
||||
"textDirection",
|
||||
"textPath",
|
||||
"textStyleRanges",
|
||||
"type",
|
||||
@@ -103,6 +105,7 @@ pub const NODE_KEYS: &[&str] = &[
|
||||
"visible",
|
||||
"width",
|
||||
"widthProfile",
|
||||
"wrapStyle",
|
||||
"x",
|
||||
"y",
|
||||
];
|
||||
@@ -431,6 +434,16 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
|
||||
insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
// The paragraphs' base direction: one for the layer when they agree, else each one's (Auto
|
||||
// throughout says nothing).
|
||||
let dirs: Vec<figcraft_doc::TextDirection> = t.paragraphs.iter().map(|p| p.direction).collect();
|
||||
if let Some(first) = dirs.first().copied().filter(|d| *d != figcraft_doc::TextDirection::Auto)
|
||||
&& dirs.iter().all(|d| *d == first)
|
||||
{
|
||||
insert_prop(o, options, "textDirection", || json!(first));
|
||||
} else if dirs.iter().any(|d| *d != figcraft_doc::TextDirection::Auto) {
|
||||
insert_prop(o, options, "lineDirections", || dirs.iter().map(|d| json!(d)).collect());
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
insert_prop(o, options, "listSpacing", || json!(t.list_spacing));
|
||||
}
|
||||
@@ -442,6 +455,9 @@ fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &In
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
|
||||
}
|
||||
if t.wrap_style != figcraft_doc::WrapStyle::Auto {
|
||||
insert_prop(o, options, "wrapStyle", || json!(t.wrap_style));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
insert_prop(o, options, "styledRuns", || serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -104,3 +104,31 @@ fn the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_wer
|
||||
assert!(r.get("connector").is_none(), "{r}");
|
||||
assert!(matches!(node(&s, id(&r, "id")).board, Some(BoardItem::Sticky { .. })));
|
||||
}
|
||||
|
||||
/// `at` (#1919; "drag the preview to any location on your board", Build faster with quick create):
|
||||
/// the next one is centred on that point, wherever it is, and the connector takes the sides facing
|
||||
/// each other; `at` must be two finite numbers.
|
||||
#[test]
|
||||
fn the_next_one_goes_where_it_is_dropped() {
|
||||
let mut s = board();
|
||||
let a = id(&s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "width": 200, "height": 120})).unwrap(), "id");
|
||||
let r = s.execute("board.addConnected", &json!({"id": a, "side": "below", "at": [700, 60]})).unwrap();
|
||||
let (b, c) = (id(&r, "id"), id(&r, "connector"));
|
||||
assert_eq!(at(&s, b), (600.0, 0.0), "centred on the point");
|
||||
let k = connector(&s, c);
|
||||
assert_eq!(
|
||||
(k.from, k.to),
|
||||
(End::Node { node: a, side: Side::Bottom, at: None }, End::Node { node: b, side: Side::Auto, at: None }),
|
||||
"leaves by the handle's side"
|
||||
);
|
||||
// Inside a frame at (1000, 500): the page point lands in the frame's coordinates.
|
||||
let f = id(&s.execute("frame.create", &json!({"x": 1000, "y": 500, "width": 800, "height": 600})).unwrap(), "id");
|
||||
let inner = id(&s.execute("board.shape", &json!({"shape": "ellipse", "x": 0, "y": 0, "width": 200, "height": 120, "parent": f})).unwrap(), "id");
|
||||
let r = s.execute("board.addConnected", &json!({"id": inner, "at": [1300, 700]})).unwrap();
|
||||
let placed = id(&r, "id");
|
||||
assert_eq!(node(&s, placed).parent, Some(f), "beside it, in the frame");
|
||||
assert_eq!(at(&s, placed), (200.0, 140.0), "centred on the page point, in the frame's coordinates");
|
||||
for wrong in [json!([1]), json!("x"), json!([0, "y"]), json!({"x": 1, "y": 2})] {
|
||||
assert!(s.execute("board.addConnected", &json!({"id": a, "at": wrong})).is_err(), "{wrong}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -33,6 +33,36 @@ pub use connectors::{END_HANDLE_PX, selected_connector_ends};
|
||||
use connectors::{connector_cmd, reattach};
|
||||
pub use derived::route;
|
||||
pub(crate) use derived::{settle_routes, settle_stamps, settle_text, stamp_target};
|
||||
|
||||
/// The topmost sticky or shape at `p` on `page`, other than `except`, sections and frames looked
|
||||
/// into: what a "+" handle dragged onto it joins (#1919).
|
||||
pub fn sticky_or_shape_at(doc: &Document, page: NodeId, p: Point, except: Option<NodeId>) -> Option<NodeId> {
|
||||
fn find(doc: &Document, id: NodeId, p: Point, except: Option<NodeId>, depth: usize) -> Option<NodeId> {
|
||||
let n = doc.node(id)?;
|
||||
for c in n.children.iter().rev() {
|
||||
let Some(k) = doc.node(*c) else { continue };
|
||||
if !k.visible {
|
||||
continue;
|
||||
}
|
||||
match k.board {
|
||||
Some(BoardItem::Sticky { .. } | BoardItem::Shape { .. }) => {
|
||||
if Some(*c) != except && doc.world_bounds(*c).is_some_and(|b| b.contains(p)) {
|
||||
return Some(*c);
|
||||
}
|
||||
}
|
||||
// Not a board object of its own (a section, a frame): what is inside it may be.
|
||||
None if depth < 32 => {
|
||||
if let Some(hit) = find(doc, *c, p, except, depth + 1) {
|
||||
return Some(hit);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
find(doc, page, p, except, 0)
|
||||
}
|
||||
pub use edit::label_of;
|
||||
pub(crate) use edit::{copied_items, copy_map, rejoin};
|
||||
use edit::{label_cmd, set_color, set_style};
|
||||
@@ -114,7 +144,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"board.addConnected",
|
||||
"Add connected shape",
|
||||
"{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector",
|
||||
"{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), at? ([x, y] on the page: the next one's centre, anywhere, instead of its place on that side; the connector then leaves by `side` and arrives by the side facing it), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector",
|
||||
true,
|
||||
add_connected
|
||||
),
|
||||
|
||||
@@ -223,6 +223,19 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let color = color.unwrap_or(s.tool.board.sticky);
|
||||
let (to_at, from_side, to_side) = connected_place((at.x, at.y), (n.width, n.height), next, &side)
|
||||
.ok_or_else(|| bad(cmd, format!("`side` is right, below, left or above, not `{side}`")))?;
|
||||
// Dropped somewhere by hand ("drag the preview to any location on your board", #1919): the
|
||||
// next one is centred on that point of the page (placed in the parent's coordinates), and the
|
||||
// connector leaves by the handle's side and arrives by the side facing it.
|
||||
let (to_at, from_side, to_side) = match drop_point(cmd, p)? {
|
||||
Some((x, y)) => {
|
||||
let local = doc.world_transform(parent).inverse() * figcraft_geom::Point::new(x, y);
|
||||
if !(local.x.is_finite() && local.y.is_finite()) {
|
||||
return Err(bad(cmd, "`at` can't be placed in that parent"));
|
||||
}
|
||||
((local.x - next.0 / 2.0, local.y - next.1 / 2.0), from_side, Side::Auto)
|
||||
}
|
||||
None => (to_at, from_side, to_side),
|
||||
};
|
||||
let (routing, author, page) = (s.tool.board.routing, author(s), s.page()?);
|
||||
if editing.is_some() {
|
||||
textedit::end(s)?;
|
||||
@@ -246,6 +259,16 @@ pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": id, "text": text, "connector": line}))
|
||||
}
|
||||
|
||||
/// `at?` of `board.addConnected`: a point on the page, `[x, y]`, or none.
|
||||
fn drop_point(cmd: &str, p: &Value) -> Result<Option<(f64, f64)>> {
|
||||
let Some(v) = get(p, "at") else { return Ok(None) };
|
||||
let pair = v.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
match pair {
|
||||
Some((x, y)) if x.is_finite() && y.is_finite() => Ok(Some((x, y))),
|
||||
_ => Err(bad(cmd, "`at` is [x, y] on the page, two finite numbers")),
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape with text's layers: the frame, its outline and its text.
|
||||
fn make_shape(
|
||||
doc: &mut Document,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -107,3 +107,45 @@ fn the_pencil_records_pressure_and_its_brush() {
|
||||
let last = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Vector { .. })).max_by_key(|n| n.id).unwrap();
|
||||
assert!(last.stroke.profile().is_none() && last.stroke.brush.is_none());
|
||||
}
|
||||
|
||||
/// The Pencil draws black on a light page and white on a dark page or inside a dark frame, the
|
||||
/// rule new text follows (#1741; Figma: "in black, unless you're sketching on a dark canvas or
|
||||
/// frame"), and the stroke in progress previews in that colour.
|
||||
#[test]
|
||||
fn the_pencil_draws_white_on_a_dark_background() {
|
||||
use figcraft_color::Color;
|
||||
let mut s = Session::new();
|
||||
s.execute("tool.set", &json!({"tool": "pencil"})).unwrap();
|
||||
// A stroke from (x, y); its colour, and the preview colour the overlay carried while drawing.
|
||||
let stroke = |s: &mut Session, x: f64, y: f64| {
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, x + 40.0, y + 10.0, 1.0)).unwrap();
|
||||
let preview = s.tool.overlay.pen_color;
|
||||
s.pointer(&PointerEvent::at(PointerKind::Move, x + 80.0, y, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, x + 80.0, y, 1.0)).unwrap();
|
||||
assert!(s.tool.overlay.pen_color.is_none(), "no preview colour after the stroke");
|
||||
let doc = s.doc().unwrap();
|
||||
let last = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Vector { .. })).max_by_key(|n| n.id).unwrap();
|
||||
(last.strokes.first().and_then(|p| p.solid_color()).unwrap(), preview)
|
||||
};
|
||||
assert_eq!(stroke(&mut s, 0.0, 0.0), (Color::BLACK, Some(Color::BLACK)), "a light page");
|
||||
s.execute("page.setBackground", &json!({"color": "#1E1E1E"})).unwrap();
|
||||
assert_eq!(stroke(&mut s, 0.0, 100.0), (Color::WHITE, Some(Color::WHITE)), "a dark page");
|
||||
// A dark frame on a light page, and a light frame on a dark page.
|
||||
s.execute("page.setBackground", &json!({"color": "#F5F5F5"})).unwrap();
|
||||
s.execute("node.create", &json!({"type": "frame", "x": 300, "y": 300, "width": 200, "height": 200, "props": {"fills": ["#202020"]}})).unwrap();
|
||||
assert_eq!(stroke(&mut s, 320.0, 320.0), (Color::WHITE, Some(Color::WHITE)), "inside a dark frame");
|
||||
s.execute("page.setBackground", &json!({"color": "#1E1E1E"})).unwrap();
|
||||
s.execute("node.create", &json!({"type": "frame", "x": 600, "y": 300, "width": 200, "height": 200, "props": {"fills": ["#FFFFFF"]}})).unwrap();
|
||||
assert_eq!(stroke(&mut s, 620.0, 320.0), (Color::BLACK, Some(Color::BLACK)), "inside a light frame on a dark page");
|
||||
// A frame without a fill, or with a see-through one, is looked through to what is behind it.
|
||||
s.execute("node.create", &json!({"type": "frame", "x": 900, "y": 300, "width": 200, "height": 200, "props": {"fills": []}})).unwrap();
|
||||
assert_eq!(stroke(&mut s, 920.0, 320.0), (Color::WHITE, Some(Color::WHITE)), "a frame without a fill on a dark page");
|
||||
s.execute("node.create", &json!({"type": "frame", "x": 1200, "y": 300, "width": 200, "height": 200, "props": {"fills": ["#FFFFFF40"]}})).unwrap();
|
||||
assert_eq!(stroke(&mut s, 1220.0, 320.0), (Color::WHITE, Some(Color::WHITE)), "a mostly transparent light frame on a dark page");
|
||||
// The threshold is Color::is_dark: white reads better than black from #757575 down.
|
||||
for (bg, want) in [("#767676", Color::BLACK), ("#757575", Color::WHITE)] {
|
||||
s.execute("page.setBackground", &json!({"color": bg})).unwrap();
|
||||
assert_eq!(stroke(&mut s, 0.0, 600.0).0, want, "on {bg}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +109,9 @@ pub fn eligible(doc: &Document, ids: &[NodeId]) -> Vec<NodeId> {
|
||||
out.push(id);
|
||||
}
|
||||
}
|
||||
let ids: Vec<NodeId> = ids.iter().copied().filter(|i| doc.contains(*i)).collect();
|
||||
// Each id counts once: a repeated group must not be walked once per repeat.
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let ids: Vec<NodeId> = ids.iter().copied().filter(|i| doc.contains(*i) && seen.insert(*i)).collect();
|
||||
let mut out = Vec::new();
|
||||
for id in roots(doc, &ids) {
|
||||
walk(doc, id, 0, &mut out);
|
||||
@@ -132,8 +134,8 @@ fn world_area(doc: &Document, part: Part) -> Option<(BezPath, bool)> {
|
||||
}
|
||||
match (&n.kind, part.region) {
|
||||
(NodeKind::Vector { network }, Some(i)) => {
|
||||
let rounded = network.rounded();
|
||||
let net = rounded.as_ref().unwrap_or(network);
|
||||
let drawn = figcraft_doc::geometry::drawn_network(network, n.corners.smoothing);
|
||||
let net = drawn.as_ref();
|
||||
let region = net.regions.get(i)?;
|
||||
let path = net.region_path(i)?;
|
||||
(path, if region.winding == WindingRule::EvenOdd { figcraft_render::FillRule::EvenOdd } else { figcraft_render::FillRule::Winding })
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -277,14 +284,15 @@ fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInse
|
||||
let text = normalized;
|
||||
edit(s, |t, te| {
|
||||
let (a, b) = range(te);
|
||||
// "- ", "* " or "1. " typed at the start of a plain paragraph starts a list.
|
||||
// "- ", "* ", "1. " or "1) " typed at the start of a plain paragraph starts a list (the
|
||||
// creation characters of Create bulleted and numbered lists).
|
||||
if text == " " && a == b && t.para_list(t.para_of(a)).list == ListStyle::None {
|
||||
let chars: Vec<char> = t.characters.chars().collect();
|
||||
let start = line_start(&chars, a);
|
||||
let typed: String = chars.get(start..a).unwrap_or(&[]).iter().collect();
|
||||
let list = match typed.as_str() {
|
||||
"-" | "*" => ListStyle::Bulleted,
|
||||
"1." => ListStyle::Numbered,
|
||||
"1." | "1)" => ListStyle::Numbered,
|
||||
_ => ListStyle::None,
|
||||
};
|
||||
if list != ListStyle::None {
|
||||
@@ -302,9 +310,21 @@ fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInse
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// List keys: Tab nests the selected list items a level deeper; Shift+Tab, Backspace at an item's
|
||||
/// start and Enter on an empty item move it a level out (at the top: out of the list). Returns
|
||||
/// whether the key was taken.
|
||||
/// What a list key does to the selected items.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum ListMove {
|
||||
/// A level deeper (Tab).
|
||||
Deeper,
|
||||
/// A level out; at the top, out of the list (Shift+Tab, Enter on an empty item).
|
||||
Out,
|
||||
/// Out of the list at the level it had: "Use Backspace or Delete at the beginning of a list
|
||||
/// item to delete the counter, but keep the same level of indentation."
|
||||
Unlist,
|
||||
}
|
||||
|
||||
/// List keys: Tab nests the selected list items a level deeper; Shift+Tab and Enter on an empty
|
||||
/// item move it a level out (at the top: out of the list); Backspace and Delete at an item's start
|
||||
/// take its marker and keep its level. Returns whether the key was taken.
|
||||
fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
|
||||
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
|
||||
let t = text_of(s, te.id)?;
|
||||
@@ -314,19 +334,24 @@ fn list_key(s: &mut Session, key: &str, shift: bool) -> Result<bool> {
|
||||
let item = t.para_list(t.para_of(a)).list != ListStyle::None;
|
||||
let (pa, pb) = t.para_span(a, b);
|
||||
let any_item = (pa..=pb).any(|p| t.para_list(p).list != ListStyle::None);
|
||||
let deeper = match key {
|
||||
"Tab" if any_item => !shift,
|
||||
"Backspace" if item && at_start => false,
|
||||
"Enter" if item && at_start && chars.get(a).is_none_or(|c| *c == '\n') => false,
|
||||
let mv = match key {
|
||||
"Tab" if any_item && !shift => ListMove::Deeper,
|
||||
"Tab" if any_item => ListMove::Out,
|
||||
"Backspace" | "Delete" if item && at_start => ListMove::Unlist,
|
||||
"Enter" if item && at_start && chars.get(a).is_none_or(|c| *c == '\n') => ListMove::Out,
|
||||
_ => return Ok(false),
|
||||
};
|
||||
edit(s, |t, _| {
|
||||
t.set_para_lists(a, b, |pl| match pl.list {
|
||||
ListStyle::None => {}
|
||||
_ if deeper => pl.indent = pl.indent.saturating_add(1),
|
||||
_ if pl.indent > 0 => pl.indent -= 1,
|
||||
_ => pl.list = ListStyle::None,
|
||||
})
|
||||
if mv == ListMove::Unlist {
|
||||
t.unlist_keeping_levels(a, b);
|
||||
} else {
|
||||
t.set_para_lists(a, b, |pl| match (pl.list, mv) {
|
||||
(ListStyle::None, _) | (_, ListMove::Unlist) => {}
|
||||
(_, ListMove::Deeper) => pl.indent = pl.indent.saturating_add(1),
|
||||
(_, ListMove::Out) if pl.indent > 0 => pl.indent -= 1,
|
||||
(_, ListMove::Out) => pl.list = ListStyle::None,
|
||||
});
|
||||
}
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
@@ -410,7 +435,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R
|
||||
let t = text_of(s, te.id)?;
|
||||
let chars: Vec<char> = t.characters.chars().collect();
|
||||
let n = chars.len();
|
||||
if matches!(key, "Tab" | "Backspace" | "Enter") && !word && !line && list_key(s, key, shift)? {
|
||||
if matches!(key, "Tab" | "Backspace" | "Delete" | "Enter") && !word && !line && list_key(s, key, shift)? {
|
||||
return Ok(());
|
||||
}
|
||||
match key {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user