mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 06:44:43 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b7e0afd19 |
No files matched your search
@@ -1,6 +0,0 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
name: test (ubuntu-latest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
run: |
|
||||
@@ -42,12 +42,12 @@ jobs:
|
||||
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \
|
||||
libgl1-mesa-dev libgtk-3-dev
|
||||
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy, rustfmt
|
||||
|
||||
# Only main saves caches; PRs restore main's, so PR caches don't evict main's.
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
cache-on-failure: true
|
||||
@@ -74,11 +74,11 @@ jobs:
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: wasm32
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
@@ -32,4 +32,4 @@ jobs:
|
||||
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
|
||||
with:
|
||||
run-package-check: true
|
||||
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
@@ -24,14 +24,8 @@ jobs:
|
||||
with:
|
||||
app-name: figcraft
|
||||
app-title: FigCraft
|
||||
# Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only
|
||||
# craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter.
|
||||
# Re-pin once one commit has both (storytold/craft-fonts#42).
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
has-server: true
|
||||
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
|
||||
# crates/text/build.rs keeps the web build to Inter.
|
||||
web-craft-fonts: true
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -81,7 +81,6 @@ 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`).
|
||||
|
||||
### Quality gates (before every commit)
|
||||
|
||||
@@ -25,7 +25,6 @@ Release builds embed fonts from the optional craft-fonts build input
|
||||
| Family | Licence | Upstream |
|
||||
|---|---|---|
|
||||
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
|
||||
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
|
||||
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
|
||||
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
|
||||
|
||||
@@ -81,7 +81,6 @@ 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`).
|
||||
|
||||
### Quality gates (before every commit)
|
||||
|
||||
Generated
+258
-204
File diff suppressed because it is too large.
Load diff
+4
-13
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.7.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -34,9 +34,6 @@ figcraft-plugin = { path = "crates/plugin" }
|
||||
|
||||
# Which paths stored in a document may be followed (craft-libs).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
|
||||
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
|
||||
# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`.
|
||||
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -45,8 +42,6 @@ serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
regex = "1.13"
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
proptest = "1"
|
||||
imbl = { version = "7", features = ["serde"] }
|
||||
postcard = { version = "1", default-features = false, features = ["alloc"] }
|
||||
@@ -103,15 +98,11 @@ todo = "deny"
|
||||
unimplemented = "deny"
|
||||
unreachable = "deny"
|
||||
|
||||
# Workspace crates keep file:line in panic backtraces; dependencies carry no debug info. Both make
|
||||
# the test build faster and the target dir smaller.
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.figcraft.png" width="128" height="128" alt="FigCraft icon: an engraved chameleon on a mint-green tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">FigCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -24,9 +20,6 @@
|
||||
<img alt="macOS · Windows · Linux · FreeBSD · Web" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-native-0EA5C6?style=flat-square">
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20%2F%20Apache--2.0-3a3a3a?style=flat-square">
|
||||
<img alt="Status: early alpha" src="https://img.shields.io/badge/status-early%20alpha-d69e2e?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_figcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/figcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -234,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
|
||||
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
|
||||
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
|
||||
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
|
||||
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
|
||||
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
|
||||
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
|
||||
|
||||
+4
-67
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents)
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7)
|
||||
|
||||
FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow,
|
||||
native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through
|
||||
@@ -50,7 +50,7 @@ against real Figma documents. Ready-for-real-work is ~53%, inside alpha's ~40–
|
||||
~75% **and** reliable `.fig` import: the `.fig` import's tail (5–10 h), a real-file corpus with measured
|
||||
fidelity (15–30 h), the interaction pass (25–40 h of the 40–60), core depth to ~80% (40–75 h),
|
||||
basic multiplayer on the landed core (30–55 h), stability and platform QA (20–40 h), a partial GPU path (15–30 h);
|
||||
native speakers' reviews of the nine language drafts need people.
|
||||
native speakers' reviews of the six language drafts need people.
|
||||
|
||||
## By dimension
|
||||
|
||||
@@ -63,7 +63,7 @@ native speakers' reviews of the nine language drafts need people.
|
||||
| Stability | 55% | 20–40 | [gaps](docs/gaps.md) |
|
||||
| Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Hardware | 60% | 10–21 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Localization | 39% (layer; English; Russian and Turkish reviewed by native speakers; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
|
||||
| Localization | 39% (layer; English; Spanish with a Latin American overlay, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese drafts; CJK UI fonts) | 29–51 | [localization-parity](docs/localization-parity.md) |
|
||||
| Platforms | 75% | 10–20 | [hardware-parity](docs/hardware-parity.md) |
|
||||
| Ecosystem / plugins | 38% (own WASM plugins with panels, settings and widgets; a self-hosted plugin directory) | 8–26 | [gaps](docs/gaps.md) |
|
||||
| AI features | 0% | 30–60 | [gaps](docs/gaps.md) |
|
||||
@@ -126,19 +126,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
1. `.fig` import's tail: fixes from real files, connector labels, table cells, filled areas with their own fills (the best-effort import, editable vector networks, the other layer kinds and variable prototype actions landed 2026-10-11): 5–10 h; then checking the rest of the experimental `.fig` export (landed 2026-10-12; written files open and draw in other software) on effects, images, prototypes and real files: 5–15 h.
|
||||
2. Real-file corpus and measured fidelity: 15–30 h.
|
||||
3. Tool-by-tool interaction pass against Figma: 40–60 h.
|
||||
4. Languages: native reviews of the es (with es-419), fr, de, pt-BR, ja, ko, zh-Hans, id and cs drafts (people), the entries added since each catalog was written, then Hindi, Vietnamese and Arabic ([localization-parity](docs/localization-parity.md)).
|
||||
4. Languages: native reviews of the es, fr, de, pt-BR, ja and ko drafts (people), Latin American Spanish (1–2 h), then Simplified Chinese (3–5 h).
|
||||
5. Multiplayer (M9): accounts and teams on top of the live sessions and the standalone server (transport, hosting, invite links that open the app, document sync, presence with remote text carets, cursor chat and spotlight, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, the browser client, `figcraft-server` with persistence and share links to edit or view, team libraries on the server landed): 42–84 h.
|
||||
|
||||
## Progress log
|
||||
|
||||
- **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged.
|
||||
- **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged.
|
||||
- **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged.
|
||||
- **2026-10-15** · Removing someone from a live session (M9, #338): the host or the file's owner removes a person from the avatar's menu or with `collab.removePerson`; they keep their copy and can't rejoin while the session runs. Numbers unchanged.
|
||||
- **2026-10-15** · Languages (M10, #257, #263, #469, #477, #288): Russian (one/few/many plurals) and Turkish catalogs reviewed by native speakers, Czech and Indonesian drafts, the Japanese draft completed; Preferences ▸ Language lists 12 languages and Latin American Spanish. Numbers unchanged.
|
||||
- **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged.
|
||||
- **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged.
|
||||
- **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged.
|
||||
- **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged.
|
||||
- **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged.
|
||||
- **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged.
|
||||
@@ -324,61 +316,6 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged |
|
||||
| 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Removing someone from a live session (#338): right-click a person's avatar ▸ Remove *name* from session, or `collab.removePerson {client}`, when you host the session or own the file on a server; the session ends for them (they keep their copy), the code or link they hold no longer gets them back in while it runs, and everyone else stays. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Pages list (#272, #258): a long pages list scrolls in its own area, so the layers below stay in view; page and layer drops count only inside the list's visible rows. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Variables export in the browser (#368): Export… ▸ Variables JSON / CSS custom properties did nothing in the web build and now arrives as a download; importing there still needs a file picker. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Font picker keys (#352, #345): Up and Down step through the font families from the current one, keeping the highlight in view and previewing it on the canvas; Enter applies it, Escape leaves the text as it was. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Leaving Design ends the pen path (#308, #307): switching to Prototype or Inspect while the Pen draws ends the path and goes back to Move (pressing Design again keeps drawing), and a click on the toolbar no longer reaches the canvas under it. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Vector edit mode keeps the canvas still (#322): the edit mode hints in the Design panel wrap, so entering edit mode and picking a region no longer shift the canvas by about 25 points in the middle of the click (which dragged the region picked). Numbers unchanged |
|
||||
| 2026-10-15 | minor | Drop shadows under a background blur (#317, #255): a layer with both casts its shadows from what it paints (a translucent fill casts a lighter shadow than an opaque one) and reads the backdrop only after drawing them, so a shadow shown behind transparent areas is seen through the blur. Numbers unchanged |
|
||||
| 2026-10-15 | minor | When the window can't open (#306, #261): Windows release builds have no console, so a graphics failure at start quit without a word; the error is now logged and shown in a message box in the interface language, with what to try (unset `WGPU_BACKEND`, update the driver, OpenGL) and where to report it, skipped with `--control`. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Crashes on Windows (#271, #314, #337; #224 and two more): a text field focused before it was drawn aborted the screen-reader layer; double-clicking a page, creating a variable collection and renaming a style folder no longer close the app. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Deeply nested layers (#330, #210): one depth guard (256) on the three recursive layout paths, so a deep file can't run the layout out of stack. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Image memory budgets (#326; #203, #189): decoded images and a file's images have budgets; the decode cache evicts its least recently used entries. Numbers unchanged |
|
||||
| 2026-10-15 | minor | macOS file dialogs (#253, #250) no longer re-enter the event loop; a dialog's result goes to the file and window it was opened for, and dialogs are parented to the main window. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Recovery copies (#297, #303, #331, #336; #202, #185, #187): a copy counts only after its write succeeds and a failed one is retried; saves and closes wait for the write in flight; copies keep version history and branches; a recovered copy stays until this run has its own. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Text edits (#280, #278) no longer roll back earlier changes; a drag released off the canvas ends at the pointer's last canvas position. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Rollback (#282, #301; #196): a failed command rolls back during a drag or other gesture too; a failed batch keeps the redo steps. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Atomic save and export (#284): staging files are uniquely named and created exclusively, so a file or link already at the old fixed staging name is never written through, and two writers of one file no longer share one. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Alt-drag copies (#283) snap to their original, and Duplicate (Cmd+D) then repeats the last copy's offset. Numbers unchanged |
|
||||
| 2026-10-15 | minor | The pen (#277) shows its Bezier handles while a point is dragged out. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Corner handles (#293, #292): a press on a corner handle resizes instead of picking the corner-radius handle. Numbers unchanged |
|
||||
| 2026-10-15 | minor | New text (#372, #362) takes a readable colour from the frame it is drawn in, not only the page (text drawn in a white frame on a dark page was white). Numbers unchanged |
|
||||
| 2026-10-15 | minor | Outline stroke (#386) works on every selected layer. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Send to back (#444) counts per parent when the selection spans frames. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Align buttons (#276) are off when the selection has nothing to align to. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Gradient type icons (#275) show their gradient. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Corner resize cursors (#388) stay diagonal at any proportions; the text selection highlight (#390) stays inside the box on the last line. Numbers unchanged |
|
||||
| 2026-10-15 | minor | New pages (#311, #295) take the current page's background; the theme still decides a new file's page. Numbers unchanged |
|
||||
| 2026-10-15 | minor | A slice's export preview (#265) shows the content under it. Numbers unchanged |
|
||||
| 2026-10-15 | minor | SVG export (#327, #325) keeps a nested frame's fill and stroke inside its parent's clip. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Home screen (#318, #324): recent files notice files deleted while it is open; a long path no longer draws over the file name. Numbers unchanged |
|
||||
| 2026-10-15 | minor | The Small caps sample in Type settings (#355) draws from capitals, no longer a box where the UI font lacks the small-capital letter. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Web clipboard (#375): copy and paste in the browser go through the system clipboard, so a paste after a copy brings the copied layers instead of whatever text the clipboard held. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Zoom previews (#312): when renders from scratch are slow, a new zoom first shows the whole view coarsely instead of the page colour, then the refined image; images of another file or page never show. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Hidden windows (#313, #332; #269): a minimised or covered window answers control and MCP requests (commands, inspection, `ui.render`) at once, each window on its own; screenshots and input still wait for it to be on screen. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Server (#340, #302): a compaction cut short closes the file instead of confirming lost edits (#183); each team library version is kept in its own file (#206). Numbers unchanged |
|
||||
| 2026-10-15 | minor | Counters (#343, #344, #353; #309): collab sequence numbers, layer name counters and opacity digits can't overflow. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Commands refuse bad indexes and ids (#475, #384, #310): `page.select` and `doc.switch`, `effect.set` and `export.update` / `export.remove`, `page.move` with a non-page id. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Find and replace (#358, #361) changes every occurrence, case-insensitively, also when the replacement contains the search text. Numbers unchanged |
|
||||
| 2026-10-15 | minor | The gradient shorthand (#254, #252) is read by the engine, so the CLI, control channel and MCP share it. Numbers unchanged |
|
||||
| 2026-10-15 | minor | .fig import (#279, #281): resized instances lay out their layers by their constraints (#266); explicit variable modes whose collection is named by its asset key, as library collections are, come across (#267). Numbers unchanged |
|
||||
| 2026-10-15 | minor | Colour emoji (#300, #268) draw from their own colour tables (sbix and CBDT/EBDT bitmap strikes, COLR v0 layers), a colour face tried first; COLR v1 still draws outlines. Numbers unchanged |
|
||||
| 2026-10-15 | minor | macOS (#290, #289): `.figcraft` files open from Finder, Open With and the Dock. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Windows (#294, #291): the first preferred language alone picks the UI language. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Unix font discovery (#474) follows `XDG_DATA_HOME`. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged |
|
||||
| 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) |
|
||||
| 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
|
||||
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
|
||||
| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
|
||||
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
|
||||
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
|
||||
|
||||
@@ -29,20 +29,9 @@ fn image_format(e: &str) -> Option<ExportFormat> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
|
||||
/// confined run can only write inside a write root (#833).
|
||||
///
|
||||
/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
|
||||
/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
|
||||
/// writing through it to its target -- a change of behaviour for anyone whose output path is a
|
||||
/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
|
||||
/// `Roots::check` has already refused a link that leads out of the root.
|
||||
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
|
||||
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
}
|
||||
if figcraft_engine::file_access::current().is_some() {
|
||||
return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
|
||||
std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?;
|
||||
}
|
||||
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
|
||||
}
|
||||
@@ -255,7 +244,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
println!("{out}");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
|
||||
let ids = match target {
|
||||
Some(t) => vec![t],
|
||||
None => {
|
||||
@@ -340,8 +329,7 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
}
|
||||
|
||||
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
|
||||
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
|
||||
let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let mut s = match input {
|
||||
Some(path) => {
|
||||
|
||||
@@ -22,7 +22,6 @@ USAGE:
|
||||
figcraft-cli convert <in> <out>
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
|
||||
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
|
||||
figcraft-cli commands [--json] [--filter TEXT]
|
||||
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
|
||||
@@ -43,33 +42,12 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines)
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
|
||||
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
|
||||
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
|
||||
only inside a write root, everything else refused -- so giving no write root denies writing
|
||||
altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`,
|
||||
`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes
|
||||
as the user who started it, as before. A running app confines itself: start it with
|
||||
`figcraft --control PORT` and the same flags (`--attach` refuses them).
|
||||
`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files the agent's commands touch: reads only inside a read or write root,
|
||||
writes only inside a write root, everything else refused. A running app confines itself: start it
|
||||
with `figcraft --control PORT` and the same flags (`--attach` refuses them).
|
||||
See docs/cli.md and docs/mcp.md.";
|
||||
|
||||
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
|
||||
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
|
||||
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
|
||||
|
||||
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
if read.is_empty() && write.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
|
||||
}
|
||||
|
||||
/// Runs `f` confined to the roots `a` carries.
|
||||
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
|
||||
let roots = roots_of(a)?.map(std::sync::Arc::new);
|
||||
figcraft_engine::file_access::confined(roots.as_ref(), f)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
match run(&argv) {
|
||||
@@ -120,38 +98,35 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"info" => {
|
||||
let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?;
|
||||
let a = Args::parse(rest, &[], &["json"])?;
|
||||
let file = a.one_positional("info <file>")?;
|
||||
confined(&a, || files::info(&file, a.flag("json")))
|
||||
files::info(&file, a.flag("json"))
|
||||
}
|
||||
"export" => {
|
||||
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
|
||||
let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?;
|
||||
let file = a.one_positional("export <file> -o <out>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
|
||||
let target =
|
||||
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
|
||||
confined(&a, || files::export(&file, &out, &target))
|
||||
files::export(&file, &out, &target)
|
||||
}
|
||||
"convert" => {
|
||||
let a = Args::parse(rest, &ROOT_FLAGS, &[])?;
|
||||
let a = Args::parse(rest, &[], &[])?;
|
||||
match a.positional.as_slice() {
|
||||
[i, o] => confined(&a, || files::convert(i, o)),
|
||||
[i, o] => files::convert(i, o),
|
||||
_ => Err(CliError::Usage("convert <in> <out>".into())),
|
||||
}
|
||||
}
|
||||
"render" => {
|
||||
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &[])?;
|
||||
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
|
||||
let file = a.one_positional("render <file> -o <out.png>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
|
||||
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
|
||||
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
|
||||
}
|
||||
"run" => {
|
||||
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &[])?;
|
||||
let a = Args::parse(rest, &["in", "out"], &[])?;
|
||||
let script = a.one_positional("run <script.json|.jsonl>")?;
|
||||
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
|
||||
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
|
||||
}
|
||||
"commands" => {
|
||||
let a = Args::parse(rest, &["filter"], &["json"])?;
|
||||
@@ -162,20 +137,23 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"mcp" => {
|
||||
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &[])?;
|
||||
let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("mcp takes no positional arguments".into()));
|
||||
}
|
||||
let attach = a.value("attach");
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
mcp(attach.as_deref(), roots_of(&a)?)
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) {
|
||||
(true, _) => None,
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
(false, Some(_)) => {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
(false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?),
|
||||
};
|
||||
mcp(a.value("attach").as_deref(), roots)
|
||||
}
|
||||
"plugin" => {
|
||||
let a = Args::parse(rest, &["server"], &[])?;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! The binary end to end.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
@@ -171,98 +172,3 @@ fn commands_json_lists_everything() {
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert!(v.as_array().unwrap().len() > 200);
|
||||
}
|
||||
|
||||
/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a
|
||||
/// root, and a write outside a write root, are refused and leave no file behind. Without the flags
|
||||
/// the same commands behave as they always did.
|
||||
#[test]
|
||||
fn file_subcommands_are_confined_to_automation_roots() {
|
||||
let d = tmp("file-roots");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
for sub in ["in", "out"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
|
||||
// A document to work on, made unconfined.
|
||||
std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
|
||||
let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap();
|
||||
assert!(made.status.success(), "{}", out_of(&made));
|
||||
|
||||
// Inside the roots: renders as usual.
|
||||
let ok = bin()
|
||||
.args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(ok.status.success(), "{}", out_of(&ok));
|
||||
assert!(d.join("out/ok.png").is_file());
|
||||
|
||||
// A write outside the write root is refused, and nothing is written.
|
||||
let escaped = d.join("escaped.png");
|
||||
let refused = bin()
|
||||
.args([
|
||||
"render",
|
||||
&p("in/doc.figcraft"),
|
||||
"-o",
|
||||
&escaped.to_string_lossy(),
|
||||
"--automation-read-root",
|
||||
&p("in"),
|
||||
"--automation-write-root",
|
||||
&p("out"),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!refused.status.success());
|
||||
assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused));
|
||||
assert!(!escaped.exists(), "the refused render wrote a file anyway");
|
||||
|
||||
// No write root at all: writing is refused outright, reading still works.
|
||||
let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap();
|
||||
assert!(!no_write.status.success());
|
||||
assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write));
|
||||
|
||||
// A read outside every root is refused: `info` on the document with only `out` readable.
|
||||
let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap();
|
||||
assert!(!read_refused.status.success());
|
||||
assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused));
|
||||
|
||||
// `convert` and `run` take them as well, and a root that is not a folder is refused.
|
||||
let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap();
|
||||
assert!(!missing.status.success());
|
||||
assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing));
|
||||
|
||||
// Unconfined, the same render still writes wherever it is told.
|
||||
let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap();
|
||||
assert!(plain.status.success(), "{}", out_of(&plain));
|
||||
assert!(escaped.is_file());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// An output path that is a symbolic link is written through, to its target, as `std::fs::write`
|
||||
/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have
|
||||
/// replaced the link with a regular file, because that write stages the bytes and renames them
|
||||
/// into place.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_unconfined_write_follows_a_symbolic_link() {
|
||||
let d = tmp("symlink-out");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(d.join("real")).unwrap();
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
|
||||
let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap();
|
||||
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
|
||||
|
||||
let target = d.join("real/target.png");
|
||||
std::fs::write(&target, b"placeholder").unwrap();
|
||||
let link = d.join("link.png");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
|
||||
let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link");
|
||||
let written = std::fs::read(&target).unwrap();
|
||||
assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Depth refusal through the actual CLI, including its exit status and destination writes.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_doc::color::{Color, Paint};
|
||||
use figcraft_doc::{Document, NodeKind};
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Headless runs lay text out with the installed fonts, like the desktop app, unless
|
||||
//! `--no-system-fonts` or `FIGCRAFT_NO_SYSTEM_FONTS=1` opts out.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
//! The crate's integration tests, built as one test binary.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod cli;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod export_depth;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod fonts;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod pdf;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod pdf_print;
|
||||
@@ -1,4 +1,5 @@
|
||||
//! `figcraft-cli export --format pdf` and `convert … .pdf` end to end.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! `figcraft-cli export` with PDF print options: bleed, marks, CMYK; bad lengths are usage errors.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
@@ -261,9 +261,7 @@ fn fuzzed_frames_dont_hurt_the_server() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// read() returns Result<Message>, not a lock guard; the fuzz loop ends here on purpose and
|
||||
// the close frame from the server is not interesting.
|
||||
let _ = ws.read(); // NOSONAR
|
||||
let _ = ws.read();
|
||||
}
|
||||
// Still serving, the file intact.
|
||||
let mut b = join(&p.edit, "Bo");
|
||||
|
||||
+1
@@ -2,6 +2,7 @@
|
||||
//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
|
||||
//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
|
||||
//! still restores silently.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_collab::message::{Access, ClientMsg};
|
||||
use figcraft_collab::{Client, Outgoing, Replica, Server};
|
||||
@@ -1,5 +0,0 @@
|
||||
//! The crate's integration tests, built as one test binary.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod header_recovery;
|
||||
mod problem_retention;
|
||||
File renamed without changes.
@@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The browser shell: web `Services` and the eframe web runner.
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::{ContentsFuture, FigApp, Services};
|
||||
use figcraft_ui_egui::{FigApp, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const CANVAS_ID: &str = "figcraft_canvas";
|
||||
@@ -75,10 +75,9 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser services. Files are opened and imported through the browser's file picker, which hands
|
||||
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
|
||||
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
|
||||
/// paste events with their text (a page can't read the clipboard when it likes).
|
||||
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
|
||||
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
|
||||
/// events with their text (a page can't read the clipboard when it likes).
|
||||
struct WebServices {
|
||||
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
|
||||
reduce_motion: Option<web_sys::MediaQueryList>,
|
||||
@@ -86,16 +85,6 @@ struct WebServices {
|
||||
}
|
||||
|
||||
impl Services for WebServices {
|
||||
/// An `<input type="file">` taking `exts`, clicked; its file is read once picked.
|
||||
fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option<ContentsFuture> {
|
||||
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
Some(Box::pin(async move {
|
||||
pick_file(&accept).await.unwrap_or_else(|e| {
|
||||
log::error!("couldn't open a file: {e:?}");
|
||||
None
|
||||
})
|
||||
}))
|
||||
}
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -147,90 +136,6 @@ impl Services for WebServices {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
|
||||
/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file
|
||||
/// can't be read.
|
||||
async fn pick_file(accept: &str) -> Result<Option<Result<(String, Vec<u8>), String>>, wasm_bindgen::JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?;
|
||||
input.set_type("file");
|
||||
input.set_accept(accept);
|
||||
input.set_hidden(true);
|
||||
// Some browsers (older Safari) open the picker only for an input in the page.
|
||||
body.append_child(&input)?;
|
||||
let (closed, picker) = PickerClosed::new(&window, input.clone());
|
||||
input.click();
|
||||
let result = wasm_bindgen_futures::JsFuture::from(closed).await;
|
||||
let file = input.files().and_then(|files| files.get(0));
|
||||
drop(picker);
|
||||
result?;
|
||||
let Some(file) = file else { return Ok(None) };
|
||||
let name = file.name();
|
||||
if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) {
|
||||
return Ok(Some(Err(why)));
|
||||
}
|
||||
Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
|
||||
Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())),
|
||||
Err(e) => {
|
||||
log::error!("couldn't read {name}: {e:?}");
|
||||
Err(figcraft_ui_egui::unreadable(&name))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// How long after the page gets focus back the file picker counts as closed (milliseconds): a
|
||||
/// picked file's `change` can come just after the focus, and must win. A `change` later than this
|
||||
/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel.
|
||||
const PICKER_FOCUS_SETTLE_MS: i32 = 1_000;
|
||||
|
||||
/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed
|
||||
/// without one) or shortly after the page gets focus back, for browsers that never send `cancel`
|
||||
/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one.
|
||||
/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer.
|
||||
struct PickerClosed {
|
||||
window: web_sys::Window,
|
||||
input: web_sys::HtmlInputElement,
|
||||
_settle: wasm_bindgen::closure::Closure<dyn FnMut()>,
|
||||
on_focus: wasm_bindgen::closure::Closure<dyn FnMut()>,
|
||||
timer: std::rc::Rc<std::cell::Cell<Option<i32>>>,
|
||||
}
|
||||
|
||||
impl PickerClosed {
|
||||
fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) {
|
||||
let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::<js_sys::Function>));
|
||||
let slot = resolve.clone();
|
||||
let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res));
|
||||
let settle = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
|
||||
if let Some(res) = resolve.borrow_mut().take() {
|
||||
let _ = res.call0(&wasm_bindgen::JsValue::NULL);
|
||||
}
|
||||
});
|
||||
let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref());
|
||||
let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref());
|
||||
let timer = std::rc::Rc::new(std::cell::Cell::new(None));
|
||||
let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::<js_sys::Function>().clone());
|
||||
let on_focus = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
|
||||
if t.get().is_none() {
|
||||
t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok());
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref());
|
||||
(closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PickerClosed {
|
||||
fn drop(&mut self) {
|
||||
if let Some(t) = self.timer.take() {
|
||||
self.window.clear_timeout_with_handle(t);
|
||||
}
|
||||
let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref());
|
||||
self.input.remove();
|
||||
}
|
||||
}
|
||||
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
|
||||
@@ -16,10 +16,6 @@ figcraft-ui-egui = { workspace = true }
|
||||
figcraft-collab-net = { workspace = true }
|
||||
figcraft-format = { workspace = true }
|
||||
figcraft-text = { workspace = true }
|
||||
# Shared decision of who draws the window frame on Linux (compositor vs. app), replacing the
|
||||
# bespoke Wayland xdg-decoration probe. See `[workspace.dependencies]`: pinned to the unmerged
|
||||
# craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable.
|
||||
craft-window = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
|
||||
serde_json = { workspace = true }
|
||||
@@ -49,6 +45,7 @@ wat = { workspace = true }
|
||||
winresource = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Use vector window controls without a title-font renderer or embedded Cantarell. The compositor
|
||||
# probe that used to need raw-window-handle and wayland-client here now lives in craft-window.
|
||||
# Use vector window controls without a title-font renderer or embedded Cantarell.
|
||||
winit = { version = "0.30", default-features = false, features = ["wayland-csd-adwaita-notitle"] }
|
||||
raw-window-handle = "0.6"
|
||||
wayland-client = "0.31"
|
||||
@@ -9,7 +9,7 @@
|
||||
/// What the app was started with.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct Args {
|
||||
/// `--control <port>` (or `--control=<port>`): the JSON-lines control server.
|
||||
/// `--control <port>`: the JSON-lines control server.
|
||||
pub control: Option<u16>,
|
||||
pub files: Vec<String>,
|
||||
/// The last invite link given.
|
||||
@@ -71,8 +71,8 @@ pub fn parse(args: &[String]) -> Args {
|
||||
};
|
||||
if let Some(roots) = roots {
|
||||
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
|
||||
} else if flag == "--control" {
|
||||
out.control = inline.or_else(|| it.next().cloned()).and_then(|p| p.parse().ok());
|
||||
} else if a == "--control" {
|
||||
out.control = it.next().and_then(|p| p.parse().ok());
|
||||
} else if is_invite(a) {
|
||||
out.join = Some(a.clone());
|
||||
} else if let Some(path) = file_url_path(a) {
|
||||
|
||||
@@ -16,14 +16,6 @@ fn files_and_the_control_port() {
|
||||
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_port_accepts_the_equals_form() {
|
||||
let a = parse(&args(&["--control=7980", "a.figcraft"]));
|
||||
assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() });
|
||||
// A bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control=nope"])), Args::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_repeat_and_are_never_files() {
|
||||
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Request winit's frame only on Wayland compositors without xdg-decoration.
|
||||
//! Desktop/session names cannot identify the backend of an individual window.
|
||||
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use raw_window_handle::{HasDisplayHandle, RawDisplayHandle};
|
||||
use wayland_client::globals::{GlobalListContents, registry_queue_init};
|
||||
use wayland_client::protocol::wl_registry;
|
||||
use wayland_client::{Connection, Dispatch, QueueHandle};
|
||||
|
||||
/// Capture the socket path before winit consumes an inherited WAYLAND_SOCKET.
|
||||
/// Never consume, duplicate or query that inherited connection: there may be no
|
||||
/// independently addressable display, or WAYLAND_DISPLAY may name another server.
|
||||
pub fn display_socket() -> Option<PathBuf> {
|
||||
if std::env::var_os("WAYLAND_SOCKET").is_some_and(|s| !s.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let display = std::env::var_os("WAYLAND_DISPLAY").filter(|s| !s.is_empty())?;
|
||||
let path = PathBuf::from(display);
|
||||
if path.is_absolute() {
|
||||
Some(path)
|
||||
} else {
|
||||
std::env::var_os("XDG_RUNTIME_DIR").filter(|s| !s.is_empty()).map(|dir| PathBuf::from(dir).join(path))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn needs_native_frame(cc: &eframe::CreationContext<'_>, socket: Option<PathBuf>) -> bool {
|
||||
let wayland = cc.display_handle().is_ok_and(|h| matches!(h.as_raw(), RawDisplayHandle::Wayland(_)));
|
||||
if !wayland {
|
||||
log::debug!("window controls: non-Wayland window, retaining custom controls");
|
||||
return false;
|
||||
}
|
||||
let server_decorations = socket.and_then(probe);
|
||||
log::debug!("window controls: Wayland xdg-decoration available: {server_decorations:?}");
|
||||
native_frame(wayland, server_decorations)
|
||||
}
|
||||
|
||||
fn native_frame(wayland: bool, server_decorations: Option<bool>) -> bool {
|
||||
wayland && server_decorations == Some(false)
|
||||
}
|
||||
|
||||
fn probe(socket: PathBuf) -> Option<bool> {
|
||||
// The compositor must not be able to block startup on a registry roundtrip.
|
||||
// On failure/timeout retain FigCraft's own working title bar and buttons.
|
||||
let (send, receive) = mpsc::sync_channel(1);
|
||||
std::thread::Builder::new()
|
||||
.name("wayland-decorations".into())
|
||||
.spawn(move || {
|
||||
let _ = send.send(registry_has_decorations(socket));
|
||||
})
|
||||
.ok()?;
|
||||
receive.recv_timeout(Duration::from_millis(500)).ok().flatten()
|
||||
}
|
||||
|
||||
fn registry_has_decorations(socket: PathBuf) -> Option<bool> {
|
||||
// A fresh connection leaves winit's event queue and inherited descriptors alone.
|
||||
let stream = UnixStream::connect(socket).ok()?;
|
||||
let connection = Connection::from_socket(stream).ok()?;
|
||||
let (globals, _queue) = registry_queue_init::<Registry>(&connection).ok()?;
|
||||
Some(globals.contents().with_list(|list| list.iter().any(|g| g.interface == "zxdg_decoration_manager_v1")))
|
||||
}
|
||||
|
||||
struct Registry;
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for Registry {
|
||||
fn event(
|
||||
_state: &mut Self,
|
||||
_registry: &wl_registry::WlRegistry,
|
||||
_event: wl_registry::Event,
|
||||
_data: &GlobalListContents,
|
||||
_connection: &Connection,
|
||||
_queue: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn only_wayland_without_server_decorations_uses_the_frame() {
|
||||
for (wayland, server_decorations, expected) in [
|
||||
(false, None, false),
|
||||
(false, Some(false), false),
|
||||
(false, Some(true), false),
|
||||
(true, Some(true), false),
|
||||
(true, Some(false), true),
|
||||
(true, None, false),
|
||||
] {
|
||||
assert_eq!(super::native_frame(wayland, server_decorations), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unavailable_display_keeps_the_custom_controls() {
|
||||
assert_eq!(super::probe("/nonexistent/figcraft-wayland-test".into()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unresponsive_display_cannot_block_window_startup() -> std::io::Result<()> {
|
||||
let socket = std::env::temp_dir().join(format!("figcraft-decoration-timeout-{}", std::process::id()));
|
||||
let listener = std::os::unix::net::UnixListener::bind(&socket)?;
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(super::probe(socket.clone()), None);
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(2));
|
||||
drop(listener);
|
||||
let _ = std::fs::remove_file(socket);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+20
-101
@@ -21,6 +21,8 @@ mod args;
|
||||
mod control_server;
|
||||
mod gamepad;
|
||||
mod graphics;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_window;
|
||||
mod recovery;
|
||||
mod services;
|
||||
mod startup;
|
||||
@@ -191,8 +193,10 @@ fn main() -> eframe::Result {
|
||||
// default, never a build from source unless asked).
|
||||
session.live.links = figcraft_collab_net::links::system();
|
||||
session.invite_links_startup();
|
||||
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
|
||||
// tips and templates; later runs start in a new file.
|
||||
let first_run = svc.prefs.is_none() && args.join.is_none();
|
||||
if session.docs.is_empty() && opens_new_file(first_run, session.prefs.start_on_home, port.is_some()) {
|
||||
if session.docs.is_empty() && !first_run {
|
||||
let _ = session.execute("file.new", &json!({}));
|
||||
}
|
||||
// The interface language from the first frame on, and for the message when the window can't
|
||||
@@ -220,13 +224,8 @@ fn main() -> eframe::Result {
|
||||
.with_inner_size([1440.0, 900.0])
|
||||
.with_min_inner_size([800.0, 500.0])
|
||||
.with_drag_and_drop(true);
|
||||
// Who draws the window frame is decided once, before the viewport is built, by the shared
|
||||
// craft-window crate (the rest of the Craft suite uses it too). On macOS the system frame is
|
||||
// kept with FigCraft's inset traffic lights; on Windows it is the system frame; on Linux the
|
||||
// decision follows the desktop: a Wayland compositor with server-side decorations (KDE, Sway,
|
||||
// Hyprland, COSMIC) or an X11 window manager keeps the native frame, while GNOME's Mutter —
|
||||
// which draws no server-side frame — leaves it to the app's own custom title bar. This replaces
|
||||
// the old bespoke xdg-decoration probe that always drew FigCraft's custom title bar on KDE.
|
||||
// Start frameless on Linux; the app creator checks the actual display backend
|
||||
// before enabling a client-side frame. Other windows inherit that decision.
|
||||
fig.ui.title_bar = desktop_title_bar();
|
||||
viewport = window_chrome(viewport, fig.ui.title_bar);
|
||||
if let Some(icon) = icon {
|
||||
@@ -235,6 +234,8 @@ fn main() -> eframe::Result {
|
||||
let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() };
|
||||
graphics::configure(&mut options.wgpu_options);
|
||||
figcraft_ui_egui::windows::install(fig);
|
||||
#[cfg(target_os = "linux")]
|
||||
let linux_display = linux_window::display_socket();
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched the app
|
||||
// as well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -245,6 +246,11 @@ fn main() -> eframe::Result {
|
||||
"FigCraft",
|
||||
options,
|
||||
Box::new(move |_cc| {
|
||||
#[cfg(target_os = "linux")]
|
||||
if linux_window::needs_native_frame(_cc, linux_display) {
|
||||
let _ = with_app(|fig| fig.ui.title_bar = figcraft_ui_egui::TitleBar::Native);
|
||||
_cc.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Decorations(true));
|
||||
}
|
||||
Ok(Box::new(App {
|
||||
queue,
|
||||
ctx_slot,
|
||||
@@ -262,43 +268,9 @@ fn main() -> eframe::Result {
|
||||
result
|
||||
}
|
||||
|
||||
/// With nothing to open, whether the window starts in a new file rather than on the home screen.
|
||||
/// A first run (no settings kept yet) shows the home screen's tips and templates, and later runs
|
||||
/// start in a new file unless the preference asks for Home, which `--control` overrides: its
|
||||
/// callers expect a document to act on.
|
||||
fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool {
|
||||
!first_run && (!start_on_home || control)
|
||||
}
|
||||
|
||||
/// The title bar FigCraft starts with, decided by the shared craft-window crate so every Craft app
|
||||
/// agrees on who draws the frame per desktop. The override variable is `FIGCRAFT_TITLEBAR`
|
||||
/// (`app` / `system`). macOS keeps its inset traffic lights unchanged (craft-window returns the
|
||||
/// desktop frame there); Windows keeps the system frame; on Linux the compositor/window manager
|
||||
/// decides (KDE/Sway/Hyprland/COSMIC and X11 keep the native frame, GNOME gets the custom one).
|
||||
fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
|
||||
let choice = craft_window::Choice::probe(craft_window::Prefix::new("FIGCRAFT"));
|
||||
title_bar_for(choice.into_frame())
|
||||
}
|
||||
|
||||
/// Map craft-window's [`craft_window::Frame`] onto FigCraft's own [`figcraft_ui_egui::TitleBar`].
|
||||
///
|
||||
/// `Frame::App` means the app draws its own frame — FigCraft's custom title bar. `Frame::Desktop`
|
||||
/// means the desktop draws it, but FigCraft keeps each platform's existing look there:
|
||||
/// - macOS: the inset title bar with its traffic lights (`TitleBar::Inset`), unchanged.
|
||||
/// - Windows: FigCraft's own custom title bar (`TitleBar::Custom`), unchanged — Windows never used
|
||||
/// the plain native frame, and craft-window returning `Desktop` on Windows must not change that.
|
||||
/// - Linux and the BSDs: the compositor's native frame (`TitleBar::Native`). This is the only
|
||||
/// behaviour change: KDE and the other server-side-decoration compositors now keep their frame.
|
||||
fn title_bar_for(frame: craft_window::Frame) -> figcraft_ui_egui::TitleBar {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
match frame {
|
||||
craft_window::Frame::Desktop if cfg!(target_os = "macos") => TitleBar::Inset,
|
||||
// Windows kept FigCraft's own title bar before this change; keep it.
|
||||
craft_window::Frame::Desktop if cfg!(target_os = "windows") => TitleBar::Custom,
|
||||
craft_window::Frame::Desktop => TitleBar::Native,
|
||||
// The app draws the frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override).
|
||||
craft_window::Frame::App(_) => TitleBar::Custom,
|
||||
}
|
||||
if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom }
|
||||
}
|
||||
|
||||
fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::TitleBar) -> egui::ViewportBuilder {
|
||||
@@ -310,66 +282,13 @@ fn window_chrome(viewport: egui::ViewportBuilder, title_bar: figcraft_ui_egui::T
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_bar_tests {
|
||||
use figcraft_ui_egui::TitleBar;
|
||||
|
||||
/// craft-window's app frame (GNOME's Wayland session, or a `FIGCRAFT_TITLEBAR=app` override)
|
||||
/// becomes FigCraft's custom title bar on every platform, and `window_chrome` then drops the
|
||||
/// decorations so FigCraft draws its own.
|
||||
#[cfg(all(test, target_os = "linux"))]
|
||||
mod linux_window_tests {
|
||||
#[test]
|
||||
fn the_app_frame_maps_to_the_custom_title_bar() {
|
||||
let layout = craft_window::Layout::default();
|
||||
let bar = super::title_bar_for(craft_window::Frame::App(layout));
|
||||
assert_eq!(bar, TitleBar::Custom);
|
||||
fn linux_starts_with_the_existing_frameless_title_bar() {
|
||||
let bar = super::desktop_title_bar();
|
||||
assert_eq!(bar, figcraft_ui_egui::TitleBar::Custom);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.decorations, Some(false));
|
||||
}
|
||||
|
||||
/// craft-window's desktop frame keeps each platform's existing look: macOS its inset traffic
|
||||
/// lights, Windows FigCraft's own custom title bar (unchanged), and Linux/BSD the compositor's
|
||||
/// native frame. The Linux case is the only behaviour change: KDE and the other
|
||||
/// server-side-decoration compositors now keep their native frame.
|
||||
#[test]
|
||||
fn the_desktop_frame_is_mapped_per_platform() {
|
||||
let bar = super::title_bar_for(craft_window::Frame::Desktop);
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(bar, TitleBar::Inset);
|
||||
} else if cfg!(target_os = "windows") {
|
||||
// Windows kept its own title bar before and must keep it now.
|
||||
assert_eq!(bar, TitleBar::Custom);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.decorations, Some(false));
|
||||
} else {
|
||||
assert_eq!(bar, TitleBar::Native);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.decorations, Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS is untouched by this change: its inset title bar with the system traffic lights stays,
|
||||
/// and `window_chrome` still asks for the full-size content view rather than toggling the
|
||||
/// window decorations.
|
||||
#[test]
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_keeps_its_inset_title_bar() {
|
||||
let bar = super::desktop_title_bar();
|
||||
assert_eq!(bar, TitleBar::Inset);
|
||||
let viewport = super::window_chrome(egui::ViewportBuilder::default(), bar);
|
||||
assert_eq!(viewport.fullsize_content_view, Some(true));
|
||||
assert_eq!(viewport.title_shown, Some(false));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod start_tests {
|
||||
use super::opens_new_file;
|
||||
|
||||
#[test]
|
||||
fn the_home_preference_starts_on_home_except_for_control_callers() {
|
||||
assert!(opens_new_file(false, false, false), "a new file by default");
|
||||
assert!(!opens_new_file(false, true, false), "Home when the preference asks");
|
||||
assert!(opens_new_file(false, true, true), "--control still starts with a document");
|
||||
assert!(!opens_new_file(true, false, false), "a first run shows the home screen");
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,6 @@ display_name = "VanTan"
|
||||
real_name = "Tyler Jennings"
|
||||
display_name = "tylerjenningsw"
|
||||
|
||||
[people.SiadeLucas]
|
||||
real_name = "Siade Lucas"
|
||||
sync_github_name = true
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
@@ -41,11 +37,6 @@ real_name = "Justin John"
|
||||
|
||||
[people.Losminek]
|
||||
|
||||
[people.alrifatsabbir]
|
||||
real_name = "Al Rifat Sabbir"
|
||||
display_name = "Al Rifat Sabbir"
|
||||
sync_github_name = true
|
||||
|
||||
[people.RevDonz]
|
||||
|
||||
[people.Nael-Nathanael]
|
||||
@@ -65,9 +56,3 @@ sync_github_name = true
|
||||
|
||||
[people.pechnikov2026-star]
|
||||
sync_github_name = true
|
||||
|
||||
[people.MortenHusted]
|
||||
sync_github_name = true
|
||||
|
||||
[people.AganzeFelicite]
|
||||
sync_github_name = true
|
||||
@@ -141,9 +141,7 @@ fn old_server() -> (Endpoint, std::thread::JoinHandle<()>) {
|
||||
let t = std::thread::spawn(move || {
|
||||
for stream in listener.incoming().take(2).flatten() {
|
||||
let Ok(mut ws) = tungstenite::accept(stream) else { continue };
|
||||
// read() returns Result<Message>, not a lock guard; this test server deliberately
|
||||
// ignores whatever the client sent first.
|
||||
let _ = ws.read(); // NOSONAR
|
||||
let _ = ws.read();
|
||||
let refused = ServerMsg::Refused { reason: "that message couldn't be read".into() };
|
||||
let _ = ws.send(Message::Binary(Bytes::from(message::encode(&refused).unwrap())));
|
||||
let _ = ws.close(None);
|
||||
|
||||
@@ -16,7 +16,7 @@ use figcraft_engine::live::{Event, Status};
|
||||
use web_time::Instant;
|
||||
|
||||
use crate::inbox::Inbox;
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
|
||||
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
|
||||
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
|
||||
@@ -114,9 +114,6 @@ impl<S: Socket> Driver<S> {
|
||||
/// A binary message from the host.
|
||||
pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) {
|
||||
let Some(c) = self.conn.as_mut() else { return };
|
||||
if bytes.len() > self.cfg.limits.max_server_message {
|
||||
return self.end(too_large(&self.cfg.limits), now);
|
||||
}
|
||||
c.last_heard = now;
|
||||
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
|
||||
// The join code's answer: join the file it opens, on a new connection.
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
|
||||
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
|
||||
use figcraft_collab::{Client, Limits, Outgoing, Server};
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN};
|
||||
use figcraft_engine::live::{Event, Status};
|
||||
use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
@@ -262,15 +262,6 @@ fn refusals_and_the_end_of_the_session_are_final() {
|
||||
assert!(d.done());
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused")));
|
||||
}
|
||||
// A message over the size limit: the file is too large for this app, not refused by the host.
|
||||
let inbox = Inbox::new(Arc::new(|| {}));
|
||||
let mut cfg = GuestConfig::new(invite(), "Ada".into());
|
||||
cfg.limits.max_server_message = 2 << 20;
|
||||
let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0);
|
||||
d.opened(t0);
|
||||
d.message(&vec![0; (2 << 20) + 1], t0);
|
||||
assert!(d.done());
|
||||
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message};
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::{Endpoint, Invite};
|
||||
pub use crate::join::GuestConfig;
|
||||
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
|
||||
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
|
||||
use crate::manage;
|
||||
use crate::net::{Duplex, NetLimits, config, open, shutdown};
|
||||
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
|
||||
@@ -367,7 +367,7 @@ fn read_loop(mut ws: WebSocket<Duplex>, ctx: &ReaderCtx) {
|
||||
Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)),
|
||||
Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()),
|
||||
Ok(_) => {}
|
||||
Err(tungstenite::Error::Capacity(_)) => break too_large(&ctx.cfg.limits),
|
||||
Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()),
|
||||
Err(e) => break Outcome::Retry(e.to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -220,8 +220,7 @@ fn valid_host(s: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
let Some((name, port)) = s.rsplit_once(':') else { return false };
|
||||
port.bytes().all(|b| b.is_ascii_digit())
|
||||
&& port.parse::<u16>().is_ok_and(|p| p != 0)
|
||||
port.parse::<u16>().is_ok_and(|p| p != 0)
|
||||
&& (1..=253).contains(&name.len())
|
||||
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
|
||||
&& !name.starts_with(['-', '.'])
|
||||
@@ -240,9 +239,6 @@ fn percent_decode(s: &str) -> Option<String> {
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if b == b'%' {
|
||||
let hex = bytes.get(i + 1..i + 3)?;
|
||||
if !hex.iter().all(u8::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?);
|
||||
i += 3;
|
||||
} else {
|
||||
|
||||
@@ -123,19 +123,3 @@ proptest! {
|
||||
prop_assert_eq!(Invite::parse(&i.url()), Ok(i));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plus_or_sign_inside_a_percent_escape_is_broken() {
|
||||
assert!(Endpoint::parse("ws://example.org/a/?key=%41").is_ok());
|
||||
for bad in ["%+1", "%-1", "%1", "%+a"] {
|
||||
assert!(Endpoint::parse(&format!("ws://example.org/a/?key={bad}")).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_must_be_all_digits() {
|
||||
assert!(Endpoint::parse("ws://localhost:80/").is_ok());
|
||||
for bad in ["ws://localhost:+80/", "ws://localhost:-80/", "ws://localhost:/"] {
|
||||
assert!(Endpoint::parse(bad).is_err(), "accepted {bad}");
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use std::time::Duration;
|
||||
|
||||
use figcraft_collab::Client;
|
||||
use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg};
|
||||
use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN};
|
||||
use figcraft_engine::live::{Event, Role, SessionInfo, Status};
|
||||
|
||||
use crate::inbox::Inbox;
|
||||
use crate::invite::Invite;
|
||||
@@ -95,12 +95,6 @@ pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
|
||||
})
|
||||
}
|
||||
|
||||
/// The host sent a message over this app's size limit (the file's welcome, usually): final, and
|
||||
/// not the host's doing.
|
||||
pub(crate) fn too_large(limits: &NetLimits) -> Outcome {
|
||||
Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string()))
|
||||
}
|
||||
|
||||
/// The server's answer to the join code: the invite is whole now.
|
||||
pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> {
|
||||
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) {
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
|
||||
use figcraft_collab::message::{self, 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};
|
||||
use figcraft_engine::live::{Event, PEOPLE, Status};
|
||||
use tungstenite::protocol::WebSocket;
|
||||
use tungstenite::{Bytes, Message};
|
||||
|
||||
@@ -642,31 +642,3 @@ fn the_host_removes_someone_and_they_cant_come_back() {
|
||||
host.stop();
|
||||
assert_eq!(host.connections(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
|
||||
let mut doc = Document::new("B".repeat(1 << 20));
|
||||
doc.repair();
|
||||
let probe = Probe::new();
|
||||
let cfg = HostConfig {
|
||||
addr: SocketAddr::from(([127, 0, 0, 1], 0)),
|
||||
any_port_if_taken: false,
|
||||
file: FILE.into(),
|
||||
token: TOKEN.into(),
|
||||
doc,
|
||||
name: "Hana".into(),
|
||||
limits: fast_limits(),
|
||||
core: Limits::default(),
|
||||
};
|
||||
let mut host = Host::start(cfg, probe.inbox.clone()).unwrap();
|
||||
let invite = Invite::new(host.addr().to_string(), FILE, TOKEN);
|
||||
|
||||
let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into()));
|
||||
guest_cfg.limits = NetLimits { max_server_message: 1 << 20, ..fast_limits() };
|
||||
let mut gp = Probe::new();
|
||||
let _g = Guest::start(guest_cfg, gp.inbox.clone()).unwrap();
|
||||
let too_large = TOO_LARGE_TO_JOIN.replace("{mb}", "1");
|
||||
gp.wait("the file too large to join", |e| e.iter().any(|ev| matches!(ev, Event::Status(Status::Failed { reason }) if *reason == too_large)));
|
||||
assert!(!welcomed(&gp.events));
|
||||
host.stop();
|
||||
}
|
||||
@@ -13,7 +13,6 @@ figcraft-doc = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
imbl = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
postcard = { workspace = true }
|
||||
|
||||
@@ -135,7 +135,7 @@ impl Local {
|
||||
let query = link.strip_prefix("figcraft://join?")?;
|
||||
let get = |key: &str| query.split('&').find_map(|kv| kv.split_once('=').filter(|(k, _)| *k == key).map(|(_, v)| v));
|
||||
let addr: std::net::SocketAddrV4 = get("host")?.parse().ok()?;
|
||||
let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
|
||||
let hex = get("token")?.strip_prefix(TOKEN_PREFIX).filter(|h| h.len() == 11)?;
|
||||
let local = Local { ip: *addr.ip(), port: addr.port(), secret: u64::from_str_radix(hex, 16).ok()? };
|
||||
(get("file") == Some(LOCAL_FILE) && local.port != 0).then_some(local)
|
||||
}
|
||||
|
||||
@@ -104,7 +104,6 @@ fn only_local_session_links_have_a_code() {
|
||||
"figcraft://join?host=[::1]:47900&file=local&token=local-0123456789a",
|
||||
"figcraft://join?host=192.168.1.20:0&file=local&token=local-0123456789a",
|
||||
"figcraft://join?host=192.168.1.20:47900&file=local&token=local-0123456789az",
|
||||
"figcraft://join?host=10.0.0.1:47900&file=local&token=local-+0000000001",
|
||||
"figcraft://join?server=wss://example.org/&file=local&token=local-0123456789a",
|
||||
"ws://192.168.1.20:47900/?file=local&token=local-0123456789a",
|
||||
"",
|
||||
|
||||
@@ -696,38 +696,22 @@ pub enum ServerMsg {
|
||||
},
|
||||
}
|
||||
|
||||
/// (days since 1970-01-01, seconds-of-day, nanos) → (proleptic Gregorian year, the same wall-clock
|
||||
/// instant moved into chrono's range by whole 400-year cycles). The calendar repeats every 400
|
||||
/// years (146_097 days), so month, day and time of day come from chrono and only the year is
|
||||
/// carried, which keeps the result exact for any `i64` day count. This is not hand-rolled calendar
|
||||
/// math: chrono still does the date, the cycle count just shifts the year.
|
||||
fn utc_parts(days: i64, sod: u32, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = chrono::NaiveTime::from_num_seconds_from_midnight_opt(sod.min(86_399), nanos.min(999_999_999));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM UTC` for seconds since 1970 (how servers and apps show the times in these
|
||||
/// messages). Years past chrono's own range (262143) keep converting: the instant is moved into
|
||||
/// range by whole 400-year cycles and the real year restored, so every `u64` reads as the date the
|
||||
/// old hand-rolled conversion produced (e.g. `u64::MAX` → `584554051223-11-09 07:00 UTC`).
|
||||
/// messages).
|
||||
pub fn utc_date(secs: u64) -> String {
|
||||
// The day count of any `u64` seconds fits in `i64` (`u64::MAX / 86_400` ≈ 2.1e14), and the
|
||||
// seconds-of-day is < 86_400, so the conversion is always exact (no epoch fallback). Splitting
|
||||
// with `u64` arithmetic (as the old code did) avoids the `i64` seconds overflow near `u64::MAX`.
|
||||
let (days, sod) = (i64::try_from(secs / 86_400).unwrap_or(i64::MAX), (secs % 86_400) as u32);
|
||||
let (y, t) = utc_parts(days, sod, 0);
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999 (the old code did not).
|
||||
format!("{y:04}-{}", t.format("%m-%d %H:%M UTC"))
|
||||
let days = i64::try_from(secs / 86_400).unwrap_or(0);
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
// Days to a civil date (proleptic Gregorian).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02} UTC")
|
||||
}
|
||||
|
||||
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
|
||||
@@ -768,25 +752,3 @@ pub fn decode_json<T: DeserializeOwned>(text: &str, max_bytes: usize) -> Result<
|
||||
}
|
||||
serde_json::from_str(text).map_err(|e| Error::Decode(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `utc_date` keeps the exact `YYYY-MM-DD HH:MM UTC` output the hand-rolled civil-from-days
|
||||
/// code produced (leap days and all).
|
||||
#[test]
|
||||
fn utc_date_formats_seconds_since_1970() {
|
||||
assert_eq!(utc_date(0), "1970-01-01 00:00 UTC");
|
||||
assert_eq!(utc_date(1_000_000), "1970-01-12 13:46 UTC");
|
||||
assert_eq!(utc_date(951_782_400), "2000-02-29 00:00 UTC");
|
||||
assert_eq!(utc_date(1_709_164_800), "2024-02-29 00:00 UTC");
|
||||
assert_eq!(utc_date(1_791_208_980), "2026-10-05 14:03 UTC");
|
||||
// A year past 9999 keeps the plain zero-padded year (no `+`), as the old `{y:04}` did.
|
||||
assert_eq!(utc_date(253_402_300_800), "10000-01-01 00:00 UTC");
|
||||
// Past chrono's own year range (262143) the instant is moved into range by whole 400-year
|
||||
// cycles and the real year restored, so it keeps the old hand-rolled output instead of
|
||||
// falling back to the epoch.
|
||||
assert_eq!(utc_date(u64::MAX), "584554051223-11-09 07:00 UTC");
|
||||
}
|
||||
}
|
||||
+3
-15
@@ -61,7 +61,7 @@ impl<'de> Deserialize<'de> for Color {
|
||||
}
|
||||
|
||||
fn clamp01(v: f32) -> f32 {
|
||||
if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }
|
||||
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
|
||||
}
|
||||
|
||||
impl Default for Color {
|
||||
@@ -162,13 +162,7 @@ impl Color {
|
||||
p.parse::<f32>().ok().map(|v| v / 255.0)
|
||||
}
|
||||
};
|
||||
let alpha = match parts.get(3) {
|
||||
Some(a) => parse_alpha(a)?,
|
||||
None => 1.0,
|
||||
};
|
||||
if parts.len() > 4 {
|
||||
return None;
|
||||
}
|
||||
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
|
||||
return Some(Self::new(num(0)?, num(1)?, num(2)?, alpha));
|
||||
}
|
||||
if let Some(inner) = t.strip_prefix("hsla(").or_else(|| t.strip_prefix("hsl(")).and_then(|r| r.strip_suffix(')')) {
|
||||
@@ -176,13 +170,7 @@ impl Color {
|
||||
let h = parts.first()?.trim_end_matches("deg").parse::<f32>().ok()?;
|
||||
let s = parts.get(1)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
|
||||
let l = parts.get(2)?.trim_end_matches('%').parse::<f32>().ok()? / 100.0;
|
||||
let alpha = match parts.get(3) {
|
||||
Some(a) => parse_alpha(a)?,
|
||||
None => 1.0,
|
||||
};
|
||||
if parts.len() > 4 {
|
||||
return None;
|
||||
}
|
||||
let alpha = parts.get(3).and_then(|a| parse_alpha(a)).unwrap_or(1.0);
|
||||
return Some(Hsl { h, s, l }.to_color().with_alpha(alpha));
|
||||
}
|
||||
if t == "transparent" {
|
||||
|
||||
@@ -80,7 +80,7 @@ fn blend_names() {
|
||||
#[test]
|
||||
fn nan_inputs_are_sanitized() {
|
||||
let c = Color::new(f32::NAN, 2.0, -1.0, f32::INFINITY);
|
||||
assert_eq!(c.to_rgba8(), [0, 255, 0, 255]);
|
||||
assert_eq!(c.to_rgba8(), [0, 255, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -106,21 +106,3 @@ fn crop_defaults_to_fill_and_roundtrips() {
|
||||
assert!((a - b).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infinite_channels_clamp() {
|
||||
let c = Color::parse_css("rgb(1e40, 0, 0)").unwrap();
|
||||
assert_eq!(c.r, 1.0);
|
||||
let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY);
|
||||
assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_alpha_or_extra_args_rejected() {
|
||||
assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None);
|
||||
assert_eq!(Color::parse_css("hsla(0,0%,0%,abc)"), None);
|
||||
assert_eq!(Color::parse_css("rgb(1,2,3,4,5)"), None);
|
||||
assert_eq!(Color::parse_css("hsl(0,0%,0%,0.5,1)"), None);
|
||||
assert_eq!(Color::parse_css("rgb(1,2,3)").unwrap().a, 1.0);
|
||||
assert_eq!(Color::parse_css("rgba(1,2,3,0.5)").unwrap().a, 0.5);
|
||||
}
|
||||
+7
-25
@@ -949,39 +949,21 @@ impl Document {
|
||||
self.styles.iter().find(|s| s.id == id || s.name == id)
|
||||
}
|
||||
|
||||
/// The effective variable modes for a node: the ones it sets over [`Self::inherited_modes`].
|
||||
/// The effective variable modes for a node (walking up its ancestors).
|
||||
pub fn modes_for(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
|
||||
let mut out = self.inherited_modes(id);
|
||||
if let Some(n) = self.node(id) {
|
||||
out.extend(n.var_modes.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The modes a node is on where it sets none (Auto): its ancestors', and for an instance the
|
||||
/// ones its main component sets, which are closer than any container's.
|
||||
pub fn inherited_modes(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
|
||||
let mut out = BTreeMap::new();
|
||||
for c in self.ancestors(id).into_iter().rev() {
|
||||
let mut chain = vec![id];
|
||||
chain.extend(self.ancestors(id));
|
||||
for c in chain.into_iter().rev() {
|
||||
if let Some(n) = self.node(c) {
|
||||
out.extend(self.main_modes(n).chain(&n.var_modes).map(|(k, v)| (k.clone(), v.clone())));
|
||||
for (k, v) in &n.var_modes {
|
||||
out.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(n) = self.node(id) {
|
||||
out.extend(self.main_modes(n).map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The modes an instance's main component sets (none for other layers).
|
||||
fn main_modes<'a>(&'a self, n: &Node) -> impl Iterator<Item = (&'a CollectionId, &'a ModeId)> {
|
||||
let main = match n.kind {
|
||||
NodeKind::Instance { component, .. } => self.node(component),
|
||||
_ => None,
|
||||
};
|
||||
main.into_iter().flat_map(|m| &m.var_modes)
|
||||
}
|
||||
|
||||
/// Check structural invariants (used by tests and after loading untrusted files).
|
||||
pub fn validate(&self) -> Vec<String> {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
@@ -60,9 +60,6 @@ impl LetterSpacing {
|
||||
}
|
||||
|
||||
pub fn trim_num(v: f64) -> String {
|
||||
if v.abs() >= 9.0e18 {
|
||||
return format!("{v}");
|
||||
}
|
||||
let r = (v * 100.0).round() / 100.0;
|
||||
if r.fract().abs() < 1e-9 { format!("{}", r as i64) } else { format!("{r}") }
|
||||
}
|
||||
@@ -763,15 +760,6 @@ impl TextData {
|
||||
mod case_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trim_num_prints_huge_whole_numbers() {
|
||||
assert_eq!(trim_num(1e19), "10000000000000000000");
|
||||
assert_eq!(trim_num(-1e19), "-10000000000000000000");
|
||||
assert_eq!(trim_num(12.0), "12");
|
||||
let v = 12345678901234567890.0_f64;
|
||||
assert_eq!(trim_num(v).parse::<f64>().unwrap(), v);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_case_respects_context() {
|
||||
assert_eq!(TextCase::Title.apply("hello wORLD don't 3d x-ray"), "Hello WORLD Don't 3d X-Ray");
|
||||
|
||||
@@ -55,51 +55,3 @@ fn reduce_motion_is_a_preference_that_persists() {
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert!(!old.reduce_motion);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_size_is_relative_to_the_system_and_persists() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.prefs.ui_scale, 1.0, "the system's own size by default");
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.0);
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25);
|
||||
assert_eq!(s.prefs.ui_scale, 1.25);
|
||||
// Reading changes nothing; "auto" (older scripts) is the system's own size.
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25);
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], 1.0);
|
||||
// Nothing to undo: the document didn't change.
|
||||
assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false));
|
||||
// Saved with the other preferences and read back.
|
||||
s.prefs.ui_scale = 0.9;
|
||||
let v = serde_json::to_value(&s.prefs).unwrap();
|
||||
assert_eq!(v["ui_scale"], 0.9);
|
||||
let back: crate::Prefs = serde_json::from_value(v).unwrap();
|
||||
assert_eq!(back.ui_scale, 0.9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_size_stops_at_the_ends_and_junk_never_changes_it() {
|
||||
let mut s = Session::new();
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 0.01})).unwrap()["scale"], crate::UI_SCALE_MIN);
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": -4})).unwrap()["scale"], crate::UI_SCALE_MIN);
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX);
|
||||
// The size is a factor (1.25), not a percentage: 125 is far past the end and stops there.
|
||||
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX);
|
||||
s.prefs.ui_scale = 1.5;
|
||||
for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] {
|
||||
assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}");
|
||||
assert_eq!(s.prefs.ui_scale, 1.5, "{junk}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_size_and_keeps_the_rest() {
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert_eq!(old.ui_scale, 1.0);
|
||||
for bad in [json!("big"), json!(null), json!([2]), json!(true)] {
|
||||
let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap();
|
||||
assert_eq!((p.ui_scale, p.theme.as_str()), (1.0, "dark"), "{bad}");
|
||||
}
|
||||
// A number out of range is clamped rather than refused.
|
||||
let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap();
|
||||
assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark"));
|
||||
}
|
||||
@@ -97,77 +97,3 @@ fn effect_moves_and_removals_reject_junk() {
|
||||
ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7}));
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
|
||||
}
|
||||
|
||||
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
|
||||
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
|
||||
}
|
||||
|
||||
fn brand_id(s: &Session) -> String {
|
||||
s.doc().unwrap().variables.get("brand").unwrap().id.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_paint_edits_detach_the_colour_binding() {
|
||||
// An edit that reported success keeps its colour: the binding it overwrote lets go (as
|
||||
// selection colours already do), instead of putting the variable's colour back.
|
||||
let edits = [
|
||||
("fill.set", json!({"color": "#00FF00"})),
|
||||
("node.set", json!({"props": {"fill": "#00FF00"}})),
|
||||
("node.set", json!({"props": {"fills": ["#00FF00"]}})),
|
||||
];
|
||||
for (cmd, mut p) in edits {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
p["ids"] = json!([r]);
|
||||
ex(&mut s, cmd, p.clone());
|
||||
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}");
|
||||
assert!(bound(&s, r).is_empty(), "{cmd} {p}");
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
|
||||
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable");
|
||||
}
|
||||
for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"}));
|
||||
p["ids"] = json!([r]);
|
||||
ex(&mut s, cmd, p);
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}");
|
||||
assert!(n.bound.is_empty(), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_bound_fills_adds_no_paint_back() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}}));
|
||||
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
|
||||
assert!(bound(&s, r).is_empty());
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
|
||||
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_opacity_edits_keep_the_colour_binding() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"}));
|
||||
assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable");
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5);
|
||||
// The same colour with another opacity, through node.set, keeps it too.
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}}));
|
||||
assert_eq!(bound(&s, r).len(), 1);
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_set_with_their_binding_in_the_same_call_keep_it() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
let brand = brand_id(&s);
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}}));
|
||||
assert_eq!(bound(&s, r), [("fill".to_string(), brand)]);
|
||||
assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies");
|
||||
}
|
||||
@@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result
|
||||
}
|
||||
return Ok(json!({"ids": ids, "operation": op}));
|
||||
}
|
||||
let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?;
|
||||
let out = crate::cmd::structure::wrap_boolean(s, p, op)?;
|
||||
// Operands of a new group follow its operation.
|
||||
if let Some(g) = crate::params::id_of(&out["id"]) {
|
||||
let doc = s.doc_mut()?;
|
||||
|
||||
@@ -27,13 +27,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spacing
|
||||
),
|
||||
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
|
||||
spec!(
|
||||
"arrange.reorder",
|
||||
"Reorder selection",
|
||||
"{ids?, source, target, swap?, fromGesture?} (insert at target's visual slot; no hierarchy changes)",
|
||||
true,
|
||||
reorder
|
||||
),
|
||||
spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)),
|
||||
spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)),
|
||||
spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px),
|
||||
@@ -535,7 +528,6 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0));
|
||||
let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0);
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let n = doc.get(i)?;
|
||||
let (ow, oh) = (n.width, n.height);
|
||||
@@ -559,49 +551,6 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
super::props::restore_worlds(doc, &kept)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Reorder visual slots without rebuilding or reparenting layers.
|
||||
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "arrange.reorder";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let source = req_id(cmd, p, "source")?;
|
||||
let target = req_id(cmd, p, "target")?;
|
||||
let ds = s.ds()?;
|
||||
let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone());
|
||||
let layout =
|
||||
crate::selection_reorder::ReorderLayout::new(&base, &ids).ok_or_else(|| bad(cmd, "select a regular row, column or complete aligned grid"))?;
|
||||
let positions = layout
|
||||
.positions(source, target, boolean(p, "swap") == Some(true))
|
||||
.ok_or_else(|| bad(cmd, "source and target must belong to the selection"))?;
|
||||
// Validate every delta before editing; absolute targets always come from the gesture's
|
||||
// snapshot so crossing several slots and returning to the start does not accumulate edits.
|
||||
let doc = s.doc()?;
|
||||
let mut moves = Vec::new();
|
||||
for (id, at) in positions {
|
||||
let now = doc.world_bounds(id).ok_or_else(|| bad(cmd, "layer is no longer available"))?;
|
||||
let (dx, dy) = (at.x - now.x0, at.y - now.y0);
|
||||
if !dx.is_finite() || !dy.is_finite() {
|
||||
return Err(bad(cmd, "non-finite reorder result"));
|
||||
}
|
||||
if dx.abs() > 1e-9 || dy.abs() > 1e-9 {
|
||||
let node = doc.get(id)?;
|
||||
let parent = node.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
|
||||
let transform = parent.inverse() * Affine::translate((dx, dy)) * parent * node.transform;
|
||||
if !transform.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
return Err(bad(cmd, "non-finite local transform"));
|
||||
}
|
||||
moves.push((id, dx, dy));
|
||||
}
|
||||
}
|
||||
if !moves.is_empty() {
|
||||
let doc = s.doc_mut()?;
|
||||
for (id, dx, dy) in moves {
|
||||
move_world(doc, id, dx, dy)?;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -56,17 +56,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s.set_selection(out.iter().filter_map(id_of).collect());
|
||||
Ok(json!({"ids": out}))
|
||||
}),
|
||||
spec!(
|
||||
"component.combineVariants",
|
||||
"Combine as variants",
|
||||
"{ids? (main components; none already a variant, a library copy or inside an instance)} (wraps them in a new component set; names without `=` become \"Property 1=Default\", \"Property 1=VariantN\")",
|
||||
true,
|
||||
combine
|
||||
),
|
||||
spec!("component.combineVariants", "Combine as variants", "{ids? (components)}", true, combine),
|
||||
spec!(
|
||||
"component.addVariant",
|
||||
"Add variant",
|
||||
"{id? (component set, variant, or a main component not in a set yet: it becomes a set of itself and the new variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)",
|
||||
"{id? (component set or variant), property?, value?} (duplicates the variant; `property`=`value` names the new one)",
|
||||
true,
|
||||
add_variant
|
||||
),
|
||||
@@ -336,25 +330,23 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"id": target}))
|
||||
}
|
||||
|
||||
/// The name a new component set takes from its first variant: the path before the last '/'
|
||||
/// ("Button/State=Hover" → "Button"), or a plain name as is ("Button"). A bare "prop=value" name
|
||||
/// ("State=Default, Size=Small") names no set.
|
||||
fn set_name_from(variant: &str) -> Option<String> {
|
||||
let base = variant.rsplit_once('/').map_or(variant, |(path, _)| path).trim();
|
||||
(!base.is_empty() && !base.contains('=')).then(|| base.to_string())
|
||||
}
|
||||
|
||||
/// Wrap components in a new component set (named after the first) placed around them.
|
||||
fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
fn wrap_in_set(doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
let cmd = "component.combineVariants";
|
||||
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?;
|
||||
if inside_instance(doc, top) || owning_instance(doc, parent).is_some() {
|
||||
return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into()));
|
||||
return Err(EngineError::Disabled(cmd.into(), "components inside an instance can't be combined".into()));
|
||||
}
|
||||
let bounds = doc.bounds_of(ids).unwrap_or_default();
|
||||
let pw = doc.world_transform(parent);
|
||||
let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0));
|
||||
let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component"));
|
||||
let base = ids
|
||||
.first()
|
||||
.and_then(|i| doc.node(*i))
|
||||
.map(|n| n.name.split(['/', '=']).next().unwrap_or("Component").trim().to_string())
|
||||
.filter(|n| !n.is_empty() && !n.contains(','))
|
||||
.unwrap_or_else(|| "Component".into());
|
||||
let set = doc.alloc_id();
|
||||
let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height());
|
||||
n.transform = figcraft_geom::Affine::translate((local.x0, local.y0));
|
||||
@@ -366,71 +358,22 @@ fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result<NodeId>
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
const INSIDE_INSTANCE: &str = "components inside an instance can't become variants";
|
||||
|
||||
/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they
|
||||
/// can: anything but main components, components that are variants already (combining them would
|
||||
/// put a set in a set), library copies, components inside an instance, and a component inside
|
||||
/// another one of `ids`.
|
||||
fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> {
|
||||
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.combineVariants";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) {
|
||||
Some("select components only")
|
||||
} else if ids.iter().any(|i| variant_set(doc, *i).is_some()) {
|
||||
Some("components that are variants already can't be combined again")
|
||||
} else if ids.iter().any(|i| doc.is_remote(*i)) {
|
||||
Some(crate::library::READ_ONLY)
|
||||
} else if ids.iter().any(|i| inside_instance(doc, *i)) {
|
||||
Some(INSIDE_INSTANCE)
|
||||
} else if roots(doc, ids).len() != ids.len() {
|
||||
Some("a component can't be combined with one inside it")
|
||||
} else {
|
||||
None
|
||||
return Err(bad(cmd, "select components only"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more
|
||||
/// main components that [`combine_refusal`] lets through.
|
||||
pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool {
|
||||
ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::<BTreeSet<_>>().len() == ids.len()
|
||||
}
|
||||
|
||||
/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main
|
||||
/// component not in a set yet (it becomes one); never a library copy or inside an instance.
|
||||
pub fn can_add_variant(doc: &Document, id: NodeId) -> bool {
|
||||
let Some(n) = doc.node(id) else { return false };
|
||||
let local = !doc.is_remote(id) && !inside_instance(doc, id);
|
||||
match n.kind {
|
||||
NodeKind::ComponentSet { .. } => {
|
||||
local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. })))
|
||||
}
|
||||
NodeKind::Component { .. } => local,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default",
|
||||
/// "Property 1=VariantN" unless already in prop=value form. Returns the set.
|
||||
fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result<NodeId> {
|
||||
let set = wrap_in_set(cmd, doc, ids)?;
|
||||
let ids = z_sorted(doc, &ids);
|
||||
let set = wrap_in_set(doc, &ids)?;
|
||||
for (k, i) in ids.iter().enumerate() {
|
||||
// Name variants "Property 1=Variant N" unless already in prop=value form.
|
||||
let node = doc.get_mut(*i)?;
|
||||
if !node.name.contains('=') {
|
||||
node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) });
|
||||
}
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
fn combine(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.combineVariants";
|
||||
let mut seen = BTreeSet::new();
|
||||
let ids: Vec<NodeId> = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect();
|
||||
let doc = s.doc_mut()?;
|
||||
if let Some(why) = combine_refusal(doc, &ids) {
|
||||
return Err(EngineError::Disabled(cmd.into(), why.into()));
|
||||
}
|
||||
let ids = z_sorted(doc, &ids);
|
||||
let set = make_variant_set(cmd, doc, &ids)?;
|
||||
s.set_selection(vec![set]);
|
||||
Ok(json!({"id": set}))
|
||||
}
|
||||
@@ -454,19 +397,15 @@ fn fit_set(doc: &mut Document, set: NodeId) {
|
||||
|
||||
fn add_variant(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.addVariant";
|
||||
let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?;
|
||||
let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or variant"))?;
|
||||
let doc = s.doc_mut()?;
|
||||
let (set, base) = match doc.get(t)?.kind {
|
||||
NodeKind::ComponentSet { .. } => {
|
||||
let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?;
|
||||
(t, last)
|
||||
}
|
||||
NodeKind::Component { .. } => match variant_set(doc, t) {
|
||||
Some(set) => (set, t),
|
||||
// A main component on its own becomes a set of itself, then gains the new variant.
|
||||
None => (make_variant_set(cmd, doc, &[t])?, t),
|
||||
},
|
||||
_ => return Err(bad(cmd, "select a component set or component")),
|
||||
NodeKind::Component { .. } => (variant_set(doc, t).ok_or_else(|| bad(cmd, "the component isn't a variant (combine as variants first)"))?, t),
|
||||
_ => return Err(bad(cmd, "select a component set or variant")),
|
||||
};
|
||||
let options = variant_options(doc, set);
|
||||
let mut pairs = variant_props(&doc.get(base)?.name);
|
||||
@@ -533,7 +472,6 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Own layers lose their link (slot content stays as it is); nested instances stay
|
||||
// instances of their own mains.
|
||||
let nested = unlink_layers(doc, i, Some(i));
|
||||
keep_main_modes(doc, i)?;
|
||||
let n = doc.get_mut(i)?;
|
||||
n.kind = NodeKind::Frame;
|
||||
n.source = None;
|
||||
@@ -553,19 +491,6 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"ids": out}))
|
||||
}
|
||||
|
||||
/// Make the variable modes instance `i` takes from its main its own (it's being detached).
|
||||
fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> {
|
||||
let modes = match doc.get(i)?.kind {
|
||||
NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(),
|
||||
_ => return Ok(()),
|
||||
};
|
||||
let own = &mut doc.get_mut(i)?.var_modes;
|
||||
for (c, m) in modes {
|
||||
own.entry(c).or_insert(m);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset_instance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.resetInstance";
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
@@ -683,7 +608,7 @@ fn make_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?;
|
||||
let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name));
|
||||
let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null);
|
||||
add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
|
||||
add_prop(s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
|
||||
}
|
||||
|
||||
fn remove_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1114,12 +1039,7 @@ fn valid_prop_name(cmd: &str, name: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
add_prop_for("component.addProperty", s, p)
|
||||
}
|
||||
|
||||
/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its
|
||||
/// slot property here).
|
||||
fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.addProperty";
|
||||
let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?;
|
||||
let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?;
|
||||
let t = req_string(cmd, p, "type")?;
|
||||
@@ -1164,7 +1084,7 @@ fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.unwrap_or_else(|| "Default".into());
|
||||
let set = match owner {
|
||||
Owner::Set(s) => s,
|
||||
Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?,
|
||||
Owner::Comp(c) => wrap_in_set(doc, &[c])?,
|
||||
};
|
||||
for c in Owner::Set(set).components(doc) {
|
||||
let cn = doc.get_mut(c)?;
|
||||
|
||||
@@ -18,19 +18,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"edit.duplicate",
|
||||
"Duplicate",
|
||||
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
|
||||
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}",
|
||||
true,
|
||||
duplicate
|
||||
),
|
||||
spec!("edit.copy", "Copy", "{ids?}", false, copy),
|
||||
spec!("edit.cut", "Cut", "{ids?}", true, cut),
|
||||
spec!(
|
||||
"edit.paste",
|
||||
"Paste",
|
||||
"{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
|
||||
true,
|
||||
paste
|
||||
),
|
||||
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
|
||||
spec!(
|
||||
"edit.pasteDesignLayers",
|
||||
"Paste design layers",
|
||||
@@ -267,7 +261,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
|
||||
let i = req_index("effect.set", p, "index")?;
|
||||
let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||||
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||||
for id in req_targets("effect.set", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if let Some(slot) = n.effects.get_mut(i) {
|
||||
@@ -472,7 +466,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
|
||||
let idx = doc.index_in_parent(i).map(|x| x + 1);
|
||||
let w = n.width;
|
||||
let nid = crate::components::copy_in_file(doc, i, parent, idx)?;
|
||||
let nid = doc.duplicate_into(i, parent, idx)?;
|
||||
let (dx, dy) = match (num(p, "dx"), num(p, "dy")) {
|
||||
(Some(dx), dy) => (dx, dy.unwrap_or(0.0)),
|
||||
(None, Some(dy)) => (0.0, dy),
|
||||
@@ -487,8 +481,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
nn.transform = Affine::translate((dx, dy)) * nn.transform;
|
||||
}
|
||||
}
|
||||
// An instance keeps the name instances get by default.
|
||||
if rename && !matches!(doc.get(nid)?.kind, NodeKind::Instance { .. }) {
|
||||
if rename {
|
||||
let nn = doc.get_mut(nid)?;
|
||||
nn.name = bump_name(&nn.name);
|
||||
}
|
||||
@@ -522,13 +515,6 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
|
||||
s.clipboard.source = Some(source);
|
||||
s.clipboard.source_path = source_path;
|
||||
s.clipboard.source_doc = uid;
|
||||
s.clipboard.copied_components = nodes
|
||||
.iter()
|
||||
.filter(|(_, sub)| {
|
||||
cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
|
||||
})
|
||||
.map(|(root, _)| *root)
|
||||
.collect();
|
||||
s.clipboard.nodes = nodes;
|
||||
s.clipboard.images = images;
|
||||
s.clipboard.bounds = bounds;
|
||||
@@ -607,8 +593,7 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (nodes, images, bounds) = copied(&doc, &tops);
|
||||
let props = s.clipboard.props.take();
|
||||
// No open file has id 0. So paste treats this copy as a copy from a different file.
|
||||
s.clipboard =
|
||||
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() };
|
||||
s.clipboard = crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None };
|
||||
let mut out = paste(s, p)?;
|
||||
if !other.issues.is_empty() {
|
||||
out["warnings"] = json!(other.issues);
|
||||
@@ -638,7 +623,6 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
|
||||
}
|
||||
// From another file: its instances follow the mains this file can have.
|
||||
let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc));
|
||||
let copy_local = s.ds()?.uid == clip.source_doc;
|
||||
let doc = s.doc_mut()?;
|
||||
for (k, v) in &clip.images {
|
||||
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
@@ -652,16 +636,6 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
|
||||
let mut out = Vec::new();
|
||||
let mut all = BTreeMap::new();
|
||||
for (root, nodes) in &clip.nodes {
|
||||
if copy_local
|
||||
&& clip.copied_components.contains(root)
|
||||
&& let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. }))
|
||||
&& doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
|
||||
{
|
||||
let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?;
|
||||
doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform;
|
||||
out.push(instance);
|
||||
continue;
|
||||
}
|
||||
let mut map = BTreeMap::new();
|
||||
for n in nodes {
|
||||
map.insert(n.id, doc.alloc_id());
|
||||
@@ -687,12 +661,10 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// else in the frame's centre; otherwise in place on the page (or the viewport centre).
|
||||
let sel = s.selection();
|
||||
let doc = s.doc()?;
|
||||
let copy_local = s.ds()?.uid == s.clipboard.source_doc;
|
||||
let target = id(p, "parent").or_else(|| {
|
||||
sel.first()
|
||||
.copied()
|
||||
.filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. })))
|
||||
.filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i)))
|
||||
});
|
||||
let parent = target.unwrap_or(page);
|
||||
let at = match (num(p, "x"), num(p, "y")) {
|
||||
@@ -959,13 +931,9 @@ pub fn opacity_from_digits(digits: &str) -> Option<f32> {
|
||||
fn opacity_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let digits = req_string("object.opacityKeys", p, "digits")?;
|
||||
let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?;
|
||||
let ids = req_targets("object.opacityKeys", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
doc.get_mut(i)?.opacity = o;
|
||||
for i in req_targets("object.opacityKeys", s, p)? {
|
||||
s.doc_mut()?.get_mut(i)?.opacity = o;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
Ok(json!({"opacity": o}))
|
||||
}
|
||||
|
||||
@@ -1213,12 +1181,10 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let props = if skipped.contains(&i) { &without_layout } else { &props };
|
||||
super::props::apply("node.set", doc, i, props)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
if let Some(o) = pivot {
|
||||
for (i, q) in pinned {
|
||||
let d = o - doc.world_transform(i) * q;
|
||||
@@ -1376,7 +1342,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
if let Some(b) = blend(p, "blend") {
|
||||
slot.blend = b;
|
||||
}
|
||||
super::lists::rebind_edited_paint(n, fills, at, &old);
|
||||
super::lists::remap_stop_bindings(n, fills, at, &old);
|
||||
if fills {
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
@@ -1410,8 +1376,8 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let e: Effect = match super::props::effect_from("effect.add", p, "effect")? {
|
||||
Some(e) => e,
|
||||
let e: Effect = match get(p, "effect") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?,
|
||||
None => {
|
||||
let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into());
|
||||
Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))?
|
||||
|
||||
@@ -92,20 +92,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
|
||||
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
|
||||
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
|
||||
let from = req_index("file.moveTab", p, "from")?;
|
||||
let to = req_index("file.moveTab", p, "to")?;
|
||||
if from >= s.docs.len() || to >= s.docs.len() {
|
||||
return Err(bad("file.moveTab", "no such document"));
|
||||
}
|
||||
if from != to {
|
||||
let active_uid = s.docs.get(s.active).map(|d| d.uid);
|
||||
let doc = s.docs.remove(from);
|
||||
s.docs.insert(to, doc);
|
||||
s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0);
|
||||
}
|
||||
Ok(json!({"index": to}))
|
||||
}),
|
||||
spec!(
|
||||
"file.recover",
|
||||
"Recover file",
|
||||
@@ -542,8 +528,7 @@ fn load_with(
|
||||
return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
|
||||
}
|
||||
let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
// Older files saved an untouched default title even after the user chose a filename.
|
||||
if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" {
|
||||
if loaded.doc.name.is_empty() {
|
||||
loaded.doc.name = file_stem(name_hint);
|
||||
}
|
||||
Ok((loaded, false))
|
||||
@@ -854,14 +839,8 @@ fn save_to(s: &mut Session, path: String) -> Result<Value> {
|
||||
}
|
||||
let i = crate::branches::file_tab(s);
|
||||
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
|
||||
let mut snapshot = FileSnapshot::of(ds);
|
||||
let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old));
|
||||
if follows_path && ds.path.as_deref() != Some(path.as_str()) {
|
||||
Arc::make_mut(&mut snapshot.doc).name = file_stem(&path);
|
||||
}
|
||||
let bytes = snapshot.encode()?;
|
||||
let bytes = file_bytes(ds)?;
|
||||
write(&path, &bytes)?;
|
||||
ds.doc = snapshot.doc;
|
||||
ds.path = Some(path.clone());
|
||||
ds.mark_saved();
|
||||
ds.revision += 1;
|
||||
@@ -1395,36 +1374,14 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into()));
|
||||
}
|
||||
let uid = ds.uid;
|
||||
let active_uid = s.docs.get(s.active).map(|d| d.uid);
|
||||
s.docs.remove(i);
|
||||
// Removing a tab before the active one shifts its index; keep the same document selected.
|
||||
s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1)));
|
||||
if s.active >= s.docs.len() {
|
||||
s.active = s.docs.len().saturating_sub(1);
|
||||
}
|
||||
crate::branches::close_tabs(s, uid, None);
|
||||
ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tab_order_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn moving_and_closing_inactive_tabs_preserves_the_active_document() {
|
||||
let mut session = Session::new();
|
||||
for name in ["Two", "Three", "Four"] {
|
||||
session.execute("file.new", &json!({"name": name})).unwrap();
|
||||
}
|
||||
session.execute("doc.switch", &json!({"index": 2})).unwrap();
|
||||
let active_uid = session.docs[session.active].uid;
|
||||
let moved_uid = session.docs[0].uid;
|
||||
session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap();
|
||||
assert_eq!(session.docs[2].uid, moved_uid);
|
||||
assert_eq!(session.docs[session.active].uid, active_uid);
|
||||
session.execute("file.close", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(session.docs[session.active].uid, active_uid);
|
||||
assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
/// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around.
|
||||
fn cycle_doc(s: &mut Session, dir: i64) -> Result<Value> {
|
||||
let n = s.docs.len() as i64;
|
||||
|
||||
@@ -149,35 +149,6 @@ mod tests {
|
||||
assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err());
|
||||
}
|
||||
|
||||
/// Each side key writes its own padding slot and leaves the other three alone.
|
||||
#[test]
|
||||
fn each_side_padding_sets_its_side_alone() {
|
||||
let mut s = Session::new();
|
||||
let (f, _, _) = al_frame(&mut s);
|
||||
// One side at a time: the other three stay 0 ([top, right, bottom, left]).
|
||||
for (key, v, want) in [
|
||||
("paddingTop", 8.0, [8.0, 0.0, 0.0, 0.0]),
|
||||
("paddingRight", 12.0, [0.0, 12.0, 0.0, 0.0]),
|
||||
("paddingBottom", 16.0, [0.0, 0.0, 16.0, 0.0]),
|
||||
("paddingLeft", 20.0, [0.0, 0.0, 0.0, 20.0]),
|
||||
] {
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 0}}));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {key: v}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, want, "{key}");
|
||||
}
|
||||
// The [t, r, b, l] array and the axis pairs agree with the four sides.
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": [8, 12, 16, 20]}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 12.0, 16.0, 20.0]);
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingHorizontal": 4}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 4.0, 16.0, 4.0]);
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingVertical": 2}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [2.0, 4.0, 2.0, 4.0]);
|
||||
// Negatives clamp to 0; junk is refused.
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingTop": -5}}));
|
||||
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], 0.0);
|
||||
assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_alignment_lines_up_text() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -112,8 +112,7 @@ pub fn stop_binding(field: &str) -> Option<usize> {
|
||||
/// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after
|
||||
/// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's
|
||||
/// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices.
|
||||
/// Returns the removed stop's index.
|
||||
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) -> Option<usize> {
|
||||
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
|
||||
use figcraft_color::PaintKind;
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) };
|
||||
@@ -123,11 +122,11 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
|
||||
// The first stop that differs is the removed one when the rest moved down unchanged.
|
||||
let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len());
|
||||
if a.stops.get(k + 1..) != b.stops.get(k..) {
|
||||
return None;
|
||||
return;
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
(_, Some(PaintKind::Gradient(_))) => return None,
|
||||
(_, Some(PaintKind::Gradient(_))) => return,
|
||||
_ => None,
|
||||
};
|
||||
let keys: Vec<(String, Option<String>)> = n
|
||||
@@ -151,42 +150,6 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
|
||||
}
|
||||
}
|
||||
n.bound.extend(kept);
|
||||
removed
|
||||
}
|
||||
|
||||
/// After an explicit edit changed paint `index` of `n`'s fills (or strokes) from `old`: its stop
|
||||
/// bindings follow a removed stop, then each binding whose value the edit changed (the colour, the
|
||||
/// opacity, a stop's colour) lets go, so the edit stays instead of being put back from the
|
||||
/// variable. Bindings whose value the edit kept stay; a paint that is gone takes its bindings.
|
||||
pub(crate) fn rebind_edited_paint(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
|
||||
let removed = remap_stop_bindings(n, fills, index, old);
|
||||
let mut old = old.clone();
|
||||
if let (Some(k), figcraft_color::PaintKind::Gradient(g)) = (removed, &mut old.kind)
|
||||
&& k < g.stops.len()
|
||||
{
|
||||
g.stops.remove(k);
|
||||
}
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }.cloned();
|
||||
n.bound.retain(|key, _| match list_binding(key) {
|
||||
Some((l, i, field)) if l == list && i == index => new.as_ref().is_some_and(|new| paint_field_kept(&old, new, field)),
|
||||
_ => true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a paint's bound `field` (`color`, `opacity`, `stops/<j>/color`, …) has the same value in
|
||||
/// `new` as in `old`.
|
||||
fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, field: &str) -> bool {
|
||||
use figcraft_color::PaintKind;
|
||||
match field {
|
||||
"color" => matches!((&old.kind, &new.kind), (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) if a == b),
|
||||
"opacity" => old.opacity == new.opacity,
|
||||
_ => match (stop_binding(field), &old.kind, &new.kind) {
|
||||
(Some(j), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(j).map(|s| s.color) == b.stops.get(j).map(|s| s.color),
|
||||
(Some(_), _, _) => false,
|
||||
_ => old == new,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Where item `k` lands when the item at `from` moves to `to`.
|
||||
|
||||
@@ -61,48 +61,6 @@ pub fn snake(v: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` as an effect list, refusing keys an effect doesn't read: serde dropped them without a word,
|
||||
/// so a shadow given `offsetY` kept the default offset.
|
||||
pub fn effects_from(cmd: &str, v: &Value) -> Result<Vec<Effect>> {
|
||||
let effects: Vec<Effect> = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?;
|
||||
for (i, given) in v.as_array().into_iter().flatten().enumerate() {
|
||||
refuse_ignored_effect_keys(cmd, &format!("effects[{i}]"), given, |v| serde_json::from_value(snake(v)).ok())?;
|
||||
}
|
||||
Ok(effects)
|
||||
}
|
||||
|
||||
/// `p[key]` as one effect, read as serde reads it (its aliases too), refusing keys it doesn't read.
|
||||
pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result<Option<Effect>> {
|
||||
let Some(v) = get(p, key) else { return Ok(None) };
|
||||
let effect: Effect = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?;
|
||||
refuse_ignored_effect_keys(cmd, &format!("`{key}`"), v, |v| serde_json::from_value(v.clone()).ok())?;
|
||||
Ok(Some(effect))
|
||||
}
|
||||
|
||||
/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to
|
||||
/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing.
|
||||
fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option<Effect>) -> Result<()> {
|
||||
let Some(fields) = given.as_object() else { return Ok(()) };
|
||||
let parsed = parse(given);
|
||||
let ignored: Vec<&str> = fields
|
||||
.iter()
|
||||
.filter(|(_, v)| !v.is_null())
|
||||
.filter(|(k, _)| {
|
||||
let mut probe = fields.clone();
|
||||
probe.insert((*k).clone(), Value::Null);
|
||||
parse(&Value::Object(probe)).is_some_and(|e| Some(e) == parsed)
|
||||
})
|
||||
.map(|(k, _)| k.as_str())
|
||||
.collect();
|
||||
if ignored.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let takes = parsed.and_then(|e| serde_json::to_value(e).ok()).map(crate::inspect::camel).unwrap_or_default();
|
||||
let kind = takes.get("type").and_then(Value::as_str).unwrap_or("effect").to_string();
|
||||
let names: Vec<&str> = takes.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
|
||||
Err(bad(cmd, format!("{at}: unknown properties: {} (a {kind} takes {})", ignored.join(", "), names.join(", "))))
|
||||
}
|
||||
|
||||
pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
let s = v.as_str()?;
|
||||
// Accept camelCase, snake/upper-case and spaced variants.
|
||||
@@ -339,12 +297,6 @@ pub const LAYOUT_KEYS: &[&str] = &[
|
||||
"gridRowTracks",
|
||||
];
|
||||
|
||||
/// Drop the bindings of auto layout fields (gap, padding…): they go with the auto layout, or
|
||||
/// re-applying them would bring it back.
|
||||
pub fn drop_layout_bindings(n: &mut Node) {
|
||||
n.bound.retain(|k, _| !LAYOUT_KEYS.contains(&k.as_str()));
|
||||
}
|
||||
|
||||
/// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an
|
||||
/// auto layout: an auto layout key on them is refused, not stored where nothing reads it
|
||||
/// (`node.set` on several layers skips them instead, see `edit::set`).
|
||||
@@ -363,32 +315,18 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay
|
||||
Ok(ensure_layout(n))
|
||||
}
|
||||
|
||||
/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list
|
||||
/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`).
|
||||
fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
|
||||
let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints);
|
||||
for (i, p) in old.iter().enumerate() {
|
||||
super::lists::rebind_edited_paint(n, fills, i, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
|
||||
/// bindings last so paints set in the same call don't detach them.
|
||||
fn apply_order(k: &str) -> u8 {
|
||||
match k {
|
||||
"width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
"boundVariables" => 4,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a property map to one node. `x`/`y` are relative to the parent.
|
||||
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
|
||||
let mut unknown = Vec::new();
|
||||
// Geometry keys first so rotation applies to the final size.
|
||||
let order = |k: &str| match k {
|
||||
"width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
_ => 1,
|
||||
};
|
||||
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
|
||||
keys.sort_by_key(|(k, _)| apply_order(k));
|
||||
keys.sort_by_key(|(k, _)| order(k));
|
||||
for (k, v) in keys {
|
||||
let k = k.as_str();
|
||||
match k {
|
||||
@@ -460,15 +398,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
|
||||
doc.get_mut(id)?.blend = b;
|
||||
}
|
||||
"fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
|
||||
"fills" => doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?,
|
||||
"fill" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
|
||||
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
|
||||
doc.get_mut(id)?.fills = vec![Paint::solid(c)];
|
||||
}
|
||||
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
|
||||
"strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?,
|
||||
"stroke" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
|
||||
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
|
||||
doc.get_mut(id)?.strokes = vec![Paint::solid(c)];
|
||||
}
|
||||
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
|
||||
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
|
||||
@@ -524,7 +462,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
}
|
||||
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
"effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
|
||||
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
|
||||
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
|
||||
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
|
||||
"devStatus" => {
|
||||
@@ -548,14 +486,8 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
return Err(bad(cmd, "auto layout needs a frame"));
|
||||
}
|
||||
match v {
|
||||
Value::Null => {
|
||||
n.layout = None;
|
||||
drop_layout_bindings(n);
|
||||
}
|
||||
Value::String(s) if s.eq_ignore_ascii_case("none") => {
|
||||
n.layout = None;
|
||||
drop_layout_bindings(n);
|
||||
}
|
||||
Value::Null => n.layout = None,
|
||||
Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None,
|
||||
Value::String(s) => {
|
||||
let mode = match s.to_ascii_lowercase().as_str() {
|
||||
"horizontal" | "row" => LayoutMode::Horizontal,
|
||||
|
||||
@@ -10,13 +10,11 @@ use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout
|
||||
/// add/remove, move to page and vector path edits.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.group", Wrap::Group)),
|
||||
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)),
|
||||
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.frameSelection", Wrap::Frame)),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
|
||||
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
|
||||
spec!(
|
||||
"object.convertToGroup",
|
||||
@@ -44,7 +42,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Wrap in new section",
|
||||
"{ids?, name?} (layers on the page or in a section; the section wraps them with some room)",
|
||||
true,
|
||||
|s, p| wrap(s, p, "object.wrapInSection", Wrap::Section)
|
||||
|s, p| wrap(s, p, Wrap::Section)
|
||||
),
|
||||
spec!(
|
||||
"object.resizeToFit",
|
||||
@@ -73,9 +71,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// Hugging needs auto layout and the children that filled have nothing to fill: both
|
||||
// become fixed (the frame keeps filling its own auto layout parent).
|
||||
let doc = s.doc_mut()?;
|
||||
let n = doc.get_mut(i)?;
|
||||
n.layout = None;
|
||||
super::props::drop_layout_bindings(n);
|
||||
doc.get_mut(i)?.layout = None;
|
||||
figcraft_layout::coherent_sizing(doc, i);
|
||||
}
|
||||
ok()
|
||||
@@ -132,27 +128,26 @@ const SECTION_PAD: f64 = 40.0;
|
||||
const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group";
|
||||
|
||||
/// Wrap the selection in a new boolean group doing `op`.
|
||||
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result<Value> {
|
||||
wrap(s, p, cmd, Wrap::Bool(op))
|
||||
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result<Value> {
|
||||
wrap(s, p, Wrap::Bool(op))
|
||||
}
|
||||
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place. Errors name `cmd`,
|
||||
/// the command that asked.
|
||||
fn wrap(s: &mut Session, p: &Value, cmd: &str, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place.
|
||||
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets("object.group", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "can't group a page"))?;
|
||||
let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?;
|
||||
// Sections sit on the page or in sections, never in frames or groups.
|
||||
let board = |doc: &Document, i: NodeId| {
|
||||
doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
|
||||
};
|
||||
if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) {
|
||||
return Err(bad(cmd, "only layers on the page or in a section can be wrapped in a section"));
|
||||
return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section"));
|
||||
}
|
||||
if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) {
|
||||
return Err(bad(cmd, "sections can't go in groups or frames"));
|
||||
return Err(bad("object.group", "sections can't go in groups or frames"));
|
||||
}
|
||||
let index = doc.index_in_parent(top).map(|i| i + 1);
|
||||
let mut bounds = doc.bounds_of(&ids).unwrap_or_default();
|
||||
@@ -290,7 +285,7 @@ fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.grids.clear();
|
||||
n.guides.clear();
|
||||
n.styles.grid = None;
|
||||
super::props::drop_layout_bindings(n);
|
||||
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
|
||||
let kids = n.children.clone();
|
||||
for k in kids {
|
||||
// Out of the flow there is nothing to fill or to be absolute against.
|
||||
@@ -343,7 +338,7 @@ fn mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return ok();
|
||||
}
|
||||
// Group, then the bottom-most child is the mask.
|
||||
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), "object.mask", Wrap::Group)?;
|
||||
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?;
|
||||
let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?;
|
||||
let doc = s.doc_mut()?;
|
||||
if let Some(first) = doc.get(gid)?.children.first().copied() {
|
||||
@@ -477,7 +472,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = doc.get_mut(fill_id)?;
|
||||
f.strokes.clear();
|
||||
}
|
||||
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), "vector.outlineStroke", Wrap::Group)?;
|
||||
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?;
|
||||
if let Some(g) = id_of(&r["id"]) {
|
||||
out.push(g);
|
||||
}
|
||||
@@ -524,7 +519,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ids
|
||||
} else {
|
||||
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
|
||||
let r = wrap(s, &json!({"ids": ids}), "layout.add", Wrap::Frame)?;
|
||||
let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?;
|
||||
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
|
||||
let f = s.doc_mut()?.get_mut(fid)?;
|
||||
f.fills.clear();
|
||||
|
||||
@@ -110,26 +110,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
))
|
||||
}),
|
||||
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
|
||||
let cmd = "variable.createCollection";
|
||||
let name = req_string(cmd, p, "name")?.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad(cmd, "name can't be empty"));
|
||||
}
|
||||
let modes: Vec<String> = match get(p, "modes") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(|m| m.trim().to_string())).collect(),
|
||||
_ => vec!["Mode 1".into()],
|
||||
};
|
||||
if modes.is_empty() {
|
||||
return Err(bad(cmd, "a collection needs at least one mode"));
|
||||
}
|
||||
if modes.iter().any(String::is_empty) {
|
||||
return Err(bad(cmd, "mode names can't be empty"));
|
||||
}
|
||||
let name = req_string("variable.createCollection", p, "name")?;
|
||||
let doc = s.doc_mut()?;
|
||||
if doc.variables.collections.iter().any(|c| c.name == name) {
|
||||
return Err(bad(cmd, format!("a collection named `{name}` already exists")));
|
||||
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
|
||||
}
|
||||
let id = doc.new_collection_id();
|
||||
let modes: Vec<String> = match get(p, "modes") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
|
||||
_ => vec!["Mode 1".into()],
|
||||
};
|
||||
let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect();
|
||||
doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None });
|
||||
Ok(json!({"id": id}))
|
||||
@@ -960,104 +950,6 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
|
||||
apply_bindings_matching(doc, only, |_| true);
|
||||
}
|
||||
|
||||
/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings).
|
||||
pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec<std::sync::Arc<Node>> {
|
||||
ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect()
|
||||
}
|
||||
|
||||
/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit
|
||||
/// changed drops its binding, which would otherwise write the variable's value back (#809).
|
||||
/// Compared by value, so keys written unchanged keep their bindings and fields moved along with
|
||||
/// another (proportions, min/max) lose theirs.
|
||||
pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc<Node>]) {
|
||||
for old in before {
|
||||
let Some(n) = doc.node(old.id) else { continue };
|
||||
let edited: Vec<String> = n
|
||||
.bound
|
||||
.keys()
|
||||
// A binding the edit itself made (`boundVariables`) stays.
|
||||
.filter(|f| old.bound.get(*f) == n.bound.get(*f))
|
||||
.filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING))
|
||||
.filter(|f| bound_field(old, f) != bound_field(n, f))
|
||||
.cloned()
|
||||
.collect();
|
||||
if edited.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(n) = doc.get_mut(old.id) {
|
||||
for f in edited {
|
||||
n.bound.remove(&f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field).
|
||||
fn bound_field(n: &Node, field: &str) -> Option<String> {
|
||||
let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind {
|
||||
NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::<Vec<_>>().join(",")),
|
||||
_ => None,
|
||||
};
|
||||
let layout = n.layout.as_ref();
|
||||
let cl = &n.child_layout;
|
||||
let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied());
|
||||
Some(match field {
|
||||
"width" => format!("{}", n.width),
|
||||
"height" => format!("{}", n.height),
|
||||
"minWidth" => format!("{:?}", cl.min_w),
|
||||
"maxWidth" => format!("{:?}", cl.max_w),
|
||||
"minHeight" => format!("{:?}", cl.min_h),
|
||||
"maxHeight" => format!("{:?}", cl.max_h),
|
||||
"opacity" => format!("{}", n.opacity),
|
||||
"visible" => format!("{}", n.visible),
|
||||
"gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)),
|
||||
"crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)),
|
||||
"padding" => format!("{:?}", layout.map(|l| l.padding)),
|
||||
"paddingTop" => format!("{:?}", pad(0)),
|
||||
"paddingRight" => format!("{:?}", pad(1)),
|
||||
"paddingBottom" => format!("{:?}", pad(2)),
|
||||
"paddingLeft" => format!("{:?}", pad(3)),
|
||||
"paddingHorizontal" => format!("{:?}", (pad(1), pad(3))),
|
||||
"paddingVertical" => format!("{:?}", (pad(0), pad(2))),
|
||||
"cornerRadius" => format!("{:?}", n.corners.radii),
|
||||
"topLeftRadius" => format!("{:?}", n.corners.radii.first()),
|
||||
"topRightRadius" => format!("{:?}", n.corners.radii.get(1)),
|
||||
"bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)),
|
||||
"bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)),
|
||||
"strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)),
|
||||
"strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides),
|
||||
"arcStart" | "arcSweep" | "arcInner" => match &n.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } => format!(
|
||||
"{}",
|
||||
if field == "arcStart" {
|
||||
start
|
||||
} else if field == "arcSweep" {
|
||||
sweep
|
||||
} else {
|
||||
inner
|
||||
}
|
||||
),
|
||||
_ => return None,
|
||||
},
|
||||
"characters" => match &n.kind {
|
||||
NodeKind::Text(t) => t.characters.clone(),
|
||||
_ => return None,
|
||||
},
|
||||
"paragraphSpacing" | "paragraphIndent" => match &n.kind {
|
||||
NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }),
|
||||
_ => return None,
|
||||
},
|
||||
"fontFamily" => text(&|s| s.font_family.clone())?,
|
||||
"fontStyle" => text(&|s| s.font_style.clone())?,
|
||||
"fontWeight" => text(&|s| s.weight().to_string())?,
|
||||
"fontSize" => text(&|s| s.font_size.to_string())?,
|
||||
"lineHeight" => text(&|s| format!("{:?}", s.line_height))?,
|
||||
"letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?,
|
||||
// shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice.
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too,
|
||||
/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity
|
||||
/// (styles' paints and layers') before the bindings are first resolved, so it applies once.
|
||||
@@ -1115,8 +1007,6 @@ pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
|
||||
doc.mark_dirty(pending);
|
||||
}
|
||||
|
||||
/// Re-apply the variable bindings whose field passes `include`, on the `only` nodes (every bound
|
||||
/// node when `None`). Auto layout fields wait while their node has no auto layout.
|
||||
fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>, include: impl Fn(&str) -> bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
if doc.variables.variables.is_empty() {
|
||||
@@ -1131,7 +1021,6 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
|
||||
let modes = doc.modes_for(id);
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
let binds = n.bound.clone();
|
||||
let has_layout = n.layout.is_some();
|
||||
let mut sets = serde_json::Map::new();
|
||||
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
|
||||
let mut paint_sets: Vec<(bool, usize, Option<usize>, Color)> = Vec::new();
|
||||
@@ -1141,10 +1030,6 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
|
||||
if !include(&field) {
|
||||
continue;
|
||||
}
|
||||
// A binding never creates an auto layout: without one it waits for the next.
|
||||
if !has_layout && super::props::LAYOUT_KEYS.contains(&field.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
|
||||
// One list item's value: a paint's colour, an effect's or a layout grid's field.
|
||||
if let Some((list, i, prop)) = list_binding(&field) {
|
||||
|
||||
@@ -18,7 +18,6 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
|
||||
let ds = s.ds_mut()?;
|
||||
let before = ds.doc.clone();
|
||||
let doc = ds.doc_mut();
|
||||
let bound = super::styles::bound_before(doc, &[id]);
|
||||
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
|
||||
if a == b {
|
||||
t.restyle(0, t.char_count(), &f);
|
||||
@@ -26,21 +25,19 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
|
||||
t.restyle(a, b, &f);
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &bound);
|
||||
crate::normalize(doc, &before);
|
||||
return ok();
|
||||
}
|
||||
let ids = req_targets("text", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids: Vec<NodeId> = ids.into_iter().flat_map(|i| doc.subtree(i)).collect();
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for d in ids {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
||||
let n = t.char_count();
|
||||
t.restyle(0, n, &f);
|
||||
for i in ids {
|
||||
for d in doc.subtree(i) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
||||
let n = t.char_count();
|
||||
t.restyle(0, n, &f);
|
||||
}
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -351,13 +348,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
|
||||
let ids = req_targets("text.set", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let mut m = Map::new();
|
||||
m.insert("characters".into(), json!(c));
|
||||
super::props::apply("text.set", doc, i, &m)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
@@ -369,7 +364,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let ids = req_targets("text.style", s, p)?;
|
||||
let m = p.as_object().cloned().unwrap_or_default();
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
|
||||
@@ -383,7 +377,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
super::props::apply_text_style("text.style", t, a, b, k, v)?;
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
),
|
||||
@@ -481,40 +474,5 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
})
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.rescanFonts",
|
||||
"Rescan installed fonts",
|
||||
"{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}",
|
||||
true,
|
||||
|s, _| {
|
||||
let rescan = figcraft_text::rescan_system_fonts();
|
||||
let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed));
|
||||
Ok(json!({"added": rescan.added, "refitted": refitted}))
|
||||
}
|
||||
),
|
||||
spec!(
|
||||
"text.loadFont",
|
||||
"Load font file",
|
||||
"{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}",
|
||||
true,
|
||||
|s, p| {
|
||||
let cmd = "text.loadFont";
|
||||
let bytes = if let Some(b64) = string(p, "data") {
|
||||
use base64::Engine as _;
|
||||
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
|
||||
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
|
||||
} else if let Some(path) = string(p, "path") {
|
||||
super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)?
|
||||
} else {
|
||||
return Err(bad(cmd, "pass `path` or `data` (base64)"));
|
||||
};
|
||||
let families = figcraft_text::load_font_bytes(bytes);
|
||||
if families.is_empty() {
|
||||
return Err(bad(cmd, "not a font file"));
|
||||
}
|
||||
let refitted = crate::fonts::refit_open(s, None, Some(&families));
|
||||
Ok(json!({"families": families, "refitted": refitted}))
|
||||
}
|
||||
),
|
||||
]
|
||||
}
|
||||
@@ -370,25 +370,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
|
||||
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
|
||||
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
|
||||
spec!(
|
||||
"pref.uiScale",
|
||||
"Interface size",
|
||||
"{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
|
||||
false,
|
||||
|s, p| {
|
||||
match get(p, "scale") {
|
||||
None => {}
|
||||
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = 1.0,
|
||||
Some(_) => {
|
||||
let scale = req_num("pref.uiScale", p, "scale")?;
|
||||
s.prefs.ui_scale = crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number"))?;
|
||||
}
|
||||
}
|
||||
Ok(json!({"scale": s.prefs.ui_scale}))
|
||||
}
|
||||
),
|
||||
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
|
||||
spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)),
|
||||
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
|
||||
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
|
||||
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
|
||||
@@ -427,7 +409,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"pref.language",
|
||||
"Interface language",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}",
|
||||
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}",
|
||||
false,
|
||||
|s, p| {
|
||||
let lang = req_string("pref.language", p, "language")?;
|
||||
@@ -540,24 +522,3 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn unused(_: NodeId) {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod start_on_home_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn start_on_home_is_a_preference_that_persists() {
|
||||
let mut s = Session::new();
|
||||
assert!(!s.prefs.start_on_home);
|
||||
assert_eq!(s.execute("pref.startOnHome", &json!({})).unwrap()["on"], true);
|
||||
assert!(s.prefs.start_on_home);
|
||||
// Saved with the other preferences and read back; older settings files without it read as off.
|
||||
let saved: crate::Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
|
||||
assert!(saved.start_on_home);
|
||||
assert_eq!(s.execute("pref.startOnHome", &json!({"on": false})).unwrap()["on"], false);
|
||||
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
|
||||
assert!(!old.start_on_home);
|
||||
}
|
||||
}
|
||||
@@ -259,25 +259,6 @@ fn stroke_color(n: &Node) -> Option<Color> {
|
||||
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color())
|
||||
}
|
||||
|
||||
/// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border
|
||||
/// on each side that has a weight.
|
||||
fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> {
|
||||
let Some(c) = stroke_color(n) else { return Vec::new() };
|
||||
let Some(sides) = n.stroke.sides else {
|
||||
let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t);
|
||||
return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))];
|
||||
};
|
||||
let color = tok(doc, n, &["stroke"], c.to_css(), t);
|
||||
let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES);
|
||||
named
|
||||
.zip(sides)
|
||||
.filter(|(_, weight)| *weight > 0.0)
|
||||
.map(|((side, field), weight)| {
|
||||
(format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn drop_shadow(n: &Node) -> Option<&Shadow> {
|
||||
n.effects.iter().filter(|e| e.visible).find_map(|e| match &e.kind {
|
||||
EffectKind::DropShadow(s) => Some(s),
|
||||
@@ -471,8 +452,9 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
||||
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
|
||||
}
|
||||
}
|
||||
for (key, value) in css_borders(doc, n, t) {
|
||||
put(&key, value);
|
||||
if let Some(c) = stroke_color(n) {
|
||||
let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t);
|
||||
put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)));
|
||||
}
|
||||
let shadows: Vec<String> = n
|
||||
.effects
|
||||
@@ -876,16 +858,11 @@ pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra
|
||||
swift_layer(ctx, n, depth, out, extra);
|
||||
}
|
||||
|
||||
/// A layer name safe for a single-line `//` comment: line breaks become spaces.
|
||||
fn one_line(name: &str) -> String {
|
||||
name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ")
|
||||
}
|
||||
|
||||
fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
|
||||
let doc = ctx.doc;
|
||||
let pad = indent(depth);
|
||||
if ctx.too_deep(depth) {
|
||||
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
|
||||
out.push_str(&format!("{pad}// {} …\n", n.name));
|
||||
return;
|
||||
}
|
||||
// Nested instances are calls; an instance swap parameter is a view.
|
||||
@@ -1058,7 +1035,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
|
||||
let doc = ctx.doc;
|
||||
let pad = indent(depth);
|
||||
if ctx.too_deep(depth) {
|
||||
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
|
||||
out.push_str(&format!("{pad}// {} …\n", n.name));
|
||||
return;
|
||||
}
|
||||
// Nested instances are calls; an instance swap parameter is a composable.
|
||||
@@ -1191,26 +1168,4 @@ mod tests {
|
||||
assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
|
||||
assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() {
|
||||
let mut s = Session::new();
|
||||
let name = "deep\nevil()\r\u{2028}more()";
|
||||
let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap();
|
||||
let root = parent;
|
||||
for _ in 0..16 {
|
||||
parent =
|
||||
id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"])
|
||||
.unwrap();
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
for lang in [CodeLang::SwiftUi, CodeLang::Compose] {
|
||||
let code = code_for(doc, root, lang);
|
||||
assert!(code.contains("// deep evil() more() …\n"), "{code}");
|
||||
for line in code.lines() {
|
||||
let t = line.trim_start();
|
||||
assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,28 +285,11 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
|
||||
root.dev_resources.clear();
|
||||
// The main's interactions play on its instances in prototypes; the instance's own start empty.
|
||||
root.interactions.clear();
|
||||
// So do the variable modes the main sets (see `Document::inherited_modes`).
|
||||
root.var_modes.clear();
|
||||
doc.insert(root, parent, index)?;
|
||||
sync_instance(doc, id);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file
|
||||
/// has one main per component, so a main component's copy is an instance of it; a variant copied
|
||||
/// into its set stays a new variant, and every other layer is deep-copied.
|
||||
pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option<usize>) -> Result<NodeId, DocError> {
|
||||
let n = doc.get(id)?;
|
||||
let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. }));
|
||||
if !matches!(n.kind, NodeKind::Component { .. }) || in_set {
|
||||
return doc.duplicate_into(id, parent, index);
|
||||
}
|
||||
let transform = n.transform;
|
||||
let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?;
|
||||
doc.get_mut(instance)?.transform = transform;
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
/// Which part of the carried overrides to apply.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Carry {
|
||||
|
||||
@@ -82,179 +82,6 @@ fn text(n: &Node) -> &TextData {
|
||||
n.kind.text().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_a_main_component_pastes_a_linked_instance() {
|
||||
for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] {
|
||||
let mut s = Session::new();
|
||||
let k = kit(&mut s);
|
||||
ex(&mut s, "select.set", json!({"ids": [k.card]}));
|
||||
ex(&mut s, "edit.copy", json!({}));
|
||||
if command != "edit.paste" {
|
||||
ex(&mut s, "select.set", json!({"ids": [k.inst]}));
|
||||
}
|
||||
let before = undo_len(&s);
|
||||
let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap();
|
||||
assert_eq!(component_of(&s, pasted), k.card, "{command}");
|
||||
assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
|
||||
assert_eq!(node(&s, pasted).source, Some(k.card));
|
||||
assert_eq!(undo_len(&s), before + 1);
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc().unwrap().node(pasted).is_none());
|
||||
ex(&mut s, "edit.redo", json!({}));
|
||||
assert_eq!(component_of(&s, pasted), k.card);
|
||||
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
|
||||
assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated");
|
||||
healthy(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() {
|
||||
let mut s = Session::new();
|
||||
let (set, _, hover) = button_set(&mut s);
|
||||
ex(&mut s, "select.set", json!({"ids": [hover]}));
|
||||
ex(&mut s, "edit.copy", json!({}));
|
||||
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
|
||||
assert_eq!(component_of(&s, pasted), hover);
|
||||
assert_eq!(node(&s, pasted).name, node(&s, set).name);
|
||||
let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") };
|
||||
assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover");
|
||||
assert_eq!(props["Show dot"], true);
|
||||
assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
|
||||
|
||||
ex(&mut s, "select.set", json!({"ids": [set]}));
|
||||
ex(&mut s, "edit.copy", json!({}));
|
||||
let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
|
||||
assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. }));
|
||||
assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap()));
|
||||
assert_eq!(kids(&s, copied_set).len(), 2);
|
||||
for variant in kids(&s, copied_set) {
|
||||
assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. }));
|
||||
assert!(!kids(&s, set).contains(&variant));
|
||||
}
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cut_components_and_components_copied_to_another_file_remain_components() {
|
||||
for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] {
|
||||
let mut s = Session::new();
|
||||
let k = kit(&mut s);
|
||||
ex(&mut s, copy, json!({"ids": [k.card]}));
|
||||
if undo_cut {
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
}
|
||||
if new_file {
|
||||
ex(&mut s, "file.new", json!({}));
|
||||
}
|
||||
let page = s.page().unwrap();
|
||||
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
|
||||
assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}");
|
||||
assert_eq!(kids(&s, pasted).len(), 3);
|
||||
healthy(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_a_component_does_not_link_to_an_id_reused_after_undo() {
|
||||
let mut s = Session::new();
|
||||
let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"})));
|
||||
let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
|
||||
ex(&mut s, "edit.copy", json!({"ids": [main]}));
|
||||
s.undo().unwrap();
|
||||
s.undo().unwrap();
|
||||
let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"})));
|
||||
ex(&mut s, "component.create", json!({"ids": [other]}));
|
||||
assert_eq!(other, main, "undo makes the original id available again");
|
||||
let page = s.page().unwrap();
|
||||
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
|
||||
assert_eq!(node(&s, pasted).name, "Copied main");
|
||||
assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0));
|
||||
assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() {
|
||||
let mut s = Session::new();
|
||||
let k = kit(&mut s);
|
||||
ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}}));
|
||||
let original = s.doc().unwrap().world_transform(k.card);
|
||||
let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000})));
|
||||
ex(&mut s, "edit.copy", json!({"ids": [k.card]}));
|
||||
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap();
|
||||
assert_eq!(component_of(&s, pasted), k.card);
|
||||
for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) {
|
||||
assert!((actual - expected).abs() < 1e-9);
|
||||
}
|
||||
assert_eq!(node(&s, pasted).parent, Some(frame));
|
||||
let before = s.doc().unwrap().clone();
|
||||
assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_a_main_component_makes_a_linked_instance() {
|
||||
for rename in [false, true] {
|
||||
let mut s = Session::new();
|
||||
ex(&mut s, "pref.renameDuplicates", json!({"on": rename}));
|
||||
let k = kit(&mut s);
|
||||
let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 50, "height": 50, "props": {"name": "Box"}})));
|
||||
let before = s.doc().unwrap().clone();
|
||||
let out = ex(&mut s, "edit.duplicate", json!({"ids": [k.card, rect]}));
|
||||
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().filter_map(crate::params::id_of).collect();
|
||||
assert_eq!(s.selection(), ids);
|
||||
let copy = *ids.iter().find(|i| matches!(node(&s, **i).kind, NodeKind::Instance { .. })).expect("the main's copy is an instance");
|
||||
assert_eq!(component_of(&s, copy), k.card);
|
||||
assert_eq!(node(&s, copy).name, "Card", "instances keep their default name (rename={rename})");
|
||||
assert_eq!(node(&s, copy).parent, Some(s.page().unwrap()));
|
||||
let (b, c) = (s.doc().unwrap().world_bounds(k.card).unwrap(), s.doc().unwrap().world_bounds(copy).unwrap());
|
||||
assert_eq!((c.x0, c.y0, c.width(), c.height()), (b.x1 + 100.0, b.y0, b.width(), b.height()), "a top-level frame's offset");
|
||||
let other = *ids.iter().find(|i| **i != copy).unwrap();
|
||||
assert!(matches!(node(&s, other).kind, NodeKind::Rectangle), "other layers are still copied");
|
||||
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
|
||||
assert_eq!(chars(&s, copy_of(&s, copy, k.title)), "Updated", "the instance follows its main");
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(*s.doc().unwrap(), before, "undo restores exactly");
|
||||
healthy(&s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_dragging_a_main_component_copies_an_instance() {
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Tile"})));
|
||||
let main = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
let ev = |kind, x| PointerEvent { alt: true, ..PointerEvent::at(kind, x, 50.0, 1.0) };
|
||||
s.pointer(&ev(PointerKind::Down, 50.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, 150.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Move, 250.0)).unwrap();
|
||||
s.pointer(&ev(PointerKind::Up, 250.0)).unwrap();
|
||||
let [copy] = s.selection()[..] else { panic!("one copy selected") };
|
||||
assert_eq!(component_of(&s, copy), main);
|
||||
assert_eq!(s.doc().unwrap().world_bounds(copy).unwrap().x0, 200.0);
|
||||
assert_eq!(s.doc().unwrap().world_bounds(main).unwrap().x0, 0.0, "the main stays");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_a_variant_adds_a_variant_to_its_set() {
|
||||
let mut s = Session::new();
|
||||
let (set, _, hover) = button_set(&mut s);
|
||||
let copy = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [hover]}))["ids"][0]).unwrap();
|
||||
assert!(matches!(node(&s, copy).kind, NodeKind::Component { .. }));
|
||||
assert_eq!(node(&s, copy).parent, Some(set));
|
||||
assert_eq!(kids(&s, set).len(), 3);
|
||||
// A whole set duplicates as a new set of variants.
|
||||
let set2 = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [set]}))["ids"][0]).unwrap();
|
||||
assert!(matches!(node(&s, set2).kind, NodeKind::ComponentSet { .. }));
|
||||
assert!(kids(&s, set2).iter().all(|v| matches!(node(&s, *v).kind, NodeKind::Component { .. })));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overridable_properties_stay_through_main_edits_and_reset() {
|
||||
// (what, layer, group, override on the instance, the same property edited on the main, read,
|
||||
@@ -879,35 +706,3 @@ fn auto_layout_instances_follow_their_main() {
|
||||
assert_eq!(node(&s, i).child_layout.min_w, Some(80.0));
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_component_set_is_named_after_its_first_variant() {
|
||||
// "Button/State=Hover" groups under "Button": the set takes the path before the last '/'.
|
||||
// A bare property name ("State=Default, Size=Small") names no set, and neither does an empty
|
||||
// name: the set gets the next numbered default, like a new component. A plain name is kept.
|
||||
let rows = [
|
||||
("Button/State=Hover", "Button", "slash-prefixed: the path before the last '/'"),
|
||||
("Icons/Arrow/Dir=Up", "Icons/Arrow", "nested path: everything before the last '/'"),
|
||||
("State=Default, Size=Small", "Component 1", "bare prop=value: not \"State\""),
|
||||
("", "Component 1", "empty name: the numbered default"),
|
||||
];
|
||||
for (name, expected, why) in rows {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20})));
|
||||
ex(&mut s, "node.set", json!({"id": f, "props": {"name": name}}));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
assert_eq!(node(&s, c).name, name, "{why}: the variant keeps its name");
|
||||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c]})));
|
||||
assert_eq!(node(&s, set).name, expected, "{why}");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
// Add variant on a lone component (a variant property on it) wraps it in a set named after it.
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 20, "name": "Button"})));
|
||||
let c = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
|
||||
let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "State", "type": "variant"})));
|
||||
assert_eq!(node(&s, set).name, "Button");
|
||||
assert_eq!(node(&s, c).name, "State=Default");
|
||||
healthy(&s);
|
||||
}
|
||||
@@ -747,29 +747,3 @@ fn alt_dropping_a_library_component_swaps_to_it() {
|
||||
assert!(doc.node(glyph).unwrap().overrides.is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_set_on_a_bound_instance_layer_detaches_it_as_an_override() {
|
||||
// Without unbinding first: the explicit colour detaches the variable on the instance layer only,
|
||||
// and stays through main edits until the override is reset.
|
||||
let mut s = Session::new();
|
||||
let (chip, _) = bound_chip(&mut s);
|
||||
let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap();
|
||||
let bg = find_path(s.doc().unwrap(), i, &["Bg"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [bg], "props": {"fill": "#ff00ff"}}));
|
||||
let n = s.doc().unwrap().node(bg).unwrap().clone();
|
||||
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF00FF");
|
||||
assert!(!n.bound.contains_key("fill"));
|
||||
assert!(n.overrides.contains("fills"));
|
||||
let main_bg = find_path(s.doc().unwrap(), chip, &["Bg"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [main_bg], "props": {"height": 28}}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "Chip", "value": "#445566"}));
|
||||
let doc = s.doc().unwrap();
|
||||
healthy(doc);
|
||||
assert_eq!(doc.node(bg).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF");
|
||||
assert!(doc.node(main_bg).unwrap().bound.contains_key("fill"), "the main keeps its binding");
|
||||
ex(&mut s, "component.resetOverride", json!({"id": bg, "group": "fills"}));
|
||||
let n = s.doc().unwrap().node(bg).unwrap();
|
||||
assert!(n.bound.contains_key("fill"));
|
||||
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "445566");
|
||||
}
|
||||
@@ -194,26 +194,3 @@ fn only_frames_on_the_page_or_in_a_section_become_sections() {
|
||||
assert!(err.contains("holds sections"), "{err}");
|
||||
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_errors_name_the_command_that_was_run() {
|
||||
// Grouping, framing, auto layout, masks and booleans share one wrapping step; a refusal names
|
||||
// the command that asked, not `object.group`.
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
for cmd in ["object.group", "object.frameSelection", "layout.add", "bool.union"] {
|
||||
let err = s.execute(cmd, &json!({"ids": [page]})).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("`{cmd}`")), "{cmd}: {err}");
|
||||
}
|
||||
let r = rect(&mut s, page, 0.0, 0.0);
|
||||
let err = s.execute("object.mask", &json!({"ids": [page, r]})).unwrap_err().to_string();
|
||||
assert!(err.contains("`object.mask`"), "{err}");
|
||||
let section = id(ex(&mut s, "node.create", json!({"type": "section", "x": 300, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "node.set", json!({"ids": [section], "props": {"fills": ["#FFFFFF"], "strokes": ["#000000"], "strokeWeight": 1}}));
|
||||
let err = s.execute("vector.outlineStroke", &json!({"ids": [section]})).unwrap_err().to_string();
|
||||
assert!(err.contains("`vector.outlineStroke`"), "{err}");
|
||||
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100})));
|
||||
let inner = rect(&mut s, f, 10.0, 10.0);
|
||||
let err = s.execute("object.wrapInSection", &json!({"ids": [inner, r]})).unwrap_err().to_string();
|
||||
assert!(err.contains("`object.wrapInSection`"), "{err}");
|
||||
}
|
||||
@@ -51,32 +51,6 @@ fn effect_set_edits_noise_texture_and_glass() {
|
||||
assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "depth": "deep"}})).is_err());
|
||||
}
|
||||
|
||||
/// A key an effect doesn't have is refused, naming what it takes: serde dropped it without a word,
|
||||
/// so a shadow given `offsetX`/`offsetY` (the names variable bindings use) kept the default offset.
|
||||
#[test]
|
||||
fn effects_refuse_keys_they_dont_have() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
let shadow = json!({"type": "dropShadow", "color": "#00000040", "offsetX": 0, "offsetY": 1, "radius": 3});
|
||||
let err = s.execute("node.set", &json!({"ids": [r], "props": {"effects": [shadow]}})).unwrap_err().to_string();
|
||||
assert!(err.contains("effects[0]") && err.contains("offsetX, offsetY") && err.contains("offset"), "{err}");
|
||||
assert!(s.doc().unwrap().node(r).unwrap().effects.is_empty(), "nothing stored");
|
||||
let err = s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radious": 8}})).unwrap_err().to_string();
|
||||
assert!(err.contains("radious"), "{err}");
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap();
|
||||
let err = s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "spred": 2}})).unwrap_err().to_string();
|
||||
assert!(err.contains("spred"), "{err}");
|
||||
// Every kind's own keys still go in, camelCase or snake_case, and so does what inspect reports.
|
||||
for ty in ["innerShadow", "layerBlur", "backgroundBlur", "noise", "texture", "glass"] {
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap();
|
||||
}
|
||||
let reported = crate::inspect::node_json(s.doc().unwrap(), r, 0)["effects"].clone();
|
||||
s.execute("node.set", &json!({"ids": [r], "props": {"effects": reported}})).unwrap();
|
||||
let camel = json!({"type": "dropShadow", "offset": [0, 1], "showBehind": true, "visible": true});
|
||||
let snake = json!({"type": "innerShadow", "offset": [0, 1], "show_behind": false});
|
||||
s.execute("node.set", &json!({"ids": [r], "props": {"effects": [camel, snake]}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_scales_grain_and_glass() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -62,7 +62,7 @@ impl Roots {
|
||||
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
|
||||
Ok(_) => {}
|
||||
// Why a path can't be resolved says what exists there: only told for paths inside a root.
|
||||
Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
|
||||
Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
|
||||
Err(_) => {}
|
||||
}
|
||||
if roots.is_empty() {
|
||||
@@ -79,10 +79,9 @@ impl Roots {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and
|
||||
/// its longest leading part that exists resolved, so links on the way are followed as the roots'
|
||||
/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it).
|
||||
fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool {
|
||||
/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one
|
||||
/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix).
|
||||
fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
|
||||
let Ok(abs) = std::path::absolute(path) else { return false };
|
||||
let mut lexical = PathBuf::new();
|
||||
for c in abs.components() {
|
||||
@@ -94,18 +93,7 @@ fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool {
|
||||
c => lexical.push(c),
|
||||
}
|
||||
}
|
||||
let mut rest: Vec<&OsStr> = Vec::new();
|
||||
let mut at = lexical.as_path();
|
||||
let resolved = loop {
|
||||
if let Ok(mut base) = std::fs::canonicalize(at) {
|
||||
base.extend(rest.iter().rev());
|
||||
break base;
|
||||
}
|
||||
let (Some(name), Some(parent)) = (at.file_name(), at.parent()) else { return false };
|
||||
rest.push(name);
|
||||
at = parent;
|
||||
};
|
||||
roots.iter().any(|r| resolved.starts_with(r))
|
||||
roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r)))
|
||||
}
|
||||
|
||||
/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed);
|
||||
|
||||
@@ -348,10 +348,6 @@ fn outside_paths_are_refused_alike_whatever_exists_there() {
|
||||
let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")];
|
||||
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) {
|
||||
outside.push(f.p("outside/dangling.txt"));
|
||||
// Reached through a link in the root, it is outside all the same.
|
||||
if symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) {
|
||||
outside.push(f.p("root/escape/dangling.txt"));
|
||||
}
|
||||
}
|
||||
for p in &outside {
|
||||
let e = refusal(p);
|
||||
@@ -363,19 +359,6 @@ fn outside_paths_are_refused_alike_whatever_exists_there() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_given_through_a_link_tells_why_paths_in_it_are_refused() {
|
||||
let f = Fixture::new("linked-root");
|
||||
if !symlink_dir(&f.0.join("root"), &f.0.join("via")) || !symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
|
||||
eprintln!("skipped: this system doesn't let tests make symbolic links");
|
||||
return;
|
||||
}
|
||||
// Like `/tmp/work` on macOS, where `/tmp` leads to `/private/tmp`.
|
||||
let r = Roots::new(&[], &[f.p("via")]).unwrap();
|
||||
let e = r.check(Path::new(&f.p("via/dangling.txt")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("leads nowhere"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_resolved_paths_count_as_the_apps_own_folder() {
|
||||
let f = Fixture::new("own");
|
||||
|
||||
+10
-70
@@ -1,15 +1,13 @@
|
||||
//! Fonts a document names that this machine doesn't have (#752): text layers, their styled
|
||||
//! ranges, text styles and the string variables bound to a font family. Text in such a family
|
||||
//! is drawn in a fallback font, so the user is told which ones are missing. Once such a font is
|
||||
//! there, [`refit`] lets auto-sizing text measure its box in it again.
|
||||
//! is drawn in a fallback font, so the user is told which ones are missing.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableValue, Variables};
|
||||
use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::{get, req_targets};
|
||||
use crate::{Result, Session, spec};
|
||||
|
||||
/// A family the document names that no installed or built-in font provides.
|
||||
@@ -81,78 +79,20 @@ fn string_values(vars: &Variables, id: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Auto-sizing text layers measure their boxes again: a box measured while its family was missing
|
||||
/// (in a fallback font) follows the real face once that's installed or loaded. Only layers under
|
||||
/// `under` (every layer: `None`) that use one of `families` (any family: `None`). Returns the
|
||||
/// layers whose size changed.
|
||||
pub fn refit(doc: &mut Document, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
|
||||
let want: Option<BTreeSet<String>> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect());
|
||||
let scope: Option<BTreeSet<NodeId>> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect());
|
||||
let ids: Vec<NodeId> = doc
|
||||
.nodes()
|
||||
.filter(|n| scope.as_ref().is_none_or(|s| s.contains(&n.id)))
|
||||
.filter(|n| {
|
||||
let NodeKind::Text(t) = &n.kind else { return false };
|
||||
let uses = |w: &BTreeSet<String>| {
|
||||
w.contains(&t.style.font_family.trim().to_lowercase())
|
||||
|| t.spans().iter().any(|(_, _, st)| w.contains(&st.font_family.trim().to_lowercase()))
|
||||
};
|
||||
want.as_ref().is_none_or(uses)
|
||||
&& figcraft_text::auto_size(n).is_some_and(|(w, h)| (w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
|
||||
})
|
||||
.map(|n| n.id)
|
||||
.collect();
|
||||
if !ids.is_empty() {
|
||||
let before = doc.clone();
|
||||
doc.mark_dirty(ids.iter().copied());
|
||||
crate::normalize(doc, &before);
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// [`refit`] in the open document, if there is one.
|
||||
pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
|
||||
match s.doc_mut() {
|
||||
Ok(doc) => refit(doc, under, families),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `text.refit`: the layers `ids` names (an `ids` that isn't a list of layers is refused), else the
|
||||
/// whole document.
|
||||
fn refit_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let under = match get(p, "ids") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(_) => Some(req_targets("text.refit", s, p)?),
|
||||
};
|
||||
s.doc()?;
|
||||
Ok(json!({"refitted": refit_open(s, under.as_deref(), None)}))
|
||||
}
|
||||
|
||||
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
|
||||
s.request_ui("dialog.missingFonts", json!({}));
|
||||
Ok(json!({"families": families}))
|
||||
}
|
||||
|
||||
/// The font commands: `text.missingFonts` and `text.refit`.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!(
|
||||
"text.missingFonts",
|
||||
"Missing fonts",
|
||||
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
|
||||
false,
|
||||
missing_fonts
|
||||
),
|
||||
spec!(
|
||||
"text.refit",
|
||||
"Fit text to fonts",
|
||||
"{ids? (these layers and the text in them; default: the whole document)} auto-sizing text measures its box again, e.g. a box measured in a fallback font while its family was missing → {refitted: [ids whose size changed]}",
|
||||
true,
|
||||
refit_text
|
||||
),
|
||||
]
|
||||
vec![spec!(
|
||||
"text.missingFonts",
|
||||
"Missing fonts",
|
||||
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
|
||||
false,
|
||||
missing_fonts
|
||||
)]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -124,92 +124,3 @@ fn needs_an_open_document() {
|
||||
let mut s = Session::empty();
|
||||
assert!(s.execute("text.missingFonts", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Make `id`'s box stale, as if it was measured in another font: `dw` wider than its content.
|
||||
fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 {
|
||||
let doc = s.doc_mut().unwrap();
|
||||
let n = doc.get_mut(id).unwrap();
|
||||
let want = figcraft_text::auto_size(n).unwrap().0;
|
||||
n.width = want + dw;
|
||||
doc.take_dirty();
|
||||
want
|
||||
}
|
||||
|
||||
/// The width of layer `id`.
|
||||
fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 {
|
||||
s.doc().unwrap().node(id).unwrap().width
|
||||
}
|
||||
|
||||
/// The ids a refit reports.
|
||||
fn refitted(r: &Value) -> Vec<u64> {
|
||||
r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
|
||||
}
|
||||
|
||||
/// Stale boxes fit their text again, by family, by layer or everywhere; fixed boxes keep theirs.
|
||||
#[test]
|
||||
fn refit_measures_stale_text_boxes_again() {
|
||||
let mut s = Session::new();
|
||||
let a = text(&mut s, "FigCraft Refit A");
|
||||
let b = text(&mut s, "FigCraft Refit B");
|
||||
let fixed = text(&mut s, "FigCraft Refit A");
|
||||
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(fixed).unwrap().kind {
|
||||
t.auto_resize = figcraft_doc::TextAutoResize::None;
|
||||
}
|
||||
s.execute("select.none", &json!({})).unwrap();
|
||||
let want_a = stale(&mut s, a, -30.0);
|
||||
let want_b = stale(&mut s, b, 25.0);
|
||||
s.doc_mut().unwrap().get_mut(fixed).unwrap().width = 7.0;
|
||||
s.doc_mut().unwrap().take_dirty();
|
||||
// Only the families asked for.
|
||||
let only_a = super::refit(s.doc_mut().unwrap(), None, Some(&["figcraft refit a".to_string()]));
|
||||
assert_eq!(only_a, vec![a]);
|
||||
assert_eq!(width(&s, a), want_a);
|
||||
assert_eq!(width(&s, b), want_b + 25.0);
|
||||
// Only the layers asked for, then everything; a fixed box keeps its size.
|
||||
stale(&mut s, a, 12.0);
|
||||
assert!(refitted(&s.execute("text.refit", &json!({"ids": [b]})).unwrap()) == vec![b.0]);
|
||||
assert_eq!((width(&s, a), width(&s, b)), (want_a + 12.0, want_b));
|
||||
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()) == vec![a.0]);
|
||||
assert_eq!(width(&s, a), want_a);
|
||||
assert_eq!(width(&s, fixed), 7.0);
|
||||
// Nothing stale: nothing changes.
|
||||
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty());
|
||||
assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err());
|
||||
// An `ids` that isn't a list is refused, not taken as the whole document.
|
||||
stale(&mut s, a, 9.0);
|
||||
assert!(s.execute("text.refit", &json!({"ids": a.0.to_string()})).is_err());
|
||||
assert_eq!(width(&s, a), want_a + 9.0);
|
||||
assert!(Session::empty().execute("text.refit", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// Text inside an instance is refitted too.
|
||||
#[test]
|
||||
fn refit_reaches_text_in_instances() {
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "FigCraft Refit C");
|
||||
let comp = id_of(&s.execute("component.create", &json!({"ids": [t]})).unwrap()["id"]).unwrap();
|
||||
let inst = id_of(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 0})).unwrap()["id"]).unwrap();
|
||||
let copy = s.doc().unwrap().node(inst).unwrap().children[0];
|
||||
let want = stale(&mut s, copy, -20.0);
|
||||
let r = s.execute("text.refit", &json!({"ids": [inst]})).unwrap();
|
||||
assert_eq!(refitted(&r), vec![copy.0]);
|
||||
assert_eq!(width(&s, copy), want);
|
||||
}
|
||||
|
||||
/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added.
|
||||
#[test]
|
||||
fn fonts_load_from_files_and_rescans_report_new_families() {
|
||||
let mut s = Session::new();
|
||||
assert!(s.execute("text.loadFont", &json!({})).is_err());
|
||||
assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file");
|
||||
assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err());
|
||||
assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err());
|
||||
if let Some(f) = figcraft_text::CRAFT_FONTS.first() {
|
||||
use base64::Engine as _;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(f.bytes);
|
||||
let r = s.execute("text.loadFont", &json!({"data": data})).unwrap();
|
||||
assert_eq!(r["families"].as_array().map(Vec::len), Some(1));
|
||||
}
|
||||
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
|
||||
assert!(r["added"].is_array());
|
||||
}
|
||||
@@ -165,10 +165,6 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
o.insert("operandOperation".into(), json!(op));
|
||||
}
|
||||
}
|
||||
// Layer data written with `node.set {props: {pluginData}}` (pages too).
|
||||
if !n.plugin_data.is_empty() {
|
||||
o.insert("pluginData".into(), json!(n.plugin_data));
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
o.insert("characters".into(), json!(t.characters));
|
||||
|
||||
@@ -214,26 +214,6 @@ fn css_for_common_layers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual stroke sides are borders on those sides only: a header with a divider under it isn't
|
||||
/// boxed in, and a side bound to a variable names it.
|
||||
#[test]
|
||||
fn css_borders_follow_individual_stroke_sides() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
set(&mut s, &[r], json!({"strokes": ["#000000"], "strokeWeight": 1, "strokeSides": [0, 0, 2, 0]}));
|
||||
let c = css(&mut s, r);
|
||||
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
|
||||
assert!(!c.contains("border:") && !c.contains("border-top") && !c.contains("border-right") && !c.contains("border-left"), "{c}");
|
||||
|
||||
let col = ex(&mut s, "variable.createCollection", json!({"name": "Borders"}));
|
||||
let col = col["id"].as_str().unwrap().to_string();
|
||||
ex(&mut s, "variable.create", json!({"collection": col, "name": "border/top", "type": "number", "value": 3}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"}));
|
||||
let c = css(&mut s, r);
|
||||
assert!(c.contains("border-top: var(--border-top, 3px) solid #000000;"), "{c}");
|
||||
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_code_for_a_simple_layer() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
//! Variable modes on instances: an instance is on the modes its main component sets, under the
|
||||
//! ones set on the instance itself, so switching variants or changing the main's mode reaches it.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::NodeId;
|
||||
|
||||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
|
||||
}
|
||||
|
||||
fn id(v: Value) -> NodeId {
|
||||
id_of(&v["id"]).unwrap()
|
||||
}
|
||||
|
||||
fn fill(s: &Session, n: NodeId) -> String {
|
||||
s.doc().unwrap().node(n).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn set_mode(s: &mut Session, n: NodeId, mode: Value) {
|
||||
run(s, "variable.setMode", json!({"ids": [n], "collection": "Tone", "mode": mode}));
|
||||
}
|
||||
|
||||
/// A "Tone" collection whose variable `c` is red in mode A and blue in mode B.
|
||||
fn tone(s: &mut Session) {
|
||||
run(s, "variable.createCollection", json!({"name": "Tone", "modes": ["A", "B"]}));
|
||||
run(s, "variable.create", json!({"collection": "Tone", "name": "c", "type": "color", "values": {"A": "#FF0000", "B": "#0000FF"}}));
|
||||
}
|
||||
|
||||
/// A component filled from `c`, on `mode` when one is given.
|
||||
fn swatch(s: &mut Session, name: &str, mode: Option<&str>) -> NodeId {
|
||||
let f = id(run(s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 40, "name": name})));
|
||||
run(s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "c"}));
|
||||
if let Some(m) = mode {
|
||||
set_mode(s, f, json!(m));
|
||||
}
|
||||
id(run(s, "component.create", json!({"ids": [f]})))
|
||||
}
|
||||
|
||||
/// A set "Kind" whose variants A and B each set their own Tone mode.
|
||||
fn kinds(s: &mut Session) -> (NodeId, NodeId) {
|
||||
tone(s);
|
||||
let a = swatch(s, "Kind=A", Some("A"));
|
||||
let b = swatch(s, "Kind=B", Some("B"));
|
||||
let set = id(run(s, "component.combineVariants", json!({"ids": [a, b]})));
|
||||
(set, b)
|
||||
}
|
||||
|
||||
fn instance(s: &mut Session, component: NodeId) -> NodeId {
|
||||
id(run(s, "instance.create", json!({"component": component, "x": 200, "y": 0})))
|
||||
}
|
||||
|
||||
fn switch(s: &mut Session, i: NodeId, kind: &str) {
|
||||
run(s, "instance.setProperties", json!({"ids": [i], "properties": {"Kind": kind}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_variants_takes_the_new_variants_mode() {
|
||||
let mut s = Session::new();
|
||||
let (set, _) = kinds(&mut s);
|
||||
let i = instance(&mut s, set);
|
||||
assert_eq!(fill(&s, i), "FF0000");
|
||||
switch(&mut s, i, "B");
|
||||
assert_eq!(fill(&s, i), "0000FF", "variant B sets mode B");
|
||||
switch(&mut s, i, "A");
|
||||
assert_eq!(fill(&s, i), "FF0000");
|
||||
assert!(s.doc().unwrap().node(i).unwrap().var_modes.is_empty(), "the instance sets no mode of its own");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_follow_a_mode_their_main_sets_later() {
|
||||
let mut s = Session::new();
|
||||
tone(&mut s);
|
||||
let main = swatch(&mut s, "Chip", None);
|
||||
let i = instance(&mut s, main);
|
||||
assert_eq!(fill(&s, i), "FF0000", "no mode set: the collection's default");
|
||||
set_mode(&mut s, main, json!("B"));
|
||||
assert_eq!(fill(&s, i), "0000FF");
|
||||
set_mode(&mut s, main, Value::Null);
|
||||
assert_eq!(fill(&s, i), "FF0000", "the main back on Auto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mode_set_on_the_instance_wins_and_stays_through_variant_switches() {
|
||||
let mut s = Session::new();
|
||||
let (set, _) = kinds(&mut s);
|
||||
let i = instance(&mut s, set);
|
||||
set_mode(&mut s, i, json!("B"));
|
||||
assert_eq!(fill(&s, i), "0000FF");
|
||||
for kind in ["B", "A"] {
|
||||
switch(&mut s, i, kind);
|
||||
assert_eq!(fill(&s, i), "0000FF", "own mode B on variant {kind}");
|
||||
}
|
||||
set_mode(&mut s, i, Value::Null);
|
||||
assert_eq!(fill(&s, i), "FF0000", "back on Auto: variant A's mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_main_mode_is_closer_than_the_frame_around_the_instance() {
|
||||
let mut s = Session::new();
|
||||
let (_, b) = kinds(&mut s);
|
||||
let frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100})));
|
||||
set_mode(&mut s, frame, json!("A"));
|
||||
let i = id(run(&mut s, "instance.create", json!({"component": b, "x": 10, "y": 10, "parent": frame})));
|
||||
assert_eq!(fill(&s, i), "0000FF", "the main's mode B, not the frame's A");
|
||||
// Nested: an outer component on mode A holding an instance of B.
|
||||
let outer_frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 100, "height": 100, "name": "Outer"})));
|
||||
run(&mut s, "instance.create", json!({"component": b, "x": 0, "y": 0, "parent": outer_frame}));
|
||||
set_mode(&mut s, outer_frame, json!("A"));
|
||||
let outer = id(run(&mut s, "component.create", json!({"ids": [outer_frame]})));
|
||||
let copy = instance(&mut s, outer);
|
||||
let nested = s.doc().unwrap().node(copy).unwrap().children[0];
|
||||
assert_eq!(fill(&s, nested), "0000FF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detaching_keeps_the_mode_it_took_from_its_main() {
|
||||
let mut s = Session::new();
|
||||
let (set, b) = kinds(&mut s);
|
||||
let i = instance(&mut s, set);
|
||||
switch(&mut s, i, "B");
|
||||
run(&mut s, "component.detach", json!({"ids": [i]}));
|
||||
set_mode(&mut s, b, json!("A"));
|
||||
assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B");
|
||||
assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1);
|
||||
}
|
||||
@@ -49,7 +49,6 @@ pub mod proto_run;
|
||||
pub mod rename;
|
||||
pub mod rotation_origin;
|
||||
pub mod selection_colors;
|
||||
pub mod selection_reorder;
|
||||
pub mod selection_spacing;
|
||||
pub mod shape_handles;
|
||||
pub mod shapebuilder;
|
||||
@@ -604,23 +603,6 @@ impl DocState {
|
||||
}
|
||||
}
|
||||
|
||||
/// The smallest and largest interface size, relative to the system's display scale (1.0 is the
|
||||
/// system's own size).
|
||||
pub const UI_SCALE_MIN: f64 = 0.75;
|
||||
pub const UI_SCALE_MAX: f64 = 3.0;
|
||||
|
||||
/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None`.
|
||||
pub fn clamp_ui_scale(scale: f64) -> Option<f64> {
|
||||
scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX))
|
||||
}
|
||||
|
||||
/// Reads the saved interface size. Anything but a number reads as 1 (the system's size) and a
|
||||
/// number out of range is clamped, so a settings file edited by hand never costs the other
|
||||
/// preferences.
|
||||
fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
|
||||
Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale).unwrap_or(1.0))
|
||||
}
|
||||
|
||||
/// App-wide preferences that commands read (nudge amounts, behaviours).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
@@ -648,16 +630,9 @@ pub struct Prefs {
|
||||
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
|
||||
/// too). Prototype transitions still play.
|
||||
pub reduce_motion: bool,
|
||||
/// How large the whole interface is drawn, relative to the system's display scale: 1.0 is the
|
||||
/// system's own size, 1.25 a quarter larger (kept between [`UI_SCALE_MIN`] and
|
||||
/// [`UI_SCALE_MAX`]). The canvas zoom is separate.
|
||||
#[serde(deserialize_with = "read_ui_scale")]
|
||||
pub ui_scale: f64,
|
||||
/// Files that have a path are saved by themselves a few seconds after they change (the app's
|
||||
/// timer; off: only File ▸ Save writes them).
|
||||
pub auto_save: bool,
|
||||
/// The desktop app starts on the Home screen instead of in a new file.
|
||||
pub start_on_home: bool,
|
||||
/// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed
|
||||
/// builds, off for development builds.
|
||||
pub invite_links: Option<bool>,
|
||||
@@ -723,9 +698,7 @@ impl Default for Prefs {
|
||||
language: "system".into(),
|
||||
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
|
||||
reduce_motion: false,
|
||||
ui_scale: 1.0,
|
||||
auto_save: false,
|
||||
start_on_home: false,
|
||||
invite_links: None,
|
||||
plugins: Vec::new(),
|
||||
plugin_settings: BTreeMap::new(),
|
||||
@@ -762,8 +735,6 @@ pub struct Clipboard {
|
||||
/// The page-space bounds of the copied selection.
|
||||
pub bounds: Option<figcraft_geom::Rect>,
|
||||
pub source_doc: u64,
|
||||
/// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused).
|
||||
pub copied_components: std::collections::BTreeSet<NodeId>,
|
||||
/// The document copied from (pastes into other files follow its mains) and its file path.
|
||||
pub source: Option<Arc<Document>>,
|
||||
pub source_path: Option<String>,
|
||||
@@ -1062,9 +1033,7 @@ impl Session {
|
||||
};
|
||||
let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) else { return };
|
||||
let changed = !Arc::ptr_eq(&ds.doc, &bdoc);
|
||||
// Saving may adopt the chosen filename as the document title. The encoded snapshot is
|
||||
// already final; normalising it again would detach it from the saved baseline.
|
||||
if changed && !matches!(spec.id, "file.save" | "file.saveAs") {
|
||||
if changed {
|
||||
normalize(ds.doc_mut(), &bdoc);
|
||||
}
|
||||
let edit = changed && ds.interaction.is_none();
|
||||
@@ -1129,11 +1098,8 @@ impl Session {
|
||||
let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) };
|
||||
let mut doc = (*it.doc).clone();
|
||||
let mut sel = it.selection.clone();
|
||||
// What the gesture changes on the selected layers is an explicit edit.
|
||||
let before = cmd::styles::bound_before(&doc, &it.selection);
|
||||
let res = f(&mut doc, &mut sel);
|
||||
if res.is_ok() {
|
||||
cmd::styles::detach_edited(&mut doc, &before);
|
||||
normalize(&mut doc, &it.doc);
|
||||
// The same preview again (a handle held still) keeps the document: a new one would
|
||||
// re-render the canvas for nothing.
|
||||
@@ -1200,13 +1166,6 @@ impl Session {
|
||||
}
|
||||
ds.clean_selection();
|
||||
ds.revision += 1;
|
||||
let (uid, doc) = (ds.uid, ds.doc.clone());
|
||||
if self.clipboard.source_doc == uid {
|
||||
self.clipboard.copied_components.retain(|id| {
|
||||
doc.node(*id)
|
||||
.is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::ComponentSet { .. }))
|
||||
});
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
@@ -1501,35 +1460,21 @@ fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool {
|
||||
}
|
||||
|
||||
/// The layers whose variable bindings an edit may have changed: every bound layer when a variable
|
||||
/// changed, else the touched layers and everything below those that moved, are new, chose
|
||||
/// another variable mode or became an instance of another main (modes are inherited, an
|
||||
/// instance's from its main too), and the instances of a main that chose another mode.
|
||||
/// changed, else the touched layers and everything below those that moved, are new or chose
|
||||
/// another variable mode (modes are inherited).
|
||||
fn binding_scope(doc: &Document, before: &Document, touched: &BTreeSet<NodeId>) -> Option<BTreeSet<NodeId>> {
|
||||
if doc.variables != before.variables {
|
||||
return None;
|
||||
}
|
||||
let main = |n: &Node| match n.kind {
|
||||
figcraft_doc::NodeKind::Instance { component, .. } => Some(component),
|
||||
_ => None,
|
||||
};
|
||||
let mut out = BTreeSet::new();
|
||||
for &id in touched {
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
let Some(b) = before.node(id) else {
|
||||
out.extend(doc.subtree(id));
|
||||
continue;
|
||||
};
|
||||
let modes = b.var_modes != n.var_modes;
|
||||
if modes || b.parent != n.parent || main(b) != main(n) {
|
||||
let inherited = before.node(id).is_none_or(|b| b.var_modes != n.var_modes || b.parent != n.parent);
|
||||
if inherited {
|
||||
out.extend(doc.subtree(id));
|
||||
} else {
|
||||
out.insert(id);
|
||||
}
|
||||
if modes && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }) {
|
||||
for i in components::instances_of(doc, id) {
|
||||
out.extend(doc.subtree(i));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -1707,8 +1652,6 @@ mod import_journal_tests;
|
||||
#[cfg(test)]
|
||||
mod inspect_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod instance_modes_tests;
|
||||
#[cfg(test)]
|
||||
mod instance_resizing_tests;
|
||||
#[cfg(test)]
|
||||
mod interaction_rollback_tests;
|
||||
@@ -1777,8 +1720,6 @@ mod pixel_preview_tests;
|
||||
#[cfg(test)]
|
||||
mod playground_tests;
|
||||
#[cfg(test)]
|
||||
mod plugin_data_tests;
|
||||
#[cfg(test)]
|
||||
mod plugin_directory_tests;
|
||||
#[cfg(test)]
|
||||
mod plugin_panels_tests;
|
||||
@@ -1883,8 +1824,6 @@ mod variables_import_shapes_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_io_tests;
|
||||
#[cfg(test)]
|
||||
mod variants_discovery_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_multi_behaviour_tests;
|
||||
@@ -1899,7 +1838,5 @@ mod view_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod widgets_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod selection_reorder_tests;
|
||||
#[cfg(test)]
|
||||
mod selection_spacing_tests;
|
||||
@@ -81,15 +81,11 @@ pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D97
|
||||
|
||||
/// `#RRGGBB` → (r, g, b); `None` for anything else.
|
||||
pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> {
|
||||
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
|
||||
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.is_ascii())?;
|
||||
let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok());
|
||||
Some((byte(0)?, byte(2)?, byte(4)?))
|
||||
}
|
||||
|
||||
/// Why a join failed when a message from the host is over this app's size limit (`{mb}`: the
|
||||
/// limit in MB). The UI translates it.
|
||||
pub const TOO_LARGE_TO_JOIN: &str = "This file is too large to join here (up to {mb} MB)";
|
||||
|
||||
/// The connection to a live session.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "state")]
|
||||
|
||||
@@ -265,7 +265,7 @@ fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() {
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let offline_path = saved_file(&mut s);
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().name, offline_path.file_stem().unwrap().to_string_lossy());
|
||||
assert_eq!(s.doc().unwrap().name, "Untitled");
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().versions.len(), 1);
|
||||
assert_eq!(s.live_of(uid).doc_uid, Some(uid));
|
||||
@@ -273,7 +273,7 @@ fn revert_offline_file_works_while_another_file_is_live_and_after_leaving() {
|
||||
s.execute("doc.switch", &json!({"index": 0})).unwrap();
|
||||
s.execute("collab.leave", &json!({})).unwrap();
|
||||
s.execute("file.revert", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().name, path.file_stem().unwrap().to_string_lossy());
|
||||
assert_eq!(s.doc().unwrap().name, "Untitled");
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(s.ds().unwrap().versions.len(), 1);
|
||||
assert_eq!(s.live().doc_uid, None);
|
||||
|
||||
@@ -507,11 +507,3 @@ fn hosting_commands_check_what_they_are_given() {
|
||||
assert!(e.contains("join a file"), "{e}");
|
||||
assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_color_rejects_signs_and_non_hex() {
|
||||
use crate::live::parse_color;
|
||||
assert_eq!(parse_color("#+1+2+3"), None);
|
||||
assert_eq!(parse_color("#-1-2-3"), None);
|
||||
assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255)));
|
||||
}
|
||||
@@ -120,12 +120,7 @@ pub fn same_server(a: &str, b: &str) -> bool {
|
||||
let s = plain_server(s).to_ascii_lowercase();
|
||||
let s = s.trim_end_matches('/');
|
||||
let port = if s.starts_with("wss://") { ":443" } else { ":80" };
|
||||
let start = s.find("://").map_or(0, |i| i + 3);
|
||||
let end = s[start..].find('/').map_or(s.len(), |i| start + i);
|
||||
match s[start..end].strip_suffix(port) {
|
||||
Some(host) => format!("{}{}{}", &s[..start], host, &s[end..]),
|
||||
None => s.to_string(),
|
||||
}
|
||||
s.strip_suffix(port).unwrap_or(s).to_string()
|
||||
};
|
||||
norm(a) == norm(b)
|
||||
}
|
||||
|
||||
@@ -583,11 +583,3 @@ fn debug_output_of_a_record_leaves_the_secrets_out() {
|
||||
no_secrets("debug", &text);
|
||||
assert!(text.contains("share.example") && text.contains(".."), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_server_ignores_the_default_port_before_a_path() {
|
||||
use crate::online::same_server;
|
||||
assert!(same_server("wss://share.example:443/figcraft/", "wss://share.example/figcraft/"));
|
||||
assert!(same_server("ws://host:80/x", "ws://host/x"));
|
||||
assert!(!same_server("wss://share.example:8443/figcraft/", "wss://share.example/figcraft/"));
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
//! Layer data (`pluginData`) reads back through `node.get` and `document.inspect` (#879): what
|
||||
//! `node.set` writes on a layer or a page comes back as `{key: value}`, a removed key goes, and a
|
||||
//! layer without any reports none.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, idv};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn node_get_reports_the_layer_data_node_set_wrote() {
|
||||
let mut s = Session::new();
|
||||
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
|
||||
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
|
||||
let page = s.ds().unwrap().page;
|
||||
assert!(ex(&mut s, "node.get", json!({"id": f})).get("pluginData").is_none(), "no data, no field");
|
||||
ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"com.example.sync": "{\"v\":1}", "n": 2}}}));
|
||||
ex(&mut s, "node.set", json!({"ids": [page], "props": {"pluginData": {"com.example.sync": "page"}}}));
|
||||
let got = ex(&mut s, "node.get", json!({"id": f}))["pluginData"].clone();
|
||||
assert_eq!(got, json!({"com.example.sync": "{\"v\":1}", "n": "2"}), "values read back as the strings stored");
|
||||
assert_eq!(ex(&mut s, "node.get", json!({"id": page}))["pluginData"], json!({"com.example.sync": "page"}), "pages keep data too");
|
||||
assert!(ex(&mut s, "node.get", json!({"id": r})).get("pluginData").is_none(), "other layers report none");
|
||||
// The document tree reports it as well (the page and its children).
|
||||
let tree = crate::inspect::document(&s, 2, false);
|
||||
assert_eq!(tree["pages"][0]["pluginData"], json!({"com.example.sync": "page"}));
|
||||
// A null removes a key; undo brings it back.
|
||||
ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"n": null}}}));
|
||||
assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"], json!({"com.example.sync": "{\"v\":1}"}));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"]["n"], "2");
|
||||
}
|
||||
@@ -89,46 +89,6 @@ fn assert_save_state_unchanged(s: &Session, before: &DocState, recent: &[String]
|
||||
assert_eq!(s.prefs.recent_files, recent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_native_save_uses_chosen_filename_for_an_untitled_document() {
|
||||
let mut files = SaveFiles::new();
|
||||
let path = files.path("Redis.figcraft");
|
||||
let copy = files.path("Redis copy.figcraft");
|
||||
let manual_copy = files.path("Other.figcraft");
|
||||
let mut s = Session::new();
|
||||
s.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.ds().unwrap().doc.name, "Redis");
|
||||
assert!(!s.ds().unwrap().is_dirty());
|
||||
assert_eq!(figcraft_format::load_bytes(&std::fs::read(&path).unwrap()).unwrap().0.name, "Redis");
|
||||
|
||||
let mut reopened = Session::empty();
|
||||
reopened.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(reopened.ds().unwrap().doc.name, "Redis");
|
||||
|
||||
s.execute("file.saveAs", &json!({"path": copy.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.ds().unwrap().doc.name, "Redis copy");
|
||||
s.execute("file.rename", &json!({"name": "Chosen title"})).unwrap();
|
||||
s.execute("file.saveAs", &json!({"path": manual_copy.to_string_lossy()})).unwrap();
|
||||
assert_eq!(s.ds().unwrap().doc.name, "Chosen title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_an_older_file_uses_its_filename_only_for_the_default_title() {
|
||||
let mut files = SaveFiles::new();
|
||||
let old_path = files.path("Redis.figcraft");
|
||||
let named_path = files.path("Other.figcraft");
|
||||
let mut original = Session::new();
|
||||
std::fs::write(&old_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap();
|
||||
original.execute("file.rename", &json!({"name": "Chosen title"})).unwrap();
|
||||
std::fs::write(&named_path, crate::cmd::file::file_bytes(original.ds().unwrap()).unwrap()).unwrap();
|
||||
|
||||
let mut reopened = Session::empty();
|
||||
reopened.execute("file.open", &json!({"path": old_path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(reopened.ds().unwrap().doc.name, "Redis");
|
||||
reopened.execute("file.open", &json!({"path": named_path.to_string_lossy()})).unwrap();
|
||||
assert_eq!(reopened.ds().unwrap().doc.name, "Chosen title");
|
||||
}
|
||||
|
||||
fn read_fig(path: &std::path::Path, result: &Value) -> figcraft_doc::Document {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
assert_eq!(result["format"], "fig");
|
||||
|
||||
@@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
|
||||
use crate::cmd::lists::{BoundList, list_binding};
|
||||
use crate::params::*;
|
||||
use crate::{CommandSpec, Result, Session, bad, spec};
|
||||
|
||||
@@ -153,6 +153,37 @@ fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>
|
||||
Ok(ts)
|
||||
}
|
||||
|
||||
/// Detach variables only where the explicit edit changed their value. In particular, an
|
||||
/// opacity edit keeps the color binding and a range edit keeps other characters' node bindings.
|
||||
fn unbind_changed(n: &mut figcraft_doc::Node, t: &PaintTarget, old: &Paint, new: &Paint) {
|
||||
// Range fills override the node's paints; the node binding still belongs to other chars.
|
||||
if t.range.is_some() {
|
||||
return;
|
||||
}
|
||||
let list = if t.fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
n.bound.retain(|key, _| {
|
||||
let Some((l, index, field)) = list_binding(key) else { return true };
|
||||
if l != list || index != t.index {
|
||||
return true;
|
||||
}
|
||||
match field {
|
||||
"color" => match (&old.kind, &new.kind) {
|
||||
(PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) => a == b,
|
||||
_ => false,
|
||||
},
|
||||
"opacity" => old.opacity == new.opacity,
|
||||
_ => {
|
||||
let stop = field.strip_prefix("stops/").and_then(|s| s.strip_suffix("/color")).and_then(|s| s.parse::<usize>().ok());
|
||||
match (stop, &old.kind, &new.kind) {
|
||||
(Some(i), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(i).map(|s| s.color) == b.stops.get(i).map(|s| s.color),
|
||||
(Some(_), _, _) => false,
|
||||
_ => old == new,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "selection.setPaint";
|
||||
let targets = checked_targets(s, p, CMD)?;
|
||||
@@ -166,22 +197,18 @@ fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let old = slot.clone();
|
||||
*slot = paint.clone();
|
||||
let n = s.doc_mut()?.get_mut(target.id)?;
|
||||
// Variables detach only where the edit changed their value; range fills override the node's
|
||||
// paints, whose bindings still belong to the other characters.
|
||||
unbind_changed(n, target, &old, &paint);
|
||||
if let Some((start, end)) = target.range {
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.restyle(start, end, |style| style.fills = Some(paints.clone()));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if target.fills {
|
||||
} else if target.fills {
|
||||
n.fills = paints;
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
n.strokes = paints;
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
rebind_edited_paint(n, target.fills, target.index, &old);
|
||||
}
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
//! Geometry for reordering a smart selection. Only regular, non-overlapping rows, columns
|
||||
//! and complete top-left-aligned grids have unambiguous insertion slots.
|
||||
//! Marking several items and irregular grids are out of scope.
|
||||
use figcraft_doc::{Document, NodeId};
|
||||
use figcraft_geom::{Point, Rect};
|
||||
|
||||
use crate::selection_spacing::selection_spacing;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ReorderLayout {
|
||||
/// Visual order, independent of selection order and layer stacking.
|
||||
pub slots: Vec<(NodeId, Rect)>,
|
||||
columns: usize,
|
||||
rows: usize,
|
||||
gap_x: f64,
|
||||
gap_y: f64,
|
||||
}
|
||||
|
||||
impl ReorderLayout {
|
||||
pub fn new(doc: &Document, ids: &[NodeId]) -> Option<Self> {
|
||||
let layout = selection_spacing(doc, ids);
|
||||
let h = layout.horizontal.as_ref();
|
||||
let v = layout.vertical.as_ref();
|
||||
let gap = |axis: Option<&crate::selection_spacing::Axis>| match axis {
|
||||
Some(a) => a.value.filter(|v| *v >= 0.0),
|
||||
None => Some(0.0),
|
||||
};
|
||||
let (gap_x, gap_y) = (gap(h)?, gap(v)?);
|
||||
let columns = h.map_or(1, |a| a.tracks.len());
|
||||
let rows = v.map_or(1, |a| a.tracks.len());
|
||||
if ids.len() < 2 || columns.checked_mul(rows)? != ids.len() {
|
||||
return None;
|
||||
}
|
||||
let mut cells = std::collections::BTreeMap::new();
|
||||
for id in ids {
|
||||
let col = match h {
|
||||
Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?,
|
||||
None => 0,
|
||||
};
|
||||
let row = match v {
|
||||
Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?,
|
||||
None => 0,
|
||||
};
|
||||
let b = doc.world_bounds(*id)?;
|
||||
if columns > 1 && rows > 1 {
|
||||
let x = h?.tracks.get(col)?.bounds.x0;
|
||||
let y = v?.tracks.get(row)?.bounds.y0;
|
||||
if (b.x0 - x).abs() > 0.01 || (b.y0 - y).abs() > 0.01 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if cells.insert((row, col), (*id, b)).is_some() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(Self { slots: cells.into_values().collect(), columns, rows, gap_x, gap_y })
|
||||
}
|
||||
|
||||
/// Insertion shifts intervening items; swapping exchanges just two entries. Sizes and
|
||||
/// original gaps are retained, so unequal-sized layers reflow instead of overlapping.
|
||||
pub fn positions(&self, source: NodeId, target: NodeId, swap: bool) -> Option<Vec<(NodeId, Point)>> {
|
||||
let from = self.slots.iter().position(|(id, _)| *id == source)?;
|
||||
let to = self.slots.iter().position(|(id, _)| *id == target)?;
|
||||
if from == to {
|
||||
return Some(self.slots.iter().map(|(id, b)| (*id, Point::new(b.x0, b.y0))).collect());
|
||||
}
|
||||
let mut order = self.slots.clone();
|
||||
if swap {
|
||||
order.swap(from, to);
|
||||
} else {
|
||||
let item = order.remove(from);
|
||||
order.insert(to, item);
|
||||
}
|
||||
let mut widths = vec![0.0_f64; self.columns];
|
||||
let mut heights = vec![0.0_f64; self.rows];
|
||||
for (i, (_, b)) in order.iter().enumerate() {
|
||||
let w = widths.get_mut(i % self.columns)?;
|
||||
*w = w.max(b.width());
|
||||
let h = heights.get_mut(i / self.columns)?;
|
||||
*h = h.max(b.height());
|
||||
}
|
||||
let x0 = self.slots.iter().map(|(_, b)| b.x0).fold(f64::INFINITY, f64::min);
|
||||
let y0 = self.slots.iter().map(|(_, b)| b.y0).fold(f64::INFINITY, f64::min);
|
||||
let starts = |sizes: &[f64], mut at: f64, gap: f64| {
|
||||
sizes
|
||||
.iter()
|
||||
.map(|size| {
|
||||
let start = at;
|
||||
at += size + gap;
|
||||
start
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let xs = starts(&widths, x0, self.gap_x);
|
||||
let ys = starts(&heights, y0, self.gap_y);
|
||||
order
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, b))| {
|
||||
let x = if self.columns == 1 { b.x0 } else { *xs.get(i % self.columns)? };
|
||||
let y = if self.rows == 1 { b.y0 } else { *ys.get(i / self.columns)? };
|
||||
Some((*id, Point::new(x, y)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
use crate::{Session, selection_reorder::ReorderLayout};
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
|
||||
NodeId(s.execute("shape.rectangle", &json!({"x":x,"y":y,"width":w,"height":h,"parent":parent})).unwrap()["id"].as_u64().unwrap())
|
||||
}
|
||||
fn xy(s: &Session, id: NodeId) -> (f64, f64) {
|
||||
let b = s.doc().unwrap().world_bounds(id).unwrap();
|
||||
(b.x0, b.y0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insertion_reflows_unequal_sizes_with_original_gaps_and_hierarchy() {
|
||||
for vertical in [false, true] {
|
||||
let mut s = Session::new();
|
||||
let mut ids = vec![];
|
||||
for (at, size) in [(0., 40.), (50., 60.), (120., 20.)] {
|
||||
ids.push(rect(
|
||||
&mut s,
|
||||
if vertical { 0. } else { at },
|
||||
if vertical { at } else { 0. },
|
||||
if vertical { 30. } else { size },
|
||||
if vertical { size } else { 30. },
|
||||
None,
|
||||
));
|
||||
}
|
||||
let before = s.doc().unwrap().clone();
|
||||
// Explicit selection order must not determine visual order.
|
||||
s.execute("arrange.reorder", &json!({"ids":[ids[2],ids[0],ids[1]],"source":ids[0],"target":ids[2]})).unwrap();
|
||||
for (id, at) in [(ids[1], 0.), (ids[2], 70.), (ids[0], 100.)] {
|
||||
assert_eq!(xy(&s, id), if vertical { (0., at) } else { (at, 0.) });
|
||||
let old = before.node(id).unwrap();
|
||||
let new = s.doc().unwrap().node(id).unwrap();
|
||||
assert_eq!((new.width, new.height, new.parent), (old.width, old.height, old.parent));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().node(s.ds().unwrap().page).unwrap().children, before.node(s.ds().unwrap().page).unwrap().children);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
s.redo().unwrap();
|
||||
assert_eq!(xy(&s, ids[0]), if vertical { (0., 100.) } else { (100., 0.) });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grids_insert_across_rows_and_swap_only_the_two_entries() {
|
||||
let mut s = Session::new();
|
||||
let mut ids = vec![];
|
||||
for row in 0..2 {
|
||||
for col in 0..3 {
|
||||
ids.push(rect(&mut s, col as f64 * 70., row as f64 * 60., 50., 40., None));
|
||||
}
|
||||
}
|
||||
let before = s.doc().unwrap().clone();
|
||||
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4]})).unwrap();
|
||||
assert_eq!(xy(&s, ids[0]), (70., 60.));
|
||||
assert_eq!(xy(&s, ids[3]), (140., 0.));
|
||||
assert_eq!(xy(&s, ids[5]), (140., 60.));
|
||||
s.undo().unwrap();
|
||||
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4],"swap":true})).unwrap();
|
||||
assert_eq!(xy(&s, ids[0]), (70., 60.));
|
||||
assert_eq!(xy(&s, ids[4]), (0., 0.));
|
||||
for id in [ids[1], ids[2], ids[3], ids[5]] {
|
||||
assert_eq!(s.doc().unwrap().node(id), before.node(id));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_reflow_keeps_gaps_with_different_sizes() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0., 0., 40., 30., None);
|
||||
let b = rect(&mut s, 50., 0., 60., 30., None);
|
||||
let c = rect(&mut s, 0., 50., 40., 50., None);
|
||||
let d = rect(&mut s, 50., 50., 60., 50., None);
|
||||
s.execute("arrange.reorder", &json!({"ids":[a,b,c,d],"source":a,"target":d})).unwrap();
|
||||
assert_eq!(xy(&s, b), (0., 0.));
|
||||
assert_eq!(xy(&s, c), (70., 0.));
|
||||
assert_eq!(xy(&s, d), (0., 70.));
|
||||
assert_eq!(xy(&s, a), (70., 70.));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_are_absolute_and_returning_to_start_is_a_noop() {
|
||||
let mut s = Session::new();
|
||||
let ids: Vec<_> = (0..3).map(|i| rect(&mut s, i as f64 * 70., 0., 50., 40., None)).collect();
|
||||
s.execute("select.set", &json!({"ids":ids})).unwrap();
|
||||
let before = s.doc().unwrap().clone();
|
||||
let history = s.undo_depth().0;
|
||||
s.begin_interaction("Reorder selection").unwrap();
|
||||
for target in [ids[2], ids[1], ids[0]] {
|
||||
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap();
|
||||
}
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
assert_eq!(s.undo_depth().0, history);
|
||||
s.begin_interaction("Reorder selection").unwrap();
|
||||
for target in [ids[1], ids[2]] {
|
||||
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap();
|
||||
}
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(s.undo_depth().0, history + 1);
|
||||
assert_eq!(s.selection(), ids);
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_selections_and_targets_do_not_edit_the_document() {
|
||||
let mut s = Session::new();
|
||||
let a = rect(&mut s, 0., 0., 50., 40., None);
|
||||
let b = rect(&mut s, 70., 0., 50., 40., None);
|
||||
let c = rect(&mut s, 160., 0., 50., 40., None);
|
||||
for args in [
|
||||
json!({"ids":[a,b,c],"source":a,"target":b}), // Mixed gaps.
|
||||
json!({"ids":[a,b],"source":c,"target":b}),
|
||||
json!({"ids":[a,b],"source":a,"target":999999}),
|
||||
json!({"ids":[a,a,b],"source":a,"target":b}),
|
||||
json!({"ids":[a,b],"source":[],"target":b}),
|
||||
] {
|
||||
let before = s.doc().unwrap().clone();
|
||||
assert!(s.execute("arrange.reorder", &args).is_err());
|
||||
assert_eq!(s.doc().unwrap(), &before);
|
||||
}
|
||||
for props in [json!({"locked":true}), json!({"visible":false})] {
|
||||
s.execute("node.set", &json!({"id":b,"props":props})).unwrap();
|
||||
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b]).is_none());
|
||||
s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap();
|
||||
}
|
||||
let d = rect(&mut s, 0., 70., 50., 40., None);
|
||||
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b, d]).is_none(), "an incomplete grid has no slots");
|
||||
let frame =
|
||||
NodeId(s.execute("frame.create", &json!({"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap());
|
||||
let x = rect(&mut s, 0., 0., 50., 40., Some(frame));
|
||||
let y = rect(&mut s, 70., 0., 50., 40., Some(frame));
|
||||
assert!(ReorderLayout::new(s.doc().unwrap(), &[x, y]).is_none());
|
||||
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, x]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reordering_children_of_a_rotated_parent_uses_page_coordinates() {
|
||||
let mut s = Session::new();
|
||||
let frame = NodeId(
|
||||
s.execute("frame.create", &json!({"x":200,"y":200,"width":300,"height":100,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(),
|
||||
);
|
||||
let a = rect(&mut s, 0., 0., 50., 40., Some(frame));
|
||||
let b = rect(&mut s, 70., 0., 50., 40., Some(frame));
|
||||
let (pa, pb) = (xy(&s, a), xy(&s, b));
|
||||
s.execute("arrange.reorder", &json!({"ids":[a,b],"source":a,"target":b})).unwrap();
|
||||
let (qa, qb) = (xy(&s, a), xy(&s, b));
|
||||
assert!((qa.0 - pb.0).abs() < 1e-7 && (qa.1 - pb.1).abs() < 1e-7);
|
||||
assert!((qb.0 - pa.0).abs() < 1e-7 && (qb.1 - pa.1).abs() < 1e-7);
|
||||
}
|
||||
@@ -41,17 +41,6 @@ fn props_of(s: &mut Session, id: NodeId) -> Vec<Value> {
|
||||
ex(s, "component.properties", json!({"id": id}))["properties"].as_array().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A refused slot is reported by `component.makeSlot`, the command that was run, not by the
|
||||
/// property command it adds its slot property through.
|
||||
#[test]
|
||||
fn a_refused_slot_names_make_slot() {
|
||||
let mut s = Session::new();
|
||||
let (c, ..) = slot_card(&mut s);
|
||||
let other = idv(&ex(&mut s, "frame.create", json!({"x": 150, "y": 10, "width": 40, "height": 30, "parent": c, "name": "Other"})));
|
||||
let err = s.execute("component.makeSlot", &json!({"id": other, "name": "Content"})).unwrap_err().to_string();
|
||||
assert!(err.contains("`component.makeSlot`") && err.contains("a property named `Content` already exists"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_are_frames_of_a_main_with_a_slot_property() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -376,7 +376,6 @@ fn pointer_draw_move_resize() {
|
||||
assert_eq!(s.selection(), vec![r]);
|
||||
}
|
||||
|
||||
/// No command panics on junk parameters.
|
||||
#[test]
|
||||
fn every_command_survives_junk_params() {
|
||||
// A junk-parameter sweep: no command may panic (guard turns panics into Internal errors,
|
||||
@@ -419,9 +418,6 @@ fn every_command_survives_junk_params() {
|
||||
// Selection color slots: malformed indexes, ranges and paints.
|
||||
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
|
||||
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
|
||||
// Smart-selection reorder targets and modes as junk.
|
||||
json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}),
|
||||
json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}),
|
||||
// Rotation origins and placing with a tool as junk.
|
||||
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
|
||||
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
|
||||
@@ -438,8 +434,6 @@ fn every_command_survives_junk_params() {
|
||||
| "file.placeImage"
|
||||
| "image.place"
|
||||
| "file.revert"
|
||||
// Takes no params and walks the font folders: one walk per junk is slow.
|
||||
| "text.rescanFonts"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -177,19 +177,20 @@ fn vertical_trim_through_node_set() {
|
||||
|
||||
#[test]
|
||||
fn paragraph_indent_only_moves_left_aligned_text() {
|
||||
figcraft_text::scan_system_fonts();
|
||||
let mut s = Session::new();
|
||||
let t = text(&mut s, "Indented paragraph", json!({"fontSize": 20, "paragraphIndent": 30}));
|
||||
let first_x = |s: &Session| {
|
||||
let (l, _) = figcraft_text::layout_node(s.doc().unwrap().node(t).unwrap()).unwrap();
|
||||
(l.glyphs.first().map(|g| g.x), l.width)
|
||||
(l.glyphs.first().map_or(0.0, |g| g.x), l.width)
|
||||
};
|
||||
// Without any font there are no glyphs, but the indent alone still gives the box a width.
|
||||
let (Some(x), w) = first_x(&s) else { return };
|
||||
let (x, w) = first_x(&s);
|
||||
if w <= 0.0 {
|
||||
return;
|
||||
}
|
||||
assert!((x - 30.0).abs() < 1e-6, "left: indented");
|
||||
s.execute("node.set", &json!({"id": t, "props": {"textAlign": "center"}})).unwrap();
|
||||
let (x, w2) = first_x(&s);
|
||||
assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.is_some_and(|x| x.abs() < 1e-6), "centered auto-width text: no indent, the box shrinks by it");
|
||||
assert!((w2 - (w - 30.0)).abs() < 1e-6 && x.abs() < 1e-6, "centered auto-width text: no indent, the box shrinks by it");
|
||||
}
|
||||
|
||||
/// A new text made with the Text tool by a press at `from`, a drag to `to` and a release, holding
|
||||
|
||||
@@ -782,7 +782,6 @@ mod tests {
|
||||
assert!(r.is_null() || r["axes"].is_array());
|
||||
}
|
||||
|
||||
/// No text command panics on junk parameters while a text is being edited.
|
||||
#[test]
|
||||
fn text_commands_survive_junk_while_editing() {
|
||||
let junk = [
|
||||
@@ -794,8 +793,7 @@ mod tests {
|
||||
json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}),
|
||||
json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}),
|
||||
];
|
||||
// `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow.
|
||||
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") {
|
||||
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.")) {
|
||||
for j in &junk {
|
||||
let (mut s, id) = setup("Hi there\nyou");
|
||||
begin(&mut s, id, Some(2), false).unwrap();
|
||||
|
||||
@@ -1300,7 +1300,7 @@ impl Session {
|
||||
for i in &ids {
|
||||
let parent = doc.get(*i)?.parent.unwrap_or(page);
|
||||
let idx = doc.index_in_parent(*i).map(|x| x + 1);
|
||||
copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?);
|
||||
copies.push(doc.duplicate_into(*i, parent, idx)?);
|
||||
}
|
||||
moving = copies;
|
||||
*sel = moving.clone();
|
||||
|
||||
@@ -279,34 +279,6 @@ fn bindings_by_field_and_type() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_bindings_go_with_the_auto_layout() {
|
||||
// Docs silent; pinned (#810): removing a frame's auto layout drops its gap and padding bindings,
|
||||
// and a binding never brings an auto layout back. One made without auto layout waits for it.
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "C"}));
|
||||
run(&mut s, "variable.create", json!({"collection": "C", "name": "g", "type": "number", "value": 12}));
|
||||
let removals =
|
||||
[("layout.remove", json!({})), ("node.set", json!({"props": {"layout": null}})), ("node.set", json!({"props": {"layout": "none"}}))];
|
||||
for (cmd, mut p) in removals {
|
||||
let f = frame(&mut s, None);
|
||||
run(&mut s, "layout.add", json!({"ids": [f]}));
|
||||
for field in ["gap", "crossGap", "paddingTop"] {
|
||||
run(&mut s, "variable.bind", json!({"ids": [f], "field": field, "variable": "g"}));
|
||||
}
|
||||
p["ids"] = json!([f]);
|
||||
run(&mut s, cmd, p.clone());
|
||||
let n = node(&s, f);
|
||||
assert!(n.layout.is_none(), "{cmd} {p}: auto layout stays removed");
|
||||
assert!(n.bound.is_empty(), "{cmd} {p}: layout bindings dropped");
|
||||
}
|
||||
let f = frame(&mut s, None);
|
||||
run(&mut s, "variable.bind", json!({"ids": [f], "field": "gap", "variable": "g"}));
|
||||
assert!(node(&s, f).layout.is_none(), "binding gap adds no auto layout");
|
||||
run(&mut s, "layout.add", json!({"ids": [f]}));
|
||||
assert_eq!(node(&s, f).layout.map(|l| l.gap), Some(12.0), "the waiting binding applies to the new auto layout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_bound_variable_keeps_the_last_value() {
|
||||
// Documented: deleting a variable unbinds what used it, which keeps showing its last value.
|
||||
@@ -393,90 +365,3 @@ fn number_variables_are_pixels_and_edits_are_one_undo_step() {
|
||||
s.undo().unwrap();
|
||||
assert_eq!(lh(&s), LineHeight::Pixels(20.0), "the value and the bound layer go back together");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_collection_keeps_at_least_one_named_mode() {
|
||||
// A collection keeps at least one mode (removeMode) and mode names can't be empty (renameMode),
|
||||
// so createCollection refuses an empty mode list, blank mode names and a blank collection name.
|
||||
let mut s = Session::new();
|
||||
for p in [
|
||||
json!({"name": "C", "modes": []}),
|
||||
json!({"name": "C", "modes": [1]}),
|
||||
json!({"name": "C", "modes": ["", " "]}),
|
||||
json!({"name": "C", "modes": ["A", " "]}),
|
||||
json!({"name": " "}),
|
||||
json!({"name": ""}),
|
||||
] {
|
||||
assert!(s.execute("variable.createCollection", &p).is_err(), "{p} should be refused");
|
||||
}
|
||||
assert!(s.doc().unwrap().variables.collections.is_empty(), "a refused call creates nothing");
|
||||
run(&mut s, "variable.createCollection", json!({"name": "C", "modes": [" Light ", "Dark"]}));
|
||||
let names: Vec<String> = s.doc().unwrap().variables.collections[0].modes.iter().map(|m| m.name.clone()).collect();
|
||||
assert_eq!(names, ["Light", "Dark"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_edits_of_bound_fields_stick_and_detach_that_binding() {
|
||||
// #809: an edit of a bound field detaches its binding (as a paint edit does) instead of
|
||||
// the variable's value coming back; bindings whose value the edit kept stay.
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "C"}));
|
||||
let w = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "w", "type": "number", "value": 48})));
|
||||
let o = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "o", "type": "number", "value": 40})));
|
||||
let r = rect(&mut s, None);
|
||||
for (field, v) in [("width", &w), ("height", &w), ("opacity", &o)] {
|
||||
run(&mut s, "variable.bind", json!({"ids": [r], "field": field, "variable": v}));
|
||||
}
|
||||
assert_eq!((node(&s, r).width, node(&s, r).opacity), (48.0, 0.4));
|
||||
|
||||
run(&mut s, "node.set", json!({"ids": [r], "width": 100, "opacity": 0.4}));
|
||||
let n = node(&s, r);
|
||||
assert_eq!(n.width, 100.0);
|
||||
assert!(!n.bound.contains_key("width"));
|
||||
assert!(n.bound.contains_key("height") && n.bound.contains_key("opacity"), "unchanged values keep their bindings");
|
||||
|
||||
run(&mut s, "transform.resize", json!({"ids": [r], "height": 30}));
|
||||
assert_eq!(node(&s, r).height, 30.0);
|
||||
assert!(!node(&s, r).bound.contains_key("height"));
|
||||
|
||||
run(&mut s, "object.opacityKeys", json!({"ids": [r], "digits": "9"}));
|
||||
assert_eq!(node(&s, r).opacity, 0.9);
|
||||
assert!(node(&s, r).bound.is_empty());
|
||||
|
||||
// A value set with its binding in the same call keeps the new binding.
|
||||
run(&mut s, "node.set", json!({"ids": [r], "width": 5, "boundVariables": {"width": w}}));
|
||||
assert_eq!((node(&s, r).width, node(&s, r).bound.get("width")), (48.0, Some(&w)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "C"}));
|
||||
let v = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "r", "type": "number", "value": 8})));
|
||||
let r = rect(&mut s, None);
|
||||
run(&mut s, "variable.bind", json!({"ids": [r], "field": "cornerRadius", "variable": v}));
|
||||
s.begin_interaction("Edit").unwrap();
|
||||
let props = serde_json::Map::from_iter([("cornerRadius".to_string(), json!(2))]);
|
||||
s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap();
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(node(&s, r).corners.radii, [2.0; 4]);
|
||||
assert!(node(&s, r).bound.is_empty());
|
||||
|
||||
let t = id(run(&mut s, "text.create", json!({"x": 0, "y": 0, "text": "Hi"})));
|
||||
run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v}));
|
||||
run(&mut s, "text.style", json!({"id": t, "fontSize": 20}));
|
||||
assert!(node(&s, t).bound.is_empty());
|
||||
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 20.0);
|
||||
|
||||
// The font size/weight/tracking shortcuts and text content edits detach too.
|
||||
run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v}));
|
||||
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 8.0);
|
||||
run(&mut s, "text.sizeUp", json!({"ids": [t]}));
|
||||
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 9.0);
|
||||
assert!(node(&s, t).bound.is_empty());
|
||||
let c = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "c", "type": "string", "value": "Hello"})));
|
||||
run(&mut s, "variable.bind", json!({"ids": [t], "field": "characters", "variable": c}));
|
||||
run(&mut s, "text.set", json!({"id": t, "characters": "Bye"}));
|
||||
assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye");
|
||||
assert!(node(&s, t).bound.is_empty());
|
||||
}
|
||||
@@ -371,29 +371,3 @@ fn variant_properties_follow_string_variables() {
|
||||
assert_eq!(main(&s), variants[1]);
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() {
|
||||
let mut s = Session::new();
|
||||
let r = gradient_rect(&mut s);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/2/color", "variable": "brand"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/1/color", "variable": "ink"}));
|
||||
let cur = |s: &Session| s.doc().unwrap().node(r).unwrap().fills[0].clone();
|
||||
// Removing the first stop moves both bindings down with their stops.
|
||||
let mut p = cur(&s);
|
||||
if let PaintKind::Gradient(g) = &mut p.kind {
|
||||
g.stops.remove(0);
|
||||
}
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
|
||||
assert_eq!(bound_keys(&s, r), ["fills/0/stops/0/color", "fills/0/stops/1/color"]);
|
||||
// Recolouring one bound stop detaches that stop only.
|
||||
let mut p = cur(&s);
|
||||
if let PaintKind::Gradient(g) = &mut p.kind {
|
||||
g.stops[0].color = Color::WHITE;
|
||||
}
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
|
||||
assert_eq!(bound_keys(&s, r), ["fills/0/stops/1/color"]);
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000ff"}));
|
||||
assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]);
|
||||
healthy(&s);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
//! Combine as variants and Add variant where people look for them (#903): the predicates the
|
||||
//! context menu and the Design panel offer them by, combining refusing components that are
|
||||
//! variants already (it used to put a set inside a set), and Add variant on a main component that
|
||||
//! isn't in a set yet making it one.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
use crate::cmd::component::{can_add_variant, can_combine_variants};
|
||||
use crate::components::tests::{ex, healthy, idv, kids, node};
|
||||
use figcraft_doc::*;
|
||||
|
||||
/// A main component named `name` at `x`, made from a 120×40 frame with a "Bg" rectangle.
|
||||
fn component(s: &mut Session, name: &str, x: f64) -> NodeId {
|
||||
let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 120, "height": 40, "name": name})));
|
||||
ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 120, "height": 40, "parent": f, "props": {"name": "Bg"}}));
|
||||
idv(&ex(s, "component.create", json!({"ids": [f]})))
|
||||
}
|
||||
|
||||
fn undo_len(s: &Session) -> usize {
|
||||
s.ds().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn sets(s: &Session) -> usize {
|
||||
s.doc().unwrap().nodes().filter(|n| matches!(n.kind, NodeKind::ComponentSet { .. })).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combine_is_offered_for_two_or_more_main_components_only() {
|
||||
let mut s = Session::new();
|
||||
let a = component(&mut s, "A", 0.0);
|
||||
let b = component(&mut s, "B", 200.0);
|
||||
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50})));
|
||||
let inst = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 400})));
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(can_combine_variants(doc, &[a, b]));
|
||||
assert!(!can_combine_variants(doc, &[]), "nothing selected");
|
||||
assert!(!can_combine_variants(doc, &[a]), "one component (the command still takes it)");
|
||||
assert!(!can_combine_variants(doc, &[a, a]), "the same component twice");
|
||||
assert!(!can_combine_variants(doc, &[a, frame]), "a frame isn't a component");
|
||||
assert!(!can_combine_variants(doc, &[a, inst]), "an instance isn't a main component");
|
||||
|
||||
let c = component(&mut s, "C", 400.0);
|
||||
let d = component(&mut s, "D", 600.0);
|
||||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [c, d]})));
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(!can_combine_variants(doc, &[c, d]), "variants of one set");
|
||||
assert!(!can_combine_variants(doc, &[c, a]), "a variant and a lone component");
|
||||
assert!(!can_combine_variants(doc, &[set, a]), "a set isn't a component");
|
||||
|
||||
// Components in different frames still combine.
|
||||
let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 100})));
|
||||
let e = component(&mut s, "E", 0.0);
|
||||
ex(&mut s, "node.reparent", json!({"ids": [e], "parent": holder}));
|
||||
assert!(can_combine_variants(s.doc().unwrap(), &[a, e]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_variant_is_offered_for_sets_variants_and_lone_components() {
|
||||
let mut s = Session::new();
|
||||
let a = component(&mut s, "A", 0.0);
|
||||
let b = component(&mut s, "B", 200.0);
|
||||
let lone = component(&mut s, "Lone", 400.0);
|
||||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]})));
|
||||
let frame = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 50, "height": 50})));
|
||||
let inst = idv(&ex(&mut s, "instance.create", json!({"component": lone, "x": 0, "y": 400})));
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(can_add_variant(doc, set));
|
||||
assert!(can_add_variant(doc, a));
|
||||
assert!(can_add_variant(doc, lone));
|
||||
assert!(!can_add_variant(doc, frame));
|
||||
assert!(!can_add_variant(doc, inst));
|
||||
assert!(!can_add_variant(doc, kids(&s, inst)[0]), "a layer inside an instance");
|
||||
assert!(!can_add_variant(doc, NodeId(999_999)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combining_variants_again_is_refused_instead_of_nesting_sets() {
|
||||
let mut s = Session::new();
|
||||
let a = component(&mut s, "State=Default", 0.0);
|
||||
let b = component(&mut s, "State=Hover", 200.0);
|
||||
let lone = component(&mut s, "State=Pressed", 400.0);
|
||||
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": [a, b]})));
|
||||
let before = s.doc().unwrap().clone();
|
||||
let steps = undo_len(&s);
|
||||
for ids in [vec![a, b], vec![a, lone]] {
|
||||
let err = s.execute("component.combineVariants", &json!({"ids": ids})).unwrap_err();
|
||||
assert!(err.to_string().contains("variants already"), "{err}");
|
||||
}
|
||||
assert_eq!(*s.doc().unwrap(), before, "a refused combine changes nothing");
|
||||
assert_eq!(undo_len(&s), steps);
|
||||
assert_eq!(sets(&s), 1);
|
||||
assert_eq!(kids(&s, set), vec![a, b]);
|
||||
// One lone component still makes a set of one (other commands rely on it).
|
||||
ex(&mut s, "component.combineVariants", json!({"ids": [lone]}));
|
||||
assert_eq!(sets(&s), 2);
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combining_refuses_nested_components_and_components_inside_instances() {
|
||||
let mut s = Session::new();
|
||||
// A "Card" component holding an "Icon" component.
|
||||
let card = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})));
|
||||
let icon = idv(&ex(&mut s, "frame.create", json!({"x": 10, "y": 10, "width": 20, "height": 20, "name": "Icon", "parent": card})));
|
||||
let icon = idv(&ex(&mut s, "component.create", json!({"ids": [icon]})));
|
||||
let card = idv(&ex(&mut s, "component.create", json!({"ids": [card]})));
|
||||
let other = component(&mut s, "Other", 300.0);
|
||||
let inst = idv(&ex(&mut s, "instance.create", json!({"component": card, "x": 0, "y": 300})));
|
||||
let inner = kids(&s, inst).into_iter().find(|c| matches!(node(&s, *c).kind, NodeKind::Component { .. })).expect("the instance's copy of Icon");
|
||||
|
||||
let doc = s.doc().unwrap();
|
||||
assert!(!can_combine_variants(doc, &[card, icon]), "a component and one inside it");
|
||||
assert!(can_combine_variants(doc, &[icon, other]), "a nested component with another one is fine");
|
||||
assert!(!can_combine_variants(doc, &[inner, other]), "a component inside an instance");
|
||||
let before = s.doc().unwrap().clone();
|
||||
let err = s.execute("component.combineVariants", &json!({"ids": [card, icon]})).unwrap_err();
|
||||
assert!(err.to_string().contains("inside it"), "{err}");
|
||||
assert_eq!(*s.doc().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_variant_on_a_lone_component_makes_it_a_set_in_one_step() {
|
||||
let mut s = Session::new();
|
||||
let c = component(&mut s, "Button", 0.0);
|
||||
let inst = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 0, "y": 300})));
|
||||
let before = s.doc().unwrap().clone();
|
||||
let steps = undo_len(&s);
|
||||
|
||||
let v = idv(&ex(&mut s, "component.addVariant", json!({"id": c})));
|
||||
let set = node(&s, c).parent.unwrap();
|
||||
let set_node = node(&s, set);
|
||||
assert!(matches!(set_node.kind, NodeKind::ComponentSet { .. }));
|
||||
assert_eq!(set_node.name, "Button", "the set takes the component's name");
|
||||
assert_eq!(kids(&s, set), vec![c, v]);
|
||||
assert_eq!(node(&s, c).name, "Property 1=Default");
|
||||
assert_eq!(node(&s, v).name, "Property 1=Variant2");
|
||||
assert_eq!(s.selection(), vec![v]);
|
||||
assert!(set_node.height >= 40.0 * 2.0 + 20.0, "the set wraps both variants: {}", set_node.height);
|
||||
assert!(matches!(node(&s, inst).kind, NodeKind::Instance { component, .. } if component == c), "instances keep their main");
|
||||
assert_eq!(undo_len(&s), steps + 1, "wrapping and adding are one undo step");
|
||||
healthy(&s);
|
||||
|
||||
s.undo().unwrap();
|
||||
assert_eq!(*s.doc().unwrap(), before, "undo puts the lone component back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_variant_on_a_lone_component_keeps_prop_value_names_and_rolls_back_on_errors() {
|
||||
let mut s = Session::new();
|
||||
let c = component(&mut s, "Size=Small", 0.0);
|
||||
let before = s.doc().unwrap().clone();
|
||||
let steps = undo_len(&s);
|
||||
assert!(s.execute("component.addVariant", &json!({"id": c, "property": "Nope", "value": "x"})).is_err());
|
||||
assert_eq!(*s.doc().unwrap(), before, "a refused Add variant leaves no set behind");
|
||||
assert_eq!(undo_len(&s), steps);
|
||||
|
||||
let v = idv(&ex(&mut s, "component.addVariant", json!({"id": c, "property": "Size", "value": "Large"})));
|
||||
assert_eq!(node(&s, c).name, "Size=Small");
|
||||
assert_eq!(node(&s, v).name, "Size=Large");
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_variant_on_a_lone_component_in_auto_layout_keeps_its_place() {
|
||||
let mut s = Session::new();
|
||||
let row = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 100, "name": "Row"})));
|
||||
ex(&mut s, "layout.add", json!({"ids": [row]}));
|
||||
let first = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": row})));
|
||||
let c = component(&mut s, "Chip", 0.0);
|
||||
ex(&mut s, "node.reparent", json!({"ids": [c], "parent": row}));
|
||||
let last = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40, "parent": row})));
|
||||
|
||||
ex(&mut s, "component.addVariant", json!({"id": c}));
|
||||
let set = node(&s, c).parent.unwrap();
|
||||
assert_eq!(kids(&s, row), vec![first, set, last], "the set takes the component's place in the row");
|
||||
assert_eq!(kids(&s, set).len(), 2);
|
||||
healthy(&s);
|
||||
}
|
||||
+2
@@ -1,3 +1,5 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_engine::{
|
||||
Session,
|
||||
doc::NodeKind,
|
||||
@@ -1,14 +0,0 @@
|
||||
//! The crate's integration tests, built as one test binary.
|
||||
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod empty_text_box_undo;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
|
||||
mod library_style_acceptance;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod list_indent_response;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod scope_reset;
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod selection_colors;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod shape_builder_response;
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
//! Accepting a library update that changes only a paint style refreshes the layers linked to it.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::disallowed_methods)]
|
||||
|
||||
use figcraft_doc::{NodeId, StyleKind};
|
||||
use figcraft_engine::Session;
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
//! `text.listIndent` / `text.listOutdent` report `changed` only when the document changed.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_engine::{
|
||||
Session,
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
|
||||
use figcraft_color::Paint;
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::{
|
||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user