Merge main into #1215

This commit is contained in:
bflatastic
2026-10-26 09:19:37 +07:00
309 changed files with 17840 additions and 2285 deletions
+8
View File
@@ -3,5 +3,13 @@
[env]
CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true }
# The Windows UI Automation adapter can be asked about a node that disappeared in the
# same frame. On MinGW, converting that HRESULT through windows-result's thread COM
# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra
# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. Every Windows GNU
# target (x86_64, i686, the gnullvm ones) gets it, as the check in apps/figcraft/src/main.rs asks.
[target.'cfg(all(windows, target_env = "gnu"))']
rustflags = ["--cfg=windows_slim_errors"]
[alias]
xtask = "run -q -p xtask --"
+4 -5
View File
@@ -21,11 +21,10 @@ jobs:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter):
# crates/text/build.rs fails a build that requires craft-fonts 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
# Combined Latin presentation and core UI catalog. Required web builds enforce
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
require-craft-fonts: true
packages: >-
libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
libgtk-3-dev
+1 -1
View File
@@ -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: 37827adc41fd165f9a9234ac40f94dbad353aa69
+4 -5
View File
@@ -24,13 +24,12 @@ 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
# Combined Latin presentation and core UI catalog. Required web builds enforce
# Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
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.
# crates/text/build.rs keeps the web allowlist small (Inter and Georgian).
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
+11 -2
View File
@@ -26,8 +26,17 @@ Release builds embed fonts from the optional craft-fonts build input
|---|---|---|
| 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) |
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, 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) |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4.
FigCraft filters both families from every build in accordance with its asset policy.
egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates.
## egui dependency font icons
| Asset | Author | Source | Licence | Use |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
Generated
+493 -167
View File
File diff suppressed because it is too large Load Diff
+32 -10
View File
@@ -6,7 +6,7 @@ exclude = ["plugins"]
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
[workspace.package]
version = "0.10.0"
version = "0.11.2"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -32,11 +32,15 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
# 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" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
# 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"] }
# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
@@ -79,9 +83,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable",
wat = "1.261"
# ui
egui = "0.36"
egui_extras = { version = "0.36", default-features = false }
eframe = { version = "0.36", default-features = false }
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
eframe = { version = "0.37", default-features = false }
rfd = "0.17"
arboard = { version = "3.6", default-features = false }
webbrowser = "1"
@@ -122,14 +126,32 @@ codegen-units = 4
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
[profile.web-release]
inherits = "release"
opt-level = "s"
# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate.
opt-level = "z"
lto = "fat"
codegen-units = 1
debug = false
strip = true
panic = "abort"
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
# craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a
# panic while drawing unwinds without aborting.
[patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" }
+17
View File
@@ -51,3 +51,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
egui icon font subset (dependency asset)
----------------------------------------
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
uses the same MIT license. Upstream license text follows unchanged.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+12 -2
View File
@@ -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-24 · **Change:** minor (boards: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); 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-25 · **Change:** minor (egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; 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)
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
@@ -86,7 +86,7 @@ native speakers' reviews of the nine language drafts need people.
| Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) |
| Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) |
| Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) |
| Dev Mode / inspect | 97% | 76% | 2–4 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) |
| Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) |
| Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) |
| Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) |
@@ -132,6 +132,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
## Progress log
- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged.
- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change.
- **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%.
- **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)).
- **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.
@@ -142,6 +145,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
- **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-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged.
- **2026-10-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed
filenames open normally; the global `--no-system-fonts` flag also respects the terminator.
Binary regression coverage opens five such native files and checks their JSON info.
- **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.
@@ -328,6 +335,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. |
| 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged |
| 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding |
| 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding |
| 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 |
+4
View File
@@ -20,6 +20,10 @@ impl Args {
let mut bad: Option<CliError> = None;
let mut it = argv.iter();
while let Some(arg) = it.next() {
if arg == "--" {
a.positional.extend(it.cloned());
break;
}
let name = if let Some(n) = arg.strip_prefix("--") {
n
} else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) {
+62 -20
View File
@@ -5,7 +5,7 @@ use std::path::Path;
use std::process::ExitCode;
use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings};
use figcraft_engine::Session;
use figcraft_engine::{BatchScope, BatchStep, Session};
use serde_json::{Value, json};
use crate::{CliError, CliResult};
@@ -272,14 +272,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
return Err(CliError::Failed("the page is empty: nothing to export".into()));
}
let mut used = std::collections::BTreeSet::new();
let mut next_suffix: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for id in ids {
let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer"));
let mut name = base.clone();
let mut k = 2;
while !used.insert(name.to_lowercase()) {
name = format!("{base} {k}");
k += 1;
}
let base_lower = base.to_lowercase();
let next = next_suffix.entry(base_lower).or_insert(1);
let name = loop {
let k = *next;
*next += 1;
let candidate = if k == 1 { base.clone() } else { format!("{base} {k}") };
if used.insert(candidate.to_lowercase()) {
break candidate;
}
};
let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned();
write(&path, &bytes_of(&[id])?)?;
println!("{path}");
@@ -313,7 +318,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul
}
/// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines.
pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
let steps: Vec<Value> = match serde_json::from_str::<Value>(text) {
Ok(Value::Array(a)) => a,
Ok(Value::Object(o)) if o.contains_key("commands") => {
@@ -338,7 +343,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
Some(p @ Value::Object(_)) => p.clone(),
Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")),
};
Ok((cmd.to_string(), params))
let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?;
Ok(BatchStep { name, ..BatchStep::new(cmd, params) })
})
.collect()
}
@@ -356,31 +362,30 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
None => Session::new(),
};
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
// (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
// a step that fails takes the whole script back instead of leaving the earlier steps applied.
// The result lines are held back until the script is through, so none of them reports work that
// was rolled back.
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
// (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the
// steps before it, and a step that fails takes the whole script back instead of leaving the
// earlier steps applied. The result lines are held back until the script is through, so none of
// them reports work that was rolled back.
let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut failed_at = 0;
let outcome = s.grouped(&format!("run {script}"), |s| {
for (i, (cmd, params)) in steps.iter().enumerate() {
for (i, step) in steps.iter().enumerate() {
failed_at = i;
let v = s.batch_step(&results, i, cmd, params)?;
s.batch_step(&mut scope, &step.command, &step.params)?;
s.ui_requests.clear();
results.push(v);
}
Ok(())
});
if let Err(e) = outcome {
let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str());
let i = failed_at;
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
eprintln!("the script's document changes were rolled back");
return Ok(ExitCode::from(1));
}
for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() {
println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v}));
}
if let Some(out) = out {
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
@@ -506,6 +511,43 @@ mod tests {
std::fs::remove_dir_all(dir).unwrap();
}
/// A script names its steps with `as` and reads paths into their results, like `command.batch`.
#[test]
fn a_script_refers_to_named_steps_and_paths() {
let dir = temp_dir("run-ref");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}},
{"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}}
]"#,
)
.unwrap();
let out = dir.join("out.figcraft");
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
let mut s = Session::empty();
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
let doc = s.doc().unwrap();
let top = doc.node(s.page().unwrap()).unwrap().children.clone();
let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect();
assert_eq!(names, ["Card", "Copy"]);
std::fs::remove_dir_all(dir).unwrap();
}
/// Two steps with one name fail before anything runs.
#[test]
fn a_script_with_a_name_twice_runs_nothing() {
let dir = temp_dir("run-dup-name");
let script = dir.join("script.json");
std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap();
let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") };
assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}");
std::fs::remove_dir_all(dir).unwrap();
}
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
#[test]
fn a_failed_step_leaves_no_output() {
+24 -15
View File
@@ -108,15 +108,24 @@ impl From<String> for CliError {
pub type CliResult<T> = Result<T, CliError>;
fn run(argv: &[String]) -> CliResult<ExitCode> {
let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts");
let argv: Vec<String> = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect();
if system_fonts {
fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult<Args> {
let flags = [flags, &["no-system-fonts"]].concat();
let args = Args::parse(argv, valued, &flags)?;
if !args.flag("no-system-fonts") {
// Text lays out with the installed fonts, like the desktop app, so headless renders and
// auto layout sizes match what users see.
figcraft_text::scan_system_fonts_on_demand();
}
let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) };
Ok(args)
}
fn run(argv: &[String]) -> CliResult<ExitCode> {
let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else {
return Err(CliError::Usage("missing subcommand".into()));
};
let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?;
// Keep leading global flags with the subcommand's options, where values and `--` are parsed.
let rest: Vec<String> = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect();
match cmd.as_str() {
"help" | "--help" | "-h" => {
println!("{USAGE}");
@@ -127,13 +136,13 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"info" => {
let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?;
let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?;
let file = a.one_positional("info <file>")?;
confined(&a, || 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 = parse_args(&rest, &valued, &["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 =
@@ -141,7 +150,7 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
let a = Args::parse(rest, &ROOT_FLAGS, &[])?;
let a = parse_args(&rest, &ROOT_FLAGS, &[])?;
match a.positional.as_slice() {
[i, o] => confined(&a, || files::convert(i, o)),
_ => Err(CliError::Usage("convert <in> <out>".into())),
@@ -149,19 +158,19 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
"render" => {
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let a = parse_args(&rest, &valued, &[])?;
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()))
}
"run" => {
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let a = parse_args(&rest, &valued, &[])?;
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()))
}
"commands" => {
let a = Args::parse(rest, &["filter"], &["json"])?;
let a = parse_args(&rest, &["filter"], &["json"])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("commands takes no positional arguments".into()));
}
@@ -170,7 +179,7 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
"mcp" => {
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let a = parse_args(&rest, &valued, &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
@@ -185,7 +194,7 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
mcp(attach.as_deref(), roots_of(&a)?)
}
"plugin" => {
let a = Args::parse(rest, &["server"], &[])?;
let a = parse_args(&rest, &["server"], &[])?;
match a.positional.as_slice() {
[verb, folder] if verb == "publish" => {
let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?;
@@ -195,10 +204,10 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
}
}
"perf" => {
let a = Args::parse(rest, &["layers", "instances"], &[])?;
let a = parse_args(&rest, &["layers", "instances"], &[])?;
let sizes: Vec<usize> = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
let instances: Vec<usize> = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
perf::run(&sizes, &instances, system_fonts)
perf::run(&sizes, &instances, !a.flag("no-system-fonts"))
}
other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))),
}
+32
View File
@@ -13,6 +13,38 @@ fn tmp(name: &str) -> std::path::PathBuf {
d
}
#[test]
fn option_terminator_allows_dash_prefixed_filenames() {
let dir = tmp("option-terminator");
let doc = figcraft_doc::Document::new("Dash-prefixed file");
let bytes = figcraft_format::save_bytes(&doc, None).unwrap();
for name in ["--drawing.figcraft", "-h", "--json", "--no-system-fonts", "--"] {
std::fs::write(dir.join(name), &bytes).unwrap();
let output = bin().current_dir(&dir).args(["info", "--json", "--no-system-fonts", "--", name]).output().unwrap();
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(info["file"], name);
assert_eq!(info["name"], "Dash-prefixed file");
}
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn global_font_flag_respects_option_values() {
for filter in ["--", "--no-system-fonts"] {
let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap();
assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr));
assert!(serde_json::from_slice::<serde_json::Value>(&output.stdout).unwrap().is_array());
}
}
#[test]
fn global_font_flag_can_precede_the_subcommand() {
let output = bin().args(["--no-system-fonts", "commands", "--json"]).output().unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(!serde_json::from_slice::<Vec<serde_json::Value>>(&output.stdout).unwrap().is_empty());
}
#[test]
fn bad_arguments_exit_2_without_panicking() {
for args in [
+3 -3
View File
@@ -120,15 +120,15 @@ impl Abuse {
open
}
/// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones
/// before it; enough wrong ones in a row lock the address out.
/// `ip` tried a join code that was right (`ok`) or wrong: a right one doesn't forgive the wrong
/// ones before it (they only decay with time); enough wrong ones lock the address out.
pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) {
if !ok {
self.wrong_codes.take(now);
}
for (k, times) in ip_blocks(ip) {
let a = self.address(k, f64::from(times), now);
if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
if now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT {
a.wrong = 0;
}
if !ok {
+12 -8
View File
@@ -262,17 +262,21 @@ fn the_limits_on_codes_over_time() {
let t0 = Instant::now();
let me = ip("198.51.100.9");
let mut abuse = Abuse::new(AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 });
// A right code forgives the wrong ones before it.
// A right code, even from a neighbour in the same /64, doesn't forgive the wrong ones.
let a = ip("2001:db8:1:2::1");
let b = ip("2001:db8:1:2::2");
for _ in 0..WRONG_CODES - 1 {
abuse.code_tried(a, false, t0);
}
abuse.code_tried(b, true, t0);
abuse.code_tried(a, true, t0);
assert_eq!(abuse.code_locked(a, t0), None);
abuse.code_tried(a, false, t0);
assert_eq!(abuse.code_locked(a, t0), Some(CODE_LOCKOUT));
// Wrong codes: locked for the lockout, then free again.
for _ in 0..WRONG_CODES {
abuse.code_tried(me, false, t0);
}
abuse.code_tried(me, true, t0);
for _ in 0..WRONG_CODES - 1 {
abuse.code_tried(me, false, t0);
}
assert_eq!(abuse.code_locked(me, t0), None);
// One more: locked for the lockout, then free again.
abuse.code_tried(me, false, t0);
assert_eq!(abuse.code_locked(me, t0), Some(CODE_LOCKOUT));
assert!(abuse.code_locked(me, t0 + CODE_LOCKOUT - Duration::from_secs(1)).is_some());
assert_eq!(abuse.code_locked(me, t0 + CODE_LOCKOUT), None);
+5 -4
View File
@@ -128,13 +128,14 @@ fn u64_at(b: &[u8], at: usize) -> Option<u64> {
/// A snapshot file's bytes.
pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result<Vec<u8>, String> {
let body = message::encode(state).map_err(|e| e.to_string())?;
let mut out = Vec::with_capacity(SNAP_HEADER + body.len());
let mut out = Vec::with_capacity(SNAP_HEADER);
out.extend_from_slice(SNAP_MAGIC);
out.push(FORMAT);
out.extend_from_slice(&generation.to_le_bytes());
out.extend_from_slice(&crc(&body).to_le_bytes());
out.extend_from_slice(&body);
out.extend_from_slice(&0u32.to_le_bytes());
let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?;
let sum = crc(&out[SNAP_HEADER..]);
out[SNAP_HEADER - 4..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes());
Ok(out)
}
+9 -5
View File
@@ -1480,14 +1480,18 @@ impl Backend for Store {
}
fn idle(&mut self, file: &str, server: &mut Server) -> bool {
let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| {
let _ = flush_log(file, log);
log.dirty
let compact = self.files.get_mut(file).is_some_and(|entry| {
let snapshot_seq = entry.meta.seq;
entry.log.as_mut().is_some_and(|log| {
// Issued client ids and refusals are durable in the log; only edits need a
// new image-bearing snapshot. Still try a snapshot if syncing the log failed.
flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq)
})
});
if dirty {
if compact {
self.compact(file, server);
}
// It closes anyway, everything in its snapshot: a compaction cut short needs no report.
// It closes anyway, with its state in the snapshot and durable log.
self.cut_short.retain(|f| f != file);
if let Some(e) = self.files.get_mut(file) {
e.log = None;
+41
View File
@@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() {
assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows");
}
#[test]
fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() {
let dir = TempDir::new("read-only-reconnects");
let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) };
let (id, original) = {
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let (id, _, _) = publish(&mut store, "Read only");
let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap();
(id, bytes)
};
let mut last_client = 0;
for _ in 0..6 {
let mut store = Store::open(dir.path(), c.clone()).unwrap();
let mut server = store.open(&id).unwrap();
let client = join(&mut server, Access::View);
assert!(client.id() > last_client, "issued ids survive closing and reopening the store");
last_client = client.id();
store.changed(&id, &mut server);
// Idle must sync even if no periodic flush ran before the connection closed.
assert!(store.idle(&id, &mut server));
assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot");
assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original);
let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap();
assert!(!log.entries.is_empty());
assert_eq!(server.seq(), 0);
}
}
#[test]
fn a_long_log_is_compacted_while_open() {
let dir = TempDir::new("compact");
@@ -252,6 +280,19 @@ fn temporary_directories_are_removed() {
assert!(!path.exists());
}
#[test]
fn snapshot_encoding_keeps_the_existing_framing_and_checksum() {
let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap();
let body = figcraft_collab::message::encode(&state).unwrap();
let mut legacy = b"FCSN\x01".to_vec();
legacy.extend_from_slice(&37u64.to_le_bytes());
legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes());
legacy.extend_from_slice(&body);
let encoded = disk::encode_snapshot(37, &state).unwrap();
assert_eq!(encoded, legacy);
assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state));
}
#[test]
fn random_bytes_never_panic_the_readers() {
let mut rng = Rng::new(7);
+10 -2
View File
@@ -69,7 +69,15 @@ struct WebShell {
}
impl eframe::App for WebShell {
// `FigApp::ui` runs its own per-frame logic first (fonts, theme), like the desktop app.
// eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep
// autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it.
/// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs
/// still advance autosave, live and control deadlines without painting. `FigApp::ui` does
/// not repeat it.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx));
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.fig.ui(ui);
}
@@ -117,7 +125,7 @@ impl Services for WebServices {
}
fn open_url(&mut self, url: &str) {
let Some(window) = web_sys::window() else { return };
if let Err(e) = window.open_with_url_and_target(url, "_blank") {
if let Err(e) = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer") {
log::error!("couldn't open {url}: {e:?}");
}
}
+2 -1
View File
@@ -21,7 +21,7 @@ figcraft-text = { workspace = true }
# 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"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
serde_json = { workspace = true }
log = { workspace = true }
rfd = { workspace = true }
@@ -33,6 +33,7 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col
# The system's reduce-motion setting (UISettings.AnimationsEnabled), interface language
# (GlobalizationPreferences.Languages) and controllers for prototypes (Gaming.Input.Gamepad).
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] }
# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a
+1
View File
@@ -1,6 +1,7 @@
//! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack
//! (no-op elsewhere).
fn main() {
println!("cargo::rustc-check-cfg=cfg(windows_slim_errors)");
println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico");
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
if target_os == "windows" {
+39 -6
View File
@@ -6,7 +6,7 @@ use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use figcraft_ui_egui::ControlRequest;
use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState};
use serde_json::{Value, json};
type Queue = Arc<Mutex<Vec<ControlRequest>>>;
@@ -21,6 +21,34 @@ const TIMEOUT: Duration = Duration::from_secs(60);
/// core busy until it recovers.
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
/// Why a request's transport wait ended without a control reply.
#[derive(Debug, PartialEq, Eq)]
enum WaitFailure {
Timeout,
/// The UI side dropped its sender before replying (the app is closing).
Disconnected,
}
/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses.
/// A commit that got the gate first has already queued its reply: consume that reply instead of
/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing.
fn wait_for_reply(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate, timeout: Duration) -> Result<ControlReply, WaitFailure> {
match rx.recv_timeout(timeout) {
Ok(reply) => Ok(reply),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate),
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected),
}
}
/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically
/// against a gate whose commit already happened.
fn after_timeout(rx: &std::sync::mpsc::Receiver<ControlReply>, gate: &RequestGate) -> Result<ControlReply, WaitFailure> {
match gate.cancel_if_open() {
RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected),
RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout),
}
}
/// Deferred requests for a hidden window must not make the UI's queue grow without a bound.
pub fn drain_queue(queue: &Queue, target: &mut Vec<ControlRequest>) {
target.retain(|request| !request.is_cancelled());
@@ -69,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut
}
}
/// Serve one control connection: read bounded JSON lines, reject oversized frames and a
/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input
/// answers with an error and closes the connection so HTTP body lines cannot become commands.
fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> {
out.set_read_timeout(Some(timeout))?;
out.set_write_timeout(Some(timeout))?;
@@ -96,7 +127,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = v.get("params").cloned().unwrap_or(json!({}));
let (tx, rx) = std::sync::mpsc::channel();
let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
let gate = RequestGate::new();
let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
pending.retain(|request| !request.is_cancelled());
if pending.len() >= MAX_QUEUED_REQUESTS {
@@ -104,20 +135,22 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s
writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?;
continue;
}
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() });
pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() });
drop(pending);
if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() {
c.request_repaint();
}
match rx.recv_timeout(timeout) {
match wait_for_reply(&rx, &gate, timeout) {
Ok(r) => match r.result {
Ok(res) => json!({"id": r.id, "ok": true, "result": res}),
Err(e) => json!({"id": r.id, "ok": false, "error": e}),
},
Err(_) => {
cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
Err(WaitFailure::Timeout) => {
json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"})
}
Err(WaitFailure::Disconnected) => {
json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"})
}
}
}
Err(e) => {
+52 -2
View File
@@ -28,8 +28,9 @@ fn reply(reader: &mut BufReader<TcpStream>) -> Value {
serde_json::from_str(&line).unwrap()
}
/// Build a `ui.inspect` control request with a throwaway reply channel.
fn request() -> ControlRequest {
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() }
ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() }
}
#[test]
@@ -115,6 +116,52 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() {
worker.join().unwrap().unwrap();
}
/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's
/// timeout branch consumes that reply and leaves the state `Replied`.
#[test]
fn a_committed_reply_wins_over_the_transport_timeout() {
let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id()));
let path = dir.join("shot.png");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel();
// The UI's final commit gets the gate first: the file and reply are both committed before the
// transport's timeout branch can look at the gate.
let committed = gate.commit(|| {
std::fs::write(&path, b"png").unwrap();
tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap();
});
assert!(committed.is_some());
assert!(path.is_file());
let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed");
assert_eq!(reply.id, json!(7));
assert!(reply.result.is_ok());
assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit");
assert!(path.is_file(), "a winning commit keeps its file");
let _ = std::fs::remove_dir_all(&dir);
}
/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate.
#[test]
fn a_timeout_cancels_an_open_request() {
let gate = RequestGate::new();
let (_tx, rx) = mpsc::channel::<ControlReply>();
assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout)));
assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout");
}
/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not
/// cancelled.
#[test]
fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() {
let gate = RequestGate::new();
let (tx, rx) = mpsc::channel::<ControlReply>();
drop(tx);
assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected)));
assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision");
}
#[test]
fn idle_and_partial_frame_connections_expire() {
for bytes in [b"".as_slice(), b"{\"id\":"] {
@@ -227,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() {
worker.join().unwrap().unwrap();
}
/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted
/// and answered, and a window that finally draws discards its expired command before
/// dispatch.
#[test]
fn timed_out_requests_release_a_full_deferred_ui_queue() {
use figcraft_ui_egui::{FigApp, NoServices, control, windows};
@@ -262,7 +312,7 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() {
// Include expired requests in the socket queue, too: admission must reclaim their slots.
pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| {
let r = request();
r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed);
r.state.cancel_if_open();
r
}));
let (mut client, server) = pair();
+25 -3
View File
@@ -15,6 +15,11 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
// A missing Windows GNU flag re-enables the COM error-info lookup that crashes while AccessKit
// reports a removed UI Automation node. Catch build configurations that drop the workaround.
#[cfg(all(target_os = "windows", target_env = "gnu", not(windows_slim_errors)))]
compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Automation responsive");
#[cfg(target_os = "macos")]
mod apple_events;
mod args;
@@ -62,6 +67,9 @@ struct App {
}
impl eframe::App for App {
/// Native eframe logic callback: store the first context, drain control requests, run
/// guarded background work, poll macOS open events, handle close requests and title
/// changes, and schedule recovery repaints on the app clock.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
{
let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
@@ -71,7 +79,9 @@ impl eframe::App for App {
}
let title = with_app(|fig| {
control_server::drain_queue(&self.queue, &mut fig.control_queue);
figcraft_ui_egui::control::drain_hidden(fig, ctx);
// Phase-safe background work: control queues, autosave/recovery deadlines, live polling.
// It runs on logic-only callbacks too, so it never reads UI input.
figcraft_ui_egui::windows::guard(fig, ctx, |fig| fig.background_tick(ctx));
#[cfg(target_os = "macos")]
apple_events::poll(&self.apple_events, fig, ctx);
// Closing the window asks about unsaved changes (and keeps the app running while other
@@ -86,8 +96,14 @@ impl eframe::App for App {
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
self.title = title;
}
let now = ctx.input(|i| i.time);
if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() {
// Recovery deadlines use the same monotonic clock as the app's background work: a
// logic-only tick leaves `ctx.input(i.time)` stale.
if let Some(wait) = with_app(|fig| {
let now = fig.now_secs();
recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))
})
.flatten()
{
ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
}
}
@@ -117,6 +133,11 @@ impl eframe::App for App {
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FIGCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
let args: Vec<String> = std::env::args().skip(1).collect();
if args.iter().any(|a| a == "--version" || a == "-V") {
@@ -199,6 +220,7 @@ fn main() -> eframe::Result {
// open (no frame then).
figcraft_ui_egui::i18n::set(&session.prefs.language, figcraft_ui_egui::Services::system_language(&svc).as_deref());
let mut fig = FigApp::new(session, Box::new(svc));
fig.set_startup_update_check(true);
fig.automation_roots = roots;
fig.ui.home = false;
// Files named on the command line are the user's too: their missing fonts are listed.
+26 -4
View File
@@ -206,10 +206,12 @@ impl Recovery {
let doc = d.doc.clone();
let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid));
// Which online file it is and what of the server's this very copy has (a line each:
// the server, the file's id, then the session, client id and last change), while it
// the server, the file's id, then the session, client id, last change and the secret the
// client id resumes with), while it
// is one. Written after the copy, never before: a note must not promise more of the
// server's changes than its copy holds.
let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq));
let online =
s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret));
let job = move || {
if !write_atomic(&path, &doc, &versions, &branches) {
return false;
@@ -312,7 +314,10 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option<u64> {
let (server, id, at) = (lines.next()?, lines.next()?, lines.next()?);
let mut at = at.split(' ').map(|n| n.parse::<u64>().ok());
let (session, client, seq) = (at.next()??, at.next()??, at.next()??);
let params = json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq});
// Absent in notes written before ids had secrets.
let secret = at.next().flatten().unwrap_or(0);
let params =
json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq, "secret": secret});
s.execute("collab.recoverFile", &params).ok()?.get("session")?.as_u64()
}
@@ -338,7 +343,24 @@ fn stamp(s: &Session, d: &DocState) -> Vec<u64> {
fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool {
let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false };
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok()
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true).mode(0o600);
let Ok(mut f) = opts.open(&tmp) else { return false };
if std::io::Write::write_all(&mut f, &bytes).is_err() {
let _ = std::fs::remove_file(&tmp);
return false;
}
}
#[cfg(not(unix))]
{
if std::fs::write(&tmp, bytes).is_err() {
return false;
}
}
std::fs::rename(&tmp, path).is_ok()
}
/// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as
+1 -1
View File
@@ -193,7 +193,7 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() {
let left = crash(&s, &root);
let note = std::fs::read_to_string(left.join(format!("{}.online", s.docs[0].uid))).unwrap();
let at = s.online_of(s.docs[0].uid).unwrap().2;
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {}", at.session, at.client_id, at.seq), "which file, who the app was, what it had");
assert_eq!(note, format!("{SERVER}\nf1\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret), "which file, who the app was, what it had");
assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret");
let (mut next, script, mut r, opened) = restart(&s, &root);
assert_eq!(opened, 0, "nothing comes back as a plain copy");
+54 -6
View File
@@ -2,8 +2,8 @@
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use figcraft_engine::{Prefs, Session};
use figcraft_ui_egui::{DialogFuture, FigApp, Services};
@@ -20,6 +20,8 @@ pub struct Desktop {
language: Option<String>,
/// Recovery copies of unsaved files (written at once after a panic).
recovery: crate::recovery::Shared,
/// The folder of the last file or folder a dialog picked, where the next one starts.
last_folder: Arc<Mutex<Option<PathBuf>>>,
}
pub fn config_dir() -> Option<PathBuf> {
@@ -54,7 +56,15 @@ impl Desktop {
let reduce_motion = Arc::new(AtomicBool::new(false));
let flag = reduce_motion.clone();
std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed));
Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery }
Self {
clipboard: arboard::Clipboard::new().ok(),
dir,
prefs,
reduce_motion,
language: system_language(),
recovery,
last_folder: Arc::default(),
}
}
}
@@ -262,19 +272,57 @@ fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> {
}
}
/// The folder to start the next dialog in once `path` was picked: the file's folder, or the folder
/// picked itself.
pub(crate) fn folder_to_remember(path: &Path, is_folder: bool) -> Option<PathBuf> {
let folder = if is_folder { Some(path) } else { path.parent() };
folder.filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf)
}
/// The remembered folder, unless it is gone since: then the system's own choice.
pub(crate) fn start_folder(last: Option<PathBuf>) -> Option<PathBuf> {
last.filter(|f| f.is_dir())
}
impl Desktop {
/// A dialog that starts in the last folder used. The Linux portal does not remember it between
/// dialogs as macOS and Windows do.
fn dialog_in_last_folder(&self, title: &str) -> rfd::AsyncFileDialog {
let dialog = dialog(title);
match start_folder(self.last_folder.lock().ok().and_then(|l| l.clone())) {
Some(folder) => dialog.set_directory(folder),
None => dialog,
}
}
}
/// `pick`, with the folder of what it picks kept in `last` for the next dialog.
pub(crate) fn remembering(last: Arc<Mutex<Option<PathBuf>>>, pick: DialogFuture, is_folder: bool) -> DialogFuture {
Box::pin(async move {
let path = pick.await?;
if let (Some(folder), Ok(mut last)) = (folder_to_remember(&path, is_folder), last.lock()) {
*last = Some(folder);
}
Some(path)
})
}
impl Services for Desktop {
fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture {
picked(dialog(title).add_filter("Supported files", exts).pick_file())
let pick = picked(self.dialog_in_last_folder(title).add_filter("Supported files", exts).pick_file());
remembering(self.last_folder.clone(), pick, false)
}
fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture {
let mut dialog = dialog(title).set_file_name(name);
let mut dialog = self.dialog_in_last_folder(title).set_file_name(name);
for (label, extensions) in save_filters(exts) {
dialog = dialog.add_filter(label, extensions);
}
picked(dialog.save_file())
let pick = picked(dialog.save_file());
remembering(self.last_folder.clone(), pick, false)
}
fn pick_folder(&mut self, title: &str) -> DialogFuture {
picked(dialog(title).pick_folder())
let pick = picked(self.dialog_in_last_folder(title).pick_folder());
remembering(self.last_folder.clone(), pick, true)
}
fn clipboard_set_text(&mut self, s: &str) {
if let Some(c) = self.clipboard.as_mut() {
+25
View File
@@ -117,3 +117,28 @@ fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
let _ = std::fs::remove_dir_all(&dir);
}
/// #1290: the Linux portal opens every dialog where it likes, so the app keeps the folder itself.
#[test]
fn the_next_dialog_starts_in_the_folder_of_the_last_pick() {
use crate::services::{folder_to_remember, remembering, start_folder};
use std::path::PathBuf;
let dir = root("last-folder");
let file = dir.join("Poster.figcraft");
assert_eq!(folder_to_remember(&file, false), Some(dir.clone()), "a file: its folder");
assert_eq!(folder_to_remember(&dir, true), Some(dir.clone()), "a folder pick: the folder itself");
assert_eq!(folder_to_remember(std::path::Path::new("Poster.figcraft"), false), None, "a bare name has no folder");
let last = Arc::new(Mutex::new(None::<PathBuf>));
let mut pick = remembering(last.clone(), Box::pin(std::future::ready(Some(file.clone()))), false);
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
assert_eq!(pick.as_mut().poll(&mut cx), std::task::Poll::Ready(Some(file)));
assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a pick is remembered");
let mut cancelled = remembering(last.clone(), Box::pin(std::future::ready(None)), false);
assert_eq!(cancelled.as_mut().poll(&mut cx), std::task::Poll::Ready(None));
assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a cancelled dialog keeps the folder");
assert_eq!(start_folder(Some(dir.clone())), Some(dir.clone()));
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own");
}
+4
View File
@@ -76,3 +76,7 @@ sync_github_name = true
real_name = "Gert Pellin"
display_name = "Gert Pellin"
sync_github_name = false
[people.dotCooCoo]
real_name = "Robert Lee"
sync_github_name = true
+8 -2
View File
@@ -87,7 +87,10 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() {
let mut late = session("Cy");
late.execute("collab.join", &json!({"code": shown})).unwrap();
pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. }));
assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status);
// The port is free again: another program's host may have taken it, and that host refuses
// the code's secret.
let label = late.live().status.label();
assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}");
}
#[test]
@@ -103,7 +106,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() {
assert!(host.live().peers.is_empty());
// A typing mistake is caught before anything connects.
let mut typo: Vec<char> = local.code().chars().collect();
typo[7] = if typo[7] == 'A' { 'B' } else { 'A' };
// The last symbol contains only checksum bits. Changing payload bits can collide with
// the eight-bit checksum, so it does not guarantee a malformed code.
let last = typo.last_mut().unwrap();
*last = if *last == 'A' { 'B' } else { 'A' };
let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::<String>()})).unwrap_err().to_string();
assert!(e.contains("typing mistake"), "{e}");
// A view-only code doesn't exist here, and says where it does.
+2 -2
View File
@@ -189,9 +189,9 @@ pub(crate) enum Taken {
/// in answer must go out on this connection.
pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken {
match m {
ServerMsg::Welcome { session, client_id, seq, ref peers, access, .. } => {
ServerMsg::Welcome { session, client_id, seq, ref peers, access, secret, .. } => {
if let Ok(mut r) = resume.lock() {
*r = Some(Resume { session, client_id, seq });
*r = Some(Resume { session, client_id, seq, secret });
}
// The sync client opens the document (and fills in its name and pages).
let peers = peers.clone();
+40 -15
View File
@@ -488,6 +488,21 @@ fn silent_connections_time_out() {
host.stop();
}
#[test]
fn connections_without_a_hello_are_told_why_they_end() {
// A socket's read timeout can end a moment before the hello's deadline; the connection still
// gets the refusal. Twelve at once, so that happens to some of them.
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.server.host.parse().unwrap();
std::thread::scope(|s| {
let waits: Vec<_> = (0..12).map(|_| s.spawn(move || Raw::connect(addr).refusal())).collect();
for w in waits {
assert_eq!(w.join().unwrap().as_deref(), Some("no hello in time"));
}
});
host.stop();
}
#[test]
fn guests_reconnect_after_a_network_failure() {
let (mut host, mut hp, invite) = start();
@@ -540,7 +555,9 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() {
let (mut host, mut hp, invite) = start_with(limits, Limits::default());
let probe = Probe::new();
let mut cfg = quick(GuestConfig::new(invite.clone(), "Ada".into()));
cfg.limits = limits;
// The guest pings as often as the host expects. Its own idle timeout stays long: it also bounds
// the wait for the welcome, which a loaded machine can take longer than 600 ms to send.
cfg.limits.keepalive = limits.keepalive;
let _g = Guest::start(cfg, probe.inbox.clone()).unwrap();
let mut gp = probe;
gp.wait("welcome", welcomed);
@@ -672,34 +689,42 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
}
/// How long a connection that never says hello stays open while `poke` sends it something every
/// 300 ms (told whether it's the first time); `None`: still open after 6 s.
/// 300 ms (told whether it's the first time); `None`: still open after 6 s. A thread of its own
/// reads the socket and notes when the host ends the stream, so pongs queued ahead of the close
/// and a slow poke loop don't delay it.
fn pre_hello_lifetime(mut poke: impl FnMut(&mut Raw, bool) -> bool) -> Option<Duration> {
let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default());
let addr: SocketAddr = invite.server.host.parse().unwrap();
let mut r = Raw::connect(addr);
let mut watch = r.0.get_ref().try_clone().unwrap();
let t0 = Instant::now();
let (ended_tx, ended) = std::sync::mpsc::channel();
let reader = std::thread::spawn(move || {
let mut buf = [0u8; 1024];
let until = Instant::now() + WAIT;
loop {
match watch.read(&mut buf) {
Ok(0) => break,
Ok(_) => {}
Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) && Instant::now() < until => {}
Err(_) => break,
}
}
let _ = ended_tx.send(Instant::now());
});
let (mut closed, mut first) = (None, true);
while t0.elapsed() < Duration::from_secs(6) {
if !poke(&mut r, std::mem::take(&mut first)) {
closed = Some(t0.elapsed());
break;
}
std::thread::sleep(Duration::from_millis(300));
// Drain what the host sent back (pongs, the close); an error means it hung up.
r.0.get_mut().set_read_timeout(Some(Duration::from_millis(10))).unwrap();
match r.0.read() {
Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => {
closed = Some(t0.elapsed());
break;
}
Err(tungstenite::Error::Io(e)) if !matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => {
closed = Some(t0.elapsed());
break;
}
_ => {}
if let Ok(at) = ended.recv_timeout(Duration::from_millis(300)) {
closed = Some(at.saturating_duration_since(t0));
break;
}
}
host.stop();
let _ = reader.join();
closed
}
+18 -10
View File
@@ -195,17 +195,25 @@ impl Duplex {
impl Read for Duplex {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
if let Some(d) = self.deadline {
let left = d.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(io::ErrorKind::TimedOut.into());
let n = loop {
if let Some(d) = self.deadline {
let left = d.saturating_duration_since(Instant::now());
if left.is_zero() {
return Err(io::ErrorKind::TimedOut.into());
}
self.read.set_read_timeout(Some(left))?;
}
self.read.set_read_timeout(Some(left))?;
}
let n = match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
}?;
let read = match &self.tls {
Some(t) => tls::read(t, &mut self.read, &self.write, buf),
None => self.read.read(buf),
};
match read {
// The socket's timeout can run out a moment before the deadline (it counts whole
// milliseconds): wait out the rest, so a read times out only once it has passed.
Err(e) if self.deadline.is_some() && matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {}
read => break read?,
}
};
if let Some(m) = self.meter.as_mut() {
m.add(n)?;
}
+10 -1
View File
@@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 {
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap()
}
/// Asserts two files are hosted and joined independently: each has its own port, session and
/// edits, presence stays in its file's session, and closing one file's session leaves the
/// other live.
#[test]
fn two_files_are_hosted_and_joined_independently() {
let mut host = session("Hana");
@@ -123,8 +126,14 @@ fn two_files_are_hosted_and_joined_independently() {
host.execute("doc.switch", &json!({"index": 0})).unwrap();
assert_eq!(host.live_summary()["link"], a["link"]);
assert_eq!(host.live_summary()["document"], 0);
// Each joined file opens in front when its welcome arrives, and the two joins race: the file
// whose welcome came last is in front.
assert_eq!(guest.ds().unwrap().uid, guest.docs[2].uid, "the file that arrived last is in front");
// Concurrent joins may complete in either order. Put B in front explicitly so the following
// assertions exercise A's background session independent of network timing.
guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap();
assert_eq!(guest.ds().unwrap().uid, gb, "B is in front while A receives edits");
// An edit in A reaches the guest's copy of A only, while B is in front at the guest.
assert_eq!(guest.ds().unwrap().uid, gb, "the file joined last is in front");
let in_a = rect(&mut host);
host.execute("doc.switch", &json!({"index": 1})).unwrap();
let in_b = rect(&mut host);
+1
View File
@@ -181,6 +181,7 @@ node_props! {
Annotations => annotations,
CodeLinks => code_links,
DevResources => dev_resources,
Measurements => measurements,
Board => board,
}
+10 -5
View File
@@ -41,6 +41,8 @@ pub struct Client {
/// The session joined (see [`ServerMsg::Welcome`]).
session: u64,
id: u32,
/// What this client resumes its id with (see [`Resume::secret`]).
secret: u64,
/// The server's state as of `seq`.
confirmed: Replica,
seq: u64,
@@ -84,7 +86,7 @@ impl Client {
/// Start from the server's `Welcome` (which must carry a snapshot).
pub fn join(welcome: ServerMsg) -> Result<Client, Error> {
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access } = welcome else {
let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access, secret } = welcome else {
return Err(Error::Protocol("expected a welcome with the document".into()));
};
let mut confirmed = Replica::from_snapshot(&snapshot)?;
@@ -93,6 +95,7 @@ impl Client {
Ok(Client {
session,
id: client_id,
secret,
view: confirmed.clone(),
confirmed,
seq,
@@ -415,9 +418,10 @@ impl Client {
ClientMsg::Hello(Hello { resume: Some(self.resume()), ..hello })
}
/// What a reconnect resumes with: this client's session, id and last sequence number.
/// What a reconnect resumes with: this client's session, id, last sequence number and the
/// secret its id was given with.
pub fn resume(&self) -> Resume {
Resume { session: self.session, client_id: self.id, seq: self.seq }
Resume { session: self.session, client_id: self.id, seq: self.seq, secret: self.secret }
}
/// Handle a message from the server. Returns messages to send: batches that were waiting for
@@ -458,10 +462,11 @@ impl Client {
Ok(Vec::new())
}
ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())),
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => {
ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret } => {
if session != self.session || client_id != self.id {
return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access });
return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret });
}
self.secret = secret;
self.access = access;
self.rejoin(seq, catch_up, &processed, peers, recompute)
}
+13 -1
View File
@@ -11,6 +11,16 @@ use crate::message::{
use crate::sim::Rng;
use crate::{Error, Replica, diff};
#[test]
fn encoding_into_a_prefix_keeps_the_wire_body() {
let msg = moved_layer();
let prefix = b"container header".to_vec();
let bytes = crate::message::encode_into(&msg, prefix.clone()).unwrap();
assert_eq!(&bytes[..prefix.len()], prefix);
assert_eq!(&bytes[prefix.len()..], encode(&msg).unwrap());
assert_eq!(decode::<ClientMsg>(&bytes[prefix.len()..], bytes.len()).unwrap(), msg);
}
/// A typical edit: one layer dragged (its transform).
fn moved_layer() -> ClientMsg {
let mut doc = Document::new("f");
@@ -38,7 +48,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
name: "Ada".into(),
color: "#FF8800".into(),
client_version: "0.2.0".into(),
resume: Some(Resume { session: 77, client_id: 4, seq: 10 }),
resume: Some(Resume { session: 77, client_id: 4, seq: 10, secret: 0x5EC2E7 }),
}),
moved_layer(),
cursor(),
@@ -102,6 +112,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
processed: Processed::default(),
peers: vec![peer.clone()],
access: Access::Edit,
secret: 0x5EC2E7,
},
ServerMsg::Welcome {
session: 77,
@@ -111,6 +122,7 @@ fn samples() -> (Vec<ClientMsg>, Vec<ServerMsg>) {
processed: Processed { last_batch: 7, refused: vec![3, 6] },
peers: vec![],
access: Access::View,
secret: 1,
},
ServerMsg::Changes(Accepted { seq: 11, author: 4, batch_id: 2, changes: vec![set.clone(), Change::Delete { id: NodeId(5) }] }),
ServerMsg::Reject { batch_id: 3, reason: "no".into(), fixes: vec![set] },
+3 -6
View File
@@ -132,18 +132,15 @@ impl<T: Keyed> List<T> {
let keys: Vec<T::Key> = value.read("order")?;
let list = (self.get_mut)(doc);
let mut slots: Vec<Option<T>> = std::mem::take(list).into_iter().map(Some).collect();
let mut at: BTreeMap<T::Key, Vec<usize>> = BTreeMap::new();
let mut at: BTreeMap<T::Key, std::collections::VecDeque<usize>> = BTreeMap::new();
for (i, e) in slots.iter().enumerate() {
if let Some(e) = e {
at.entry(e.key()).or_default().push(i);
at.entry(e.key()).or_default().push_back(i);
}
}
for k in keys {
let Some(idx) = at.get_mut(&k) else { continue };
if idx.is_empty() {
continue;
}
let i = idx.remove(0);
let Some(i) = idx.pop_front() else { continue };
if let Some(e) = slots.get_mut(i).and_then(Option::take) {
list.push(e);
}
+7 -5
View File
@@ -6,11 +6,12 @@
//! values, or a layer deleted on one side while the other side edited it or something inside it
//! (including adding layers to it or moving layers into or out of it). The layer lists whose items
//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype
//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides
//! changing different items of the list (or one adding items at its end) is no conflict, only the
//! same item changed both ways or the list shortened on a side while the other changed it. Nothing
//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the
//! other side's changes to it are left out. Everything else from both sides is kept.
//! interactions, accessibility annotations, Dev Mode resources and measurements) merge item by
//! item: both sides changing different items of the list (or one adding items at its end) is no
//! conflict, only the same item changed both ways or the list shortened on a side while the other
//! changed it. Nothing that conflicts is merged until it is resolved: each conflict is resolved for
//! one side, and the other side's changes to it are left out. Everything else from both sides is
//! kept.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
@@ -176,6 +177,7 @@ const PER_ITEM: &[Prop] = &[
// Not `CodeLinks`: there is one per language, and lining them up by position could leave two
// for one language (the second hidden), so they merge whole.
Prop::DevResources,
Prop::Measurements,
];
/// Both sides' writes `b` and `m` of list property `key` merged item by item against the base
+15 -6
View File
@@ -94,6 +94,10 @@ pub struct Resume {
pub client_id: u32,
/// The last sequence number it applied.
pub seq: u64,
/// The secret the server gave with this client id ([`ServerMsg::Welcome`]'s `secret`): only
/// the client the id was given to can resume it (0: none).
#[serde(default)]
pub secret: u64,
}
/// Someone in the session.
@@ -635,7 +639,8 @@ pub enum ServerMsg {
/// Joined: the session's identity (a server that restarted without its saved state is a new
/// session), this connection's client id, the current sequence number, the document (or the
/// batches missed since a reconnecting client's last sequence number), what the server did
/// with this client's batches so far, who else is here and what this client may do.
/// with this client's batches so far, who else is here, what this client may do and the
/// secret it resumes its id with ([`Resume::secret`]; only this client is told).
Welcome {
session: u64,
client_id: u32,
@@ -644,6 +649,8 @@ pub enum ServerMsg {
processed: Processed,
peers: Vec<Peer>,
access: Access,
#[serde(default)]
secret: u64,
},
/// Accepted changes, in order, to everyone (the author's own come back as confirmation).
Changes(Accepted),
@@ -732,11 +739,13 @@ pub fn utc_date(secs: u64) -> String {
/// The binary form of `msg`: [`WIRE_VERSION`], then postcard.
pub fn encode<T: Serialize>(msg: &T) -> Result<Vec<u8>, Error> {
let body = postcard::to_allocvec(msg).map_err(|e| Error::Encode(e.to_string()))?;
let mut out = Vec::with_capacity(body.len() + 1);
out.push(WIRE_VERSION);
out.extend(body);
Ok(out)
encode_into(msg, Vec::new())
}
/// Append the binary form of `msg` to an owned prefix, without a second payload-sized buffer.
pub fn encode_into<T: Serialize>(msg: &T, mut prefix: Vec<u8>) -> Result<Vec<u8>, Error> {
prefix.push(WIRE_VERSION);
postcard::to_extend(msg, prefix).map_err(|e| Error::Encode(e.to_string()))
}
/// Read a binary message of at most `max_bytes` (untrusted input: never panics, allocates at most
+19
View File
@@ -253,3 +253,22 @@ fn random_drops_and_reconnects_converge() {
}
assert!(snapshots > 0, "no reconnect needed a snapshot");
}
#[test]
fn resume_needs_the_issued_secret() {
let mut s = Server::new(base(), "tok", Limits { max_peers: 2, ..Limits::default() });
let mut host = join(&mut s);
let guest = join(&mut s);
// A participant who knows the (public) session and the host's id can't take that id over,
// whether the session is full or not, and with view-only access or not.
let forged = message::Resume { client_id: host.id(), ..guest.resume() };
assert_eq!(forged.session, host.resume().session);
let refused = s.join_as(Hello { resume: Some(forged), ..hello() }, message::Access::View);
assert!(matches!(refused, Err(ServerMsg::Refused { .. })), "a forged resume of a live id is refused: {refused:?}");
s.leave(guest.id());
let (id, _) = s.join_as(Hello { resume: Some(forged), ..hello() }, message::Access::View).unwrap();
assert_ne!(id, host.id(), "a forged resume joins as new");
// The real client still comes back as itself.
let resend = come_back(&mut s, &mut host);
assert!(resend.is_empty());
}
+27 -7
View File
@@ -7,7 +7,7 @@ use figcraft_doc::{Document, NodeId};
use serde::{Deserialize, Serialize};
use crate::change::{Change, Entry, Prop, Target};
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg, Signal, reads_signals};
use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_signals};
use crate::replica::{Mode, Replica, create_change};
use crate::{Error, Limits, docprops, same_secret};
@@ -61,6 +61,9 @@ pub struct Server {
/// Every client id handed out (a reconnecting client keeps its id) and what was done with
/// its batches.
issued: BTreeMap<u32, Processed>,
/// The secret each client id resumes with (sent only in that client's welcome). Not saved:
/// after a restart, the first to come back for an id that isn't connected sets it.
secrets: BTreeMap<u32, u64>,
/// The latest accepted batches, oldest first (a reconnecting client gets what it missed).
log: VecDeque<Accepted>,
/// Changes in `log`, and about how many bytes it holds ([`weight`]).
@@ -116,6 +119,7 @@ impl Server {
deleted: BTreeSet::new(),
removed: BTreeSet::new(),
issued: BTreeMap::new(),
secrets: BTreeMap::new(),
log: VecDeque::new(),
log_changes: 0,
log_bytes: 0,
@@ -219,6 +223,7 @@ impl Server {
&& let Some(old) = self.issued.keys().copied().find(|k| !self.peers.contains_key(k))
{
self.issued.remove(&old);
self.secrets.remove(&old);
}
}
@@ -266,7 +271,7 @@ impl Server {
if !same_secret(&hello.token, &self.token) {
return Err(refuse("wrong session token"));
}
let resume = hello.resume.filter(|r| r.session == self.session && self.issued.contains_key(&r.client_id));
let resume = hello.resume.filter(|r| r.session == self.session && self.issued.contains_key(&r.client_id) && self.may_resume(r));
if self.peers.len() >= self.limits.max_peers && resume.is_none_or(|r| !self.peers.contains_key(&r.client_id)) {
return Err(refuse("the session is full"));
}
@@ -282,15 +287,17 @@ impl Server {
Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()),
_ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?),
};
let id = match resume {
Some(r) => r.client_id,
let (id, secret) = match resume {
Some(r) => (r.client_id, *self.secrets.entry(r.client_id).or_insert(r.secret)),
None => {
let id = self.next_client;
self.next_client += 1;
self.issued.insert(id, Processed::default());
let secret = new_session_id().max(1);
self.secrets.insert(id, secret);
self.forget_oldest();
self.note(Journal::Issued { client: id });
id
(id, secret)
}
};
let processed = self.issued.get(&id).cloned().unwrap_or_default();
@@ -298,7 +305,7 @@ impl Server {
let signals = reads_signals(&hello.client_version);
let peer = Peer { client_id: id, name: hello.name, color: hello.color };
let others = self.peers.values().filter(|p| p.client_id != id).cloned().collect();
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access };
let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access, secret };
self.peers.insert(id, peer.clone());
if access == Access::View {
self.viewers.insert(id);
@@ -318,6 +325,16 @@ impl Server {
Ok((id, outs))
}
/// Whether `r` may take its client id back: it carries the secret that id was given with, or
/// (after a restart, which forgets secrets) a secret for an id nobody is connected as.
fn may_resume(&self, r: &Resume) -> bool {
r.secret != 0
&& match self.secrets.get(&r.client_id) {
Some(s) => *s == r.secret,
None => !self.peers.contains_key(&r.client_id),
}
}
/// Client `id`'s connection closed.
pub fn leave(&mut self, id: u32) -> Vec<Outgoing> {
self.presence.remove(&id);
@@ -541,11 +558,14 @@ impl Server {
nodes.extend(t.write(self.state.value(&t)?.as_ref()));
}
}
let mut restored = std::collections::BTreeSet::new();
for id in gone {
match doc.node(id) {
Some(_) => {
for d in doc.subtree(id) {
if let Some(n) = doc.node(d) {
if restored.insert(d)
&& let Some(n) = doc.node(d)
{
nodes.push(create_change(n, self.state.key(d))?);
}
}
+3 -2
View File
@@ -122,7 +122,8 @@ impl Color {
/// Parse hex the way the colour field does: `#` optional, 1, 2, 3, 4, 6 or 8 digits
/// (`F` → `FFFFFF`, `AB` → `ABABAB`, `ABC` → `AABBCC`).
pub fn from_hex(s: &str) -> Option<Self> {
let s = s.trim().trim_start_matches('#');
let s = s.trim();
let s = s.strip_prefix('#').unwrap_or(s);
if !s.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
@@ -355,7 +356,7 @@ impl ImageFilters {
}
/// The built-in adjustment presets: `(id, label, adjustments)`. "Reset" is the identity.
pub const PRESETS: [(&'static str, &'static str, ImageFilters); 7] = [
pub const PRESETS: [(&str, &str, ImageFilters); 7] = [
("vivid", "Vivid", ImageFilters { contrast: 0.2, saturation: 0.45, highlights: -0.1, shadows: 0.1, ..ImageFilters::NONE }),
("muted", "Muted", ImageFilters { contrast: -0.15, saturation: -0.45, ..ImageFilters::NONE }),
("warm", "Warm", ImageFilters { temperature: 0.5, tint: 0.1, saturation: 0.1, ..ImageFilters::NONE }),
+9
View File
@@ -115,6 +115,15 @@ fn infinite_channels_clamp() {
assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0));
}
#[test]
fn hex_allows_at_most_one_leading_hash() {
assert!(Color::from_hex("##ff0000").is_none());
assert!(Color::from_hex("###fff").is_none());
assert!(Color::from_hex_or_name("##ff0000").is_none());
assert_eq!(Color::from_hex("#ff0000").unwrap().to_hex(), "FF0000");
assert_eq!(Color::from_hex("ff0000").unwrap().to_hex(), "FF0000");
}
#[test]
fn malformed_alpha_or_extra_args_rejected() {
assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None);
+3
View File
@@ -35,6 +35,9 @@ impl Effect {
impl TextStyle {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
self.fills.iter_mut().flatten().for_each(|p| p.for_each_color_mut(f));
if let Some(c) = self.decoration_look.as_deref_mut().and_then(|l| l.color.as_mut()) {
f(c);
}
}
}
+8
View File
@@ -134,3 +134,11 @@ fn profile_and_presets_serialize_only_when_set() {
assert_eq!(back.color_profile, ColorProfile::DisplayP3);
assert_eq!(back.image_presets, d.image_presets);
}
#[test]
fn text_decoration_colour_is_visited() {
let mut s = TextStyle::default();
s.set_look(|l| l.color = Some(RED));
s.for_each_color_mut(&mut |c| *c = Color::BLACK);
assert_eq!(s.look().color, Some(Color::BLACK), "decoration colour not visited");
}
+5 -3
View File
@@ -17,8 +17,10 @@ pub fn shape_path(n: &Node) -> Option<BezPath> {
| NodeKind::Section { .. } => figcraft_geom::rounded_rect(w, h, c.radii, c.smoothing),
NodeKind::Slice => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0),
NodeKind::Ellipse { start, sweep, inner } => figcraft_geom::ellipse(w, h, *start, *sweep, *inner),
NodeKind::Polygon { count } => figcraft_geom::rounded_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0]),
NodeKind::Star { count, inner_ratio } => figcraft_geom::rounded_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0]),
NodeKind::Polygon { count } => figcraft_geom::smoothed_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0], c.smoothing),
NodeKind::Star { count, inner_ratio } => {
figcraft_geom::smoothed_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0], c.smoothing)
}
NodeKind::Line => {
let mut p = BezPath::new();
p.move_to((0.0, 0.0));
@@ -126,7 +128,7 @@ pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolR
/// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask
/// below it, a hidden one masks nothing) doesn't cover the point, so nothing of `c` shows there.
fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool {
pub fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool {
if !doc.has_masks() {
return false;
}
+30
View File
@@ -71,8 +71,38 @@ impl Document {
if let (Some(now), Some(r)) = (self.node(root), d.nodes.get_mut(&root)) {
Arc::make_mut(r).parent = now.parent;
}
// A damaged file can give a historical layer itself (or an ancestor) as a child; a copy
// with a cycle under the root would send every walk of it round forever.
if !d.is_tree_under(root) {
return None;
}
d.dev_snapshots.clear();
d.dirty = crate::Dirty::default();
Some(d)
}
/// Whether every layer under `root` is reached once (no layer is its own descendant or listed
/// by two parents), walking iteratively and stopping at the first repeat.
fn is_tree_under(&self, root: NodeId) -> bool {
let mut seen = BTreeSet::new();
let mut stack = vec![root];
while let Some(id) = stack.pop() {
if !seen.insert(id) {
return false;
}
let Some(n) = self.nodes.get(&id) else {
return false;
};
for &c in &n.children {
let Some(child) = self.nodes.get(&c) else {
return false;
};
if child.parent != Some(id) {
return false;
}
stack.push(c);
}
}
true
}
}
+51
View File
@@ -34,3 +34,54 @@ fn a_snapshot_shows_the_layer_as_it_was() {
doc.dev_snapshots.clear();
assert!(serde_json::to_value(&doc).unwrap().get("dev_snapshots").is_none());
}
#[test]
fn a_snapshot_with_a_layer_inside_itself_is_refused() {
let mut doc = Document::new("d");
let page = doc.first_page().unwrap();
let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap();
let mut snap = doc.snapshot_subtree(f).unwrap();
// A damaged file: the historical frame lists itself as a child.
let mut bad = (*snap.nodes[0]).clone();
bad.children.push(f);
snap.nodes[0] = Arc::new(bad);
doc.dev_snapshots.insert(f, Arc::new(snap));
assert!(doc.with_dev_snapshot(f).is_none());
}
#[test]
fn a_snapshot_with_missing_or_nonreciprocal_child_is_refused() {
use crate::NodeId;
let mut doc = Document::new("d");
let page = doc.first_page().unwrap();
let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap();
let _c1 = doc.create(f, NodeKind::Rectangle, "R1", 0.0, 0.0, 10.0, 10.0).unwrap();
let snap = doc.snapshot_subtree(f).unwrap();
let mut missing = snap.clone();
let mut bad_root = (*missing.nodes[0]).clone();
bad_root.children.push(NodeId(99999));
missing.nodes[0] = Arc::new(bad_root);
doc.dev_snapshots.insert(f, Arc::new(missing));
assert!(doc.with_dev_snapshot(f).is_none());
let mut nonrecip = snap.clone();
let mut bad_child = (*nonrecip.nodes[1]).clone();
bad_child.parent = Some(NodeId(88888));
nonrecip.nodes[1] = Arc::new(bad_child);
doc.dev_snapshots.insert(f, Arc::new(nonrecip));
assert!(doc.with_dev_snapshot(f).is_none());
}
#[test]
fn validate_is_linear_in_a_wide_parent() {
let mut doc = Document::new("d");
let page = doc.first_page().unwrap();
for i in 0..5_000 {
doc.create(page, NodeKind::Rectangle, "R", f64::from(i), 0.0, 1.0, 1.0).unwrap();
}
assert!(doc.validate().is_empty());
let kid = doc.node(page).unwrap().children[0];
doc.get_mut(page).unwrap().children.retain(|c| *c != kid);
assert!(doc.validate().iter().any(|m| m.contains("not listed in parent")));
}
+12 -2
View File
@@ -856,6 +856,12 @@ impl Document {
{
*component = *m;
}
// A measurement copied with the layer it measures to measures to the copy; one to a
// layer left behind isn't copied.
c.measurements.retain_mut(|m| match m.to {
Some(t) => map.get(&t).map(|n| m.to = Some(*n)).is_some(),
None => true,
});
// A connector (or label) copied with what it joins joins the copies.
if let Some(b) = c.board.as_mut() {
b.remap(map);
@@ -1017,6 +1023,10 @@ impl Document {
}
}
let mut issues = Vec::new();
// Every (parent, child) edge and the page list, indexed once so each node's checks are
// lookups: a wide parent or a long page list doesn't make validation quadratic.
let edges: std::collections::BTreeSet<(NodeId, NodeId)> = self.nodes.iter().flat_map(|(p, n)| n.children.iter().map(|c| (*p, *c))).collect();
let page_ids: std::collections::BTreeSet<NodeId> = self.pages.iter().copied().collect();
for (id, n) in self.nodes.iter() {
if n.id != *id {
note(&mut issues, || format!("node key {id} has id {}", n.id));
@@ -1033,12 +1043,12 @@ impl Document {
}
}
if let Some(p) = n.parent
&& !self.node(p).is_some_and(|pn| pn.children.contains(id))
&& !(self.node(p).is_some() && edges.contains(&(p, *id)))
{
note(&mut issues, || format!("{id} not listed in parent {p}"));
}
// The library store's root is a page that is never listed.
let listed = self.pages.contains(id) || (n.is_page() && self.libraries.store == Some(*id));
let listed = page_ids.contains(id) || (n.is_page() && self.libraries.store == Some(*id));
if n.is_page() != listed {
note(&mut issues, || format!("page list mismatch for {id}"));
}
+20
View File
@@ -237,6 +237,17 @@ pub struct DevResource {
pub name: String,
}
/// A measurement kept on the canvas for developers, stored on the layer it starts from: to another
/// layer (`to`), or across one of the layer's own sides (`side`). Dev Mode draws it from the layers'
/// current bounds, so it follows them.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Measurement {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub to: Option<NodeId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub side: Option<crate::Side>,
}
/// A kind of annotation (Development, Accessibility…): its stable id, name and the colour its
/// callouts are drawn in.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -386,6 +397,11 @@ impl NodeKind {
self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. })
}
/// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it).
pub fn has_corner_smoothing(&self) -> bool {
self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. })
}
pub fn text(&self) -> Option<&TextData> {
match self {
NodeKind::Text(t) => Some(t),
@@ -518,6 +534,9 @@ pub struct Node {
/// Dev resources: links for developers (docs, code, tickets…), in the order they were added.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dev_resources: Vec<DevResource>,
/// Dev Mode measurements that start from this layer, in the order they were added.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub measurements: Vec<Measurement>,
/// On a board: what this object is (a sticky note, a connector, a stamp…).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub board: Option<crate::BoardItem>,
@@ -631,6 +650,7 @@ impl Node {
annotations: Vec::new(),
code_links: Vec::new(),
dev_resources: Vec::new(),
measurements: Vec::new(),
board: None,
}
}
+1 -1
View File
@@ -795,7 +795,7 @@ fn yes() -> bool {
impl Effect {
/// Effect types in the order the Design panel lists them: (label, type name).
pub const TYPES: [(&'static str, &'static str); 7] = [
pub const TYPES: [(&str, &str); 7] = [
("Drop shadow", "dropShadow"),
("Inner shadow", "innerShadow"),
("Layer blur", "layerBlur"),
+1 -1
View File
@@ -1003,7 +1003,7 @@ impl PrototypeSettings {
];
/// The device whose screen is [`custom_size`](Self::custom_size) (no body is drawn around it).
pub const CUSTOM_DEVICE: &'static str = "Custom size";
pub const CUSTOM_DEVICE: &str = "Custom size";
/// A new custom size.
pub const CUSTOM_DEFAULT: [f64; 2] = [1280.0, 720.0];
/// The largest side a custom size may have (points).
+67
View File
@@ -559,3 +559,70 @@ fn is_ancestor_holds_for_very_deep_chains() {
assert!(d.is_ancestor(root, leaf));
assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..))));
}
#[test]
fn text_insert_at_run_boundary_keeps_runs_disjoint() {
let mut t = TextData::new("0123456789");
t.restyle(0, 5, |s| s.font_size = 20.0);
t.restyle(5, 10, |s| s.font_size = 30.0);
t.replace_range(5, 5, "xx");
let runs: Vec<(usize, usize)> = t.runs.iter().map(|r| (r.start, r.end)).collect();
assert_eq!(runs, vec![(0, 7), (7, 12)]);
}
#[test]
fn many_small_vector_subpaths_build_and_stroke_without_rescans() {
// Many closed subpaths whose last point repeats the start (#F-332), a long open chain (#F-334)
// and a big rounded polygon (#F-333): each used to scan the whole network per step.
let mut p = figcraft_geom::kurbo::BezPath::new();
for i in 0..3_000 {
let x = f64::from(i) * 10.0;
p.move_to((x, 0.0));
p.line_to((x + 5.0, 0.0));
p.line_to((x + 5.0, 5.0));
p.line_to((x, 0.0));
p.close_path();
}
let net = VectorNetwork::from_path(&p);
assert_eq!(net.vertices.len(), 3 * 3_000);
assert_eq!(net.regions[0].loops.len(), 3_000);
assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len()));
assert_eq!(net.stroke_runs().len(), 3_000);
let pts: Vec<Point> = (0..20_000).map(|i| Point::new(f64::from(i), f64::from(i % 7))).collect();
let chain = VectorNetwork::from_points(&pts, false);
let runs = chain.stroke_runs();
assert_eq!(runs.len(), 1);
assert!(!runs[0].closed);
let ring: Vec<Point> = (0..4_000)
.map(|i| {
let t = f64::from(i) / 4_000.0 * std::f64::consts::TAU;
Point::new(1000.0 * t.cos(), 1000.0 * t.sin() * if i % 2 == 0 { 1.0 } else { 0.9 })
})
.collect();
let mut poly = VectorNetwork::from_points(&ring, true);
for v in &mut poly.vertices {
v.radius = 0.5;
}
let rounded = poly.rounded().unwrap();
assert!(rounded.regions[0].loops[0].len() > poly.segments.len(), "arcs are walked between the sides");
assert_eq!(rounded.regions[0].loops[0].len(), rounded.segments.len());
}
#[test]
fn two_segment_closed_loop_rounded_corners() {
let mut v0 = Vertex::new(0.0, 0.0);
v0.radius = 2.0;
let mut v1 = Vertex::new(10.0, 0.0);
v1.radius = 2.0;
let net = VectorNetwork {
vertices: vec![v0, v1],
segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }],
regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }],
};
let r = net.rounded().unwrap();
assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop");
let path = net.fill_path();
assert!(!path.elements().is_empty());
}
+8 -1
View File
@@ -579,7 +579,14 @@ impl TextData {
}
self.runs.sort_by_key(|r| r.start);
let mut merged: Vec<StyleRun> = Vec::with_capacity(self.runs.len());
for r in std::mem::take(&mut self.runs) {
for mut r in std::mem::take(&mut self.runs) {
// Runs never overlap: a later run starts where the previous one ends at the earliest.
if let Some(prev) = merged.last() {
r.start = r.start.max(prev.end);
if r.start >= r.end {
continue;
}
}
match merged.last_mut() {
Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end,
_ => merged.push(r),
+64 -30
View File
@@ -134,6 +134,16 @@ pub struct VectorNetwork {
pub regions: Vec<Region>,
}
/// Point the subpath's last segment (the only one ending at the fresh vertex `from`) at `to`,
/// so merging a duplicate endpoint doesn't rescan the segments of earlier subpaths.
fn redirect_last_segment(net: &mut VectorNetwork, loop_segs: &[usize], from: usize, to: usize) {
if let Some(s) = loop_segs.last().and_then(|i| net.segments.get_mut(*i))
&& s.b == from
{
s.b = to;
}
}
impl VectorNetwork {
/// A network from a polyline / polygon.
pub fn from_points(points: &[Point], closed: bool) -> Self {
@@ -169,11 +179,7 @@ impl VectorNetwork {
if a == f || loop_segs.len() < 2 || (pa - pf).hypot() > 1e-6 || a + 1 != net.vertices.len() {
return;
}
for s in net.segments.iter_mut() {
if s.b == a {
s.b = f;
}
}
redirect_last_segment(net, loop_segs, a, f);
net.vertices.pop();
all_loops.push(std::mem::take(loop_segs));
};
@@ -219,11 +225,7 @@ impl VectorNetwork {
let same = matches!((close_pt, first_pt), (Some(c), Some(f)) if (c - f).hypot() <= 1e-6);
if same && a != f {
// Merge the duplicate end vertex into the first one.
for s in net.segments.iter_mut() {
if s.b == a {
s.b = f;
}
}
redirect_last_segment(&mut net, &loop_segs, a, f);
if a + 1 == net.vertices.len() {
net.vertices.pop();
}
@@ -385,6 +387,7 @@ impl VectorNetwork {
s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y);
s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y);
}
let mut by_vertex: std::collections::BTreeMap<usize, usize> = std::collections::BTreeMap::new();
for c in corners {
if let Some(v) = out.vertices.get_mut(c.v) {
(v.x, v.y) = (c.a.x, c.a.y);
@@ -400,15 +403,28 @@ impl VectorNetwork {
}
out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb });
let arc = out.segments.len() - 1;
// The arc goes between the two segments wherever a loop walks through the point.
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
let n = lp.len();
let between =
|k: usize| matches!((lp.get(k), lp.get((k + 1) % n)), (Some(&x), Some(&y)) if (x, y) == (c.s1, c.s2) || (x, y) == (c.s2, c.s1));
if let Some(k) = (0..n).find(|k| between(*k)) {
lp.insert(k + 1, arc);
by_vertex.insert(c.v, arc);
}
// The arc goes between the two segments wherever a loop walks through the point. Each loop
// is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic.
for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) {
let Some(&first_idx) = lp.first() else { continue };
let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) {
(Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b,
(Some(first), _) => first.a,
_ => 0,
};
let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()));
let mut cur = start_v;
for &x in lp.iter() {
rebuilt.push(x);
let Some(s) = self.segments.get(x) else { continue };
cur = if s.a == cur { s.b } else { s.a };
if let Some(&arc) = by_vertex.get(&cur) {
rebuilt.push(arc);
}
}
*lp = rebuilt;
}
Some(out)
}
@@ -547,25 +563,40 @@ impl VectorNetwork {
}
}
let degree = |v: usize| degrees.get(v).copied().unwrap_or(0);
let next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> {
self.segments.iter().enumerate().find_map(|(i, s)| {
if used.get(i).copied().unwrap_or(true) {
return None;
// Each vertex's segments in segment order, and how far down that list the used ones reach,
// so finding the next unused segment never rescans the whole network.
let mut adjacent: Vec<Vec<(usize, bool)>> = vec![Vec::new(); self.vertices.len()];
for (i, s) in self.segments.iter().enumerate() {
if let Some(l) = adjacent.get_mut(s.a) {
l.push((i, false));
}
if s.b != s.a
&& let Some(l) = adjacent.get_mut(s.b)
{
l.push((i, true));
}
}
let mut cursor = vec![0usize; self.vertices.len()];
let mut remaining = self.segments.len();
let mut next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> {
let list = adjacent.get(v)?;
let at = cursor.get_mut(v)?;
while let Some(&(i, rev)) = list.get(*at) {
if !used.get(i).copied().unwrap_or(true) {
return Some((i, rev));
}
if s.a == v {
Some((i, false))
} else if s.b == v {
Some((i, true))
} else {
None
}
})
*at += 1;
}
None
};
let stop = |v: usize| degree(v) != 2 || breaks(v);
// Start runs at endpoints, junctions and breaks first, then whatever remains (pure loops).
let mut starts: Vec<usize> = (0..self.vertices.len()).filter(|v| stop(*v)).collect();
starts.extend((0..self.vertices.len()).filter(|v| !stop(*v)));
for v0 in starts {
if remaining == 0 {
break;
}
while let Some((si, rev)) = next_from(v0, &used) {
let Some(start) = self.vertices.get(v0) else { break };
let mut path = BezPath::new();
@@ -574,8 +605,11 @@ impl VectorNetwork {
let mut closed = false;
let mut seg = Some((si, rev));
while let Some((i, r)) = seg {
if let Some(u) = used.get_mut(i) {
if let Some(u) = used.get_mut(i)
&& !*u
{
*u = true;
remaining = remaining.saturating_sub(1);
}
let Some(s) = self.segments.get(i) else { break };
self.push_seg(&mut path, s, r);
+12
View File
@@ -221,3 +221,15 @@ fn canvas_stacking_paints_and_exports_the_first_child_on_top() {
s.undo().unwrap();
assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]);
}
#[test]
fn a_size_given_with_a_new_path_fixes_that_axis() {
let mut s = Session::new();
let f = al_frame(&mut s, json!({}));
let v = id(s.execute("node.create", &json!({"type": "vector", "path": "M0 0 L10 10", "closed": false, "parent": f})).unwrap());
set(&mut s, v, json!({"sizingH": "fill"}));
assert_eq!(sizing(&s, v).0, Sizing::Fill);
set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30}));
assert_eq!(sizing(&s, v).0, Sizing::Fixed);
assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v));
}
+192
View File
@@ -0,0 +1,192 @@
//! Step references in `command.batch`: a step's params can use what an earlier step returned.
//!
//! `{"$step": n}` stands for the `id` in the result of step `n` (zero-based), and `{"$ref": "name"}`
//! for the `id` in the result of the step that called itself `name` with `"as": "name"`. Either
//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the
//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way.
use std::collections::HashMap;
use serde_json::{Map, Value};
use crate::{Result, bad};
/// Deepest params nesting the resolver walks before giving up.
const MAX_DEPTH: usize = 64;
/// One step of a batch: a command, its params and the name later steps can call it by.
#[derive(Clone, Debug, PartialEq)]
pub struct BatchStep {
pub command: String,
pub params: Value,
/// The step's `as`, for `{"$ref": name}` in later steps.
pub name: Option<String>,
}
impl BatchStep {
/// A step without a name.
pub fn new(command: impl Into<String>, params: Value) -> Self {
Self { command: command.into(), params, name: None }
}
}
/// A step's `as` (`step` is its index, for the error): `None` when it has none.
pub fn step_name(step: usize, json: &Value) -> Result<Option<String>> {
match json.get("as") {
None | Some(Value::Null) => Ok(None),
Some(Value::String(name)) if !name.is_empty() => Ok(Some(name.clone())),
Some(_) => Err(bad("command.batch", format!("step {step}: `as` must be a name (a non-empty string)"))),
}
}
/// The steps of one batch run so far: their results, and which step each `as` name belongs to.
/// A nested `command.batch` step gets its own scope, so its references are to its own steps.
#[derive(Clone, Debug, Default)]
pub struct BatchScope {
names: HashMap<String, usize>,
results: Vec<Value>,
}
impl BatchScope {
/// The scope for running `steps`. Two steps with the same name fail here, before any step runs.
pub fn new(steps: &[BatchStep]) -> Result<Self> {
let mut names = HashMap::new();
for (i, step) in steps.iter().enumerate() {
let Some(name) = &step.name else { continue };
if let Some(first) = names.insert(name.clone(), i) {
return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name")));
}
}
Ok(Self { names, results: Vec::with_capacity(steps.len()) })
}
/// The results of the steps run so far, in order.
pub fn results(&self) -> &[Value] {
&self.results
}
pub fn into_results(self) -> Vec<Value> {
self.results
}
/// The index of the step that runs next.
pub fn next_step(&self) -> usize {
self.results.len()
}
pub(crate) fn push(&mut self, result: Value) {
self.results.push(result);
}
/// `params` of the next step with every reference in it replaced.
pub(crate) fn resolve(&self, params: &Value) -> Result<Value> {
self.resolve_in(params, 0)
}
fn resolve_in(&self, v: &Value, depth: usize) -> Result<Value> {
let step = self.next_step();
if depth > MAX_DEPTH {
return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels")));
}
match v {
Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o),
Value::Object(o) => {
let mut out = Map::with_capacity(o.len());
for (k, x) in o {
out.insert(k.clone(), self.resolve_in(x, depth + 1)?);
}
Ok(Value::Object(out))
}
Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
_ => Ok(v.clone()),
}
}
/// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for.
fn reference(&self, o: &Map<String, Value>) -> Result<Value> {
let step = self.next_step();
let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}"));
let key = if o.contains_key("$step") { "$step" } else { "$ref" };
if let Some(k) = o.keys().find(|k| *k != key && *k != "field") {
return Err(fail(format!("a `{key}` reference takes only `field`, not `{k}`")));
}
let (n, who) = if key == "$step" {
let n = o.get("$step").and_then(Value::as_u64).ok_or_else(|| fail("`$step` must be a step index (0, 1, …)".into()))?;
let n = usize::try_from(n).ok().filter(|n| *n < step).ok_or_else(|| {
let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) };
fail(format!("`$step` {n} must be an earlier step ({range})"))
})?;
(n, format!("step {n}"))
} else {
let name = o.get("$ref").and_then(Value::as_str).ok_or_else(|| fail("`$ref` must be a step name (a string)".into()))?;
let n = *self.names.get(name).ok_or_else(|| fail(format!("`$ref` \"{name}\" names no step ({})", self.known_names())))?;
if n >= step {
return Err(fail(format!("`$ref` \"{name}\" is step {n}, not an earlier step")));
}
(n, format!("step {n} (\"{name}\")"))
};
let path = match o.get("field") {
None => "id",
Some(Value::String(f)) => f.as_str(),
Some(_) => return Err(fail("`field` must be a string".into())),
};
let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?;
lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}")))
}
fn known_names(&self) -> String {
let mut names: Vec<_> = self.names.iter().collect();
names.sort_by_key(|(_, i)| **i);
if names.is_empty() {
return "no step has an `as` name".into();
}
let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect();
list.join(", ")
}
}
/// `path` in `v`: a key as written, else a slash path (`ids/0`, `bounds/x`; a leading `/` as in
/// JSON Pointer is allowed). The error is the detail after "has no `path`".
fn lookup<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> {
// A key as written first, so a key holding `/` still works as it did before paths.
if let Some(x) = v.get(path) {
return Ok(x);
}
let hint = if path.contains('.') && !path.contains('/') { " (paths use `/`, as in `ids/0`)" } else { "" };
walk(v, path.strip_prefix('/').unwrap_or(path)).map_err(|why| format!("{why}{hint}"))
}
fn walk<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> {
let mut cur = v;
let mut walked = String::new();
for seg in path.split('/') {
let here = if walked.is_empty() { "the result".to_string() } else { format!("`{walked}`") };
cur = match cur {
Value::Object(o) => o.get(seg).ok_or_else(|| if walked.is_empty() { String::new() } else { format!(": {here} has no `{seg}`") })?,
Value::Array(a) => match seg.parse::<usize>() {
Ok(i) => a.get(i).ok_or_else(|| {
let items = if a.len() == 1 { "1 item".to_string() } else { format!("{} items", a.len()) };
format!(": {here} has {items}")
})?,
Err(_) => return Err(format!(": {here} is a list, so `{seg}` must be an index (0, 1, …)")),
},
_ => return Err(format!(": {here} is {}", kind(cur))),
};
if !walked.is_empty() {
walked.push('/');
}
walked.push_str(seg);
}
Ok(cur)
}
fn kind(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "a list",
Value::Object(_) => "an object",
}
}
+148
View File
@@ -110,3 +110,151 @@ fn a_nested_batch_refers_to_its_own_steps() {
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one");
}
/// Runs `commands` as a batch and returns the error message, which must be `BadParams`.
fn batch_error(s: &mut Session, commands: serde_json::Value) -> String {
match s.execute("command.batch", &json!({"commands": commands})) {
Err(EngineError::BadParams { msg, .. }) => msg,
other => panic!("expected BadParams, got {other:?}"),
}
}
#[test]
fn a_named_step_is_referred_to_by_its_name() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"commands": [
{"command": "node.create", "as": "card", "params": {"type": "frame", "width": 200, "height": 100}},
{"command": "node.create", "as": "swatch", "params": {"type": "rectangle", "parent": {"$ref": "card"}, "width": 20, "height": 20}},
{"command": "node.set", "params": {"ids": [{"$ref": "swatch", "field": "id"}], "props": {"name": "Swatch"}}},
{"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0}}}
]}),
)
.unwrap();
let (frame, rect, text) = (id(&results[0]), id(&results[1]), id(&results[3]));
let doc = s.doc().unwrap();
assert_eq!(doc.node(rect).unwrap().parent, Some(frame));
assert_eq!(doc.node(rect).unwrap().name, "Swatch");
assert_eq!(doc.node(text).unwrap().parent, Some(frame), "`$step` and `$ref` mix in one batch");
assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step");
}
/// `edit.duplicate` answers `{"ids": [...]}`: a slash path in `field` takes one of them.
#[test]
fn a_field_path_indexes_into_a_list_in_the_result() {
let mut s = Session::new();
let results = s
.execute(
"command.batch",
&json!({"commands": [
{"command": "node.create", "as": "a", "params": {"type": "frame", "name": "A", "width": 10, "height": 10}},
{"command": "node.create", "as": "b", "params": {"type": "frame", "name": "B", "width": 10, "height": 10}},
{"command": "edit.duplicate", "as": "copies", "params": {"ids": [{"$ref": "a"}, {"$ref": "b"}]}},
{"command": "node.set", "params": {"ids": [{"$ref": "copies", "field": "ids/1"}], "props": {"name": "B copy"}}},
{"command": "node.set", "params": {"ids": [{"$step": 2, "field": "/ids/0"}], "props": {"name": "A copy"}}}
]}),
)
.unwrap();
let copies = results[2]["ids"].as_array().unwrap().clone();
let doc = s.doc().unwrap();
assert_eq!(doc.node(crate::params::id_of(&copies[0]).unwrap()).unwrap().name, "A copy", "a leading `/` works too");
assert_eq!(doc.node(crate::params::id_of(&copies[1]).unwrap()).unwrap().name, "B copy");
}
#[test]
fn a_field_path_walks_nested_objects_and_takes_whole_values() {
let mut scope = crate::BatchScope::default();
scope.push(json!({"ids": [7, 8], "bounds": {"x": 1.5, "corners": [{"r": 4}]}, "a/b": "slash key"}));
let resolved = scope
.resolve(&json!({
"x": {"$step": 0, "field": "bounds/x"},
"r": {"$step": 0, "field": "bounds/corners/0/r"},
"all": {"$step": 0, "field": "ids"},
"key": {"$step": 0, "field": "a/b"},
"pointer": {"$step": 0, "field": "/ids"}
}))
.unwrap();
assert_eq!(resolved, json!({"x": 1.5, "r": 4, "all": [7, 8], "key": "slash key", "pointer": [7, 8]}), "a key holding `/` is still read as a key");
}
#[test]
fn bad_names_and_paths_fail_and_roll_back() {
let mut s = Session::new();
let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
let frame = |name: &str| json!({"command": "node.create", "as": name, "params": {"type": "frame", "width": 10, "height": 10}});
let dup = json!({"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}});
let set = |r: serde_json::Value| json!({"command": "node.set", "params": {"ids": [r], "props": {"name": "x"}}});
let later = json!({"command": "node.create", "as": "later", "params": {"type": "frame", "width": 10, "height": 10}});
for (commands, needle) in [
(json!([frame("card"), frame("card")]), "step 1: `as` \"card\" is already step 0's name"),
(json!([frame("card"), set(json!({"$ref": "nope"}))]), "step 1: `$ref` \"nope\" names no step (\"card\" is step 0)"),
(json!([{"command": "node.create", "params": {"type": "frame"}}, set(json!({"$ref": "nope"}))]), "no step has an `as` name"),
(json!([frame("card"), set(json!({"$ref": "later"})), later]), "step 1: `$ref` \"later\" is step 2, not an earlier step"),
(
json!([frame("card"), {"command": "node.set", "as": "self", "params": {"ids": [{"$ref": "self"}], "props": {}}}]),
"is step 1, not an earlier step",
),
(json!([frame("card"), set(json!({"$ref": 0}))]), "`$ref` must be a step name"),
(json!([frame("card"), set(json!({"$ref": "card", "id": 3}))]), "a `$ref` reference takes only `field`, not `id`"),
(json!([frame("card"), set(json!({"$ref": "card", "$step": 0}))]), "a `$step` reference takes only `field`, not `$ref`"),
(json!([frame("card"), {"command": "node.create", "as": "", "params": {}}]), "step 1: `as` must be a name"),
(json!([frame("card"), {"command": "node.create", "as": 3, "params": {}}]), "step 1: `as` must be a name"),
(
json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/1"}))]),
"step 2: step 1 (\"copy\")'s result has no `ids/1`: `ids` has 1 item",
),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/first"}))]), "`ids` is a list, so `first` must be an index"),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/0/x"}))]), "has no `ids/0/x`: `ids/0` is a number"),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "map/0"}))]), "step 1 (\"copy\")'s result has no `map/0`"),
(json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids.0"}))]), "has no `ids.0` (paths use `/`, as in `ids/0`)"),
(json!([frame("card"), set(json!({"$step": 0, "field": "id/0"}))]), "has no `id/0`: `id` is a number"),
] {
let msg = batch_error(&mut s, commands);
assert!(msg.contains(needle), "{msg:?} should contain {needle:?}");
assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back");
}
}
#[test]
fn text_that_looks_like_a_named_reference_is_left_alone() {
let mut s = Session::new();
let text = "{\"$ref\": \"card\"} $ref";
let results = s.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": text}}]})).unwrap();
let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone();
assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == text));
}
/// A nested batch has its own names: it can't see the outer batch's, may reuse them, and the
/// outer batch can't see its.
#[test]
fn a_nested_batch_has_its_own_names() {
let mut s = Session::new();
let frame = |name: &str| json!({"command": "node.create", "as": "frame", "params": {"type": "frame", "name": name, "width": 100, "height": 100}});
let results = s
.execute(
"command.batch",
&json!({"commands": [
frame("Outer"),
{"command": "command.batch", "as": "inner", "params": {"commands": [
frame("Inner"),
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}}
]}},
{"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "1/id"}], "props": {"name": "In inner"}}}
]}),
)
.unwrap();
let inner = &results[1];
let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone();
assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own frame, not the outer one");
assert_eq!(rect.name, "In inner", "a path indexes into a nested batch's result list");
let msg = batch_error(
&mut s,
json!([frame("Outer"), {"command": "command.batch", "params": {"commands": [
{"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}}
]}}]),
);
assert!(msg.contains("`$ref` \"frame\" names no step"), "the outer names aren't visible inside: {msg}");
}
+9 -2
View File
@@ -206,8 +206,9 @@ fn distribute(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> {
let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect();
items.sort_by(|a, b| if horizontal { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
let total: f64 = items.iter().map(|(_, b)| if horizontal { b.width() } else { b.height() }).sum();
let (Some(first), Some(last)) = (items.first(), items.last()) else { return ok() };
let span = if horizontal { last.1.x1 - first.1.x0 } else { last.1.y1 - first.1.y0 };
let Some(first) = items.first() else { return ok() };
let end = items.iter().map(|(_, b)| if horizontal { b.x1 } else { b.y1 }).fold(f64::MIN, f64::max);
let span = if horizontal { end - first.1.x0 } else { end - first.1.y0 };
let gap = num(p, "spacing").unwrap_or((span - total) / (items.len().saturating_sub(1).max(1)) as f64);
let mut pos = if horizontal { first.1.x0 } else { first.1.y0 };
for (i, b) in items.clone() {
@@ -494,9 +495,15 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> {
t.paragraph_spacing *= k;
t.paragraph_indent *= k;
t.list_spacing *= k;
if let Some(tp) = &mut t.path {
tp.scale(k, k);
}
}
if let NodeKind::Vector { network } = &mut n.kind {
network.scale(k, k);
for v in &mut network.vertices {
v.radius *= k;
}
}
n.width *= k;
n.height *= k;
+14 -4
View File
@@ -217,14 +217,14 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"component.addProperty",
"Add component property",
"{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default?, layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}",
"{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default? (boolean: the bound layer's visibility in the main; without it, the layer's current visibility), layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}",
true,
add_prop
),
spec!(
"component.editProperty",
"Edit component property",
"{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}",
"{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place; boolean: the main's bound layers show or hide to match), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}",
true,
edit_prop
),
@@ -1151,7 +1151,8 @@ fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
}
let layer_node = layer.and_then(|l| doc.node(l)).cloned();
let def = match t.to_ascii_lowercase().as_str() {
"boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").unwrap_or(true) },
// Without a default, a bound layer keeps showing as it does: its visibility is the default.
"boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) },
"text" => PropDef::Text {
default: string(p, "default")
.or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone()))
@@ -1255,7 +1256,12 @@ fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, fi
}
_ => {}
}
doc.get_mut(layer)?.prop_refs.insert(field.to_string(), property.to_string());
let n = doc.get_mut(layer)?;
n.prop_refs.insert(field.to_string(), property.to_string());
// On the main, a boolean property's value is the visibility of the layers it controls.
if let PropDef::Boolean { default } = def {
n.visible = *default;
}
Ok(())
}
@@ -1424,6 +1430,10 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result<Value> {
rename_prop_refs(doc, *c, &name, Some(nn))?;
}
}
// The main's bound layers show the new default.
if let PropDef::Boolean { default } = new_def {
set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default);
}
ok()
}
+39 -18
View File
@@ -167,6 +167,42 @@ pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
}
/// A vector's geometry made from SVG path data (see [`path_network`]).
pub(crate) struct PathShape {
/// The network, with the path's own box moved to the origin and scaled to the size asked for.
pub network: VectorNetwork,
pub width: f64,
pub height: f64,
/// Where the path's own box starts in its coordinates (unscaled).
pub offset: (f64, f64),
}
/// The network SVG path data `d` draws, for `node.create` and `node.set`: the path's own box moves
/// to the origin, and `width`/`height` scale it as [`vector_scale`] says. Data that doesn't parse
/// or draws nothing is refused.
pub(crate) fn path_network(cmd: &str, d: &str, width: Option<f64>, height: Option<f64>) -> Result<PathShape> {
use figcraft_geom::Shape;
let empty = || bad(cmd, "`path` draws nothing: a vector needs a visible line or area");
let mut path = parse_path_data(d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
let bb = path.bounding_box();
if !(bb.x0.is_finite() && bb.y0.is_finite() && bb.width().is_finite() && bb.height().is_finite()) {
return Err(bad(cmd, "`path` has coordinates too large to draw"));
}
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(empty());
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(width, height, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
let network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(empty());
}
Ok(PathShape { network, width: bb.width() * sx, height: bb.height() * sy, offset: (bb.x0, bb.y0) })
}
/// How much a vector's path is scaled to take the size asked for: with both sides, each scales
/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on
/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0
@@ -229,27 +265,12 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let asked = (num(p, "width"), num(p, "height"));
if let NodeKind::Vector { network } = &mut kind {
if let Some(d) = string(p, "path") {
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
use figcraft_geom::Shape;
let bb = path.bounding_box();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
*network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
let shape = path_network(cmd, &d, asked.0, asked.1)?;
*network = shape.network;
if boolean(p, "closed") == Some(false) {
network.regions.clear();
}
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
(w, h, offset) = (shape.width, shape.height, shape.offset);
} else if let Some(Value::Array(pts)) = get(p, "points") {
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
if pts.len() < 2 {
+68 -32
View File
@@ -4,11 +4,11 @@ use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::*;
use figcraft_geom::Affine;
use serde_json::{Value, json};
use serde_json::{Map, Value, json};
use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -216,7 +216,13 @@ pub fn specs() -> Vec<CommandSpec> {
let i = req_id("node.get", p, "id")?;
Ok(crate::inspect::node_json(s.doc()?, i, 1))
}),
spec!("node.reparent", "Move layer into", "{ids?, parent, index?}", true, reparent),
spec!(
"node.reparent",
"Move layer into",
"{ids?, parent, index? (final position among parent's children, bottom first; default top)}",
true,
reparent
),
spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| {
let i = req_id("node.reorder", p, "id")?;
let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize;
@@ -336,15 +342,16 @@ pub fn specs() -> Vec<CommandSpec> {
}
ok()
}),
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| {
spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| {
let label = string(p, "label").unwrap_or_else(|| "Batch".into());
let cmds: Vec<(String, Value)> = match get(p, "commands") {
let cmds: Vec<BatchStep> = match get(p, "commands") {
Some(Value::Array(a)) => {
let mut out = Vec::new();
for (i, c) in a.iter().enumerate() {
let cmd =
c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?;
out.push((cmd.to_string(), c.get("params").cloned().unwrap_or(json!({}))));
let params = c.get("params").cloned().unwrap_or(json!({}));
out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) });
}
out
}
@@ -535,6 +542,7 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
s.clipboard.nodes = nodes;
s.clipboard.images = images;
s.clipboard.bounds = bounds;
s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1);
Ok(())
}
@@ -594,6 +602,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option<Vec<u8>> {
figcraft_format::fig::is_fig(&bytes).then_some(bytes)
}
/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing,
/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers,
/// store them as a foreign-file clipboard source with a bumped layers generation, then
/// delegate to `paste` and append import warnings.
fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "edit.pasteDesignLayers";
let bytes = match (string(p, "html"), string(p, "data")) {
@@ -610,8 +622,18 @@ 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() };
let layers_generation = s.clipboard.layers_generation.wrapping_add(1);
s.clipboard = crate::Clipboard {
nodes,
images,
props,
bounds,
source_doc: 0,
source: Some(doc),
source_path: None,
copied_components: BTreeSet::new(),
layers_generation,
};
let mut out = paste(s, p)?;
if !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
@@ -1169,6 +1191,9 @@ fn find_replace(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"matches": hits}))
}
/// Keys only some layer types take, the test for those layers, and why the others skip them.
type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str);
fn set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("node.set", s, p)?;
let props = match get(p, "props") {
@@ -1199,34 +1224,44 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
None => Vec::new(),
};
let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props);
// Auto layout keys go to the layers that can have auto layout; with none among the targets,
// the first refuses them (`props::layout_mut`).
// Type-specific keys go to the layers that take them and are skipped, with a warning, on the
// others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets
// that take them, the first refuses them (`props::layout_mut`, `props::apply`).
let doc = &s.ds()?.doc;
let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect();
let mut without_layout = props.clone();
let mut skipped = BTreeSet::new();
if !layout_keys.is_empty() {
let doc = &s.ds()?.doc;
let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like());
if ids.iter().any(frame_like) {
skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect();
without_layout.retain(|k, _| !layout_keys.contains(&k.as_str()));
let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect();
let frame_like = |n: &Node| n.kind.is_frame_like();
let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. });
let typed: [TypedKeys; 2] = [
(&layout_keys, &frame_like, "only frames, components and instances have auto layout"),
(&path_keys, &vector, "only vector layers have a path"),
];
let mut skipped: BTreeMap<NodeId, Map<String, Value>> = BTreeMap::new();
for (keys, takes, why) in typed {
let takes = |i: &NodeId| doc.node(*i).is_some_and(takes);
if keys.is_empty() || !ids.iter().any(takes) {
continue;
}
if !skipped.is_empty() {
let names: Vec<String> =
skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() };
warnings.push(format!(
"{} skipped on {}{more}: only frames, components and instances have auto layout",
layout_keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
names.join(", ")
));
let others: Vec<NodeId> = ids.iter().copied().filter(|i| !takes(i)).collect();
if others.is_empty() {
continue;
}
for i in &others {
skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str()));
}
let names: Vec<String> =
others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect();
let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() };
warnings.push(format!(
"{} skipped on {}{more}: {why}",
keys.iter().map(|k| format!("`{k}`")).collect::<Vec<_>>().join(", "),
names.join(", ")
));
}
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::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?;
}
super::styles::detach_edited(doc, &before);
if let Some(o) = pivot {
@@ -1312,9 +1347,10 @@ fn reparent(s: &mut Session, p: &Value) -> Result<Value> {
let index = num(p, "index").map(|i| i.max(0.0) as usize);
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
// The layers land together under the first sibling at or above `index` that stays put (none: on
// top), wherever they came from.
let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().skip(at).find(|c| !ids.contains(c)).copied());
// `index` is where the first moved layer ends up among the parent's children (bottom first),
// counted after the move, the way `node.reorder` counts it. The layers land together, keeping
// their order, under the sibling that stays put and ends up just after them (none: on top).
let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied());
for i in &ids {
// Counted with the layer still in place, as `reparent` expects.
let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a));
+132 -22
View File
@@ -63,10 +63,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.saveAs",
"Save as",
"{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
"{path | data: true (no path: returns the file as base64 `data` with its `name`, for a download)} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview",
false,
save_as
),
spec!(
"file.markSaved",
"Mark saved",
"{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)",
false,
mark_saved
),
spec!(
"file.export",
"Export",
@@ -77,7 +84,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.exportLayers",
"Export layers",
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}",
"{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `<layer><suffix>.<format>` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}",
false,
export_layers
),
@@ -855,37 +862,59 @@ fn color_profile(s: &mut Session, p: &Value) -> Result<Value> {
pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool {
let path =
string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None });
path.is_some_and(|path| ext(&path) != "fig")
path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p))
}
/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download).
fn wants_data(p: &Value) -> bool {
p.get("data").and_then(Value::as_bool) == Some(true)
}
/// Save the file of the active tab (the main file for a branch tab, with its branches), or export
/// the active document as an experimental `.fig` copy without changing its native save state.
fn save_to(s: &mut Session, path: String) -> Result<Value> {
if ext(&path) == "fig" {
/// Without a path the file comes back as base64 `data` for the caller to deliver (#969).
fn save_to(s: &mut Session, path: Option<String>) -> Result<Value> {
if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") {
return export_fig(s, &json!({"path": path}));
}
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);
if let Some(path) = path.as_deref()
&& follows_path
&& ds.path.as_deref() != Some(path)
{
Arc::make_mut(&mut snapshot.doc).name = file_stem(path);
}
let bytes = snapshot.encode()?;
let Some(path) = path else {
// The download can still be refused: the caller marks the file saved once it was delivered.
let name = format!("{}.figcraft", ds.doc.name);
return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}));
};
write(&path, &bytes)?;
ds.doc = snapshot.doc;
ds.path = Some(path.clone());
ds.mark_saved();
ds.revision += 1;
let uid = ds.uid;
branch_tabs_saved(s, uid);
mark_saved(s, &json!({}))?;
remember(s, &path);
Ok(json!({"path": path, "bytes": bytes.len()}))
}
/// The file of the active tab (with its branch tabs) now matches what was written or delivered.
fn mark_saved(s: &mut Session, _: &Value) -> Result<Value> {
let i = crate::branches::file_tab(s);
let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?;
ds.mark_saved();
ds.revision += 1;
let uid = ds.uid;
branch_tabs_saved(s, uid);
Ok(json!({"saved": true}))
}
fn save(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = string(p, "path") {
return save_to(s, path);
return save_to(s, Some(path));
}
match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() {
// Earlier builds wrote native data under a .fig name: never overwrite that file with an export.
@@ -893,7 +922,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into()))
}
Some(path) => save_to(s, path),
Some(path) => save_to(s, Some(path)),
None => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into()))
@@ -907,8 +936,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result<Value> {
if ext(&path).is_empty() {
path.push_str(".figcraft");
}
save_to(s, path)
save_to(s, Some(path))
}
None if wants_data(p) => save_to(s, None),
None => {
s.request_ui("dialog.saveAs", json!({}));
Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into()))
@@ -941,14 +971,37 @@ pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> O
.reduce(|a, b| a.union(b))
}
/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off,
/// and always for slices) every top-level layer of their page, so whatever shows in the area is in.
fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec<NodeId> {
let slice = ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)));
match ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) {
Some(page) if slice || !setting.contents_only => page.children.clone(),
_ => super::edit::z_sorted(doc, ids),
/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off)
/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing
/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a
/// slice anywhere else draws every top-level layer of its page.
pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec<NodeId> {
let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else {
return super::edit::z_sorted(doc, ids);
};
if !setting.contents_only {
return page.children.clone();
}
let mut own = Vec::with_capacity(ids.len());
for &i in ids {
if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) {
own.push(i);
continue;
}
match slice_container(doc, i) {
Some(c) => own.push(c),
None => return page.children.clone(),
}
}
own.sort_unstable();
own.dedup();
super::edit::z_sorted(doc, &super::edit::roots(doc, &own))
}
/// The frame or group a slice sits in: None on the page or in a section.
fn slice_container(doc: &Document, slice: NodeId) -> Option<NodeId> {
let parent = doc.node(slice)?.parent?;
doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent)
}
/// [`export_bytes`] with an export setting's format and options (its size is `scale`: see
@@ -1136,6 +1189,20 @@ fn size_param(cmd: &str, p: &Value) -> Result<Option<ExportSize>> {
/// Most files one `file.exportLayers` writes.
const MAX_EXPORT_FILES: usize = 1000;
/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy
/// are all in memory at once (in the browser too).
const MAX_ZIP_BYTES: usize = 256 << 20;
/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together.
fn add_member<'a>(members: &mut Vec<(&'a str, Vec<u8>)>, name: &'a str, bytes: Vec<u8>, limit: usize) -> Result<()> {
let total = members.iter().map(|(_, b)| b.len()).sum::<usize>().saturating_add(bytes.len());
if total > limit {
let mb = limit >> 20;
return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder")));
}
members.push((name, bytes));
Ok(())
}
/// One file of a layer export: the layer, its setting and the file name.
pub struct ExportJob {
@@ -1207,6 +1274,9 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into()));
}
let dir = string(p, "dir");
// The browser saves one download at a time: several files go as one zip (#969).
let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false);
let mut members = Vec::new();
// Print options passed here stand in for every PDF setting's own.
let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?;
let mut files = Vec::with_capacity(jobs.len());
@@ -1226,12 +1296,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result<Value> {
write(&path, &bytes)?;
f["path"] = json!(path);
}
None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?,
None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)),
}
files.push(f);
}
let with_settings: std::collections::BTreeSet<NodeId> = jobs.iter().map(|j| j.id).collect();
Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}))
let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())});
if zip {
let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?;
r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive));
}
Ok(r)
}
/// A PDF with one page per group of layers (an sRGB document), titled after the file; `area`
@@ -1578,6 +1654,25 @@ mod rest_import_tests {
assert_eq!(s.docs.len(), open, "a refused open adds nothing");
}
#[test]
fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() {
// The browser has no paths: Save as returns the file for a download (#969).
let mut s = Session::new();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path");
let recent = s.prefs.recent_files.clone();
let r = s.execute("file.saveAs", &json!({"data": true})).unwrap();
assert_eq!(r["name"], "Untitled.figcraft");
assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits");
s.execute("file.markSaved", &json!({})).unwrap();
assert!(!s.ds().unwrap().is_dirty());
assert_eq!(s.ds().unwrap().path, None, "still no path to save over");
assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember");
let mut t = Session::new();
t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap();
assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen");
}
#[test]
fn open_and_import_rest_json() {
let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id()));
@@ -1745,3 +1840,18 @@ mod staging_write_tests {
let _ = std::fs::remove_dir_all(&d);
}
}
#[cfg(test)]
mod zip_cap_tests {
use super::add_member;
#[test]
fn zip_members_stop_at_the_limit_and_keep_what_fit() {
let mut members = Vec::new();
add_member(&mut members, "a.png", vec![0; 6], 10).unwrap();
add_member(&mut members, "b.png", vec![0; 4], 10).unwrap();
let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string();
assert!(err.contains("as one zip"), "{err}");
assert_eq!(members.iter().map(|(n, _)| *n).collect::<Vec<_>>(), ["a.png", "b.png"]);
}
}
+15 -1
View File
@@ -19,6 +19,9 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// Same cap the layout pass applies to grid track counts.
const MAX_TRACKS: usize = 1000;
fn set_track(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "layout.setTrack";
let ids = req_targets(CMD, s, p)?;
@@ -38,7 +41,7 @@ fn set_track(s: &mut Session, p: &Value) -> Result<Value> {
let Some(l) = n.layout.as_mut().filter(|l| l.mode == LayoutMode::Grid) else {
return Err(bad(CMD, format!("node {id} is not a grid auto layout frame")));
};
let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize);
let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize).min(MAX_TRACKS);
if !(0.0..count as f64).contains(&index) {
return Err(bad(CMD, format!("`index` must be 0..{count}")));
}
@@ -249,6 +252,17 @@ mod tests {
assert!(s.execute("node.set", &json!({"id": f, "props": {"rowTracks": 5}})).is_err());
}
#[test]
fn set_track_clamps_hostile_grid_counts() {
let mut s = Session::new();
let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 320, "height": 200, "props": {"layout": "grid"}})));
// A crafted file can carry a count node.set would have clamped.
s.doc_mut().unwrap().get_mut(f).unwrap().layout.as_mut().unwrap().grid_columns = 5_000_000;
let p = json!({"ids": [f], "axis": "columns", "index": 4_999_999, "sizing": "hug"});
assert!(s.execute("layout.setTrack", &p).is_err());
assert!(node(&s, f).layout.unwrap().column_tracks.len() <= 1000);
}
#[test]
fn layout_props_survive_hostile_values() {
let junk = [json!(null), json!("NaN"), json!(-1e308), json!(1e308), json!([]), json!({}), json!(true), json!("-5fr")];
+3
View File
@@ -99,6 +99,7 @@ fn registry() -> &'static Registry {
list.extend(crate::code_connect::specs());
list.extend(crate::annotations::specs());
list.extend(crate::dev_resources::specs());
list.extend(crate::dev_measurements::specs());
list.extend(crate::distance::specs());
list.extend(crate::collab::specs());
list.extend(crate::branches::specs());
@@ -109,6 +110,8 @@ fn registry() -> &'static Registry {
list.extend(crate::online::specs());
list.extend(crate::invite_links::specs());
list.extend(crate::templates::specs());
list.extend(crate::datafill::specs());
list.extend(crate::textfit::specs());
list.extend(crate::board::specs());
let mut index = HashMap::new();
for (i, s) in list.iter().enumerate() {
+71 -11
View File
@@ -26,7 +26,9 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l
textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \
listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \
listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \
fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), \
path (a vector's SVG path data: replaces its geometry, keeping the layer, its paints, styles and bindings; width/height in the same call scale it as \
node.create does; its own box goes at the layer's x/y, or with x/y given, at x + its left edge and y + its top as node.create places it), pointCount, innerRadius, arcStart, arcSweep, \
arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \
group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}";
@@ -142,7 +144,9 @@ pub(crate) fn sizing(v: &Value) -> Option<Sizing> {
pub(crate) fn line_height(v: &Value) -> Option<LineHeight> {
match v {
Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto),
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LineHeight::Percent),
Value::String(s) if s.trim().ends_with('%') => {
s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).map(LineHeight::Percent)
}
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
other => f64_of(other).map(LineHeight::Pixels),
}
@@ -150,7 +154,9 @@ pub(crate) fn line_height(v: &Value) -> Option<LineHeight> {
pub(crate) fn letter_spacing(v: &Value) -> Option<LetterSpacing> {
match v {
Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LetterSpacing::Percent),
Value::String(s) if s.trim().ends_with('%') => {
s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).map(LetterSpacing::Percent)
}
Value::Object(_) => serde_json::from_value(v.clone()).ok(),
other => f64_of(other).map(LetterSpacing::Pixels),
}
@@ -382,7 +388,7 @@ fn paint_none(v: &Value) -> bool {
/// paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
match k {
"width" | "height" | "w" | "h" => 0,
"path" | "width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
"fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4,
@@ -391,16 +397,35 @@ fn apply_order(k: &str) -> u8 {
}
}
/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing
/// into W or H.
fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
let n = doc.get_mut(id)?;
if is_w {
n.child_layout.sizing_h = Sizing::Fixed;
} else {
n.child_layout.sizing_v = Sizing::Fixed;
}
Ok(())
}
/// 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();
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| apply_order(k));
// Where a new `path` had its own box, for `x`/`y` in the same call (as `node.create` places it).
let mut path_offset = (0.0, 0.0);
for (k, v) in keys {
let k = k.as_str();
match k {
"path" => path_offset = set_path(cmd, doc, id, v, props)?,
// A new path takes the size asked for as its scale (`set_path`); the size still fixes
// its auto layout axis.
"width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?,
"x" | "y" => {
let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
let val = val + if k == "x" { path_offset.0 } else { path_offset.1 };
// X/Y are the node's own origin (its rotated top-left for rotated nodes), in the
// frame it is in (groups have no space of their own).
let n = doc.get(id)?;
@@ -442,13 +467,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
TextAutoResize::None
};
}
// Explicit width/height makes hugging/filling fixed (like typing into W/H).
let n = doc.get_mut(id)?;
if is_w {
n.child_layout.sizing_h = Sizing::Fixed;
} else {
n.child_layout.sizing_v = Sizing::Fixed;
}
fix_axis(doc, id, is_w)?;
}
"rotation" => {
let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?;
@@ -939,6 +958,33 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
Ok(())
}
/// Replace vector `id`'s geometry with SVG path data `v`, as `node.create` reads it: `width`/`height`
/// in the same call scale it, and the path's own box goes at the layer's origin, which keeps its
/// place, rotation and everything else. Returns where the path's own box was, which `x`/`y` in the
/// same call add (so `{path, x: 0, y: 0}` places it as `node.create` does). Layers inside an
/// instance are refused: their geometry comes from the main component, and a sync would undo it.
fn set_path(cmd: &str, doc: &mut Document, id: NodeId, v: &Value, props: &Map<String, Value>) -> Result<(f64, f64)> {
let d = v.as_str().ok_or_else(|| bad(cmd, "`path` must be SVG path data (a string)"))?;
let n = doc.get(id)?;
if !matches!(n.kind, NodeKind::Vector { .. }) {
return Err(bad(cmd, format!("`path` applies to vector layers, and layer {} “{}” is a {}", n.id, n.name, n.kind.type_name())));
}
if crate::components::inside_instance(doc, id) {
return Err(bad(cmd, format!("`path`: layer {} “{}” is inside an instance: edit its main component, or detach the instance", n.id, n.name)));
}
let size = |long: &str, short: &str| -> Result<Option<f64>> {
props.get(long).or_else(|| props.get(short)).map(|v| f64_of(v).ok_or_else(|| bad(cmd, format!("`{long}` must be a number")))).transpose()
};
let shape = super::create::path_network(cmd, d, size("width", "w")?, size("height", "h")?)?;
let n = doc.get_mut(id)?;
if let NodeKind::Vector { network } = &mut n.kind {
*network = shape.network;
}
n.width = shape.width;
n.height = shape.height;
Ok(shape.offset)
}
/// The override group a property key belongs to.
pub fn override_group(k: &str) -> &'static str {
match k {
@@ -1235,3 +1281,17 @@ pub fn describe(doc: &Document, id: NodeId) -> Value {
let _ = Color::BLACK;
v
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn non_finite_percent_strings_are_rejected() {
for s in ["NaN%", "inf%", "-inf%"] {
assert!(line_height(&json!(s)).is_none(), "line_height accepted {s}");
assert!(letter_spacing(&json!(s)).is_none(), "letter_spacing accepted {s}");
}
assert!(matches!(line_height(&json!("150%")), Some(LineHeight::Percent(p)) if p == 150.0));
}
}
+11
View File
@@ -155,6 +155,17 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}
),
spec!(
"proto.linkNotice",
"Notice before leaving for a link",
"{show? (default true; false skips it)} whether a presented prototype's Open link to a website first says that it leaves the prototype, with Open link and Cancel; kept with the preferences → {shown}",
false,
|s, p| {
let show = boolean(p, "show").unwrap_or(true);
s.prefs.link_notice_skipped = !show;
Ok(json!({"shown": show}))
}
),
]
}
+20 -2
View File
@@ -126,7 +126,9 @@ fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result<Value> {
}
/// Put a text layer on a path: the text takes the path layer's place, geometry and transform, and
/// the path layer is removed.
/// the path layer is removed. The path's effects move to the text when it has any, and its fills
/// when one of them shows, each with its style and variable bindings; otherwise the text keeps
/// its own.
fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "text.onPath";
let doc = s.doc()?;
@@ -144,6 +146,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
}
let (parent, index) = (pn.parent.ok_or_else(|| bad(CMD, "pages have no path"))?, doc.index_in_parent(path));
let (transform, w, h) = (pn.transform, pn.width, pn.height);
let fills = pn.fills.iter().any(|f| f.visible).then(|| (pn.fills.clone(), pn.styles.fill.clone()));
let effects = (!pn.effects.is_empty()).then(|| (pn.effects.clone(), pn.styles.effect.clone()));
let moved = |k: &str| match super::lists::list_binding(k) {
Some((super::lists::BoundList::Fills, ..)) => fills.is_some(),
Some((super::lists::BoundList::Effects, ..)) => effects.is_some(),
_ => false,
};
let bound: Vec<(String, VariableId)> = pn.bound.iter().filter(|(k, _)| moved(k)).map(|(k, v)| (k.clone(), v.clone())).collect();
let mut tp = TextPath::new(&geom);
tp.start = num(p, "start").filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0);
tp.flip = boolean(p, "flip").unwrap_or(false);
@@ -153,6 +163,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result<Value> {
let n = doc.get_mut(text)?;
n.transform = transform;
(n.width, n.height) = (w, h);
n.bound.retain(|k, _| !moved(k));
n.bound.extend(bound);
if let Some((fills, style)) = fills {
(n.fills, n.styles.fill) = (fills, style);
}
if let Some((effects, style)) = effects {
(n.effects, n.styles.effect) = (effects, style);
}
if let NodeKind::Text(t) = &mut n.kind {
t.path = Some(Box::new(tp));
}
@@ -165,7 +183,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"text.onPath",
"Text on path",
"{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and the path layer is removed → {id}",
"{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and takes its effects and its fills when one shows, and the path layer is removed → {id}",
true,
on_path
),
+24 -9
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.export",
"Export variables",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]), path? (write it there)} → {text}, or {path} when written",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written",
false,
export
),
@@ -46,8 +46,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
let text = match string(p, "format").as_deref().unwrap_or("json") {
"json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls))
.map_err(|e| EngineError::Other(e.to_string()))?,
"css" => css(&doc.variables, &colls),
_ => return Err(bad(cmd, "format: json|css")),
"css" => css(doc, &colls, false),
"tailwind" => css(doc, &colls, true),
_ => return Err(bad(cmd, "format: json|css|tailwind")),
};
match string(p, "path") {
Some(path) => {
@@ -66,26 +67,40 @@ fn css_safe(text: &str) -> String {
/// Each collection's variables as custom properties (named as generated code names them): the
/// default mode's values on `:root`, every other mode's on `[data-<collection>="<mode>"]`;
/// aliases are `var(--name)`.
fn css(vars: &Variables, colls: &[&Collection]) -> String {
let mut out = String::new();
fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
let vars = &doc.variables;
// Tailwind theme names, unique per file and the same ones page code reads.
let theme = crate::tailwind::Theme::new(doc);
let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) };
// Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and
// font weight don't.
let roles = crate::token_roles::Roles::new(doc);
let mut out = if tailwind { "@import \"tailwindcss\";\n\n".to_string() } else { String::new() };
for c in colls {
let attr = css_var_name(&c.name);
for (i, m) in c.modes.iter().enumerate() {
let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) };
let sel = match i {
// `static`: Tailwind otherwise drops theme variables no utility uses, and pages also
// read them through `var()` and `(--name)` utilities.
0 if tailwind => "@theme static".to_string(),
0 => ":root".to_string(),
_ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)),
};
out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name)));
for v in vars.variables.iter().filter(|v| v.collection == c.id) {
let value = match v.values.get(&m.id) {
Some(VariableValue::Color(col)) => col.to_css(),
Some(VariableValue::Number(x)) => trim_num(*x),
Some(VariableValue::Number(x)) if roles.role(v) == crate::token_roles::NumberRole::Unitless => trim_num(*x),
Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)),
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
Some(VariableValue::Boolean(b)) => b.to_string(),
Some(VariableValue::Alias(a)) => match vars.get(a) {
Some(t) => format!("var({})", css_var_of(t)),
Some(t) => format!("var({})", name(t)),
None => continue,
},
None => continue,
};
out.push_str(&format!(" {}: {value};\n", css_var_of(v)));
out.push_str(&format!(" {}: {value};\n", name(v)));
}
out.push_str("}\n");
}
+3
View File
@@ -535,6 +535,9 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("tool.pen", "Pen", "{}", false, |s, _| tool(s, ToolId::Pen)),
spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)),
spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)),
spec!("tool.textOnPath", "Text on a path", "{} a click on a shape's or vector's path puts new text on it", false, |s, _| {
tool(s, ToolId::TextOnPath)
}),
spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)),
spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)),
// `apply: false` is for the UI colour picker: the eyedropper only samples (into
+89 -14
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"inspect.code",
"Code for layer",
"{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
"{id?, language?: css|html|react|leptos|tailwind|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}",
false,
|s, p| {
let cmd = "inspect.code";
@@ -40,7 +40,14 @@ pub fn specs() -> Vec<CommandSpec> {
return Err(bad(cmd, "no such layer"));
}
let connected = crate::code_connect::link(doc, id, lang).is_some();
let code = code_for(doc, id, lang);
let mobile = match get(p, "mobile") {
None | Some(serde_json::Value::Null) => None,
Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?),
};
let code = match (lang, mobile) {
(CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)),
_ => code_for(doc, id, lang),
};
let code = match rem {
Some(root) if lang.is_web() => px_to_rem(&code, root),
_ => code,
@@ -61,18 +68,22 @@ pub enum CodeLang {
React,
/// A Leptos component (`view!` markup) with its stylesheet.
Leptos,
/// A React function component styled with Tailwind CSS v4 utilities (no stylesheet).
Tailwind,
SwiftUi,
Compose,
}
impl CodeLang {
pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose];
pub const ALL: [CodeLang; 7] =
[CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose];
pub fn id(self) -> &'static str {
match self {
CodeLang::Css => "css",
CodeLang::Html => "html",
CodeLang::React => "react",
CodeLang::Leptos => "leptos",
CodeLang::Tailwind => "tailwind",
CodeLang::SwiftUi => "swiftui",
CodeLang::Compose => "compose",
}
@@ -83,11 +94,13 @@ impl CodeLang {
CodeLang::Html => "HTML + CSS",
CodeLang::React => "React",
CodeLang::Leptos => "Leptos",
CodeLang::Tailwind => "React + Tailwind",
CodeLang::SwiftUi => "SwiftUI",
CodeLang::Compose => "Compose",
}
}
/// Languages whose lengths are CSS pixels (they can be shown in rem).
/// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths
/// are utility classes).
pub fn is_web(self) -> bool {
matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos)
}
@@ -109,7 +122,12 @@ pub(crate) fn num(v: f64) -> String {
return "0".into();
}
let r = (v * 100.0).round() / 100.0;
if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") }
if (r - r.round()).abs() < 1e-9 {
// `as i64` saturates past 2^63, so larger whole values are printed from the float.
if r.abs() < 9e18 { format!("{}", r.round() as i64) } else { format!("{:.0}", r.round()) }
} else {
format!("{r}")
}
}
/// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end
@@ -194,6 +212,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String {
match lang {
CodeLang::Css => css(doc, id),
CodeLang::Html => html(doc, id),
CodeLang::Tailwind => crate::tailwind::code(doc, id, None),
CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang),
}
}
@@ -316,25 +335,32 @@ pub(crate) fn indent(depth: usize) -> String {
/// The CSS custom properties generated code uses: name → fallback value, in first-use order.
#[derive(Default)]
pub(crate) struct Tokens(pub Vec<(String, String)>);
pub(crate) struct Tokens {
vars: Vec<(String, String)>,
/// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name.
pub tailwind: bool,
/// The file's theme names (computed on first use).
theme: Option<crate::tailwind::Theme>,
}
impl Tokens {
/// `var(--name, value)`, remembering the variable.
fn var(&mut self, variable: &Variable, value: String) -> String {
let name = css_var_of(variable);
fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String {
let name =
if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) };
let out = format!("var({name}, {value})");
if !self.0.iter().any(|(n, _)| *n == name) {
self.0.push((name, value));
if !self.vars.iter().any(|(n, _)| *n == name) {
self.vars.push((name, value));
}
out
}
/// A `:root` rule declaring the variables (empty without any).
pub fn root_rule(&self) -> String {
if self.0.is_empty() {
if self.vars.is_empty() {
return String::new();
}
let decls: String = self.0.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect();
format!(":root {{\n{decls}}}\n")
}
}
@@ -365,7 +391,7 @@ pub fn css_var_of(v: &Variable) -> String {
/// `value`, or a reference to the variable bound to the first of `fields` that has one.
fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String {
match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) {
Some(v) => t.var(v, value),
Some(v) => t.var(doc, v, value),
None => value,
}
}
@@ -659,6 +685,14 @@ pub(crate) struct Markup<'c, 'a> {
/// Each layer's path in the tree (names with sibling counts), class and declarations, so
/// variants can be compared layer by layer.
pub rules: Vec<(String, String, String)>,
/// Instances as their own (overridden) layers instead of component calls, except those
/// connected to code (page code).
pub inline: bool,
/// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages.
pub fluid: bool,
/// With `fluid`, other layers at least this share of the root's width are fluid too (a
/// divider, a banner image); smaller ones (icons) keep their size. Default: none.
pub fluid_leaves: f64,
}
impl<'c, 'a> Markup<'c, 'a> {
@@ -673,6 +707,9 @@ impl<'c, 'a> Markup<'c, 'a> {
root_class: None,
root_attrs: String::new(),
rules: Vec::new(),
inline: false,
fluid: false,
fluid_leaves: f64::INFINITY,
}
}
@@ -754,7 +791,10 @@ impl<'c, 'a> Markup<'c, 'a> {
}
}
// Nested instances are calls (component code), in a wrapper that places them when needed.
if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) {
// Page code inlines instances, except those connected to code (codeConnect.set): their
// snippet, e.g. a design-system `<Button>`.
let connected = self.inline && crate::code_connect::link(doc, n.id, CodeLang::React).is_some();
if self.code() && (!self.inline || connected) && !root && matches!(n.kind, NodeKind::Instance { .. }) {
let at = depth + usize::from(!decls.is_empty());
let run = if self.lang == CodeLang::Leptos { ".run()" } else { "" };
let inner = match self.ctx.param(n, "mainComponent") {
@@ -774,6 +814,11 @@ impl<'c, 'a> Markup<'c, 'a> {
let hugs = n.layout.is_some() || matches!(n.kind, NodeKind::Text(_));
let hug = (hugs && n.child_layout.sizing_h == Sizing::Hug, hugs && n.child_layout.sizing_v == Sizing::Hug);
let mut own = css_decls(doc, n, &mut self.tokens, hug);
let page = self.ctx.doc.node(self.ctx.root).map_or(f64::INFINITY, |r| r.width);
if self.fluid && (n.layout.is_some() || matches!(n.kind, NodeKind::Text(_)) || (!root && n.width >= page * self.fluid_leaves)) {
// Full width, or sized by the flex row (`flex: 1`): no cap needed.
own = fluid(&own, n.width >= page - 0.5 || decls.contains("flex: 1 1 0;"), n.layout.is_some());
}
if n.layout.is_none() && !n.children.is_empty() && !root && in_flow {
own.push_str("position: relative;\n");
}
@@ -840,6 +885,28 @@ impl<'c, 'a> Markup<'c, 'a> {
}
}
/// `decls` for a box that follows the page: a fixed width becomes `width: 100%` capped at that
/// width (or just the full width for boxes as wide as the page), and a frame's fixed height a
/// minimum, so content can wrap and grow.
// ponytail: width-based heuristic; fixed-size leaves (icons, images, shapes) keep their pixels.
fn fluid(decls: &str, full: bool, frame: bool) -> String {
let mut out = String::with_capacity(decls.len() + 32);
for line in decls.lines() {
if let Some(w) = line.strip_prefix("width: ") {
out.push_str("width: 100%;\n");
if !full {
out.push_str(&format!("max-width: {w}\n"));
}
} else if let Some(h) = line.strip_prefix("height: ").filter(|_| frame) {
out.push_str(&format!("min-height: {h}\n"));
} else {
out.push_str(line);
out.push('\n');
}
}
out
}
// ------------------------------------------------------------------ SwiftUI
fn swift_color(c: Color) -> String {
@@ -1142,6 +1209,14 @@ mod tests {
use crate::params::id_of;
use serde_json::json;
#[test]
fn num_keeps_huge_whole_numbers() {
assert_eq!(num(1e20), "100000000000000000000");
assert_eq!(num(-1e20), "-100000000000000000000");
assert_eq!(num(1e18), "1000000000000000000");
assert_eq!(num(-0.0), "0");
}
fn card(s: &mut Session) -> NodeId {
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()["id"]).unwrap();
s.execute("node.set", &json!({"id": f, "props": {"fills": ["#FFFFFF"], "cornerRadius": 12}})).unwrap();
+3 -3
View File
@@ -246,9 +246,9 @@ fn a_group_of_commands_syncs_as_one_step() {
let (f, r) = frame_and_rect(room.server.document());
let start = room.server.replica().clone();
let cmds = vec![
("node.set".to_string(), json!({"id": r, "props": {"name": "A"}})),
("node.set".to_string(), json!({"id": f, "props": {"name": "B"}})),
("shape.rectangle".to_string(), json!({"x": 0, "y": 0, "width": 5, "height": 5})),
crate::BatchStep::new("node.set", json!({"id": r, "props": {"name": "A"}})),
crate::BatchStep::new("node.set", json!({"id": f, "props": {"name": "B"}})),
crate::BatchStep::new("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5})),
];
room.s(0).batch("Three", &cmds).unwrap();
room.pump();
+5
View File
@@ -225,7 +225,9 @@ pub fn type_name(name: &str) -> String {
/// Words no generated parameter may be called (keywords of TypeScript, Swift and Kotlin, and
/// names the generated code uses).
const RESERVED: &[&str] = &[
"arguments",
"as",
"await",
"body",
"break",
"case",
@@ -239,6 +241,7 @@ const RESERVED: &[&str] = &[
"do",
"else",
"enum",
"eval",
"export",
"extends",
"extension",
@@ -249,6 +252,7 @@ const RESERVED: &[&str] = &[
"function",
"guard",
"if",
"implements",
"import",
"in",
"init",
@@ -264,6 +268,7 @@ const RESERVED: &[&str] = &[
"operator",
"package",
"private",
"protected",
"protocol",
"public",
"ref",
@@ -43,6 +43,18 @@ fn button_set(s: &mut Session) -> (NodeId, NodeId, NodeId) {
(set, variants[0], variants[1])
}
#[test]
fn strict_mode_reserved_words_are_not_parameter_names() {
let mut s = Session::new();
let (c, title, _) = card(&mut s);
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Protected", "type": "text", "layer": title}));
let react = code(&mut s, c, "react");
assert!(react.contains("protectedValue"), "React renames the property:\n{react}");
for bad in [" protected?", " protected =", "{protected}"] {
assert!(!react.contains(bad), "reserved word used as a binding ({bad:?}):\n{react}");
}
}
#[test]
fn components_take_their_properties_as_parameters() {
let mut s = Session::new();
+69
View File
@@ -302,6 +302,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
root.code_links.clear();
// The main's dev resources show on its instances without being copied.
root.dev_resources.clear();
// Measurements belong to the layer they were placed on.
root.measurements.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`).
@@ -720,6 +722,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
let prev = old.get(&nid);
// Annotations belong to the layer they were added to, not to its copies.
n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default();
n.measurements = prev.map(|o| o.measurements.clone()).unwrap_or_default();
if let Some(o) = prev {
carry_overrides(o, &mut n, Carry::Values);
keep_laid_out(doc, orig, o, &mut n);
@@ -797,6 +800,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT
fresh.code_links.clear();
// An instance's own dev resources (its main's are listed with them, not copied).
fresh.dev_resources = root.dev_resources.clone();
fresh.measurements = root.measurements.clone();
fresh.locked = root.locked;
fresh.visible = root.visible;
fresh.var_modes = root.var_modes.clone();
@@ -1223,9 +1227,73 @@ fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str)
false
}
/// The components sharing the properties of component `c`: the variants of its set, or `c` alone.
pub fn property_owners(doc: &Document, c: NodeId) -> Vec<NodeId> {
match variant_set(doc, c).and_then(|s| doc.node(s)) {
Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(),
None => vec![c],
}
}
/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding
/// their layers bound to it: on a main component, a boolean property's value is the visibility of
/// its layers. Returns the components and layers it changed.
pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec<NodeId> {
let mut out = Vec::new();
for &c in comps {
let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue };
let Some(PropDef::Boolean { default }) = props.get(name) else { continue };
if *default != value
&& let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind)
{
props.insert(name.to_string(), PropDef::Boolean { default: value });
out.push(c);
}
// The main's own layers (not those of instances nested in it, which follow their mains).
let layers: Vec<NodeId> = doc
.subtree(c)
.into_iter()
.filter(|d| *d != c && !inside_instance(doc, *d))
.filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name)))
.collect();
for d in layers {
if let Some(n) = doc.node_mut(d) {
n.visible = value;
out.push(d);
}
}
}
out
}
/// A main component's layer bound to a boolean property was shown or hidden: that is the
/// property's new default, which its other bound layers (in every variant) take too. Returns the
/// components and layers that changed as a result.
fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec<NodeId> {
let mut out = Vec::new();
for &id in changed {
let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue };
if n.visible == b.visible || inside_instance(doc, id) {
continue;
}
let Some(name) = n.prop_refs.get("visible").cloned() else { continue };
let visible = n.visible;
let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue };
out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible));
}
out
}
/// After an edit: record overrides inside instances, then rebuild instances whose main changed,
/// whose properties or main were switched, or whose overrides were reset.
pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet<NodeId>) {
let mut changed = changed.to_vec();
for id in follow_boolean_defaults(doc, before, &changed) {
if !changed.contains(&id) {
changed.push(id);
}
}
let changed = changed.as_slice();
// 0. A slot whose layers were added, removed or reordered holds the instance's own content
// from now on; copies of instance layers placed elsewhere stop mirroring their main.
for &id in changed {
@@ -1591,6 +1659,7 @@ fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option<NodeId
n.overrides.clear();
n.prop_refs.clear();
n.annotations.clear();
n.measurements.clear();
insert_linked(doc, n).ok()?;
}
map.get(&src).copied()
+84
View File
@@ -429,6 +429,90 @@ fn component_properties_apply_to_instances() {
healthy(&s);
}
/// The default of boolean property `name` on component `c`.
fn bool_default(s: &Session, c: NodeId, name: &str) -> Option<bool> {
match &node(s, c).kind {
NodeKind::Component { props, .. } => match props.get(name) {
Some(PropDef::Boolean { default }) => Some(*default),
_ => None,
},
_ => None,
}
}
#[test]
fn boolean_property_default_is_the_mains_visibility() {
let mut s = Session::new();
let (c, bg, title, dot) = card(&mut s);
// #993: a `false` default hides the bound layer in the main, not only in instances.
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show dot", "type": "boolean", "default": false, "layer": dot}));
assert!(!node(&s, dot).visible);
let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0})));
assert!(!node(&s, copy_of(&s, i, dot)).visible);
// Without a default, the property takes the layer's visibility.
ex(&mut s, "node.set", json!({"id": bg, "props": {"visible": false}}));
ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show bg", "type": "boolean", "layer": bg}));
assert_eq!(bool_default(&s, c, "Show bg"), Some(false));
assert!(!node(&s, bg).visible);
// Binding a layer to an existing property shows it as the default says.
ex(&mut s, "component.bindProperty", json!({"layer": title, "property": "Show dot"}));
assert!(!node(&s, title).visible);
// A new default shows or hides every bound layer of the main; instances keep their values.
ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false}}));
ex(&mut s, "component.editProperty", json!({"id": c, "name": "Show dot", "default": true}));
assert!(node(&s, dot).visible && node(&s, title).visible);
assert!(!node(&s, copy_of(&s, i, dot)).visible, "the instance's own value stays");
// Renamed with a new default in one go.
ex(&mut s, "component.editProperty", json!({"id": c, "name": "Show dot", "newName": "Dot", "default": false}));
assert!(!node(&s, dot).visible && !node(&s, title).visible);
assert_eq!(bool_default(&s, c, "Dot"), Some(false));
// Showing a bound layer on the main is the property's new default, for its other layers too.
ex(&mut s, "node.set", json!({"id": dot, "props": {"visible": true}}));
assert_eq!(bool_default(&s, c, "Dot"), Some(true));
assert!(node(&s, title).visible);
let j = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 600, "y": 0})));
assert!(node(&s, copy_of(&s, j, dot)).visible && node(&s, copy_of(&s, j, title)).visible);
assert!(!node(&s, copy_of(&s, i, dot)).visible, "existing instances keep their values");
// Hiding a layer inside an instance is an override there, not a new default.
let jdot = copy_of(&s, j, dot);
ex(&mut s, "node.set", json!({"id": jdot, "props": {"visible": false}}));
assert_eq!(bool_default(&s, c, "Dot"), Some(true));
assert!(node(&s, dot).visible);
// Undo puts the default and the layers back together.
s.undo().unwrap();
s.undo().unwrap();
s.undo().unwrap();
assert_eq!(bool_default(&s, c, "Dot"), Some(false));
assert!(!node(&s, dot).visible && !node(&s, title).visible);
healthy(&s);
}
#[test]
fn boolean_property_default_follows_across_variants() {
let mut s = Session::new();
let mut variants = Vec::new();
let mut labels = Vec::new();
for (k, state) in ["Default", "Hover"].iter().enumerate() {
let f =
idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": format!("State={state}")})));
labels.push(idv(&ex(&mut s, "text.create", json!({"text": *state, "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}}))));
variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]}))));
}
let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()})));
ex(&mut s, "component.addProperty", json!({"id": set, "name": "Show label", "type": "boolean", "default": false, "layer": labels[0]}));
ex(&mut s, "component.bindProperty", json!({"layer": labels[1], "property": "Show label"}));
assert!(labels.iter().all(|l| !node(&s, *l).visible));
ex(&mut s, "component.editProperty", json!({"id": set, "name": "Show label", "default": true}));
assert!(labels.iter().all(|l| node(&s, *l).visible));
// Hiding the label in one variant is the set's default: the other variant's label follows.
ex(&mut s, "node.set", json!({"id": labels[1], "props": {"visible": false}}));
assert!(variants.iter().all(|v| bool_default(&s, *v, "Show label") == Some(false)));
assert!(!node(&s, labels[0]).visible);
let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200})));
assert!(!node(&s, kids(&s, i)[0]).visible);
healthy(&s);
}
#[test]
fn push_changes_to_main() {
let mut s = Session::new();
+4
View File
@@ -70,6 +70,7 @@ fn scene() -> (Session, NodeId, NodeId) {
const EMPTY: (f64, f64) = (700.0, 600.0);
const ON_SQUARE: (f64, f64) = (150.0, 150.0);
const ON_SQUARE_EDGE: (f64, f64) = (101.0, 150.0);
const ON_TEXT: (f64, f64) = (420.0, 120.0);
#[test]
@@ -101,6 +102,9 @@ fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() {
(ToolId::Text, NONE, EMPTY, Crosshair, "the Text tool makes a text where it is pressed"),
(ToolId::Text, NONE, ON_SQUARE, Crosshair, "over a shape too"),
(ToolId::Text, NONE, ON_TEXT, Text, "the I-beam over a text: a click edits it there"),
(ToolId::TextOnPath, NONE, EMPTY, Default, "text on a path: a click on empty canvas does nothing"),
(ToolId::TextOnPath, NONE, ON_SQUARE, Default, "nor inside a shape, away from its path"),
(ToolId::TextOnPath, NONE, ON_SQUARE_EDGE, Text, "the I-beam over a shape's path: a click puts text on it"),
(ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"),
(ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"),
(ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"),
+219
View File
@@ -0,0 +1,219 @@
//! `data.fill`: one frame repeated per row of data (a carousel, a set of posts, cards from a
//! list): each copy gets its row's texts, colours and visibility on the layers named by the row's
//! keys, in one undo step, and optionally each copy exported as a file. Agents make a series with
//! one call instead of a duplicate, a lookup and an edit per layer and an export per copy.
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::params::{get, id, id_of, num_or, string};
use crate::{EngineError, Result, Session, bad, spec};
const CMD: &str = "data.fill";
const MAX_ROWS: usize = 500;
pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"data.fill",
"Fill copies with data",
"{id (the frame to repeat), rows: [{<layer name>: text | {text?, fill? (#rrggbb), visible?, fit? (true or text.fit's {maxLines?, minSize?, orphans?}: size the text to its box)}, name? (the copy's name)}] (every layer with that name in the copy; up to 500 rows), gap? (between copies, placed in a row to the right; default 100), formats? ([{name?, width, height}]: each row once per size, the copy resized so its auto layout reflows and named <name>-<format name>; e.g. feed 1080×1350, story 1080×1920, square 1080×1080), dir? (export each copy there as <name>.<format>), format? (png|jpg|webp|svg|pdf; default png), scale? (default 1)} one undo step → {ids (the copies, in row order), missing (keys no layer is named), files? (paths written, one per copy: same file names get a numbered suffix), errors? ([{id, path, error}] for exports that failed; the copies stay)}",
false,
fill
)]
}
/// A file name from a layer name: letters, digits, spaces, `-` and `_` kept, the rest `-`.
fn file_name(name: &str) -> String {
let s: String = name.chars().take(80).map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '-' }).collect();
let s = s.trim().to_string();
if s.is_empty() { "copy".into() } else { s }
}
fn fill(s: &mut Session, p: &Value) -> Result<Value> {
// Refused before any copy or file is made: a rollback afterwards wouldn't take files back.
if let Some(why) = s.live_read_only(s.ds()?.uid) {
return Err(EngineError::Disabled(CMD.into(), why.into()));
}
let src = id(p, "id").ok_or_else(|| bad(CMD, "`id` (the frame to repeat) required"))?;
let rows = match get(p, "rows") {
Some(Value::Array(a)) if a.iter().all(Value::is_object) && a.len() <= MAX_ROWS => a.clone(),
_ => return Err(bad(CMD, "rows: an array of objects (up to 500)")),
};
let gap = num_or(p, "gap", 100.0);
if !gap.is_finite() {
return Err(bad(CMD, "gap: a number"));
}
let format = string(p, "format").unwrap_or_else(|| "png".into()).to_ascii_lowercase();
if !["png", "jpg", "webp", "svg", "pdf"].contains(&format.as_str()) {
return Err(bad(CMD, "format: png|jpg|webp|svg|pdf"));
}
let (width, base) = {
let doc = s.doc()?;
let n = doc.node(src).ok_or_else(|| bad(CMD, "no such layer"))?;
(n.width, n.name.clone())
};
let formats = formats(p)?;
let column = formats.iter().flatten().map(|f| f.1).fold(width, f64::max);
let grid = Grid { src, base, column, gap };
let mut missing: Vec<String> = Vec::new();
let ids = s.grouped("Fill copies with data", |s| {
let mut ids = Vec::with_capacity(rows.len() * formats.len());
for (i, row) in rows.iter().enumerate() {
let mut dy = 0.0;
for fmt in &formats {
if let Some(made) = copy_row(s, &grid, (i, row), fmt.as_ref(), &mut dy, &mut missing)? {
ids.push(made);
}
}
}
Ok(ids)
})?;
let mut out = json!({"ids": ids.iter().map(|(i, _)| i.0).collect::<Vec<_>>(), "missing": missing});
// Exports after the edits landed: a failed edit writes no files. A failed export keeps the
// copies (one undo step removes them) and is reported with the files that were written.
if let Some(dir) = string(p, "dir") {
export_copies(s, &ids, &dir, (&format, num_or(p, "scale", 1.0)), &mut out);
}
Ok(out)
}
/// A size the copies are made in: its name, width and height.
type Format = (String, f64, f64);
/// `formats`: each row once per size, the copy resized so its auto layout reflows (`None`: the
/// frame's own size).
fn formats(p: &Value) -> Result<Vec<Option<Format>>> {
match get(p, "formats") {
None | Some(Value::Null) => Ok(vec![None]),
Some(Value::Array(a)) if !a.is_empty() && a.len() <= 12 => a.iter().map(format_of).collect(),
Some(_) => Err(bad(CMD, "formats: [{name?, width, height}] (1 to 12 sizes)")),
}
}
/// One `formats` entry, named `<width>x<height>` when it has no name.
fn format_of(f: &Value) -> Result<Option<Format>> {
let (w, h) = (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64));
let name = f.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_string();
match (w, h) {
(Some(w), Some(h)) if w.is_finite() && h.is_finite() && (1.0..=20_000.0).contains(&w) && (1.0..=20_000.0).contains(&h) => {
Ok(Some((if name.is_empty() { format!("{}x{}", w as i64, h as i64) } else { name }, w, h)))
}
_ => Err(bad(CMD, "formats: [{name?, width, height}] with sizes from 1 to 20000")),
}
}
/// Where the copies go: the frame repeated, its name, the column width and the gap.
struct Grid {
src: NodeId,
base: String,
column: f64,
gap: f64,
}
/// Row `i` in format `fmt`: duplicate the frame (rows go right; a row's formats stack downwards
/// from `dy`), name the copy and fill its layers. The copy and its name.
fn copy_row(
s: &mut Session,
g: &Grid,
(i, row): (usize, &Value),
fmt: Option<&Format>,
dy: &mut f64,
missing: &mut Vec<String>,
) -> Result<Option<(NodeId, String)>> {
let dx = (g.column + g.gap) * (i as f64 + 1.0);
let dup = s.execute("edit.duplicate", &json!({"ids": [g.src], "dx": dx, "dy": *dy}))?;
let copy = dup["ids"].get(0).and_then(id_of).ok_or_else(|| bad(CMD, "the frame couldn't be duplicated"))?;
let Some(fields) = row.as_object() else { return Ok(None) };
let name = fields.get("name").and_then(Value::as_str).map_or_else(|| format!("{} {}", g.base, i + 1), str::to_string);
let name = match fmt {
Some((suffix, w, h)) => {
s.execute("node.set", &json!({"id": copy, "props": {"width": w, "height": h}}))?;
*dy += h + g.gap;
format!("{name}-{suffix}")
}
None => name,
};
s.execute("node.set", &json!({"id": copy, "props": {"name": name}}))?;
for (key, value) in fields.iter().filter(|(k, _)| k.as_str() != "name") {
fill_layers(s, copy, (key, value), missing)?;
}
Ok(Some((copy, name)))
}
/// Give every layer named `key` in `copy` the row's `value`: its text, fill, visibility and fit.
/// A key no layer has goes on `missing` (once).
fn fill_layers(s: &mut Session, copy: NodeId, (key, value): (&str, &Value), missing: &mut Vec<String>) -> Result<()> {
let doc = s.doc()?;
let targets: Vec<_> = doc.subtree(copy).into_iter().filter(|t| *t != copy && doc.node(*t).is_some_and(|n| n.name == key)).collect();
if targets.is_empty() {
if !missing.iter().any(|m| m == key) {
missing.push(key.to_string());
}
return Ok(());
}
let (text, fill, visible, fit) = match value {
Value::Object(o) => (
o.get("text").map(text_of),
o.get("fill").and_then(Value::as_str),
o.get("visible").and_then(Value::as_bool),
o.get("fit").cloned().filter(|f| !matches!(f, Value::Bool(false) | Value::Null)),
),
v => (Some(text_of(v)), None, None, None),
};
let mut texts = Vec::new();
for t in targets {
let is_text = s.doc()?.node(t).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_)));
if let Some(text) = text.as_ref().filter(|_| is_text) {
s.execute("text.set", &json!({"id": t, "characters": text}))?;
}
if is_text {
texts.push(t.0);
}
if let Some(f) = fill {
s.execute("node.set", &json!({"id": t, "props": {"fills": [f]}}))?;
}
if let Some(v) = visible {
s.execute("node.set", &json!({"id": t, "props": {"visible": v}}))?;
}
}
// `fit: true` or `{maxLines?, minSize?, orphans?}`: size the text to its box.
if let Some(f) = fit.filter(|_| !texts.is_empty()) {
let mut params = if f.is_object() { f } else { json!({}) };
params["ids"] = json!(texts);
s.execute("text.fit", &params)?;
}
Ok(())
}
/// Export each copy into `dir` as `<name>.<format>` at `scale`, adding `files` (and `errors`) to
/// `out`.
fn export_copies(s: &mut Session, ids: &[(NodeId, String)], dir: &str, (format, scale): (&str, f64), out: &mut Value) {
let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::<String>::new());
for (copy, name) in ids {
// One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively,
// as macOS and Windows file systems compare names).
let base = file_name(name);
let stem =
(1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base);
used.push(stem.to_lowercase());
let path = std::path::Path::new(dir).join(format!("{stem}.{format}")).to_string_lossy().to_string();
match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": scale, "path": path})) {
Ok(_) => files.push(path),
Err(e) => errors.push(json!({"id": copy.0, "path": path, "error": e.to_string()})),
}
}
out["files"] = json!(files);
if !errors.is_empty() {
out["errors"] = json!(errors);
}
}
/// A cell as text: strings as they are, numbers and booleans as written, null as empty.
fn text_of(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Null => String::new(),
v => v.to_string(),
}
}
+142
View File
@@ -0,0 +1,142 @@
//! `data.fill`: a frame repeated per row with that row's texts, fills and visibility, in one undo
//! step, optionally exported.
use crate::Session;
use crate::components::tests::{ex, idv};
use figcraft_doc::{NodeId, NodeKind};
use serde_json::json;
/// A 1080×1350 "Post" with "Title" and "Price" texts and a "Badge" rectangle. Returns the frame.
fn post(s: &mut Session) -> NodeId {
let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"})));
ex(s, "text.create", json!({"text": "Title", "x": 80, "y": 80, "parent": f, "props": {"name": "Title"}}));
ex(s, "text.create", json!({"text": "R$ 0", "x": 80, "y": 1200, "parent": f, "props": {"name": "Price"}}));
ex(s, "shape.rectangle", json!({"x": 80, "y": 40, "width": 200, "height": 32, "parent": f, "props": {"name": "Badge", "fill": "#3DDC97"}}));
f
}
fn text(s: &Session, frame: NodeId, name: &str) -> String {
let doc = s.doc().unwrap();
doc.subtree(frame)
.into_iter()
.filter_map(|i| doc.node(i))
.find(|n| n.name == name)
.and_then(|n| match &n.kind {
NodeKind::Text(t) => Some(t.characters.clone()),
_ => None,
})
.unwrap_or_default()
}
#[test]
fn each_row_fills_a_copy_in_one_undo_step() {
let mut s = Session::new();
let f = post(&mut s);
let r = ex(
&mut s,
"data.fill",
json!({"id": f, "rows": [
{"name": "Slide 1", "Title": "Site em 7 dias", "Price": 5500},
{"name": "Slide 2", "Title": "WhatsApp com IA", "Badge": {"visible": false}},
{"Title": "Agenda sozinha", "Badge": {"fill": "#7C5CFF"}, "Subtitle": "nope"}
]}),
);
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(ids.len(), 3);
assert_eq!(r["missing"], json!(["Subtitle"]));
assert_eq!(text(&s, ids[0], "Title"), "Site em 7 dias");
assert_eq!(text(&s, ids[0], "Price"), "5500");
assert_eq!(text(&s, ids[2], "Title"), "Agenda sozinha");
assert_eq!(text(&s, f, "Title"), "Title", "the source frame is untouched");
let doc = s.doc().unwrap();
assert_eq!(doc.node(ids[0]).unwrap().name, "Slide 1");
assert_eq!(doc.node(ids[2]).unwrap().name, "Post 3");
// Copies sit in a row to the right, 100 apart.
let x = |i: NodeId| doc.node(i).unwrap().transform.as_coeffs()[4];
assert_eq!((x(ids[0]), x(ids[1])), (1180.0, 2360.0));
let badge = |i: NodeId| doc.subtree(i).into_iter().filter_map(|k| doc.node(k)).find(|n| n.name == "Badge").cloned().unwrap();
assert!(!badge(ids[1]).visible && badge(ids[0]).visible);
assert_eq!(badge(ids[2]).fills[0].solid_color().map(|c| c.to_hex()), Some("7C5CFF".into()));
// One undo removes every copy.
ex(&mut s, "edit.undo", json!({}));
assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i)));
}
#[test]
fn copies_export_to_a_folder_and_bad_input_is_refused() {
let mut s = Session::new();
let f = post(&mut s);
let dir = std::env::temp_dir().join(format!("figcraft-datafill-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let r = ex(
&mut s,
"data.fill",
json!({"id": f, "rows": [{"name": "Post/1: hero", "Title": "A"}, {"Title": "B"}], "dir": dir.to_string_lossy(), "scale": 0.25}),
);
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
assert_eq!(files.len(), 2);
assert!(files[0].ends_with("Post-1- hero.png"), "{files:?}");
assert!(files.iter().all(|p| std::fs::metadata(p).is_ok_and(|m| m.len() > 0)));
let _ = std::fs::remove_dir_all(&dir);
for bad in [json!({"id": f}), json!({"id": f, "rows": [1, 2]}), json!({"id": f, "rows": [], "format": "gif"}), json!({"id": 999_999, "rows": []})]
{
assert!(s.execute("data.fill", &bad).is_err(), "{bad}");
}
}
#[test]
fn each_row_comes_in_every_format() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
ex(&mut s, "node.set", json!({"id": f, "props": {"sizingV": "fixed", "padding": 80}}));
let t = idv(&ex(&mut s, "text.create", json!({"text": "Título", "x": 0, "y": 0, "parent": f, "props": {"name": "Title", "fontSize": 96}})));
ex(&mut s, "node.set", json!({"id": t, "props": {"sizingH": "fill", "textAutoResize": "height"}}));
let r = ex(
&mut s,
"data.fill",
json!({"id": f, "formats": [
{"name": "feed", "width": 1080, "height": 1350}, {"name": "story", "width": 1080, "height": 1920}, {"width": 1080, "height": 1080}
], "rows": [{"name": "capa", "Title": "Site em 7 dias"}, {"name": "prova", "Title": "Lighthouse 100"}]}),
);
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
assert_eq!(ids.len(), 6);
let doc = s.doc().unwrap();
let names: Vec<String> = ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect();
assert_eq!(names, ["capa-feed", "capa-story", "capa-1080x1080", "prova-feed", "prova-story", "prova-1080x1080"]);
let size = |i: NodeId| (doc.node(i).unwrap().width, doc.node(i).unwrap().height);
assert_eq!(size(ids[1]), (1080.0, 1920.0));
assert_eq!(size(ids[2]), (1080.0, 1080.0));
assert_eq!(text(&s, ids[4], "Title"), "Lighthouse 100");
// Formats of a row stack downwards; rows go right without overlapping.
let at = |i: NodeId| (doc.node(i).unwrap().transform.as_coeffs()[4], doc.node(i).unwrap().transform.as_coeffs()[5]);
assert_eq!(at(ids[0]).0, at(ids[1]).0);
assert!(at(ids[1]).1 >= at(ids[0]).1 + 1350.0);
assert!(at(ids[3]).0 >= at(ids[0]).0 + 1080.0);
for bad in [json!([]), json!([{"width": 0, "height": 10}]), json!("feed")] {
assert!(s.execute("data.fill", &json!({"id": f, "rows": [{}], "formats": bad})).is_err(), "{bad}");
}
}
#[test]
fn review_export_names_are_unique_and_failures_are_reported() {
let mut s = Session::new();
let f = post(&mut s);
let dir = std::env::temp_dir().join(format!("figcraft-datafill-names-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let r = ex(
&mut s,
"data.fill",
json!({"id": f, "rows": [{"name": "Post"}, {"name": "post"}, {"name": "a/b"}, {"name": "a:b"}], "dir": dir.to_string_lossy(), "scale": 0.1}),
);
let files: Vec<String> = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect();
let lower: std::collections::HashSet<String> = files.iter().map(|f| f.to_lowercase()).collect();
assert_eq!(lower.len(), 4, "one file per copy: {files:?}");
assert!(files.iter().all(|p| std::fs::metadata(p).is_ok()), "{files:?}");
let _ = std::fs::remove_dir_all(&dir);
// A folder that can't be written: the copies stay and every failure is reported.
let r = ex(&mut s, "data.fill", json!({"id": f, "rows": [{"name": "x"}], "dir": "/nonexistent-figcraft-dir/out"}));
assert_eq!(r["ids"].as_array().unwrap().len(), 1);
assert_eq!(r["errors"].as_array().map(Vec::len), Some(1), "{r}");
assert!(s.doc().unwrap().contains(NodeId(r["ids"][0].as_u64().unwrap())));
}
+1 -1
View File
@@ -4,7 +4,7 @@
use base64::Engine as _;
use serde_json::json;
use super::*;
use super::{NodeId, Session, Value, params};
use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer};
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
+222
View File
@@ -0,0 +1,222 @@
//! Measurements kept on the canvas (Dev Mode): a developer places one between two layers, or
//! across one side of a layer, and everyone who opens the file sees it. Each is stored on the layer
//! it starts from (`Node::measurements`: saved, synced, undoable) and drawn from the layers'
//! current bounds, so it follows them when they move or resize. One whose other layer was deleted
//! or moved to another page is neither drawn nor listed (undoing the deletion brings it back).
use figcraft_doc::{Document, Measurement, Node, NodeId, Side};
use figcraft_geom::{Point, Rect};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::distance::{between, measured};
use crate::params::{get, opt_id, opt_index};
use crate::snap::Guide;
use crate::{EngineError, Result, Session, bad, spec};
/// Most measurements starting from one layer.
pub const MAX_PER_LAYER: usize = 64;
/// Most measurements listed or drawn for one page.
pub const MAX_LISTED: usize = 2_000;
/// A measurement on a page: the layer it starts from, its index there, what it measures and the
/// lines it draws (page space).
#[derive(Clone, Debug, PartialEq)]
pub struct Placed {
pub from: NodeId,
pub index: usize,
pub measurement: Measurement,
pub lines: Vec<Guide>,
}
impl Placed {
/// Whether it starts or ends at layer `id`.
pub fn touches(&self, id: NodeId) -> bool {
self.from == id || self.measurement.to == Some(id)
}
/// The distances it shows, as on its labels.
pub fn labels(&self) -> Vec<&str> {
self.lines.iter().filter_map(|g| g.label.as_deref()).collect()
}
}
/// The line along side `side` of box `b`, labelled with that side's length.
fn side_line(b: Rect, side: Side) -> Vec<Guide> {
let (p, q) = match side {
Side::Top => (Point::new(b.x0, b.y0), Point::new(b.x1, b.y0)),
Side::Bottom => (Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)),
Side::Left => (Point::new(b.x0, b.y0), Point::new(b.x0, b.y1)),
Side::Right => (Point::new(b.x1, b.y0), Point::new(b.x1, b.y1)),
Side::Auto => return Vec::new(),
};
let mut out = Vec::new();
measured(&mut out, p, q);
out
}
/// The lines measurement `m` from layer `from` draws, in page space (none when a layer is gone).
pub fn lines(doc: &Document, from: NodeId, m: Measurement) -> Vec<Guide> {
let Some(a) = doc.world_bounds(from) else { return Vec::new() };
match (m.to, m.side) {
(Some(to), _) => doc.world_bounds(to).map(|b| between(a, b)).unwrap_or_default(),
(None, Some(side)) => side_line(a, side),
(None, None) => Vec::new(),
}
}
/// The measurements on `page` whose layers are both there, in layer order (bounded).
pub fn on_page(doc: &Document, page: NodeId) -> Vec<Placed> {
let mut out = Vec::new();
let mut stack = vec![page];
let mut visited = 0usize;
while let Some(id) = stack.pop() {
visited += 1;
if out.len() >= MAX_LISTED || visited > 200_000 {
break;
}
let Some(n) = doc.node(id) else { continue };
for (index, m) in n.measurements.iter().enumerate() {
if m.to.is_some_and(|t| t == id || doc.page_of(t) != Some(page)) {
continue;
}
out.push(Placed { from: id, index, measurement: *m, lines: lines(doc, id, *m) });
}
stack.extend(n.children.iter().rev().copied());
}
out
}
fn placed_json(doc: &Document, p: &Placed) -> Value {
let name = |id: NodeId| doc.node(id).map(|n| n.name.clone()).unwrap_or_default();
let mut j = json!({"from": p.from, "fromName": name(p.from), "index": p.index, "labels": p.labels(), "lines": p.lines});
if let Some(t) = p.measurement.to {
j["to"] = json!(t);
j["toName"] = json!(name(t));
}
if let Some(s) = p.measurement.side {
j["side"] = json!(s);
}
j
}
/// The layer a command starts from: `from`, else the first selected layer; never a page.
fn from_layer(cmd: &str, s: &Session, p: &Value) -> Result<NodeId> {
let id = match opt_id(cmd, p, "from")? {
Some(id) => id,
None => s.selection().first().copied().ok_or_else(|| bad(cmd, "select a layer or pass `from`"))?,
};
let n = s.doc()?.node(id).ok_or(EngineError::NoNode(id))?;
if n.is_page() {
return Err(bad(cmd, "measure from a layer, not a page"));
}
Ok(id)
}
/// The `side` parameter: top, right, bottom or left.
fn side_param(cmd: &str, p: &Value) -> Result<Option<Side>> {
match get(p, "side") {
None | Some(Value::Null) => Ok(None),
Some(Value::String(v)) => Side::from_id(v).filter(|s| *s != Side::Auto).map(Some).ok_or_else(|| bad(cmd, "side: top, right, bottom or left")),
Some(_) => Err(bad(cmd, "side: top, right, bottom or left")),
}
}
fn add(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "devMode.addMeasurement";
let from = from_layer(cmd, s, p)?;
let (to, side) = (opt_id(cmd, p, "to")?, side_param(cmd, p)?);
let doc = s.doc()?;
let n = doc.node(from).ok_or(EngineError::NoNode(from))?;
let to = match (to, side) {
(Some(_), Some(_)) => return Err(bad(cmd, "pass `to` or `side`, not both")),
(Some(t), None) => Some(t),
(None, Some(_)) => None,
// Neither: to the layer around it.
(None, None) => Some(
n.parent
.filter(|q| doc.node(*q).is_some_and(|q| !q.is_page()))
.ok_or_else(|| bad(cmd, "pass `to` or `side` (the layer is at the top of its page)"))?,
),
};
if let Some(t) = to {
let target = doc.node(t).ok_or(EngineError::NoNode(t))?;
if t == from {
return Err(bad(cmd, "measure to another layer, or across a `side` of this one"));
}
if target.is_page() || doc.page_of(t) != doc.page_of(from) {
return Err(bad(cmd, "measure to a layer on the same page"));
}
}
let m = Measurement { to, side };
// The same two layers measured from the other end are the same measurement.
let back = to.and_then(|t| doc.node(t)).is_some_and(|t| t.measurements.contains(&Measurement { to: Some(from), side: None }));
if n.measurements.contains(&m) || back {
return Err(bad(cmd, "that measurement is already there"));
}
if n.measurements.len() >= MAX_PER_LAYER {
return Err(bad(cmd, format!("a layer starts at most {MAX_PER_LAYER} measurements")));
}
let drawn = lines(doc, from, m);
let n = s.doc_mut()?.get_mut(from)?;
n.measurements.push(m);
Ok(json!({"from": from, "index": n.measurements.len() - 1, "lines": drawn}))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "devMode.removeMeasurement";
let from = from_layer(cmd, s, p)?;
let index = opt_index(cmd, p, "index")?;
let n = s.doc_mut()?.get_mut(from)?;
let count = n.measurements.len();
if count == 0 {
return Err(bad(cmd, "no measurement starts from this layer"));
}
let removed = match index {
Some(i) if i < count => {
n.measurements.remove(i);
1
}
Some(i) => return Err(bad(cmd, format!("no measurement {i} (the layer starts {count})"))),
None => std::mem::take(&mut n.measurements).len(),
};
Ok(json!({"removed": removed}))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "devMode.measurements";
let doc = s.doc()?;
let page = match opt_id(cmd, p, "page")? {
Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg,
Some(_) => return Err(bad(cmd, "`page` must be a page id")),
None => s.page()?,
};
let only = opt_id(cmd, p, "id")?;
Ok(Value::Array(on_page(doc, page).iter().filter(|m| only.is_none_or(|id| m.touches(id))).map(|m| placed_json(doc, m)).collect()))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"devMode.addMeasurement",
"Add measurement",
"{from? (default the first selected layer), to? (a layer on the same page), side? (top|right|bottom|left)} keeps a measurement on the canvas: between `from` and `to`, across one side of `from`, or (neither) to the layer around it; Dev Mode draws it and it follows the layers → {from, index, lines}",
true,
add
),
spec!(
"devMode.removeMeasurement",
"Remove measurement",
"{from? (default the first selected layer), index? (omit: every measurement starting from the layer)} → {removed}",
true,
remove
),
spec!(
"devMode.measurements",
"List measurements",
"{page? (default the current page), id? (only those from or to this layer)} → [{from, fromName, index, to?, toName?, side?, labels, lines: [{x0, y0, x1, y1, label, dashed}]}] (page space)",
false,
list
),
]
}
+204
View File
@@ -0,0 +1,204 @@
//! Measurements kept on the canvas: placed between two layers, across a side or to the layer
//! around one, following the layers, removed, undoable, copied with what they measure, refused on
//! bad input, synced, saved (old files without them still open) and allowed in Dev Mode.
use figcraft_collab::{Change, Prop, Replica};
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
fn ok(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn rect(s: &mut Session, x: f64, w: f64, parent: Option<NodeId>) -> NodeId {
let mut p = json!({"x": x, "y": 20, "width": w, "height": 40});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id_of(&ok(s, "shape.rectangle", p)["id"]).unwrap()
}
/// A frame with two rectangles 30 apart: (frame, a, b).
fn setup() -> (Session, NodeId, NodeId, NodeId) {
let mut s = Session::new();
let f = id_of(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 100}))["id"]).unwrap();
let a = rect(&mut s, 20.0, 50.0, Some(f));
let b = rect(&mut s, 100.0, 60.0, Some(f));
(s, f, a, b)
}
fn labels(s: &mut Session, id: Option<NodeId>) -> Vec<Vec<String>> {
let p = id.map_or(json!({}), |i| json!({"id": i}));
ok(s, "devMode.measurements", p)
.as_array()
.unwrap()
.iter()
.map(|m| m["labels"].as_array().unwrap().iter().map(|l| l.as_str().unwrap().to_string()).collect())
.collect()
}
#[test]
fn measurements_are_placed_follow_their_layers_and_are_removed() {
let (mut s, f, a, b) = setup();
let m = ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b}));
assert_eq!((&m["from"], &m["index"]), (&json!(a), &json!(0)));
assert_eq!(m["lines"][0]["label"], "30", "the gap between them");
// Across a side: the side's length; with neither, to the frame around it.
ok(&mut s, "devMode.addMeasurement", json!({"from": b, "side": "top"}));
ok(&mut s, "select.set", json!({"ids": [a]}));
ok(&mut s, "devMode.addMeasurement", json!({}));
assert_eq!(labels(&mut s, None), [vec!["30"], vec!["20", "230", "20", "40"], vec!["60"]]);
let listed = ok(&mut s, "devMode.measurements", json!({"id": b}));
assert_eq!(listed.as_array().unwrap().len(), 2, "from or to the layer");
assert_eq!((&listed[1]["side"], &listed[1]["to"]), (&json!("top"), &Value::Null));
assert_eq!(listed[0]["toName"], s.doc().unwrap().node(b).unwrap().name.as_str());
// They follow the layers.
ok(&mut s, "node.set", json!({"id": b, "props": {"x": 110, "width": 80}}));
assert_eq!(labels(&mut s, Some(b)), [vec!["40"], vec!["80"]]);
// Undo takes back an edit, and a placement.
ok(&mut s, "edit.undo", json!({}));
assert_eq!(labels(&mut s, Some(b)), [vec!["30"], vec!["60"]]);
ok(&mut s, "edit.undo", json!({}));
assert_eq!(labels(&mut s, None).len(), 2);
ok(&mut s, "edit.redo", json!({}));
// A deleted layer takes its measurements off the canvas; undo brings them back.
ok(&mut s, "edit.delete", json!({"ids": [b]}));
assert_eq!(labels(&mut s, None), [vec!["20", "230", "20", "40"]]);
ok(&mut s, "edit.undo", json!({}));
assert_eq!(labels(&mut s, None).len(), 3);
// Removed one by one, or all of a layer's.
assert_eq!(ok(&mut s, "devMode.removeMeasurement", json!({"from": a, "index": 1}))["removed"], 1);
assert_eq!(labels(&mut s, None), [vec!["30"], vec!["60"]]);
ok(&mut s, "devMode.addMeasurement", json!({"from": a, "side": "left"}));
assert_eq!(ok(&mut s, "devMode.removeMeasurement", json!({"from": a}))["removed"], 2);
assert!(s.execute("devMode.removeMeasurement", &json!({"from": a})).is_err(), "nothing left");
assert_eq!(labels(&mut s, None), [vec!["60"]]);
let _ = f;
}
#[test]
fn bad_measurements_are_refused() {
let (mut s, f, a, b) = setup();
let page = s.page().unwrap();
let other = id_of(&ok(&mut s, "page.add", json!({}))["id"]).unwrap();
let far = rect(&mut s, 0.0, 10.0, Some(other));
ok(&mut s, "page.select", json!({"id": page}));
let top = rect(&mut s, 400.0, 10.0, None);
ok(&mut s, "select.none", json!({}));
for p in [
json!({"from": a, "to": a}),
json!({"from": a, "to": page}),
json!({"from": a, "to": far}),
json!({"from": a, "to": 99_999}),
json!({"from": page, "side": "top"}),
json!({"from": a, "to": b, "side": "top"}),
json!({"from": a, "side": "middle"}),
json!({"from": a, "side": "auto"}),
json!({"from": a, "side": 3}),
json!({"from": "x", "side": "top"}),
json!({"from": top}),
json!({"side": "top"}),
] {
assert!(s.execute("devMode.addMeasurement", &p).is_err(), "{p}");
}
ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b}));
assert!(s.execute("devMode.addMeasurement", &json!({"from": a, "to": b})).is_err(), "already there");
assert!(s.execute("devMode.addMeasurement", &json!({"from": b, "to": a})).is_err(), "the same two layers");
assert!(s.execute("devMode.removeMeasurement", &json!({"from": a, "index": 5})).is_err());
assert!(s.execute("devMode.measurements", &json!({"page": a})).is_err());
assert_eq!(ok(&mut s, "devMode.measurements", json!({"page": other})), json!([]));
// A layer starts a bounded number.
for _ in 1..crate::dev_measurements::MAX_PER_LAYER {
let t = rect(&mut s, 200.0, 5.0, Some(f));
ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": t}));
}
assert!(s.execute("devMode.addMeasurement", &json!({"from": a, "side": "top"})).is_err(), "capped");
assert_eq!(s.doc().unwrap().node(a).unwrap().measurements.len(), crate::dev_measurements::MAX_PER_LAYER);
}
#[test]
fn copies_measure_what_was_copied_with_them() {
let (mut s, f, a, b) = setup();
ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b}));
ok(&mut s, "devMode.addMeasurement", json!({"from": a, "side": "bottom"}));
// The frame copied: the copy of `a` measures to the copy of `b`.
ok(&mut s, "edit.duplicate", json!({"ids": [f]}));
let copy = s.selection()[0];
let doc = s.doc().unwrap();
let kids = doc.node(copy).unwrap().children.clone();
let ca = doc.node(kids[0]).unwrap();
assert_eq!(ca.measurements.len(), 2);
assert_eq!(ca.measurements[0].to, Some(kids[1]));
// `a` alone copied: only its side measurement comes along.
ok(&mut s, "edit.duplicate", json!({"ids": [a]}));
let lone = s.selection()[0];
let m = &s.doc().unwrap().node(lone).unwrap().measurements;
assert_eq!((m.len(), m[0].to, m[0].side), (1, None, Some(figcraft_doc::Side::Bottom)));
}
#[test]
fn measurements_sync_save_and_old_files_open() {
let (mut s, _, a, b) = setup();
let before = s.doc().unwrap().clone();
ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b}));
ok(&mut s, "devMode.addMeasurement", json!({"from": b, "side": "right"}));
// A change collaborators receive.
let d = figcraft_collab::diff(&Replica::new(before), s.doc().unwrap()).unwrap();
assert!(d.changes.iter().any(|c| matches!(c, Change::Set { id, prop: Prop::Measurements, .. } if *id == a)), "{:?}", d.changes);
// What agents inspect.
assert_eq!(crate::inspect::node_json(s.doc().unwrap(), b, 0)["measurements"], json!([{"side": "right"}]));
// Saved and opened again.
let doc = s.doc().unwrap();
let bytes = figcraft_format::save_bytes(doc, None).unwrap();
let (back, _) = figcraft_format::load_bytes(&bytes).unwrap();
assert_eq!(back.node(a).unwrap().measurements, doc.node(a).unwrap().measurements);
assert_eq!(back.node(b).unwrap().measurements, doc.node(b).unwrap().measurements);
// A file from before measurements (no such field) opens with none.
fn strip(v: &mut Value) {
match v {
Value::Object(o) => {
o.remove("measurements");
o.values_mut().for_each(strip);
}
Value::Array(a) => a.iter_mut().for_each(strip),
_ => {}
}
}
let mut old = figcraft_format::to_json(doc).unwrap();
assert!(old.to_string().contains("\"measurements\""));
strip(&mut old);
let (back, _) = figcraft_format::from_json(&old.to_string()).unwrap();
assert!(back.node(a).unwrap().measurements.is_empty() && back.node(b).unwrap().name == doc.node(b).unwrap().name);
}
#[test]
fn dev_mode_places_measurements() {
use crate::devinspect::dev_mode_allows;
for id in ["devMode.addMeasurement", "devMode.removeMeasurement", "devMode.measurements"] {
assert!(dev_mode_allows(id), "{id}");
}
}
#[test]
fn instances_keep_their_own_measurements_across_main_edits() {
let mut s = Session::new();
let f = id_of(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40}))["id"]).unwrap();
let r = rect(&mut s, 5.0, 10.0, Some(f));
ok(&mut s, "devMode.addMeasurement", json!({"from": r, "side": "top"}));
let main = id_of(&ok(&mut s, "component.create", json!({"ids": [f]}))["id"]).unwrap();
let inst = id_of(&ok(&mut s, "instance.create", json!({"component": main, "x": 200, "y": 0}))["id"]).unwrap();
let inner = s.doc().unwrap().node(inst).unwrap().children[0];
// The main's measurements aren't copied onto its instances.
assert!(s.doc().unwrap().node(inner).unwrap().measurements.is_empty());
ok(&mut s, "devMode.addMeasurement", json!({"from": inner}));
ok(&mut s, "devMode.addMeasurement", json!({"from": inst, "side": "left"}));
ok(&mut s, "node.set", json!({"id": r, "props": {"opacity": 0.5}}));
ok(&mut s, "node.set", json!({"id": main, "props": {"width": 120}}));
let doc = s.doc().unwrap();
assert_eq!(doc.node(inner).unwrap().measurements[0].to, Some(inst), "kept across a main edit");
assert_eq!(doc.node(inst).unwrap().measurements.len(), 1);
assert_eq!(doc.node(r).unwrap().measurements.len(), 1);
}
+7 -5
View File
@@ -219,14 +219,16 @@ pub struct DevItem {
}
/// Whether Dev Mode runs command `id` from the menus and shortcuts: commands that don't edit the
/// document, and Dev Mode's own edits (annotations, statuses, dev resources, code connections,
/// comments). Choosing a tool other than moving around and commenting is refused too, and so is
/// Revert to saved, which isn't an undo step but throws the design's unsaved edits away.
/// document, and Dev Mode's own edits (annotations, statuses, dev resources, measurements, code
/// connections, comments). Choosing a tool other than moving around and commenting is refused too,
/// and so is Revert to saved, which isn't an undo step but throws the design's unsaved edits away.
pub fn dev_mode_allows(id: &str) -> bool {
const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."];
const OWN: [&str; 6] = ["annotation.", "dev.", "devMode.", "devResource.", "codeConnect.", "comment."];
// Edits recorded as one grouped undo step, so their spec says no history of its own.
const GROUPED: [&str; 1] = ["data.fill"];
match id.strip_prefix("tool.") {
Some(t) => matches!(t, "move" | "hand" | "comment"),
None if id == "file.revert" => false,
None if id == "file.revert" || GROUPED.contains(&id) => false,
None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history),
}
}
@@ -124,6 +124,7 @@ fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts
"tool.place",
"file.import",
"file.revert",
"data.fill",
] {
assert!(!dev_mode_allows(id), "{id} edits the design");
}
+1 -1
View File
@@ -34,7 +34,7 @@ pub fn label(d: f64) -> String {
}
/// A labelled distance line, unless it has no length.
fn measured(out: &mut Vec<Guide>, a: Point, b: Point) {
pub(crate) fn measured(out: &mut Vec<Guide>, a: Point, b: Point) {
let d = (b - a).hypot();
if d > EPS && d.is_finite() {
out.push(Guide { label: Some(label(d)), ..Guide::new(a, b) });
+247
View File
@@ -60,6 +60,161 @@ fn text_goes_onto_a_path_and_off_again() {
assert!(s.doc().unwrap().node(v).is_some() && text_path(&s, t).is_none(), "undo brings the path back");
}
/// A click (press and release) at (`x`, `y`) at 100%.
fn click(s: &mut Session, x: f64, y: f64) {
use crate::tools::{PointerEvent, PointerKind};
s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap();
}
fn editing(s: &Session) -> Option<NodeId> {
s.ds().unwrap().text_edit.as_ref().map(|te| te.id)
}
#[test]
fn the_text_on_a_path_tool_types_new_text_onto_the_path_it_clicks() {
let mut s = Session::new();
let v = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100, "props": {"fill": "#FF0000"}})).unwrap());
s.execute("effect.add", &json!({"ids": [v], "type": "dropShadow"})).unwrap();
let (vxf, vw, fills, effects) = {
let n = s.doc().unwrap().node(v).unwrap();
(n.transform, n.width, n.fills.clone(), n.effects.clone())
};
assert!(!fills.is_empty() && !effects.is_empty());
s.set_tool(crate::tools::ToolId::TextOnPath);
// Away from the path a click does nothing.
click(&mut s, 140.0, 160.0);
assert!(editing(&s).is_none() && s.doc().unwrap().node(v).is_some(), "only a path takes text");
// A quarter of the way along, a few pixels off the line.
click(&mut s, 90.0, 103.0);
let t = editing(&s).expect("typing starts on the path");
assert!(s.doc().unwrap().node(v).is_none(), "the path layer became the text's path");
crate::textedit::insert(&mut s, "Along").unwrap();
crate::textedit::end(&mut s).unwrap();
let doc = s.doc().unwrap();
let n = doc.node(t).unwrap();
assert_eq!((n.transform, n.width), (vxf, vw), "in the path's place");
assert_eq!(n.kind.text().map(|t| t.characters.as_str()), Some("Along"));
assert_eq!((&n.fills, &n.effects), (&fills, &effects), "the path's fill and effects carry over to the text");
let tp = text_path(&s, t).unwrap();
assert!((tp.start - 0.25).abs() < 0.01, "the text starts where the path was clicked: {}", tp.start);
assert_eq!(s.tool.tool, crate::tools::ToolId::Move, "back to Move after placing, as with the Text tool");
s.execute("edit.undo", &json!({})).unwrap();
let doc = s.doc().unwrap();
assert!(doc.node(v).is_some() && doc.node(t).is_none(), "one undo step brings the path back");
}
#[test]
fn a_shape_in_front_whose_path_is_away_from_the_click_does_not_hide_the_path_behind() {
let mut s = Session::new();
let back = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100})).unwrap());
// A filled square over the line: the click is inside it, 20 or more from its outline.
let front = id(s.execute("shape.rectangle", &json!({"x": 60, "y": 80, "width": 100, "height": 60})).unwrap());
s.set_tool(crate::tools::ToolId::TextOnPath);
click(&mut s, 90.0, 100.0);
assert!(editing(&s).is_some(), "typing starts on the path behind");
let doc = s.doc().unwrap();
assert!(doc.node(back).is_none() && doc.node(front).is_some(), "the clicked path took the text");
}
#[test]
fn a_path_scaled_more_one_way_is_reached_where_it_shows() {
let mut s = Session::new();
let v = id(s.execute("vector.create", &json!({"path": "M0 0 L100 100", "x": 0, "y": 0})).unwrap());
// Ten times wider than tall: on the page the line runs from (0, 0) to (1000, 100).
s.doc_mut().unwrap().get_mut(v).unwrap().transform = figcraft_geom::Affine::scale_non_uniform(10.0, 1.0);
s.set_tool(crate::tools::ToolId::TextOnPath);
// 5 below the line on the page, halfway along it.
click(&mut s, 500.0, 55.0);
let t = editing(&s).expect("typing starts on the path");
let tp = text_path(&s, t).unwrap();
assert!((tp.start - 0.5).abs() < 0.01, "{}", tp.start);
// A layer shown at a tenth of its size: its outline is still reached 5 screen pixels away.
let mut s = Session::new();
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1000, "height": 1000})).unwrap());
s.doc_mut().unwrap().get_mut(r).unwrap().transform = figcraft_geom::Affine::scale(0.1);
s.set_tool(crate::tools::ToolId::TextOnPath);
click(&mut s, 105.0, 50.0);
assert!(editing(&s).is_some() && s.doc().unwrap().node(r).is_none(), "typing starts on the small square's path");
}
#[test]
fn paths_a_mask_or_a_turned_clipping_frame_hides_take_no_text() {
let mut s = Session::new();
// A square masked by a circle in its top-left corner: its right edge doesn't show.
let m = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap());
let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
s.execute("object.mask", &json!({"ids": [m, r]})).unwrap();
// A clipping frame turned 45°, with a line outside it: inside the frame's bounding box, but clipped.
let f = id(s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 100, "props": {"clip": true}})).unwrap());
let l = id(s.execute("vector.create", &json!({"path": "M0 0 L40 0", "x": -60, "y": 50, "parent": f})).unwrap());
s.execute("node.set", &json!({"id": f, "props": {"rotation": 45}})).unwrap();
let hidden = s.doc().unwrap().world_transform(l) * figcraft_geom::Point::new(20.0, 0.0);
let frame_box = s.doc().unwrap().world_bounds(f).unwrap();
assert!(frame_box.contains(hidden), "the line's middle is inside the frame's bounding box");
s.set_tool(crate::tools::ToolId::TextOnPath);
for (x, y) in [(100.0, 50.0), (hidden.x, hidden.y)] {
click(&mut s, x, y);
assert!(editing(&s).is_none(), "nothing shows at ({x}, {y})");
}
let doc = s.doc().unwrap();
assert!(doc.node(r).is_some() && doc.node(l).is_some());
}
#[test]
fn text_on_a_stroke_only_path_gets_the_usual_fill() {
let mut s = Session::new();
let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100, "props": {"fills": []}})).unwrap());
assert!(!s.doc().unwrap().node(v).unwrap().fills.iter().any(|f| f.visible), "a path with a stroke only");
s.set_tool(crate::tools::ToolId::TextOnPath);
click(&mut s, 40.0, 100.0);
let t = editing(&s).expect("typing starts on the path");
assert!(s.doc().unwrap().node(t).unwrap().fills.iter().any(|f| f.visible), "the text can be seen");
}
#[test]
fn text_on_a_path_left_empty_puts_the_path_back_without_an_undo_step() {
let mut s = Session::new();
let r = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap());
let locked = id(s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 100, "height": 100, "props": {"locked": true}})).unwrap());
let before = (s.doc().unwrap().clone(), s.undo_labels(100).len());
s.set_tool(crate::tools::ToolId::TextOnPath);
click(&mut s, 400.0, 150.0);
assert!(editing(&s).is_none(), "a locked layer's path doesn't take text");
// Inside the square, away from its outline, nothing happens either.
click(&mut s, 150.0, 150.0);
assert!(editing(&s).is_none());
click(&mut s, 100.0, 150.0);
assert!(editing(&s).is_some() && s.doc().unwrap().node(r).is_none());
crate::textedit::end(&mut s).unwrap();
assert_eq!(*s.doc().unwrap(), before.0, "the square is back as it was");
assert_eq!(s.undo_labels(100).len(), before.1, "no undo step");
assert!(s.doc().unwrap().node(locked).is_some());
// A double-click on the path, then nothing typed: the same (with this tool and the Text tool).
use crate::tools::{PointerEvent, PointerKind, ToolId};
for tool in [ToolId::TextOnPath, ToolId::Text] {
s.set_tool(tool);
click(&mut s, 100.0, 150.0);
s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 100.0, 150.0, 1.0)).unwrap();
assert!(editing(&s).is_some(), "{tool:?}");
crate::textedit::end(&mut s).unwrap();
assert_eq!(*s.doc().unwrap(), before.0, "{tool:?}: the file is back as it was");
assert_eq!(s.undo_labels(100).len(), before.1, "{tool:?}: no undo step");
}
}
#[test]
fn scaling_text_scales_its_path() {
let mut s = Session::new();
let t = id(s.execute("text.create", &json!({"text": "Around", "x": 0, "y": 0})).unwrap());
let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100})).unwrap());
s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap();
let w0 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width();
s.execute("transform.scale", &json!({"ids": [t], "factor": 2.0})).unwrap();
let w1 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width();
assert!((w1 - w0 * 2.0).abs() < 1.0, "path width {w0} -> {w1}");
}
#[test]
fn svg_text_paths_import_as_text_on_a_path() {
// SVG text is parsed with Inter standing in for every family (CI has no fonts).
@@ -79,6 +234,83 @@ fn svg_text_paths_import_as_text_on_a_path() {
assert_eq!(t.align_h, figcraft_doc::TextAlignH::Center);
}
/// A text layer with its own green fill bound to `ink` and a layer blur, and a curve; the
/// variables `ink` and `shadow` exist.
fn text_and_curve(s: &mut Session, path_props: Value) -> (NodeId, NodeId) {
let t = id(s.execute("text.create", &json!({"text": "Around", "x": 0, "y": 0})).unwrap());
let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100, "props": path_props})).unwrap());
s.execute("variable.createCollection", &json!({"name": "Paint"})).unwrap();
for (name, value) in [("ink", "#00FF00"), ("shadow", "#FF000080")] {
s.execute("variable.create", &json!({"collection": "Paint", "name": name, "type": "color", "value": value})).unwrap();
}
s.execute("variable.bind", &json!({"ids": [t], "field": "fills/0/color", "variable": "ink"})).unwrap();
s.execute("effect.add", &json!({"ids": [t], "type": "layerBlur"})).unwrap();
(t, v)
}
#[test]
fn text_put_on_a_filled_path_takes_its_fill_and_effects() {
let mut s = Session::new();
let (t, v) = text_and_curve(&mut s, json!({"fill": "#FF0000"}));
let brand = s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": "#3366FF"})).unwrap()["id"].clone();
s.execute("style.apply", &json!({"ids": [v], "style": brand})).unwrap();
s.execute("effect.add", &json!({"ids": [v], "type": "dropShadow"})).unwrap();
s.execute("variable.bind", &json!({"ids": [v], "field": "effects/0/color", "variable": "shadow"})).unwrap();
let path = s.doc().unwrap().node(v).unwrap().clone();
let text = s.doc().unwrap().node(t).unwrap().clone();
assert!(path.styles.fill.is_some() && path.bound.contains_key("effects/0/color"));
s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap();
let n = s.doc().unwrap().node(t).unwrap();
// "the vector object's fill and effects are automatically transferred to the text layer"
assert_eq!((&n.fills, &n.styles.fill), (&path.fills, &path.styles.fill), "the path's fill and its style");
assert_eq!((&n.effects, &n.styles.effect), (&path.effects, &path.styles.effect), "the path's effects");
assert_eq!(n.bound, path.bound, "the path's bindings replace the text's fill binding");
s.execute("edit.undo", &json!({})).unwrap();
let n = s.doc().unwrap().node(t).unwrap();
assert_eq!((&n.fills, &n.styles.fill), (&text.fills, &text.styles.fill), "undo gives the text its own fill back");
assert_eq!((&n.effects, &n.styles.effect), (&text.effects, &text.styles.effect), "undo gives the text its own effects back");
assert_eq!(n.bound, text.bound, "undo gives the text its own fill binding back");
}
#[test]
fn text_put_on_a_stroke_only_path_keeps_its_own_fill_and_effects() {
let mut s = Session::new();
let (t, v) = text_and_curve(&mut s, json!({"fills": []}));
let text = s.doc().unwrap().node(t).unwrap().clone();
assert!(!text.fills.is_empty() && !text.effects.is_empty());
s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap();
let n = s.doc().unwrap().node(t).unwrap();
assert_eq!((&n.fills, &n.effects, &n.bound), (&text.fills, &text.effects, &text.bound), "a path with no fill or effects to give");
}
#[test]
fn text_put_on_a_path_with_an_empty_effect_style_keeps_its_own_effects() {
let mut s = Session::new();
let (t, v) = text_and_curve(&mut s, json!({"fills": []}));
let none = s.execute("style.create", &json!({"type": "effect", "name": "None", "value": []})).unwrap()["id"].clone();
s.execute("style.apply", &json!({"ids": [v], "style": none})).unwrap();
let path = s.doc().unwrap().node(v).unwrap().clone();
assert!(path.effects.is_empty() && path.styles.effect.is_some());
let text = s.doc().unwrap().node(t).unwrap().clone();
s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap();
let n = s.doc().unwrap().node(t).unwrap();
assert_eq!((&n.effects, &n.styles.effect), (&text.effects, &text.styles.effect), "a path whose effect style draws no effects");
}
#[test]
fn text_put_on_a_path_takes_a_fill_binding_made_through_fills() {
let mut s = Session::new();
let (t, v) = text_and_curve(&mut s, json!({"fill": "#FF0000"}));
s.execute("variable.bind", &json!({"ids": [v], "field": "fills", "variable": "shadow"})).unwrap();
s.execute("variable.bind", &json!({"ids": [t], "field": "fills", "variable": "ink"})).unwrap();
let path = s.doc().unwrap().node(v).unwrap().clone();
assert!(path.bound.contains_key("fills"));
s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap();
let n = s.doc().unwrap().node(t).unwrap();
assert_eq!(n.bound, path.bound, "the path's `fills` binding replaces the text's");
assert_eq!(n.fills, path.fills);
}
#[test]
fn pattern_fills_tile_their_source_live() {
let mut s = Session::new();
@@ -131,3 +363,18 @@ fn a_pattern_of_itself_draws_nothing_instead_of_recursing() {
assert!(figcraft_svg::export_svg(doc, &[r], &figcraft_svg::SvgOptions::default()).is_some());
assert!(figcraft_pdf::export_nodes_pdf(doc, &[r]).is_ok());
}
#[test]
fn the_text_on_a_path_tool_carries_a_fill_bound_through_fills() {
let mut s = Session::new();
let v = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100, "props": {"fill": "#FF0000"}})).unwrap());
s.execute("variable.createCollection", &json!({"name": "Paint"})).unwrap();
s.execute("variable.create", &json!({"collection": "Paint", "name": "ink", "type": "color", "value": "#00FF00"})).unwrap();
s.execute("variable.bind", &json!({"ids": [v], "field": "fills", "variable": "ink"})).unwrap();
let bound = s.doc().unwrap().node(v).unwrap().bound.clone();
assert!(bound.contains_key("fills"));
s.set_tool(crate::tools::ToolId::TextOnPath);
click(&mut s, 90.0, 103.0);
let t = editing(&s).expect("typing starts on the path");
assert_eq!(s.doc().unwrap().node(t).unwrap().bound, bound, "the fill keeps following its variable");
}
@@ -256,6 +256,43 @@ fn masks_and_slices_export_what_shows() {
let _ = under;
}
/// Ignore overlapping layers (contents only) on a slice: inside a frame or group the slice exports
/// only what is in that container; off, or on the page, everything in its area.
#[test]
fn a_slice_in_a_frame_or_group_exports_only_that_container() {
let mut s = Session::new();
let slice_in = |s: &mut Session, parent: Option<NodeId>, x: f64| {
let mut p = json!({"type": "slice", "x": x, "y": 25, "width": 50, "height": 50});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id(&ex(s, "node.create", p))
};
let at = |s: &mut Session, p: Value| pixels(&export(s, p)).get_pixel(40, 40).0;
// A frame holding a red square and a slice, with a blue square on the page above it.
let f = frame(&mut s, 0.0, 0.0, 100.0, 100.0, false);
rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#FF0000", Some(f));
let in_frame = slice_in(&mut s, Some(f), 25.0);
rect(&mut s, 50.0, 50.0, 100.0, 100.0, "#0000FF", None);
assert_eq!(png_size(&export(&mut s, json!({"ids": [in_frame]}))), (50, 50), "the slice still exports its own area");
assert_eq!(at(&mut s, json!({"ids": [in_frame]})), [255, 0, 0, 255], "on by default: only the frame's content, the blue square left out");
assert_eq!(at(&mut s, json!({"ids": [in_frame], "contentsOnly": false})), [0, 0, 255, 255], "off: everything in the slice's area");
// The same in a group.
let red = rect(&mut s, 300.0, 0.0, 100.0, 100.0, "#FF0000", None);
let grouped = slice_in(&mut s, None, 325.0);
let g = id(&ex(&mut s, "object.group", json!({"ids": [red, grouped]})));
assert_eq!(s.doc().unwrap().node(grouped).unwrap().parent, Some(g));
rect(&mut s, 350.0, 50.0, 100.0, 100.0, "#0000FF", None);
assert_eq!(at(&mut s, json!({"ids": [grouped]})), [255, 0, 0, 255], "in a group: only the group's content");
assert_eq!(at(&mut s, json!({"ids": [grouped], "contentsOnly": false})), [0, 0, 255, 255], "off: everything in its area");
// On the page the option has no effect.
rect(&mut s, 600.0, 0.0, 100.0, 100.0, "#FF0000", None);
let top = slice_in(&mut s, None, 625.0);
rect(&mut s, 650.0, 50.0, 100.0, 100.0, "#0000FF", None);
assert_eq!(at(&mut s, json!({"ids": [top]})), [0, 0, 255, 255], "on the page: everything in its area, with the option on");
assert_eq!(at(&mut s, json!({"ids": [top], "contentsOnly": false})), [0, 0, 255, 255], "on the page: everything in its area, with it off");
}
#[test]
fn several_layers_and_frames_to_pdf() {
let mut s = Session::new();
+8 -4
View File
@@ -64,16 +64,20 @@ pub fn missing(doc: &Document) -> Vec<Missing> {
used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect()
}
/// Every string value of variable `id` in any mode, following aliases (at most 32 deep).
/// Every string value of variable `id` in any mode, following aliases (each variable once).
fn string_values(vars: &Variables, id: &str) -> Vec<String> {
let mut out = Vec::new();
let mut todo = vec![(id.to_string(), 0)];
while let Some((id, depth)) = todo.pop() {
let mut seen = BTreeSet::new();
let mut todo = vec![id.to_string()];
while let Some(id) = todo.pop() {
if !seen.insert(id.clone()) {
continue;
}
let Some(var) = vars.get(&id) else { continue };
for v in var.values.values() {
match v {
VariableValue::String(s) => out.push(s.clone()),
VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)),
VariableValue::Alias(a) => todo.push(a.clone()),
_ => {}
}
}
+19
View File
@@ -119,6 +119,25 @@ fn blank_families_and_aliases_are_handled() {
assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]);
}
/// Aliases in several modes are followed once per variable: a chain that branches in every mode
/// would otherwise grow to billions of steps (a file can hold one).
#[test]
fn branching_alias_chains_are_walked_once_per_variable() {
let mut s = Session::new();
s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap();
for i in 0..32 {
s.execute("variable.create", &json!({"collection": "Type", "name": format!("V{i}"), "type": "string", "value": "x"})).unwrap();
}
let doc = s.doc_mut().unwrap();
let ids: Vec<String> = doc.variables.variables.iter().map(|v| v.id.clone()).collect();
for (i, var) in doc.variables.variables.iter_mut().enumerate() {
let next = ids.get(i + 1).cloned().unwrap_or_else(|| "FigCraft Missing H".into());
let leaf = |v: String| if i + 1 < ids.len() { VariableValue::Alias(v) } else { VariableValue::String(v) };
var.values = [("m1".to_string(), leaf(next.clone())), ("m2".to_string(), leaf(next))].into();
}
assert_eq!(string_values(&doc.variables, &ids[0]), vec!["FigCraft Missing H", "FigCraft Missing H"]);
}
#[test]
fn needs_an_open_document() {
let mut s = Session::empty();
+3
View File
@@ -144,6 +144,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
if !n.code_links.is_empty() {
o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
}
if !n.measurements.is_empty() {
o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
}
let dev = crate::dev_resources::list_json(doc, n.id);
if dev.as_array().is_some_and(|a| !a.is_empty()) {
o.insert("devResources".into(), dev);
+60 -59
View File
@@ -13,6 +13,7 @@ pub mod al_keys;
pub mod annotations;
pub mod arc_handles;
pub mod background;
pub mod batch;
pub mod board;
pub mod booleans;
pub mod branches;
@@ -23,6 +24,8 @@ pub mod codegen;
pub mod collab;
pub mod component_codegen;
pub mod components;
pub mod datafill;
pub mod dev_measurements;
pub mod dev_resources;
pub mod devcompare;
pub mod devinspect;
@@ -58,13 +61,17 @@ pub mod snap;
pub mod style_vars;
pub mod synthetic;
pub mod synthetic_ds;
pub mod tailwind;
pub mod templates;
pub mod textedit;
pub mod textfit;
pub mod textpreview;
pub mod token_roles;
pub mod tools;
pub mod vectoredit;
pub mod widgets;
pub use batch::{BatchScope, BatchStep};
pub use cmd::{CommandSpec, all_commands, find_command};
pub use figcraft_collab;
pub use figcraft_doc as doc;
@@ -116,47 +123,6 @@ pub fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() }
}
/// Deepest params nesting `resolve_step_refs` walks before giving up.
const STEP_REF_MAX_DEPTH: usize = 64;
/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default
/// `id`) of `results[n]`, the result of an earlier batch step.
fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result<Value> {
if depth > STEP_REF_MAX_DEPTH {
return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels")));
}
match v {
Value::Object(o) if o.contains_key("$step") => {
if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") {
return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`")));
}
let n = o
.get("$step")
.and_then(Value::as_u64)
.ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?;
let field = match o.get("field") {
None => "id",
Some(Value::String(f)) => f.as_str(),
Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))),
};
let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else {
let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) };
return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})")));
};
result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`")))
}
Value::Object(o) => {
let mut out = serde_json::Map::with_capacity(o.len());
for (k, x) in o {
out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?);
}
Ok(Value::Object(out))
}
Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::<Result<Vec<_>>>().map(Value::Array),
_ => Ok(v.clone()),
}
}
/// One undo step.
#[derive(Clone, Debug)]
pub struct HistoryEntry {
@@ -325,6 +291,17 @@ pub struct TextEdit {
pub anchor: usize,
/// The node was created by this edit (deleted if left empty).
pub created: bool,
/// Identity of this editing session (`textedit::begin` assigns a fresh one).
///
/// The IME owner is `(document uid, node)`, which stays the same through ordinary edits
/// and caret moves; this token changes only when editing is started again, so an IME
/// context is never silently inherited by a replaced target that reuses the same node.
///
/// It is deliberately not serialized: current consumers only inspect a live session. A
/// serialized `TextEdit` restored from data would get zero here, so it must not be restored
/// into a live editor without minting (or otherwise invalidating) a new session.
#[serde(default, skip_serializing)]
pub editing_session: u64,
/// An IME composition in progress, shown inline at the caret; the document gets it on commit.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preedit: Option<Preedit>,
@@ -403,7 +380,8 @@ pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore
/// Commands that first end a version preview: they act on the document itself. Saving does so
/// only with a native destination; export copies and pending format choices keep the preview.
const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"];
const ENDS_PREVIEW: &[&str] =
&["file.save", "file.saveAs", "file.markSaved", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"];
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
@@ -687,6 +665,9 @@ pub struct Prefs {
/// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them
/// again).
pub getting_started_dismissed: bool,
/// A prototype's Open link goes to a website without first telling the viewer that it leaves
/// the prototype (the notice's "Don't show this again"; `proto.linkNotice` shows it again).
pub link_notice_skipped: bool,
/// How each home screen section shows its files, by section id (`"recents"`): `"grid"` (cards
/// with the picture each file carries) or `"list"` (a table). Missing: the grid.
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
@@ -736,6 +717,7 @@ impl Default for Prefs {
recent_files: Vec::new(),
online_files: Vec::new(),
getting_started_dismissed: false,
link_notice_skipped: false,
home_views: BTreeMap::new(),
home_sort: "modified".into(),
home_order: "desc".into(),
@@ -766,6 +748,10 @@ pub struct Clipboard {
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>,
/// Monotonic generation of the copied layers (contents, images, bounds and source). A UI
/// clipboard mirror records it, so a later programmatic copy that never touched the system
/// clipboard is not mistaken for the user's own copy just because the text still matches.
pub layers_generation: u64,
/// 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>,
@@ -962,6 +948,8 @@ impl Session {
ds.end_preview();
}
let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid));
// Cut and copy write the clipboard before a later check can refuse the command.
let clipboard_before = self.clipboard.clone();
let result = guard::run(id, || (spec.run)(self, params))
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
.and_then(|v| self.check_preview(id, &before).map(|()| v))
@@ -994,6 +982,7 @@ impl Session {
ds.selection = sel;
ds.page = page;
}
self.clipboard = clipboard_before;
}
}
result
@@ -1248,28 +1237,30 @@ impl Session {
}
/// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id`
/// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field),
/// so a step can target a layer that an earlier step of the same batch created. Any object
/// with a `$step` key in a step's params is such a reference, except inside a nested
/// `command.batch` step: its references are to its own steps.
pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result<Vec<Value>> {
/// in the result of the earlier step `n`, and `{"$ref": "name"}` by the `id` in the result of
/// the earlier step whose `as` is `name`; either takes a `field`, a key or slash path (`ids/0`)
/// into that result. So a step can target a layer that an earlier step of the same batch
/// created. Any object with a `$step` or `$ref` key in a step's params is such a reference,
/// except inside a nested `command.batch` step: its references are to its own steps.
pub fn batch(&mut self, label: &str, steps: &[BatchStep]) -> Result<Vec<Value>> {
let mut scope = BatchScope::new(steps)?;
self.grouped(label, |s| {
let mut results: Vec<Value> = Vec::with_capacity(cmds.len());
for (step, (id, p)) in cmds.iter().enumerate() {
let v = s.batch_step(&results, step, id, p)?;
results.push(v);
for step in steps {
s.batch_step(&mut scope, &step.command, &step.params)?;
}
Ok(results)
Ok(scope.into_results())
})
}
/// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of
/// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one
/// undo step; a caller with its own list — the CLI's script runner — uses this so its steps
/// resolve `$step` the same way, inside its own [`Session::grouped`].
pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result<Value> {
let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? };
self.execute(cmd, &p)
/// The next step of a batch: the references in `params` resolve against `scope` (the steps
/// before it), the command runs and its result joins `scope`. [`Session::batch`] runs a list
/// of these as one undo step; a caller with its own list — the CLI's script runner — uses this
/// so its steps resolve references the same way, inside its own [`Session::grouped`].
pub fn batch_step(&mut self, scope: &mut BatchScope, cmd: &str, params: &Value) -> Result<()> {
let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? };
let v = self.execute(cmd, &p)?;
scope.push(v);
Ok(())
}
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
@@ -1655,8 +1646,12 @@ mod cursor_tests;
#[cfg(test)]
mod dark_page_text_tests;
#[cfg(test)]
mod datafill_tests;
#[cfg(test)]
mod design_paste_tests;
#[cfg(test)]
mod dev_measurements_tests;
#[cfg(test)]
mod dev_resources_tests;
#[cfg(test)]
mod devcompare_tests;
@@ -1869,6 +1864,8 @@ mod styles_behaviour_tests;
#[cfg(test)]
mod svg_import_tests;
#[cfg(test)]
mod tailwind_tests;
#[cfg(test)]
mod templates_tests;
#[cfg(test)]
mod tests;
@@ -1885,6 +1882,8 @@ mod text_style_picker_tests;
#[cfg(test)]
mod text_tail_tests;
#[cfg(test)]
mod textfit_tests;
#[cfg(test)]
mod theme_pages_tests;
#[cfg(test)]
mod tiff_tests;
@@ -1911,6 +1910,8 @@ mod vector_multi_behaviour_tests;
#[cfg(test)]
mod vector_offset_tests;
#[cfg(test)]
mod vector_path_set_tests;
#[cfg(test)]
mod version_preview_tests;
#[cfg(test)]
mod version_tests;
+7
View File
@@ -105,6 +105,13 @@ fn a_view_link_makes_the_file_read_only() {
refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}), VIEW_ONLY);
refused(&mut s, "node.set", json!({"ids": [card.0], "props": {"name": "Mine"}}), VIEW_ONLY);
refused(&mut s, "edit.delete", json!({"ids": [card.0]}), VIEW_ONLY);
// Refused before it makes copies or writes files, which no rollback takes back.
let dir = std::env::temp_dir().join(format!("figcraft-view-fill-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
refused(&mut s, "data.fill", json!({"id": card.0, "rows": [{"name": "Copy"}], "dir": dir.to_string_lossy()}), VIEW_ONLY);
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no file was written");
let _ = std::fs::remove_dir_all(&dir);
refused(&mut s, "tool.set", json!({"tool": "text"}), VIEW_ONLY);
assert!(matches!(s.begin_interaction("Move"), Err(EngineError::Disabled(..))));
assert_eq!(*s.doc().unwrap(), before);
+18
View File
@@ -250,3 +250,21 @@ fn measure_distances_command() {
assert!(s.execute("measure.distances", &json!({"ids": [f]})).is_err(), "top level: pass `to`");
assert!(s.execute("measure.distances", &json!({"ids": [r], "to": 99999})).is_err());
}
#[test]
fn leaving_the_canvas_clears_the_hover_measurement() {
let mut s = Session::new();
let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0, None);
rect(&mut s, 100.0, 0.0, 50.0, 50.0, None);
ex(&mut s, "select.set", json!({"ids": [a]}));
assert!(!hover(&mut s, 125.0, 25.0, true, false, false).is_empty());
assert!(s.tool.overlay.measure_box.is_some());
s.pointer_left();
assert!(s.tool.overlay.guides.is_empty(), "{:?}", s.tool.overlay.guides);
assert_eq!(s.tool.overlay.measure_box, None);
// Dev Mode measures without Alt; leaving clears those too.
s.tool.dev_mode = true;
assert!(!hover(&mut s, 125.0, 25.0, false, false, false).is_empty());
s.pointer_left();
assert!(s.tool.overlay.guides.is_empty());
}
+3 -2
View File
@@ -521,7 +521,8 @@ fn recover_file(s: &mut Session, p: &Value) -> Result<Value> {
let i = named(s, CMD, p)?;
let num = |key: &str| p.get(key).and_then(Value::as_u64).ok_or_else(|| bad(CMD, format!("`{key}` must be a whole number")));
let client_id = u32::try_from(num("client")?).map_err(|_| bad(CMD, "`client` must be a client id"))?;
let at = Resume { session: num("session")?, client_id, seq: num("seq")? };
let secret = p.get("secret").and_then(Value::as_u64).unwrap_or(0);
let at = Resume { session: num("session")?, client_id, seq: num("seq")?, secret };
if s.prefs.online_files.get(i).is_some_and(|f| s.online_session(f).is_some()) {
return Err(bad(CMD, "this file is open already"));
}
@@ -642,7 +643,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"collab.recoverFile",
"Recover online file",
"{path (a recovery copy of one of your online files), file, server?, session, client, seq (who the app was in the file when the copy was written, and the server's last change it had: from the note beside the copy)} → {session, status: \"connecting\"}: joins the file again as its owner and keeps the copy until the file arrives. Then: the server has everything in the copy → nothing more; the copy has changes of yours the server never confirmed and nothing else changed there → they are applied on top as one edit (\"Recovered changes\", synced to everyone); anything else changed meanwhile → the copy opens beside the file as “<name> (recovered copy)”; the file can't be joined → the copy opens as an unsaved document. The session's message says which",
"{path (a recovery copy of one of your online files), file, server?, session, client, seq, secret? (who the app was in the file when the copy was written, and the server's last change it had: from the note beside the copy)} → {session, status: \"connecting\"}: joins the file again as its owner and keeps the copy until the file arrives. Then: the server has everything in the copy → nothing more; the copy has changes of yours the server never confirmed and nothing else changed there → they are applied on top as one edit (\"Recovered changes\", synced to everyone); anything else changed meanwhile → the copy opens beside the file as “<name> (recovered copy)”; the file can't be joined → the copy opens as an unsaved document. The session's message says which",
false,
recover_file
),
+36 -8
View File
@@ -99,16 +99,18 @@ fn scene(s: &mut Session) -> Scene {
#[test]
fn dragging_in_the_layers_panel_reorders_by_the_documented_rules() {
// The panel drops layers with `node.reparent {ids, parent, index}`: `index` counts the
// parent's children bottom first, as they are before the move.
// The panel drops layers with `node.reparent {ids, parent, index}`: `index` is where the first
// moved layer ends up among the parent's children, bottom first, counted after the move.
// (moved, index in F, F's children after (bottom first), derivation)
let rows: [(&[&str], usize, [&str; 4], &str); 6] = [
(&["a"], 4, ["b", "c", "d", "a"], "a layer dragged to the top of its parent's list comes to the front"),
let rows: [(&[&str], usize, [&str; 4], &str); 8] = [
(&["a"], 3, ["b", "c", "d", "a"], "a layer dragged to the top of its parent's list comes to the front"),
(&["a"], 9, ["b", "c", "d", "a"], "an index past the end is the front"),
(&["d"], 0, ["d", "a", "b", "c"], "a layer dragged to the bottom of the list goes to the back"),
(&["a"], 2, ["b", "a", "c", "d"], "a layer dropped between two layers lands between them"),
(&["a", "b"], 4, ["c", "d", "a", "b"], "several selected layers move together, keeping their order"),
(&["a", "d"], 2, ["b", "a", "d", "c"], "several layers from apart land together where they are dropped"),
(&["b", "c"], 1, ["a", "b", "c", "d"], "silent: dropping a block of layers onto itself changes nothing (pinned)"),
(&["a"], 1, ["b", "a", "c", "d"], "a layer moved up one place goes up one place (#992)"),
(&["a"], 0, ["a", "b", "c", "d"], "silent: a layer asked to stay where it is stays (pinned)"),
(&["a", "b"], 2, ["c", "d", "a", "b"], "several selected layers move together, keeping their order"),
(&["a", "d"], 1, ["b", "a", "d", "c"], "several layers from apart land together where they are asked to"),
(&["b", "c"], 1, ["a", "b", "c", "d"], "silent: moving a block of layers to where it is changes nothing (pinned)"),
];
for (moved, index, after, why) in rows {
let mut s = Session::new();
@@ -172,6 +174,32 @@ fn dragging_into_frames_groups_and_auto_layout() {
);
}
#[test]
fn reparent_index_is_the_final_position_in_auto_layout_and_across_parents() {
// #992: `index` is the layer's place among the parent's children after the move.
let mut s = Session::new();
let row = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Row", "props": {"layout": "horizontal"}})));
let text = |s: &mut Session, t: &str, parent: NodeId| id(&ex(s, "node.create", json!({"type": "text", "name": t, "text": t, "parent": parent})));
let (first, second, third) = (text(&mut s, "first", row), text(&mut s, "second", row), text(&mut s, "third", row));
let names = |s: &Session, p: NodeId| kids(s, p).iter().map(|i| name(s, *i)).collect::<Vec<_>>();
ex(&mut s, "node.reparent", json!({"ids": [first], "parent": row, "index": 1}));
assert_eq!(names(&s, row), ["second", "first", "third"], "moved down one place");
ex(&mut s, "node.reparent", json!({"ids": [first], "parent": row, "index": 2}));
assert_eq!(names(&s, row), ["second", "third", "first"], "moved to the end");
ex(&mut s, "node.reparent", json!({"ids": [first], "parent": row, "index": 0}));
assert_eq!(names(&s, row), ["first", "second", "third"], "moved to the start");
let x = |s: &Session, i: NodeId| s.doc().unwrap().node(i).unwrap().transform.as_coeffs()[4];
assert!(x(&s, first) < x(&s, second) && x(&s, second) < x(&s, third), "the flow follows the new order");
// Into another parent, `index` means the same thing.
let other = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Other", "props": {"layout": "horizontal"}})));
let (p, q) = (text(&mut s, "p", other), text(&mut s, "q", other));
ex(&mut s, "node.reparent", json!({"ids": [second], "parent": other, "index": 1}));
assert_eq!(kids(&s, other), [p, second, q], "lands at index 1 of the new parent");
ex(&mut s, "node.reparent", json!({"ids": [first, third], "parent": other, "index": 3}));
assert_eq!(kids(&s, other), [p, second, q, first, third], "several layers start at index 3, keeping their order");
assert!(kids(&s, row).is_empty());
}
#[test]
fn renaming_several_layers_by_the_documented_codes() {
// Three layers named `names`, bottom to top; (names, params, new names, derivation).
+3
View File
@@ -113,6 +113,8 @@ const DENIED: &[&str] = &[
"edit.redo",
"edit.repeat",
"command.batch",
// Runs other commands inside (duplicate, edit, export), past this policy, like a batch.
"data.fill",
// Network sessions, online files and their recovery copies on disk.
"collab.",
// Opens files in new windows.
@@ -391,6 +393,7 @@ pub(crate) fn register(s: &mut Session, entry: InstalledPlugin, wasm: Vec<u8>) -
match list.iter_mut().find(|x| x.manifest.id == id) {
Some(slot) => {
*slot = entry;
s.prefs.plugin_settings.remove(&id);
true
}
None => {
+11
View File
@@ -170,6 +170,7 @@ fn files_dialogs_history_and_plugins_are_off_limits() {
("plugin.remove", json!({"id": "org.test.deny"})),
("edit.undo", json!({})),
("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})),
("data.fill", json!({"id": a, "rows": [{}], "dir": dir.to_string_lossy()})),
("variable.export", json!({"format": "css", "path": target})),
("codeConnect.export", json!({"path": target})),
("codeConnect.import", json!({"path": "/etc/passwd"})),
@@ -405,3 +406,13 @@ fn a_full_undo_history_keeps_its_steps_and_tampered_manifests_are_refused() {
s.prefs.plugins[0].manifest.main = "../../elsewhere.wasm".into();
assert!(matches!(s.execute("plugin.run", &json!({"id": "org.test.many"})), Err(EngineError::BadParams { .. })));
}
#[test]
fn reinstalling_under_an_existing_id_drops_the_old_settings() {
let mut s = Session::new();
let m = manifest("org.test.reuse", "read", &[]);
install(&mut s, m.clone(), &script(&[], OnRefusal::Fail, ""));
s.prefs.plugin_settings.entry("org.test.reuse".into()).or_default().insert("key".into(), json!("v"));
install(&mut s, m, &script(&[], OnRefusal::Fail, ""));
assert!(!s.prefs.plugin_settings.contains_key("org.test.reuse"), "{:?}", s.prefs.plugin_settings);
}
+6
View File
@@ -81,6 +81,9 @@ impl BatchRename {
out.push_str(name.get(last..m.start()).unwrap_or_default());
let (before, after) = (name.get(..m.start()).unwrap_or_default(), name.get(m.end()..).unwrap_or_default());
expand(&self.replace, &Ctx { matched: m.as_str(), before, after, groups: Some(&caps), up, down }, &mut out);
if out.len() >= MAX_NAME * 4 {
break;
}
last = m.end();
}
out.push_str(name.get(last..).unwrap_or_default());
@@ -110,6 +113,9 @@ impl BatchRename {
fn expand(template: &str, c: &Ctx, out: &mut String) {
let mut it = template.chars().peekable();
while let Some(ch) = it.next() {
if out.len() >= MAX_NAME * 4 {
break;
}
if ch != '$' {
out.push(ch);
continue;
+14
View File
@@ -240,3 +240,17 @@ fn the_scale_tool_only_acts_on_handles() {
assert_rect(bounds(&s, r), [10.0, 10.0, 110.0, 60.0]);
assert!(near(s.doc().unwrap().node(r).unwrap().width, 100.0));
}
#[test]
fn scale_node_scales_vector_point_corner_radius() {
let mut s = Session::new();
let v = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}))["id"]).unwrap();
ex(&mut s, "node.set", json!({"id": v, "props": {"cornerRadius": 10}}));
ex(&mut s, "transform.scale", json!({"ids": [v], "factor": 2}));
let d = s.doc().unwrap();
let NodeKind::Vector { network } = &d.node(v).unwrap().kind else { panic!("not a vector") };
assert!(!network.vertices.is_empty());
for vx in &network.vertices {
assert_eq!(vx.radius, 20.0, "corner radius scaled with the layer");
}
}

Some files were not shown because too many files have changed in this diff Show More