mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-10 03:01:45 +00:00
Merge upstream/main into fix/text-mixed-fill-section
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
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
|
||||
@@ -19,7 +19,14 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
|
||||
credits:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
|
||||
release:
|
||||
needs: credits
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
@@ -33,6 +40,7 @@ jobs:
|
||||
# crates/text/build.rs keeps the web allowlist small (Inter and Georgian).
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
|
||||
@@ -1,3 +1,56 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
|
||||
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
|
||||
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
|
||||
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
|
||||
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
|
||||
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
|
||||
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
|
||||
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
|
||||
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
|
||||
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
|
||||
10. **Keep secrets and personal details out** of the repository, logs and requests.
|
||||
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
|
||||
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
|
||||
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
|
||||
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
|
||||
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
|
||||
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
|
||||
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
|
||||
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
|
||||
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
|
||||
|
||||
## Licence allowlist
|
||||
|
||||
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
|
||||
|
||||
- `ISC`
|
||||
- `MIT`
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
- `CC0-1.0`
|
||||
- `CC-BY-4.0`
|
||||
- `CC-BY-SA-4.0`
|
||||
- `LicenseRef-Public-Domain`
|
||||
|
||||
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
|
||||
<!-- craft-house-rules:end -->
|
||||
|
||||
# FigCraft — instructions for agents
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust (egui) reimplementation of the Figma design workflow:
|
||||
@@ -12,11 +65,10 @@ Repos don't share code.
|
||||
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, estimates) and
|
||||
[`docs/gaps.md`](docs/gaps.md) (the ranked work list). Unless the user gives you a task, pick
|
||||
work from the top of `docs/gaps.md`.
|
||||
2. Read `plan/STATUS.md` and `plan/execution-plan.md` (local, gitignored) if present; behaviour
|
||||
reference notes are in `plan/figma/*.md`.
|
||||
3. Orient → plan → implement + test → verify (screenshot) → record (ROADMAP, devlog) → commit.
|
||||
2. Orient → plan → implement + test → verify (screenshot) → record (ROADMAP, devlog) → commit.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
`plan/` is an optional gitignored notebook. Behaviour reference notes may be in `plan/figma/*.md`.
|
||||
Do not ask for it, and do not stop when it is absent.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
@@ -59,6 +111,9 @@ Repos don't share code.
|
||||
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
|
||||
(workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
|
||||
data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
|
||||
- New workspace crates include `[lints] workspace = true` in `Cargo.toml`. Production crate roots
|
||||
declare the crate-level lint attributes before modules or other crate items; crate-level `//!`
|
||||
documentation may precede them.
|
||||
- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
|
||||
is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
|
||||
safety nets, not substitutes for `Result`.
|
||||
@@ -78,13 +133,11 @@ Repos don't share code.
|
||||
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
### Quality gates (before every commit)
|
||||
`cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). One task per commit with a
|
||||
milestone id in the message (`M2.1: auto layout wrap`). Commit after every landed arc of work; push
|
||||
to `main`.
|
||||
milestone id in the message (`M2.1: auto layout wrap`). Commit after every landed arc of work.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run -p figcraft -- --control <port> [file.figcraft|file.svg|image]`. Pick a free port
|
||||
@@ -98,7 +151,6 @@ to `main`.
|
||||
incumbent side by side where the owner allows (`plan/figma/`).
|
||||
- Headless: `figcraft-cli` (`info`, `export`, `run`, `commands`, `mcp`). MCP: `figcraft-cli mcp`
|
||||
(see `docs/mcp.md`).
|
||||
- Shell gotcha on the owner's machine: `mv`/`cp` may be aliased interactive — use `/bin/mv -f`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent, edit only the crates/files you own,
|
||||
write manifests atomically, delete your target dir when done (disk is limited).
|
||||
|
||||
|
||||
@@ -1,133 +1 @@
|
||||
# FigCraft — instructions for agents
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust (egui) reimplementation of the Figma design workflow:
|
||||
interface design, auto layout, components and variants, styles and variables, prototyping and
|
||||
inspection. It runs natively on macOS, Windows, Linux and FreeBSD, and in the browser via WASM.
|
||||
Siblings: `../vectorcraft` (Illustrator-class), `../photocraft` (Photoshop-class), `../designcraft`
|
||||
(InDesign-class) and the other Crafting Apps; same conventions.
|
||||
|
||||
Repos don't share code.
|
||||
|
||||
## Start every session here
|
||||
1. Read [`ROADMAP.md`](ROADMAP.md) (stage, headline numbers, estimates) and
|
||||
[`docs/gaps.md`](docs/gaps.md) (the ranked work list). Unless the user gives you a task, pick
|
||||
work from the top of `docs/gaps.md`.
|
||||
2. Read `plan/STATUS.md` and `plan/execution-plan.md` (local, gitignored) if present; behaviour
|
||||
reference notes are in `plan/figma/*.md`.
|
||||
3. Orient → plan → implement + test → verify (screenshot) → record (ROADMAP, devlog) → commit.
|
||||
|
||||
`plan/` is gitignored (local only).
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
### Clean-room
|
||||
- Behaviour comes from public documentation and black-box observation of the running incumbent app
|
||||
with synthetic documents only. Never read, disassemble or copy anything inside the incumbent's
|
||||
application bundle, web bundle or network traffic (names and public listings only).
|
||||
- Never copy GPL/AGPL code.
|
||||
- Reference screenshots of the incumbent stay local under the gitignored `plan/figma/screenshots/`
|
||||
and are never committed, published or used as assets.
|
||||
|
||||
### Assets: no third-party product iconography or images — ever (absolute rule from the project owner)
|
||||
- **Never add, copy, trace, redraw-from, embed or ship any icon, image, illustration, cursor, logo,
|
||||
template, UI kit, preset library, font file or screenshot from Figma, Adobe, Avid, Autodesk or any
|
||||
other product, or from any source whose licence doesn't allow it.** This includes "lookalike"
|
||||
redraws of their icons. Breaking this rule is the worst thing you can do in this repo.
|
||||
- Every image, icon, font or other asset must be one of:
|
||||
- original work created for FigCraft by a contributor (licensed MIT OR Apache-2.0);
|
||||
- OSI open source;
|
||||
- public domain / CC0;
|
||||
- Creative Commons with redistribution allowed.
|
||||
- **Every asset file must have a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (path, author, source,
|
||||
licence, notes). `cargo xtask assets` (part of `cargo xtask ci`) fails on any unlisted file under
|
||||
`assets/`, `docs/brand/` or `docs/images/`.
|
||||
- FigCraft's UI icons are drawn in code (`crates/ui-egui/src/icons.rs`) from simple primitives and
|
||||
are original. Keep it that way: new icons are new code, designed from scratch.
|
||||
- The app icon is the owner's chameleon drawing, vectorised (`assets/app-icon/README.md`); `cargo xtask icon`
|
||||
renders the PNGs and `.ico` from `assets/app-icon/figcraft.svg`.
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
|
||||
repo.** The app uses it only through the optional `CRAFT_FONTS_DIR` build input
|
||||
(`.cargo/config.toml` points it at `../craft-fonts` for local builds; `crates/text/build.rs`
|
||||
embeds it). Everything must build, test and run without it (system fonts are scanned at runtime).
|
||||
- Demo and test content is synthetic or public domain; never personal photos or data.
|
||||
- Code and original assets are MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, `NOTICE`). The
|
||||
ArtCraft logos in `docs/brand/` are trademarks, not open source, under
|
||||
`docs/brand/LICENSE-brand.txt`: never modify them or use them outside FigCraft.
|
||||
- If the provenance of an asset is unclear, don't add it.
|
||||
|
||||
### Never crash
|
||||
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
|
||||
(workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
|
||||
data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
|
||||
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
|
||||
is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
|
||||
safety nets, not substitutes for `Result`.
|
||||
- `unsafe_code = "forbid"` workspace-wide.
|
||||
- Every crash fix comes with a regression test. The engine has a junk-parameter sweep over every
|
||||
command (`crates/engine/src/tests.rs`): extend fuzz/sweep tests when you add parsers or commands.
|
||||
|
||||
### Architecture
|
||||
- **Everything is a command.** User-visible behaviour = a command in `crates/engine/src/cmd/*`
|
||||
(`spec!(id, label, params doc, undoable, run)`) + tests. The menus, shortcuts, Actions palette,
|
||||
CLI, control channel and MCP server all dispatch the same command ids. Command ids follow the
|
||||
incumbent's menu catalog in `crates/engine/src/catalog.rs` (the parity metric: `cargo xtask parity`).
|
||||
- **Programmatic calls never open dialogs.** Commands only *request* UI (`Session::request_ui`); the
|
||||
control channel drops dialog requests.
|
||||
- **Layering** is enforced by `cargo xtask layers`: geom/color (L0) → doc (L1) → layout/text/pathops/plugin/collab
|
||||
(L2) → render/format (L3) → svg/pdf (L4) → engine (L5) → ui-egui/mcp (L6) → apps. Nothing below
|
||||
L6 depends on egui/eframe/rfd/winit. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` /
|
||||
`app.set_props(ids, props)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
### Quality gates (before every commit)
|
||||
`cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). One task per commit with a
|
||||
milestone id in the message (`M2.1: auto layout wrap`). Commit after every landed arc of work; push
|
||||
to `main`.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run -p figcraft -- --control <port> [file.figcraft|file.svg|image]`. Pick a free port
|
||||
(`python3 -c 'import socket;s=socket.socket();s.bind(("127.0.0.1",0));print(s.getsockname()[1])'`);
|
||||
sibling apps use the control channel too.
|
||||
- Drive it with JSON lines, e.g.
|
||||
`{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}`
|
||||
then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods are documented
|
||||
in `crates/ui-egui/src/control.rs` and `docs/control-protocol.md`.
|
||||
- **For UI work, look at the result** (take a screenshot and read the PNG). Compare with the
|
||||
incumbent side by side where the owner allows (`plan/figma/`).
|
||||
- Headless: `figcraft-cli` (`info`, `export`, `run`, `commands`, `mcp`). MCP: `figcraft-cli mcp`
|
||||
(see `docs/mcp.md`).
|
||||
- Shell gotcha on the owner's machine: `mv`/`cp` may be aliased interactive — use `/bin/mv -f`.
|
||||
- Parallel agents: separate `CARGO_TARGET_DIR` per agent, edit only the crates/files you own,
|
||||
write manifests atomically, delete your target dir when done (disk is limited).
|
||||
|
||||
## Roadmap
|
||||
Progress docs: [`ROADMAP.md`](ROADMAP.md) (one-page
|
||||
summary and stage), [`docs/target-app-parity.md`](docs/target-app-parity.md) (the authoritative
|
||||
assessment), [`docs/gaps.md`](docs/gaps.md) (ranked work list), [`docs/roadmap.md`](docs/roadmap.md)
|
||||
(milestones), [`docs/architecture.md`](docs/architecture.md), the per-area `docs/*-parity.md` files
|
||||
and the generated [`docs/parity-checklist.md`](docs/parity-checklist.md) (`cargo xtask parity`).
|
||||
When a task lands, update the affected rows and the status line and revision history in the same
|
||||
commit. Grade by behaviour against the incumbent, not by whether a menu item exists; err low.
|
||||
|
||||
## Contributor credits (About window)
|
||||
|
||||
- About ▸ Contributors are compiled into the binary from `contributors/contributors.json`
|
||||
(commit stats; never hand-edit) and `contributors/people.toml` (names people chose for
|
||||
themselves).
|
||||
- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
@AGENTS.md
|
||||
|
||||
+6
-1
File diff suppressed because one or more lines are too long
@@ -77,6 +77,7 @@ fn root(name: &str) -> PathBuf {
|
||||
r
|
||||
}
|
||||
|
||||
/// What the scripted link says about the session it is in.
|
||||
fn info() -> SessionInfo {
|
||||
SessionInfo {
|
||||
role: Role::Guest,
|
||||
@@ -90,6 +91,7 @@ fn info() -> SessionInfo {
|
||||
address: SERVER.into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
view: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,3 +104,11 @@ real_name = "Oleksii Sukholita"
|
||||
|
||||
[people.helenkwok]
|
||||
real_name = "Helen Kwok"
|
||||
|
||||
[people.denisg0mes]
|
||||
real_name = "Denis Gomes"
|
||||
display_name = "Denis Gomes"
|
||||
|
||||
[people.Hamza0101]
|
||||
real_name = "Hamza Naveed"
|
||||
display_name = "Hamza0101"
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Join codes over the network: a file shared on the local network joined by its code (two
|
||||
//! engine sessions over loopback), what the browser's driver does with an online code, a server
|
||||
//! Join codes over the network: a file shared on the local network joined by its code, and by
|
||||
//! its view-only code to look without editing (engine sessions over loopback), what the browser's driver does with an online code, a server
|
||||
//! older than join codes, and the links that name a code. (A `figcraft-server` handing codes out
|
||||
//! and resolving them is tested in its own crate, with the same link.)
|
||||
|
||||
use std::net::{Ipv4Addr, TcpListener};
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpListener};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -18,8 +18,9 @@ use crate::driver::{Driver, Socket};
|
||||
use crate::guest::GuestConfig;
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::{Endpoint, Invite, Lookup};
|
||||
use crate::link::reachable;
|
||||
use crate::loopback_tests::{fast_limits, quick};
|
||||
use crate::{NetLink, manage};
|
||||
use crate::{NetLink, lan_ip, manage};
|
||||
|
||||
fn session(name: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
@@ -53,6 +54,7 @@ fn layers(s: &Session) -> usize {
|
||||
s.doc().map_or(0, |d| d.node_count())
|
||||
}
|
||||
|
||||
/// A local network share's code joins its session from another app, and edits sync both ways.
|
||||
#[test]
|
||||
fn a_local_share_is_joined_by_its_code_and_edits_sync() {
|
||||
let (mut host, mut guest) = (session("Hana"), session("Ada"));
|
||||
@@ -65,7 +67,13 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() {
|
||||
assert_eq!(local.ip, Ipv4Addr::LOCALHOST);
|
||||
assert_eq!(shared["address"], format!("127.0.0.1:{}", local.port));
|
||||
assert_eq!(shared["link"], local.link(), "the code and the invite link name the same session");
|
||||
assert!(shared["share"]["viewCode"].is_null(), "everyone edits on the local network");
|
||||
// A view-only code too, with its own secret, and no comment code.
|
||||
let view = shared["share"]["viewCode"].as_str().unwrap();
|
||||
let Code::Local(viewing) = code::parse(view).unwrap() else { panic!("not a local code") };
|
||||
assert_eq!((viewing.ip, viewing.port), (local.ip, local.port), "the same session");
|
||||
assert_ne!(viewing.secret, local.secret, "a secret of its own");
|
||||
assert_eq!(shared["share"]["viewLink"], viewing.link());
|
||||
assert!(shared["share"]["commentCode"].is_null());
|
||||
// Typed the way people type: lower case, spaces for the dashes.
|
||||
let typed = shown.to_lowercase().replace('-', " ");
|
||||
assert_eq!(guest.execute("collab.join", &json!({"code": typed})).unwrap()["status"], "connecting");
|
||||
@@ -93,6 +101,8 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() {
|
||||
assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}");
|
||||
}
|
||||
|
||||
/// A well-formed code with the wrong secret is refused by the host, a mistyped one never connects,
|
||||
/// and there is no comment code on the local network.
|
||||
#[test]
|
||||
fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
|
||||
let (mut host, mut guest) = (session("Hana"), session("Ada"));
|
||||
@@ -112,11 +122,120 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
|
||||
*last = if *last == 'A' { 'B' } else { 'A' };
|
||||
let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::<String>()})).unwrap_err().to_string();
|
||||
assert!(e.contains("typing mistake"), "{e}");
|
||||
// A view-only code doesn't exist here, and says where it does.
|
||||
let e = host.execute("collab.share", &json!({"access": "view"})).unwrap_err().to_string();
|
||||
// A comment code doesn't exist here, and says where it does.
|
||||
let e = host.execute("collab.share", &json!({"access": "comment"})).unwrap_err().to_string();
|
||||
assert!(e.contains("share it online"), "{e}");
|
||||
}
|
||||
|
||||
/// A local network share started for looking only shows its view code: whoever joins with it sees
|
||||
/// every change and can't make one, the edit code (picked later) still edits, and stopping ends
|
||||
/// both.
|
||||
#[test]
|
||||
fn a_local_view_code_lets_people_look_and_not_edit() {
|
||||
let (mut host, mut viewer, mut editor) = (session("Hana"), session("Ada"), session("Bo"));
|
||||
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local", "access": "view"})).unwrap();
|
||||
assert_eq!(shared["share"]["access"], "view");
|
||||
let (shown, edit) = (shared["code"].as_str().unwrap().to_string(), shared["share"]["editCode"].as_str().unwrap().to_string());
|
||||
assert_eq!(shared["share"]["viewCode"], shown, "the code shown is the view code");
|
||||
assert_ne!(shown, edit);
|
||||
viewer.execute("collab.join", &json!({"code": shown})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer], "the viewer's join", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
let summary = viewer.live_summary();
|
||||
assert_eq!((&summary["access"], &summary["readOnly"]), (&json!("view"), &json!(true)));
|
||||
// The host's edit reaches the viewer; the viewer's is refused before it leaves.
|
||||
let before = layers(&host);
|
||||
host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer], "the host's edit", |s| layers(s[1]) == before + 1);
|
||||
let e = viewer.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap_err().to_string();
|
||||
assert!(e.contains("View only"), "{e}");
|
||||
// The edit code, picked now, still edits, and the viewer sees it.
|
||||
assert_eq!(host.execute("collab.share", &json!({"access": "edit"})).unwrap()["code"], edit);
|
||||
editor.execute("collab.join", &json!({"code": edit})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer, &mut editor], "the editor's join", |s| live(s[2]) && s[0].live().peers.len() == 2);
|
||||
assert_eq!(editor.live_summary()["access"], "edit");
|
||||
editor.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer, &mut editor], "the editor's edit", |s| layers(s[0]) == before + 2 && layers(s[1]) == before + 2);
|
||||
assert_eq!(viewer.live_summary()["readOnly"], true, "picking the edit code changes nothing for who joined to look");
|
||||
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
|
||||
pump(&mut [&mut host, &mut viewer, &mut editor], "the end", |s| !s[1].live().status.active() && !s[2].live().status.active());
|
||||
}
|
||||
|
||||
/// A local network share's `link` and `url` are the ones that go with the code shown: started for
|
||||
/// looking only, whoever joins through either can't edit, and the session's line in `sessions`
|
||||
/// says the same.
|
||||
#[test]
|
||||
fn a_local_view_share_s_link_and_url_only_let_people_look() {
|
||||
let mut host = session("Hana");
|
||||
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "local", "access": "view"})).unwrap();
|
||||
for key in ["link", "url"] {
|
||||
let way = shared[key].as_str().unwrap().to_string();
|
||||
let mut viewer = session("Ada");
|
||||
viewer.execute("collab.join", &json!({"link": way})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer], "the join", |s| live(s[1]) && s[0].live().peers.len() == 1);
|
||||
let summary = viewer.live_summary();
|
||||
assert_eq!((&summary["access"], &summary["readOnly"]), (&json!("view"), &json!(true)), "joined with {key} {way}");
|
||||
let before = layers(&host);
|
||||
let e = viewer.execute("shape.ellipse", &json!({"x": 80, "y": 10, "width": 40, "height": 30})).unwrap_err().to_string();
|
||||
assert!(e.contains("View only"), "{key}: {e}");
|
||||
host.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer], "the host's edit", |s| layers(s[1]) == before + 1);
|
||||
assert_eq!(layers(&host), before + 1, "nothing of the viewer's reached the host ({key})");
|
||||
viewer.execute("collab.leave", &json!({})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer], "the leave", |s| s[0].live().peers.is_empty());
|
||||
}
|
||||
// The link, the URL and the code name the same way in, wherever the session is listed.
|
||||
let Code::Local(local) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
|
||||
assert_eq!((&shared["link"], &shared["share"]["viewLink"]), (&json!(local.link()), &json!(local.link())));
|
||||
let url = shared["url"].as_str().unwrap();
|
||||
assert!(url.starts_with("ws://") && url.ends_with(&format!("&token={}", local.token())), "{url}");
|
||||
assert_eq!(shared["sessions"][0]["link"], shared["link"]);
|
||||
// Picking the edit code makes them the edit ones too.
|
||||
let edit = host.execute("collab.share", &json!({"access": "edit"})).unwrap();
|
||||
assert_eq!((&edit["link"], &edit["sessions"][0]["link"]), (&edit["share"]["editLink"], &edit["share"]["editLink"]));
|
||||
let edit_token = edit["link"].as_str().unwrap().rsplit("token=").next().unwrap();
|
||||
assert!(edit["url"].as_str().unwrap().ends_with(&format!("&token={edit_token}")), "{}", edit["url"]);
|
||||
assert_ne!((&edit["link"], &edit["url"]), (&shared["link"], &shared["url"]));
|
||||
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
|
||||
}
|
||||
|
||||
/// A share listening on every interface (`bind` 0.0.0.0) names where others reach it, never
|
||||
/// 0.0.0.0: this machine's local network address (loopback without one) in its codes, links, URL
|
||||
/// and address, and its view code and edit link both join it.
|
||||
#[test]
|
||||
fn a_share_on_every_interface_names_an_address_others_reach() {
|
||||
let (mut host, mut viewer, mut editor) = (session("Hana"), session("Ada"), session("Bo"));
|
||||
let shared = host.execute("collab.share", &json!({"via": "local", "bind": "0.0.0.0", "access": "view"})).unwrap();
|
||||
let Code::Local(viewing) = code::parse(shared["code"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
|
||||
let at = lan_ip().unwrap_or(Ipv4Addr::LOCALHOST.into());
|
||||
assert_eq!(IpAddr::from(viewing.ip), at);
|
||||
let address = format!("{at}:{}", viewing.port);
|
||||
assert_eq!(shared["address"], address);
|
||||
let Code::Local(editing) = code::parse(shared["share"]["editCode"].as_str().unwrap()).unwrap() else { panic!("not a local code") };
|
||||
assert_eq!((editing.ip, editing.port), (viewing.ip, viewing.port));
|
||||
for way in [&shared["link"], &shared["url"], &shared["share"]["viewLink"], &shared["share"]["editLink"], &shared["sessions"][0]["link"]] {
|
||||
let way = way.as_str().unwrap_or_default();
|
||||
assert!(way.contains(&address) && !way.contains("0.0.0.0"), "{way}");
|
||||
}
|
||||
viewer.execute("collab.join", &json!({"code": shared["code"]})).unwrap();
|
||||
editor.execute("collab.join", &json!({"link": shared["share"]["editLink"]})).unwrap();
|
||||
pump(&mut [&mut host, &mut viewer, &mut editor], "the joins", |s| live(s[1]) && live(s[2]) && s[0].live().peers.len() == 2);
|
||||
assert_eq!((&viewer.live_summary()["access"], &editor.live_summary()["access"]), (&json!("view"), &json!("edit")));
|
||||
assert_eq!(host.execute("collab.stop", &json!({})).unwrap()["ended"], true);
|
||||
}
|
||||
|
||||
/// A listener on one address is reached at it; one on every interface at an address of its own
|
||||
/// family, on its port.
|
||||
#[test]
|
||||
fn a_listener_on_every_interface_is_reached_at_an_address_of_its_family() {
|
||||
let one: SocketAddr = "192.168.1.20:47900".parse().unwrap();
|
||||
assert_eq!(reachable(one), one);
|
||||
for any in ["0.0.0.0:47900", "[::]:47901"] {
|
||||
let any: SocketAddr = any.parse().unwrap();
|
||||
let at = reachable(any);
|
||||
assert!(!at.ip().is_unspecified() && at.is_ipv4() == any.is_ipv4() && at.port() == any.port(), "{any}: {at}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An invite naming only `server` (what a join code is asked at).
|
||||
fn code_at(server: &Endpoint) -> Invite {
|
||||
Invite { server: server.clone(), file: String::new(), token: String::new() }
|
||||
|
||||
@@ -391,7 +391,7 @@ impl<S: Socket> Driver<S> {
|
||||
match after(outcome, &self.cfg, self.was_live, &mut self.attempt, &self.inbox) {
|
||||
Next::Stop(e) => {
|
||||
if let Some(e) = e {
|
||||
self.inbox.push(e);
|
||||
self.inbox.push(*e);
|
||||
}
|
||||
self.done = true;
|
||||
}
|
||||
|
||||
@@ -419,6 +419,8 @@ fn pump(d: &mut Driver<Loopback>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser's driver joins a real host over loopback, edits with it, sends presence and hears
|
||||
/// the session end.
|
||||
#[test]
|
||||
fn joins_a_real_host_and_edits_with_it() {
|
||||
let host_probe = Inbox::new(Arc::new(|| {}));
|
||||
@@ -428,6 +430,7 @@ fn joins_a_real_host_and_edits_with_it() {
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
view_token: None,
|
||||
doc: shared(),
|
||||
name: "Hana".into(),
|
||||
limits: fast_limits(),
|
||||
|
||||
@@ -67,7 +67,7 @@ impl Guest {
|
||||
});
|
||||
if let Err(o) = asked.and_then(|(file, token)| resolved(&mut cfg, file, token)) {
|
||||
if let Next::Stop(Some(e)) = after(unresolved(&cfg, o), &cfg, false, &mut 0, &inbox) {
|
||||
inbox.push(e);
|
||||
inbox.push(*e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -217,7 +217,7 @@ fn supervise(cfg: &GuestConfig, inbox: &Inbox, rx: &Receiver<Cmd>, me: &Sender<C
|
||||
match after(outcome, cfg, was_live, &mut attempt, inbox) {
|
||||
Next::Stop(e) => {
|
||||
if let Some(e) = e {
|
||||
inbox.push(e);
|
||||
inbox.push(*e);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The session host the app embeds: the shared [hub](crate::hub) serving one file, the one the
|
||||
//! app shares, with the session's single token. The person hosting is in it without a socket:
|
||||
//! their presence goes in with [`Host::presence`], their edits with [`Host::send`], what others do
|
||||
//! comes out through their [`Inbox`]. When the host stops, the session ends for everyone.
|
||||
//! app shares, with the session's edit token and, for a share that has one, its view token. The
|
||||
//! person hosting is in it without a socket: their presence goes in with [`Host::presence`], their
|
||||
//! edits with [`Host::send`], what others do comes out through their [`Inbox`]. When the host
|
||||
//! stops, the session ends for everyone.
|
||||
|
||||
use std::io;
|
||||
use std::net::{SocketAddr, TcpListener};
|
||||
@@ -26,6 +27,9 @@ pub struct HostConfig {
|
||||
/// The file's session id and secret (both in the invite).
|
||||
pub file: String,
|
||||
pub token: String,
|
||||
/// A second secret that lets its holders look and not edit (`None`: everyone who gets in
|
||||
/// edits). It must differ from `token`.
|
||||
pub view_token: Option<String>,
|
||||
/// The shared document (the session's authoritative copy starts from it).
|
||||
pub doc: Document,
|
||||
/// The host's own name (empty: "Host").
|
||||
@@ -40,21 +44,28 @@ pub struct Host {
|
||||
me: Peer,
|
||||
}
|
||||
|
||||
/// The app's one file and its token: everyone with the invite edits.
|
||||
/// The app's one file and its tokens: whoever has the edit token edits, whoever has the view
|
||||
/// token only looks (the server refuses their changes).
|
||||
struct OneFile {
|
||||
file: String,
|
||||
token: String,
|
||||
view: Option<String>,
|
||||
}
|
||||
|
||||
impl Backend for OneFile {
|
||||
/// Let in a hello for `file` with `token`, with what its token allows.
|
||||
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
|
||||
if file != self.file {
|
||||
return Err("this invite is for another file or an earlier session".into());
|
||||
}
|
||||
if !same_secret(token, &self.token) {
|
||||
return Err("wrong session token".into());
|
||||
// Both tokens are compared, whichever matches, so the time taken doesn't tell them apart.
|
||||
let edit = same_secret(token, &self.token);
|
||||
let view = self.view.as_deref().is_some_and(|v| same_secret(token, v));
|
||||
match (edit, view) {
|
||||
(true, _) => Ok(Admission { access: Access::Edit, link: None }),
|
||||
(false, true) => Ok(Admission { access: Access::View, link: None }),
|
||||
(false, false) => Err("wrong session token".into()),
|
||||
}
|
||||
Ok(Admission { access: Access::Edit, link: None })
|
||||
}
|
||||
|
||||
fn open(&mut self, _: &str) -> Result<Server, String> {
|
||||
@@ -69,6 +80,10 @@ impl Backend for OneFile {
|
||||
impl Host {
|
||||
/// Listen and start the hub; the host joins its own session as the first person.
|
||||
pub fn start(cfg: HostConfig, inbox: Inbox) -> io::Result<Host> {
|
||||
// A view token equal to the edit token would let its holders edit: nobody gets in with it.
|
||||
if cfg.view_token.as_deref().is_some_and(|v| same_secret(v, &cfg.token)) {
|
||||
return Err(io::Error::other("the view-only secret is the same as the edit secret"));
|
||||
}
|
||||
let listener = match TcpListener::bind(cfg.addr) {
|
||||
Ok(l) => l,
|
||||
Err(e) if e.kind() == io::ErrorKind::AddrInUse && cfg.any_port_if_taken => TcpListener::bind(SocketAddr::new(cfg.addr.ip(), 0))?,
|
||||
@@ -99,7 +114,7 @@ impl Host {
|
||||
}
|
||||
}
|
||||
let me = server.peers().find(|p| p.client_id == me).cloned().ok_or_else(|| io::Error::other("the host didn't join its own session"))?;
|
||||
let backend = OneFile { file: cfg.file.clone(), token: cfg.token };
|
||||
let backend = OneFile { file: cfg.file.clone(), token: cfg.token, view: cfg.view_token };
|
||||
let local = Local { file: cfg.file, server, id: me.client_id, inbox };
|
||||
let hosting = Hosting::start(listener, backend, cfg.limits, Some(local))?;
|
||||
Ok(Host { hosting, me })
|
||||
|
||||
@@ -137,8 +137,9 @@ pub(crate) fn closed(code: u16, reason: &str) -> Outcome {
|
||||
|
||||
/// What a guest does after a connection ended.
|
||||
pub(crate) enum Next {
|
||||
/// Done: tell the UI this (nothing when you left).
|
||||
Stop(Option<Event>),
|
||||
/// Done: tell the UI this (nothing when you left). Boxed: an event can be large (a welcome),
|
||||
/// and this one is only ever a status or the end of the session.
|
||||
Stop(Option<Box<Event>>),
|
||||
/// Connect again after this pause (the UI has been told: `Reconnecting`).
|
||||
Reconnect(Duration),
|
||||
}
|
||||
@@ -147,12 +148,12 @@ pub(crate) enum Next {
|
||||
/// can't reach the host; a live connection that dropped tries again with growing pauses until the
|
||||
/// attempts run out.
|
||||
pub(crate) fn after(outcome: Outcome, cfg: &GuestConfig, was_live: bool, attempt: &mut u32, inbox: &Inbox) -> Next {
|
||||
let failed = |reason: String| Next::Stop(Some(Event::Status(Status::Failed { reason })));
|
||||
let failed = |reason: String| Next::Stop(Some(Box::new(Event::Status(Status::Failed { reason }))));
|
||||
match outcome {
|
||||
Outcome::Left => Next::Stop(None),
|
||||
Outcome::Refused(why) => failed(format!("The host refused: {why}")),
|
||||
Outcome::Failed(why) => failed(why),
|
||||
Outcome::Ended(reason) => Next::Stop(Some(Event::Ended { reason })),
|
||||
Outcome::Ended(reason) => Next::Stop(Some(Box::new(Event::Ended { reason }))),
|
||||
Outcome::Retry(why) if !was_live => failed(format!("Couldn't join the live session: {why}")),
|
||||
Outcome::Retry(why) => {
|
||||
*attempt += 1;
|
||||
@@ -228,6 +229,7 @@ pub(crate) fn take(mut m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mu
|
||||
address: inv.server.display(),
|
||||
network: !inv.loopback(),
|
||||
access,
|
||||
view: None,
|
||||
};
|
||||
seen.peers = peers.clone();
|
||||
seen.info = Some(info.clone());
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! [`NetLink`]: the engine's [`LiveLink`] over the network.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr, UdpSocket};
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
@@ -9,7 +9,7 @@ use figcraft_collab::code;
|
||||
use figcraft_collab::message::{Access, Admin, AdminReply, ClientMsg, LibraryReply, LibraryReq, PluginReply, PluginReq};
|
||||
use figcraft_engine::live::{
|
||||
Bind, Event, HostInfo, HostOptions, LiveLink, NO_SESSION, NetReply, NetRequest, PageInfo, Presence, PublishOptions, Published, Resume, Role,
|
||||
SessionId, SessionInfo, Shared,
|
||||
SessionId, SessionInfo, Shared, WayIn,
|
||||
};
|
||||
|
||||
use crate::guest::{Guest, GuestConfig, Publish, upload_file};
|
||||
@@ -97,24 +97,62 @@ impl NetLink {
|
||||
}
|
||||
}
|
||||
|
||||
/// A new secret for a hosted session: one a join code can carry (`short`: 44 bits, see
|
||||
/// `figcraft_collab::code`), else a full one.
|
||||
fn session_secret(short: bool) -> Result<String, String> {
|
||||
match short {
|
||||
true => Ok(code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token()),
|
||||
false => token::secret(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A view-only secret for a session whose edit secret is `edit`: of the same kind, never the same.
|
||||
fn view_secret(short: bool, edit: &str) -> Result<String, String> {
|
||||
for _ in 0..3 {
|
||||
let view = session_secret(short)?;
|
||||
if view != edit {
|
||||
return Ok(view);
|
||||
}
|
||||
}
|
||||
Err("couldn't make a view-only secret".into())
|
||||
}
|
||||
|
||||
/// This machine's address on the local network: the interface the default route leaves by (no
|
||||
/// packet is sent).
|
||||
pub fn lan_ip() -> Option<IpAddr> {
|
||||
let sock = UdpSocket::bind(SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0))).ok()?;
|
||||
// Documentation and private ranges: only the route matters.
|
||||
for probe in ["192.0.2.1:9", "10.255.255.255:9"] {
|
||||
if sock.connect(probe).is_ok()
|
||||
&& let Ok(a) = sock.local_addr()
|
||||
&& !a.ip().is_loopback()
|
||||
&& !a.ip().is_unspecified()
|
||||
{
|
||||
return Some(a.ip());
|
||||
}
|
||||
}
|
||||
None
|
||||
route_ip(Ipv4Addr::UNSPECIFIED.into(), &["192.0.2.1:9", "10.255.255.255:9"])
|
||||
}
|
||||
|
||||
/// The address of `any`'s family that the route to the first of `probes` that has one leaves by
|
||||
/// (no packet is sent): never a loopback, unspecified or IPv6 link-local one, which others can't
|
||||
/// reach this machine at.
|
||||
fn route_ip(any: IpAddr, probes: &[&str]) -> Option<IpAddr> {
|
||||
let sock = UdpSocket::bind(SocketAddr::new(any, 0)).ok()?;
|
||||
probes.iter().find_map(|probe| {
|
||||
sock.connect(probe).ok()?;
|
||||
let ip = sock.local_addr().ok()?.ip();
|
||||
let link_local = matches!(ip, IpAddr::V6(v6) if v6.is_unicast_link_local());
|
||||
(!ip.is_loopback() && !ip.is_unspecified() && !link_local).then_some(ip)
|
||||
})
|
||||
}
|
||||
|
||||
/// Where others reach a session listening on `addr`: `addr` itself, or for a listener on every
|
||||
/// interface (`0.0.0.0`, `::`) this machine's local network address of the same family (its
|
||||
/// loopback without one), on the same port. Links name it; the listener keeps every interface.
|
||||
pub(crate) fn reachable(addr: SocketAddr) -> SocketAddr {
|
||||
let ip = match addr.ip() {
|
||||
ip if !ip.is_unspecified() => ip,
|
||||
IpAddr::V4(_) => lan_ip().unwrap_or(Ipv4Addr::LOCALHOST.into()),
|
||||
// Documentation and unique local ranges, as for IPv4.
|
||||
IpAddr::V6(_) => route_ip(Ipv6Addr::UNSPECIFIED.into(), &["[2001:db8::1]:9", "[fd00::1]:9"]).unwrap_or(Ipv6Addr::LOCALHOST.into()),
|
||||
};
|
||||
SocketAddr::new(ip, addr.port())
|
||||
}
|
||||
|
||||
impl LiveLink for NetLink {
|
||||
/// Host the open file on `o.bind`: a join code's short secret when `o.short`, and a second,
|
||||
/// view-only secret with its invite link when `o.view`.
|
||||
fn host(&mut self, session: SessionId, o: HostOptions) -> Result<SessionInfo, String> {
|
||||
let ip = match o.bind {
|
||||
Bind::Local => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
@@ -123,11 +161,10 @@ impl LiveLink for NetLink {
|
||||
};
|
||||
// A session shared with a join code: its secret is short enough to be typed with the
|
||||
// address (see `figcraft_collab::code`); the code and the invite link name the same session.
|
||||
let (file, token) = if o.short {
|
||||
(code::LOCAL_FILE.to_string(), code::Local::new(Ipv4Addr::UNSPECIFIED, 0, token::random()?).token())
|
||||
} else {
|
||||
(token::file_id()?, token::secret()?)
|
||||
};
|
||||
let file = if o.short { code::LOCAL_FILE.to_string() } else { token::file_id()? };
|
||||
let token = session_secret(o.short)?;
|
||||
// A way in for looking only: a second secret of the same kind, never the edit one.
|
||||
let view_token = o.view.then(|| view_secret(o.short, &token)).transpose()?;
|
||||
let inbox = self.sessions.inbox();
|
||||
let doc = (*o.doc).clone();
|
||||
let pages = doc.pages.iter().map(|p| PageInfo { id: p.0, name: doc.node(*p).map(|n| n.name.clone()).unwrap_or_default() }).collect();
|
||||
@@ -137,13 +174,17 @@ impl LiveLink for NetLink {
|
||||
any_port_if_taken: o.port.is_none(),
|
||||
file: file.clone(),
|
||||
token: token.clone(),
|
||||
view_token: view_token.clone(),
|
||||
doc,
|
||||
name: o.name,
|
||||
limits: self.limits,
|
||||
core: figcraft_collab::Limits::default(),
|
||||
};
|
||||
let host = Host::start(cfg, inbox.clone()).map_err(|e| format!("can't listen on {ip}: {e}"))?;
|
||||
let invite = Invite::new(host.addr().to_string(), file, token);
|
||||
// Listening on every interface (`0.0.0.0`, `::`), the links name an address others reach.
|
||||
let at = reachable(host.addr()).to_string();
|
||||
let view = view_token.map(|v| Invite::new(at.clone(), file.clone(), v)).map(|v| WayIn { link: v.link(), url: v.url() });
|
||||
let invite = Invite::new(at.clone(), file, token);
|
||||
let info = SessionInfo {
|
||||
role: Role::Host,
|
||||
me: host.me().client_id,
|
||||
@@ -153,9 +194,10 @@ impl LiveLink for NetLink {
|
||||
pages,
|
||||
link: invite.link(),
|
||||
url: invite.url(),
|
||||
address: host.addr().to_string(),
|
||||
address: at,
|
||||
network: !ip.is_loopback(),
|
||||
access: Access::Edit,
|
||||
view,
|
||||
};
|
||||
self.started(session, Active::Host(host), inbox);
|
||||
Ok(info)
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::net::{SocketAddr, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use figcraft_collab::message::{self, CatchUp, ClientMsg, Hello, Presence, ServerMsg, Signal};
|
||||
use figcraft_collab::message::{self, Access, CatchUp, ClientMsg, Hello, Presence, ServerMsg, Signal};
|
||||
use figcraft_collab::{Client, Limits};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN};
|
||||
@@ -78,6 +78,7 @@ pub(crate) fn fast_limits() -> NetLimits {
|
||||
NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() }
|
||||
}
|
||||
|
||||
/// A host on a free loopback port with these limits, a probe of what it is told, and its invite.
|
||||
fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) {
|
||||
let probe = Probe::new();
|
||||
let mut doc = Document::new("Shared file");
|
||||
@@ -87,6 +88,7 @@ fn start_with(limits: NetLimits, core: Limits) -> (Host, Probe, Invite) {
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
view_token: None,
|
||||
doc,
|
||||
name: "Hana".into(),
|
||||
limits,
|
||||
@@ -214,6 +216,7 @@ fn guests_join_and_see_each_other() {
|
||||
assert_eq!(host.connections(), 0);
|
||||
}
|
||||
|
||||
/// A guest with the wrong token or for another file is refused, and the secret never shows.
|
||||
#[test]
|
||||
fn a_wrong_token_or_file_is_refused() {
|
||||
let (mut host, mut hp, invite) = start();
|
||||
@@ -236,6 +239,57 @@ fn a_wrong_token_or_file_is_refused() {
|
||||
host.stop();
|
||||
}
|
||||
|
||||
/// Join `addr` with `token` over a bare socket and make an edit: what the welcome allowed, and
|
||||
/// whether the host took the edit (`Changes`) or rejected it (`Reject`).
|
||||
fn join_and_edit(addr: SocketAddr, token: &str) -> (Access, ServerMsg) {
|
||||
let mut raw = Raw::hello(addr, token);
|
||||
let mut welcome = std::iter::from_fn(|| raw.next()).find(|m| matches!(m, ServerMsg::Welcome { .. })).expect("a welcome");
|
||||
message::unpack(&mut welcome, 64 << 20).unwrap();
|
||||
let ServerMsg::Welcome { access, .. } = welcome else { panic!("not a welcome: {welcome:?}") };
|
||||
let mut client = Client::join(welcome).unwrap();
|
||||
let mut doc = client.document().clone();
|
||||
doc.add_page("Mine");
|
||||
assert!(raw.send(&client.edit(&doc).unwrap().unwrap()));
|
||||
let answer = std::iter::from_fn(|| raw.next()).find(|m| matches!(m, ServerMsg::Changes(_) | ServerMsg::Reject { .. })).expect("an answer");
|
||||
(access, answer)
|
||||
}
|
||||
|
||||
/// A session hosted with a view token as well (a local network share's view-only code): whoever
|
||||
/// joins with it is welcomed as a viewer and their edits are rejected, the edit token still edits,
|
||||
/// any other token is still refused, and a view token equal to the edit token isn't hosted.
|
||||
#[test]
|
||||
fn a_view_token_lets_people_look_and_not_edit() {
|
||||
const VIEW: &str = "ViewOnlyViewOnlyView00";
|
||||
let probe = Probe::new();
|
||||
let cfg = |view: &str| {
|
||||
let mut doc = Document::new("Shared file");
|
||||
doc.repair();
|
||||
HostConfig {
|
||||
addr: SocketAddr::from(([127, 0, 0, 1], 0)),
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
view_token: Some(view.into()),
|
||||
doc,
|
||||
name: "Hana".into(),
|
||||
limits: fast_limits(),
|
||||
core: Limits::default(),
|
||||
}
|
||||
};
|
||||
let same = Host::start(cfg(TOKEN), probe.inbox.clone()).err().expect("a view token equal to the edit token is refused");
|
||||
assert!(same.to_string().contains("view-only"), "{same}");
|
||||
let mut host = Host::start(cfg(VIEW), probe.inbox.clone()).unwrap();
|
||||
let (access, answer) = join_and_edit(host.addr(), VIEW);
|
||||
assert_eq!(access, Access::View);
|
||||
assert!(matches!(&answer, ServerMsg::Reject { reason, .. } if reason.contains("view")), "{answer:?}");
|
||||
let (access, answer) = join_and_edit(host.addr(), TOKEN);
|
||||
assert_eq!(access, Access::Edit);
|
||||
assert!(matches!(answer, ServerMsg::Changes(_)), "{answer:?}");
|
||||
let refusal = Raw::hello(host.addr(), "WrongWrongWrongWrong00").refusal();
|
||||
assert!(refusal.as_deref().is_some_and(|r| r.contains("token")), "{refusal:?}");
|
||||
host.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreachable_hosts_fail_at_once() {
|
||||
// A port nothing listens on.
|
||||
@@ -677,6 +731,7 @@ fn the_host_removes_someone_and_they_cant_come_back() {
|
||||
assert_eq!(host.connections(), 0);
|
||||
}
|
||||
|
||||
/// A welcome larger than the guest takes says the file is too large to join.
|
||||
#[test]
|
||||
fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
|
||||
let mut doc = Document::new("B".repeat(1 << 20));
|
||||
@@ -687,6 +742,7 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
view_token: None,
|
||||
doc,
|
||||
name: "Hana".into(),
|
||||
limits: fast_limits(),
|
||||
|
||||
@@ -662,7 +662,10 @@ pub struct Glass {
|
||||
pub dispersion: f64,
|
||||
/// Blur of the backdrop (a blur radius, document units, 0–[`Self::MAX_FROST`]).
|
||||
pub frost: f64,
|
||||
/// How widely the light spreads over the glass (0–1).
|
||||
/// Which way the curved edge bends the backdrop: towards the shape's core (0; along the edge's
|
||||
/// normal for a shape too big to work one out) to away from the centre of the layer's bounds
|
||||
/// (1; on a ring or an L, whose centre lies outside the shape, an inner edge bends towards the
|
||||
/// hole, as the reference tool's does).
|
||||
pub splay: f64,
|
||||
}
|
||||
|
||||
|
||||
@@ -345,3 +345,16 @@ fn remove_all_auto_layout_strips_the_frame_and_every_frame_nested_in_it() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(laid_out(&s), [true, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reorder_rejects_negative_and_fractional_indexes() {
|
||||
let mut s = Session::new();
|
||||
let f = frame(&mut s, None, 0.0, 0.0, 200.0, 200.0);
|
||||
let a = rect(&mut s, Some(f), 0.0, 0.0, 10.0, 10.0);
|
||||
let b = rect(&mut s, Some(f), 0.0, 0.0, 10.0, 10.0);
|
||||
let c = rect(&mut s, Some(f), 0.0, 0.0, 10.0, 10.0);
|
||||
for bad in [json!(-1), json!(0.5), json!("x")] {
|
||||
assert!(s.execute("node.reorder", &json!({"id": c, "index": bad})).is_err(), "{bad} accepted");
|
||||
assert_eq!(kids(&s, f), vec![a, b, c]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1352,6 +1352,15 @@ pub fn library_of(doc: &Document, id: NodeId) -> Option<&str> {
|
||||
doc.libraries.import_of(root).map(|r| r.library.as_str())
|
||||
}
|
||||
|
||||
pub fn library_source(doc: &Document, id: NodeId) -> Option<(&str, NodeId)> {
|
||||
if !doc.is_remote(id) {
|
||||
return None;
|
||||
}
|
||||
let record = doc.libraries.import_of(asset_root(doc, id)?)?;
|
||||
let key = record.ids.iter().find(|(_, local)| **local == id).map_or(record.key, |(key, _)| *key);
|
||||
Some((record.library.as_str(), key))
|
||||
}
|
||||
|
||||
/// The display name of library `id` in `doc` (its link's name, else the id).
|
||||
pub fn library_name<'a>(doc: &'a Document, id: &'a str) -> &'a str {
|
||||
doc.libraries.link(id).map_or(id, |l| l.name.as_str())
|
||||
|
||||
@@ -189,8 +189,6 @@ fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) {
|
||||
let style = s.doc().unwrap().styles[0].id.clone();
|
||||
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
||||
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
||||
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
|
||||
assert!(e.to_string().contains("library"), "{e}");
|
||||
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
|
||||
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
|
||||
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
|
||||
@@ -399,6 +397,75 @@ fn add_place_and_update_keep_overrides() {
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
fn go_to_main(s: &mut Session, id: NodeId) -> Value {
|
||||
s.active = 0;
|
||||
ex(s, "component.goToMain", json!({"id": id}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn going_to_a_library_main_opens_the_library_file() {
|
||||
let d = dir("go-to-main");
|
||||
let lib_path = path_str(&d.join("kit.figcraft"));
|
||||
let mut lib = Session::new();
|
||||
let k = kit(&mut lib);
|
||||
let mut chips = Vec::new();
|
||||
for (i, color) in ["#ff0000", "#00ff00"].iter().enumerate() {
|
||||
let f = idv(&ex(&mut lib, "frame.create", json!({"x": 400.0 + i as f64 * 100.0, "y": 0, "width": 60, "height": 20, "name": "Chip"})));
|
||||
ex(&mut lib, "node.set", json!({"id": f, "props": {"fill": color}}));
|
||||
chips.push(idv(&ex(&mut lib, "component.create", json!({"ids": [f]}))));
|
||||
}
|
||||
ex(&mut lib, "component.combineVariants", json!({"ids": chips}));
|
||||
let kit_page = lib.doc().unwrap().page_of(k.card).unwrap();
|
||||
ex(&mut lib, "page.add", json!({"name": "Notes"}));
|
||||
let lid = publish_to(&mut lib, &lib_path);
|
||||
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "library.add", json!({"path": lib_path}));
|
||||
let card = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Card", "x": 0, "y": 0})));
|
||||
let nested_button = node(&s, card).children[0];
|
||||
let chip = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Chip", "x": 0, "y": 200})));
|
||||
ex(&mut s, "instance.setProperties", json!({"ids": [chip], "properties": {"Property 1": "Variant2"}}));
|
||||
s.ds_mut().unwrap().view.vw = 900.0;
|
||||
s.ds_mut().unwrap().view.vh = 500.0;
|
||||
let using = s.doc().unwrap().clone();
|
||||
|
||||
let r = go_to_main(&mut s, card);
|
||||
assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(k.card), Some(1)), "{r}");
|
||||
let ds = s.ds().unwrap();
|
||||
assert_eq!(ds.path.as_deref(), Some(lib_path.as_str()));
|
||||
assert_eq!((ds.selection.clone(), ds.page), (vec![k.card], kit_page));
|
||||
assert_eq!((ds.view.vw, ds.view.vh), (900.0, 500.0), "the canvas it opens on is the one shown, so the fit stands");
|
||||
let shown = ds.doc.world_bounds(k.card).unwrap().center();
|
||||
assert_eq!((ds.view.cx, ds.view.cy), (shown.x, shown.y));
|
||||
|
||||
for (layer, main) in [(nested_button, k.button), (chip, chips[1])] {
|
||||
let r = go_to_main(&mut s, layer);
|
||||
assert_eq!((id_of(&r["id"]), r["index"].as_u64()), (Some(main), Some(1)), "{r}");
|
||||
assert_eq!(s.ds().unwrap().selection, vec![main]);
|
||||
}
|
||||
assert_eq!(s.docs.len(), 2, "the open library file comes to the front");
|
||||
assert_eq!(s.docs[0].doc.as_ref(), &using, "the file going there is unchanged");
|
||||
|
||||
s.active = 1;
|
||||
ex(&mut s, "edit.delete", json!({"ids": [k.card]}));
|
||||
let r = go_to_main(&mut s, card);
|
||||
assert!(r["id"].is_null() && r["index"] == 1, "{r}");
|
||||
assert!(s.message.take().is_some_and(|m| m.contains("isn't in this file any more")));
|
||||
|
||||
ex(&mut s, "file.close", json!({"index": 1, "force": true}));
|
||||
std::fs::rename(&lib_path, d.join("moved.figcraft")).unwrap();
|
||||
s.active = 0;
|
||||
let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err();
|
||||
assert!(e.to_string().contains("isn't at"), "{e}");
|
||||
assert_eq!(s.docs.len(), 1);
|
||||
|
||||
let NodeKind::Instance { component: copy, .. } = node(&s, card).kind else { panic!("not an instance") };
|
||||
s.doc_mut().unwrap().libraries_mut().components.retain(|r| r.root != copy);
|
||||
let e = s.execute("component.goToMain", &json!({"id": card})).unwrap_err();
|
||||
assert!(e.to_string().contains("no link to its library"), "an imported copy without its record: {e}");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_styles_apply_and_import_with_pages() {
|
||||
let d = dir("styles");
|
||||
|
||||
@@ -184,14 +184,21 @@ fn edit_if(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit) -> bool
|
||||
let doc = ds.doc_mut();
|
||||
let n = doc.get_mut(te.id)?;
|
||||
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) };
|
||||
let derived_before = crate::cmd::props::text_layer_name(&t.characters);
|
||||
if !f(t, &mut te) {
|
||||
return Ok(false);
|
||||
}
|
||||
// Any edit ends a composition (a commit replaces it with the final text).
|
||||
te.preedit = None;
|
||||
let len = t.char_count();
|
||||
let derived_after = crate::cmd::props::text_layer_name(&t.characters);
|
||||
te.caret = te.caret.min(len);
|
||||
te.anchor = te.anchor.min(len);
|
||||
// The layer's name follows the characters while it is the name they derive, so the Layers
|
||||
// panel keeps up with what the canvas shows; a name the author typed is left alone.
|
||||
if crate::cmd::props::text_name_is_derived(&n.name, derived_before.as_ref()) {
|
||||
n.name = derived_after.unwrap_or_default();
|
||||
}
|
||||
if n.source.is_some() {
|
||||
n.overrides.insert("characters".into());
|
||||
}
|
||||
|
||||
@@ -218,15 +218,16 @@ impl Session {
|
||||
// Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping.
|
||||
let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl;
|
||||
let mut guides = Vec::new();
|
||||
if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) {
|
||||
// Keep the box's origin on whole pixels (a rotated parent frame's, inside one);
|
||||
// snapping to layers comes after, so it wins on the axes it snaps.
|
||||
d = targets.round_move(d);
|
||||
}
|
||||
if snap_on {
|
||||
let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6));
|
||||
d += c;
|
||||
guides = g2;
|
||||
}
|
||||
if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) {
|
||||
// Keep the box's origin on whole pixels (a rotated parent frame's, inside one).
|
||||
d = targets.round_move(d);
|
||||
}
|
||||
let ids = ids.clone();
|
||||
let dup = *duplicate;
|
||||
let page = self.page()?;
|
||||
@@ -475,20 +476,21 @@ impl Session {
|
||||
let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None };
|
||||
let mut p = ev.p();
|
||||
let mut guides = Vec::new();
|
||||
let _ = start;
|
||||
// In the handle box's own axes (one layer: its local space).
|
||||
let (frame, w0, h0) = r.frame;
|
||||
// The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool
|
||||
// scales by the factor as dragged). Snapping to layers comes after, so it wins on the axes
|
||||
// it snaps.
|
||||
if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) {
|
||||
p = Point::new(p.x.round(), p.y.round());
|
||||
}
|
||||
if let Some(t) = &targets {
|
||||
let (dx, dy, g) = t.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6));
|
||||
p.x += dx;
|
||||
p.y += dy;
|
||||
guides = g;
|
||||
}
|
||||
let _ = start;
|
||||
// In the handle box's own axes (one layer: its local space).
|
||||
let (frame, w0, h0) = r.frame;
|
||||
// The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool
|
||||
// scales by the factor as dragged).
|
||||
if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) {
|
||||
p = Point::new(p.x.round(), p.y.round());
|
||||
}
|
||||
let q = frame.inverse() * p;
|
||||
let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false);
|
||||
if scale_tool {
|
||||
|
||||
@@ -29,20 +29,23 @@ impl Session {
|
||||
let tool = self.tool.tool;
|
||||
let mut p = ev.p();
|
||||
let mut guides = Vec::new();
|
||||
let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true);
|
||||
// The pixel grid first: snapping to layers comes after, so it wins on the axes it snaps.
|
||||
if pixel {
|
||||
p = Point::new(p.x.round(), p.y.round());
|
||||
}
|
||||
if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl {
|
||||
let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6));
|
||||
p.x += dx;
|
||||
p.y += dy;
|
||||
guides = g;
|
||||
}
|
||||
let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true);
|
||||
let mut created = id;
|
||||
let page = self.page()?;
|
||||
self.preview(|doc, sel| {
|
||||
let (mut a, mut b) = (start, p);
|
||||
let (mut a, b) = (start, p);
|
||||
if pixel {
|
||||
a = Point::new(a.x.round(), a.y.round());
|
||||
b = Point::new(b.x.round(), b.y.round());
|
||||
}
|
||||
let pw = doc.world_transform(parent);
|
||||
let (kind, base) = match tool {
|
||||
|
||||
@@ -186,28 +186,42 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
true,
|
||||
reset_override
|
||||
),
|
||||
spec!("component.goToMain", "Go to main component", "{id?}", false, |s, p| {
|
||||
let cmd = "component.goToMain";
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
|
||||
let doc = s.doc()?;
|
||||
let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?;
|
||||
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else {
|
||||
return Err(bad(cmd, "not an instance"));
|
||||
};
|
||||
if !doc.contains(component) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
|
||||
spec!(
|
||||
"component.goToMain",
|
||||
"Go to main component",
|
||||
"{id?} selects the instance's main component; a library component's main is in its library file, which opens in a tab (or comes to the front) with the main selected. Returns {id} (the main in the file now shown), and `index` (the tab) when it went to a library file",
|
||||
false,
|
||||
|s, p| {
|
||||
let cmd = "component.goToMain";
|
||||
let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?;
|
||||
let doc = s.doc()?;
|
||||
let inst = owning_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?;
|
||||
let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else {
|
||||
return Err(bad(cmd, "not an instance"));
|
||||
};
|
||||
if !doc.contains(component) {
|
||||
return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into()));
|
||||
}
|
||||
if let Some((library, key)) = crate::library::library_source(doc, component) {
|
||||
let name = crate::library::library_name(doc, library).to_string();
|
||||
let library = library.to_string();
|
||||
return go_to_library_main(s, &library, &name, key);
|
||||
}
|
||||
if doc.is_remote(component) {
|
||||
return Err(EngineError::Disabled(
|
||||
cmd.into(),
|
||||
"the main component is a library copy with no link to its library (it came with an imported file): enable that library to reach it"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
super::edit::select_and_page(s, vec![component])?;
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.world_bounds(component) {
|
||||
ds.view.fit(b, 2.0);
|
||||
}
|
||||
Ok(json!({"id": component}))
|
||||
}
|
||||
if let Some(lib) = crate::library::library_of(doc, component) {
|
||||
let name = crate::library::library_name(doc, lib);
|
||||
return Err(EngineError::Disabled(cmd.into(), format!("the main component is in the library \"{name}\": open that file to edit it")));
|
||||
}
|
||||
super::edit::select_and_page(s, vec![component])?;
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.world_bounds(component) {
|
||||
ds.view.fit(b, 2.0);
|
||||
}
|
||||
Ok(json!({"id": component}))
|
||||
}),
|
||||
),
|
||||
spec!("component.pushChanges", "Push changes to main component", "{id?}", true, push_changes),
|
||||
spec!(
|
||||
"component.restoreMain",
|
||||
@@ -283,6 +297,43 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn go_to_library_main(s: &mut Session, library: &str, name: &str, key: NodeId) -> Result<Value> {
|
||||
let cmd = "component.goToMain";
|
||||
let refuse = |why: String| EngineError::Disabled(cmd.into(), why);
|
||||
let path = s
|
||||
.doc()?
|
||||
.libraries
|
||||
.link(library)
|
||||
.map(|l| l.path.clone())
|
||||
.ok_or_else(|| refuse(format!("the library \"{name}\" was removed from this file")))?;
|
||||
if crate::library_server::is_server_link(&path) {
|
||||
return Err(refuse(format!("the main component is in the team library \"{name}\": open its file from the server to edit it")));
|
||||
}
|
||||
if !craft_docpath::is_local(&path) {
|
||||
return Err(refuse(format!("{path}: a library is opened only from this computer")));
|
||||
}
|
||||
if !crate::file_access::exists(&path) {
|
||||
return Err(refuse(format!("the library file of \"{name}\" isn't at {path} any more")));
|
||||
}
|
||||
let viewport = s.ds().map(|d| (d.view.vw, d.view.vh))?;
|
||||
let opened = super::file::open(s, &json!({"path": path}))?;
|
||||
let ds = s.ds_mut()?;
|
||||
(ds.view.vw, ds.view.vh) = viewport;
|
||||
let doc = &ds.doc;
|
||||
let same_library = doc.published.as_ref().is_some_and(|p| p.manifest.id == library);
|
||||
let main = doc.node(key).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }));
|
||||
if !same_library || !main || doc.is_remote(key) {
|
||||
s.message = Some(format!("{name}: the main component isn't in this file any more"));
|
||||
return Ok(json!({"id": null, "index": opened["index"]}));
|
||||
}
|
||||
super::edit::select_and_page(s, vec![key])?;
|
||||
let ds = s.ds_mut()?;
|
||||
if let Some(b) = ds.doc.world_bounds(key) {
|
||||
ds.view.fit(b, 2.0);
|
||||
}
|
||||
Ok(json!({"id": key, "index": opened["index"]}))
|
||||
}
|
||||
|
||||
/// Place an instance of `component` centred on page point (`x`, `y`), in the frame or auto layout
|
||||
/// slot under it (`instance.place`).
|
||||
pub(crate) fn place(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -302,7 +302,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
(w, h, offset) = (shape.width, shape.height, shape.offset);
|
||||
} else if let Some(Value::Array(pts)) = get(p, "points") {
|
||||
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
|
||||
let pts: Vec<Point> = pts
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let xy = v.as_array().filter(|a| a.len() == 2)?;
|
||||
Some(Point::new(f64_of(&xy[0])?, f64_of(&xy[1])?))
|
||||
})
|
||||
.collect::<Option<_>>()
|
||||
.ok_or_else(|| bad(cmd, "`points` entries must be numeric [x, y] pairs"))?;
|
||||
if pts.len() < 2 {
|
||||
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
|
||||
}
|
||||
|
||||
@@ -326,7 +326,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| {
|
||||
let i = req_id("node.reorder", p, "id")?;
|
||||
let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize;
|
||||
let idx = req_index("node.reorder", p, "index")?;
|
||||
s.doc_mut()?.reorder(i, idx)?;
|
||||
ok()
|
||||
}),
|
||||
|
||||
@@ -37,7 +37,8 @@ pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variable
|
||||
pub use open::MAX_RECENT;
|
||||
#[cfg(test)]
|
||||
use open::append_pages;
|
||||
use open::{import, open, open_recent, revert};
|
||||
pub(crate) use open::open;
|
||||
use open::{import, open_recent, revert};
|
||||
pub(crate) use save::save_ends_preview;
|
||||
pub use save::{FileSnapshot, file_bytes, thumbnail};
|
||||
use save::{color_profile, mark_saved, save, save_as};
|
||||
|
||||
@@ -54,7 +54,7 @@ pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"files": files}))
|
||||
}
|
||||
|
||||
pub(super) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(crate) fn open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let variables = variables_param("file.open", p)?;
|
||||
if string(p, "path").is_none()
|
||||
&& let Some(d) = string(p, "data")
|
||||
|
||||
@@ -129,7 +129,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("page.move", "Move page", "{id?, index}", true, |s, p| {
|
||||
let id = id(p, "id").unwrap_or(s.page()?);
|
||||
let k = req_num("page.move", p, "index")?.max(0.0) as usize;
|
||||
let k = req_index("page.move", p, "index")?;
|
||||
let doc = s.doc_mut()?;
|
||||
if !doc.pages.contains(&id) {
|
||||
return Err(bad("page.move", format!("{id} is not a page")));
|
||||
|
||||
@@ -41,6 +41,30 @@ pub(crate) fn normalize_line_endings(s: &str) -> String {
|
||||
s.replace("\r\n", "\n").replace(['\r', '\u{2028}', '\u{2029}'], "\n")
|
||||
}
|
||||
|
||||
/// The name a text layer derives from its characters: the first line, at most 64 characters, or
|
||||
/// None when that line is blank (a layer with no name of its own, which the Layers panel shows by
|
||||
/// its kind).
|
||||
///
|
||||
/// A layer whose name is this derived name is one nobody has renamed, so the editor keeps its name
|
||||
/// in step as the characters are typed ([`crate::textedit`]); any other name was chosen by the
|
||||
/// author and is kept. Commands never rename, so a layer named for its role keeps that name as a
|
||||
/// field key even when `characters` replace text that reads like it.
|
||||
pub(crate) fn text_layer_name(characters: &str) -> Option<String> {
|
||||
let first: String = characters.lines().next().unwrap_or("").chars().take(64).collect();
|
||||
(!first.trim().is_empty()).then_some(first)
|
||||
}
|
||||
|
||||
/// Whether `name` is one a text layer was given for its characters rather than by its author, so
|
||||
/// the editor may keep it in step with them. Blank text leaves a layer named `""` when the Text
|
||||
/// tool draws it and `Text` when `node.create` makes it, and both read as unnamed in the panel.
|
||||
pub(crate) fn text_name_is_derived(name: &str, derived: Option<&String>) -> bool {
|
||||
match derived {
|
||||
Some(d) => name == d,
|
||||
// `NodeKind::Text(_).label()`, which the panel falls back to for an unnamed layer.
|
||||
None => name.is_empty() || name == "Text",
|
||||
}
|
||||
}
|
||||
|
||||
/// camelCase object keys → snake_case (accept the inspect/camel forms for serde-backed values).
|
||||
pub fn snake(v: &Value) -> Value {
|
||||
match v {
|
||||
|
||||
@@ -12,7 +12,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::lists::BoundList;
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::{boolean, get, num, req_string, string};
|
||||
use crate::params::{boolean, get, req_index, req_string, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -258,13 +258,13 @@ fn remove_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn move_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "variable.moveMode";
|
||||
let to = num(p, "to").filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, "`to` must be a number"))?;
|
||||
let to = req_index(cmd, p, "to")?;
|
||||
let doc = s.doc_mut()?;
|
||||
let (ci, mode) = mode_of(doc, cmd, p)?;
|
||||
let coll = doc.variables.collections.get_mut(ci).ok_or_else(|| bad(cmd, "no such collection"))?;
|
||||
let from = coll.modes.iter().position(|m| m.id == mode).ok_or_else(|| bad(cmd, "no such mode"))?;
|
||||
let m = coll.modes.remove(from);
|
||||
let at = (to.max(0.0) as usize).min(coll.modes.len());
|
||||
let at = to.min(coll.modes.len());
|
||||
coll.modes.insert(at, m);
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -81,11 +81,12 @@ fn only_a_comment_link_leaves_the_comment_tool() {
|
||||
assert!(!tool_left(crate::live::PUBLISHING, ToolId::Comment));
|
||||
}
|
||||
|
||||
/// A local network share has edit and view-only codes, not a comment code, and says where one is.
|
||||
#[test]
|
||||
fn a_local_share_has_no_comment_code() {
|
||||
let (mut s, _) = session();
|
||||
let e = s.execute("collab.share", &json!({"via": "local", "access": "comment"})).unwrap_err().to_string();
|
||||
assert!(e.contains("can edit it"), "{e}");
|
||||
assert!(e.contains("share it online for a comment code"), "{e}");
|
||||
let e = s.execute("collab.share", &json!({"access": "owner"})).unwrap_err().to_string();
|
||||
assert!(e.contains("\"comment\""), "{e}");
|
||||
}
|
||||
|
||||
@@ -378,3 +378,12 @@ fn the_text_on_a_path_tool_carries_a_fill_bound_through_fills() {
|
||||
let t = editing(&s).expect("typing starts on the path");
|
||||
assert_eq!(s.doc().unwrap().node(t).unwrap().bound, bound, "the fill keeps following its variable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_point_is_rejected() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("vector.create", &json!({"points": [[0, 0], ["abc", 5], [100, 100]], "closed": false})).is_err());
|
||||
assert!(s.execute("vector.create", &json!({"points": [[0, 0], [1], [100, 100]]})).is_err());
|
||||
assert!(s.execute("vector.create", &json!({"points": [[0, 0], 7, [100, 100]]})).is_err());
|
||||
assert!(s.execute("vector.create", &json!({"points": [[0, 0], [50, 5], [100, 100]]})).is_ok());
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use crate::live::{
|
||||
Access, Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, Presence, PublishOptions, Published, Role, SessionInfo, Shared, SharedLink, Status,
|
||||
join_target,
|
||||
WayIn, join_target,
|
||||
};
|
||||
use crate::{EngineError, Session};
|
||||
|
||||
@@ -36,13 +36,23 @@ struct Link(Arc<Mutex<Script>>);
|
||||
|
||||
/// Where the scripted host listens, and its session's secret.
|
||||
const HOST: Local = Local { ip: Ipv4Addr::new(192, 168, 1, 20), port: 47_900, secret: 0x123_4567_89AB };
|
||||
/// The same session's view-only secret.
|
||||
const VIEW: Local = Local { secret: 0xBA9_8765_4321, ..HOST };
|
||||
|
||||
/// The WebSocket URL the scripted link gives with an invite `link` (the same secret).
|
||||
fn mock_url(link: &str) -> String {
|
||||
link.replace("figcraft://join?host=", "ws://")
|
||||
}
|
||||
|
||||
impl LiveLink for Link {
|
||||
/// Host as the network link does: a code's short secret when shared with a code, and the
|
||||
/// view-only link when asked for one.
|
||||
fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
// As the network link does: a short secret when the session is shared with a code.
|
||||
let link = if opts.short { HOST.link() } else { "figcraft://join?host=192.168.1.20:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv".to_string() };
|
||||
let view = opts.view.then(|| WayIn { link: VIEW.link(), url: mock_url(&VIEW.link()) });
|
||||
s.hosted.push(opts);
|
||||
Ok(SessionInfo {
|
||||
role: Role::Host,
|
||||
@@ -51,11 +61,12 @@ impl LiveLink for Link {
|
||||
color: "#0D99FF".into(),
|
||||
file_name: "Shared".into(),
|
||||
pages: vec![],
|
||||
url: link.replace("figcraft://join?host=", "ws://"),
|
||||
url: mock_url(&link),
|
||||
link,
|
||||
address: "192.168.1.20:47900".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
view,
|
||||
})
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
@@ -146,6 +157,7 @@ fn on_the_server(s: &mut Session, script: &Arc<Mutex<Script>>, codes: Option<(&s
|
||||
address: "wss://share.example:443/".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
view: None,
|
||||
};
|
||||
let events = vec![Event::Published(published), Event::Shared(Ok(links(0, codes))), Event::Welcome { info, peers: vec![] }];
|
||||
script.lock().unwrap().events.extend(events);
|
||||
@@ -352,6 +364,8 @@ fn online_sharing_is_off_without_a_server() {
|
||||
assert!(matches!(bare.execute("collab.join", &json!({"code": HOST.code()})), Err(EngineError::Disabled(..))));
|
||||
}
|
||||
|
||||
/// A local network share shows the code that carries the host's address, has a view-only code
|
||||
/// for the same session, switches which shows without hosting again, and listens where told.
|
||||
#[test]
|
||||
fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() {
|
||||
let (mut s, script) = session();
|
||||
@@ -360,30 +374,48 @@ fn sharing_on_the_local_network_shows_a_code_that_carries_the_address() {
|
||||
let shown = r["code"].as_str().unwrap();
|
||||
assert_eq!(code::parse(shown), Ok(code::Code::Local(HOST)), "{shown}");
|
||||
assert_eq!(shown, code::grouped(&HOST.code()));
|
||||
assert_eq!((&r["link"], &r["share"]["viewCode"]), (&json!(HOST.link()), &Value::Null));
|
||||
assert_eq!(r["link"], json!(HOST.link()));
|
||||
// A view-only code too, for the same session with its own secret; no comment code.
|
||||
let view = code::grouped(&VIEW.code());
|
||||
assert_eq!((&r["share"]["viewCode"], &r["share"]["viewLink"]), (&json!(view), &json!(VIEW.link())));
|
||||
assert_eq!((&r["share"]["editLink"], &r["share"]["commentCode"]), (&json!(HOST.link()), &Value::Null));
|
||||
{
|
||||
let sc = script.lock().unwrap();
|
||||
let [h] = sc.hosted.as_slice() else { panic!("hosted {}", sc.hosted.len()) };
|
||||
assert!(h.short && h.bind == Bind::Lan && h.port.is_none(), "the local network unless told otherwise");
|
||||
assert!(h.short && h.view && h.bind == Bind::Lan && h.port.is_none(), "the local network unless told otherwise");
|
||||
}
|
||||
// Everyone edits there.
|
||||
assert!(err(&mut s, "collab.share", json!({"access": "view"})).contains("share it online"));
|
||||
// Which code shows: the view code, then the edit code again (nothing is hosted twice).
|
||||
// The link goes with the code: a view-only code's link doesn't edit.
|
||||
let r = ex(&mut s, "collab.share", json!({"access": "view"}));
|
||||
assert_eq!((&r["code"], &r["share"]["access"]), (&json!(view), &json!("view")));
|
||||
assert_eq!((&r["link"], &r["url"], &r["sessions"][0]["link"]), (&json!(VIEW.link()), &json!(mock_url(&VIEW.link())), &json!(VIEW.link())));
|
||||
let r = ex(&mut s, "collab.share", json!({"access": "edit"}));
|
||||
assert_eq!((&r["code"], &r["link"], &r["url"]), (&json!(code::grouped(&HOST.code())), &json!(HOST.link()), &json!(mock_url(&HOST.link()))));
|
||||
assert!(err(&mut s, "collab.share", json!({"access": "comment"})).contains("share it online"));
|
||||
assert_eq!(script.lock().unwrap().hosted.len(), 1);
|
||||
assert_eq!(ex(&mut s, "collab.stop", json!({}))["ended"], true);
|
||||
assert!(script.lock().unwrap().asked.is_empty(), "no server to tell");
|
||||
assert!(err(&mut s, "collab.share", json!({"via": "local", "access": "view"})).contains("share it online"));
|
||||
// Shared for looking only from the start: the edit code isn't the one shown.
|
||||
let r = ex(&mut s, "collab.share", json!({"via": "local", "access": "view"}));
|
||||
assert_eq!((&r["code"], &r["share"]["access"]), (&json!(view), &json!("view")));
|
||||
assert_eq!((&r["link"], &r["url"]), (&json!(VIEW.link()), &json!(mock_url(&VIEW.link()))), "the edit link and URL aren't handed out");
|
||||
assert_eq!(r["sessions"][0]["link"], json!(VIEW.link()));
|
||||
ex(&mut s, "collab.stop", json!({}));
|
||||
assert!(err(&mut s, "collab.share", json!({"via": "local", "access": "comment"})).contains("share it online"));
|
||||
// Where to listen is the advanced part.
|
||||
ex(&mut s, "collab.share", json!({"via": "local", "bind": "local", "port": 5000}));
|
||||
let sc = script.lock().unwrap();
|
||||
assert!(sc.hosted[1].bind == Bind::Local && sc.hosted[1].port == Some(5000));
|
||||
assert!(sc.hosted[2].bind == Bind::Local && sc.hosted[2].port == Some(5000));
|
||||
drop(sc);
|
||||
ex(&mut s, "collab.stop", json!({}));
|
||||
for bad in [json!({"via": "post"}), json!({"via": "local", "port": 70000}), json!({"via": "local", "bind": "everywhere"})] {
|
||||
assert!(matches!(s.execute("collab.share", &bad), Err(EngineError::BadParams { .. })), "{bad}");
|
||||
}
|
||||
// A plain `collab.host` session has an invite link, not a code.
|
||||
// A plain `collab.host` session has an invite link, not a code, and everyone in it edits.
|
||||
let r = ex(&mut s, "collab.host", json!({}));
|
||||
assert_eq!((&r["code"], &r["share"]), (&Value::Null, &Value::Null));
|
||||
assert!(!script.lock().unwrap().hosted[2].short);
|
||||
let h = &script.lock().unwrap().hosted[3];
|
||||
assert!(!h.short && !h.view);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -90,6 +90,7 @@ impl LiveLink for Mock {
|
||||
}
|
||||
}
|
||||
|
||||
/// A session you are in as `role`, as client `me`, everyone in it editing.
|
||||
pub(crate) fn info(role: Role, me: u32) -> SessionInfo {
|
||||
SessionInfo {
|
||||
role,
|
||||
@@ -103,6 +104,7 @@ pub(crate) fn info(role: Role, me: u32) -> SessionInfo {
|
||||
address: "127.0.0.1:47900".into(),
|
||||
network: false,
|
||||
access: crate::live::Access::Edit,
|
||||
view: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ fn edit_link(server: &str, file: &str) -> String {
|
||||
format!("figcraft://join?server={server}&file={file}&token={EDIT_TOKEN}")
|
||||
}
|
||||
|
||||
/// A session you are in as `role` under `name`, invited with `link`.
|
||||
fn info(role: Role, name: &str, link: &str) -> SessionInfo {
|
||||
SessionInfo {
|
||||
role,
|
||||
@@ -149,6 +150,7 @@ fn info(role: Role, name: &str, link: &str) -> SessionInfo {
|
||||
address: SERVER.into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
view: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -398,6 +398,20 @@ fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_move_refuses_negative_or_fractional_indexes() {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "page.add", json!({}));
|
||||
let p3 = id(&ex(&mut s, "page.add", json!({})));
|
||||
let before = pages(&mut s);
|
||||
for bad_index in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("page.move", &json!({"id": p3, "index": bad_index})).is_err(), "page.move index {bad_index} must fail");
|
||||
assert_eq!(pages(&mut s), before, "page.move index {bad_index} leaves the order alone");
|
||||
}
|
||||
ex(&mut s, "page.move", json!({"id": p3, "index": 0}));
|
||||
assert_eq!(pages(&mut s)[0]["id"], json!(p3), "a valid index still moves the page");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assets_panel_lists_files_and_drops_by_the_documented_rules() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -72,7 +72,7 @@ pub(crate) use join::start_join;
|
||||
pub use join::{can_remove_people, specs};
|
||||
pub use types::{
|
||||
Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, MAX_NAME, MAX_PEERS, MAX_SELECTION, NO_HOSTING, NO_SESSION, NetReply, NetRequest, ONLINE_OFF,
|
||||
PEOPLE, PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via,
|
||||
PEOPLE, PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via, WayIn,
|
||||
join_target, parse_color,
|
||||
};
|
||||
|
||||
@@ -147,6 +147,18 @@ impl Live {
|
||||
self.info.as_ref().map(|i| i.role)
|
||||
}
|
||||
|
||||
/// The session's invite link and WebSocket URL as `collab.peers` gives them. A file shared
|
||||
/// on the local network gives the ones that go with the code shown: the session's own link
|
||||
/// and URL are the edit way in, and a view-only share must not hand them out. Otherwise the
|
||||
/// session's own (online, the owner's: `share` has the links for others).
|
||||
pub fn invite(&self) -> (Option<&str>, Option<&str>) {
|
||||
let info = self.info.as_ref();
|
||||
match &self.share {
|
||||
Some(sh) if sh.via == Via::Local => info.and_then(|i| i.way_in(sh.access)).unzip(),
|
||||
_ => (info.map(|i| i.link.as_str()), info.map(|i| i.url.as_str())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop following. False when you weren't following anyone.
|
||||
fn unfollow(&mut self) -> bool {
|
||||
self.followed = None;
|
||||
|
||||
@@ -36,14 +36,16 @@ pub(super) fn unavailable(cmd: &str) -> EngineError {
|
||||
)
|
||||
}
|
||||
|
||||
/// `collab.host`: host the open file (on this machine unless `p` says where) with an invite link.
|
||||
pub(super) fn host(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
start_host(s, "collab.host", p, Bind::Local, false)?;
|
||||
start_host(s, "collab.host", p, Bind::Local, false, false)?;
|
||||
Ok(s.live_summary())
|
||||
}
|
||||
|
||||
/// Start hosting the open file for command `cmd` (on `bind` unless `p` says where; `short`: with
|
||||
/// a secret a join code can carry): the new session.
|
||||
fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) -> Result<SessionId> {
|
||||
/// a secret a join code can carry; `view`: with a second secret for looking only): the new
|
||||
/// session.
|
||||
fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool, view: bool) -> Result<SessionId> {
|
||||
let port = match get(p, "port") {
|
||||
None => None,
|
||||
Some(v) => Some(v.as_u64().and_then(|n| u16::try_from(n).ok()).ok_or_else(|| bad(cmd, "`port` must be a whole number from 0 to 65535"))?),
|
||||
@@ -55,7 +57,7 @@ fn start_host(s: &mut Session, cmd: &str, p: &Value, bind: Bind, short: bool) ->
|
||||
Some(other) => other.parse::<IpAddr>().map(Bind::Addr).map_err(|_| bad(cmd, "`bind` must be \"local\", \"lan\" or an IP address"))?,
|
||||
};
|
||||
let (doc, uid) = s.ds().map(|ds| (ds.doc.clone(), ds.uid))?;
|
||||
let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short };
|
||||
let opts = HostOptions { port, bind, name: display_name(&s.prefs), doc, short, view };
|
||||
let (id, info) = s.live.start(cmd, |l, id| l.host(id, opts).map_err(|e| EngineError::Other(format!("couldn't start the live session: {e}"))))?;
|
||||
s.live.open(Live { id, status: Status::Live, info: Some(info), doc_uid: Some(uid), tab: Some(uid), ..Live::default() });
|
||||
Ok(id)
|
||||
@@ -82,8 +84,9 @@ fn start_publish(s: &mut Session, cmd: &str, server: &str, p: &Value, share: boo
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Why a local network share has no view-only or comment code.
|
||||
const LOCAL_EDITS: &str = "everyone who joins a file shared on the local network can edit it: share it online for a view-only or comment code";
|
||||
/// Why a local network share has no comment code.
|
||||
const LOCAL_COMMENTS: &str =
|
||||
"a file shared on the local network has an edit code and a view-only code, not a comment code: share it online for a comment code";
|
||||
|
||||
/// Why a share has no comment code.
|
||||
const NO_COMMENT_LINK: &str =
|
||||
@@ -94,6 +97,8 @@ fn access_param(cmd: &str, p: &Value) -> Result<Option<Access>> {
|
||||
string(p, "access").map(|a| Access::parse(&a).ok_or_else(|| bad(cmd, "`access` must be \"edit\", \"comment\" or \"view\""))).transpose()
|
||||
}
|
||||
|
||||
/// `collab.share`: share the open file with join codes, online or on the local network, or (shared
|
||||
/// already) switch which code shows.
|
||||
pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "collab.share";
|
||||
let via = match string(p, "via").as_deref().map(str::trim) {
|
||||
@@ -112,8 +117,8 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"this file is shared the other way already: stop sharing it first (collab.stop), or leave its session (collab.leave)",
|
||||
));
|
||||
}
|
||||
if access.is_some_and(|a| a != Access::Edit) && shared == Via::Local {
|
||||
return Err(bad(CMD, LOCAL_EDITS));
|
||||
if access == Some(Access::Comment) && shared == Via::Local {
|
||||
return Err(bad(CMD, LOCAL_COMMENTS));
|
||||
}
|
||||
if let Some(req) = share_request(s, Vec::new(), true).filter(|_| s.live().share.as_ref().is_some_and(|sh| sh.links.is_none())) {
|
||||
let links = share_answer(s, req)?.ok_or_else(|| EngineError::Other("the server made no links".into()))?;
|
||||
@@ -130,8 +135,8 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let access = access.unwrap_or_default();
|
||||
let mut server = String::new();
|
||||
let id = match via {
|
||||
Via::Local if access != Access::Edit => return Err(bad(CMD, LOCAL_EDITS)),
|
||||
Via::Local => start_host(s, CMD, p, Bind::Lan, true)?,
|
||||
Via::Local if access == Access::Comment => return Err(bad(CMD, LOCAL_COMMENTS)),
|
||||
Via::Local => start_host(s, CMD, p, Bind::Lan, true, true)?,
|
||||
Via::Online => {
|
||||
server = string(p, "server").unwrap_or_else(|| s.prefs.publish_server.clone()).trim().to_string();
|
||||
if server.is_empty() {
|
||||
@@ -141,17 +146,25 @@ pub(super) fn share(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
if let Some(live) = s.live.sessions.get_mut(&id) {
|
||||
// On the local network the session's own link is what others join with, and the code says
|
||||
// what the link says: where the session is and its secret. (Online they arrive with the file.)
|
||||
let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| {
|
||||
let code = code::Local::from_link(&i.link).map(|l| l.code());
|
||||
Shared { edit: SharedLink { id: String::new(), link: i.link.clone(), code }, ..Shared::default() }
|
||||
// On the local network the session's own links are what others join with (its view link
|
||||
// only lets them look), and each code says what its link says: where the session is and
|
||||
// that link's secret. (Online they arrive with the file.)
|
||||
let links = live.info.as_ref().filter(|_| via == Via::Local).map(|i| Shared {
|
||||
edit: local_link(&i.link),
|
||||
view: i.view.as_ref().map(|v| local_link(&v.link)),
|
||||
comment: None,
|
||||
});
|
||||
live.share = Some(Share { via, server, access, links });
|
||||
}
|
||||
Ok(s.live_summary())
|
||||
}
|
||||
|
||||
/// A way into a session this app hosts: its invite link and the join code that stands for it
|
||||
/// (none when the link can't be put in one: a host without an IPv4 address).
|
||||
fn local_link(link: &str) -> SharedLink {
|
||||
SharedLink { id: String::new(), link: link.to_string(), code: code::Local::from_link(link).map(|l| l.code()) }
|
||||
}
|
||||
|
||||
/// What others join the open file's server file with changed: in its session and its record.
|
||||
pub(super) fn shared_now(s: &mut Session, links: Option<Shared>) {
|
||||
if let Some((server, file)) = s.live().server_file() {
|
||||
|
||||
@@ -162,6 +162,7 @@ fn spotlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"spotlighting": s.live().spotlighting}))
|
||||
}
|
||||
|
||||
/// The live session commands (`collab.*`).
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
@@ -174,7 +175,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.share",
|
||||
"Share with a join code",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (online only: which code `code` is), server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` an invite link. Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
|
||||
"{via?: \"online\" (default: the open file is kept on the server in Preferences, `pref.publishServer`, and the server hands out the codes) | \"local\" (this app hosts it for people on the same network; no server), access?: \"edit\" (default) | \"comment\" (online only: view and comment, not edit) | \"view\" (look only: their edits are refused): which code `code` is, server? (online: another server's address, with ?key=… when it needs its key), key? (online: the server's key, when the address has none), name? (online: the file's name there), port?, bind?: \"lan\" (default) | \"local\" (this machine only) | an IP address (local)} → the open file's session as `collab.peers` gives it: `code` is what others type to join (`collab.join {code}`, the Join field), `link` the invite link to give with it and `url` its WebSocket URL (local: the ones that go with `code`, so a view-only code's link and URL let people look only; online: your own link to the file, which edits: `share` has the link that goes with each code). Local: at once, the code carries the host's IPv4 address, port and secret (K7M2Q-…, 20 symbols), and `share` is {via, access, shared, editCode, viewCode, editLink, viewLink}: the edit and view-only ways in, each with its own secret (commentCode and commentLink null). Online: status \"connecting\" first; once live `code` is the server's (K7M-2QX-9TD) and `share` is {via, access, shared, editCode, viewCode, commentCode, editLink, viewLink, commentLink}: links made for others (commentCode and commentLink null from a server older than comment links) (the file's own links in `published` stay yours); `code` stays null when the server is older than join codes (give `share.editLink` / `share.viewLink` instead). Called again while shared: only switches which code `code` is (`access`). Called after `collab.stop` on a file that is still on the server: new links and codes for that same file (nothing is published twice). `collab.stop` stops sharing",
|
||||
false,
|
||||
share
|
||||
),
|
||||
@@ -230,7 +231,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.peers",
|
||||
"People in this file",
|
||||
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view|comment (view: a server's view link, edits are refused; comment: its comment link, edits other than to comment threads are refused), readOnly (the open file can't be edited now: view only, comment only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, commentCode, editLink, viewLink, commentLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
|
||||
"{} → the open file's session (each open file has its own or none): {available, canHost (false in the browser: it can only join), session (its id; null: this file isn't in a session), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, access: edit|view|comment (view: a view link or code, a server's or a local network share's, edits are refused; comment: a server's comment link, edits other than to comment threads are refused), readOnly (the open file can't be edited now: view only, comment only, or being published), published (a file you published: {server, file, name, edit, view}), code (the join code to give others, when the file was shared with `collab.share`; null otherwise or while it is on its way), share ({via: online|local, access, shared (false: a file of yours on a server that nobody else gets into now), editCode, viewCode, commentCode, editLink, viewLink, commentLink (what others join with; null when not shared)} or null), follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}], sessions: [{session, document (index of its open document; null for a join on its way), status, reason, role, fileName, link, peers (how many others)}] (every session of the app)}",
|
||||
false,
|
||||
|s, _| Ok(s.live_summary())
|
||||
),
|
||||
|
||||
@@ -283,6 +283,7 @@ impl Session {
|
||||
})
|
||||
.collect();
|
||||
let mine = synced.and_then(|d| d.text_edit.as_ref()).map(|t| editing(&editing_of(t)));
|
||||
let (link, url) = l.invite();
|
||||
let sessions: Vec<Value> = self
|
||||
.live
|
||||
.iter()
|
||||
@@ -295,7 +296,7 @@ impl Session {
|
||||
"reason": reason(o),
|
||||
"role": o.role(),
|
||||
"fileName": info.map(|i| i.file_name.as_str()),
|
||||
"link": info.map(|i| i.link.as_str()),
|
||||
"link": o.invite().0,
|
||||
"peers": o.peers.len(),
|
||||
})
|
||||
})
|
||||
@@ -313,8 +314,8 @@ impl Session {
|
||||
"color": info.map(|i| i.color.as_str()),
|
||||
"fileName": info.map(|i| i.file_name.as_str()),
|
||||
"pages": info.map(|i| &i.pages),
|
||||
"link": info.map(|i| i.link.as_str()),
|
||||
"url": info.map(|i| i.url.as_str()),
|
||||
"link": link,
|
||||
"url": url,
|
||||
"address": info.map(|i| i.address.as_str()),
|
||||
"network": info.map(|i| i.network),
|
||||
"access": l.client.as_ref().map(Client::access).or(info.map(|i| i.access)),
|
||||
|
||||
@@ -119,9 +119,33 @@ pub struct SessionInfo {
|
||||
pub address: String,
|
||||
/// Reachable from other machines (the host listens on a network address, not loopback).
|
||||
pub network: bool,
|
||||
/// What you may do: edit, only view (a server's view link), or view and comment (its comment
|
||||
/// link).
|
||||
/// What you may do: edit, only view (a view link or code), or view and comment (a server's
|
||||
/// comment link).
|
||||
pub access: Access,
|
||||
/// A session this app hosts with a way in for looking only ([`HostOptions::view`]): its
|
||||
/// invite link and WebSocket URL, which carry the view secret (`None`: everyone who joins
|
||||
/// edits, or you joined).
|
||||
pub view: Option<WayIn>,
|
||||
}
|
||||
|
||||
impl SessionInfo {
|
||||
/// The invite link and WebSocket URL that let people into a session this app hosts with
|
||||
/// `access` (its own link and URL edit). `None`: no way in gives that.
|
||||
pub fn way_in(&self, access: Access) -> Option<(&str, &str)> {
|
||||
match access {
|
||||
Access::Edit => Some((&self.link, &self.url)),
|
||||
Access::View => self.view.as_ref().map(|v| (v.link.as_str(), v.url.as_str())),
|
||||
Access::Comment => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A way into a session this app hosts: an invite link and the WebSocket URL it connects to,
|
||||
/// which carry the same secret.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct WayIn {
|
||||
pub link: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// A file you published to a server: where, and its first share links (they carry their tokens:
|
||||
@@ -390,6 +414,9 @@ pub struct HostOptions {
|
||||
pub doc: Arc<Document>,
|
||||
/// Shared with a join code (`collab.share`): the session's secret is short enough to type.
|
||||
pub short: bool,
|
||||
/// Also make a second secret whose holders only look: they see every change, and the session
|
||||
/// refuses theirs ([`SessionInfo::view`]).
|
||||
pub view: bool,
|
||||
}
|
||||
|
||||
/// What [`LiveLink::publish`] needs to put the open file on a server.
|
||||
|
||||
@@ -748,3 +748,71 @@ fn a_link_is_underlined_by_default_and_underline_takes_it_off() {
|
||||
s.execute("text.underline", &json!({"ids": [f]})).unwrap();
|
||||
assert_eq!(inner_style(&s).decoration, TextDecoration::None, "Underline on the frame reads the text inside it");
|
||||
}
|
||||
|
||||
fn layer_name(s: &Session, t: NodeId) -> String {
|
||||
s.doc().unwrap().node(t).unwrap().name.clone()
|
||||
}
|
||||
|
||||
/// Select everything in the layer being edited and delete it.
|
||||
fn clear_edited_text(s: &mut Session, t: NodeId) {
|
||||
let len = s.doc().unwrap().node(t).unwrap().kind.text().unwrap().char_count();
|
||||
crate::textedit::set_caret(s, 0, false).unwrap();
|
||||
crate::textedit::set_caret(s, len, true).unwrap();
|
||||
crate::textedit::key(s, "Backspace", false, false, false).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it() {
|
||||
// The Layers panel shows a layer's name, so a layer still carrying the name its characters
|
||||
// gave it has to follow them while they are typed or the panel and the canvas disagree
|
||||
// (#1747). The target app's plugin API calls this `autoRename`: "Whether updating the
|
||||
// characters in the text node should update the name of the node… This is automatically reset
|
||||
// to false if `name` is modified in order to allow the node to keep the new name."
|
||||
let mut s = Session::new();
|
||||
|
||||
// Nobody renamed this layer, so typing in it keeps its name up to date.
|
||||
let t = text(&mut s, "hello", json!({}));
|
||||
assert_eq!(layer_name(&s, t), "hello", "a new layer is named after its first line");
|
||||
crate::textedit::begin(&mut s, t, Some(5), false).unwrap();
|
||||
crate::textedit::insert(&mut s, " there").unwrap();
|
||||
assert_eq!((chars(&s, t).as_str(), layer_name(&s, t).as_str()), ("hello there", "hello there"), "the name follows what is typed");
|
||||
|
||||
// A name the author typed is theirs: later typing leaves it alone.
|
||||
s.execute("node.set", &json!({"id": t, "props": {"name": "Greeting"}})).unwrap();
|
||||
crate::textedit::begin(&mut s, t, Some(11), false).unwrap();
|
||||
crate::textedit::insert(&mut s, "!").unwrap();
|
||||
assert_eq!((chars(&s, t).as_str(), layer_name(&s, t).as_str()), ("hello there!", "Greeting"), "a chosen name is kept");
|
||||
|
||||
// Only the first line names the layer.
|
||||
let v = text(&mut s, "first", json!({}));
|
||||
crate::textedit::begin(&mut s, v, Some(5), false).unwrap();
|
||||
crate::textedit::insert(&mut s, "\nsecond").unwrap();
|
||||
assert_eq!((chars(&s, v).as_str(), layer_name(&s, v).as_str()), ("first\nsecond", "first"), "the first line alone names it");
|
||||
|
||||
// Emptied text reads as a new empty text layer does, not as the word that was deleted.
|
||||
clear_edited_text(&mut s, v);
|
||||
assert_eq!((chars(&s, v).as_str(), layer_name(&s, v).as_str()), ("", ""), "emptied text has no name of its own, so the panel shows its kind");
|
||||
|
||||
// The usual way a layer is made: the Text tool, a click, then typing into the empty layer.
|
||||
use crate::tools::{PointerEvent, PointerKind, ToolId};
|
||||
s.set_tool(ToolId::Text);
|
||||
s.pointer(&PointerEvent::at(PointerKind::Down, 300.0, 300.0, 1.0)).unwrap();
|
||||
s.pointer(&PointerEvent::at(PointerKind::Up, 300.0, 300.0, 1.0)).unwrap();
|
||||
let drawn = s.ds().unwrap().text_edit.as_ref().unwrap().id;
|
||||
assert_eq!(layer_name(&s, drawn), "", "a layer the tool drew has no name of its own yet");
|
||||
crate::textedit::insert(&mut s, "Pricing").unwrap();
|
||||
assert_eq!((chars(&s, drawn).as_str(), layer_name(&s, drawn).as_str()), ("Pricing", "Pricing"), "the first word typed names it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setting_characters_as_a_command_never_renames_the_layer() {
|
||||
// Only the editor follows the characters. Layer names are field keys for `data.fill`,
|
||||
// component property bindings and templates, which write `characters` into a layer named for
|
||||
// its role ("Title") and look it up by that name again, so a command must leave the name as it
|
||||
// is even when it reads like the text it replaces.
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "Title", json!({}));
|
||||
assert_eq!(layer_name(&s, t), "Title");
|
||||
s.execute("node.set", &json!({"id": t, "props": {"characters": "Site em 7 dias"}})).unwrap();
|
||||
assert_eq!((chars(&s, t).as_str(), layer_name(&s, t).as_str()), ("Site em 7 dias", "Title"), "the field keeps its name");
|
||||
}
|
||||
|
||||
@@ -625,6 +625,33 @@ fn smart_guides_snap_to_edges_centres_and_equal_spacing() {
|
||||
assert_eq!(bounds(&s, b).origin(), Point::new(198.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_to_layers_wins_over_the_pixel_grid() {
|
||||
// Both on (the defaults): a layer's centre at x = 5.5 is still a target.
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0.0, 0.0, 11.0, 11.0, None);
|
||||
let b = rect(&mut s, 200.0, 300.0, 100.0, 100.0, None);
|
||||
let _ = a;
|
||||
ex(&mut s, "select.set", json!({"ids": [b]}));
|
||||
// Moving: B's centre (grabbed) snaps to A's; the free axis stays on whole pixels.
|
||||
s.pointer(&ev(PointerKind::Down, Point::new(250.0, 350.0), NONE)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, Point::new(6.0, 352.4), NONE)).unwrap();
|
||||
assert!(s.tool.overlay.guides.iter().any(|g| near(g.x0, 5.5) && near(g.x1, 5.5)), "{:?}", s.tool.overlay.guides);
|
||||
s.pointer(&ev(PointerKind::Up, Point::new(6.0, 352.4), NONE)).unwrap();
|
||||
assert_eq!(bounds(&s, b).origin(), Point::new(-44.5, 302.0), "moved onto A's centre");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
// Out of reach of any layer, a move lands on whole pixels.
|
||||
drag(&mut s, Point::new(250.0, 350.0), Point::new(260.3, 360.6), NONE);
|
||||
assert_eq!(bounds(&s, b).origin(), Point::new(210.0, 311.0));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
// Resizing: the left handle snaps to A's centre; the bottom one lands on whole pixels.
|
||||
drag(&mut s, Point::new(200.0, 350.0), Point::new(6.0, 350.0), NONE);
|
||||
assert!(near(bounds(&s, b).x0, 5.5), "{:?}", bounds(&s, b));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
drag(&mut s, Point::new(250.0, 400.0), Point::new(250.0, 450.6), NONE);
|
||||
assert_eq!(size(&s, b), (100.0, 151.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corner_cursors_stay_diagonal_whatever_the_proportions() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -68,6 +68,20 @@ fn types_and_their_starting_values() {
|
||||
assert!(s.execute("variable.create", &json!({"collection": "C", "name": "y", "type": "number", "value": "wide"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_mode_rejects_negative_and_fractional_indexes() {
|
||||
let mut s = Session::new();
|
||||
theme(&mut s);
|
||||
run(&mut s, "variable.addMode", json!({"collection": "Theme", "name": "Dim"}));
|
||||
let order =
|
||||
|s: &Session| -> Vec<String> { s.doc().unwrap().variables.collection("Theme").unwrap().modes.iter().map(|m| m.name.clone()).collect() };
|
||||
let before = order(&s);
|
||||
for to in [json!(-1), json!(1.9)] {
|
||||
assert!(s.execute("variable.moveMode", &json!({"collection": "Theme", "mode": "Dim", "to": to})).is_err(), "{to}");
|
||||
assert_eq!(order(&s), before, "{to}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_new_duplicated_reordered_and_the_default() {
|
||||
// Documented: a new mode starts with the default mode's values and a duplicated one with its
|
||||
|
||||
@@ -75,6 +75,9 @@ const MAX_CHUNK: u64 = 512 * 1024 * 1024;
|
||||
/// Largest decompressed schema chunk: Figma's schema unpacks to well under 1 MB, so a schema that
|
||||
/// inflates past this is refused before its definitions are decoded.
|
||||
const MAX_SCHEMA: u64 = 16 * 1024 * 1024;
|
||||
/// Longest image hash (bytes) an image is found by: `images/` names and embedded images both
|
||||
/// stop at 64 bytes (128 hex digits).
|
||||
pub(crate) const MAX_IMAGE_HASH: usize = 64;
|
||||
/// Largest image member read.
|
||||
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
|
||||
/// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on
|
||||
@@ -141,10 +144,14 @@ fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result
|
||||
let max_nodes = layer_budget(packed);
|
||||
drop(message);
|
||||
let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name);
|
||||
let (json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
|
||||
let (mut json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes);
|
||||
// The message goes before the layers are built, so the images it carries are copied out first.
|
||||
let embedded = embedded_copies(&msg);
|
||||
drop(msg);
|
||||
let aliases = embedded_aliases(&embedded, |hash| archive.has_image(hash));
|
||||
if !aliases.is_empty() {
|
||||
canonical_image_refs(&mut json, &aliases);
|
||||
}
|
||||
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
|
||||
let (mut doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
|
||||
let skipped = archive.image_budget.skipped.get();
|
||||
@@ -227,9 +234,53 @@ fn embedded_copies(msg: &Kv) -> BTreeMap<String, Arc<[u8]>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Embedded images ([`embedded_copies`]) whose blob an earlier hash already names, each with that
|
||||
/// hash. Hashes that also have an `images/` member keep their own name: the member is read first.
|
||||
/// Pointing every alias at one name has the import copy and hash each blob once, however many
|
||||
/// hashes a file points at it.
|
||||
fn embedded_aliases(embedded: &BTreeMap<String, Arc<[u8]>>, in_zip: impl Fn(&str) -> bool) -> BTreeMap<String, String> {
|
||||
let mut first: BTreeMap<usize, &String> = BTreeMap::new();
|
||||
let mut out = BTreeMap::new();
|
||||
for (hash, bytes) in embedded {
|
||||
if in_zip(hash) {
|
||||
continue;
|
||||
}
|
||||
// Hashes naming one blob share its copy, so the copy's address tells the blob.
|
||||
let canonical = *first.entry(bytes.as_ptr() as usize).or_insert(hash);
|
||||
if canonical != hash {
|
||||
out.insert(hash.clone(), canonical.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Rename every `imageRef` in REST `json` found in `aliases` to the name it maps to.
|
||||
fn canonical_image_refs(json: &mut serde_json::Value, aliases: &BTreeMap<String, String>) {
|
||||
use serde_json::Value;
|
||||
let mut pending = vec![json];
|
||||
while let Some(value) = pending.pop() {
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
for (key, field) in fields.iter_mut() {
|
||||
match field {
|
||||
Value::String(r) if key == "imageRef" => {
|
||||
if let Some(canonical) = aliases.get(r.as_str()) {
|
||||
r.clone_from(canonical);
|
||||
}
|
||||
}
|
||||
_ => pending.push(field),
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(items) => pending.extend(items.iter_mut()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hash and blob index of an `Image` value whose `dataBlob` points at a usable blob.
|
||||
fn embedded_image(image: &Kv, blobs: &[Kv]) -> Option<(String, usize)> {
|
||||
let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= 64)?;
|
||||
let hash = image.get("hash").and_then(Kv::bytes).filter(|h| !h.is_empty() && h.len() <= MAX_IMAGE_HASH)?;
|
||||
let Some(Kv::Uint(index)) = image.get("dataBlob") else { return None };
|
||||
let index = usize::try_from(*index).ok()?;
|
||||
let bytes = blob(blobs, index)?;
|
||||
@@ -275,10 +326,15 @@ impl<'b> Archive<'b> {
|
||||
Ok(Archive { canvas, file_name, zip: Some(z), image_budget })
|
||||
}
|
||||
|
||||
/// Whether `images/` has a member for image `hash`.
|
||||
fn has_image(&self, hash: &str) -> bool {
|
||||
self.zip.as_ref().is_some_and(|z| z.index_for_name(&format!("images/{hash}")).is_some())
|
||||
}
|
||||
|
||||
/// Bytes of image `hash` (lowercase hex) from `images/`.
|
||||
fn image(&self, hash: &str) -> Option<Vec<u8>> {
|
||||
let budget = &self.image_budget;
|
||||
if hash.is_empty() || hash.len() > 128 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
if hash.is_empty() || hash.len() > 2 * MAX_IMAGE_HASH || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
// A clone shares the parsed directory and the bytes; the resolver only has shared access.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Malformed image paint filters are skipped with a warning.
|
||||
//! Malformed image paint filters are skipped with a warning, and image hashes too long to name
|
||||
//! an image aren't spelled out.
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -25,3 +26,12 @@ fn boolean_filter_values_are_malformed_even_when_false() {
|
||||
assert!(warnings.iter().any(|warning| warning.contains(expected_warning)), "{warnings:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_oversized_image_hash_is_not_spelled_out() {
|
||||
let paint = |len: usize| Kv::Object(vec![("type", Kv::Enum("IMAGE")), ("image", Kv::Object(vec![("hash", Kv::Bytes(vec![0xab; len]))]))]);
|
||||
let image_ref = |len: usize| super::paint(&paint(len), &mut |_| {}).and_then(|p| p.get("imageRef").cloned());
|
||||
assert_eq!(image_ref(20), Some(Value::String("ab".repeat(20))));
|
||||
assert_eq!(image_ref(crate::fig::MAX_IMAGE_HASH), Some(Value::String("ab".repeat(crate::fig::MAX_IMAGE_HASH))));
|
||||
assert_eq!(image_ref(crate::fig::MAX_IMAGE_HASH + 1), Some(Value::String(String::new())));
|
||||
}
|
||||
|
||||
@@ -162,8 +162,17 @@ pub(crate) fn paint(p: &Kv, warn: &mut dyn FnMut(String)) -> Option<Value> {
|
||||
o.insert("gradientStops".into(), Value::Array(stops));
|
||||
}
|
||||
"IMAGE" => {
|
||||
let hash: String =
|
||||
p.get("image").and_then(|i| i.get("hash")).and_then(Kv::bytes).unwrap_or_default().iter().map(|b| format!("{b:02x}")).collect();
|
||||
// A hash longer than any the file's images can be named by resolves to nothing: it
|
||||
// isn't spelled out, which a shared style would repeat for every layer using it.
|
||||
let hash: String = p
|
||||
.get("image")
|
||||
.and_then(|i| i.get("hash"))
|
||||
.and_then(Kv::bytes)
|
||||
.filter(|h| h.len() <= crate::fig::MAX_IMAGE_HASH)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect();
|
||||
o.insert("imageRef".into(), json!(hash));
|
||||
let mode = p.get("imageScaleMode").and_then(Kv::str).unwrap_or("FILL");
|
||||
o.insert("scaleMode".into(), json!(mode));
|
||||
|
||||
@@ -795,6 +795,44 @@ fn hashes_naming_one_blob_share_one_copy_of_it() {
|
||||
assert_eq!(*copies[&"63".repeat(20)], [7; 16]);
|
||||
}
|
||||
|
||||
/// Hashes naming one blob are imported as one image: every alias is renamed to the first hash
|
||||
/// before layers are built, so the blob is copied and hashed once, not once per hash.
|
||||
#[test]
|
||||
fn hashes_naming_one_blob_are_imported_once() {
|
||||
let rect = |i: u64, hash: u8, blob: u64| {
|
||||
let paint = o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(vec![hash; 20])), ("dataBlob", u(blob))]))]);
|
||||
node(i, Some((1, &format!("{i:04}"))), "RECTANGLE", &format!("R{i}"), vec![("size", xy(10.0, 10.0)), ("fillPaints", arr(vec![paint]))])
|
||||
};
|
||||
let mut nodes = vec![node(0, None, "DOCUMENT", "D", vec![]), node(1, Some((0, "!")), "CANVAS", "P", vec![])];
|
||||
nodes.extend((2..42).map(|i| rect(i, i as u8, 0)));
|
||||
let msg = o(vec![("nodeChanges", arr(nodes)), ("blobs", arr(vec![o(vec![("bytes", Kv::Bytes(png()))])]))]);
|
||||
let copies = embedded_copies(&msg);
|
||||
let aliases = embedded_aliases(&copies, |_| false);
|
||||
assert_eq!(aliases.len(), 39);
|
||||
assert!(aliases.values().all(|c| *c == "02".repeat(20)));
|
||||
// A hash `images/` also holds keeps its own name.
|
||||
assert!(!embedded_aliases(&copies, |h| h == "05".repeat(20)).contains_key(&"05".repeat(20)));
|
||||
let (doc, warnings) = import(&canvas(&schema(), &msg), "x").unwrap();
|
||||
assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
|
||||
assert_eq!(doc.images.len(), 1);
|
||||
let key = doc.images.keys().next().unwrap();
|
||||
for i in 2..42 {
|
||||
let figcraft_doc::color::PaintKind::Image(img) = &named(&doc, &format!("R{i}")).fills[0].kind else { panic!("not an image fill") };
|
||||
assert_eq!(&img.image, key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_refs_are_renamed_wherever_they_sit() {
|
||||
let aliases: BTreeMap<String, String> = [("b".to_string(), "a".to_string())].into();
|
||||
let mut json = serde_json::json!({"fills": [{"imageRef": "b"}, {"imageRef": "c"}], "styles": {"s": {"fills": [{"imageRef": "b"}]}}, "name": "b"});
|
||||
canonical_image_refs(&mut json, &aliases);
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({"fills": [{"imageRef": "a"}, {"imageRef": "c"}], "styles": {"s": {"fills": [{"imageRef": "a"}]}}, "name": "b"})
|
||||
);
|
||||
}
|
||||
|
||||
mod image_budget {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ pub fn effects(n: &Node, frame: EffectFrame<'_>) -> Option<Pixmap> {
|
||||
fx::mask_by(&mut l, cov.as_ref()?, false);
|
||||
l
|
||||
}
|
||||
EffectKind::Glass(g) => glass::glass(g, frame)?,
|
||||
EffectKind::Glass(g) => glass::glass(g, frame, glass::RoundedRect::of(n))?,
|
||||
_ => continue,
|
||||
};
|
||||
match out.as_mut() {
|
||||
|
||||
@@ -254,16 +254,65 @@ fn glass_edges_bend_split_and_catch_the_light() {
|
||||
let (rim, middle) = changed(&split, &bent);
|
||||
assert!(rim > 0 && middle == 0, "dispersion: rim {rim}, middle {middle}");
|
||||
|
||||
// The light (from the top left by default) brightens the top-left rim more than the
|
||||
// bottom-right one, and only near the edge.
|
||||
// The light (from the top left by default) is a line on the 1-unit bevel: the top-left corner
|
||||
// catches it all, the bottom-right one (facing away) 0.6 as much, the top side (45° from it)
|
||||
// 0.574 and the bottom side 0.6 × 0.574, and the top-right corner (side on) none. Further in
|
||||
// only the faint gradient across the curve shows, and nothing past `depth`.
|
||||
let (d, f, _) = glass_scene(Glass { light_intensity: 1.0, ..plain });
|
||||
let lit = render_node(&d, f, 1.0).unwrap();
|
||||
let gain = |x: u32, y: u32| i32::from(px(&lit, x, y)[1]) - i32::from(px(&flat, x, y)[1]);
|
||||
assert!(gain(82, 42) > gain(197, 117), "{} vs {}", gain(82, 42), gain(197, 117));
|
||||
assert!(gain(82, 42) > 40, "{}", gain(82, 42));
|
||||
let (top_left, bottom_right, top, bottom, top_right) = (gain(80, 40), gain(199, 119), gain(140, 40), gain(140, 119), gain(199, 40));
|
||||
assert!(
|
||||
top_left > bottom_right && bottom_right > top && top > bottom && bottom > top_right.abs(),
|
||||
"{top_left} {bottom_right} {top} {bottom} {top_right}"
|
||||
);
|
||||
assert!(top_left > 120 && top_right.abs() <= 2, "{top_left} {top_right}");
|
||||
assert!(gain(140, 44).abs() <= 13, "4 px in: the gradient only, {}", gain(140, 44));
|
||||
assert_eq!(gain(140, 80), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splay_turns_the_bend_towards_the_centre() {
|
||||
// Refraction only, so the bend alone moves the stripes' edges.
|
||||
let bend = Glass { frost: 0.0, refraction: 1.0, dispersion: 0.0, light_intensity: 0.0, depth: 16.0, ..Glass::default() };
|
||||
let (d, f, _) = glass_scene(bend.clone());
|
||||
let edge = render_node(&d, f, 1.0).unwrap();
|
||||
let (d, f, _) = glass_scene(Glass { splay: 1.0, ..bend });
|
||||
let centre = render_node(&d, f, 1.0).unwrap();
|
||||
let diff = |xs: std::ops::Range<u32>, ys: std::ops::Range<u32>| {
|
||||
let mut most = 0;
|
||||
for y in ys {
|
||||
for x in xs.clone() {
|
||||
most = most.max(px(&edge, x, y).iter().zip(px(¢re, x, y)).map(|(a, b)| a.abs_diff(b)).max().unwrap());
|
||||
}
|
||||
}
|
||||
most
|
||||
};
|
||||
// Halfway along the left side the edge's normal and the way from the centre (140, 80) agree:
|
||||
// nearly the same pixels. In the top-left corner they part, and so does what shows there.
|
||||
assert!(diff(80..96, 78..82) <= 8, "mid-left: {}", diff(80..96, 78..82));
|
||||
assert!(diff(80..96, 40..56) > 40, "corner: {}", diff(80..96, 40..56));
|
||||
}
|
||||
|
||||
/// Glass with nothing under it (an export of the pane alone): the light on the bevel still shows,
|
||||
/// the alpha rising with it, and the flat inside and the outside stay clear.
|
||||
#[test]
|
||||
fn glass_over_nothing_still_catches_the_light() {
|
||||
let mut d = Document::new("glass");
|
||||
let p = d.first_page().unwrap();
|
||||
let pane = d.create(p, NodeKind::Rectangle, "Glass", 40.0, 30.0, 120.0, 80.0).unwrap();
|
||||
let n = d.get_mut(pane).unwrap();
|
||||
n.fills.clear();
|
||||
n.effects = vec![Effect { kind: EffectKind::Glass(Glass { light_intensity: 1.0, ..Glass::default() }), visible: true }];
|
||||
let out = render_area(&d, &[pane], Rect::new(0.0, 0.0, 200.0, 140.0), 1.0).unwrap();
|
||||
// The top-left corner faces the light (from the top left): white, as opaque as it is bright.
|
||||
let corner = px(&out, 40, 30);
|
||||
assert!(corner[3] > 100 && corner.iter().all(|v| v.abs_diff(corner[3]) <= 2), "{corner:?}");
|
||||
assert!(out.data().chunks_exact(4).all(|q| q[0] <= q[3] && q[1] <= q[3] && q[2] <= q[3]), "premultiplied");
|
||||
assert_eq!(px(&out, 100, 70), [0; 4]);
|
||||
assert_eq!(px(&out, 10, 10), [0; 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_and_backdrop_blur_survive_bands_and_partial_renders() {
|
||||
let (d, _, _) = glass_scene(Glass::default());
|
||||
|
||||
+724
-102
File diff suppressed because it is too large
Load Diff
@@ -59,25 +59,31 @@ fn cut_diffs(d: &Document, p: NodeId) -> Vec<(u32, (u8, usize))> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pane deeper than the light may reach, over black and lit from the left: across its middle row
|
||||
/// each pixel is the light alone. It fades out smoothly where it stops, with no ring.
|
||||
/// A deep pane over black, lit from the left, at 2× (its 1-unit bevel 2 px wide): across its middle
|
||||
/// row the left rim catches the light, the right rim 0.6 as much, and in between only the faint
|
||||
/// gradient across the curve shows (black can't darken, so the lit half stays black), changing
|
||||
/// smoothly, with no ring.
|
||||
#[test]
|
||||
fn the_light_fades_out_where_it_stops() {
|
||||
fn the_light_is_a_line_on_the_bevel() {
|
||||
let mut d = Document::new("glass");
|
||||
let p = d.first_page().unwrap();
|
||||
let back = d.create(p, NodeKind::Rectangle, "Back", 0.0, 0.0, 700.0, 700.0).unwrap();
|
||||
d.get_mut(back).unwrap().fills = vec![Paint::solid(Color::BLACK)];
|
||||
let pane = d.create(p, NodeKind::Rectangle, "Glass", 50.0, 50.0, 600.0, 600.0).unwrap();
|
||||
let g = Glass { depth: 300.0, splay: 1.0, light_angle: -90.0, light_intensity: 1.0, frost: 0.0, refraction: 0.0, ..Glass::default() };
|
||||
let g = Glass { depth: 300.0, light_angle: -90.0, light_intensity: 1.0, frost: 0.0, refraction: 0.0, ..Glass::default() };
|
||||
let n = d.get_mut(pane).unwrap();
|
||||
n.fills.clear();
|
||||
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
|
||||
let full = render_view_single(&d, p, &View { origin: (0.0, 0.0), scale: 2.0, width: 1400, height: 1400 }, &Options::default()).unwrap();
|
||||
let row = &full.data()[700 * 1400 * 4..][..1400 * 4];
|
||||
let red: Vec<u8> = row.chunks(4).map(|px| px[0]).skip(101).take(598).collect();
|
||||
let step = red.windows(2).map(|w| w[0].abs_diff(w[1])).max().unwrap();
|
||||
assert!(step <= 2, "the light jumps {step} levels between neighbouring pixels");
|
||||
assert!(red[0] > 200 && red[597] < red[0], "lit at the edge, fading in: {} … {}", red[0], red[597]);
|
||||
let red: Vec<u8> = row.chunks(4).map(|px| px[0]).collect();
|
||||
// The pane covers pixels 100..1300 of the row.
|
||||
let (left, right) = (red[100], red[1299]);
|
||||
assert!(left > 230, "the left rim catches the light, less the gradient: {left}");
|
||||
assert!((140..=175).contains(&right), "the right rim 0.6 as much, plus the gradient: {right}");
|
||||
assert!(red[106] <= 14 && red[1293] <= 14, "3 units in, the gradient only: {} {}", red[106], red[1293]);
|
||||
let step = red[104..1296].windows(2).map(|w| w[0].abs_diff(w[1])).max().unwrap();
|
||||
assert!(step <= 2, "the gradient jumps {step} levels between neighbouring pixels");
|
||||
}
|
||||
|
||||
/// Digests of glass renders over a spread of settings, scales and corners: compare two builds'
|
||||
@@ -115,30 +121,39 @@ fn glass_digests() {
|
||||
|
||||
#[test]
|
||||
fn a_cut_through_the_pane_matches_the_whole_render() {
|
||||
// The last look is deep enough that the light is held to `MAX_LIGHT_PX`.
|
||||
// The last two looks are deep, so the curve reaches far in from every cut: 60 units (at 2× a
|
||||
// core 122 px in, which takes the widest margin a core gets) and 300 (too big for a core).
|
||||
let looks = [
|
||||
wide_light(),
|
||||
Glass::default(),
|
||||
Glass { splay: 0.5, frost: 0.0, refraction: 0.0, ..Glass::default() },
|
||||
// Splay with the bend and dispersion on: the dispersed samples follow the turned bend.
|
||||
Glass { splay: 1.0, frost: 0.0, ..Glass::default() },
|
||||
Glass { depth: 60.0, ..Glass::default() },
|
||||
Glass { depth: 300.0, ..wide_light() },
|
||||
];
|
||||
// Rounded corners without the pane's fill: a fill's curved edge rasters 1–2 levels apart in a
|
||||
// cut, and that is the fill, not the glass.
|
||||
for radius in [0.0, 40.0] {
|
||||
// Plain corners work out in closed form; smoothed ones (sharp ones too: any smoothing counts)
|
||||
// from the coverage, the deepest look too big for a core and bending along its normals.
|
||||
for (radius, smoothing) in [(0.0, 0.0), (40.0, 0.0), (0.0, 0.6), (40.0, 0.6)] {
|
||||
for g in &looks {
|
||||
let (mut d, p, pane) = pane_over_stripes(g.clone(), radius);
|
||||
d.get_mut(pane).unwrap().corners.smoothing = smoothing;
|
||||
if radius > 0.0 {
|
||||
d.get_mut(pane).unwrap().fills.clear();
|
||||
}
|
||||
for (cut, diff) in cut_diffs(&d, p) {
|
||||
assert_eq!(diff, (0, 0), "cut at {cut} px, radius {radius}, with {g:?}");
|
||||
assert_eq!(diff, (0, 0), "cut at {cut} px, radius {radius}, smoothing {smoothing}, with {g:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `w` × `h` pane of glass `g` at (`x`, `y`) over stripes, alone on its page: (document, page,
|
||||
/// pane). Each test gives its own size, so tests running at once don't share cached shapes.
|
||||
/// pane). Each test gives its own size, so tests running at once don't share cached shapes. Its
|
||||
/// corners are smoothed, so its fields come from its coverage and are kept (a plain rounded
|
||||
/// rectangle's are worked out in closed form, with nothing to keep).
|
||||
fn pane(g: Glass, x: f64, y: f64, w: f64, h: f64) -> (Document, NodeId, NodeId) {
|
||||
let mut d = Document::new("glass");
|
||||
let p = d.first_page().unwrap();
|
||||
@@ -150,6 +165,7 @@ fn pane(g: Glass, x: f64, y: f64, w: f64, h: f64) -> (Document, NodeId, NodeId)
|
||||
let n = d.get_mut(pane).unwrap();
|
||||
n.transform = Affine::translate((x, y));
|
||||
n.corners.radii = [12.0; 4];
|
||||
n.corners.smoothing = 0.6;
|
||||
n.effects = vec![Effect { kind: EffectKind::Glass(g), visible: true }];
|
||||
(d, p, pane)
|
||||
}
|
||||
@@ -165,6 +181,14 @@ fn computed(f: impl FnOnce()) -> u64 {
|
||||
crate::glass::glass_fields_computed() - before
|
||||
}
|
||||
|
||||
/// How many glass shapes, and how many shapes' bend directions towards their core, `f` works out
|
||||
/// on this thread.
|
||||
fn computed_and_bent(f: impl FnOnce()) -> (u64, u64) {
|
||||
let before = (crate::glass::glass_fields_computed(), crate::glass::glass_bends_computed());
|
||||
f();
|
||||
(crate::glass::glass_fields_computed() - before.0, crate::glass::glass_bends_computed() - before.1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kept_fields_render_the_same_pixels_as_fresh_ones() {
|
||||
let (d, p, _) = pane(Glass::default(), 40.0, 30.0, 211.0, 137.0);
|
||||
@@ -203,6 +227,13 @@ fn what_the_shape_depends_on_works_it_out_again_and_nothing_else_does() {
|
||||
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "part of a pixel");
|
||||
d.get_mut(pane).unwrap().width = 230.0;
|
||||
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "shape");
|
||||
let _ = render(&d, p, (0.0, 0.0), 1.0);
|
||||
// A depth a unit more keeps the same margin: the kept shape is reused, and only the bend
|
||||
// towards the core, which moves with the depth, is worked out again, once (a Depth slider
|
||||
// dragged works out nothing else).
|
||||
set(&mut d, Glass { depth: 21.0, ..Glass::default() });
|
||||
assert_eq!(computed_and_bent(|| drop(render(&d, p, (0.0, 0.0), 1.0))), (0, 1), "depth, within the same margin");
|
||||
assert_eq!(computed_and_bent(|| drop(render(&d, p, (0.0, 0.0), 1.0))), (0, 0), "the same depth again");
|
||||
set(&mut d, Glass { depth: 70.0, ..Glass::default() });
|
||||
assert_eq!(computed(|| drop(render(&d, p, (0.0, 0.0), 1.0))), 1, "depth");
|
||||
}
|
||||
|
||||
+65
-21
@@ -3,22 +3,25 @@
|
||||
//! `CRAFT_FONTS` is empty and native apps use system fonts. Required web releases enforce Inter
|
||||
//! Regular/SemiBold and Noto Sans Georgian Regular after filtering. Recipe: craft-fonts `docs/integration.md`.
|
||||
//! Noto Sans CJK only with `FIGCRAFT_EMBED_CJK` set (the web build's only way to show CJK UI text).
|
||||
//! `FIGCRAFT_WEB_FONTS` (comma-separated families) adds manifest families to the web build.
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Font build script: emit `craft_fonts.rs` from `input_fonts`, passing the
|
||||
/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target and `FIGCRAFT_EMBED_CJK` inputs;
|
||||
/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target, `FIGCRAFT_EMBED_CJK` and `FIGCRAFT_WEB_FONTS` inputs;
|
||||
/// report generation and write failures with `cargo::error`.
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
println!("cargo::rerun-if-env-changed=FIGCRAFT_EMBED_CJK");
|
||||
println!("cargo::rerun-if-env-changed=FIGCRAFT_WEB_FONTS");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from);
|
||||
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let embed_cjk = std::env::var_os("FIGCRAFT_EMBED_CJK").is_some();
|
||||
match input_fonts(dir.as_deref(), wasm, required, embed_cjk) {
|
||||
let web_fonts = web_fonts(&std::env::var("FIGCRAFT_WEB_FONTS").unwrap_or_default());
|
||||
match input_fonts(dir.as_deref(), wasm, required, embed_cjk, &web_fonts) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) => println!("cargo::error={e}"),
|
||||
}
|
||||
@@ -29,21 +32,37 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The families `FIGCRAFT_WEB_FONTS` names: comma-separated, trimmed, empty names dropped.
|
||||
fn web_fonts(list: &str) -> Vec<String> {
|
||||
list.split(',').map(str::trim).filter(|f| !f.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
/// The families in `web_fonts` that `faces` has no face of, in the order named.
|
||||
fn not_embedded<'a>(web_fonts: &'a [String], faces: &[(String, String)]) -> Vec<&'a str> {
|
||||
web_fonts.iter().map(String::as_str).filter(|w| !faces.iter().any(|(family, _)| family == w)).collect()
|
||||
}
|
||||
|
||||
/// Collect the effective catalog and enforce the required web release faces after filtering.
|
||||
fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_cjk: bool) -> Result<String, String> {
|
||||
/// Families named in `web_fonts` that nothing embedded get a warning (most likely misspelt).
|
||||
fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_cjk: bool, web_fonts: &[String]) -> Result<String, String> {
|
||||
let Some(dir) = dir else {
|
||||
for family in web_fonts {
|
||||
println!("cargo::warning=FIGCRAFT_WEB_FONTS: {family:?} isn't embedded: this build has no craft-fonts");
|
||||
}
|
||||
return if required { Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()) } else { Ok(String::new()) };
|
||||
};
|
||||
let mut out = String::new();
|
||||
let mut faces = Vec::new();
|
||||
let mut loaded = false;
|
||||
for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] {
|
||||
let path = dir.join(manifest);
|
||||
if manifest == "fonts/latin-manifest.txt" && !path.exists() {
|
||||
watch(&path);
|
||||
continue;
|
||||
}
|
||||
match craft_fonts(dir, manifest, wasm, embed_cjk) {
|
||||
match craft_fonts(dir, manifest, wasm, embed_cjk, web_fonts) {
|
||||
Ok(catalog) => {
|
||||
loaded = true;
|
||||
out.push_str(&catalog.entries);
|
||||
faces.extend(catalog.faces);
|
||||
}
|
||||
@@ -51,6 +70,11 @@ fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_
|
||||
Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
let why =
|
||||
if loaded { "no manifest row was kept for it (misspelt? CJK also needs FIGCRAFT_EMBED_CJK)" } else { "no craft-fonts manifest was read" };
|
||||
for family in not_embedded(web_fonts, &faces) {
|
||||
println!("cargo::warning=FIGCRAFT_WEB_FONTS: {family:?} isn't embedded: {why}");
|
||||
}
|
||||
if required && wasm {
|
||||
for (family, style) in [("Inter", "Regular"), ("Inter", "SemiBold"), ("Noto Sans Georgian", "Regular")] {
|
||||
if !faces.iter().any(|(f, s)| f == family && s == style) {
|
||||
@@ -82,9 +106,9 @@ struct Catalog {
|
||||
|
||||
/// Parse a font manifest of `family | style | file | scripts` rows into `CraftFont`
|
||||
/// entries: skip Adobe-authored families, skip CJK unless requested, keep the web catalog
|
||||
/// to its budgeted families, canonicalize and watch each face, and emit `include_bytes!`
|
||||
/// items.
|
||||
fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result<Catalog, String> {
|
||||
/// to its budgeted families and the ones `web_fonts` adds, canonicalize and watch each face, and
|
||||
/// emit `include_bytes!` items.
|
||||
fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool, web_fonts: &[String]) -> Result<Catalog, String> {
|
||||
let manifest = dir.join(manifest_rel);
|
||||
watch(&manifest);
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
@@ -105,9 +129,11 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk:
|
||||
continue;
|
||||
}
|
||||
// Web builds have a size budget: Inter, Noto Sans Georgian (small, for the Georgian UI), only the
|
||||
// SemiBold wordmark, and optionally Noto Sans CJK (every CJK script, for CJK UI text).
|
||||
// SemiBold wordmark, optionally Noto Sans CJK (every CJK script, for CJK UI text), and the
|
||||
// families a distribution names in `FIGCRAFT_WEB_FONTS` (a Thai face for Thai text, say).
|
||||
let wordmark = *family == "Archivo" && *style == "SemiBold";
|
||||
if wasm && !matches!(*family, "Inter" | "Noto Sans Georgian") && !wordmark && !cjk {
|
||||
let added = web_fonts.iter().any(|f| f == family);
|
||||
if wasm && !matches!(*family, "Inter" | "Noto Sans Georgian") && !wordmark && !cjk && !added {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
@@ -161,9 +187,9 @@ mod tests {
|
||||
fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() {
|
||||
let f = Fixture::new();
|
||||
std::fs::write(f.0.join("fonts/latin-manifest.txt"), include_str!("tests/fixtures/latin-families.txt")).unwrap();
|
||||
let native = craft_fonts(&f.0, "fonts/latin-manifest.txt", false, false).unwrap();
|
||||
let native = craft_fonts(&f.0, "fonts/latin-manifest.txt", false, false, &[]).unwrap();
|
||||
assert_eq!(native.faces.len(), 43, "all prior permitted presentation faces remain available");
|
||||
let web = craft_fonts(&f.0, "fonts/latin-manifest.txt", true, false).unwrap();
|
||||
let web = craft_fonts(&f.0, "fonts/latin-manifest.txt", true, false, &[]).unwrap();
|
||||
assert_eq!(web.faces.len(), 4, "the web catalog keeps only the four Inter faces");
|
||||
for catalog in [&native, &web] {
|
||||
assert!(!catalog.entries.contains("Source Sans 3"));
|
||||
@@ -171,16 +197,16 @@ mod tests {
|
||||
}
|
||||
assert!(native.faces.iter().any(|(family, _)| family == "Carlito"));
|
||||
assert!(native.faces.iter().any(|(family, _)| family == "Liberation Serif"));
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).is_ok());
|
||||
assert!(input_fonts(Some(&f.0), true, true, false, &[]).is_ok());
|
||||
}
|
||||
|
||||
/// Asserts required web input rejects a catalog missing Inter Regular or Inter SemiBold.
|
||||
#[test]
|
||||
fn required_web_rejects_core_only_and_partial_latin_catalogs() {
|
||||
let f = Fixture::new();
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter Regular"));
|
||||
assert!(input_fonts(Some(&f.0), true, true, false, &[]).unwrap_err().contains("Inter Regular"));
|
||||
f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Unrelated", "SemiBold")]);
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold"));
|
||||
assert!(input_fonts(Some(&f.0), true, true, false, &[]).unwrap_err().contains("Inter SemiBold"));
|
||||
}
|
||||
|
||||
/// Asserts required web input embeds the Inter and Georgian catalogs (three entries) and
|
||||
@@ -189,12 +215,12 @@ mod tests {
|
||||
fn required_web_embeds_both_catalogs_and_requires_georgian() {
|
||||
let f = Fixture::new();
|
||||
f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]);
|
||||
let entries = input_fonts(Some(&f.0), true, true, false).unwrap();
|
||||
let entries = input_fonts(Some(&f.0), true, true, false, &[]).unwrap();
|
||||
assert_eq!(entries.matches("CraftFont {").count(), 3);
|
||||
assert!(entries.contains("family: \"Inter\""));
|
||||
assert!(entries.contains("family: \"Noto Sans Georgian\""));
|
||||
f.manifest("manifest.txt", &[("Other", "Regular")]);
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular"));
|
||||
assert!(input_fonts(Some(&f.0), true, true, false, &[]).unwrap_err().contains("Noto Sans Georgian Regular"));
|
||||
}
|
||||
|
||||
/// Asserts absent font input is empty when optional and an error when required, while present
|
||||
@@ -202,10 +228,10 @@ mod tests {
|
||||
#[test]
|
||||
fn optional_and_native_inputs_keep_their_existing_policy() {
|
||||
let f = Fixture::new();
|
||||
assert!(input_fonts(None, true, false, false).unwrap().is_empty());
|
||||
assert!(input_fonts(None, true, true, false).unwrap_err().contains("CRAFT_FONTS_DIR is missing"));
|
||||
assert!(input_fonts(Some(&f.0), true, false, false).unwrap().contains("Noto Sans Georgian"));
|
||||
assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian"));
|
||||
assert!(input_fonts(None, true, false, false, &[]).unwrap().is_empty());
|
||||
assert!(input_fonts(None, true, true, false, &[]).unwrap_err().contains("CRAFT_FONTS_DIR is missing"));
|
||||
assert!(input_fonts(Some(&f.0), true, false, false, &[]).unwrap().contains("Noto Sans Georgian"));
|
||||
assert!(input_fonts(Some(&f.0), false, true, false, &[]).unwrap().contains("Noto Sans Georgian"));
|
||||
}
|
||||
|
||||
/// Asserts required web input rejects a manifest whose face file is missing, even when every
|
||||
@@ -215,6 +241,24 @@ mod tests {
|
||||
let f = Fixture::new();
|
||||
f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]);
|
||||
std::fs::remove_file(f.0.join("fonts/face.ttf")).unwrap();
|
||||
assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("fonts/face.ttf"));
|
||||
assert!(input_fonts(Some(&f.0), true, true, false, &[]).unwrap_err().contains("fonts/face.ttf"));
|
||||
}
|
||||
|
||||
/// Asserts `FIGCRAFT_WEB_FONTS` (split on commas, trimmed) adds the families it names to the web
|
||||
/// catalog only, leaves native and unset builds as they were, and reports a family nothing
|
||||
/// embedded.
|
||||
#[test]
|
||||
fn web_fonts_add_the_families_they_name_to_the_web_catalog() {
|
||||
let f = Fixture::new();
|
||||
f.manifest("manifest.txt", &[("Noto Sans Georgian", "Regular"), ("Noto Sans Thai", "Regular"), ("Noto Sans Hebrew", "Regular")]);
|
||||
let named = web_fonts(" Noto Sans Thai, ,Noto Sans Thia ");
|
||||
assert_eq!(named, ["Noto Sans Thai", "Noto Sans Thia"]);
|
||||
let families = |c: &Catalog| c.faces.iter().map(|(family, _)| family.clone()).collect::<Vec<_>>();
|
||||
let web = craft_fonts(&f.0, "fonts/manifest.txt", true, false, &named).unwrap();
|
||||
assert_eq!(families(&web), ["Noto Sans Georgian", "Noto Sans Thai"], "the named family joins; the others stay out");
|
||||
assert_eq!(families(&craft_fonts(&f.0, "fonts/manifest.txt", true, false, &[]).unwrap()), ["Noto Sans Georgian"], "unset: as before");
|
||||
assert_eq!(craft_fonts(&f.0, "fonts/manifest.txt", false, false, &[]).unwrap().faces.len(), 3, "native builds embed every family");
|
||||
assert_eq!(not_embedded(&named, &web.faces), ["Noto Sans Thia"], "a misspelt family is reported");
|
||||
assert!(input_fonts(Some(&f.0), true, false, false, &named).unwrap().contains("family: \"Noto Sans Thai\""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,7 @@ name = "English"
|
||||
"Another server, or one that needs its key. The usual server is set in Preferences." = "Another server, or one that needs its key. The usual server is set in Preferences."
|
||||
"Any status" = "Any status"
|
||||
"Anyone on your network with this code can edit the file, while this app stays open." = "Anyone on your network with this code can edit the file, while this app stays open."
|
||||
"Anyone on your network with this code can look at the file, not change it, while this app stays open." = "Anyone on your network with this code can look at the file, not change it, while this app stays open."
|
||||
"Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them." = "Anyone with the edit link can change the file; the view link only lets people look. The links carry their secrets: share them like passwords. `figcraft-server links` makes more and revokes them."
|
||||
"Anyone with this code can edit the file." = "Anyone with this code can edit the file."
|
||||
"Anyone with this code can look at the file, not change it." = "Anyone with this code can look at the file, not change it."
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
//! The Design panel's boolean operation menu leads each entry with its operation's icon (#2003),
|
||||
//! and each entry still runs its command: drawn headless.
|
||||
|
||||
use egui::accesskit::Node;
|
||||
use egui::{Event, PointerButton, Pos2, Rect, Shape, pos2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::icons::Icon;
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
const OPS: [(&str, Icon, &str); 4] = [
|
||||
("Union", Icon::Union, "bool.union"),
|
||||
("Subtract", Icon::Subtract, "bool.subtract"),
|
||||
("Intersect", Icon::Intersect, "bool.intersect"),
|
||||
("Exclude", Icon::Exclude, "bool.exclude"),
|
||||
];
|
||||
|
||||
fn id(v: serde_json::Value) -> NodeId {
|
||||
serde_json::from_value(v["id"].clone()).unwrap()
|
||||
}
|
||||
|
||||
/// Two overlapping layers selected, headless.
|
||||
fn two_layers() -> Ui {
|
||||
let mut s = Session::new();
|
||||
let a = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
|
||||
let b = id(s.execute("shape.ellipse", &json!({"x": 50, "y": 50, "width": 100, "height": 100})).unwrap());
|
||||
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.frame(vec![]);
|
||||
u
|
||||
}
|
||||
|
||||
fn nodes(u: &mut Ui) -> Vec<Node> {
|
||||
u.ctx.enable_accesskit();
|
||||
u.frame(vec![]);
|
||||
u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect()
|
||||
}
|
||||
|
||||
fn bounds(n: &Node) -> Rect {
|
||||
let b = n.bounds().unwrap();
|
||||
Rect::from_min_max(pos2(b.x0 as f32, b.y0 as f32), pos2(b.x1 as f32, b.y1 as f32))
|
||||
}
|
||||
|
||||
fn click(u: &mut Ui, at: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
u.frame(vec![
|
||||
Event::PointerMoved(at),
|
||||
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu open: the nodes on screen.
|
||||
fn open_menu(u: &mut Ui) -> Vec<Node> {
|
||||
let all = nodes(u);
|
||||
let button = all.iter().find(|n| n.label() == Some("Boolean groups")).expect("the Design panel's Boolean groups button");
|
||||
click(u, bounds(button).center());
|
||||
for _ in 0..10 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
nodes(u)
|
||||
}
|
||||
|
||||
/// How many strokes and fills sit in the left 40 points of `row` (where the icon is).
|
||||
fn marks_in_lead(shapes: &[egui::epaint::ClippedShape], row: Rect) -> usize {
|
||||
fn walk(s: &Shape, area: Rect, n: &mut usize) {
|
||||
match s {
|
||||
Shape::Vec(v) => v.iter().for_each(|s| walk(s, area, n)),
|
||||
Shape::Path(_) | Shape::Mesh(_) | Shape::Rect(_) | Shape::LineSegment { .. } if area.contains_rect(s.visual_bounding_rect()) => *n += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let area = Rect::from_min_max(row.min, pos2(row.min.x + 40.0, row.max.y));
|
||||
let mut n = 0;
|
||||
shapes.iter().for_each(|c| walk(&c.shape, area, &mut n));
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_boolean_menu_leads_every_operation_with_its_icon() {
|
||||
let mut u = two_layers();
|
||||
let all = open_menu(&mut u);
|
||||
let out = u.frame_output(vec![]);
|
||||
for (name, _, _) in OPS {
|
||||
let row = all.iter().find(|n| n.label() == Some(name)).unwrap_or_else(|| panic!("{name} in the menu"));
|
||||
let n = marks_in_lead(&out.shapes, bounds(row));
|
||||
assert!(n >= 2, "{name}'s icon painted at the start of its row ({n} marks)");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_boolean_menu_entry_still_runs_its_command() {
|
||||
for (name, icon, _) in OPS {
|
||||
let mut u = two_layers();
|
||||
let all = open_menu(&mut u);
|
||||
let row = all.iter().find(|n| n.label() == Some(name)).unwrap_or_else(|| panic!("{name} in the menu"));
|
||||
click(&mut u, bounds(row).center());
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let sel = u.app.session.selection();
|
||||
assert_eq!(sel.len(), 1, "{name} made one boolean group");
|
||||
assert_eq!(crate::layers::node_icon(u.app.session.doc().unwrap(), sel[0]), icon, "{name}'s group shows {icon:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The x every label (checked or not, with an icon or without, Flatten) starts at.
|
||||
fn label_xs(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, f32)> {
|
||||
fn walk(s: &Shape, out: &mut Vec<(String, f32)>) {
|
||||
match s {
|
||||
Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
Shape::Text(t) => out.push((t.galley.text().to_owned(), t.pos.x)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
shapes.iter().for_each(|c| walk(&c.shape, &mut out));
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_boolean_menu_label_starts_at_the_same_x() {
|
||||
for checked in [false, true] {
|
||||
let mut u = two_layers();
|
||||
if checked {
|
||||
u.app.run("bool.union", json!({}));
|
||||
u.frame(vec![]);
|
||||
}
|
||||
open_menu(&mut u);
|
||||
let out = u.frame_output(vec![]);
|
||||
let xs = label_xs(&out.shapes);
|
||||
let at: Vec<f32> = ["Union", "Subtract", "Intersect", "Exclude", "Flatten"]
|
||||
.iter()
|
||||
.map(|n| xs.iter().rev().find(|(t, _)| t == n).unwrap_or_else(|| panic!("{n} label (checked: {checked})")).1)
|
||||
.collect();
|
||||
assert!(at.iter().all(|x| (x - at[0]).abs() < 0.01), "labels line up (checked: {checked}): {at:?}");
|
||||
}
|
||||
}
|
||||
@@ -195,21 +195,37 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers
|
||||
/// labels, handles, comments, versions/branch/dev/live UI and the cursor.
|
||||
pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) {
|
||||
let rect = ui.max_rect();
|
||||
app.canvas.rect = rect;
|
||||
let old_rect = std::mem::replace(&mut app.canvas.rect, rect);
|
||||
let ctx = ui.ctx().clone();
|
||||
let ppp = ctx.pixels_per_point();
|
||||
let resp = ui.interact(rect, ui.scope_id().with("canvas"), Sense::click_and_drag());
|
||||
let key_box = app.ui.key_box;
|
||||
crate::a11y::canvas(&mut app.ui.nav, &resp, || key_box.map_or_else(|| describe(&app.session), describe_box));
|
||||
if let Ok(ds) = app.session.ds_mut()
|
||||
&& ((ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5)
|
||||
{
|
||||
// Keep the centre; the first sizing after opening re-fits.
|
||||
if let Ok(ds) = app.session.ds_mut() {
|
||||
let resized = (ds.view.vw - f64::from(rect.width())).abs() > 0.5 || (ds.view.vh - f64::from(rect.height())).abs() > 0.5;
|
||||
// The first sizing after opening re-fits.
|
||||
let first = ds.view.vw == 1200.0 && ds.view.vh == 800.0;
|
||||
ds.view.vw = f64::from(rect.width());
|
||||
ds.view.vh = f64::from(rect.height());
|
||||
if first {
|
||||
ds.zoom_to_fit();
|
||||
// While a button is held that went down on the canvas (a click that selects shows the
|
||||
// right panel, minimized, and a click that deselects hides it; the left panel and rail
|
||||
// can appear too) the page point under a still pointer stays where it is: the gesture's
|
||||
// own pointer events would otherwise map to another page point and the move handling
|
||||
// would take that for a drag. `to_page` is `origin + (p - rect.min) / z` with
|
||||
// `origin = c - vw / 2 / z`, so the centre moves by half the size change plus the
|
||||
// rect's own move, over the zoom.
|
||||
let held = resp.is_pointer_button_down_on() && !first && old_rect.is_finite();
|
||||
let z = ds.view.zoom.max(1e-9);
|
||||
let (dw, dh) = if resized { (f64::from(rect.width()) - ds.view.vw, f64::from(rect.height()) - ds.view.vh) } else { (0.0, 0.0) };
|
||||
if resized {
|
||||
// Otherwise keep the centre.
|
||||
ds.view.vw = f64::from(rect.width());
|
||||
ds.view.vh = f64::from(rect.height());
|
||||
if first {
|
||||
ds.zoom_to_fit();
|
||||
}
|
||||
}
|
||||
if held {
|
||||
ds.view.cx += (dw / 2.0 + f64::from(rect.min.x - old_rect.min.x)) / z;
|
||||
ds.view.cy += (dh / 2.0 + f64::from(rect.min.y - old_rect.min.y)) / z;
|
||||
}
|
||||
}
|
||||
let own_cursor = input(app, ui, &resp);
|
||||
|
||||
@@ -187,13 +187,18 @@ fn setup() -> (Ui, NodeId, NodeId) {
|
||||
(u, a, b)
|
||||
}
|
||||
|
||||
/// The field named `name` in the Design panel: the middle of its value, and what it shows.
|
||||
/// The topmost field named `name` in the Design panel: its value centre and displayed text.
|
||||
/// Layer opacity precedes the equally named opacity fields in paint rows.
|
||||
fn field(u: &mut Ui, name: &str) -> (Pos2, String) {
|
||||
u.frame(vec![]);
|
||||
let out = u.frame_output(vec![]);
|
||||
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
|
||||
let n =
|
||||
nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::SpinButton && n.label() == Some(name)).unwrap_or_else(|| panic!("no field {name:?}"));
|
||||
let n = nodes
|
||||
.iter()
|
||||
.map(|(_, n)| n)
|
||||
.filter(|n| n.role() == Role::SpinButton && n.label() == Some(name))
|
||||
.min_by(|a, b| a.bounds().map_or(f64::INFINITY, |r| r.y0).total_cmp(&b.bounds().map_or(f64::INFINITY, |r| r.y0)))
|
||||
.unwrap_or_else(|| panic!("no field {name:?}"));
|
||||
let b = n.bounds().expect("bounds");
|
||||
(pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32), n.value().unwrap_or_default().to_string())
|
||||
}
|
||||
@@ -203,11 +208,26 @@ fn label(u: &mut Ui, name: &str) -> Pos2 {
|
||||
u.frame(vec![]);
|
||||
let out = u.frame_output(vec![]);
|
||||
let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes;
|
||||
let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::SpinButton && n.label() == Some(name)).unwrap();
|
||||
let n = nodes
|
||||
.iter()
|
||||
.map(|(_, n)| n)
|
||||
.filter(|n| n.role() == Role::SpinButton && n.label() == Some(name))
|
||||
.min_by(|a, b| a.bounds().map_or(f64::INFINITY, |r| r.y0).total_cmp(&b.bounds().map_or(f64::INFINITY, |r| r.y0)))
|
||||
.unwrap();
|
||||
let b = n.bounds().unwrap();
|
||||
pos2(b.x0 as f32 - 12.0, ((b.y0 + b.y1) / 2.0) as f32)
|
||||
}
|
||||
|
||||
/// Layer and fill opacity are both named for assistive technology, even without a visible lead.
|
||||
#[test]
|
||||
fn paint_opacity_has_an_accessible_name() {
|
||||
let (mut u, _, _) = setup();
|
||||
let output = u.frame_output(vec![]);
|
||||
let nodes = output.platform_output.accesskit_update.unwrap().nodes;
|
||||
let opacity_fields = nodes.iter().filter(|(_, n)| n.role() == Role::SpinButton && n.label() == Some("Opacity")).count();
|
||||
assert_eq!(opacity_fields, 2, "both layer and fill opacity must have an accessible name");
|
||||
}
|
||||
|
||||
fn geom(u: &Ui, n: NodeId) -> (f64, f64, f64, f64) {
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
let b = doc.world_bounds(n).unwrap();
|
||||
|
||||
@@ -29,6 +29,8 @@ pub use draw::{paint, paint_outlines};
|
||||
|
||||
icons! {
|
||||
Move,
|
||||
/// Select layers using a colour: selection corners around a colour swatch.
|
||||
SelectColor,
|
||||
Scale,
|
||||
FitContents,
|
||||
Hand,
|
||||
@@ -378,6 +380,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The four boolean operation icons each paint shapes and no two paint the same ones, so the
|
||||
/// buttons can be told apart (#2003).
|
||||
#[test]
|
||||
fn the_boolean_operation_icons_paint_and_differ() {
|
||||
let ctx = egui::Context::default();
|
||||
let ops = [Icon::Union, Icon::Subtract, Icon::Intersect, Icon::Exclude];
|
||||
let painted: Vec<String> = ops
|
||||
.iter()
|
||||
.map(|&icon| {
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(16.0)), icon, Color32::WHITE);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
assert!(!out.shapes.is_empty(), "{icon:?} paints at least one shape");
|
||||
format!("{:?}", out.shapes.iter().map(|c| &c.shape).collect::<Vec<_>>())
|
||||
})
|
||||
.collect();
|
||||
for i in 0..ops.len() {
|
||||
for j in (i + 1)..ops.len() {
|
||||
assert_ne!(painted[i], painted[j], "{:?} and {:?} paint different shapes", ops[i], ops[j]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concave_polygons_triangulate() {
|
||||
let arrow = [pos2(4.0, 2.0), pos2(4.0, 13.6), pos2(6.9, 10.9), pos2(8.8, 14.8), pos2(10.6, 14.0), pos2(8.7, 10.2), pos2(12.6, 10.2)];
|
||||
|
||||
@@ -88,6 +88,11 @@ impl P<'_> {
|
||||
// A hairline around the mesh gives it the same soft edge as the convex fills.
|
||||
self.painter.add(Shape::closed_line(v, Stroke::new(self.px, self.color)));
|
||||
}
|
||||
/// A polygon filled in the icon colour at reduced alpha, so the strokes over it stay readable.
|
||||
fn fill_soft(&self, pts: &[(f32, f32)]) {
|
||||
let soft = P { painter: self.painter, o: self.o, s: self.s, px: self.px, stroke: self.stroke, color: self.color.gamma_multiply(0.4) };
|
||||
soft.fill(pts);
|
||||
}
|
||||
/// Triangles `tris` over `verts` (x, y, alpha): the colour fades between the vertices.
|
||||
fn shade(&self, verts: &[(f32, f32, f32)], tris: &[u32]) {
|
||||
let mut mesh = Mesh::default();
|
||||
@@ -280,6 +285,13 @@ fn draw(p: &P, icon: Icon) {
|
||||
use Icon::*;
|
||||
match icon {
|
||||
Move => p.fill(&[(4.0, 2.0), (4.0, 13.6), (6.9, 10.9), (8.8, 14.8), (10.6, 14.0), (8.7, 10.2), (12.6, 10.2)]),
|
||||
SelectColor => {
|
||||
p.poly(&[(2.5, 5.5), (2.5, 2.5), (5.5, 2.5)], false);
|
||||
p.poly(&[(10.5, 2.5), (13.5, 2.5), (13.5, 5.5)], false);
|
||||
p.poly(&[(2.5, 10.5), (2.5, 13.5), (5.5, 13.5)], false);
|
||||
p.poly(&[(10.5, 13.5), (13.5, 13.5), (13.5, 10.5)], false);
|
||||
p.circle(8.0, 8.0, 2.5);
|
||||
}
|
||||
Scale => {
|
||||
p.rect(2.5, 7.5, 8.5, 13.5, 0.0);
|
||||
p.line(6.0, 10.0, 13.0, 3.0);
|
||||
@@ -386,11 +398,20 @@ fn draw(p: &P, icon: Icon) {
|
||||
ChevronUp => p.poly(&[(4.5, 10.0), (8.0, 6.5), (11.5, 10.0)], false),
|
||||
ChevronRight => p.poly(&[(6.0, 4.5), (9.5, 8.0), (6.0, 11.5)], false),
|
||||
ChevronLeft => p.poly(&[(10.0, 4.5), (6.5, 8.0), (10.0, 11.5)], false),
|
||||
Plus => {
|
||||
p.line(7.5, 2.0, 7.5, 13.0);
|
||||
p.line(2.0, 7.5, 13.0, 7.5);
|
||||
Plus | Minus => {
|
||||
// Snap the shared centre once, then give every arm the same pixel length.
|
||||
// Snapping each line's ends separately can offset its midpoint at small sizes.
|
||||
let c = p.sp(8.0, 8.0);
|
||||
let half = p.stroke.width / 2.0;
|
||||
let reach = (5.0 * p.s / p.px).round() * p.px + half;
|
||||
let mut mesh = Mesh::default();
|
||||
mesh.add_colored_rect(Rect::from_min_max(c - vec2(reach, half), c + vec2(reach, half)), p.color);
|
||||
if icon == Plus {
|
||||
mesh.add_colored_rect(Rect::from_min_max(c - vec2(half, reach), c + vec2(half, -half)), p.color);
|
||||
mesh.add_colored_rect(Rect::from_min_max(c + vec2(-half, half), c + vec2(half, reach)), p.color);
|
||||
}
|
||||
p.painter.add(Shape::mesh(mesh));
|
||||
}
|
||||
Minus => p.line(2.0, 7.5, 13.0, 7.5),
|
||||
Eye | EyeOff => {
|
||||
// An almond (two arcs meeting at the corners) with the pupil.
|
||||
let (r, cy, half) = (7.0f32, 7.5f32, 5.5f32);
|
||||
@@ -427,24 +448,28 @@ fn draw(p: &P, icon: Icon) {
|
||||
p.fill_rect(11.5, 2.0, 14.5, 5.0, 0.0);
|
||||
}
|
||||
Boolean | Union => {
|
||||
p.rect(2.5, 2.5, 9.5, 9.5, 0.0);
|
||||
p.rect(6.5, 6.5, 13.5, 13.5, 0.0);
|
||||
// Two squares offset diagonally: the outline of both together, filled.
|
||||
p.fill_soft(&[(2.5, 2.5), (9.5, 2.5), (9.5, 6.5), (13.5, 6.5), (13.5, 13.5), (6.5, 13.5), (6.5, 9.5), (2.5, 9.5)]);
|
||||
p.poly(&[(2.5, 2.5), (9.5, 2.5), (9.5, 6.5), (13.5, 6.5), (13.5, 13.5), (6.5, 13.5), (6.5, 9.5), (2.5, 9.5)], true);
|
||||
}
|
||||
Subtract => {
|
||||
p.fill_rect(2.0, 2.0, 10.0, 10.0, 0.0);
|
||||
// The first square less the overlap, filled; both squares outlined.
|
||||
p.fill_soft(&[(2.5, 2.5), (9.5, 2.5), (9.5, 6.5), (6.5, 6.5), (6.5, 9.5), (2.5, 9.5)]);
|
||||
p.rect(2.5, 2.5, 9.5, 9.5, 0.0);
|
||||
p.rect(6.5, 6.5, 13.5, 13.5, 0.0);
|
||||
}
|
||||
Intersect => {
|
||||
// Only the overlap filled; both squares outlined.
|
||||
p.fill_soft(&[(6.5, 6.5), (9.5, 6.5), (9.5, 9.5), (6.5, 9.5)]);
|
||||
p.rect(2.5, 2.5, 9.5, 9.5, 0.0);
|
||||
p.rect(6.5, 6.5, 13.5, 13.5, 0.0);
|
||||
p.fill_rect(6.0, 6.0, 10.0, 10.0, 0.0);
|
||||
}
|
||||
Exclude => {
|
||||
p.fill_rect(2.0, 2.0, 10.0, 6.0, 0.0);
|
||||
p.fill_rect(2.0, 6.0, 6.0, 10.0, 0.0);
|
||||
p.fill_rect(10.0, 6.0, 14.0, 14.0, 0.0);
|
||||
p.fill_rect(6.0, 10.0, 10.0, 14.0, 0.0);
|
||||
p.rect(6.5, 6.5, 9.5, 9.5, 0.0);
|
||||
// Both squares less the overlap, filled; both squares outlined.
|
||||
p.fill_soft(&[(2.5, 2.5), (9.5, 2.5), (9.5, 6.5), (6.5, 6.5), (6.5, 9.5), (2.5, 9.5)]);
|
||||
p.fill_soft(&[(13.5, 6.5), (13.5, 13.5), (6.5, 13.5), (6.5, 9.5), (9.5, 9.5), (9.5, 6.5)]);
|
||||
p.rect(2.5, 2.5, 9.5, 9.5, 0.0);
|
||||
p.rect(6.5, 6.5, 13.5, 13.5, 0.0);
|
||||
}
|
||||
Page => {
|
||||
p.poly(&[(3.5, 2.5), (9.5, 2.5), (12.5, 5.5), (12.5, 13.5), (3.5, 13.5)], true);
|
||||
|
||||
@@ -54,6 +54,8 @@ mod async_dialog_tests;
|
||||
#[cfg(test)]
|
||||
mod blend_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod bool_menu_icon_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod canvas_double_click_tests;
|
||||
@@ -255,6 +257,8 @@ mod ui_params_tests;
|
||||
#[cfg(test)]
|
||||
mod ui_scale_tests;
|
||||
#[cfg(test)]
|
||||
mod variable_picker_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_export_ui_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_rename_tests;
|
||||
|
||||
@@ -108,6 +108,9 @@ pub struct LiveUi {
|
||||
focus_join: bool,
|
||||
/// Share on the local network (else online, through the server in Preferences).
|
||||
pub local: bool,
|
||||
/// What the code a local network share starts with lets people do: edit, or only look (the
|
||||
/// edit code then doesn't show until it is picked).
|
||||
pub local_access: Access,
|
||||
/// The Share popover's Advanced part shows; what it sets for a local network share: only
|
||||
/// this computer can join, and the port (empty: the usual one).
|
||||
pub advanced: bool,
|
||||
@@ -852,6 +855,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
}
|
||||
if local {
|
||||
widgets::label(ui, tr("People on your network join with a code. Nothing goes through a server."), t.text_secondary);
|
||||
local_access_choice(app, ui);
|
||||
} else {
|
||||
online_note(app, ui, t, &server);
|
||||
}
|
||||
@@ -861,10 +865,24 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
join_and_advanced(app, ui, t, |app, ui, t| share_advanced(app, ui, t, local, &server));
|
||||
}
|
||||
|
||||
/// What the code of a local network share lets people do, picked before it starts: edit the file
|
||||
/// or only look at it.
|
||||
fn local_access_choice(app: &mut FigApp, ui: &mut Ui) {
|
||||
let levels: Vec<(Access, &str)> = LEVELS.into_iter().filter(|(a, _)| *a != Access::Comment).collect();
|
||||
let labels: Vec<&str> = levels.iter().map(|(_, l)| tr(l)).collect();
|
||||
let at = levels.iter().position(|(a, _)| *a == app.ui.live.local_access).unwrap_or_default();
|
||||
if let Some((a, _)) = widgets::segmented_text(ui, &labels, at).and_then(|i| levels.get(i)) {
|
||||
app.ui.live.local_access = *a;
|
||||
}
|
||||
}
|
||||
|
||||
/// Start sharing the open file on the local network, or online on `server` (the one in
|
||||
/// Preferences).
|
||||
fn start_sharing(app: &mut FigApp, local: bool, server: &str) {
|
||||
let mut params = json!({"via": if local { "local" } else { "online" }});
|
||||
if local {
|
||||
params["access"] = json!(app.ui.live.local_access.id());
|
||||
}
|
||||
if local && app.ui.live.this_computer {
|
||||
params["bind"] = json!("local");
|
||||
}
|
||||
@@ -1068,6 +1086,7 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
|
||||
(true, Access::View) => tr("Anyone with this code can look at the file, not change it."),
|
||||
(true, Access::Comment) => tr("Anyone with this code can look at the file and comment on it, not change it."),
|
||||
(true, Access::Edit) => tr("Anyone with this code can edit the file."),
|
||||
(false, Access::View) => tr("Anyone on your network with this code can look at the file, not change it, while this app stays open."),
|
||||
(false, _) => tr("Anyone on your network with this code can edit the file, while this app stays open."),
|
||||
};
|
||||
widgets::label(ui, who, t.text_secondary);
|
||||
@@ -1081,7 +1100,8 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) {
|
||||
}
|
||||
|
||||
/// What the code shown lets people do, to pick from the levels the share has links for: a server
|
||||
/// has links (and codes) for looking only and for commenting; a session this app hosts doesn't.
|
||||
/// has links (and codes) for looking only and for commenting; a session this app hosts on the
|
||||
/// local network has one for looking only.
|
||||
fn access_choice(app: &mut FigApp, ui: &mut Ui, share: &Share) {
|
||||
let levels: Vec<(Access, &str)> = LEVELS.into_iter().filter(|(a, _)| share.links.as_ref().is_some_and(|l| l.of(*a).is_some())).collect();
|
||||
if levels.len() < 2 {
|
||||
|
||||
@@ -17,6 +17,7 @@ type Queue = Arc<Mutex<Vec<(u64, LiveEvent)>>>;
|
||||
struct Hosting(Queue);
|
||||
|
||||
impl LiveLink for Hosting {
|
||||
/// Host at once, with an invite link and nobody else in the session.
|
||||
fn host(&mut self, session: u64, _: HostOptions) -> Result<SessionInfo, String> {
|
||||
let address = format!("127.0.0.1:{}", 47_899 + session);
|
||||
Ok(SessionInfo {
|
||||
@@ -31,6 +32,7 @@ impl LiveLink for Hosting {
|
||||
address,
|
||||
network: false,
|
||||
access: Access::Edit,
|
||||
view: None,
|
||||
})
|
||||
}
|
||||
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
|
||||
|
||||
@@ -44,6 +44,7 @@ pub(crate) fn joined(h: &mut Harness, access: Access) {
|
||||
address: "127.0.0.1:47900".into(),
|
||||
network: false,
|
||||
access,
|
||||
view: None,
|
||||
};
|
||||
h.send(vec![LiveEvent::Welcome { info, peers: vec![] }, LiveEvent::Sync(w)]);
|
||||
h.frames(2);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sharing with a join code in the window: the Share popover not sharing (online or the local
|
||||
//! network, one Start button, the rest under Advanced), sharing online (the code large, copying,
|
||||
//! what it lets people do, who is here, stop: the file stays on the server, not shared, and can
|
||||
//! be shared again or removed), sharing on the local network, a server without
|
||||
//! be shared again or removed), sharing on the local network (to edit, or to look only), a server without
|
||||
//! codes, joining with the one join field (the popover, the home screen, the Join dialog), its
|
||||
//! errors, the server preference, and an idle popover asking for no frames.
|
||||
|
||||
@@ -13,7 +13,7 @@ use figcraft_engine::figcraft_collab::code::{self, Local};
|
||||
use figcraft_engine::figcraft_collab::message::ClientMsg;
|
||||
use figcraft_engine::live::{
|
||||
Access, DEFAULT_SERVER, Event as LiveEvent, HostOptions, LiveLink, NetReply, NetRequest, Peer, Presence, PublishOptions, Published, Role,
|
||||
SessionInfo, Shared as SharedLinks, SharedLink, Status,
|
||||
SessionInfo, Shared as SharedLinks, SharedLink, Status, WayIn,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -53,9 +53,12 @@ struct Link(Shared);
|
||||
|
||||
/// Where the scripted host listens, and its session's secret.
|
||||
const HOST: Local = Local { ip: std::net::Ipv4Addr::new(192, 168, 1, 20), port: 47_900, secret: 0x123_4567_89AB };
|
||||
/// The same session's view-only secret.
|
||||
const VIEW: Local = Local { secret: 0xBA9_8765_4321, ..HOST };
|
||||
/// A link without a code in it (an address a code can't carry).
|
||||
const PLAIN: &str = "figcraft://join?host=[fd00::1]:47900&file=f&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
|
||||
/// What the scripted link says about a session you are in as `role`, invited with `link`.
|
||||
fn info(role: Role, link: &str) -> SessionInfo {
|
||||
SessionInfo {
|
||||
role,
|
||||
@@ -69,16 +72,20 @@ fn info(role: Role, link: &str) -> SessionInfo {
|
||||
address: "192.168.1.20:47900".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
view: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveLink for Link {
|
||||
/// Host as the network link does: port 6 stands for an address a code can't carry, and a
|
||||
/// share for looking only gets a view link too.
|
||||
fn host(&mut self, session: u64, opts: HostOptions) -> Result<SessionInfo, String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
s.session = session;
|
||||
let link = if opts.port == Some(6) { PLAIN.to_string() } else { HOST.link() };
|
||||
let (link, view) = if opts.port == Some(6) { (PLAIN.to_string(), PLAIN.replace("AbCd", "View")) } else { (HOST.link(), VIEW.link()) };
|
||||
let view = opts.view.then(|| WayIn { url: view.replace("figcraft://join?host=", "ws://"), link: view });
|
||||
s.hosted.push(opts);
|
||||
Ok(info(Role::Host, &link))
|
||||
Ok(SessionInfo { view, ..info(Role::Host, &link) })
|
||||
}
|
||||
fn join(&mut self, session: u64, link: &str, _: &str) -> Result<(), String> {
|
||||
let mut s = self.0.lock().unwrap();
|
||||
@@ -208,6 +215,8 @@ fn document_arrives(h: &mut Harness, script: &Shared) {
|
||||
h.frames(3);
|
||||
}
|
||||
|
||||
/// Not sharing: one way to start (online or the local network, which also offers Can edit / Can
|
||||
/// view) and one to join, with addresses, keys and ports under Advanced.
|
||||
#[test]
|
||||
fn not_sharing_the_popover_offers_one_way_to_start_and_one_to_join() {
|
||||
let (mut h, script) = harness();
|
||||
@@ -223,9 +232,11 @@ fn not_sharing_the_popover_offers_one_way_to_start_and_one_to_join() {
|
||||
}
|
||||
let shapes = click(&mut h, &shapes, "Advanced");
|
||||
assert!(has_text(&shapes, "Publish to another server…") && !has_text(&shapes, "Port"));
|
||||
// The local network: what it means, and its own advanced part.
|
||||
assert!(!has_text(&shapes, "Can view"), "online, what the code lets people do is picked once shared");
|
||||
// The local network: what it means, what the code lets people do, and its own advanced part.
|
||||
let shapes = click(&mut h, &shapes, "Local network");
|
||||
assert!(has_text(&shapes, "People on your network join with a code. Nothing goes through a server."));
|
||||
assert!(has_text(&shapes, "Can edit") && has_text(&shapes, "Can view") && !has_text(&shapes, "Can comment"));
|
||||
assert!(has_text(&shapes, "This computer only") && has_text(&shapes, "Port") && !has_text(&shapes, "Publish to another server…"));
|
||||
assert!(!has_text(&shapes, "About this server"));
|
||||
assert_eq!(crate::live::inspect(&h.app)["local"], true);
|
||||
@@ -377,6 +388,8 @@ fn a_file_that_is_not_shared_can_be_removed_from_the_server_after_asking() {
|
||||
assert!(has_text(&shapes, "Share this file") && has_text(&shapes, "Removed from the server"));
|
||||
}
|
||||
|
||||
/// Sharing on the local network shows the longer code, copies it and its link, and Can view
|
||||
/// shows the view-only code; Advanced picks this computer only and a port.
|
||||
#[test]
|
||||
fn sharing_on_the_local_network_shows_its_longer_code() {
|
||||
let (mut h, script) = harness();
|
||||
@@ -388,15 +401,24 @@ fn sharing_on_the_local_network_shows_its_longer_code() {
|
||||
assert!(has_text(&shapes, t), "sharing shows {t:?}");
|
||||
}
|
||||
assert!(has_text(&shapes, "Anyone on your network with this code can edit the file, while this app stays open."));
|
||||
assert!(!has_text(&shapes, "Can view"), "everyone edits on the local network");
|
||||
assert!(has_text(&shapes, "Can view") && !has_text(&shapes, "Can comment"), "a view-only code too, no comment code");
|
||||
{
|
||||
let s = script.lock().unwrap();
|
||||
assert!(s.hosted[0].short && s.hosted[0].bind == figcraft_engine::live::Bind::Lan && s.hosted[0].port.is_none());
|
||||
assert!(s.hosted[0].short && s.hosted[0].view && s.hosted[0].bind == figcraft_engine::live::Bind::Lan && s.hosted[0].port.is_none());
|
||||
assert!(s.published.is_empty(), "nothing goes to a server");
|
||||
}
|
||||
let shapes = click(&mut h, &shapes, "Copy code");
|
||||
let shapes = click(&mut h, &shapes, "Copy link");
|
||||
assert_eq!(script.lock().unwrap().copied, [shown, HOST.link()]);
|
||||
assert_eq!(script.lock().unwrap().copied, [shown.clone(), HOST.link()]);
|
||||
// Can view shows the view-only code and link, and says what they let people do.
|
||||
let shapes = click(&mut h, &shapes, "Can view");
|
||||
let view = code::grouped(&VIEW.code());
|
||||
assert!(has_text(&shapes, &view) && !has_text(&shapes, &shown));
|
||||
assert!(has_text(&shapes, "Anyone on your network with this code can look at the file, not change it, while this app stays open."));
|
||||
let shapes = click(&mut h, &shapes, "Copy code");
|
||||
let shapes = click(&mut h, &shapes, "Copy link");
|
||||
assert_eq!(script.lock().unwrap().copied[2..], [view, VIEW.link()]);
|
||||
assert_eq!(script.lock().unwrap().hosted.len(), 1, "the same session");
|
||||
// Advanced: this computer only, on a port of your choosing.
|
||||
let shapes = click(&mut h, &shapes, "Stop sharing");
|
||||
let shapes = click(&mut h, &shapes, "Advanced");
|
||||
@@ -407,6 +429,27 @@ fn sharing_on_the_local_network_shows_its_longer_code() {
|
||||
assert!(s.hosted[1].bind == figcraft_engine::live::Bind::Local && s.hosted[1].port == Some(6));
|
||||
}
|
||||
|
||||
/// Can view picked before a local network share starts: the view-only code is the one shown, so
|
||||
/// the edit code isn't on the screen until Can edit is picked.
|
||||
#[test]
|
||||
fn a_local_share_can_start_for_looking_only() {
|
||||
let (mut h, script) = harness();
|
||||
let shapes = open(&mut h);
|
||||
let shapes = click(&mut h, &shapes, "Local network");
|
||||
let shapes = click(&mut h, &shapes, "Can view");
|
||||
assert_eq!(h.app.ui.live.local_access, Access::View);
|
||||
assert_eq!(h.app.session.live().status, Status::Off, "picking starts nothing");
|
||||
let shapes = click(&mut h, &shapes, "Start sharing");
|
||||
let (view, edit) = (code::grouped(&VIEW.code()), code::grouped(&HOST.code()));
|
||||
assert!(has_text(&shapes, &view) && !has_text(&shapes, &edit));
|
||||
assert!(has_text(&shapes, "Anyone on your network with this code can look at the file, not change it, while this app stays open."));
|
||||
assert_eq!(crate::live::inspect(&h.app)["share"]["access"], "view");
|
||||
assert!(script.lock().unwrap().hosted[0].view);
|
||||
// Can edit shows the edit code.
|
||||
let shapes = click(&mut h, &shapes, "Can edit");
|
||||
assert!(has_text(&shapes, &edit) && has_text(&shapes, "Anyone on your network with this code can edit the file, while this app stays open."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_code_the_link_is_shared_instead() {
|
||||
// A server from before join codes.
|
||||
|
||||
@@ -131,6 +131,7 @@ impl Harness {
|
||||
address: "127.0.0.1:47900".into(),
|
||||
network: false,
|
||||
access: figcraft_engine::live::Access::Edit,
|
||||
view: None,
|
||||
};
|
||||
self.send(vec![LiveEvent::Welcome { info, peers: vec![Peer { client_id: 1, name: "Hana".into(), color: "#9747FF".into() }] }]);
|
||||
}
|
||||
|
||||
@@ -129,9 +129,6 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, f64, f64)> {
|
||||
Some(readable) => readable,
|
||||
None => with_pixel_size(encoded_clipboard_image(app)?)?,
|
||||
};
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS {
|
||||
return None;
|
||||
}
|
||||
let density = figcraft_format::pixel_density(&png);
|
||||
let (w, h) = figcraft_format::placed_size(w, h, density, figcraft_engine::cmd::create::PLACED_IMAGE_MAX_SIDE);
|
||||
Some((png, w, h))
|
||||
@@ -140,8 +137,14 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec<u8>, f64, f64)> {
|
||||
const MAX_PASTED_IMAGE_PIXELS: u64 = 100_000_000;
|
||||
|
||||
fn with_pixel_size(png: Vec<u8>) -> Option<(Vec<u8>, (u32, u32))> {
|
||||
let size = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_dimensions().ok()?;
|
||||
Some((png, size))
|
||||
use image::ImageDecoder as _;
|
||||
let decoder = image::ImageReader::new(std::io::Cursor::new(&png)).with_guessed_format().ok()?.into_decoder().ok()?;
|
||||
let (w, h) = decoder.dimensions();
|
||||
if w == 0 || h == 0 || u64::from(w) * u64::from(h) > MAX_PASTED_IMAGE_PIXELS {
|
||||
return None;
|
||||
}
|
||||
image::DynamicImage::from_decoder(decoder).ok()?;
|
||||
Some((png, (w, h)))
|
||||
}
|
||||
|
||||
fn encoded_clipboard_image(app: &mut FigApp) -> Option<Vec<u8>> {
|
||||
|
||||
@@ -48,6 +48,7 @@ type Shared = Arc<Mutex<Script>>;
|
||||
|
||||
struct Link(Shared);
|
||||
|
||||
/// A session you are in as `role` under `name`.
|
||||
fn info(role: Role, name: &str) -> SessionInfo {
|
||||
SessionInfo {
|
||||
role,
|
||||
@@ -61,6 +62,7 @@ fn info(role: Role, name: &str) -> SessionInfo {
|
||||
address: "192.168.1.20:47900".into(),
|
||||
network: true,
|
||||
access: Access::Edit,
|
||||
view: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -346,3 +346,130 @@ fn a_narrow_window_shows_one_side_panel_at_a_time() {
|
||||
let wide = canvas_width(&mut app, vec2(1440.0, 900.0));
|
||||
assert!(wide > 600.0 && wide < 1100.0, "a wide window shows both panels beside the canvas ({wide})");
|
||||
}
|
||||
|
||||
/// Five frames side by side at 46% zoom with the interface minimized and nothing selected.
|
||||
fn minimized_row() -> (Ui, Vec<figcraft_doc::NodeId>) {
|
||||
let mut s = Session::new();
|
||||
let mut ids = Vec::new();
|
||||
for k in 0..5 {
|
||||
let r = s.execute("frame.create", &json!({"x": k * 470, "y": 0, "width": 400, "height": 600})).unwrap();
|
||||
ids.push(figcraft_engine::params::id_of(&r["id"]).unwrap());
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": []})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.run("view.minimize", json!({"on": true}));
|
||||
u.app.run("view.set", json!({"zoom": 0.46, "cx": 1500, "cy": 300}));
|
||||
for _ in 0..4 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
(u, ids)
|
||||
}
|
||||
|
||||
fn frame_x(u: &Ui, id: figcraft_doc::NodeId) -> f64 {
|
||||
u.app.session.doc().unwrap().world_bounds(id).unwrap().x0
|
||||
}
|
||||
|
||||
/// A click on a frame while minimized selects it and shows the right panel (the canvas gets
|
||||
/// narrower): the frame stays where it was, and no step is added to the history.
|
||||
#[test]
|
||||
fn a_click_that_shows_the_right_panel_does_not_move_the_frame() {
|
||||
let (mut u, ids) = minimized_row();
|
||||
assert!(u.app.ui.right_panel_rect.is_none(), "nothing selected: no right panel");
|
||||
let wide = u.app.canvas.rect.width();
|
||||
let steps = u.app.session.ds().unwrap().history.undo.len();
|
||||
let p = u.app.to_screen(1880.0 + 200.0, 300.0);
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]);
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]);
|
||||
for _ in 0..4 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert_eq!(u.app.session.selection(), vec![ids[4]], "the click selects the frame");
|
||||
assert!(u.app.ui.right_panel_rect.is_some(), "the right panel shows");
|
||||
assert!(u.app.canvas.rect.width() < wide - 100.0, "the canvas is narrower");
|
||||
assert_eq!(frame_x(&u, ids[4]), 1880.0, "a click did not move the frame");
|
||||
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps, "no history step");
|
||||
}
|
||||
|
||||
/// A real drag still moves the frame, by the pointer's screen movement over the zoom, in one step.
|
||||
#[test]
|
||||
fn a_drag_after_the_right_panel_shows_moves_the_frame_by_the_pointer() {
|
||||
let (mut u, ids) = minimized_row();
|
||||
let steps = u.app.session.ds().unwrap().history.undo.len();
|
||||
let p = u.app.to_screen(1880.0 + 200.0, 300.0);
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]);
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![Event::PointerMoved(p + vec2(50.0, 0.0))]);
|
||||
u.frame(vec![Event::PointerMoved(p + vec2(100.0, 0.0))]);
|
||||
u.frame(vec![Event::PointerButton {
|
||||
pos: p + vec2(100.0, 0.0),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: Default::default(),
|
||||
}]);
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert!(u.app.ui.right_panel_rect.is_some(), "the right panel shows");
|
||||
let moved = frame_x(&u, ids[4]) - 1880.0;
|
||||
assert!((moved - 100.0 / 0.46).abs() < 6.0, "moved by 100 points over the zoom, got {moved}");
|
||||
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps + 1, "one step");
|
||||
}
|
||||
|
||||
/// Clicking empty canvas deselects and hides the right panel again; the marquee that starts there
|
||||
/// has no offset: nothing moves and nothing is selected.
|
||||
#[test]
|
||||
fn a_click_that_hides_the_right_panel_does_not_move_the_frame() {
|
||||
let (mut u, ids) = minimized_row();
|
||||
u.app.run("select.set", json!({"ids": [ids[4]]}));
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert!(u.app.ui.right_panel_rect.is_some());
|
||||
let p = u.app.to_screen(1880.0 + 200.0, 1000.0);
|
||||
let steps = u.app.session.ds().unwrap().history.undo.len();
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]);
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]);
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert!(u.app.ui.right_panel_rect.is_none(), "deselected: no right panel");
|
||||
assert_eq!(frame_x(&u, ids[4]), 1880.0);
|
||||
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// The left sidebar and rail appearing while a press is held (Minimize toggled off mid-press)
|
||||
/// moves the canvas's left edge: a still pointer keeps its page point, so the release moves nothing.
|
||||
#[test]
|
||||
fn a_held_press_keeps_its_page_point_when_the_canvas_moves() {
|
||||
let (mut u, ids) = minimized_row();
|
||||
u.app.run("select.set", json!({"ids": [ids[3]]}));
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let steps = u.app.session.ds().unwrap().history.undo.len();
|
||||
let left = u.app.canvas.rect.min.x;
|
||||
let p = u.app.to_screen(1880.0 + 200.0, 300.0);
|
||||
u.frame(vec![Event::PointerMoved(p)]);
|
||||
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }]);
|
||||
u.frame(vec![]);
|
||||
let before = u.app.to_page(p);
|
||||
u.app.run("view.minimize", json!({"on": false}));
|
||||
u.frame(vec![]);
|
||||
u.frame(vec![]);
|
||||
assert!(u.app.canvas.rect.min.x > left + 100.0, "the left sidebar and rail moved the canvas's edge");
|
||||
let after = u.app.to_page(p);
|
||||
assert!((after.x - before.x).abs() < 0.5 && (after.y - before.y).abs() < 0.5, "the pointer's page point moved: {before:?} to {after:?}");
|
||||
u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }]);
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert_eq!(frame_x(&u, ids[4]), 1880.0, "the frame did not move");
|
||||
assert_eq!(frame_x(&u, ids[3]), 1410.0);
|
||||
assert_eq!(u.app.session.ds().unwrap().history.undo.len(), steps, "no history step");
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ pub(crate) use inspect::{inspect_section, prop_row};
|
||||
pub(crate) use menu::menu_row_width;
|
||||
#[cfg(test)]
|
||||
pub(crate) use menu::preview_blend;
|
||||
pub use menu::{blend_menu, context_popup, menu_check, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row};
|
||||
pub use menu::{blend_menu, context_popup, menu_check, menu_icon_item, menu_item, menu_item_beside_checks, menu_item_with, menu_popup, menu_row};
|
||||
pub(crate) use menu::{block, menu_row_lit, menu_style, row, row_rect};
|
||||
use paints::{appearance, paints, variable_modes};
|
||||
pub(crate) use stroke::{CAP_ART_W, cap_art, join_label};
|
||||
@@ -89,12 +89,18 @@ impl FigApp {
|
||||
|
||||
/// Apply a field edit through any command: `params` builds the command's params from the value.
|
||||
pub fn field_run(&mut self, edit: Option<FieldEdit>, cmd: &str, params: impl Fn(f64) -> Value) {
|
||||
self.field_run_named(edit, "Edit", cmd, params);
|
||||
}
|
||||
|
||||
/// Apply a field edit with a specific undo label for scrubs; commit only on release.
|
||||
/// Typed edits use `run`; scrub errors are discarded as in `field_run`.
|
||||
pub(crate) fn field_run_named(&mut self, edit: Option<FieldEdit>, label: &str, cmd: &str, params: impl Fn(f64) -> Value) {
|
||||
match edit {
|
||||
Some(FieldEdit::Set(v)) => {
|
||||
self.run(cmd, params(v));
|
||||
}
|
||||
Some(FieldEdit::Scrub(v, done)) => {
|
||||
let _ = self.session.begin_interaction("Edit");
|
||||
let _ = self.session.begin_interaction(label);
|
||||
let _ = self.session.execute(cmd, ¶ms(v));
|
||||
if done {
|
||||
let _ = self.session.commit_interaction();
|
||||
@@ -483,18 +489,17 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
|
||||
(BoolOp::Intersect, "bool.intersect"),
|
||||
(BoolOp::Exclude, "bool.exclude"),
|
||||
] {
|
||||
// Each entry leads with its operation's icon (the result it previews).
|
||||
let key = crate::shortcuts::shortcut(c).unwrap_or("");
|
||||
let picked = match current {
|
||||
Some(cur) => menu_check(ui, cur == op, tr(op.label()), key),
|
||||
None => menu_item(ui, tr(op.label()), key),
|
||||
};
|
||||
let icon = crate::layers::op_icon(op);
|
||||
let picked = menu_icon_item(ui, Some(icon), current.map(|cur| cur == op), tr(op.label()), key);
|
||||
if picked {
|
||||
app.run(c, json!({}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
let key = crate::shortcuts::shortcut("vector.flatten").unwrap_or("");
|
||||
let flatten = if current.is_some() { menu_item_beside_checks(ui, tr("Flatten"), key) } else { menu_item(ui, tr("Flatten"), key) };
|
||||
let flatten = menu_icon_item(ui, None, None, tr("Flatten"), key);
|
||||
if flatten {
|
||||
app.run("vector.flatten", json!({}));
|
||||
ui.close();
|
||||
|
||||
@@ -81,22 +81,30 @@ pub(crate) fn menu_row_width(label_w: f32, trail: f32) -> f32 {
|
||||
/// A menu row (see [`menu_item_with`]); its response tells hovering too. It spans the menu and
|
||||
/// is at least as wide as its label, shortcut and art.
|
||||
pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response {
|
||||
lit_row(ui, label, shortcut, art_w, art, false)
|
||||
lit_row(ui, label, shortcut, art_w, 0.0, art, false)
|
||||
}
|
||||
|
||||
/// A plain menu row highlighted when `lit`, as if hovered: the row Up and Down are on in a menu
|
||||
/// whose search field keeps the keyboard.
|
||||
pub(crate) fn menu_row_lit(ui: &mut Ui, label: &str, lit: bool) -> egui::Response {
|
||||
lit_row(ui, label, "", 0.0, |_, _, _| {}, lit)
|
||||
lit_row(ui, label, "", 0.0, 0.0, |_, _, _| {}, lit)
|
||||
}
|
||||
|
||||
fn lit_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnce(&egui::Painter, Rect, Color32), lit: bool) -> egui::Response {
|
||||
fn lit_row(
|
||||
ui: &mut Ui,
|
||||
label: &str,
|
||||
shortcut: &str,
|
||||
art_w: f32,
|
||||
lead: f32,
|
||||
art: impl FnOnce(&egui::Painter, Rect, Color32),
|
||||
lit: bool,
|
||||
) -> egui::Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let text = ui.painter().layout_no_wrap(label.to_owned(), regular(11.5), Color32::PLACEHOLDER);
|
||||
let keys =
|
||||
(!shortcut.is_empty()).then(|| ui.painter().layout_no_wrap(crate::toolbar::pretty_shortcut(shortcut), regular(11.0), Color32::PLACEHOLDER));
|
||||
let trail = keys.as_ref().map_or(0.0, |k| k.size().x + MENU_PAD).max(art_w);
|
||||
let w = ui.available_width().max(menu_row_width(text.size().x, trail).ceil());
|
||||
let w = ui.available_width().max(menu_row_width(lead + text.size().x, trail).ceil());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
|
||||
a11y::menu_item(&resp, label, shortcut, None);
|
||||
// The keyboard's item is highlighted like the hovered one.
|
||||
@@ -108,7 +116,7 @@ fn lit_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl FnOnc
|
||||
// These menus sit on the theme's popup fill (white in the light theme), not on the dark
|
||||
// `t.menu` of the menu bar: the theme's text, white only on the green highlight (#972).
|
||||
let col = if hovered { t.menu_text } else { t.text };
|
||||
ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col);
|
||||
ui.painter().galley(pos2(r.min.x + MENU_PAD + lead, r.center().y - text.size().y / 2.0), text, col);
|
||||
if let Some(k) = keys {
|
||||
let at = pos2(r.max.x - MENU_PAD - k.size().x, r.center().y - k.size().y / 2.0);
|
||||
ui.painter().galley(at, k, if hovered { t.menu_text } else { t.text_secondary });
|
||||
@@ -154,3 +162,26 @@ pub(crate) fn block(ui: &mut Ui, h: f32, inset_y: f32, add: impl FnOnce(&mut Ui)
|
||||
add(&mut child);
|
||||
r
|
||||
}
|
||||
|
||||
/// Width of the check column of [`menu_icon_item`] (the mark, or nothing, so labels line up).
|
||||
const CHECK_COL: f32 = 14.0;
|
||||
|
||||
/// A menu item in a menu whose entries lead with a 16 px icon (in the row's text colour): the
|
||||
/// boolean operations in the Design panel. `icon` is `None` for an entry without one (Flatten):
|
||||
/// its slot stays empty, so every label starts at the same x. The check mark (`on` is
|
||||
/// `Some(true)`) is drawn in its own column ahead of the icon, so a checked entry's label does
|
||||
/// not move against the unchecked ones.
|
||||
pub fn menu_icon_item(ui: &mut Ui, icon: Option<crate::icons::Icon>, on: Option<bool>, label: &str, shortcut: &str) -> bool {
|
||||
let paint = |p: &egui::Painter, r: Rect, col: Color32| {
|
||||
if on == Some(true) {
|
||||
let mark = p.layout_no_wrap("✓".to_owned(), regular(11.5), col);
|
||||
p.galley(pos2(r.min.x + MENU_PAD, r.center().y - mark.size().y / 2.0), mark, col);
|
||||
}
|
||||
if let Some(icon) = icon {
|
||||
crate::icons::paint(p, Rect::from_min_size(pos2(r.min.x + MENU_PAD + CHECK_COL, r.center().y - 8.0), vec2(16.0, 16.0)), icon, col);
|
||||
}
|
||||
};
|
||||
let resp = lit_row(ui, label, shortcut, 0.0, CHECK_COL + 22.0, paint, false);
|
||||
a11y::menu_item(&resp, label, shortcut, on);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
@@ -447,78 +447,62 @@ fn paint_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &Paint,
|
||||
let cmd = if fills { "fill.set" } else { "stroke.set" };
|
||||
let r = row_rect(ui, 32.0, |ui| {
|
||||
let w = ui.available_width() - 60.0;
|
||||
let (r, _) = ui.allocate_exact_size(vec2(w, FIELD_H), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
|
||||
let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));
|
||||
widgets::paint_swatch(ui.painter(), sw, p);
|
||||
let sresp = ui.interact(sw.expand(2.0), Id::unique(("swatch", fills, i)), Sense::click());
|
||||
app.picker_control(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, &sresp);
|
||||
let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
|
||||
// The variable bound to this paint's colour (its key on the layers: `fill` or `fills/<i>/color`).
|
||||
let bound = app.session.doc().ok().and_then(|d| {
|
||||
let key = ids.first().and_then(|id| d.node(*id)).and_then(|n| figcraft_engine::cmd::lists::paint_binding_key(n, fills, i))?;
|
||||
Some((key.to_string(), crate::variable_ui::bound_name(d, &ids, key)?))
|
||||
});
|
||||
match &p.kind {
|
||||
PaintKind::Solid { .. } if bound.is_some() => {
|
||||
let (key, name) = bound.clone().unwrap_or_default();
|
||||
let chip = Rect::from_min_max(pos2(text_r.min.x - 2.0, r.min.y + 3.0), pos2(text_r.max.x - 20.0, r.max.y - 3.0));
|
||||
child.painter().rect_filled(chip, CornerRadius::same(4), t.hover);
|
||||
child.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let short = name.rsplit('/').next().unwrap_or(&name).to_string();
|
||||
child.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).text(
|
||||
pos2(chip.min.x + 5.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
short,
|
||||
regular(11.0),
|
||||
t.text,
|
||||
);
|
||||
let _ = ui.interact(chip, Id::unique(("bound_paint", fills, i)), Sense::hover()).on_hover_text(&name);
|
||||
let dr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0));
|
||||
let dresp = ui.interact(dr, Id::unique(("detach_paint", fills, i)), Sense::click()).tip(tr("Detach variable"));
|
||||
icons::paint(ui.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary);
|
||||
if dresp.clicked() {
|
||||
app.run("variable.bind", json!({"ids": ids, "field": key, "variable": null}));
|
||||
let (sresp, e) = widgets::paint_field(ui, Id::unique(("swatch", fills, i)), Id::unique(("pop", fills, i)), p, w, |child, text_r| {
|
||||
let r = child.max_rect();
|
||||
// The variable bound to this paint's colour (its key on the layers: `fill` or `fills/<i>/color`).
|
||||
let bound = app.session.doc().ok().and_then(|d| {
|
||||
let key = ids.first().and_then(|id| d.node(*id)).and_then(|n| figcraft_engine::cmd::lists::paint_binding_key(n, fills, i))?;
|
||||
Some((key.to_string(), crate::variable_ui::bound_name(d, &ids, key)?))
|
||||
});
|
||||
match &p.kind {
|
||||
PaintKind::Solid { .. } if bound.is_some() => {
|
||||
let (key, name) = bound.clone().unwrap_or_default();
|
||||
let chip = Rect::from_min_max(pos2(text_r.min.x - 2.0, r.min.y + 3.0), pos2(text_r.max.x - 20.0, r.max.y - 3.0));
|
||||
child.painter().rect_filled(chip, CornerRadius::same(4), t.hover);
|
||||
child.painter().rect_stroke(chip, CornerRadius::same(4), Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let short = name.rsplit('/').next().unwrap_or(&name).to_string();
|
||||
child.painter().with_clip_rect(chip.shrink2(vec2(5.0, 0.0))).text(
|
||||
pos2(chip.min.x + 5.0, r.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
short,
|
||||
regular(11.0),
|
||||
t.text,
|
||||
);
|
||||
let _ = child.interact(chip, Id::unique(("bound_paint", fills, i)), Sense::hover()).on_hover_text(&name);
|
||||
let dr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0));
|
||||
let dresp = child.interact(dr, Id::unique(("detach_paint", fills, i)), Sense::click()).tip(tr("Detach variable"));
|
||||
icons::paint(child.painter(), dr.shrink(3.0), Icon::Detach, t.icon_secondary);
|
||||
if dresp.clicked() {
|
||||
app.run("variable.bind", json!({"ids": ids, "field": key, "variable": null}));
|
||||
}
|
||||
}
|
||||
}
|
||||
PaintKind::Solid { color } => {
|
||||
if let Some(h) = widgets::text_field_inline(
|
||||
&mut child,
|
||||
Id::unique(("hex", fills, i)),
|
||||
&color.to_hex(),
|
||||
text_r.shrink2(vec2(0.0, 4.0)),
|
||||
regular(11.0),
|
||||
) && let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
|
||||
{
|
||||
app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()}));
|
||||
PaintKind::Solid { color } => {
|
||||
if let Some(h) = widgets::text_field_inline(
|
||||
child,
|
||||
Id::unique(("hex", fills, i)),
|
||||
&color.to_hex(),
|
||||
text_r.shrink2(vec2(0.0, 4.0)),
|
||||
regular(11.0),
|
||||
) && let Some(c) = figcraft_color::Color::from_hex_or_name(&h)
|
||||
{
|
||||
app.run(cmd, json!({"ids": ids, "index": i, "color": c.to_css_hex()}));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text);
|
||||
// One layer's image fill: crop it on the canvas.
|
||||
if fills && ids.len() == 1 && matches!(p.kind, PaintKind::Image(_)) {
|
||||
let cr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0));
|
||||
let cresp = ui.interact(cr, Id::unique(("crop_image", i)), Sense::click()).tip(tr("Crop image"));
|
||||
icons::paint(ui.painter(), cr.shrink(3.0), Icon::Crop, if cresp.hovered() { t.icon } else { t.icon_secondary });
|
||||
if cresp.clicked() {
|
||||
app.run("image.crop", json!({"ids": ids, "index": i}));
|
||||
_ => {
|
||||
child.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, paint_label(p), regular(11.0), t.text);
|
||||
// One layer's image fill: crop it on the canvas.
|
||||
if fills && ids.len() == 1 && matches!(p.kind, PaintKind::Image(_)) {
|
||||
let cr = Rect::from_center_size(pos2(text_r.max.x - 9.0, r.center().y), vec2(18.0, 18.0));
|
||||
let cresp = child.interact(cr, Id::unique(("crop_image", i)), Sense::click()).tip(tr("Crop image"));
|
||||
icons::paint(child.painter(), cr.shrink(3.0), Icon::Crop, if cresp.hovered() { t.icon } else { t.icon_secondary });
|
||||
if cresp.clicked() {
|
||||
app.run("image.crop", json!({"ids": ids, "index": i}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max);
|
||||
ui.painter().line_segment([pos2(op_r.min.x, r.min.y + 4.0), pos2(op_r.min.x, r.max.y - 4.0)], Stroke::new(1.0, t.panel));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(op_r));
|
||||
let e = widgets::number_field(
|
||||
&mut child,
|
||||
Id::unique(("pop", fills, i)),
|
||||
Lead::None,
|
||||
Some((f64::from(p.opacity) * 100.0).round()),
|
||||
"%",
|
||||
48.0,
|
||||
1.0,
|
||||
);
|
||||
});
|
||||
app.picker_control(crate::colorpicker::PickerTarget::Paint { fills, index: i, ids: ids.clone() }, &sresp);
|
||||
match e {
|
||||
Some(FieldEdit::Set(v)) => {
|
||||
app.run(cmd, json!({"ids": ids, "index": i, "opacity": (v / 100.0).clamp(0.0, 1.0)}));
|
||||
|
||||
@@ -24,6 +24,12 @@ struct Cache {
|
||||
colors: Arc<Vec<SelectionColor>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct OpacityScrub {
|
||||
targets: Vec<PaintTarget>,
|
||||
opacity: f32,
|
||||
}
|
||||
|
||||
/// Return the cached selection colours when the document and selection match. During an
|
||||
/// interaction reuse an existing matching cache, or return an empty list without discovering
|
||||
/// colours; otherwise discover and cache them.
|
||||
@@ -47,6 +53,7 @@ fn colors(ui: &Ui, document: u64, doc: &Arc<Document>, ids: &[NodeId], interacti
|
||||
colors
|
||||
}
|
||||
|
||||
/// Open or close the picker for frozen paint targets, anchored to the invoking control.
|
||||
pub(crate) fn toggle(app: &mut FigApp, targets: Vec<PaintTarget>, response: Option<&egui::Response>) {
|
||||
let Ok(ds) = app.session.ds() else { return };
|
||||
let target = PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid };
|
||||
@@ -70,97 +77,139 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, ids: &[NodeId
|
||||
let t = Tokens::get(ui.ctx());
|
||||
widgets::section_header(ui, tr("Selection colors"), &[], false);
|
||||
// Bound the panel's height; a document with many distinct colors stays scrollable.
|
||||
ui.add_enabled_ui(!interacting, |ui| {
|
||||
let scrub_key = Id::unique("selection_color_scrub");
|
||||
if !interacting {
|
||||
ui.data_mut(|d| d.remove::<OpacityScrub>(scrub_key));
|
||||
}
|
||||
let scrubbing = ui.data(|d| d.get_temp::<OpacityScrub>(scrub_key));
|
||||
ui.add_enabled_ui(!interacting || scrubbing.is_some(), |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("selection_colors_rows").max_height(240.0).show_rows(ui, 32.0, colors.len(), |ui, range| {
|
||||
for group in &colors[range] {
|
||||
ui.push_id((&group.targets, "selection_color"), |ui| {
|
||||
crate::right::row(ui, 32.0, |ui| {
|
||||
let swatch = widgets::swatch(ui, &group.paint, 24.0).tip(tr("Edit colour"));
|
||||
toggle(app, group.targets.clone(), Some(&swatch));
|
||||
let width = (ui.available_width() - 116.0).max(40.0);
|
||||
let linked_name = group
|
||||
.style
|
||||
.as_ref()
|
||||
.and_then(|id| doc.style(id).map(|s| s.name.as_str()))
|
||||
.or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get_by_id(id).map(|v| v.name.as_str())));
|
||||
if let Some(name) = linked_name {
|
||||
let response = widgets::dropdown(ui, name, width, None);
|
||||
toggle(app, group.targets.clone(), Some(&response));
|
||||
} else if let Some(color) = group.paint.solid_color() {
|
||||
if let Some(hex) = widgets::text_field_named(ui, ui.scope_id().with("hex"), &color.to_hex(), width, tr("Color")) {
|
||||
if let Some(color) = Color::from_hex(&hex) {
|
||||
let mut paint = group.paint.clone();
|
||||
paint.kind = figcraft_color::PaintKind::Solid { color: color.with_alpha(1.0) };
|
||||
if color.a < 1.0 {
|
||||
paint.opacity = color.a;
|
||||
}
|
||||
app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint}));
|
||||
} else {
|
||||
app.toast(tr("Invalid color"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let response = widgets::dropdown(ui, &widgets::paint_name(&group.paint), width, None);
|
||||
toggle(app, group.targets.clone(), Some(&response));
|
||||
}
|
||||
let alpha = format!("{}%", figcraft_doc::trim_num(f64::from(group.paint.opacity) * 100.0));
|
||||
if let Some(value) = widgets::text_field_named(ui, ui.scope_id().with("opacity"), &alpha, 44.0, tr("Opacity"))
|
||||
&& let Ok(value) = value.trim().trim_end_matches('%').parse::<f32>()
|
||||
&& value.is_finite()
|
||||
let width = ui.available_width() - 60.0;
|
||||
let mut paint = group.paint.clone();
|
||||
if let Some(scrub) = &scrubbing
|
||||
&& scrub.targets == group.targets
|
||||
{
|
||||
let mut paint = group.paint.clone();
|
||||
paint.opacity = (value / 100.0).clamp(0.0, 1.0);
|
||||
app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint}));
|
||||
paint.opacity = scrub.opacity;
|
||||
}
|
||||
if linked_name.is_some() {
|
||||
let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") };
|
||||
if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() {
|
||||
app.run("selection.detachColor", json!({"targets": group.targets}));
|
||||
}
|
||||
} else {
|
||||
let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles"));
|
||||
crate::right::menu_popup(&styles).show(|ui| {
|
||||
let mut any = false;
|
||||
for style in &doc.styles {
|
||||
if !matches!(style.kind, StyleKind::Paint { .. }) {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
if crate::right::menu_item(ui, &style.name, "") {
|
||||
app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id}));
|
||||
app.ui.picker = None;
|
||||
ui.close();
|
||||
let mut name_response = None;
|
||||
let (swatch, opacity) = widgets::paint_field(
|
||||
ui,
|
||||
ui.scope_id().with("paint_field"),
|
||||
ui.scope_id().with("opacity"),
|
||||
&paint,
|
||||
width,
|
||||
|child, rect| {
|
||||
if linked_name.is_some() || group.paint.solid_color().is_none() {
|
||||
let name = linked_name.map(str::to_owned).unwrap_or_else(|| widgets::paint_name(&group.paint));
|
||||
child.painter().with_clip_rect(rect).text(
|
||||
rect.left_center(),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
&name,
|
||||
crate::theme::regular(11.0),
|
||||
t.text,
|
||||
);
|
||||
name_response = Some(child.interact(rect, child.scope_id().with("paint_name"), egui::Sense::click()).tip(name));
|
||||
} else if let Some(color) = group.paint.solid_color()
|
||||
&& let Some(hex) = widgets::text_field_inline(
|
||||
child,
|
||||
child.scope_id().with("hex"),
|
||||
&color.to_hex(),
|
||||
rect.shrink2(egui::vec2(0.0, 4.0)),
|
||||
crate::theme::regular(11.0),
|
||||
)
|
||||
{
|
||||
if let Some(color) = Color::from_hex_or_name(&hex) {
|
||||
let mut paint = group.paint.clone();
|
||||
paint.kind = figcraft_color::PaintKind::Solid { color: color.with_alpha(1.0) };
|
||||
if color.a < 1.0 {
|
||||
paint.opacity = color.a;
|
||||
}
|
||||
app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint}));
|
||||
} else {
|
||||
app.toast(tr("Invalid color"));
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
widgets::label(ui, tr("No color styles"), t.text_secondary);
|
||||
}
|
||||
let variables: Vec<_> = doc
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
v.kind == VariableType::Color
|
||||
&& bind_refusal(doc, &group.targets, &v.id).is_none()
|
||||
&& in_scope(doc, &group.targets, &v.id)
|
||||
})
|
||||
.collect();
|
||||
if !variables.is_empty() {
|
||||
ui.separator();
|
||||
widgets::label(ui, tr("Color variables"), t.text_secondary);
|
||||
}
|
||||
for v in variables {
|
||||
if crate::right::menu_item(ui, &v.name, "") {
|
||||
app.run("selection.bindColorVariable", json!({"targets": group.targets, "variable": v.id}));
|
||||
app.ui.picker = None;
|
||||
ui.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
toggle(app, group.targets.clone(), Some(&swatch));
|
||||
if let Some(response) = name_response {
|
||||
toggle(app, group.targets.clone(), Some(&response));
|
||||
}
|
||||
if let Some(edit) = opacity {
|
||||
let (value, scrub, done) = match edit {
|
||||
widgets::FieldEdit::Set(value) => (value, false, false),
|
||||
widgets::FieldEdit::Scrub(value, done) => (value, true, done),
|
||||
};
|
||||
let mut paint = group.paint.clone();
|
||||
paint.opacity = (value / 100.0).clamp(0.0, 1.0) as f32;
|
||||
ui.data_mut(|d| {
|
||||
if scrub && !done {
|
||||
d.insert_temp(scrub_key, OpacityScrub { targets: group.targets.clone(), opacity: paint.opacity });
|
||||
} else {
|
||||
d.remove::<OpacityScrub>(scrub_key);
|
||||
}
|
||||
});
|
||||
app.field_run_named(Some(edit), "Opacity", "selection.setPaint", |_| json!({"targets": group.targets, "paint": paint}));
|
||||
}
|
||||
if icons::button(ui, Icon::Move, 24.0, false, tr("Select layers with this color")).clicked() {
|
||||
app.run("selection.selectColor", json!({"targets": group.targets}));
|
||||
}
|
||||
widgets::tight_end(ui, |ui| {
|
||||
if linked_name.is_some() {
|
||||
let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") };
|
||||
if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() {
|
||||
app.run("selection.detachColor", json!({"targets": group.targets}));
|
||||
}
|
||||
} else {
|
||||
let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles"));
|
||||
crate::right::menu_popup(&styles).show(|ui| {
|
||||
let mut any = false;
|
||||
for style in &doc.styles {
|
||||
if !matches!(style.kind, StyleKind::Paint { .. }) {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
if crate::right::menu_item(ui, &style.name, "") {
|
||||
app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id}));
|
||||
app.ui.picker = None;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
widgets::label(ui, tr("No color styles"), t.text_secondary);
|
||||
}
|
||||
let variables: Vec<_> = doc
|
||||
.variables
|
||||
.variables
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
v.kind == VariableType::Color
|
||||
&& bind_refusal(doc, &group.targets, &v.id).is_none()
|
||||
&& in_scope(doc, &group.targets, &v.id)
|
||||
})
|
||||
.collect();
|
||||
if !variables.is_empty() {
|
||||
ui.separator();
|
||||
widgets::label(ui, tr("Color variables"), t.text_secondary);
|
||||
}
|
||||
for v in variables {
|
||||
if crate::right::menu_item(ui, &v.name, "") {
|
||||
app.run("selection.bindColorVariable", json!({"targets": group.targets, "variable": v.id}));
|
||||
app.ui.picker = None;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if icons::button(ui, Icon::SelectColor, 24.0, false, tr("Select layers with this color")).clicked() {
|
||||
app.run("selection.selectColor", json!({"targets": group.targets}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -181,6 +230,7 @@ mod tests {
|
||||
ctx: egui::Context,
|
||||
app: FigApp,
|
||||
time: f64,
|
||||
modifiers: egui::Modifiers,
|
||||
}
|
||||
|
||||
impl Panel {
|
||||
@@ -192,7 +242,7 @@ mod tests {
|
||||
ids.push(NodeId(value["id"].as_u64().unwrap()));
|
||||
}
|
||||
s.execute("select.set", &json!({"ids":ids})).unwrap();
|
||||
let mut p = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 };
|
||||
let mut p = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0, modifiers: Default::default() };
|
||||
crate::theme::install_fonts(&p.ctx, false, 0);
|
||||
crate::theme::apply(&p.ctx, crate::theme::Tokens::light());
|
||||
for _ in 0..3 {
|
||||
@@ -200,7 +250,9 @@ mod tests {
|
||||
}
|
||||
(p, ids[0], ids[1])
|
||||
}
|
||||
fn frame(&mut self, events: Vec<Event>) -> Vec<(String, Pos2)> {
|
||||
/// Run one panel frame, retaining its shapes and accessibility output for assertions.
|
||||
fn output(&mut self, mut events: Vec<Event>) -> egui::FullOutput {
|
||||
events.insert(0, Event::ModifiersChanged(self.modifiers));
|
||||
self.time += 1.0 / 60.0;
|
||||
let input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 600.0))),
|
||||
@@ -215,6 +267,10 @@ mod tests {
|
||||
section(&mut self.app, ui, &doc, &ids);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
output
|
||||
}
|
||||
fn frame(&mut self, events: Vec<Event>) -> Vec<(String, Pos2)> {
|
||||
let output = self.output(events);
|
||||
fn texts(shape: &Shape, out: &mut Vec<(String, Pos2)>) {
|
||||
match shape {
|
||||
Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.size() / 2.0)),
|
||||
@@ -300,6 +356,127 @@ mod tests {
|
||||
assert!(p.app.ui.last_error.is_none());
|
||||
}
|
||||
|
||||
/// Stored variable ids must never resolve to another variable with that name.
|
||||
#[test]
|
||||
fn linked_variable_name_uses_its_stored_id_when_another_name_matches() {
|
||||
let (mut p, a, _) = Panel::new();
|
||||
p.app.run("variable.createCollection", json!({"name":"Theme"}));
|
||||
let shadow = p.app.run("variable.create", json!({"collection":"Theme","name":"Shadow","type":"color","value":"#ff0000"})).unwrap();
|
||||
let accent = p.app.run("variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#00ff00"})).unwrap();
|
||||
p.app.run("variable.bind", json!({"id":a,"field":"fill","variable":accent["id"]}));
|
||||
p.app.run("variable.update", json!({"variable":shadow["id"],"name":accent["id"]}));
|
||||
assert!(p.app.ui.last_error.is_none());
|
||||
let texts = p.frame(vec![]);
|
||||
assert!(texts.iter().any(|(text, _)| text == "Accent"));
|
||||
assert!(!texts.iter().any(|(text, _)| text == accent["id"].as_str().unwrap()));
|
||||
}
|
||||
|
||||
/// Shared swatches stay distinguishable by their paint values to assistive technology.
|
||||
#[test]
|
||||
fn shared_swatch_announces_its_colour_and_keeps_the_edit_label() {
|
||||
let (mut p, _, _) = Panel::new();
|
||||
p.ctx.enable_accesskit();
|
||||
let output = p.output(vec![]);
|
||||
let nodes = output.platform_output.accesskit_update.unwrap().nodes;
|
||||
let mut values: Vec<_> = nodes
|
||||
.iter()
|
||||
.filter_map(|(_, node)| {
|
||||
if node.role() == egui::accesskit::Role::ColorWell {
|
||||
assert_eq!(node.label(), Some("Edit colour"));
|
||||
Some(node.value().unwrap().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
values.sort();
|
||||
assert_eq!(values, ["0000FF", "FF0000"]);
|
||||
}
|
||||
|
||||
/// Consecutive fine scrubs retain fractional baselines and each get one undo step.
|
||||
#[test]
|
||||
fn fractional_opacity_is_displayed_and_accumulates_across_fine_scrubs() {
|
||||
let (mut p, a, _) = Panel::new();
|
||||
p.app.run("fill.set", json!({"id":a, "opacity":0.999}));
|
||||
let texts = p.frame(vec![]);
|
||||
assert!(texts.iter().any(|(text, _)| text == "99.9%"));
|
||||
let red = texts.into_iter().find(|(text, _)| text == "FF0000").unwrap().1;
|
||||
let start = Pos2::new(167.0, red.y);
|
||||
p.modifiers.alt = true;
|
||||
for expected in [0.989, 0.979] {
|
||||
p.frame(vec![
|
||||
Event::PointerMoved(start),
|
||||
Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: p.modifiers },
|
||||
]);
|
||||
for dx in [-4.0, -8.0, -10.0] {
|
||||
p.frame(vec![Event::PointerMoved(start + vec2(dx, 0.0))]);
|
||||
}
|
||||
p.frame(vec![Event::PointerButton {
|
||||
pos: start - vec2(10.0, 0.0),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: p.modifiers,
|
||||
}]);
|
||||
assert!(p.app.session.ds().unwrap().interaction.is_none());
|
||||
let opacity = p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity;
|
||||
assert!((opacity - expected).abs() < 1e-6, "fine scrub kept fractional baseline: {opacity}");
|
||||
}
|
||||
assert!(p.frame(vec![]).iter().any(|(text, _)| text == "97.9%"));
|
||||
for expected in [0.989, 0.999] {
|
||||
p.app.run("edit.undo", json!({}));
|
||||
let opacity = p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity;
|
||||
assert!((opacity - expected).abs() < 1e-6, "each fine scrub is one undo step: {opacity}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_color_field_edits_hex_and_opacity_and_undoes_each_edit() {
|
||||
let (mut p, a, _) = Panel::new();
|
||||
let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1;
|
||||
p.click(red);
|
||||
p.frame(vec![Event::Text("00FF00".into())]);
|
||||
let enter = Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
|
||||
p.frame(vec![enter.clone()]);
|
||||
assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00");
|
||||
p.frame(vec![]);
|
||||
p.click(Pos2::new(176.0, red.y));
|
||||
p.frame(vec![Event::Text("45".into())]);
|
||||
p.frame(vec![enter]);
|
||||
assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 0.45);
|
||||
p.app.run("edit.undo", json!({}));
|
||||
assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 1.0);
|
||||
p.app.run("edit.undo", json!({}));
|
||||
assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_opacity_field_keeps_dragging_until_release_as_one_undo_step() {
|
||||
let (mut p, a, _) = Panel::new();
|
||||
let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1;
|
||||
let start = Pos2::new(167.0, red.y);
|
||||
p.frame(vec![
|
||||
Event::PointerMoved(start),
|
||||
Event::PointerButton { pos: start, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() },
|
||||
]);
|
||||
for dx in [-10.0, -20.0, -30.0] {
|
||||
p.frame(vec![Event::PointerMoved(start + vec2(dx, 0.0))]);
|
||||
}
|
||||
assert!(p.app.session.ds().unwrap().interaction.is_some(), "the scrub commits only on release");
|
||||
assert!(p.frame(vec![]).iter().any(|(text, _)| text == "70%"), "the live opacity stays visible while scrubbing");
|
||||
p.frame(vec![Event::PointerButton {
|
||||
pos: start - vec2(30.0, 0.0),
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed: false,
|
||||
modifiers: Default::default(),
|
||||
}]);
|
||||
assert!(p.app.session.ds().unwrap().interaction.is_none());
|
||||
assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 0.7);
|
||||
assert_eq!(p.app.session.undo_labels(1), ["Opacity"], "the shared helper preserves the scrub's undo label");
|
||||
p.app.run("edit.undo", json!({}));
|
||||
assert_eq!(p.app.session.doc().unwrap().node(a).unwrap().fills[0].opacity, 1.0);
|
||||
assert_eq!(p.app.session.selection().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_displays_the_linked_style_name_and_the_other_color() {
|
||||
let (mut p, a, b) = Panel::new();
|
||||
|
||||
@@ -423,6 +423,9 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let mut chosen: Option<Pick> = None;
|
||||
let mut close = false;
|
||||
let area = egui::Area::new(Id::unique("variable_picker_area")).order(egui::Order::Foreground).fixed_pos(p.at + vec2(0.0, 4.0)).show(ctx, |ui| {
|
||||
// The whole screen's height, not last frame's, so the list grows back after a search (#2043);
|
||||
// egui keeps the area on the screen.
|
||||
ui.set_max_height(crate::popover::max_height(ctx.content_rect()));
|
||||
egui::Frame::NONE.fill(t.menu).corner_radius(CornerRadius::same(10)).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
|
||||
ui.set_width(240.0);
|
||||
ui.horizontal(|ui| {
|
||||
|
||||
@@ -549,3 +549,18 @@ fn a_clipboard_png_past_the_byte_limit_falls_back_to_the_decoded_image() {
|
||||
let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image");
|
||||
assert_eq!((pasted.width, pasted.height), (2.0, 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clipboard_png_with_a_cut_off_body_falls_back_to_the_decoded_image() {
|
||||
let mut w = Win::new();
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
image::RgbaImage::new(4, 4).write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
let png = png.into_inner();
|
||||
let cut_off = png[..png.len() - 20].to_vec();
|
||||
assert_eq!(image::ImageReader::new(std::io::Cursor::new(&cut_off)).with_guessed_format().unwrap().into_dimensions().unwrap(), (4, 4));
|
||||
*w.clip.borrow_mut() = Clip { png: Some(cut_off), image: Some((2, 2, vec![255; 16])), ..Clip::default() };
|
||||
w.cmd_v();
|
||||
let doc = w.app.session.doc().unwrap();
|
||||
let pasted = doc.nodes().find(|n| n.name == "Pasted image").expect("pasted from the decoded image");
|
||||
assert_eq!((pasted.width, pasted.height), (2.0, 2.0));
|
||||
}
|
||||
|
||||
@@ -177,6 +177,7 @@ impl Popover {
|
||||
ui.set_invisible();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
ui.set_max_height(max_height(screen));
|
||||
// The panel is painted into a place kept before the content: the shadow and the fill
|
||||
// go under it, though its size is only known once the slot is laid out.
|
||||
let bg = ui.painter().add(egui::Shape::Noop);
|
||||
@@ -276,6 +277,16 @@ fn open_pos(anchor: Pos2, size: Vec2, screen: Rect) -> Pos2 {
|
||||
pos2(anchor.x - size.x, y)
|
||||
}
|
||||
|
||||
/// The height a popover's content is offered: the screen's, less [`EDGE`] above and below.
|
||||
///
|
||||
/// egui offers an area the size it had last frame, so content that fills what it is offered (a
|
||||
/// scrolling list) could only ever shrink: a search that narrowed a picker's list left it a few
|
||||
/// rows high after the search was cleared (#2043). The list takes what it needs of this, and the
|
||||
/// placement keeps the panel on the screen.
|
||||
pub(crate) fn max_height(screen: Rect) -> f32 {
|
||||
(screen.height() - 2.0 * EDGE).max(HEADER_H)
|
||||
}
|
||||
|
||||
/// Keeps the panel inside the screen: whole when it fits, its top-left corner when it doesn't.
|
||||
fn clamp(pos: Pos2, size: Vec2, screen: Rect) -> Pos2 {
|
||||
let (x0, y0) = (screen.min.x + EDGE, screen.min.y + EDGE);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
//! The variable picker ("Select variable" on every bindable field, aliases, styles and prototype
|
||||
//! actions) drawn headless: its list follows its rows while it is open — a search that narrows it
|
||||
//! shrinks the picker, clearing the search grows it back — and it stays on the screen (#2043: egui
|
||||
//! offers an area the size it had last frame, so the list used to stay a few rows high).
|
||||
|
||||
use egui::{Id, Pos2, RawInput, Rect, pos2, vec2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
/// A selected rectangle and 30 number variables.
|
||||
fn scene() -> (Ui, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("variable.createCollection", &json!({"name": "Sizes"})).unwrap();
|
||||
for i in 0..30 {
|
||||
s.execute("variable.create", &json!({"collection": "Sizes", "name": format!("w-{}", 100 + 10 * i), "type": "number", "value": i})).unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [r.0]})).unwrap();
|
||||
(Ui::new(s), r)
|
||||
}
|
||||
|
||||
/// Opens the picker for `r`'s stroke weight under `at`.
|
||||
fn open(u: &mut Ui, r: NodeId, at: Pos2) {
|
||||
let screen = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), ..Default::default() };
|
||||
let _ = u.ctx.run_ui(screen, |ui| crate::variable_ui::open(ui, &[r], "strokeWeight", figcraft_doc::VariableType::Number, at));
|
||||
}
|
||||
|
||||
/// The picker's rect after a few frames.
|
||||
fn settled(u: &mut Ui) -> Rect {
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
crate::variable_ui::picker_rect(&u.ctx).expect("the variable picker shows")
|
||||
}
|
||||
|
||||
fn search(u: &mut Ui, q: &str) {
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::unique("variable_picker_query"), q.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_variable_list_grows_back_after_a_search_narrows_it() {
|
||||
let (mut u, r) = scene();
|
||||
open(&mut u, r, pos2(900.0, 200.0));
|
||||
let open = settled(&mut u);
|
||||
assert!(open.height() > 300.0, "the picker lists the variables: {open:?}");
|
||||
search(&mut u, "w-100");
|
||||
let narrowed = settled(&mut u);
|
||||
assert!(narrowed.height() < open.height() - 100.0, "a search for one variable shrinks it: {open:?} → {narrowed:?}");
|
||||
search(&mut u, "");
|
||||
let again = settled(&mut u);
|
||||
assert!((again.height() - open.height()).abs() < 1.0, "clearing the search grows it back: {open:?} → {again:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_variable_picker_opened_near_the_bottom_shows_its_whole_list_on_the_screen() {
|
||||
let (mut u, r) = scene();
|
||||
open(&mut u, r, pos2(900.0, 200.0));
|
||||
let full = settled(&mut u);
|
||||
let (mut u, r) = scene();
|
||||
open(&mut u, r, pos2(900.0, 880.0));
|
||||
let low = settled(&mut u);
|
||||
assert!((low.height() - full.height()).abs() < 1.0, "as tall as higher up: {full:?} vs {low:?}");
|
||||
assert!(low.max.y <= 900.0 && low.min.y >= 0.0, "on the screen: {low:?}");
|
||||
}
|
||||
@@ -1041,6 +1041,35 @@ pub fn checker(painter: &egui::Painter, rect: Rect, cell: f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared Fill/Stroke/Selection colors control: one background, a 16 pt swatch,
|
||||
/// caller-provided colour content, and a separated, scrubbable opacity field.
|
||||
/// The caller owns picker routing and commands; this widget only draws and reports edits.
|
||||
pub fn paint_field(
|
||||
ui: &mut Ui,
|
||||
id: Id,
|
||||
opacity_id: Id,
|
||||
paint: &figcraft_color::Paint,
|
||||
width: f32,
|
||||
content: impl FnOnce(&mut Ui, Rect),
|
||||
) -> (Response, Option<FieldEdit>) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
|
||||
let sw = Rect::from_min_size(pos2(r.min.x + 4.0, r.center().y - 8.0), vec2(16.0, 16.0));
|
||||
paint_swatch(ui.painter(), sw, paint);
|
||||
let swatch = ui.interact(sw.expand(2.0), id, Sense::click()).tip(tr("Edit colour"));
|
||||
let value = if a11y::active(&swatch.ctx) { paint_name(paint) } else { String::new() };
|
||||
a11y::color(&swatch, tr("Edit colour"), &value);
|
||||
let text_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y), pos2(r.max.x - 52.0, r.max.y));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r));
|
||||
content(&mut child, text_r);
|
||||
let op_r = Rect::from_min_max(pos2(r.max.x - 48.0, r.min.y), r.max);
|
||||
ui.painter().line_segment([pos2(op_r.min.x, r.min.y + 4.0), pos2(op_r.min.x, r.max.y - 4.0)], Stroke::new(1.0, t.panel));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(op_r));
|
||||
let opacity = number_field(&mut child, opacity_id, Lead::Named(tr("Opacity")), Some(f64::from(paint.opacity) * 100.0), "%", 48.0, 1.0);
|
||||
(swatch, opacity)
|
||||
}
|
||||
|
||||
/// A paint swatch (with checkerboard under transparent colours).
|
||||
pub fn swatch(ui: &mut Ui, paint: &figcraft_color::Paint, size: f32) -> Response {
|
||||
swatch_named(ui, paint, size, tr("Color"))
|
||||
|
||||
@@ -194,6 +194,68 @@ fn a_click_in_the_colour_picker_keeps_the_style_editor_open() {
|
||||
);
|
||||
}
|
||||
|
||||
/// #2043: the list follows its rows while the picker is open. A search that narrows it shrinks
|
||||
/// the popover, and clearing the search grows it back to the full list (egui offers an area the
|
||||
/// size it had last frame, so the list used to stay a few rows high).
|
||||
#[test]
|
||||
fn the_style_list_grows_back_after_a_search_narrows_it() {
|
||||
use crate::layers_drag_tests::Ui;
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
for i in 0..30 {
|
||||
let name = format!("Blue/{}", 100 + 10 * i);
|
||||
s.execute("style.create", &json!({"type": "paint", "name": name, "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
open_for_selection(&mut u.app, StyleSlot::Fill, None);
|
||||
let height = |u: &mut Ui| {
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
u.app.ui.style_picker_popover.as_ref().expect("the style picker shows").rect.height()
|
||||
};
|
||||
let open = height(&mut u);
|
||||
assert!(open > 300.0, "the picker lists the styles: {open}");
|
||||
u.app.ui.style_picker.as_mut().expect("open").query = "Blue/100".into();
|
||||
let narrowed = height(&mut u);
|
||||
assert!(narrowed < open - 100.0, "a search for one style shrinks it: {open} → {narrowed}");
|
||||
u.app.ui.style_picker.as_mut().expect("open").query.clear();
|
||||
let again = height(&mut u);
|
||||
assert!((again - open).abs() < 1.0, "clearing the search grows it back: {open} → {again}");
|
||||
}
|
||||
|
||||
/// #2043: egui keeps an area's size after it closes, so a picker opened once on a short list (the
|
||||
/// effect styles, none yet) opened again on the colour styles a few rows high.
|
||||
#[test]
|
||||
fn the_style_list_opened_after_a_short_one_shows_every_style_it_fits() {
|
||||
use crate::layers_drag_tests::Ui;
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone();
|
||||
for i in 0..30 {
|
||||
let name = format!("Blue/{}", 100 + 10 * i);
|
||||
s.execute("style.create", &json!({"type": "paint", "name": name, "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap();
|
||||
}
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let height = |u: &mut Ui, slot: StyleSlot| {
|
||||
open_for_selection(&mut u.app, slot, None);
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
let h = u.app.ui.style_picker_popover.as_ref().expect("the style picker shows").rect.height();
|
||||
u.app.ui.style_picker = None;
|
||||
u.app.ui.style_picker_popover = None;
|
||||
u.frame(vec![]);
|
||||
h
|
||||
};
|
||||
let first = height(&mut u, StyleSlot::Fill);
|
||||
let short = height(&mut u, StyleSlot::Effect);
|
||||
assert!(short < first - 100.0, "no effect styles: {short}");
|
||||
let again = height(&mut u, StyleSlot::Fill);
|
||||
assert!((again - first).abs() < 1.0, "the colour styles as tall as the first time: {first} → {again}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_style_drops_move_into_the_folder_above() {
|
||||
let st = |id: &str, name: &str| Style {
|
||||
|
||||
+10
-2
@@ -597,8 +597,16 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
`local`), so a local share is protected by 44 bits, not 128: a code is guessed over the network
|
||||
at the rate the host accepts connections, which makes 44 bits years of trying, but treat it as
|
||||
a secret for the local network, not for the internet. The invite link names the same session.
|
||||
Whoever sees the code learns the host's address. Everyone who joins edits; a host without an
|
||||
IPv4 address gets no code and shares its link.
|
||||
Whoever sees the code learns the host's address. The session has two secrets, so two codes:
|
||||
the **edit code** and a **view-only code** (the same address and port, a 44-bit secret of its
|
||||
own). The host welcomes whoever joins with the view secret as a viewer (`Access::View`): they
|
||||
see every change and everyone's presence, the app makes the file read-only for them, and the
|
||||
session refuses any batch they send anyway, as a server does for its view links. The Share
|
||||
popover picks which code shows (Can edit / Can view, also before sharing starts, so the edit
|
||||
code need never be on screen; `collab.share {access}`, whose `link` and `url` are the ones that
|
||||
go with the code shown, so a view-only share never hands out the edit secret); picking one later changes
|
||||
nothing for who already joined. There is no comment code on the local network. A host without an IPv4
|
||||
address gets no codes and shares its links.
|
||||
- **In the browser** a code joins like a link (the page asks the server over its own WebSocket,
|
||||
then opens the session's); it shares online through the server in Preferences as the desktop
|
||||
app does, and not on the local network (a page can't listen).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); `figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (a file shared on the local network can be shared to look at only: a view-only code next to the edit code, picked in the Share popover before or while sharing)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
@@ -15,7 +15,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address, with an edit code and a view-only code, picked before or while sharing, whose holders see every change and can't make one; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 |
|
||||
| Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) |
|
||||
| Version history: named versions, autosave checkpoints, view before restore, restore, duplicate | yes (cloud) | **done**, saved in the `.figcraft` file: restoring keeps the replaced design as an autosave first, a titled autosave becomes a named version, merging a branch records the file before and after. Versions are per person in a live session (a shared history needs accounts); no comparing two versions (Dev Mode compares a ready-for-dev layer with its state when marked) | — |
|
||||
| Branching and merging | yes (paid plans) | **done, also in live sessions**: branches saved in the `.figcraft` file, each opened in its own tab; Review and merge shows both sides' changes since the branch was made and the conflicts (the same property on both sides, or a layer deleted on one side and edited on the other), each resolved for the branch or main; merge as one undo step (the branch is archived, the main file's history gets a version for the merge), update from main, archive. Each branch keeps its own version history (saved, viewed and restored on the branch tab, kept when it is merged or archived; entries for its creation and updates from main); fills, strokes, effects, layout grids, export settings, guides, interactions, annotations and Dev Mode resources merge item by item. In a live session a branch stays with its author (its ids in their own client space, far above the main file's) and merging it is one edit everyone gets; peers' edits merge against the branch's own base and conflicts resolve as offline; a view link refuses merging. Left: others in the session can't see or open your branches (the server would have to keep them); text runs conflict as a whole; a branch made offline that adds layers merges only outside a session | 1–2 |
|
||||
@@ -26,6 +26,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | minor | M9: a file shared on the local network can be shared read-only. The session the app hosts has a second secret, so a view-only code next to the edit code (same address and port, a 44-bit secret of its own); whoever joins with it is welcomed as a viewer, sees every change and presence, gets the "View only" badge and only the moving tools, and the session refuses their edits. The Share popover offers Can edit / Can view under Local network before sharing starts (the edit code then never shows) and while sharing; `collab.share {via: "local", access: "view"}` does the same (its `link` and `url`, like its `code`, are the view-only way in), and `share` in `collab.peers` lists both codes and links. No comment code on the local network. Numbers unchanged. |
|
||||
| 2026-10-31 | patch | M9, #1665: the document in a live session's welcome goes compressed (deflate) to clients whose hello announces `packed`, about seven times smaller on the wire (a synthetic file of 4,040 layers: 397 KB to 53 KB); older clients keep getting it uncompressed, and the client reads a packed document only up to the size an uncompressed welcome could have had. |
|
||||
| 2026-10-30 | minor | M9, part of #742: a team list on `figcraft-server`. With `team_files = "edit"` (or `comment`, `view`) everyone who reaches the server sees the files shared from it now (a link of theirs has a join code: Share ▸ Start sharing) and joins them by a team list link, `figcraft://join?server=…&file=…&team=1`, which carries no secret: the server hands out the file's team link while it stays shared (`ResolveTeam`, answered like a join code), and Stop sharing revokes it with the others, disconnecting whoever joined with it. Turning the list off or changing its access refuses the old team links at once. `collab.files {refresh}` asks the server in Preferences for its list (`Admin::Team`; not the public server FigCraft ships with), and Home ▸ Online shows it below your own files: a row joins its file, its menu copies the link for the app or, with the server's `web_url`, for the browser build (`<web_url>#join=…`). A browser build served beside its server names it with `<meta name="figcraft-server">`, the page's default for sharing, codes and the team list. No accounts: the list is for a server only your team reaches. Tests: `team_tests` (the server and apps over loopback), `code_tests` (links and the browser's driver), `online_tests` (the Online section), `query_tests` (the page's server). Percentages and hours unchanged |
|
||||
| 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Components, variants, styles and variables parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
|
||||
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
|
||||
@@ -50,6 +50,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|---|---|---|
|
||||
| Publish a file's components, sets, styles and variables (per-asset versions) | done | as local library files |
|
||||
| Enable a library in a file; read-only linked copies | done | |
|
||||
| Go to main component from an instance of a library component | done | opens the library file in a tab, or brings it to the front, on the page holding the main, selected and zoomed to; a variant goes to that variant. Team libraries on a server are refused with a note; no Return to instance yet |
|
||||
| Library updates available; overrides kept | done | a library file saved again within two seconds of being read, at the same length, is read again (its time stamp can't prove it unchanged; #652) |
|
||||
| Team libraries: publish to a `figcraft-server` under the file, enable by a share link (a view link reads it), background update checks | done | [server.md](server.md#team-libraries); no team-wide library list or accounts ([gaps](gaps.md) #10) |
|
||||
| Library analytics | partial | instances per component and the files using them, and the instances each file detached (per component and per file), as apps report them; insertions and detachments week by week for a year (Show usage: the past 30 days, 90 days or year), for components; the layers and files using each style and variable, without insertions, detachments or modes; no chart |
|
||||
@@ -78,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-06 | minor | M3: Go to main component (context menu, Design panel, Ctrl+Alt+Cmd+K) on an instance of a library component opens the library file, as the help center describes for mains kept in another file ("Edit main components"); it used to refuse. The file opens in a tab, or an open tab comes to the front, on the main's page with the main selected and zoomed to: a nested instance goes to its own main, a variant to that variant. A main deleted from the library file since it was published leaves the file open with a note; a library file that moved, a team library on a server and a path off this computer are refused with a message. Test: `library::tests::going_to_a_library_main_opens_the_library_file`. Numbers unchanged |
|
||||
| 2026-11-02 | minor | M3, #1885: glass effects take variables, as the reference tool's do: its light angle, intensity, refraction, depth, dispersion, frost and splay bind to number variables on a layer (`variable.bind`, `effects/<i>/<lightAngle\|lightIntensity\|refraction\|depth\|dispersion\|radius\|splay>`, `radius` being the frost) and in an effect style (`style.bind`), and the Design panel's glass fields take a variable like the shadow and blur fields. A variable reads as the field shows it: intensity, refraction, dispersion and splay in percent. Tests: `binding_lists_tests::glass_fields_take_number_variables`, `effects_tests::glass_fields_take_bound_numbers_as_the_panel_shows_them`. Grade unchanged. |
|
||||
| 2026-11-01 | patch | M3: Delete on a layer inside an instance hides it (an override) instead of being refused ("You can't delete a layer or object from an instance. If you try, Figma will only toggle the layer's visibility instead of removing it", Guide to auto layout); `edit.delete` reports those ids under `hidden`, the structure and the main component stay as they were, one undo step; deleting the instance itself still removes it. Found by a documentation audit of the auto layout page. Test: `components_behaviour_tests::deleting_a_layer_inside_an_instance_hides_it_instead` (the refusal row for it is gone from `what_instances_cannot_override_is_refused`). Numbers unchanged |
|
||||
| 2026-10-31 | minor | M3, gap 10: library analytics cover styles and variables. A file reports how many of its layers use each of a library's styles and variables, and Show usage (`library.usage`) lists them beside the components, by a Type choice, with their layers and files. Layers inside instances count for their component. Insertions, detachments and modes aren't counted for them yet. Numbers unchanged. Tests: `library_assets_tests`. |
|
||||
|
||||
@@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers.
|
||||
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
|
||||
disk or the network at run time; the web build has them too):
|
||||
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
|
||||
username; never hand-edit.
|
||||
username; never hand-edit. Release builds regenerate it for the release commit
|
||||
(`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated.
|
||||
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
|
||||
themselves. **Each contributor adds only their own entry**, in their own PR.
|
||||
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
|
||||
|
||||
+5
-2
@@ -121,8 +121,11 @@ a missing required face, style or asset fails the required web build. Excluded A
|
||||
are skipped before resolving their asset paths.
|
||||
Native builds retain the permitted Latin presentation faces. Source Sans 3 and Source Serif 4
|
||||
are filtered on every target under FigCraft's asset policy. The web build keeps its smaller
|
||||
allowlist: Inter, Noto Sans Georgian and Archivo SemiBold when supplied. Packages carry each
|
||||
embedded font's licence as `OFL-<family>.txt`
|
||||
allowlist: Inter, Noto Sans Georgian and Archivo SemiBold when supplied. A distribution whose
|
||||
users write another script adds the manifest families it needs, comma-separated, with
|
||||
`FIGCRAFT_WEB_FONTS` (`FIGCRAFT_WEB_FONTS="Noto Sans Thai" trunk build --release`); a named family
|
||||
the build doesn't embed gets a warning, and each one adds its size to the wasm, which must stay
|
||||
under the size gate in `packaging/web/package.sh`. Packages carry each embedded font's licence as `OFL-<family>.txt`
|
||||
(`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app).
|
||||
|
||||
### macOS
|
||||
|
||||
@@ -75,6 +75,7 @@ blockers.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-03 | patch | M4, #1747: a text layer still carrying the name its characters gave it is renamed as they are typed, so the Layers panel no longer shows the word a layer started with while the canvas shows another (`hello` typed into `hello there` read as `hello` in the panel). A name the author typed is kept, and only the editor follows the characters: commands leave the name alone, so the layer names `data.fill`, component property bindings and templates use as field keys stay put. Tests: `text_behaviour_tests::typing_renames_a_text_layer_that_still_carries_the_name_its_characters_gave_it`, `setting_characters_as_a_command_never_renames_the_layer`. Grade unchanged |
|
||||
| 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged |
|
||||
| 2026-10-31 | patch | M11: a trackpad or touch tap sends its press and release in one frame, so a plugin panel's field committed in the frame its button was clicked and the value was lost: the Counter's Set count… kept the old count. The plugin now gets both, the field's value first. The `window.figcraft` docs name `screenshot`'s `data`. Numbers unchanged |
|
||||
| 2026-10-31 | patch | M4: Dash and Custom strokes have a Dash cap (None, Round, Square) in the advanced stroke settings and `node.set {dashCap}`, on both ends of every dash on the canvas and in PNG, PDF and SVG. Unset, dashes keep taking the end point caps, so older files look the same; the saved file writes it only when set. Numbers unchanged |
|
||||
|
||||
File diff suppressed because one or more lines are too long
+3
-2
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user