Merge main into #1275

# Conflicts:
#	crates/doc/src/tests.rs
This commit is contained in:
bflatastic
2026-10-26 09:15:49 +07:00
274 changed files with 14771 additions and 2154 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.
+9 -1
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 (Dev Mode measurements kept on the canvas and a read-only layers panel; 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)
> **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
@@ -132,6 +132,8 @@ 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.
@@ -143,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.
@@ -329,6 +335,8 @@ 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 |
+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())) {
+51 -14
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};
@@ -318,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") => {
@@ -343,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()
}
@@ -361,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()))?;
@@ -511,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);
+9 -1
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);
}
+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.
+8 -3
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()
}
+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);
+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] },
+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());
}
+23 -6
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);
+2 -1
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;
}
+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")));
}
+6 -2
View File
@@ -1031,6 +1031,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));
@@ -1047,12 +1051,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}"));
}
+5
View File
@@ -397,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),
+67
View File
@@ -577,3 +577,70 @@ fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() {
assert_eq!(chain(MAX_RENDER_DEPTH).x1, 110.0);
assert_eq!(chain(20_000).x1, 10.0);
}
#[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);
+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;
+22 -6
View File
@@ -8,7 +8,7 @@ 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![
@@ -342,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
}
@@ -541,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(())
}
@@ -600,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")) {
@@ -616,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);
+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")];
+20 -2
View File
@@ -144,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),
}
@@ -152,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),
}
@@ -1277,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
),
+18 -8
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>\"]; numbers in px unless layers or scopes use them as opacity or font weight), 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, &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,16 +67,25 @@ 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(doc: &Document, colls: &[&Collection]) -> String {
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 = String::new();
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) {
@@ -85,12 +95,12 @@ fn css(doc: &Document, colls: &[&Collection]) -> String {
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();
+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"),
+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();
+48 -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;
@@ -60,6 +61,7 @@ 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;
@@ -69,6 +71,7 @@ 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;
@@ -120,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 {
@@ -329,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>,
@@ -407,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);
@@ -691,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")]
@@ -740,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(),
@@ -770,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>,
@@ -966,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))
@@ -998,6 +982,7 @@ impl Session {
ds.selection = sel;
ds.page = page;
}
self.clipboard = clipboard_before;
}
}
result
@@ -1252,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
@@ -1877,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;
+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
),
+1
View File
@@ -393,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 => {
+10
View File
@@ -406,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);
}
+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");
}
}
+127 -1
View File
@@ -5,11 +5,12 @@
use std::collections::{BTreeMap, BTreeSet};
use figcraft_color::{Paint, PaintKind};
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableType, VariableValue};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
use crate::cmd::styles::rebind_paint_alpha;
use crate::params::*;
use crate::{CommandSpec, Result, Session, bad, spec};
@@ -130,6 +131,20 @@ pub fn specs() -> Vec<CommandSpec> {
true,
apply_style
),
spec!(
"selection.bindColorVariable",
"Apply selection color variable",
"{targets, variable (id or name)}; binds the color variable to matching solid fill/stroke slots, replacing a color style there (text ranges with fills of their own take its value in the layer's modes)",
true,
bind_variable
),
spec!(
"selection.detachColor",
"Detach selection color",
"{targets}; unlinks the color style or variable of matching fill/stroke lists, keeping their values",
true,
detach
),
spec!(
"selection.selectColor",
"Select layers with color",
@@ -210,6 +225,117 @@ fn apply_style(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"ok": true}))
}
/// The binding key (`fills/<i>/color` or `strokes/<i>/color`) of the slot a target stands for, when the
/// target is the node's own list or a text range that shows the node's fills. Returns None for a range
/// with fills of its own, since those fills hold no link.
fn list_field(doc: &Document, target: &PaintTarget) -> Option<String> {
if let Some((start, _)) = target.range
&& doc.node(target.id)?.kind.text()?.style_at(start).fills.is_some()
{
return None;
}
let list = if target.fills { BoundList::Fills } else { BoundList::Strokes };
Some(format!("{}/{}/color", list.prefix(), target.index))
}
/// Remove every binding key of the slot (`fill` and `fills/0/color` name the same one); true when
/// one was there.
fn unbind_slot(n: &mut figcraft_doc::Node, fills: bool, index: usize) -> bool {
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let before = n.bound.len();
n.bound.retain(|key, _| list_binding(key).is_none_or(|(l, i, field)| !(l == list && i == index && field == "color")));
n.bound.len() != before
}
/// Why `variable` can't be applied to `targets` (`selection.bindColorVariable`), if it can't: a
/// variable that isn't a color, or a gradient. Scopes don't refuse, as with `variable.bind`; they
/// only filter what the menu offers ([`in_scope`]).
pub fn bind_refusal(doc: &Document, targets: &[PaintTarget], variable: &str) -> Option<String> {
let Some(var) = doc.variables.get(variable) else { return Some("no such variable".into()) };
if var.kind != VariableType::Color {
return Some("a color variable is required".into());
}
for target in targets {
if !target.paint(doc).is_some_and(|paint| matches!(paint.kind, PaintKind::Solid { .. })) {
return Some("color variables bind to solid paints".into());
}
let list = if target.fills { BoundList::Fills } else { BoundList::Strokes };
let field = format!("{}/{}/color", list.prefix(), target.index);
if !crate::cmd::variables::field_takes(doc, target.id, &field, var.kind) {
return Some(format!("`{field}` doesn't take a color variable"));
}
}
None
}
/// Whether `variable`'s scopes cover every slot of `targets` (a variable hidden from pickers has
/// none), so the Selection colors menu offers it there.
pub fn in_scope(doc: &Document, targets: &[PaintTarget], variable: &str) -> bool {
let Some(var) = doc.variables.get(variable) else { return false };
targets.iter().all(|target| {
let list = if target.fills { BoundList::Fills } else { BoundList::Strokes };
let field = format!("{}/{}/color", list.prefix(), target.index);
crate::cmd::variables::field_scope(doc, target.id, &field).is_none_or(|scope| var.in_scope(scope))
})
}
fn bind_variable(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.bindColorVariable";
let targets = checked_targets(s, p, CMD)?;
let name = req_string(CMD, p, "variable")?;
let doc = s.doc()?;
if let Some(why) = bind_refusal(doc, &targets, &name) {
return Err(bad(CMD, why));
}
let vid = doc.variables.get(&name).map(|v| v.id.clone()).ok_or_else(|| bad(CMD, "no such variable"))?;
for target in &targets {
if let Some(field) = list_field(s.doc()?, target) {
let n = s.doc_mut()?.get_mut(target.id)?;
if target.fills {
n.styles.fill = None;
} else {
n.styles.stroke = None;
}
if !unbind_slot(n, target.fills, target.index) {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true);
}
n.bound.insert(field, vid.clone());
continue;
}
// A range's own fills can't hold a binding: they take the value, as a range edit does.
let doc = s.doc()?;
let Some(VariableValue::Color(color)) = doc.variables.resolve(&vid, &doc.modes_for(target.id)) else { continue };
let mut paints = target.paints(doc).ok_or_else(|| bad(CMD, "invalid target"))?;
let Some(slot) = paints.get_mut(target.index) else { return Err(bad(CMD, "invalid paint index")) };
let own = if slot.color_alpha > 0.0 { (slot.opacity / slot.color_alpha).clamp(0.0, 1.0) } else { 1.0 };
slot.kind = PaintKind::Solid { color: color.with_alpha(1.0) };
slot.opacity = own * color.a;
slot.color_alpha = color.a;
let Some((start, end)) = target.range else { continue };
if let NodeKind::Text(t) = &mut s.doc_mut()?.get_mut(target.id)?.kind {
t.restyle(start, end, |style| style.fills = Some(paints.clone()));
}
}
Ok(json!({"ok": true}))
}
fn detach(s: &mut Session, p: &Value) -> Result<Value> {
let targets = checked_targets(s, p, "selection.detachColor")?;
for target in &targets {
let Some(field) = list_field(s.doc()?, target) else { continue };
let n = s.doc_mut()?.get_mut(target.id)?;
if target.fills {
n.styles.fill = None;
} else {
n.styles.stroke = None;
}
if unbind_slot(n, target.fills, target.index) {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false);
}
}
Ok(json!({"ok": true}))
}
fn select_color(s: &mut Session, p: &Value) -> Result<Value> {
let ts = checked_targets(s, p, "selection.selectColor")?;
let ids: Vec<_> = ts.iter().map(|t| t.id).collect::<BTreeSet<_>>().into_iter().collect();
@@ -205,6 +205,42 @@ fn corner_radius_follows_the_documented_rules() {
);
}
#[test]
fn corner_smoothing_follows_the_documented_rules() {
// Smoothing applies to any shape a radius applies to, and only to the entire shape.
// (layer, radius, derivation)
let rows: Vec<(&str, f64, &str)> = vec![
("shape.rectangle", 20.0, "rectangles smooth their rounded corners"),
("shape.polygon", 12.0, "polygons smooth their rounded corners"),
("shape.star", 4.0, "stars smooth their rounded corners"),
];
for (cmd, radius, why) in rows {
let mut s = Session::new();
let id = create(&mut s, cmd, json!({"x": 0, "y": 0, "width": 100, "height": 100}));
ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": radius}}));
let round = figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap().area().abs();
ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0.6}}));
let smooth = figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap().area().abs();
assert!((smooth - round).abs() > 1.0, "{why}: {round} then {smooth}");
ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": 0}}));
let sharp = figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap();
ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0}}));
assert_eq!(sharp, figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap(), "{why}: smoothing needs a radius");
}
// (kind, takes smoothing, derivation)
let doc_rows: Vec<(NodeKind, bool, &str)> = vec![
(NodeKind::Rectangle, true, "rectangles"),
(NodeKind::Frame, true, "frames"),
(NodeKind::Polygon { count: 5 }, true, "polygons"),
(NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, "stars"),
(NodeKind::Vector { network: Default::default() }, false, "documented for closed vectors; their points round without it here"),
(NodeKind::Line, false, "lines take no radius, so no smoothing"),
];
for (kind, smoothing, why) in doc_rows {
assert_eq!(kind.has_corner_smoothing(), smoothing, "{why}");
}
}
#[test]
fn rounded_vector_points_are_drawn() {
// A converted 100 × 100 square: a point's radius rounds that corner only (it wasn't drawn).
+23
View File
@@ -42,6 +42,29 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option<VariableType> {
}
}
/// The variable scopes a picker offers for `key` of a style of `kind`: a variable scoped to any one
/// of them fits. A paint style's colors take fill and stroke variables, an effect style's fields
/// take effect variables, and a text style's fields take the variables of their font property.
/// None when every variable of the field's type fits (a grid's fields, as on layers) or the style
/// has no such field.
pub fn field_scopes(kind: &StyleKind, key: &str) -> Option<&'static [&'static str]> {
field_type(kind, key)?;
Some(match (kind, list_binding(key)) {
(StyleKind::Paint { .. }, _) => &["FRAME_FILL", "SHAPE_FILL", "TEXT_FILL", "STROKE_COLOR"],
(StyleKind::Effect { .. }, Some((_, _, "color"))) => &["EFFECT_COLOR"],
(StyleKind::Effect { .. }, _) => &["EFFECT_FLOAT"],
(StyleKind::Text { .. }, _) => match key {
"fontSize" => &["FONT_SIZE"],
"lineHeight" => &["LINE_HEIGHT"],
"letterSpacing" => &["LETTER_SPACING"],
"fontFamily" => &["FONT_FAMILY"],
"fontStyle" => &["FONT_STYLE"],
_ => return None,
},
_ => return None,
})
}
/// The key a style stores a binding of `key` under: the first paint's colour has one spelling.
pub fn canonical(key: &str) -> String {
match list_binding(key) {
+40
View File
@@ -182,6 +182,46 @@ fn what_a_style_cannot_bind_is_refused() {
}
}
#[test]
fn style_fields_offer_their_properties_scopes() {
let mut s = Session::new();
let (_, _, a, _, sid) = two_modes(&mut s);
run(&mut s, "effect.add", json!({"ids": [a], "type": "dropShadow"}));
let fx = run(&mut s, "style.create", json!({"type": "effect", "name": "Drop", "from": a}))["id"].as_str().unwrap().to_string();
let light = node(&s, a).parent.unwrap();
run(&mut s, "grid.add", json!({"ids": [light]}));
let grid = run(&mut s, "style.create", json!({"type": "grid", "name": "Base", "from": light}))["id"].as_str().unwrap().to_string();
let body = run(&mut s, "style.create", json!({"type": "text", "name": "Body", "value": {"fontFamily": "Inter", "fontSize": 14}}))["id"]
.as_str()
.unwrap()
.to_string();
let doc = s.doc().unwrap();
let scopes = |st: &str, key: &str| crate::style_vars::field_scopes(&doc.style(st).unwrap().kind, key);
let fills: &[&str] = &["FRAME_FILL", "SHAPE_FILL", "TEXT_FILL", "STROKE_COLOR"];
for (st, key, want) in [
(&sid, "fill", Some(fills)),
(&sid, "fills/0/color", Some(fills)),
(&fx, "effects/0/color", Some(&["EFFECT_COLOR"][..])),
(&fx, "effects/0/radius", Some(&["EFFECT_FLOAT"][..])),
(&fx, "effects/0/offsetX", Some(&["EFFECT_FLOAT"][..])),
(&body, "fontSize", Some(&["FONT_SIZE"][..])),
(&body, "lineHeight", Some(&["LINE_HEIGHT"][..])),
(&body, "letterSpacing", Some(&["LETTER_SPACING"][..])),
(&body, "fontFamily", Some(&["FONT_FAMILY"][..])),
(&body, "fontStyle", Some(&["FONT_STYLE"][..])),
// Grid fields take any number variable, as on layers; fields a style lacks take none.
(&grid, "layoutGrids/0/size", None),
(&sid, "fills/3/color", None),
(&body, "opacity", None),
] {
assert_eq!(scopes(st, key), want, "{st} {key}");
}
// As on layers, scopes limit pickers only: an out-of-scope variable still binds.
run(&mut s, "variable.update", json!({"variable": "size", "scopes": ["CORNER_RADIUS"]}));
run(&mut s, "style.bind", json!({"style": body, "field": "fontSize", "variable": "size"}));
assert!(s.doc().unwrap().style(&body).unwrap().bound.contains_key("fontSize"));
}
#[test]
fn rest_and_fig_carry_style_bindings() {
let mut s = Session::new();
+519
View File
@@ -0,0 +1,519 @@
//! React + Tailwind CSS v4 for a layer tree, ready to drop into a web project: every layer's look
//! as utility classes instead of a stylesheet, bound variables as theme utilities (`bg-accent`,
//! `rounded-card`) that `variable.export {format: "tailwind"}` declares in an `@theme` block, auto
//! layout frames and text that grow with the page (`w-full max-w-[…]`) instead of fixed pixel
//! boxes, and instances inlined with their overrides. Given a mobile frame too, the code is mobile
//! first: the mobile frame's classes, and the desktop frame's where they differ behind `lg:`.
use std::collections::{BTreeMap, HashMap};
use figcraft_doc::{Document, NodeId, NodeKind, Variable, VariableId, VariableType};
use crate::codegen::{CodeLang, Ctx, Markup, css_var_name, css_var_of, num};
use crate::component_codegen::component_type_name;
use crate::token_roles::{NumberRole, Roles};
/// The breakpoint desktop classes sit behind in responsive code.
// ponytail: one breakpoint (lg = 1024px); a tablet frame would need a second pass and `md:`.
const BREAKPOINT: &str = "lg";
/// The roles and Tailwind theme names of a file's variables, computed once per export so code
/// and the `@theme` block always agree.
#[derive(Default)]
pub struct Theme {
roles: Roles,
names: HashMap<VariableId, String>,
}
impl Theme {
pub fn new(doc: &Document) -> Self {
let vars = &doc.variables.variables;
let roles = Roles::new(doc);
let mut t = Theme { roles, names: HashMap::new() };
// Short names ("accent") where they are unique, the full path ("palette-accent") where two
// variables would share one, and a numeric suffix as a last resort.
let short: Vec<String> = vars.iter().map(|v| t.candidate(v, false)).collect();
// Linear in the number of variables: counts for clashes, a set of names taken and the next
// suffix per base, so a file with very many variables can't make this quadratic.
let mut count: HashMap<&str, usize> = HashMap::new();
for n in &short {
*count.entry(n.as_str()).or_default() += 1;
}
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut next: HashMap<String, usize> = HashMap::new();
for (v, s) in vars.iter().zip(&short) {
let base = if count.get(s.as_str()).copied().unwrap_or(0) > 1 { t.candidate(v, true) } else { s.clone() };
let mut name = base.clone();
if taken.contains(&name) {
let k = next.entry(base.clone()).or_insert(1);
loop {
*k += 1;
name = format!("{base}-{k}");
if !taken.contains(&name) {
break;
}
}
}
taken.insert(name.clone());
t.names.insert(v.id.clone(), name);
}
t
}
/// What a number variable measures; a number nothing tells about is a length.
pub fn role(&self, v: &Variable) -> NumberRole {
self.roles.role(v)
}
/// The theme variable for `v`, unique in the file (see [`Theme::new`]).
pub fn name(&self, v: &Variable) -> String {
self.names.get(&v.id).cloned().unwrap_or_else(|| self.candidate(v, false))
}
/// `--color-*` for colours, `--radius-*`, `--text-*`, `--spacing-*` for numbers by role,
/// `--font-*` for font families, others their CSS name; from the last path segment, or the
/// whole path (`full`). A name already in the namespace isn't prefixed twice.
fn candidate(&self, v: &Variable, full: bool) -> String {
let ns = match v.kind {
VariableType::Color => Some("color"),
VariableType::Number => match self.role(v) {
NumberRole::Radius => Some("radius"),
NumberRole::FontSize => Some("text"),
NumberRole::Spacing => Some("spacing"),
NumberRole::Length | NumberRole::Unitless => None,
},
VariableType::String if v.scopes.as_ref().is_some_and(|s| s.iter().any(|s| s == "FONT_FAMILY")) => Some("font"),
_ => None,
};
let Some(ns) = ns else {
return if full { css_var_name(&v.name) } else { css_var_of(v) };
};
let source = if full { v.name.as_str() } else { v.name.rsplit('/').next().unwrap_or(&v.name) };
let slug = css_var_name(source);
let slug = slug.trim_start_matches('-');
let slug = [format!("{ns}s-"), format!("{ns}-")].iter().find_map(|p| slug.strip_prefix(p.as_str())).unwrap_or(slug);
// A variable named after its namespace ("radius", "colors") is the namespace's base value.
let slug = if slug == ns || slug == format!("{ns}s") { "base" } else { slug };
format!("--{ns}-{slug}")
}
}
// ------------------------------------------------------------------ declarations → classes
/// An arbitrary value: spaces become underscores (Tailwind's escape), underscores are escaped.
fn arb(v: &str) -> String {
format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_"))
}
/// The custom property a `var(--name, fallback)` value reads, when that one call is the whole
/// value (not `var(--a, 4px) 8px`, four corners of which only the first is a variable).
fn var_name(v: &str) -> Option<&str> {
let inner = v.trim().strip_prefix("var(")?;
let mut depth = 1usize;
let close = inner.char_indices().find_map(|(i, c)| {
match c {
'(' => depth += 1,
')' => depth -= 1,
_ => {}
}
(depth == 0).then_some(i)
})?;
if close + 1 != inner.len() {
return None;
}
Some(inner.split([',', ')']).next()?.trim())
}
/// `util` with `value`: a theme utility when the value reads a variable of the namespace
/// (`bg-accent`), else the variable in parentheses (`bg-(--x)`, with a type hint for `text-`
/// sizes), else an arbitrary value.
fn util(util: &str, ns: &str, value: &str, hint: &str) -> String {
match var_name(value) {
Some(name) => match name.strip_prefix(&format!("--{ns}-")) {
Some(key) if !ns.is_empty() => format!("{util}-{key}"),
_ => format!("{util}-({hint}{name})"),
},
None => format!("{util}-{}", arb(value)),
}
}
/// A length on the spacing scale (4px steps) when it fits one, else arbitrary.
fn spacing(prefix: &str, value: &str) -> String {
if var_name(value).is_some() {
return util(prefix, "spacing", value, "");
}
match value.strip_suffix("px").and_then(|n| n.parse::<f64>().ok()) {
Some(0.0) => format!("{prefix}-0"),
Some(px) if px > 0.0 && px <= 384.0 && (px / 4.0).fract() == 0.0 => format!("{prefix}-{}", num(px / 4.0)),
Some(px) if px < 0.0 && (px / 4.0).fract() == 0.0 => format!("-{prefix}-{}", num(-px / 4.0)),
_ => format!("{prefix}-{}", arb(value)),
}
}
/// The values of a `padding` shorthand (top right bottom left), as utilities.
fn padding(value: &str) -> Vec<(String, String)> {
let mut parts = Vec::new();
let mut depth = 0usize;
let mut cur = String::new();
for c in value.chars() {
match c {
'(' => depth += 1,
')' => depth = depth.saturating_sub(1),
' ' if depth == 0 => {
parts.push(std::mem::take(&mut cur));
continue;
}
_ => {}
}
cur.push(c);
}
parts.push(cur);
let [t, r, b, l] = match parts.as_slice() {
[a] => [a, a, a, a],
[t, r, b, l] => [t, r, b, l],
_ => return vec![("padding".into(), format!("p-{}", arb(value)))],
};
let zero = |v: &str| v == "0px" || v == "0";
let mut out = Vec::new();
if t == r && r == b && b == l {
if !zero(t) {
out.push(("padding".into(), spacing("p", t)));
}
} else if t == b && l == r {
for (k, p, v) in [("padding-y", "py", t), ("padding-x", "px", l)] {
if !zero(v) {
out.push((k.into(), spacing(p, v)));
}
}
} else {
for (k, p, v) in [("padding-top", "pt", t), ("padding-right", "pr", r), ("padding-bottom", "pb", b), ("padding-left", "pl", l)] {
if !zero(v) {
out.push((k.into(), spacing(p, v)));
}
}
}
out
}
fn font_weight(v: &str) -> String {
let named = match v.trim() {
"100" => "thin",
"200" => "extralight",
"300" => "light",
"400" => "normal",
"500" => "medium",
"600" => "semibold",
"700" => "bold",
"800" => "extrabold",
"900" => "black",
_ => return format!("font-{}", arb(v)),
};
format!("font-{named}")
}
/// A border shorthand (`1px solid c`) on `side` ("" or "t", "r", "b", "l") as width + colour.
fn border(side: &str, value: &str) -> Vec<(String, String)> {
// `<width> solid <colour>`, where the width may be `var(--w, 2px)`: split at the style.
let (width, color) = value.split_once(" solid ").unwrap_or((value, ""));
let (width, color) = (width.trim(), color.trim());
let b = if side.is_empty() { "border".to_string() } else { format!("border-{side}") };
let key = format!("border{}", if side.is_empty() { String::new() } else { format!("-{side}") });
let w = match width {
"1px" => b.clone(),
"0px" => format!("{b}-0"),
w => match var_name(w) {
// `border-(--w)` would read as a colour; the hint makes it a width.
Some(name) => format!("{b}-(length:{name})"),
None => format!("{b}-{}", arb(w)),
},
};
let mut out = vec![(format!("{key}-width"), w)];
if !color.is_empty() {
out.push(("border-color".into(), util("border", "color", color, "")));
}
out
}
/// One CSS declaration as utilities, keyed by the property they set (so responsive code can tell
/// which mobile class a desktop class replaces).
fn decl(key: &str, value: &str) -> Vec<(String, String)> {
let one = |c: String| vec![(key.to_string(), c)];
let kw = |c: &str| vec![(key.to_string(), c.to_string())];
match (key, value) {
("display", "flex") => kw("flex"),
("display", "block") => kw("block"),
("flex-direction", "column") => kw("flex-col"),
("flex-direction", _) => kw("flex-row"),
("flex-wrap", "wrap") => kw("flex-wrap"),
// Defaults flexbox already has.
("justify-content", "flex-start") | ("gap", "0px") => Vec::new(),
("justify-content", v) => one(format!("justify-{}", v.trim_start_matches("flex-").trim_start_matches("space-"))),
("align-items", v) => one(format!("items-{}", v.trim_start_matches("flex-"))),
("align-content", v) => one(format!("content-{}", v.trim_start_matches("flex-").trim_start_matches("space-"))),
("align-self", "stretch") => kw("self-stretch"),
("flex", "1 1 0") => kw("flex-1"),
("position", v) => one(v.to_string()),
("left" | "top" | "right" | "bottom", v) => one(spacing(key, v)),
("width", "100%") => kw("w-full"),
("width", v) => one(spacing("w", v)),
("height", v) => one(spacing("h", v)),
("max-width", v) => one(format!("max-w-{}", arb(v))),
("min-height", v) => one(format!("min-h-{}", arb(v))),
("margin-inline", "auto") => kw("mx-auto"),
("gap", v) => one(spacing("gap", v)),
("padding", v) => padding(v),
("border-radius", "50%") => kw("rounded-full"),
("border-radius", v) if v.strip_suffix("px").and_then(|n| n.parse::<f64>().ok()).is_some_and(|r| r >= 999.0) => kw("rounded-full"),
("border-radius", v) if v.contains(' ') && var_name(v).is_none() => one(format!("rounded-{}", arb(v))),
("border-radius", v) => one(util("rounded", "radius", v, "")),
("opacity", v) => match v.parse::<f64>() {
Ok(o) if ((o * 100.0).round() - o * 100.0).abs() < 1e-9 => one(format!("opacity-{}", num(o * 100.0))),
_ => one(util("opacity", "", v, "")),
},
("background", v) if v.contains("gradient(") => one(format!("bg-{}", arb(v))),
// An image shorthand (`url(…) center / cover`) is a whole `background`, not a bg colour.
("background", v) if v.starts_with("url(") => one(format!("[background:{}]", arb(v).trim_matches(['[', ']']))),
("background", v) => one(util("bg", "color", v, "color:")),
("color", v) => one(util("text", "color", v, "color:")),
("border", v) => border("", v),
("border-top", v) => border("t", v),
("border-right", v) => border("r", v),
("border-bottom", v) => border("b", v),
("border-left", v) => border("l", v),
("box-shadow", v) => one(format!("shadow-{}", arb(v))),
("filter", v) => one(match v.strip_prefix("blur(").and_then(|r| r.strip_suffix(')')) {
Some(r) => format!("blur-{}", arb(r)),
None => format!("[filter:{}]", arb(v).trim_matches(['[', ']'])),
}),
("backdrop-filter", v) => one(match v.strip_prefix("blur(").and_then(|r| r.strip_suffix(')')) {
Some(r) => format!("backdrop-blur-{}", arb(r)),
None => format!("[backdrop-filter:{}]", arb(v).trim_matches(['[', ']'])),
}),
("mix-blend-mode", v) => one(format!("mix-blend-{v}")),
("transform", v) => match v.strip_prefix("rotate(").and_then(|r| r.strip_suffix(')')) {
Some(r) => one(format!("rotate-{}", arb(r))),
None => one(format!("[transform:{}]", arb(v).trim_matches(['[', ']']))),
},
("transform-origin", "top left" | "0 0") => kw("origin-top-left"),
// `font-(--x)` would read as a weight; the hint makes an un-namespaced variable a family.
("font-family", v) if var_name(v).is_some() => one(util("font", "font", v, "family-name:")),
("font-family", v) => one(format!("font-{}", arb(&format!("'{}'", v.trim_matches('"'))))),
("font-size", v) => one(util("text", "text", v, "length:")),
("font-weight", v) => one(font_weight(v)),
("font-style", "italic") => kw("italic"),
("line-height", v) => match v.strip_suffix('%').and_then(|p| p.parse::<f64>().ok()) {
Some(p) => one(format!("leading-{}", arb(&num(p / 100.0)))),
None => one(util("leading", "", v, "")),
},
("letter-spacing", v) => one(util("tracking", "", v, "")),
("text-align", v) => one(format!("text-{v}")),
("text-transform", "uppercase") => kw("uppercase"),
("text-transform", "lowercase") => kw("lowercase"),
("text-transform", "capitalize") => kw("capitalize"),
("text-decoration", "underline") => kw("underline"),
("text-decoration", "line-through") => kw("line-through"),
_ => one(format!("[{key}:{}]", arb(value).trim_matches(['[', ']']))),
}
}
/// A rule's declarations (`key: value;` lines) as (property, utility) pairs in order.
pub fn classes(decls: &str) -> Vec<(String, String)> {
decls
.lines()
.filter_map(|l| {
let (k, v) = l.trim().trim_end_matches(';').split_once(':')?;
Some(decl(k.trim(), v.trim()))
})
.flatten()
.collect()
}
/// The utility that undoes a mobile class at the breakpoint when desktop sets nothing for it.
fn reset(key: &str) -> Option<String> {
// Border widths by side: `border-t-width` → `border-t-0`.
if let Some(side) = key.strip_prefix("border").and_then(|k| k.strip_suffix("-width")) {
return Some(format!("border{side}-0"));
}
Some(String::from(match key {
"display" => "block",
"flex-direction" => "flex-row",
"flex-wrap" => "flex-nowrap",
"flex" => "flex-initial",
"align-self" => "self-auto",
"width" => "w-auto",
"height" => "h-auto",
"max-width" => "max-w-none",
"min-height" => "min-h-0",
"text-align" => "text-left",
"justify-content" => "justify-start",
"gap" => "gap-0",
"position" => "static",
"text-transform" => "normal-case",
"opacity" => "opacity-100",
"border-radius" => "rounded-none",
"box-shadow" => "shadow-none",
"font-style" => "not-italic",
// The whole background: a mobile image or gradient would survive a colour-only reset.
"background" => "bg-none bg-transparent",
"border-color" => "border-current",
"letter-spacing" => "tracking-normal",
"line-height" => "leading-normal",
"text-decoration" => "no-underline",
"align-items" => "items-stretch",
"filter" => "filter-none",
"backdrop-filter" => "backdrop-filter-none",
"transform" => "rotate-0",
"left" | "top" | "right" | "bottom" => return Some(format!("{key}-auto")),
"mix-blend-mode" => "mix-blend-normal",
_ => return None,
}))
}
/// The sides (top, right, bottom, left) a padding utility's property sets.
fn padding_sides(key: &str) -> [bool; 4] {
match key {
"padding" => [true; 4],
"padding-x" => [false, true, false, true],
"padding-y" => [true, false, true, false],
"padding-top" => [true, false, false, false],
"padding-right" => [false, true, false, false],
"padding-bottom" => [false, false, true, false],
"padding-left" => [false, false, false, true],
_ => [false; 4],
}
}
/// Utilities zeroing `sides` (as few as cover them: `py-0`, `px-0`, else per side).
fn zero_padding(sides: [bool; 4]) -> Vec<&'static str> {
let [t, r, b, l] = sides;
let mut out = Vec::new();
match (t, b) {
(true, true) => out.push("py-0"),
(true, false) => out.push("pt-0"),
(false, true) => out.push("pb-0"),
_ => {}
}
match (r, l) {
(true, true) => out.push("px-0"),
(true, false) => out.push("pr-0"),
(false, true) => out.push("pl-0"),
_ => {}
}
out
}
/// Mobile classes plus, behind the breakpoint, the desktop ones that differ (and resets for what
/// desktop leaves unset). A layer missing on mobile is hidden there.
pub(crate) fn merge(mobile: Option<&[(String, String)]>, desktop: &[(String, String)]) -> Vec<String> {
let hidden = [("display".to_string(), "hidden".to_string())];
// A mobile frame's widths are the phone's, not caps: below the breakpoint boxes fill the page.
let fluid: Vec<(String, String)>;
let mobile = match mobile {
Some(m) => {
fluid = m.iter().filter(|(k, _)| k != "max-width").cloned().collect();
fluid.as_slice()
}
None => &hidden,
};
let mut out: Vec<String> = mobile.iter().map(|(_, c)| c.clone()).collect();
let bp = |c: &str| format!("{BREAKPOINT}:{c}");
for (_, c) in desktop {
if !mobile.iter().any(|(_, m)| m == c) {
out.push(bp(c));
}
}
for (k, _) in mobile.iter().filter(|(k, _)| !k.starts_with("padding")) {
if !desktop.iter().any(|(d, _)| d == k)
&& let Some(r) = reset(k)
{
out.extend(r.split_whitespace().map(bp));
}
}
// Padding utilities overlap (p, px/py, pt…): compare side by side, and zero on desktop the
// sides mobile pads and desktop doesn't.
let cover = |list: &[(String, String)]| {
list.iter().fold([false; 4], |mut acc, (k, _)| {
for (a, s) in acc.iter_mut().zip(padding_sides(k)) {
*a |= s;
}
acc
})
};
let (m, d) = (cover(mobile), cover(desktop));
let missing = [m[0] && !d[0], m[1] && !d[1], m[2] && !d[2], m[3] && !d[3]];
out.extend(zero_padding(missing).into_iter().map(bp));
out.dedup();
out
}
/// The markup walk for `id` in Tailwind mode: (markup, rules by path).
fn walk(doc: &Document, id: NodeId) -> (String, Vec<(String, String, String)>) {
let ctx = Ctx::new(doc, id);
let mut m = Markup::new(&ctx, CodeLang::React);
m.inline = true;
m.fluid = true;
m.fluid_leaves = 0.5;
m.tokens.tailwind = true;
m.node(id, None, 2, "");
(m.markup, m.rules)
}
/// React + Tailwind for `id` (with `mobile`, mobile first with desktop classes behind `lg:`).
pub fn code(doc: &Document, id: NodeId, mobile: Option<NodeId>) -> String {
let Some(n) = doc.node(id) else { return String::new() };
let (mut markup, rules) = walk(doc, id);
let small: Option<BTreeMap<String, Vec<(String, String)>>> =
mobile.filter(|m| doc.contains(*m) && *m != id).map(|m| walk(doc, m).1.into_iter().map(|(p, _, d)| (p, classes(&d))).collect());
// ponytail: layers only on mobile are listed, not merged in; their place in the tree is ambiguous.
let only_mobile: Vec<String> = small
.iter()
.flat_map(BTreeMap::keys)
.filter(|p| !rules.iter().any(|(d, _, _)| d == *p))
.map(|p| p.trim_start_matches('/').split('/').map(|seg| seg.rsplit_once('#').map_or(seg, |(n, _)| n)).collect::<Vec<_>>().join(" › "))
.take(20)
.collect();
// Longest class names first, so "card" never rewrites inside "card-2"'s attribute.
let mut rules = rules;
rules.sort_by_key(|(_, c, _)| std::cmp::Reverse(c.len()));
let paths: Vec<String> = rules.iter().map(|(p, _, _)| p.clone()).collect();
for (path, class, decls) in &rules {
let own = classes(decls);
// Layer names may contain "/": the parent is the longest other path this one extends.
let parent =
paths.iter().filter(|p| p.len() < path.len() && path.starts_with(&format!("{p}/"))).max_by_key(|p| p.len()).map_or("", String::as_str);
let list = match &small {
// Inside a layer hidden on mobile: desktop's classes as they are.
Some(m) if !path.is_empty() && !m.contains_key(parent) => own.into_iter().map(|(_, c)| c).collect(),
Some(m) => merge(m.get(path).map(Vec::as_slice), &own),
None => own.into_iter().map(|(_, c)| c).collect(),
};
markup = markup.replace(&format!("className=\"{class}\""), &format!("className=\"{}\"", list.join(" ")));
}
// Imports of the instances written as their connected code component, once each.
let mut imports: Vec<String> = Vec::new();
for i in doc.subtree(id).into_iter().take(20_000) {
if i != id
&& doc.node(i).is_some_and(|k| matches!(k.kind, NodeKind::Instance { .. }))
&& let Some(l) = crate::code_connect::link(doc, i, CodeLang::React)
{
let line = crate::code_connect::import_line(l, CodeLang::React);
if !line.is_empty() && !imports.contains(&line) {
imports.push(line);
}
}
}
let imports = if imports.is_empty() { String::new() } else { format!("{}\n\n", imports.join("\n")) };
let name = component_type_name(&n.name);
let mut mode = if small.is_some() { format!(", mobile first; desktop from {BREAKPOINT}") } else { String::new() };
if !only_mobile.is_empty() {
mode.push_str(&format!(". Only in the mobile frame, add by hand with {BREAKPOINT}:hidden: {}", only_mobile.join(", ")));
}
let header = comment_safe(&format!("{} as React + Tailwind CSS v4{mode}. Theme: variable.export {{format: \"tailwind\"}}.", n.name));
format!("// {header}\n{imports}export function {name}() {{\n return (\n{markup} );\n}}\n")
}
/// `text` for one `//` comment line: every JS line terminator (control characters, U+2028,
/// U+2029) becomes a space, so layer names in it can't end the comment and inject code.
fn comment_safe(text: &str) -> String {
text.chars().take(2_000).map(|c| if c.is_control() || matches!(c, '\u{2028}' | '\u{2029}') { ' ' } else { c }).collect()
}
+342
View File
@@ -0,0 +1,342 @@
//! React + Tailwind code for pages: utilities instead of a stylesheet, theme utilities for bound
//! variables, fluid boxes, inlined instances, per-side borders, mobile-first responsive merges and
//! the Tailwind theme export.
use crate::Session;
use crate::components::tests::{ex, idv};
use crate::tailwind::{classes, merge};
use figcraft_doc::NodeId;
use serde_json::json;
fn code(s: &mut Session, id: NodeId, extra: serde_json::Value) -> String {
let mut p = json!({"id": id, "language": "tailwind"});
if let (Some(p), Some(e)) = (p.as_object_mut(), extra.as_object()) {
p.extend(e.clone());
}
ex(s, "inspect.code", p)["code"].as_str().unwrap_or_default().to_string()
}
/// A 1440 "Page" (vertical auto layout, bg bound to Brand/Accent) holding a 960 "Hero" text and a
/// horizontal "Row" of two "Card" frames that fill it. Returns (page, row).
fn page(s: &mut Session, name: &str, width: f64, row_dir: &str) -> (NodeId, NodeId) {
let p = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 900, "name": name})));
ex(s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
ex(s, "node.set", json!({"id": p, "props": {"gap": 24, "padding": 32}}));
let t = idv(&ex(s, "text.create", json!({"text": "Seu site em 7 dias", "x": 0, "y": 0, "parent": p, "props": {"name": "Hero"}})));
ex(s, "node.set", json!({"id": t, "props": {"width": 960, "textAutoResize": "height"}}));
let row = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 800, "height": 200, "parent": p, "name": "Row"})));
ex(s, "layout.add", json!({"ids": [row], "direction": row_dir}));
for _ in 0..2 {
let c = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": row, "name": "Card"})));
ex(s, "node.set", json!({"id": c, "props": {"sizingH": "fill"}}));
}
(p, row)
}
fn brand(s: &mut Session) {
ex(s, "variable.createCollection", json!({"name": "Brand"}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "Brand/Accent", "type": "color", "value": "#3DDC97"}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "card", "type": "number", "value": 12}));
ex(s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 0.5}));
}
#[test]
fn utilities_replace_the_stylesheet_and_variables_become_theme_utilities() {
let mut s = Session::new();
brand(&mut s);
let (p, row) = page(&mut s, "Landing", 1440.0, "horizontal");
ex(&mut s, "variable.bind", json!({"ids": [p], "field": "fill", "variable": "Brand/Accent"}));
ex(&mut s, "node.set", json!({"id": row, "props": {"cornerRadius": 12}}));
ex(&mut s, "variable.bind", json!({"ids": [row], "field": "cornerRadius", "variable": "card"}));
let out = code(&mut s, p, json!({}));
assert!(out.contains("export function Landing() {"), "{out}");
assert!(!out.contains("import") && !out.contains("<style") && !out.contains(".css"), "no stylesheet: {out}");
for want in ["flex flex-col", "gap-6", "p-8", "bg-accent", "rounded-card", "flex-1", "w-full"] {
assert!(out.contains(want), "{want} in {out}");
}
// The page is as wide as itself: just full width; the 960 text is capped, not fixed.
assert!(out.contains("max-w-[960px]") && !out.contains("w-[1440px]") && !out.contains("max-w-[1440px]"), "{out}");
}
#[test]
fn per_side_strokes_are_borders_on_those_sides() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 60, "name": "Nav"})));
ex(&mut s, "node.set", json!({"id": f, "props": {"strokes": ["#262A31"], "strokeWeight": 1, "strokeSides": [0, 0, 1, 0]}}));
let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string();
assert!(css.contains("border-bottom: 1px solid") && !css.contains("border:"), "{css}");
let tw = code(&mut s, f, json!({}));
assert!(tw.contains("border-b ") && (tw.contains("border-[#262a31]") || tw.contains("border-[#262A31]")), "{tw}");
// Without per-side weights it is one border.
ex(&mut s, "node.set", json!({"id": f, "props": {"strokeSides": null, "strokeWeight": 2}}));
let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string();
assert!(css.contains("border: 2px solid"), "{css}");
}
#[test]
fn instances_are_inlined_with_their_overrides() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Benefit"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"}));
ex(&mut s, "text.create", json!({"text": "Original", "x": 0, "y": 0, "parent": f, "props": {"name": "Title"}}));
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let holder = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 600, "height": 200, "name": "Benefits"})));
ex(&mut s, "layout.add", json!({"ids": [holder], "direction": "horizontal"}));
let inst = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": holder})));
let doc = s.doc().unwrap();
let title = doc.node(inst).unwrap().children[0];
ex(&mut s, "text.set", json!({"id": title, "characters": "Agenda sozinha"}));
let out = code(&mut s, holder, json!({}));
assert!(out.contains("Agenda sozinha") && !out.contains("<Benefit"), "inlined with override: {out}");
}
#[test]
fn a_mobile_frame_makes_the_code_mobile_first() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
// A layer only on desktop is hidden on mobile.
ex(&mut s, "text.create", json!({"text": "Só no desktop", "x": 0, "y": 0, "parent": desk, "props": {"name": "Aside"}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
assert!(out.contains("mobile first"), "{out}");
assert!(out.contains("flex flex-col") && out.contains("lg:flex-row"), "row stacks on mobile: {out}");
assert!(out.contains("hidden") && out.contains("lg:block"), "desktop-only layer: {out}");
assert!(s.execute("inspect.code", &json!({"id": desk, "language": "tailwind", "mobile": 999_999})).is_err(), "a missing mobile frame is refused");
}
#[test]
fn declarations_map_to_idiomatic_utilities() {
let got: Vec<String> = classes(
"display: flex;\njustify-content: space-between;\nalign-items: center;\npadding: 8px 16px 8px 16px;\nfont-weight: 600;\nfont-size: 18px;\nline-height: 120%;\nopacity: 0.5;\ncolor: var(--color-muted, #9aa3ae);\nbox-shadow: 0px 4px 12px 0px rgba(0, 0, 0, 0.25);\nwhite-space: nowrap;\n",
)
.into_iter()
.map(|(_, c)| c)
.collect();
let want = [
"flex",
"justify-between",
"items-center",
"py-2",
"px-4",
"font-semibold",
"text-[18px]",
"leading-[1.2]",
"opacity-50",
"text-muted",
"shadow-[0px_4px_12px_0px_rgba(0,_0,_0,_0.25)]",
"[white-space:nowrap]",
];
assert_eq!(got, want);
}
#[test]
fn variables_export_with_units_and_as_a_tailwind_theme() {
let mut s = Session::new();
brand(&mut s);
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12, "opacity": 0.5}}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "card"}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;"), "{css}");
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
for want in ["@import \"tailwindcss\";", "@theme static {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] {
assert!(tw.to_lowercase().contains(&want.to_lowercase()), "{want} in {tw}");
}
}
#[test]
fn desktop_padding_on_other_sides_is_not_reset_away() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 24, "paddingBottom": 24, "paddingLeft": 64, "paddingRight": 64}}));
ex(&mut s, "node.set", json!({"id": mob, "props": {"padding": 24}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
assert!(out.contains("p-6 ") && out.contains("lg:py-6 lg:px-16"), "{out}");
assert!(!out.contains("lg:p-0"), "a reset would undo lg:px-16: {out}");
}
#[test]
fn a_layer_name_cant_break_out_of_the_header_comment() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "A\rB\u{2028}alert(1)\u{2029}C\nD"})));
let out = code(&mut s, f, json!({}));
let header = out.lines().next().unwrap_or_default();
assert!(header.starts_with("// ") && header.contains("alert(1)"), "{out}");
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
let name = out.lines().nth(1).and_then(|l| l.strip_prefix("export function ")).and_then(|l| l.strip_suffix("() {")).unwrap_or_default();
assert!(!name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'), "an identifier: {name:?}");
}
#[test]
fn slashed_layer_names_and_phone_widths_merge_right() {
let mut s = Session::new();
let mut frames = Vec::new();
for (name, width, dir, sizing) in [("Landing", 1440.0, "horizontal", "hug"), ("Landing mobile", 390.0, "vertical", "fill")] {
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": width, "height": 600, "name": name})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
let ctas = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 342, "height": 120, "parent": p, "name": "CTAs"})));
ex(&mut s, "layout.add", json!({"ids": [ctas], "direction": dir}));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 200, "height": 48, "parent": ctas, "name": "Button/Primary"})));
ex(&mut s, "layout.add", json!({"ids": [b], "direction": "horizontal"}));
ex(&mut s, "node.set", json!({"id": b, "props": {"sizingH": sizing}}));
ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": width * 0.8, "height": 1, "parent": p, "props": {"name": "Divider"}}));
frames.push(p);
}
// A layer only the mobile frame has.
ex(&mut s, "text.create", json!({"text": "7 dias", "x": 0, "y": 0, "parent": frames[1], "props": {"name": "Tag"}}));
let out = code(&mut s, frames[0], json!({"mobile": frames[1]}));
let button =
out.lines().find(|l| l.contains("lg:") && l.contains("rounded") || l.contains("self-stretch") && l.contains("flex-row")).unwrap_or_default();
assert!(button.contains("self-stretch") && button.contains("lg:self-auto"), "the slashed name keeps its mobile classes: {out}");
assert!(!out.contains("max-w-[342px] ") || out.contains("lg:max-w-[342px]"), "no phone-width cap below lg: {out}");
assert!(out.contains("Only in the mobile frame") && out.contains("Tag"), "{out}");
let divider = out.lines().filter(|l| l.contains("<div") && (l.contains("h-[1px]") || l.contains("h-px"))).count();
assert!(out.contains("w-full") && divider <= 2, "{out}");
}
#[test]
fn a_variable_named_after_its_namespace_is_its_base() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "radius", "type": "number", "value": 12}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12}}));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "radius"}));
assert!(code(&mut s, f, json!({})).contains("rounded-base"));
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string();
assert!(tw.contains("--radius-base: 12px;") && !tw.contains("--radius-radius"), "{tw}");
}
#[test]
fn mobile_only_layer_names_cant_break_out_of_the_header_comment() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0, "parent": mob, "props": {"name": "Tag\nalert(1)\u{2028}y"}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
let header = out.lines().next().unwrap_or_default();
assert!(header.starts_with("// ") && header.contains("alert(1)"), "listed inside the comment: {out}");
assert!(out.lines().nth(1).is_some_and(|l| l.starts_with("export function ")), "{out}");
assert!(!out.contains(['\r', '\u{2028}', '\u{2029}']), "{out:?}");
}
#[test]
fn page_code_leaves_resets_to_tailwind_preflight() {
let mut s = Session::new();
let (p, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let out = code(&mut s, p, json!({}));
for leak in ["box-sizing", "[margin:0]", "text-decoration", "cursor"] {
assert!(!out.contains(leak), "{leak} in {out}");
}
}
#[test]
fn instances_connected_to_code_are_their_snippet_with_one_import() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 160, "height": 44, "name": "Button"})));
ex(&mut s, "layout.add", json!({"ids": [f], "direction": "horizontal"}));
let label = idv(&ex(&mut s, "text.create", json!({"text": "Botão", "x": 0, "y": 0, "parent": f, "props": {"name": "Label"}})));
let comp = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "component.addProperty", json!({"id": comp, "name": "Label", "type": "text", "layer": label}));
ex(
&mut s,
"codeConnect.set",
json!({"id": comp, "language": "react", "importPath": "@/components/ui/button", "name": "Button", "template": "<Button size=\"lg\">{Label}</Button>"}),
);
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 1440, "height": 400, "name": "Landing"})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
for text in ["Agendar diagnóstico", "Ver exemplos"] {
let i = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": p})));
ex(&mut s, "instance.setProperties", json!({"id": i, "properties": {"Label": text}}));
}
let out = code(&mut s, p, json!({}));
assert_eq!(out.matches("import { Button } from \"@/components/ui/button\";").count(), 1, "{out}");
assert!(out.contains("<Button size=\"lg\">") && out.contains("Agendar diagnóstico") && out.contains("Ver exemplos"), "{out}");
assert!(!out.contains(">Botão<"), "the component's own layers aren't inlined: {out}");
// Unconnected instances are still inlined.
ex(&mut s, "codeConnect.remove", json!({"id": comp, "language": "react"}));
let out = code(&mut s, p, json!({}));
assert!(!out.contains("import ") && out.contains(">Agendar diagnóstico<"), "{out}");
}
#[test]
fn review_same_named_variables_keep_distinct_theme_names() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Palette/Accent", "type": "color", "value": "#3DDC97"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Semantic/Accent", "type": "color", "value": "#7C5CFF"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "Muted", "type": "color", "value": "#9AA3AE"}));
let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 200, "name": "Page"})));
ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"}));
let a = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "A"})));
let b = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 50, "parent": p, "name": "B"})));
ex(&mut s, "variable.bind", json!({"ids": [a], "field": "fill", "variable": "Palette/Accent"}));
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "Semantic/Accent"}));
let out = code(&mut s, p, json!({}));
assert!(out.contains("bg-palette-accent") && out.contains("bg-semantic-accent"), "{out}");
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_lowercase();
assert!(tw.contains("--color-palette-accent: #3ddc97;") && tw.contains("--color-semantic-accent: #7c5cff;"), "{tw}");
assert!(tw.contains("--color-muted:"), "unique short names stay short: {tw}");
}
#[test]
fn review_an_alias_target_takes_its_readers_role() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Brand"}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "half", "type": "number", "value": 0.5}));
ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 1}));
ex(&mut s, "variable.set", json!({"variable": "fade", "value": {"alias": "half"}}));
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string();
assert!(css.contains("--half: 0.5;") && !css.contains("0.5px"), "{css}");
}
#[test]
fn review_borders_images_and_fonts_from_variables_are_valid_utilities() {
let got: Vec<String> = classes(
"border: var(--stroke, 2px) solid #000000;\nborder-bottom: var(--hair, 1px) solid var(--color-border, #262a31);\nbackground: url(image.png) center / cover;\nfont-family: var(--brand-font, \"Inter\");\n",
)
.into_iter()
.map(|(_, c)| c)
.collect();
assert_eq!(
got,
[
"border-(length:--stroke)",
"border-[#000000]",
"border-b-(length:--hair)",
"border-border",
"[background:url(image.png)_center_/_cover]",
"font-(family-name:--brand-font)"
]
);
}
#[test]
fn review_desktop_defaults_reset_mobile_values_and_padding_per_side() {
let mut s = Session::new();
let (desk, _) = page(&mut s, "Landing", 1440.0, "horizontal");
let (mob, _) = page(&mut s, "Landing mobile", 390.0, "vertical");
ex(&mut s, "node.set", json!({"id": mob, "props": {"opacity": 0.5, "padding": 24}}));
ex(&mut s, "node.set", json!({"id": desk, "props": {"paddingTop": 0, "paddingBottom": 0, "paddingLeft": 64, "paddingRight": 64}}));
let out = code(&mut s, desk, json!({"mobile": mob}));
let root = out.lines().nth(3).unwrap_or_default();
assert!(root.contains("opacity-50") && root.contains("lg:opacity-100"), "{root}");
assert!(root.contains("p-6") && root.contains("lg:px-16") && root.contains("lg:py-0"), "{root}");
}
#[test]
fn review_desktop_without_fill_clears_a_mobile_image_or_gradient() {
let got = merge(Some(&[("background".to_string(), "[background:url(image.png)_center_/_cover]".to_string())]), &[]);
assert!(got.contains(&"lg:bg-none".to_string()) && got.contains(&"lg:bg-transparent".to_string()), "{got:?}");
}
#[test]
fn per_corner_radii_with_a_variable_first_keep_every_corner() {
let got: Vec<String> =
classes("border-radius: var(--radius-a, 4px) 8px 8px 8px;\nborder-radius: var(--radius-card, 4px);\n").into_iter().map(|(_, c)| c).collect();
assert_eq!(got, ["rounded-[var(--radius-a,_4px)_8px_8px_8px]", "rounded-card"]);
}
+20
View File
@@ -275,6 +275,20 @@ fn align_distribute_zorder() {
assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c);
}
#[test]
fn distribute_spans_the_outermost_edge() {
let mut s = sess();
let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap());
let b = id(&s.execute("shape.rectangle", &json!({"x": 150, "y": 150, "width": 500, "height": 500})).unwrap());
let c = id(&s.execute("shape.rectangle", &json!({"x": 160, "y": 160, "width": 10, "height": 10})).unwrap());
s.execute("distribute.horizontal", &json!({"ids": [a, b, c]})).unwrap();
let xs: Vec<f64> = [a, b, c].iter().map(|i| s.doc().unwrap().world_bounds(*i).unwrap().x0).collect();
assert_eq!(xs, [0.0, 75.0, 640.0]);
s.execute("distribute.vertical", &json!({"ids": [a, b, c]})).unwrap();
let ys: Vec<f64> = [a, b, c].iter().map(|i| s.doc().unwrap().world_bounds(*i).unwrap().y0).collect();
assert_eq!(ys, [0.0, 75.0, 640.0]);
}
#[test]
fn a_layer_alone_on_the_page_has_nothing_to_align_to() {
use crate::cmd::arrange::can_align;
@@ -423,6 +437,7 @@ fn every_command_survives_junk_params() {
// Selection color slots: malformed indexes, ranges and paints.
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
json!({"targets": [{"id": 2, "fills": false, "index": 99999999999u64, "range": null}], "variable": ["x"]}),
// Smart-selection reorder targets and modes as junk.
json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}),
json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}),
@@ -433,6 +448,11 @@ fn every_command_survives_junk_params() {
// would write to it.)
json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}),
json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}),
// Batch step names and reference paths as junk.
json!({"commands": [{"command": "select.none", "as": "a"}, {"command": "select.none", "as": [1]}, {"command": "select.none", "as": "a"}]}),
json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [
{"$ref": "\u{0}", "field": "ids/18446744073709551616/x"}, {"$ref": {"x": 1}}, {"$step": 0, "field": "//"}, {"$ref": "\u{0}", "field": "/"}],
"props": {"name": {"$ref": "nope", "field": 5}}}}]}),
];
for spec in all_commands() {
if matches!(
+61 -1
View File
@@ -60,11 +60,14 @@ fn arrows_and_deletes_step_over_whole_clusters() {
key(&mut s, "Right", true, false, false).unwrap();
assert_eq!(caret(&s), (1, 6));
key(&mut s, "Left", false, false, false).unwrap();
// Delete and Backspace remove a whole cluster.
// Delete removes a whole cluster; Backspace takes a letter's marks one at a time (#1220) and
// an emoji cluster whole.
key(&mut s, "Delete", false, false, false).unwrap();
assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}e\u{0301}");
key(&mut s, "Right", false, false, true).unwrap(); // to the end
key(&mut s, "Backspace", false, false, false).unwrap();
assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}e");
key(&mut s, "Backspace", false, false, false).unwrap();
assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}");
key(&mut s, "Backspace", false, false, false).unwrap();
assert_eq!(chars(&s, t), "ab");
@@ -134,6 +137,63 @@ fn ime_composition_with_cjk() {
assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none());
}
/// Asserts `delete_surrounding_bytes` removes an exact UTF-8 byte range across CJK and a
/// supplementary emoji, leaves nothing past the start, and the whole edit is one undo step.
#[test]
fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() {
// 0.37's `DeleteSurroundingBytes` counts UTF-8 bytes around the selection, before the paired
// commit. Multibyte CJK and a supplementary emoji must be removed exactly, not as tofu or
// extended to grapheme clusters, and the whole edit remains one undo step.
let mut s = Session::new();
let t = text(&mut s, "ab\u{4F60}\u{597D}c\u{1F600}d", json!({"fontSize": 24}));
begin(&mut s, t, Some(4), false).unwrap();
assert!(crate::textedit::delete_surrounding_bytes(&mut s, 6, 1).unwrap(), "你好 before and c after");
assert_eq!(chars(&s, t), "ab\u{1F600}d");
assert_eq!(caret(&s), (2, 2));
crate::textedit::set_caret(&mut s, 3, false).unwrap();
assert!(crate::textedit::delete_surrounding_bytes(&mut s, 4, 0).unwrap(), "the emoji is four bytes");
assert_eq!(chars(&s, t), "abd");
assert_eq!(caret(&s), (2, 2));
// Past the start of the text: nothing more to delete, not an error.
assert!(!crate::textedit::delete_surrounding_bytes(&mut s, 0, 0).unwrap());
crate::textedit::end(&mut s).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(chars(&s, t), "ab\u{4F60}\u{597D}c\u{1F600}d", "one undo restores the whole session");
}
/// Asserts a byte range that only partly covers a character leaves it in place, while an
/// exact range deletes it, and a selection survives for the paired commit with its
/// direction preserved.
#[test]
fn ime_delete_surrounding_bytes_keeps_partial_characters_and_selection() {
// The fork deletes the byte range exactly and leaves a character the range only partly
// covers. FigCraft's editor counts characters with the same rule (mirroring egui's
// `surrounding_bytes_to_chars`), including around a selection.
let mut s = Session::new();
let t = text(&mut s, "\u{e9}x\u{1F600}", json!({"fontSize": 24}));
let del = |s: &mut Session, b: usize, a: usize| crate::textedit::delete_surrounding_bytes(s, b, a).unwrap();
begin(&mut s, t, Some(1), false).unwrap();
assert!(!del(&mut s, 1, 0), "one byte ends inside 'é' (two bytes): leave it");
assert_eq!(chars(&s, t), "\u{e9}x\u{1F600}");
crate::textedit::set_caret(&mut s, 2, false).unwrap();
assert!(!del(&mut s, 0, 2), "two bytes start inside the four-byte emoji: leave it");
assert_eq!(chars(&s, t), "\u{e9}x\u{1F600}");
assert!(del(&mut s, 0, 4), "its four bytes cover it exactly");
assert_eq!(chars(&s, t), "\u{e9}x");
assert_eq!(caret(&s), (2, 2));
// Complete bytes before the selection go; a character the range only partly covers stays,
// and the selection itself is kept for the paired commit.
crate::textedit::set_caret(&mut s, 1, false).unwrap();
crate::textedit::set_caret(&mut s, 1, true).unwrap();
crate::textedit::set_caret(&mut s, 2, true).unwrap();
assert!(del(&mut s, 2, 2), "é before (two bytes); two bytes start inside the four-byte emoji");
// The selected 'x' is kept for the paired commit, as in stock `TextEdit`; the caret moves left
// with the deleted prefix and the selection direction is preserved.
assert_eq!(chars(&s, t), "x");
assert_eq!(caret(&s), (0, 1));
crate::textedit::end(&mut s).unwrap();
}
#[test]
fn vertical_trim_through_node_set() {
let mut s = Session::new();
+550 -8
View File
@@ -1,9 +1,85 @@
//! On-canvas text editing: caret, selection, typing. A whole editing session is one undo step.
use std::sync::{LazyLock, Mutex};
use figcraft_doc::{Document, ListStyle, NodeId, NodeKind, TextData};
use crate::{EngineError, Preedit, Result, Session, TextEdit};
/// Most committed UTF-8 bytes an outgoing IME snapshot may describe.
///
/// This mirrors the fork's `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; the engine keeps its
/// own constant so this layer has no egui dependency. The query below returns only this bounded
/// window around the selection, never a copy of the whole document.
pub const MAX_IME_SURROUNDING_BYTES: usize = 4000;
/// Stable identity of the on-canvas text edit an IME context belongs to.
///
/// `document` and `node` do not change through ordinary text or caret edits. `editing_session`
/// changes when [`begin`] starts a new session for an equal `(document, node)` pair (leaving and
/// re-entering the same layer), so a replaced target never inherits an old composition.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ImeEditOwner {
/// The document tab's uid.
pub document: u64,
/// The edited text node.
pub node: NodeId,
/// [`TextEdit::editing_session`] of the current edit.
pub editing_session: u64,
}
/// Why the committed text around the IME caret cannot be described.
///
/// These are the only reasons this custom editor reports
/// `egui::output::IMESurroundingText::Unavailable`; an empty text layer is available with empty
/// text and a collapsed selection.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ImeSurroundingUnavailable {
/// No text layer is being edited.
NotEditing,
/// The editing target is not a text node any more.
NotText,
/// The selection alone is wider than [`MAX_IME_SURROUNDING_BYTES`], so no bounded snapshot
/// can contain both selection ends.
SelectionTooWide,
/// Defensive: the computed window was not a character-boundary slice. The rounding above
/// makes this unreachable for a well-formed text model.
InvalidWindow,
}
/// One bounded, committed logical-text observation for the outgoing IME snapshot.
///
/// `text` is never display/bidi order and never contains the pre-edit (which is UI-only in this
/// editor). `cursor` and `anchor` are UTF-8 byte offsets into `text`; `anchor > cursor` is a
/// valid reversed selection.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ImeSurrounding {
/// Identity of the edit this observation belongs to.
pub owner: ImeEditOwner,
/// At most [`MAX_IME_SURROUNDING_BYTES`] bytes of committed text around the selection.
pub text: String,
/// UTF-8 byte offset of the primary caret in [`Self::text`].
pub cursor: usize,
/// UTF-8 byte offset of the selection anchor in [`Self::text`].
pub anchor: usize,
}
/// Every editing session gets its own token; zero is valid and never reused deliberately.
///
/// A mutex (rather than an atomic) also works on wasm32 targets without 64-bit atomics.
static EDITING_SESSION: LazyLock<Mutex<u64>> = LazyLock::new(|| Mutex::new(0));
/// Return the next editing-session token from a mutex-protected counter (`wrapping_add`).
/// Zero is a valid token; the mutex also works on wasm32 without 64-bit atomics.
fn next_editing_session() -> u64 {
let mut session = match EDITING_SESSION.lock() {
Ok(session) => session,
Err(poisoned) => poisoned.into_inner(),
};
*session = session.wrapping_add(1);
*session
}
fn text_of(s: &Session, id: NodeId) -> Result<TextData> {
match &s.doc()?.get(id)?.kind {
NodeKind::Text(t) => Ok(t.clone()),
@@ -17,11 +93,13 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option<usize>, created: bool) -
let n = t.char_count();
s.begin_interaction("Edit text")?;
let ds = s.ds_mut()?;
// Editing the same layer again (a double-click into a text just made) keeps it new.
let created = created || ds.text_edit.as_ref().is_some_and(|te| te.id == id && te.created);
let (caret, anchor) = match caret {
Some(c) => (c.min(n), c.min(n)),
None => (n, 0),
};
ds.text_edit = Some(TextEdit { id, caret, anchor, created, preedit: None });
ds.text_edit = Some(TextEdit { id, caret, anchor, created, editing_session: next_editing_session(), preedit: None });
ds.selection = vec![id];
ds.revision += 1;
s.live_text_notice(id);
@@ -44,9 +122,13 @@ pub fn end(s: &mut Session) -> Result<()> {
// Nothing to undo for a text box that was never filled. The gesture snapshot was taken
// after the layer was added, so it still holds it: re-base it on the document without the
// layer, and committing then records no step (and leaves the redo stack alone).
// A snapshot taken before the layer was added (the Text on a path tool, which also took
// the path layer away) is put back whole instead.
let ds = s.ds_mut()?;
let doc = ds.doc.clone();
if let Some(it) = ds.interaction.as_mut() {
if ds.interaction.as_ref().is_some_and(|it| it.doc.node(te.id).is_none()) {
s.cancel_interaction()?;
} else if let Some(it) = ds.interaction.as_mut() {
it.doc = doc;
}
}
@@ -63,14 +145,33 @@ fn range(te: &TextEdit) -> (usize, usize) {
(te.caret.min(te.anchor), te.caret.max(te.anchor))
}
/// Apply an unconditional text-model mutation through `edit_if`, whose success clears the
/// composition, clamps caret and anchor to the character count, marks a `characters` override
/// when the node is source-backed, normalizes the document and bumps the revision.
fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> {
edit_if(s, |t, te| {
f(t, te);
true
})
.map(|_| ())
}
/// [`edit`] for a mutation that can still turn out to be a no-op.
///
/// The closure returns whether it changed the model; return `false` only before mutating (the
/// text is handed over directly). A no-op leaves the composition (`preedit`), the revision and the
/// document untouched, so an IME deletion that finds nothing to delete does not cancel an
/// in-progress composition.
fn edit_if(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit) -> bool) -> Result<bool> {
let ds = s.ds_mut()?;
let Some(mut te) = ds.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) };
let before = ds.doc.clone();
let doc = ds.doc_mut();
let n = doc.get_mut(te.id)?;
let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) };
f(t, &mut te);
if !f(t, &mut te) {
return Ok(false);
}
// Any edit ends a composition (a commit replaces it with the final text).
te.preedit = None;
let len = t.char_count();
@@ -82,16 +183,83 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result
crate::normalize(doc, &before);
ds.text_edit = Some(te);
ds.revision += 1;
Ok(())
Ok(true)
}
/// What an accepted IME `Commit` did to the committed model.
///
/// An IME receipt may label the snapshot `InputMethod` only for [`Self::Plain`]. AutoFormat
/// (typing "- " at a line start turns the paragraph into a list) is an application
/// transformation in the same command, so the frame's change must be reported as `Other`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ImeInsertion {
/// Nothing was inserted (an empty, control-only, or bare-newline commit).
Nothing,
/// The committed text replaced the selection exactly.
Plain,
/// The editor transformed the commit before applying it: paragraph AutoFormat, or line-ending
/// normalisation/control-character removal. The frame's change is application-origin.
Transformed,
}
/// Type (or paste) `text` replacing the selection. Line endings are normalised and other control
/// characters dropped, so pasted plain text from any platform lands cleanly.
pub fn insert(s: &mut Session, text: &str) -> Result<()> {
let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
if text.is_empty() {
insert_model(s, text, false).map(|_| ())
}
/// End the composition without changing the document, for an empty IME commit.
///
/// Returns without touching the session when no composition is active (or nothing is being
/// edited): an empty commit is otherwise a no-op, never an error.
fn clear_composition(s: &mut Session) -> Result<()> {
let composing = s.ds().ok().and_then(|d| d.text_edit.as_ref()).is_some_and(|te| te.preedit.is_some());
if !composing {
return Ok(());
}
let ds = s.ds_mut()?;
if let Some(te) = ds.text_edit.as_mut() {
te.preedit = None;
}
ds.revision += 1;
Ok(())
}
/// Apply an IME `Commit`, reporting whether it was a plain insertion.
///
/// The UI calls this instead of [`insert`] so only a commit that the editor applied as a plain
/// insertion can earn an `InputMethod` receipt. AutoFormat, line-ending normalisation and
/// control-character removal are application transformations and are reported as
/// [`ImeInsertion::Transformed`]. Like stock `TextEdit`, a bare `"\n"`/`"\r"` commit is ignored.
pub fn insert_ime_commit(s: &mut Session, text: &str) -> Result<ImeInsertion> {
insert_model(s, text, true)
}
/// Insert committed text, optionally as an IME commit: normalize line endings, drop control
/// characters except `\n`/`\t`, ignore a bare newline/CR IME commit, clear an active
/// composition for an empty or control-only IME commit, and turn a committed space after a
/// line-start `-`, `*` or `1.` into a bulleted/numbered list. Reports whether the insertion was
/// plain, transformed, or a no-op.
fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result<ImeInsertion> {
// Stock `TextEdit` deliberately ignores these two commits (they are not a mutation); only a
// real commit may do so, since typing Enter goes through `insert`.
if ime_commit && matches!(text, "\n" | "\r") {
return Ok(ImeInsertion::Nothing);
}
let normalized: String =
crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect();
if normalized.is_empty() {
// An empty commit is how some integrations (Safari's egui web integration when the last
// composition character is deleted) end the composition. It inserts nothing, but it must
// still clear the pre-edit; stock `TextEdit` does the same in its non-ignored `Commit` arm.
if ime_commit {
clear_composition(s)?;
}
return Ok(ImeInsertion::Nothing);
}
// A commit the editor had to transform is an application edit, not a plain insertion.
let mut outcome = if normalized == text { ImeInsertion::Plain } else { ImeInsertion::Transformed };
let text = normalized;
edit(s, |t, te| {
let (a, b) = range(te);
// "- ", "* " or "1. " typed at the start of a plain paragraph starts a list.
@@ -108,13 +276,15 @@ pub fn insert(s: &mut Session, text: &str) -> Result<()> {
t.replace_range(start, a, "");
t.set_para_lists(start, start, |pl| pl.list = list);
(te.caret, te.anchor) = (start, start);
outcome = ImeInsertion::Transformed;
return;
}
}
t.replace_range(a, b, &text);
te.caret = a + text.chars().count();
te.anchor = te.caret;
})
})?;
Ok(outcome)
}
/// List keys: Tab nests the selected list items a level deeper; Shift+Tab, Backspace at an item's
@@ -243,7 +413,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R
} else if word {
word_left(&chars, a)
} else {
figcraft_text::grapheme_step(&t.characters, a, false)
figcraft_text::backspace_step(&t.characters, a)
};
(from, a)
} else {
@@ -339,6 +509,160 @@ fn select(s: &mut Session, anchor: usize, caret: usize) -> Result<()> {
Ok(())
}
/// Delete the text surrounding the caret/selection that an IME reports with
/// [`egui::ImeEvent::DeleteSurroundingBytes`]: `before_bytes` UTF-8 bytes end where the selection
/// starts and `after_bytes` start where it ends. As in stock `TextEdit`, the selected text itself
/// is kept for the paired `Commit`; only whole characters covered by the byte counts beyond the
/// selection are removed (a character the range only partly covers stays). Returns whether
/// anything was deleted.
pub fn delete_surrounding_bytes(s: &mut Session, before_bytes: usize, after_bytes: usize) -> Result<bool> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
let t = text_of(s, te.id)?;
let (a, b) = range(&te);
let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes);
if before == 0 && after == 0 {
return Ok(false);
}
edit_if(s, |t, te| {
let (a, b) = range(te);
let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes);
if before == 0 && after == 0 {
return false;
}
// Delete after the selection first so the before range's indices stay valid.
if after > 0 {
t.replace_range(b, b + after, "");
}
if before > 0 {
t.replace_range(a - before, a, "");
}
// Keep the selection: both ends move left by the deleted prefix, reversed or not.
te.caret = te.caret.saturating_sub(before);
te.anchor = te.anchor.saturating_sub(before);
true
})
}
/// Delete surrounding text counted in characters (the older `DeleteSurrounding` event). The
/// selection is kept, as in stock `TextEdit`; only text before and after it is removed.
pub fn delete_surrounding_chars(s: &mut Session, before_chars: usize, after_chars: usize) -> Result<bool> {
let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) };
let t = text_of(s, te.id)?;
let (a, b) = range(&te);
let (_, b_byte) = char_byte_offsets(&t.characters, a, b);
let before = before_chars.min(a);
let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count());
if before == 0 && after == 0 {
return Ok(false);
}
edit_if(s, |t, te| {
let (a, b) = range(te);
let (_, b_byte) = char_byte_offsets(&t.characters, a, b);
let before = before_chars.min(a);
let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count());
if before == 0 && after == 0 {
return false;
}
if after > 0 {
t.replace_range(b, b + after, "");
}
if before > 0 {
t.replace_range(a - before, a, "");
}
te.caret = te.caret.saturating_sub(before);
te.anchor = te.anchor.saturating_sub(before);
true
})
}
/// The UTF-8 byte offsets of character indices `a` and `b` in one forward pass.
///
/// An index at or past the end maps to `text.len()`. Walking once (instead of twice) matters on
/// every IME event over a large text layer; the walk is still O(max(a, b)) characters, and callers
/// that need the end still pay O(n). See [`ime_surrounding`].
fn char_byte_offsets(text: &str, a: usize, b: usize) -> (usize, usize) {
let (mut a_byte, mut b_byte) = (None, None);
for (i, (byte, _)) in text.char_indices().enumerate() {
if a_byte.is_none() && i == a {
a_byte = Some(byte);
}
if b_byte.is_none() && i == b {
b_byte = Some(byte);
}
if a_byte.is_some() && b_byte.is_some() {
break;
}
}
(a_byte.unwrap_or(text.len()), b_byte.unwrap_or(text.len()))
}
/// `(before, after)` characters covered by the IME's byte counts around the selection `[a, b)`
/// (character indices): mirror of egui's own `surrounding_bytes_to_chars`.
fn surrounding_chars(text: &str, a: usize, b: usize, before_bytes: usize, after_bytes: usize) -> (usize, usize) {
let (a_byte, b_byte) = char_byte_offsets(text, a, b);
let from = a_byte.saturating_sub(before_bytes);
let before = text[..a_byte].char_indices().rev().take_while(|(i, _)| *i >= from).count();
let after = text[b_byte..].char_indices().take_while(|(i, c)| i + c.len_utf8() <= after_bytes).count();
(before, after)
}
/// The committed text around the IME caret for the outgoing surrounding-text snapshot.
///
/// This is the edited node's logical model text (never the bidi display order), with the
/// pre-edit excluded because a composition is UI-only in this editor until it is committed.
/// Character caret/anchor indices become independent UTF-8 byte offsets, so a reversed
/// selection stays reversed. At most [`MAX_IME_SURROUNDING_BYTES`] bytes are copied: the window
/// is centred on the selection and rounded inward to character boundaries. An empty text layer
/// is `Ok` with `text == ""` and both offsets zero; the result is [`ImeSurroundingUnavailable`]
/// only when there is no text edit, the target is not a text node, or the selection alone is
/// wider than the budget.
///
/// Cost: one character walk up to the later of the two cursor indices (to turn char indices into
/// UTF-8 bytes) plus the bounded copy. The caret is a character index, so reaching a caret near
/// the end of a very large layer is still O(n); the UI calls this once per pass and twice around
/// each text-affecting event (`ime_stamp`), so such a layer pays O(caret/anchor) per event. The
/// document is never cloned here.
pub fn ime_surrounding(s: &Session) -> std::result::Result<ImeSurrounding, ImeSurroundingUnavailable> {
let ds = s.ds().map_err(|_| ImeSurroundingUnavailable::NotEditing)?;
let te = ds.text_edit.as_ref().ok_or(ImeSurroundingUnavailable::NotEditing)?;
let node = ds.doc.node(te.id).ok_or(ImeSurroundingUnavailable::NotText)?;
let NodeKind::Text(t) = &node.kind else { return Err(ImeSurroundingUnavailable::NotText) };
let text = t.characters.as_str();
// One pass for both offsets (the caret and anchor are already clamped by `edit`/`select`).
let (cursor, anchor) = char_byte_offsets(text, te.caret, te.anchor);
let (selection_start, selection_end) = (cursor.min(anchor), cursor.max(anchor));
let selection_len = selection_end - selection_start;
if selection_len > MAX_IME_SURROUNDING_BYTES {
return Err(ImeSurroundingUnavailable::SelectionTooWide);
}
// Spend the budget on both sides, giving room unused on one side to the other.
let budget = MAX_IME_SURROUNDING_BYTES - selection_len;
let before_available = selection_start;
let after_available = text.len() - selection_end;
let mut before = (budget / 2).min(before_available);
let mut after = (budget - before).min(after_available);
before = (budget - after).min(before_available);
after = (budget - before).min(after_available);
let mut window_start = selection_start - before;
let mut window_end = selection_end + after;
// Round inward so the window stays within the byte budget and on character boundaries.
while window_start < window_end && !text.is_char_boundary(window_start) {
window_start += 1;
}
while window_end > window_start && !text.is_char_boundary(window_end) {
window_end -= 1;
}
let Some(window) = text.get(window_start..window_end) else {
return Err(ImeSurroundingUnavailable::InvalidWindow);
};
Ok(ImeSurrounding {
owner: ImeEditOwner { document: ds.uid, node: te.id, editing_session: te.editing_session },
text: window.to_owned(),
cursor: cursor.saturating_sub(window_start),
anchor: anchor.saturating_sub(window_start),
})
}
/// Show an IME composition inline at the caret (`cursor` = the IME's cursor inside it, in chars;
/// default its end). The document doesn't change until the composition is committed with
/// [`insert`]; an empty `text` cancels it. Starting to compose over a selection deletes it, as
@@ -574,12 +898,25 @@ mod tests {
end(&mut s).unwrap();
}
#[test]
fn backspace_removes_only_the_final_thai_mark() {
let (mut s, id) = setup("น้ำ");
begin(&mut s, id, Some(3), false).unwrap();
key(&mut s, "Backspace", false, false, false).unwrap();
assert_eq!(data(&s, id).characters, "น้");
}
fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent {
let mut e = PointerEvent::at(kind, x, y, 1.0);
e.shift = shift;
e
}
/// Asserts double-clicking a text layer selects all text, a second double-click selects the
/// word without changing the editing session, and drag and Shift+click extend the
/// selection.
#[test]
fn pointer_double_click_drag_and_shift_click() {
let (mut s, id) = setup("Hello world");
@@ -596,10 +933,16 @@ mod tests {
s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap();
s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap();
assert_eq!(sel(&s), (0, 11));
let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session;
s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap();
s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap();
s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap();
assert_eq!(sel(&s), (6, 11));
assert_eq!(
s.ds().unwrap().text_edit.as_ref().unwrap().editing_session,
epoch,
"a double-click inside the edit is an ordinary edit: same IME replacement epoch"
);
// Drag-select from the start of "Hello" to the middle.
s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap();
assert_eq!(sel(&s), (0, 0));
@@ -811,4 +1154,203 @@ mod tests {
}
}
}
/// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed
/// selection reversed.
#[test]
fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() {
let (mut s, id) = setup("café😀e\u{301}x");
begin(&mut s, id, Some(8), false).unwrap();
set_caret(&mut s, 8, false).unwrap();
set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it
let ime = ime_surrounding(&s).unwrap();
assert_eq!(ime.text, "café😀e\u{301}x");
assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes");
assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed");
assert_eq!(ime.owner.node, id);
assert_eq!(ime.owner.document, s.ds().unwrap().uid);
}
/// Asserts an active composition is excluded from surrounding text and an empty text layer
/// reports an available, empty payload.
#[test]
fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() {
let (mut s, id) = setup("Hi");
begin(&mut s, id, Some(2), false).unwrap();
preedit(&mut s, "にほ", Some(1)).unwrap();
let ime = ime_surrounding(&s).unwrap();
assert_eq!(ime.text, "Hi", "the composition is not committed text");
assert_eq!((ime.cursor, ime.anchor), (2, 2));
end(&mut s).unwrap();
let (mut s, id) = setup("");
begin(&mut s, id, Some(0), false).unwrap();
let ime = ime_surrounding(&s).unwrap();
assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable");
}
/// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized
/// selection is describable, and one byte more is `SelectionTooWide`.
#[test]
fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() {
let long = "a".repeat(5000);
let (mut s, id) = setup(&long);
begin(&mut s, id, Some(5000), false).unwrap();
let ime = ime_surrounding(&s).unwrap();
assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES);
assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES));
// Exactly the budget is still describable; one more byte is not.
set_caret(&mut s, 1000, false).unwrap();
set_caret(&mut s, 5000, true).unwrap();
let ime = ime_surrounding(&s).unwrap();
assert_eq!(ime.text.len(), 4000);
assert_eq!((ime.cursor, ime.anchor), (4000, 0));
set_caret(&mut s, 999, false).unwrap();
set_caret(&mut s, 5000, true).unwrap();
assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide));
}
/// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new
/// edit session changes the generation.
#[test]
fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() {
let (mut s, id) = setup("Hello");
begin(&mut s, id, Some(0), false).unwrap();
let first = ime_surrounding(&s).unwrap().owner;
insert(&mut s, "x").unwrap();
set_caret(&mut s, 2, false).unwrap();
assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner");
end(&mut s).unwrap();
begin(&mut s, id, Some(0), false).unwrap();
let replaced = ime_surrounding(&s).unwrap().owner;
assert_eq!(replaced.node, id);
assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch");
}
/// Asserts byte deletion around a reversed selection removes whole characters on both
/// sides, keeps the selected text and direction, and a final no-op leaves the text alone.
#[test]
fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() {
let (mut s, id) = setup("ab你好cd");
begin(&mut s, id, Some(4), false).unwrap();
set_caret(&mut s, 4, false).unwrap();
set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好
assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap());
// "b" (whole character before the selection) and "c" (whole character after it) go; the
// selected "你好" stays for the paired commit, and the reversed direction is preserved.
assert_eq!(data(&s, id).characters, "a你好d");
assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed
assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap());
assert_eq!(data(&s, id).characters, "你好");
assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed
assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection");
assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text");
}
/// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an
/// active composition and the revision untouched.
#[test]
fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() {
let (mut s, id) = setup("ab你好cd");
begin(&mut s, id, Some(2), false).unwrap();
set_caret(&mut s, 4, true).unwrap(); // "你好"
assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap());
assert_eq!(data(&s, id).characters, "a你好d");
assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix");
set_caret(&mut s, 0, false).unwrap();
// A composition in progress survives a delete that finds nothing to remove.
preedit(&mut s, "x", None).unwrap();
let revision = s.ds().unwrap().revision;
assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap());
assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x"));
assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision");
assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap());
assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x"));
assert_eq!(s.ds().unwrap().revision, revision);
}
/// Asserts a text layer in another document has a different IME owner.
#[test]
fn ime_owner_identity_changes_with_the_document() {
let (mut s, id) = setup("Hi");
begin(&mut s, id, Some(2), false).unwrap();
let first = ime_surrounding(&s).unwrap().owner;
end(&mut s).unwrap();
s.execute("file.new", &json!({"name": "Second"})).unwrap();
let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap();
let other = crate::params::id_of(&r["id"]).unwrap();
begin(&mut s, other, Some(5), false).unwrap();
let second = ime_surrounding(&s).unwrap().owner;
assert_ne!(second.document, first.document, "another document is another IME owner");
assert_eq!(second.node, other);
}
/// Asserts a space committed after a line-start `-` becomes a bulleted list and reports
/// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`.
#[test]
fn ime_commit_reports_list_autoformat_as_an_application_transformation() {
let (mut s, id) = setup("-");
begin(&mut s, id, Some(1), false).unwrap();
assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed);
assert_eq!(data(&s, id).characters, "");
assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted);
assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain);
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
}
/// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`,
/// and bare newline/CR commits are ignored while a live composition remains.
#[test]
fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() {
let (mut s, id) = setup("Hi");
begin(&mut s, id, Some(2), false).unwrap();
// CRLF is inserted as LF, but the editor changed the commit: application-origin.
assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed);
assert_eq!(data(&s, id).characters, "Hia\nb");
// Control characters are dropped; the commit itself is a no-op.
assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing);
assert_eq!(data(&s, id).characters, "Hia\nb");
// Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one.
// (It also leaves a live composition alone, unlike the non-ignored commit below.)
preedit(&mut s, "x", None).unwrap();
assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing);
assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing);
assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into()));
preedit(&mut s, "", None).unwrap();
assert_eq!(data(&s, id).characters, "Hia\nb");
insert(&mut s, "\n").unwrap();
assert_eq!(data(&s, id).characters, "Hia\nb\n");
}
/// Asserts an empty or control-only IME commit ends a live composition (bumping the revision
/// once), inserts nothing, and is a harmless no-op when no composition is active.
#[test]
fn an_empty_ime_commit_ends_the_composition_without_inserting_text() {
let (mut s, id) = setup("Hello");
begin(&mut s, id, Some(5), false).unwrap();
preedit(&mut s, "にほ", None).unwrap();
assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into()));
let revision = s.ds().unwrap().revision;
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision");
assert!(preedit_state(&s).is_none(), "an empty commit ends the composition");
assert!(preedit_doc(&s).is_none());
assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing");
// A control-only commit normalises to empty and ends the composition too.
preedit(&mut s, "x", None).unwrap();
let revision = s.ds().unwrap().revision;
assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing);
assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision");
assert!(preedit_state(&s).is_none());
assert_eq!(data(&s, id).characters, "Hello");
// With no composition (and not even while editing) an empty commit stays a harmless no-op.
let revision = s.ds().unwrap().revision;
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
assert_eq!(s.ds().unwrap().revision, revision);
end(&mut s).unwrap();
assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing);
}
}
+4
View File
@@ -8,6 +8,7 @@ use serde_json::json;
/// A fixed-width (auto-height) headline of `width` px at 64px. Returns its id.
fn headline(s: &mut Session, text: &str, width: f64) -> NodeId {
figcraft_text::scan_system_fonts();
let t = idv(&ex(s, "text.create", json!({"text": text, "x": 0, "y": 0, "props": {"name": "Headline", "fontSize": 64}})));
ex(s, "node.set", json!({"id": t, "props": {"width": width, "textAutoResize": "height"}}));
t
@@ -63,8 +64,11 @@ fn a_one_word_last_line_is_avoided() {
assert!(l.lines.len() == 1 || words.split_whitespace().count() > 1 || words.contains('\u{a0}'), "{words:?}");
}
/// Asserts `data.fill` keeps a short copy's font size, shrinks a long copy, and leaves the
/// source text layer untouched.
#[test]
fn data_fill_sizes_each_copys_text() {
figcraft_text::scan_system_fonts();
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"})));
let t = idv(&ex(&mut s, "text.create", json!({"text": "Curto", "x": 80, "y": 80, "parent": f, "props": {"name": "Headline", "fontSize": 96}})));
+164 -5
View File
@@ -36,6 +36,7 @@ pub enum ToolId {
Pen,
Pencil,
Text,
TextOnPath,
Comment,
Eyedropper,
ShapeBuilder,
@@ -50,7 +51,7 @@ pub enum ToolId {
}
impl ToolId {
pub const ALL: [ToolId; 25] = [
pub const ALL: [ToolId; 26] = [
ToolId::Move,
ToolId::Scale,
ToolId::Hand,
@@ -66,6 +67,7 @@ impl ToolId {
ToolId::Pen,
ToolId::Pencil,
ToolId::Text,
ToolId::TextOnPath,
ToolId::Comment,
ToolId::Eyedropper,
ToolId::ShapeBuilder,
@@ -94,6 +96,7 @@ impl ToolId {
ToolId::Pen => "pen",
ToolId::Pencil => "pencil",
ToolId::Text => "text",
ToolId::TextOnPath => "textOnPath",
ToolId::Comment => "comment",
ToolId::Eyedropper => "eyedropper",
ToolId::ShapeBuilder => "shapeBuilder",
@@ -126,6 +129,7 @@ impl ToolId {
ToolId::Pen => "Pen",
ToolId::Pencil => "Pencil",
ToolId::Text => "Text",
ToolId::TextOnPath => "Text on a path",
ToolId::Comment => "Comment",
ToolId::Eyedropper => "Pick color",
ToolId::ShapeBuilder => "Shape builder",
@@ -277,7 +281,8 @@ impl ToolId {
ToolId::Pen => Cursor::Pen(PenCursor::New),
ToolId::Comment => Cursor::Comment,
ToolId::Eyedropper => Cursor::Eyedropper,
ToolId::Move | ToolId::Scale => Cursor::Default,
// Text on a path acts only over a path, where it shows the I-beam.
ToolId::Move | ToolId::Scale | ToolId::TextOnPath => Cursor::Default,
// The other tools make something where they are pressed: shapes, frames, slices, pencil
// strokes, a new text (over a text the Text tool shows the I-beam instead).
_ => Cursor::Crosshair,
@@ -643,6 +648,31 @@ pub(crate) const SNAP_PX: f64 = 5.0;
/// Screen pixels the pointer travels after a press before a move or resize starts: a click or a
/// double-click made with an unsteady hand doesn't drag what it lands on.
const DRAG_SLOP_PX: f64 = 3.0;
/// Screen pixels from a path within which the Text on a path tool takes it.
const PATH_REACH_PX: f64 = 6.0;
/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most
/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a
/// mask hides at `p` and the operands of boolean groups are left out.
fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec<NodeId>) {
let Some(n) = doc.node(id) else { return };
if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) {
return;
}
if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) {
return;
}
for c in n.paint_order().rev() {
let before = out.len();
path_candidates(doc, c, p, reach, depth + 1, out);
if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) {
out.truncate(before);
}
}
if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) {
out.push(id);
}
}
/// Corners of the selection's handle box in page coordinates (tl, tr, br, bl).
pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> {
@@ -941,6 +971,16 @@ pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: (
}
impl Session {
/// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode
/// measured and the box measured to, a vector region's highlight), unless a gesture holds it.
pub fn pointer_left(&mut self) {
if !self.tool.busy() {
self.tool.overlay.guides.clear();
self.tool.overlay.measure_box = None;
}
self.vector_pointer_left();
}
/// What a marquee starting at page point `p` selects inside: the top-level frame it starts in
/// (its children), or the focus view's frame.
pub(crate) fn marquee_within(&self, p: Point) -> Result<Option<NodeId>> {
@@ -996,7 +1036,7 @@ impl Session {
let _ = crate::shapebuilder::arrangement(self, &sel);
}
if let Ok(ds) = self.ds_mut() {
if t != ToolId::Text && t != ToolId::Move {
if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) {
ds.text_edit = None;
}
ds.revision += 1;
@@ -1056,6 +1096,7 @@ impl Session {
Ok(())
}
ToolId::Text => self.text_down(ev),
ToolId::TextOnPath => self.text_on_path_down(ev),
ToolId::Pen => self.pen_down(ev),
ToolId::Pencil => {
self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] };
@@ -1111,6 +1152,9 @@ impl Session {
if tool == ToolId::Text && (on_edited || on_text(self)) {
cursor = Cursor::Text;
}
if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) {
cursor = Cursor::Text;
}
let mut hover = None;
let mut guides = Vec::new();
let mut measure_box = None;
@@ -1976,6 +2020,10 @@ impl Session {
Ok(())
}
/// Handle a double-click gesture: finish a pen path, rename or click labels/widgets,
/// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text
/// editing (select-all on entry, word-select inside the running edit), enter vector editing
/// for selected shapes, and step groups/frames one level deeper.
fn double_click(&mut self, ev: &PointerEvent) -> Result<()> {
let ds = self.ds()?;
let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page);
@@ -2025,13 +2073,15 @@ impl Session {
&& doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))
&& (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target)
{
// Entering a text selects all of it; in the text being edited, the word.
// Entering a text selects all of it; in the text being edited, the word. A
// double-click inside the running edit is an ordinary edit: starting a new session
// here would replace the IME owner's epoch and drop a composition on a plain gesture.
let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target);
let idx = self.text_index_at(target, ev.p())?;
textedit::begin(self, target, Some(idx), false)?;
if editing {
textedit::select_word(self, idx)?;
} else {
textedit::begin(self, target, Some(idx), false)?;
textedit::key(self, "SelectAll", false, false, false)?;
}
return Ok(());
@@ -2124,6 +2174,115 @@ impl Session {
Ok(())
}
/// The layer whose path is under `ev` for the Text on a path tool and where along it the press
/// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its
/// first subpath (the one text follows), not locked and not inside an instance. Layers in front
/// whose path is further away don't hide it.
pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> {
let reach = ev.tol() * PATH_REACH_PX;
let near = |id: NodeId| -> Option<(NodeId, f64)> {
let n = doc.node(id)?;
let shape = matches!(
n.kind,
NodeKind::Rectangle
| NodeKind::Ellipse { .. }
| NodeKind::Polygon { .. }
| NodeKind::Star { .. }
| NodeKind::Line
| NodeKind::Vector { .. }
);
if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) {
return None;
}
let geom = figcraft_doc::geometry::stroke_geometry(n)?;
let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?;
// Measured on the page (a layer scaled more one way than the other is still reached
// where it shows), the start by length along the layer's own path.
let world = doc.world_transform(id);
let p = ev.p();
let mut best: Option<(f64, f64)> = None;
for (i, w) in line.points.windows(2).enumerate() {
let (a, b) = (world * w[0], world * w[1]);
let d = b - a;
let l2 = d.hypot2();
let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 };
let dist = (a.lerp(b, u) - p).hypot();
if best.is_none_or(|(_, bd)| dist < bd) {
let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?);
best = Some((la + u * (lb - la), dist));
}
}
let (along, dist) = best?;
let len = line.length();
(len > 1e-9 && dist <= reach).then_some((id, along / len))
};
let mut candidates = Vec::new();
path_candidates(doc, page, ev.p(), reach, 0, &mut candidates);
candidates.into_iter().find_map(near)
}
/// A press with the Text on a path tool: on a path, a new empty text layer goes onto it,
/// starting where it was pressed, and typing starts. The text takes the path layer's place,
/// geometry, transform, constraints and effects, and its fills when one of them shows; the path
/// layer is removed. Ending the edit with nothing typed puts the path back without an undo step
/// ([`textedit::end`]). Elsewhere a press does nothing.
fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> {
if let Some(te) = self.ds()?.text_edit.clone() {
if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) {
return self.text_click(te.id, ev, ev.shift);
}
textedit::end(self)?;
}
let ds = self.ds()?;
let (doc, page) = (ds.doc.clone(), ds.page);
let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) };
let Some(pn) = doc.node(path) else { return Ok(()) };
let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) };
let index = doc.index_in_parent(path);
self.begin_interaction("Text on a path")?;
let ds = self.ds_mut()?;
let d = ds.doc_mut();
let id = d.alloc_id();
let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height);
n.name = String::new();
n.transform = pn.transform;
n.constraints = pn.constraints;
n.child_layout = pn.child_layout.clone();
let effects = !pn.effects.is_empty();
if effects {
n.effects = pn.effects.clone();
n.styles.effect = pn.styles.effect.clone();
}
// A path drawn with a stroke only has no fill to give: the text gets the usual one.
let filled = pn.fills.iter().any(|f| f.visible);
if filled {
n.fills = pn.fills.clone();
n.styles.fill = pn.styles.fill.clone();
} else if let Some(fills) = readable_text_fills(d, parent) {
n.fills = fills;
}
// As `text.onPath`: a binding goes with the list it binds into (`fill`, `fills`, `fills/N/…`).
let carried = |k: &str| match crate::cmd::lists::list_binding(k) {
Some((crate::cmd::lists::BoundList::Fills, ..)) => filled,
Some((crate::cmd::lists::BoundList::Effects, ..)) => effects,
_ => false,
};
n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect();
if let NodeKind::Text(t) = &mut n.kind {
let mut tp = TextPath::new(&geom);
tp.start = start;
t.path = Some(Box::new(tp));
}
d.insert(n, parent, index)?;
d.remove(path)?;
ds.selection = vec![id];
textedit::begin(self, id, Some(0), true)?;
if !self.prefs.keep_tool {
self.tool.tool = ToolId::Move;
}
Ok(())
}
/// Drag out a new text box: past the drag slop it has the drag's size (at least one line
/// tall) and its top-left, and that size is fixed.
fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> {
@@ -97,3 +97,22 @@ fn preview_keeps_the_unsaved_state_and_saving_saves_the_document() {
assert_eq!(count(&s), three);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_refused_cut_leaves_the_clipboard_as_it_was() {
let mut s = Session::new();
let a = rect(&mut s, 0.0);
s.execute("file.saveVersion", &json!({"name": "One"})).unwrap();
let b = rect(&mut s, 20.0);
s.execute("edit.copy", &json!({"ids": [b]})).unwrap();
let roots = |s: &Session| s.clipboard.nodes.iter().map(|(r, _)| *r).collect::<Vec<_>>();
assert_eq!(roots(&s), vec![b]);
let source = s.clipboard.source.clone().unwrap();
let depth = s.undo_depth();
s.execute("version.preview", &json!({"index": 0})).unwrap();
let err = s.execute("edit.cut", &json!({"ids": [a]})).unwrap_err();
assert!(matches!(err, EngineError::Disabled(..)), "{err:?}");
assert_eq!(roots(&s), vec![b], "failed edit.cut changed the clipboard");
assert!(std::sync::Arc::ptr_eq(s.clipboard.source.as_ref().unwrap(), &source));
assert_eq!(s.undo_depth(), depth, "history untouched");
}
+152 -1
View File
@@ -156,7 +156,9 @@ fn invalid_or_stale_targets_roll_back_atomically() {
assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err());
assert_eq!(fill(&s, a), "FF0000");
assert_eq!(s.undo_depth(), before);
for command in ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle"] {
for command in
["selection.setPaint", "selection.selectColor", "selection.applyColorStyle", "selection.bindColorVariable", "selection.detachColor"]
{
for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] {
assert!(s.execute(command, &params).is_err());
}
@@ -270,3 +272,152 @@ fn whole_text_fill_keeps_a_color_style_link() {
run(&mut s, "style.update", json!({"style":style,"value":"#00ff00"}));
assert_eq!(fill(&s, id), "00FF00");
}
fn theme_variable(s: &mut Session, name: &str, hex: &str) -> Value {
if s.doc().unwrap().variables.collection("Theme").is_none() {
run(s, "variable.createCollection", json!({"name":"Theme"}));
}
run(s, "variable.create", json!({"collection":"Theme","name":name,"type":"color","value":hex}))["id"].clone()
}
#[test]
fn applying_a_color_variable_binds_matching_fill_and_stroke_slots_only() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#0000ff");
run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"}));
let v = theme_variable(&mut s, "Brand", "#00ff00");
let red = group(&s, &[a, b], "FF0000");
let before = s.undo_depth();
run(&mut s, "selection.bindColorVariable", json!({"targets":red.targets,"variable":"Brand"}));
assert_eq!(fill(&s, a), "00FF00");
assert_eq!(fill(&s, b), "0000FF");
assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00");
assert_eq!(s.doc().unwrap().node(a).unwrap().bound.get("fills/0/color").map(String::as_str), v.as_str());
assert_eq!(s.doc().unwrap().node(b).unwrap().bound.get("strokes/0/color").map(String::as_str), v.as_str());
assert_eq!(s.undo_depth().0, before.0 + 1);
run(&mut s, "variable.set", json!({"variable":v,"value":"#ffff00"}));
assert_eq!(fill(&s, a), "FFFF00");
assert_eq!(fill(&s, b), "0000FF");
let bound = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.variable.is_some()).unwrap();
assert_eq!(bound.targets.len(), 2);
s.undo().unwrap();
s.undo().unwrap();
assert_eq!(fill(&s, a), "FF0000");
assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty());
}
#[test]
fn a_color_variable_replaces_a_style_link_and_an_earlier_binding() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#ff0000");
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}))["id"].clone();
run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"}));
let old = theme_variable(&mut s, "Old", "#ff0000");
run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":old}));
let new = theme_variable(&mut s, "New", "#0000ff");
let targets: Vec<_> = collect(s.doc().unwrap(), &[a, b]).into_iter().flat_map(|g| g.targets).collect();
run(&mut s, "selection.bindColorVariable", json!({"targets":targets,"variable":new}));
for id in [a, b] {
let n = s.doc().unwrap().node(id).unwrap();
assert!(n.styles.fill.is_none());
assert_eq!(n.bound.len(), 1, "one binding for the slot: {:?}", n.bound);
assert_eq!(n.bound.values().next().map(String::as_str), new.as_str());
assert_eq!(fill(&s, id), "0000FF");
}
assert_eq!(collect(s.doc().unwrap(), &[a, b]).len(), 1);
}
#[test]
fn a_color_variable_on_a_text_range_with_its_own_fills_takes_its_value() {
let mut s = Session::new();
let id = NodeId(run(&mut s, "text.create", json!({"text":"abc","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap());
run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"fontSize":30,"textFills":["#0000ff"]}));
theme_variable(&mut s, "Brand", "#00ff0080");
let blue = group(&s, &[id], "0000FF");
run(&mut s, "selection.bindColorVariable", json!({"targets":blue.targets,"variable":"Brand"}));
let n = s.doc().unwrap().node(id).unwrap();
let t = n.kind.text().unwrap();
let paint = &t.style_at(1).fills.as_ref().unwrap()[0];
assert_eq!(paint.solid_color().unwrap().to_css_hex(), "#00FF0080");
assert_eq!(t.style_at(1).font_size, 30.0);
assert!(n.bound.is_empty());
assert_eq!(fill(&s, id), "FF0000");
}
#[test]
fn detaching_a_selection_color_keeps_its_value() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let b = rectangle(&mut s, "#ff0000");
let c = rectangle(&mut s, "#0000ff");
let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"}))["id"].clone();
run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"}));
let v = theme_variable(&mut s, "Accent", "#ffff0080");
run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":v}));
run(&mut s, "variable.bind", json!({"id":c,"field":"fill","variable":v}));
let colors = collect(s.doc().unwrap(), &[a, b]);
assert_eq!(colors.len(), 2);
let before = s.undo_depth();
for g in &colors {
run(&mut s, "selection.detachColor", json!({"targets":g.targets}));
}
assert_eq!(s.undo_depth().0, before.0 + 2);
assert!(s.doc().unwrap().node(a).unwrap().styles.fill.is_none());
assert!(s.doc().unwrap().node(b).unwrap().bound.is_empty());
assert!(!s.doc().unwrap().node(c).unwrap().bound.is_empty());
assert_eq!(fill(&s, a), "00FF00");
assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap().to_css_hex(), "#FFFF0080");
run(&mut s, "variable.set", json!({"variable":v,"value":"#000000"}));
assert_eq!(fill(&s, b), "FFFF00");
assert!(collect(s.doc().unwrap(), &[a, b]).iter().all(|g| g.style.is_none() && g.variable.is_none()));
}
#[test]
fn color_variables_refuse_gradients_other_types_and_unknown_names() {
use figcraft_color::{Color, Gradient, GradientKind};
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
let gradient = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK));
run(&mut s, "fill.add", json!({"id":a,"paint":gradient}));
theme_variable(&mut s, "Brand", "#00ff00");
run(&mut s, "variable.create", json!({"collection":"Theme","name":"Gap","type":"number","value":8}));
let colors = collect(s.doc().unwrap(), &[a]);
let solid = colors.iter().find(|g| g.paint.solid_color().is_some()).unwrap();
let ramp = colors.iter().find(|g| g.paint.solid_color().is_none()).unwrap();
let before = s.undo_depth();
for (targets, variable) in [(&ramp.targets, "Brand"), (&solid.targets, "Gap"), (&solid.targets, "Missing")] {
assert!(s.execute("selection.bindColorVariable", &json!({"targets":targets,"variable":variable})).is_err());
}
assert!(s.execute("selection.bindColorVariable", &json!({"targets":solid.targets})).is_err());
assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty());
assert_eq!(s.undo_depth(), before);
run(&mut s, "selection.bindColorVariable", json!({"targets":solid.targets,"variable":"Brand"}));
assert_eq!(s.doc().unwrap().node(a).unwrap().bound.len(), 1);
assert_eq!(s.undo_depth().0, before.0 + 1);
}
#[test]
fn scopes_filter_the_menu_but_the_command_binds_anyway() {
let mut s = Session::new();
let a = rectangle(&mut s, "#ff0000");
run(&mut s, "stroke.add", json!({"id":a,"color":"#ff0000"}));
theme_variable(&mut s, "Line", "#00ff00");
theme_variable(&mut s, "Hidden", "#0000ff");
run(&mut s, "variable.update", json!({"variable":"Line","scopes":["STROKE_COLOR"]}));
run(&mut s, "variable.update", json!({"variable":"Hidden","scopes":[]}));
let red = group(&s, &[a], "FF0000");
let fill_slot: Vec<_> = red.targets.iter().filter(|t| t.fills).cloned().collect();
let stroke_slot: Vec<_> = red.targets.iter().filter(|t| !t.fills).cloned().collect();
let doc = s.doc().unwrap();
let offered = |targets: &[figcraft_engine::selection_colors::PaintTarget], v: &str| figcraft_engine::selection_colors::in_scope(doc, targets, v);
assert!(offered(&stroke_slot, "Line"));
assert!(!offered(&red.targets, "Line") && !offered(&fill_slot, "Line") && !offered(&stroke_slot, "Hidden"));
run(&mut s, "selection.bindColorVariable", json!({"targets":stroke_slot,"variable":"Line"}));
assert_eq!(s.doc().unwrap().node(a).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00");
assert_eq!(fill(&s, a), "FF0000");
// As with `variable.bind`, the command itself binds outside the scopes.
run(&mut s, "selection.bindColorVariable", json!({"targets":fill_slot,"variable":"Line"}));
assert_eq!(fill(&s, a), "00FF00");
}
+4
View File
@@ -359,6 +359,10 @@ impl<'s> Decoder<'s, '_> {
fn field(&mut self, f: &Field, depth: usize) -> Result<Kv<'s>, KiwiError> {
let ty = f.ty.ok_or_else(|| KiwiError("message: untyped field".into()))?;
if !f.array {
// A nested definition spends its own value in `def`.
if !matches!(ty, Type::Def(_)) {
self.spend(1)?;
}
return self.value(ty, depth);
}
let n = self.r.var_uint()? as usize;
+148
View File
@@ -0,0 +1,148 @@
//! Large `.fig` files at the layer cap (#981): the budget a design-system file gets, the library
//! copies its instances need kept ahead of the pages, honest notes for what the cap kept out, and
//! one note for the cap itself.
use figcraft_doc::{Document, NodeKind};
use super::kiwi::{Kv, Schema};
use super::{MAX_FIG_NODES, export, import, layer_budget, map};
type Fields = Vec<(&'static str, Kv<'static>)>;
fn o(fields: Fields) -> Kv<'static> {
Kv::Object(fields)
}
fn g(id: u64) -> Kv<'static> {
o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))])
}
fn xy(x: f64, y: f64) -> Kv<'static> {
o(vec![("x", Kv::Float(x as f32)), ("y", Kv::Float(y as f32))])
}
/// A layer under `parent`; siblings come in id order.
fn node(id: u64, parent: Option<u64>, ty: &'static str, name: &str, more: Fields) -> Kv<'static> {
let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))];
if let Some(parent) = parent {
fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(format!("{id:08}")))])));
}
fields.extend(more);
o(fields)
}
fn instance(id: u64, page: u64, main: u64) -> Kv<'static> {
node(id, Some(page), "INSTANCE", "Instance", vec![("symbolData", o(vec![("symbolID", g(main))]))])
}
/// A main `id` of `layers` rectangles under `parent`.
fn main_component(id: u64, parent: u64, name: &str, layers: u64) -> Vec<Kv<'static>> {
let mut out = vec![node(id, Some(parent), "SYMBOL", name, vec![("size", xy(10.0, 10.0))])];
out.extend((1..=layers).map(|i| node(id * 10_000 + i, Some(id), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))])));
out
}
/// A page (2) of `count` instances of a main kept on the file's hidden library canvas (3), as a
/// design system's file carries the library components it uses.
fn library_message(count: u64, main_layers: u64) -> Kv<'static> {
let mut nodes = vec![
node(1, None, "DOCUMENT", "Document", vec![]),
node(2, Some(1), "CANVAS", "Screens", vec![]),
node(3, Some(1), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
];
nodes.extend(main_component(20, 3, "Library main", main_layers));
nodes.extend((0..count).map(|i| instance(1_000 + i, 2, 20)));
o(vec![("nodeChanges", Kv::Array(nodes))])
}
/// The `.fig` import's two stages at layer cap `cap`.
fn import_at(msg: &Kv, cap: usize) -> (Document, Vec<String>) {
let (json, mut warnings, networks) = map::to_rest(msg, "Capped", cap);
let (doc, more) = crate::rest::import_with_networks(&json, &|_: &str| None, networks, cap).unwrap();
warnings.extend(more);
(doc, warnings)
}
fn linked_to(doc: &Document, main_name: &str) -> usize {
doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component, .. } if doc.node(component).is_some_and(|c| c.name == main_name))).count()
}
#[test]
fn a_design_system_sized_canvas_gets_room() {
// The file in #981 stopped at 244 248 layers: a 7.8 MB canvas at 32 bytes a layer.
let reported = 244_248 * 32;
assert!(layer_budget(reported) >= 4 * 244_248, "{}", layer_budget(reported));
// Small files still can't claim more than the REST import's cap, nor any file the ceiling.
assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES);
assert_eq!(layer_budget(usize::MAX), MAX_FIG_NODES);
}
#[test]
fn library_mains_come_before_the_pages_at_the_cap() {
// 40 instances of an 11-layer library main on a page, with room for 100 layers: mapped after
// the pages, the main found no room and every instance became a frame of "a component that
// isn't in the file".
let (doc, warnings) = import_at(&library_message(40, 10), 100);
assert!(warnings.iter().any(|w| w.contains("more than 100 layers")), "{warnings:?}");
assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}");
let linked = linked_to(&doc, "Library main");
assert!(linked >= 5, "{linked} instances linked to the library main: {warnings:?}");
assert!(doc.nodes().any(|n| n.name == "Library main" && doc.is_remote(n.id)));
}
#[test]
fn instances_of_a_main_the_cap_kept_out_say_so() {
// The page's instances come before their main: at the cap, the main is skipped though the
// file has it.
let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])];
nodes.extend((0..20).map(|i| instance(100 + i, 2, 900)));
nodes.extend(main_component(900, 2, "Local main", 10));
let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 60);
assert!(warnings.iter().any(|w| w.starts_with("instances whose component was past the layer limit were imported as frames")), "{warnings:?}");
assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}");
assert!(!doc.nodes().any(|n| n.name == "Local main"));
}
#[test]
fn swap_defaults_the_cap_kept_out_say_so() {
// A kept main's instance swap default names a main with no instances, which comes after
// enough layers to be skipped at the cap.
let swap = o(vec![
("id", g(70)),
("name", Kv::Str("Icon".into())),
("type", Kv::Enum("INSTANCE_SWAP")),
("initialValue", o(vec![("guidValue", g(900))])),
]);
let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])];
nodes.push(node(50, Some(2), "SYMBOL", "Card", vec![("size", xy(10.0, 10.0)), ("componentPropDefs", Kv::Array(vec![swap]))]));
nodes.extend(main_component(60, 2, "Filler", 40));
nodes.extend(main_component(900, 2, "Icon main", 10));
let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 30);
assert!(doc.nodes().any(|n| n.name == "Card"));
assert!(!doc.nodes().any(|n| n.name == "Icon main"));
assert!(warnings.iter().any(|w| w.starts_with("instance swap properties whose default component was past the layer limit")), "{warnings:?}");
assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
}
#[test]
fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() {
// A 1 001-layer library main used 210 times: about 210 000 layers in a file far too small to
// lift the cap above the REST import's 200 000.
let file = super::tests::canvas(&Schema::parse(export::SCHEMA).unwrap(), &library_message(210, 1_000));
assert_eq!(layer_budget(file.len()), crate::rest::MAX_NODES);
let (doc, warnings) = import(&file, "Design system.fig").unwrap();
let cap: Vec<_> = warnings.iter().filter(|w| w.contains("layers; the rest were skipped")).collect();
assert_eq!(cap.len(), 1, "{warnings:?}");
assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}");
assert!(linked_to(&doc, "Library main") >= 190, "{warnings:?}");
}
#[test]
fn a_library_main_larger_than_the_cap_leaves_the_pages_room() {
// A main of 300 layers with room for 100: mapped first, it took the whole budget and the page
// came out empty.
let (doc, warnings) = import_at(&library_message(5, 300), 100);
let page = doc.nodes().find(|n| n.name == "Screens").expect("the page");
let top = page.children.len();
assert!(top >= 1, "the page kept {top} layers: {warnings:?}");
}
+64 -22
View File
@@ -42,6 +42,14 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<Str
if !m.library_contexts.is_empty() {
out[crate::rest::FIG_LIBRARY_CONTEXTS_FIELD] = Value::Array(std::mem::take(&mut m.library_contexts));
}
// Mains in the file that the layer cap kept out: their instances are told apart from those of
// components the file doesn't have (#981).
if m.count >= m.max_nodes {
let capped: Vec<Value> = m.referenced_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect();
if !capped.is_empty() {
out[crate::rest::FIG_CAPPED_MAINS_FIELD] = Value::Array(capped);
}
}
if !m.styles.is_empty() {
out["styles"] = Value::Object(std::mem::take(&mut m.styles));
}
@@ -58,7 +66,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<Str
if !styles.is_empty() {
out["styleDefinitions"] = Value::Array(styles);
}
(out, m.warnings, m.networks)
(out, m.warnings.finish(), m.networks)
}
#[cfg(test)]
@@ -387,11 +395,18 @@ struct Mapper<'a> {
library_contexts: Vec<Value>,
/// Layers visited while scanning internal canvases, across all of them.
library_scan_count: usize,
warnings: Vec<String>,
warnings: crate::rest::Warnings,
count: usize,
/// The mains in the file that instances or instance swap defaults name, and the mains mapped
/// as layers: the difference is what the layer cap kept out.
referenced_mains: BTreeSet<String>,
mapped_mains: BTreeSet<String>,
/// Most layers produced or scanned, instance expansion included; the one place the cap comes
/// from (see [`super::layer_budget`]).
max_nodes: usize,
/// Layers kept back for the visible pages' top level while library copies are mapped, so the
/// copies can't take the whole budget and leave the pages empty.
reserved: usize,
/// Vector networks, each built once: the layers that show one (every instance of a main
/// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's
/// networks took gigabytes.
@@ -464,9 +479,12 @@ impl<'a> Mapper<'a> {
override_scope_entries: 0,
library_contexts: Vec::new(),
library_scan_count: 0,
warnings: Vec::new(),
warnings: crate::rest::Warnings::default(),
count: 0,
referenced_mains: BTreeSet::new(),
mapped_mains: BTreeSet::new(),
max_nodes,
reserved: 0,
networks: Vec::new(),
network_ids: HashMap::new(),
}
@@ -493,12 +511,14 @@ impl<'a> Mapper<'a> {
.cloned()
}
/// A note about what this file's mapping left out or changed. Counted, as the REST importer
/// counts its own: these notes are met once per layer, so the kinds were named once each with
/// no sign of how much of the file they covered -- a file whose every text layer lost a
/// setting read the same as one where a single layer did (#467). The collector keeps the
/// first-seen order and the same cap, since some notes name fields of the file (image
/// filters).
fn warn(&mut self, w: impl Into<String>) {
let w = w.into();
// Capped like the REST importer's list: some warnings name fields of the file (image filters).
if self.warnings.len() < crate::rest::MAX_WARNINGS && !self.warnings.contains(&w) {
self.warnings.push(w);
}
self.warnings.add(w.into());
}
fn children(&self, id: &str) -> Vec<String> {
@@ -549,6 +569,11 @@ impl<'a> Mapper<'a> {
scope
}
/// How many layers may be produced now: the cap, less what is kept for the visible pages.
fn budget(&self) -> usize {
self.max_nodes.saturating_sub(self.reserved)
}
/// The DOCUMENT with its pages.
fn document(&mut self) -> Value {
let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone());
@@ -556,9 +581,8 @@ impl<'a> Mapper<'a> {
Some(d) => self.children(d),
None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(),
};
let mut out = Vec::new();
let mut internal = Vec::new();
let mut visible_pages = Vec::new();
let mut visible = Vec::new();
for p in pages {
let Some(&n) = self.nodes.get(&p) else { continue };
if n.get("type").and_then(Kv::str) != Some("CANVAS") {
@@ -568,17 +592,24 @@ impl<'a> Mapper<'a> {
// The hidden page that holds styles, variables and library copies.
if n.get("internalOnly").and_then(Kv::bool) == Some(true) {
internal.push((p, n));
continue;
} else {
visible.push((p, n));
}
visible_pages.push(p.clone());
out.push(self.page(&p, n));
}
// The library copies the pages use come first: in a file past the layer cap, the pages'
// last layers are skipped rather than the mains every instance of them needs (#981).
let visible_pages: Vec<String> = visible.iter().map(|(p, _)| p.clone()).collect();
let required_mains = self.referenced_component_mains(&visible_pages);
// The pages and their top-level layers, up to half the cap, stay for the pages.
let top: usize = visible_pages.iter().map(|p| 1 + self.children(p).len()).sum();
self.reserved = top.min(self.max_nodes / 2);
for (id, canvas) in internal {
if let Some(context) = self.library_context(&id, canvas, &required_mains) {
self.library_contexts.push(context);
}
}
self.reserved = 0;
let out: Vec<Value> = visible.into_iter().map(|(p, n)| self.page(&p, n)).collect();
if out.is_empty() {
self.warn("the file has no pages");
}
@@ -822,7 +853,7 @@ impl<'a> Mapper<'a> {
self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped"));
return None;
}
if self.count >= self.max_nodes {
if self.count >= self.budget() {
let max = self.max_nodes;
self.warn(format!("the file has more than {max} layers; the rest were skipped"));
return None;
@@ -859,7 +890,7 @@ impl<'a> Mapper<'a> {
let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view);
// Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector.
let connector_terminal =
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.max_nodes);
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget());
// A supported connector snapshot has its own note below instead of the generic stand-in one.
let stand = stand_in(own).filter(|_| !connector_snapshot);
let ty = match own {
@@ -879,6 +910,9 @@ impl<'a> Mapper<'a> {
self.warn("connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained");
}
self.count += 1;
if own == "SYMBOL" && inst.is_none() {
self.mapped_mains.insert(id.to_string());
}
let mut o = Obj::new();
o.insert("id".into(), json!(rest_id));
// Private provenance for the .fig importer; ordinary REST import ignores it.
@@ -1004,7 +1038,7 @@ impl<'a> Mapper<'a> {
fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option<Value> {
let shape = v.str("shapeWithTextType").unwrap_or("SQUARE");
let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty();
if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes {
if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.budget() {
return None;
}
let mut s = Obj::new();
@@ -1036,7 +1070,7 @@ impl<'a> Mapper<'a> {
/// layer filling it, inset a little; centred unless it's a note.
fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option<Value> {
let chars = own_text(v)?;
if self.count >= self.max_nodes {
if self.count >= self.budget() {
return None;
}
self.count += 1;
@@ -1171,7 +1205,7 @@ impl<'a> Mapper<'a> {
out.insert("opacity".into(), json!(root_opacity * line_opacity));
}
if connector_has_saved_label(v) {
let reason = if self.count >= self.max_nodes { "node budget" } else { "depth limit" };
let reason = if self.count >= self.budget() { "node budget" } else { "depth limit" };
self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children"));
}
}
@@ -1201,7 +1235,7 @@ impl<'a> Mapper<'a> {
/// Snapshot the straight, manual connector contract carried by its generated children.
fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec<Value> {
if self.count >= self.max_nodes {
if self.count >= self.budget() {
self.warn(format!("the file has more than {} layers; connector snapshot children were skipped", self.max_nodes));
return Vec::new();
}
@@ -1264,7 +1298,7 @@ impl<'a> Mapper<'a> {
};
let background_view = View::of(background).with([background_style]);
if !background_view.list("fillPaints").is_empty() {
if self.count >= self.max_nodes {
if self.count >= self.budget() {
self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes));
} else {
let mut rect = Obj::new();
@@ -1292,7 +1326,7 @@ impl<'a> Mapper<'a> {
transform: Affine,
view: &View<'a>,
) {
if self.count >= self.max_nodes {
if self.count >= self.budget() {
self.warn(format!("the file has more than {} layers; connector label was skipped", self.max_nodes));
return;
}
@@ -1850,7 +1884,12 @@ impl<'a> Mapper<'a> {
"BOOL" => json!({"type": "BOOLEAN", "defaultValue": init.and_then(|i| i.get("boolValue")).and_then(Kv::bool).unwrap_or(true)}),
"TEXT" => json!({"type": "TEXT", "defaultValue": init.and_then(prop_text).unwrap_or("")}),
"INSTANCE_SWAP" => match init.and_then(|i| i.get("guidValue")).and_then(guid) {
Some(g) => json!({"type": "INSTANCE_SWAP", "defaultValue": g}),
Some(g) => {
if self.nodes.contains_key(&g) {
self.referenced_mains.insert(g.clone());
}
json!({"type": "INSTANCE_SWAP", "defaultValue": g})
}
None => continue,
},
"VARIANT" => json!({"type": "VARIANT"}),
@@ -1994,6 +2033,9 @@ impl<'a> Mapper<'a> {
self.warn("instances of components that aren't in the file show no layers");
return None;
}
if !self.referenced_mains.contains(&sym) {
self.referenced_mains.insert(sym.clone());
}
if outer.is_some_and(|i| i.symbols.contains(&sym)) {
return None;
}
@@ -115,14 +115,14 @@ fn connector_snapshot_respects_last_and_zero_child_slots() {
assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit");
assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10");
assert_eq!(mapper.count, MAX_NODES);
assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped")));
assert!(mapper.warnings.seen().any(|warning| warning == format!("the file has more than {MAX_NODES} layers; connector label was skipped")));
let mut mapper = Mapper::new(&message, MAX_NODES);
mapper.count = MAX_NODES;
let children = mapper.connector_snapshot_children(&view, "1:123");
assert!(children.is_empty());
assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded");
assert!(mapper.warnings.iter().any(|warning| warning.contains("connector snapshot children were skipped")));
assert!(mapper.warnings.seen().any(|warning| warning.contains("connector snapshot children were skipped")));
}
#[test]
@@ -133,7 +133,7 @@ fn connector_snapshot_flattens_vector_at_depth_and_node_limits() {
assert_eq!(depth_limited["type"], "VECTOR");
assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty());
assert!(depth_limited.get("children").is_none());
assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children"));
assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children"));
let mut mapper = Mapper::new(&message, MAX_NODES);
mapper.count = MAX_NODES - 1;
@@ -142,5 +142,37 @@ fn connector_snapshot_flattens_vector_at_depth_and_node_limits() {
assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty());
assert!(budget_limited.get("children").is_none());
assert_eq!(mapper.count, MAX_NODES);
assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children"));
assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children"));
}
/// A mapper note is met once per layer, so the kinds were named once each with no sign of how much
/// of the file they covered: a file whose every layer lost a setting read the same as one where a
/// single layer did (#467). They are counted now, with the `(×n)` the REST importer already uses
/// and the import report already reads back off before translating a note.
#[test]
fn a_note_met_more_than_once_carries_its_count() {
let message = message();
let mut mapper = Mapper::new(&message, MAX_NODES);
for _ in 0..2 {
mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector");
}
let notes = std::mem::take(&mut mapper.warnings).finish();
let counted: Vec<&String> = notes.iter().filter(|n| n.contains("(×")).collect();
assert!(
counted.iter().any(|n| n.ends_with("(×2)") && n.contains("connector label was skipped: depth limit")),
"the note met twice says so: {notes:?}"
);
assert!(notes.iter().all(|n| !n.ends_with("(×1)")), "a count of one is left off: {notes:?}");
// One layer, and the same notes have no count at all.
let mut mapper = Mapper::new(&message, MAX_NODES);
mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector");
let once = std::mem::take(&mut mapper.warnings).finish();
assert!(once.iter().all(|n| !n.contains("(×")), "{once:?}");
assert_eq!(
once.len(),
counted.len() + notes.iter().filter(|n| !n.contains("(×")).count(),
"the same kinds, counted or not: {once:?} vs {notes:?}"
);
}
+14 -3
View File
@@ -23,6 +23,8 @@ mod export_tests;
#[cfg(test)]
mod interop_tests;
pub mod kiwi;
#[cfg(test)]
mod layer_cap_tests;
mod map;
mod network;
#[cfg(test)]
@@ -64,8 +66,11 @@ const MAX_CHUNKS: usize = 16;
/// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances
/// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the
/// budget follows what the file takes, not what it unpacks to: a small file can't claim a large
/// count, however it's built; a large one gets room.
const PACKED_BYTES_PER_LAYER: usize = 32;
/// count, however it's built; a large one gets room. Design systems are mostly instances, which
/// pack tightly: a 7.8 MB canvas stopped at 244 248 layers at 32 bytes a layer (#981). At 8, files
/// under about 1.6 MB keep the floor and the ceiling comes at a 16 MB canvas (about 11 GB and 40 s
/// to import 2 million synthetic layers; memory grows with the count, about 5.4 KB a layer).
const PACKED_BYTES_PER_LAYER: usize = 8;
const MAX_FIG_NODES: usize = 2_000_000;
/// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes.
@@ -108,7 +113,13 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
drop(msg);
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned());
let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
warnings.extend(more.into_iter().filter_map(map::adapt_warning));
// Both stages stop at the same cap and say so in the same words: one note (#981).
for w in more.into_iter().filter_map(map::adapt_warning) {
let base = w.rsplit_once(" (×").map_or(w.as_str(), |(b, _)| b);
if !warnings.iter().any(|seen| seen == base) {
warnings.push(w);
}
}
Ok((doc, warnings))
}
+12
View File
@@ -895,6 +895,18 @@ fn the_mapper_stops_at_the_layer_cap_it_is_given() {
assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}");
}
#[test]
fn scalar_fields_count_against_the_value_budget() {
// A message of 1000 scalar fields (id 1, `true`) is 1001 values: a budget of 10 refuses it.
let fields = vec![Field { name: "b".into(), ty: Some(Type::Bool), array: false, value: 1 }];
let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] };
let mut data = [1u8, 1].repeat(1000);
data.push(0);
let e = sc.decode_message_within(0, &data, 10).unwrap_err();
assert_eq!(e.0, "message: too many values");
assert!(sc.decode_message_within(0, &data, 1001).is_ok());
}
#[test]
fn hostile_input_is_refused() {
// A message that contains itself, nested past the depth cap.
+26
View File
@@ -86,3 +86,29 @@ fn many_lost_images_are_counted_not_listed_one_by_one() {
assert_eq!(issues.len(), MAX_IMAGE_ISSUES + 1, "{issues:?}");
assert_eq!(issues.last().map(String::as_str), Some("20 more images couldn't be loaded"));
}
#[test]
fn image_members_with_path_like_names_are_dropped_and_not_saved_back() {
let (d, hash) = doc_with_image();
let bytes = save_bytes(&d, None).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let doc_json = read_member(&mut z, "document.json", u64::MAX).unwrap();
let mut buf = std::io::Cursor::new(Vec::new());
let mut w = zip::ZipWriter::new(&mut buf);
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
w.start_file("document.json", opts).unwrap();
w.write_all(&doc_json).unwrap();
for n in ["images/../../x", "images/a/b"] {
w.start_file(n, opts).unwrap();
w.write_all(&png()).unwrap();
}
w.finish().unwrap();
let hostile = buf.into_inner();
let loaded = load_bytes_with_versions(&hostile).unwrap();
assert!(loaded.doc.images.keys().all(|k| k != "../../x" && k != "a/b"), "{:?}", loaded.doc.images.keys());
assert!(!loaded.issues.is_empty());
let saved = save_bytes_with_history(&loaded.doc, None, &loaded.versions, &loaded.branches).unwrap();
let z = zip::ZipArchive::new(std::io::Cursor::new(saved)).unwrap();
assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::<Vec<_>>());
let _ = hash;
}
+23 -1
View File
@@ -343,6 +343,20 @@ pub(crate) fn put<W: Write + Seek>(
z.write_all(bytes).map_err(io_err)
}
/// `files` (name, bytes; a `/` in a name makes a folder) as one zip archive: several exports
/// delivered as a single download (#969).
pub fn zip_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a [u8])>) -> Result<Vec<u8>, FormatError> {
let mut buf = std::io::Cursor::new(Vec::new());
{
let mut z = zip::ZipWriter::new(&mut buf);
for (name, bytes) in files {
put(&mut z, name, zip::write::SimpleFileOptions::default(), bytes)?;
}
z.finish().map_err(io_err)?;
}
Ok(buf.into_inner())
}
/// Add `doc` as a `document.json`-style member, serialized straight into the compressor.
fn put_json<W: Write + Seek>(z: &mut zip::ZipWriter<W>, name: &str, doc: &Document) -> Result<(), FormatError> {
z.start_file(name, json_options()).map_err(io_err)?;
@@ -524,10 +538,18 @@ fn read_images<R: Read + Seek>(
own: Option<&BTreeSet<String>>,
budget: ImageBudget,
) -> (BTreeMap<String, ImageData>, Vec<(String, FormatError)>) {
let mut hashes: Vec<String> = z.file_names().filter_map(|n| n.strip_prefix("images/")).map(str::to_string).collect();
let mut hashes: Vec<String> = z.file_names().filter_map(|n| n.strip_prefix("images/")).filter(|h| !h.is_empty()).map(str::to_string).collect();
hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h)));
let mut images = BTreeMap::new();
let mut unread = Vec::new();
// Member names that aren't plain hashes (`../x`, `a/b`) are never read, so never written back.
hashes.retain(|h| {
let ok = library::is_valid_hash(h);
if !ok {
unread.push((h.clone(), FormatError::Corrupt("its name isn't a valid image hash".to_string())));
}
ok
});
let mut left = budget.bytes;
for (i, hash) in hashes.into_iter().enumerate() {
let name = format!("images/{hash}");
+1 -1
View File
@@ -39,7 +39,7 @@ pub fn is_valid_library_id(id: &str) -> bool {
}
/// Image hashes become archive member names: only plain hex-like names are looked up.
fn is_valid_hash(h: &str) -> bool {
pub(crate) fn is_valid_hash(h: &str) -> bool {
(1..=128).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric())
}
+41 -11
View File
@@ -65,6 +65,8 @@ const MAX_DEPTH: usize = 256;
pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource";
/// Hidden .fig library context; accepted only by the `.fig` import ([`import_with_networks`]).
pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts";
/// Ids of mains in a `.fig` file that its layer cap kept out; read only by the `.fig` import.
pub(crate) const FIG_CAPPED_MAINS_FIELD: &str = "_figCappedMains";
/// Longest SVG path string read from one geometry entry.
const MAX_PATH_BYTES: usize = 4 << 20;
/// Most path elements in one vector.
@@ -215,6 +217,14 @@ fn import_parts(
cx.style_definitions(v);
cx.networks = networks;
cx.max_nodes = max_nodes;
// The `.fig` mapper already kept the layers, library copies included, within the cap; each
// library copy's wrapper frame is the one layer here it doesn't count (#981).
let contexts = if fig_sources { v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() } else { &[] };
if fig_sources {
cx.capped_mains =
v.get(FIG_CAPPED_MAINS_FIELD).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect();
cx.max_nodes = max_nodes.saturating_add(contexts.len());
}
for e in entries {
let Some(root) = e.get("document") else { continue };
match text(root, "type") {
@@ -236,11 +246,9 @@ fn import_parts(
None => cx.warn("entries without a node type were skipped"),
}
}
if fig_sources {
for context in v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).into_iter().flatten() {
let store = cx.doc.ensure_library_store();
cx.build(context, store, Space::default(), None, 0, false);
}
for context in contexts {
let store = cx.doc.ensure_library_store();
cx.build(context, store, Space::default(), None, 0, false);
}
// Drop the empty page the new document started with once real pages exist.
if let Some(p) = blank
@@ -329,15 +337,16 @@ fn children(v: &Value) -> &[Value] {
v.get("children").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default()
}
/// Distinct warnings in first-seen order, with repeat counts.
/// Distinct warnings in first-seen order, with repeat counts. Shared with the `.fig` mapper
/// (`crate::fig::map`), whose notes repeat per layer in the same way.
#[derive(Default)]
struct Warnings {
pub(crate) struct Warnings {
list: Vec<(String, usize)>,
index: BTreeMap<String, usize>,
}
impl Warnings {
fn add(&mut self, msg: String) {
pub(crate) fn add(&mut self, msg: String) {
if let Some(i) = self.index.get(&msg) {
if let Some(e) = self.list.get_mut(*i) {
e.1 += 1;
@@ -348,7 +357,17 @@ impl Warnings {
}
}
fn finish(self) -> Vec<String> {
/// The notes met so far, without their counts, for the tests that ask whether one was met
/// (the product reads them through [`Self::finish`], with the counts).
#[cfg(test)]
pub(crate) fn seen(&self) -> impl Iterator<Item = &str> {
self.list.iter().map(|(msg, _)| msg.as_str())
}
/// The notes, each with `(×n)` after it where it was met more than once. The import report
/// reads that suffix off before it looks the note up in the catalogue, so a counted note is
/// still translated (`ui-egui`'s `note_text`).
pub(crate) fn finish(self) -> Vec<String> {
self.list.into_iter().map(|(m, n)| if n > 1 { format!("{m} (×{n})") } else { m }).collect()
}
}
@@ -404,6 +423,8 @@ pub(crate) struct Ctx<'a> {
networks: Vec<Option<VectorNetwork>>,
/// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap.
max_nodes: usize,
/// Mains the `.fig` file has but its layer cap kept out ([`FIG_CAPPED_MAINS_FIELD`]).
capped_mains: BTreeSet<String>,
}
impl<'a> Ctx<'a> {
@@ -432,6 +453,7 @@ impl<'a> Ctx<'a> {
var_modes: BTreeMap::new(),
networks: Vec::new(),
max_nodes: MAX_NODES,
capped_mains: BTreeSet::new(),
}
}
@@ -998,7 +1020,11 @@ impl<'a> Ctx<'a> {
fn resolve_swap_defaults(&mut self) {
for (owner, name, rest_id) in std::mem::take(&mut self.swap_defaults) {
let Some(c) = self.component(&rest_id) else {
self.warn("instance swap properties whose default component isn't in the file were skipped");
if self.capped_mains.contains(&rest_id) {
self.warn("instance swap properties whose default component was past the layer limit were skipped");
} else {
self.warn("instance swap properties whose default component isn't in the file were skipped");
}
continue;
};
let def = PropDef::InstanceSwap { default: c, preferred: Vec::new() };
@@ -1017,7 +1043,11 @@ impl<'a> Ctx<'a> {
let frames: BTreeSet<NodeId> = self.instances.iter().map(|(i, _)| *i).collect();
let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new();
for (inst, j) in std::mem::take(&mut self.instances) {
match text(j, "componentId").and_then(|r| self.component(r)) {
let main = text(j, "componentId");
match main.and_then(|r| self.component(r)) {
None if main.is_some_and(|r| self.capped_mains.contains(r)) => {
self.warn("instances whose component was past the layer limit were imported as frames");
}
None => self.warn("instances of components that aren't in the file were imported as frames"),
Some(c) if self.doc.is_ancestor(c, inst) => self.warn("instances inside their own main component were imported as frames"),
Some(c) => links.push((inst, c, j)),

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