Merge remote-tracking branch 'origin/main' into feat/popover

This commit is contained in:
叶林橦
2026-10-20 13:06:58 +08:00
172 changed files with 18477 additions and 807 deletions
+32
View File
@@ -0,0 +1,32 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directories:
- "/"
- "/plugins"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
name: test (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install Linux system dependencies (egui/winit/rfd)
run: |
@@ -74,7 +74,7 @@ jobs:
name: wasm32 check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
+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: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc
+6
View File
@@ -24,8 +24,14 @@ jobs:
with:
app-name: figcraft
app-title: FigCraft
# Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only
# craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter.
# Re-pin once one commit has both (storytold/craft-fonts#42).
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
has-server: true
# The browser has no system fonts: without craft-fonts the web build can't draw text (#885).
# crates/text/build.rs keeps the web build to Inter.
web-craft-fonts: true
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+1
View File
@@ -25,6 +25,7 @@ Release builds embed fonts from the optional craft-fonts build input
| Family | Licence | Upstream |
|---|---|---|
| Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter |
| Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) |
| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) |
| Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts |
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) |
+4 -6
View File
@@ -10,7 +10,7 @@
<h1 align="center">FigCraft</h1>
<p align="center">
<b>Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.</b><br>
<b>Interface design and prototyping: open source, collaborative and built in pure Rust.</b><br>
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,<br>
in a native app that opens instantly and works offline. Open source, and yours.
</p>
@@ -62,7 +62,7 @@
<tr>
<td width="25%" valign="top">
<h3>🎛️ Familiar by design</h3>
The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft.
The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.
</td>
<td width="25%" valign="top">
<h3>⚡ Native and fast</h3>
@@ -227,7 +227,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust.
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
@@ -262,7 +262,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
> [!IMPORTANT]
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
## Privacy and online sharing
@@ -348,8 +348,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/brand/artcraft-mark.png" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+5
View File
@@ -316,6 +316,11 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. |
| 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged |
| 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged |
| 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged |
| 2026-10-20 | minor | Release fonts (#885): the web release embeds craft-fonts again (Inter), so the browser version draws text and lists fonts; it had none since releases moved to the shared workflow. The craft-fonts pin goes back to the commit with the Latin manifest (the Georgian change had moved it to one without Inter, which would have failed the next release). Numbers unchanged. |
| 2026-10-17 | minor | Community batch #753 (10 PRs): Selection colors for mixed selections (#403); smart-selection spacing fields and canvas handles (#402); a Present tab with device and scale controls (#537); adding auto layout to an empty frame keeps its size (#369); `node.set` applies auto layout keys to frames only and warns about font styles a family lacks (#603); canvas images on whole device pixels (#359); Adwaita window controls on Wayland without server decorations (#592, not run on GNOME or KDE by the maintainers); the keyboard shortcuts panel closes on the home screen (#548); macOS paste modifiers in tests (#750); credits column tests (#347). `cargo xtask parity` re-run: 212 / 212 menu items (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Language table re-measured from the catalogs (2,272 entries): the drafts read 95–99% (strings added since 2026-10-12 show English), Indonesian has a draft (99%; it read none), and Russian, Swedish (its command labels from #599), Turkish and Czech are listed with their counts. Per-area parity rows for the day's landings in `docs/gaps.md`, `target-app-parity.md` and the area docs. Numbers unchanged |
| 2026-10-17 | minor | Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. |
| 2026-10-17 | minor | Community fixes from #524 up (batches #727, #728, #733, #735, #736 and #729; 55 PRs) and a crash guard. `.fig` import: explicit instance identity, overrides by key, text style precedence, grid track sizes, slot content, connector labels, skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments and embedded images; large files open within budgets from the file's own size; an import report after opening; imported bytes stay out of the command journal. Files and paste: save a copy as `.fig`; Cmd/Ctrl+V pastes images copied in other apps and layers copied from another design app (`edit.pasteDesignLayers`); paste over and paste properties; clipboard reads on Linux and the BSDs time out after 2 s. Editor: live font preview on hover; Shift+I and Resources open Assets; Shift-drag moves the selection; thin strokes take a nearby double-click; dropped points merge; Flatten keeps open paths; the Actions palette gets tabs, ranked search, a keyboard highlight and recents; the colour picker opens on a new fill or stroke; text strokes follow their alignment; selection highlights after line breaks; a draggable pages/layers divider; variant properties reorder by drag; submenus keep their hover with a safe triangle; menu actions that need a value ask for it; File ▸ Rename edits the name in place. Engine: bad paint indexes, non-finite geometry and pages in copy/cut are refused; a param naming a layer says so when it isn't one; a CLI script is one undo step and resolves `$step`. Server and collaboration: message limits follow `max_file_mb` (client capped at 1 GiB); a damaged generation field no longer drops flushed edits; the web build joins a session from a `#join=` link. Also Swedish command labels, Nix CI through the shared workflow, paint bounds once per render, and a guard that turns a panic inside the boolean-op library into bad input (the stroke draws unclipped). Numbers unchanged. |
+15 -3
View File
@@ -29,9 +29,20 @@ fn image_format(e: &str) -> Option<ExportFormat> {
}
}
/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
/// confined run can only write inside a write root (#833).
///
/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
/// writing through it to its target -- a change of behaviour for anyone whose output path is a
/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
/// `Roots::check` has already refused a link that leads out of the root.
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?;
figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
}
if figcraft_engine::file_access::current().is_some() {
return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
}
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
}
@@ -244,7 +255,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
let ids = match target {
Some(t) => vec![t],
None => {
@@ -329,7 +340,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
}
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut s = match input {
Some(path) => {
+50 -28
View File
@@ -22,6 +22,7 @@ USAGE:
figcraft-cli convert <in> <out>
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
figcraft-cli commands [--json] [--filter TEXT]
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
@@ -42,12 +43,33 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines)
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files the agent's commands touch: reads only inside a read or write root,
writes only inside a write root, everything else refused. A running app confines itself: start it
with `figcraft --control PORT` and the same flags (`--attach` refuses them).
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
only inside a write root, everything else refused -- so giving no write root denies writing
altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`,
`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes
as the user who started it, as before. A running app confines itself: start it with
`figcraft --control PORT` and the same flags (`--attach` refuses them).
See docs/cli.md and docs/mcp.md.";
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
if read.is_empty() && write.is_empty() {
return Ok(None);
}
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
}
/// Runs `f` confined to the roots `a` carries.
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
let roots = roots_of(a)?.map(std::sync::Arc::new);
figcraft_engine::file_access::confined(roots.as_ref(), f)
}
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
match run(&argv) {
@@ -98,35 +120,38 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"info" => {
let a = Args::parse(rest, &[], &["json"])?;
let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?;
let file = a.one_positional("info <file>")?;
files::info(&file, a.flag("json"))
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?;
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(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 =
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
files::export(&file, &out, &target)
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
let a = Args::parse(rest, &[], &[])?;
let a = Args::parse(rest, &ROOT_FLAGS, &[])?;
match a.positional.as_slice() {
[i, o] => files::convert(i, o),
[i, o] => confined(&a, || files::convert(i, o)),
_ => Err(CliError::Usage("convert <in> <out>".into())),
}
}
"render" => {
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(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()))?;
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
}
"run" => {
let a = Args::parse(rest, &["in", "out"], &[])?;
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let script = a.one_positional("run <script.json|.jsonl>")?;
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
}
"commands" => {
let a = Args::parse(rest, &["filter"], &["json"])?;
@@ -137,23 +162,20 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"mcp" => {
let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?;
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) {
(true, _) => None,
// The app runs the commands: only its own roots can confine them.
(false, Some(_)) => {
return Err(CliError::Usage(
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
.into(),
));
}
(false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?),
};
mcp(a.value("attach").as_deref(), roots)
let attach = a.value("attach");
// The app runs the commands: only its own roots can confine them.
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
return Err(CliError::Usage(
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
.into(),
));
}
mcp(attach.as_deref(), roots_of(&a)?)
}
"plugin" => {
let a = Args::parse(rest, &["server"], &[])?;
+95
View File
@@ -172,3 +172,98 @@ fn commands_json_lists_everything() {
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(v.as_array().unwrap().len() > 200);
}
/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a
/// root, and a write outside a write root, are refused and leave no file behind. Without the flags
/// the same commands behave as they always did.
#[test]
fn file_subcommands_are_confined_to_automation_roots() {
let d = tmp("file-roots");
let _ = std::fs::remove_dir_all(&d);
for sub in ["in", "out"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned();
// A document to work on, made unconfined.
std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap();
assert!(made.status.success(), "{}", out_of(&made));
// Inside the roots: renders as usual.
let ok = bin()
.args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
.output()
.unwrap();
assert!(ok.status.success(), "{}", out_of(&ok));
assert!(d.join("out/ok.png").is_file());
// A write outside the write root is refused, and nothing is written.
let escaped = d.join("escaped.png");
let refused = bin()
.args([
"render",
&p("in/doc.figcraft"),
"-o",
&escaped.to_string_lossy(),
"--automation-read-root",
&p("in"),
"--automation-write-root",
&p("out"),
])
.output()
.unwrap();
assert!(!refused.status.success());
assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused));
assert!(!escaped.exists(), "the refused render wrote a file anyway");
// No write root at all: writing is refused outright, reading still works.
let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap();
assert!(!no_write.status.success());
assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write));
// A read outside every root is refused: `info` on the document with only `out` readable.
let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap();
assert!(!read_refused.status.success());
assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused));
// `convert` and `run` take them as well, and a root that is not a folder is refused.
let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap();
assert!(!missing.status.success());
assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing));
// Unconfined, the same render still writes wherever it is told.
let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap();
assert!(plain.status.success(), "{}", out_of(&plain));
assert!(escaped.is_file());
let _ = std::fs::remove_dir_all(&d);
}
/// An output path that is a symbolic link is written through, to its target, as `std::fs::write`
/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have
/// replaced the link with a regular file, because that write stages the bytes and renames them
/// into place.
#[cfg(unix)]
#[test]
fn an_unconfined_write_follows_a_symbolic_link() {
let d = tmp("symlink-out");
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("real")).unwrap();
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap();
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
let target = d.join("real/target.png");
std::fs::write(&target, b"placeholder").unwrap();
let link = d.join("link.png");
std::os::unix::fs::symlink(&target, &link).unwrap();
let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link");
let written = std::fs::read(&target).unwrap();
assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder");
let _ = std::fs::remove_dir_all(&d);
}
+1 -1
View File
@@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+99 -4
View File
@@ -1,7 +1,7 @@
//! The browser shell: web `Services` and the eframe web runner.
use figcraft_engine::Session;
use figcraft_ui_egui::{FigApp, Services};
use figcraft_ui_egui::{ContentsFuture, FigApp, Services};
use wasm_bindgen::JsCast as _;
const CANVAS_ID: &str = "figcraft_canvas";
@@ -75,9 +75,10 @@ impl eframe::App for WebShell {
}
}
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
/// events with their text (a page can't read the clipboard when it likes).
/// Browser services. Files are opened and imported through the browser's file picker, which hands
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
/// paste events with their text (a page can't read the clipboard when it likes).
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
@@ -85,6 +86,16 @@ struct WebServices {
}
impl Services for WebServices {
/// An `<input type="file">` taking `exts`, clicked; its file is read once picked.
fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option<ContentsFuture> {
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
Some(Box::pin(async move {
pick_file(&accept).await.unwrap_or_else(|e| {
log::error!("couldn't open a file: {e:?}");
None
})
}))
}
fn downloads(&self) -> bool {
true
}
@@ -136,6 +147,90 @@ impl Services for WebServices {
}
}
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file
/// can't be read.
async fn pick_file(accept: &str) -> Result<Option<Result<(String, Vec<u8>), String>>, wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let body = document.body().ok_or("no body")?;
let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?;
input.set_type("file");
input.set_accept(accept);
input.set_hidden(true);
// Some browsers (older Safari) open the picker only for an input in the page.
body.append_child(&input)?;
let (closed, picker) = PickerClosed::new(&window, input.clone());
input.click();
let result = wasm_bindgen_futures::JsFuture::from(closed).await;
let file = input.files().and_then(|files| files.get(0));
drop(picker);
result?;
let Some(file) = file else { return Ok(None) };
let name = file.name();
if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) {
return Ok(Some(Err(why)));
}
Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())),
Err(e) => {
log::error!("couldn't read {name}: {e:?}");
Err(figcraft_ui_egui::unreadable(&name))
}
}))
}
/// How long after the page gets focus back the file picker counts as closed (milliseconds): a
/// picked file's `change` can come just after the focus, and must win. A `change` later than this
/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel.
const PICKER_FOCUS_SETTLE_MS: i32 = 1_000;
/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed
/// without one) or shortly after the page gets focus back, for browsers that never send `cancel`
/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one.
/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer.
struct PickerClosed {
window: web_sys::Window,
input: web_sys::HtmlInputElement,
_settle: wasm_bindgen::closure::Closure<dyn FnMut()>,
on_focus: wasm_bindgen::closure::Closure<dyn FnMut()>,
timer: std::rc::Rc<std::cell::Cell<Option<i32>>>,
}
impl PickerClosed {
fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) {
let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::<js_sys::Function>));
let slot = resolve.clone();
let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res));
let settle = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
if let Some(res) = resolve.borrow_mut().take() {
let _ = res.call0(&wasm_bindgen::JsValue::NULL);
}
});
let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref());
let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref());
let timer = std::rc::Rc::new(std::cell::Cell::new(None));
let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::<js_sys::Function>().clone());
let on_focus = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
if t.get().is_none() {
t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok());
}
});
let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref());
(closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer })
}
}
impl Drop for PickerClosed {
fn drop(&mut self) {
if let Some(t) = self.timer.take() {
self.window.clear_timeout_with_handle(t);
}
let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref());
self.input.remove();
}
}
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
+15 -5
View File
@@ -2,12 +2,14 @@
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). Any other
//! `--flag` is unknown and stops the app at startup (#836): a mistyped root flag must never leave
//! the app running unconfined. After a bare `--`, every argument is a file.
/// What the app was started with.
#[derive(Debug, Default, PartialEq, Eq)]
pub struct Args {
/// `--control <port>`: the JSON-lines control server.
/// `--control <port>` (or `--control=<port>`): the JSON-lines control server.
pub control: Option<u16>,
pub files: Vec<String>,
/// The last invite link given.
@@ -17,6 +19,8 @@ pub struct Args {
/// which is refused at startup.
pub read_roots: Vec<String>,
pub write_roots: Vec<String>,
/// `--flags` the app doesn't know, as given.
pub unknown: Vec<String>,
}
/// An invite link rather than a file (`figcraft:` links, in any case).
@@ -55,6 +59,10 @@ pub fn parse(args: &[String]) -> Args {
let mut out = Args::default();
let mut it = args.iter();
while let Some(a) = it.next() {
if a == "--" {
out.files.extend(it.by_ref().cloned());
break;
}
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
let roots = match flag {
"--automation-read-root" => Some(&mut out.read_roots),
@@ -63,13 +71,15 @@ pub fn parse(args: &[String]) -> Args {
};
if let Some(roots) = roots {
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
} else if a == "--control" {
out.control = it.next().and_then(|p| p.parse().ok());
} else if flag == "--control" {
out.control = inline.or_else(|| it.next().cloned()).and_then(|p| p.parse().ok());
} else if is_invite(a) {
out.join = Some(a.clone());
} else if let Some(path) = file_url_path(a) {
out.files.push(path);
} else if !a.starts_with("--") {
} else if a.starts_with("--") {
out.unknown.push(a.clone());
} else {
out.files.push(a.clone());
}
}
+22 -1
View File
@@ -9,13 +9,21 @@ fn args(a: &[&str]) -> Vec<String> {
#[test]
fn files_and_the_control_port() {
assert_eq!(parse(&args(&[])), Args::default());
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
let a = parse(&args(&["--control", "4242", "a.figcraft", "b.svg"]));
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
// A missing or bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control"])), Args::default());
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
}
#[test]
fn the_control_port_accepts_the_equals_form() {
let a = parse(&args(&["--control=7980", "a.figcraft"]));
assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() });
// A bad port is ignored, not taken as a file.
assert_eq!(parse(&args(&["--control=nope"])), Args::default());
}
#[test]
fn automation_roots_repeat_and_are_never_files() {
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
@@ -26,6 +34,19 @@ fn automation_roots_repeat_and_are_never_files() {
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
}
#[test]
fn unknown_flags_are_refused_never_ignored() {
// #836: a mistyped root flag (or one an older release lacks) must stop the app, not start it
// unconfined. Both forms, with and without `=`.
let a = parse(&args(&["--automation-write-roots", "out", "--automation-write-dir=out", "--verbose", "a.figcraft"]));
assert_eq!(a.unknown, args(&["--automation-write-roots", "--automation-write-dir=out", "--verbose"]));
assert!(a.write_roots.is_empty());
// Single-dash arguments are left as they were (files); after `--` everything is a file.
let a = parse(&args(&["--control", "1", "--", "--odd.figcraft", "--automation-write-root=x"]));
assert_eq!(a.files, args(&["--odd.figcraft", "--automation-write-root=x"]));
assert!(a.unknown.is_empty() && a.write_roots.is_empty());
}
#[test]
fn invite_links_join_instead_of_opening() {
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
+37 -8
View File
@@ -10,7 +10,8 @@
//! `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable) confine the
//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
//! the command line are not. A root that isn't an existing folder stops the app at startup.
//! the command line are not. A root that isn't an existing folder stops the app at startup, and so
//! does any flag the app doesn't know (a mistyped root flag must not start the app unconfined).
#![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")]
@@ -107,10 +108,13 @@ impl eframe::App for App {
}
fn on_exit(&mut self) {
with_app(|fig| services::save_state(fig));
if let Some(r) = recovery::lock(&self.recovery).take() {
r.finish();
}
// With the app out of reach (busy), its recovery copies stay for the next start.
let copies = recovery::lock(&self.recovery).take();
with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
// Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
// throws on the content view AccessKit restores (craft-libs#3, #781).
#[cfg(target_os = "macos")]
std::process::exit(0);
}
}
@@ -126,6 +130,12 @@ fn main() -> eframe::Result {
return Ok(());
}
let args = args::parse(&args);
if let Some(flag) = args.unknown.first() {
eprintln!(
"FigCraft: unknown option {flag}\nUsage: figcraft [--control PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... [FILE | figcraft://join?...]..."
);
std::process::exit(2);
}
let roots = match (
args.read_roots.is_empty() && args.write_roots.is_empty(),
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
@@ -183,10 +193,8 @@ fn main() -> eframe::Result {
// default, never a build from source unless asked).
session.live.links = figcraft_collab_net::links::system();
session.invite_links_startup();
// A first run (no settings kept yet, nothing to open) starts on the home screen, with its
// tips and templates; later runs start in a new file.
let first_run = svc.prefs.is_none() && args.join.is_none();
if session.docs.is_empty() && !first_run {
if session.docs.is_empty() && opens_new_file(first_run, session.prefs.start_on_home, port.is_some()) {
let _ = session.execute("file.new", &json!({}));
}
// The interface language from the first frame on, and for the message when the window can't
@@ -258,6 +266,14 @@ fn main() -> eframe::Result {
result
}
/// With nothing to open, whether the window starts in a new file rather than on the home screen.
/// A first run (no settings kept yet) shows the home screen's tips and templates, and later runs
/// start in a new file unless the preference asks for Home, which `--control` overrides: its
/// callers expect a document to act on.
fn opens_new_file(first_run: bool, start_on_home: bool, control: bool) -> bool {
!first_run && (!start_on_home || control)
}
fn desktop_title_bar() -> figcraft_ui_egui::TitleBar {
use figcraft_ui_egui::TitleBar;
if cfg!(target_os = "macos") { TitleBar::Inset } else { TitleBar::Custom }
@@ -282,3 +298,16 @@ mod linux_window_tests {
assert_eq!(viewport.decorations, Some(false));
}
}
#[cfg(test)]
mod start_tests {
use super::opens_new_file;
#[test]
fn the_home_preference_starts_on_home_except_for_control_callers() {
assert!(opens_new_file(false, false, false), "a new file by default");
assert!(!opens_new_file(false, true, false), "Home when the preference asks");
assert!(opens_new_file(false, true, true), "--control still starts with a document");
assert!(!opens_new_file(true, false, false), "a first run shows the home screen");
}
}
+39 -5
View File
@@ -120,8 +120,7 @@ impl Recovery {
(0..10).find_map(|n| {
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
std::fs::create_dir_all(&dir).ok()?;
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
lock.try_lock().ok()?;
let lock = lock_folder(&dir).ok()?;
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
})
}
@@ -250,6 +249,17 @@ impl Recovery {
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
}
/// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was
/// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the
/// unsaved documents are copied and the copies stay for the next start, as after a crash.
pub fn quit(mut self, s: &Session, asked: bool) {
if asked || !s.docs.iter().any(|d| needs_copy(s, d)) {
return self.finish();
}
self.write(s, true);
self.settle(s);
}
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
/// runs' copies still waiting for their outcome stay for the next start.)
pub fn finish(self) {
@@ -282,6 +292,17 @@ fn remove_kept(w: Waiting) {
/// The extension of the note beside the copy of a document that is one of your online files.
const ONLINE: &str = "online";
/// How long the startup waits, once, for folder locks that may free themselves (#334).
#[cfg(unix)]
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
/// Lock the folder `dir` (`WouldBlock` while another process holds it).
fn lock_folder(dir: &Path) -> Result<File, std::fs::TryLockError> {
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?;
lock.try_lock()?;
Ok(lock)
}
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
/// file isn't recorded any more, or is open already): then it opens as any other copy.
@@ -351,6 +372,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
}
}
};
let (mut free, mut held) = (Vec::new(), Vec::new());
for e in entries.flatten() {
let path = e.path();
if !path.is_dir() {
@@ -358,10 +380,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
continue;
}
// Still running (another window of the app), or not ours to take.
let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
if lock.try_lock().is_err() {
continue;
match lock_folder(&path) {
Ok(lock) => free.push((path, lock)),
Err(std::fs::TryLockError::WouldBlock) => held.push(path),
Err(_) => {}
}
}
// A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock`
// lasts while any of them is open, so an ended run's folder can look held for a moment. Wait
// once, however many folders are held, and try those again (#334). Windows doesn't pass the
// lock to child processes.
#[cfg(unix)]
if !held.is_empty() {
std::thread::sleep(LOCK_WAIT);
free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock))));
}
for (path, lock) in free {
let lock = Arc::new(lock);
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
for f in &files {
+40
View File
@@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() {
let root = root("quitunasked");
// macOS's Quit menu: nothing asked, so the unsaved document comes back next time.
let s = edited();
Recovery::start(&root).unwrap().quit(&s, false);
let mut next = Session::empty();
assert_eq!(recover(&root, &mut next), 1);
let _ = std::fs::remove_dir_all(&root);
// Asked (saved or discarded on purpose), or nothing unsaved: nothing is left.
for (s, asked) in [(edited(), true), (Session::new(), false)] {
Recovery::start(&root).unwrap().quit(&s, asked);
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}");
}
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_run_whose_folder_name_is_still_held_takes_another() {
let root = root("pidreuse");
@@ -186,6 +203,29 @@ fn a_run_whose_folder_name_is_still_held_takes_another() {
let _ = std::fs::remove_dir_all(&root);
}
/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and
/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the
/// file is open, so a crashed run's copies must still come back once that child has exec'd.
#[cfg(unix)]
#[test]
fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() {
let root = root("fork-window");
crashed(&edited(), &root);
// Hold the crashed run's folder lock on a second descriptor, the way a child forked before its
// exec holds it, and free it shortly after.
let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap();
lock.try_lock().unwrap();
let freed = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(10));
drop(lock);
});
// The next start waits for that lock rather than reading the run as still going.
let mut next = Session::empty();
assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself");
freed.join().unwrap();
let _ = std::fs::remove_dir_all(&root);
}
/// The rectangles in `doc`.
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
+11 -3
View File
@@ -167,10 +167,18 @@ fn reveal(path: &Path) -> std::io::Result<()> {
/// The preferences' file in the config folder.
const STATE: &str = "state.json";
pub fn save_state(app: &FigApp) {
if let Some(dir) = config_dir() {
write_prefs(&dir, &app.session.prefs);
/// Everything quitting keeps, all done when it returns (macOS then ends the process): the
/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit))
/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves).
pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option<crate::recovery::Recovery>) {
if let Some(dir) = dir {
write_prefs(dir, &app.session.prefs);
}
// Before the sessions end: a recovered online file's copy waits for its session's outcome.
if let Some(r) = recovery {
r.quit(&app.session, app.ui.quit);
}
drop(std::mem::take(&mut app.session.live));
}
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
+35
View File
@@ -82,3 +82,38 @@ fn preferences_are_readable_by_their_owner_only() {
assert_eq!(mode(&dir.join("state.json")), 0o600);
let _ = std::fs::remove_dir_all(&dir);
}
/// A live link that only records the sessions it was told to leave.
struct Leaves(Arc<Mutex<Vec<u64>>>);
impl figcraft_engine::live::LiveLink for Leaves {
fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result<figcraft_engine::live::SessionInfo, String> {
Err("not in this test".into())
}
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
Ok(())
}
fn leave(&mut self, session: u64) {
self.0.lock().unwrap().push(session);
}
fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {}
fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> {
Vec::new()
}
}
#[test]
fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
let dir = root("shutdown");
let mut fig = start(&dir);
let left = Arc::new(Mutex::new(Vec::new()));
fig.session.live.install(Box::new(Leaves(left.clone())));
let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap();
fig.session.prefs.user_name = "Ada".into();
crate::services::shutdown(&mut fig, Some(&dir), None);
assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left");
assert!(!fig.session.live.available(), "nothing is left to send on");
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
let _ = std::fs::remove_dir_all(&dir);
}
+11
View File
@@ -37,6 +37,11 @@ real_name = "Justin John"
[people.Losminek]
[people.alrifatsabbir]
real_name = "Al Rifat Sabbir"
display_name = "Al Rifat Sabbir"
sync_github_name = true
[people.RevDonz]
[people.Nael-Nathanael]
@@ -56,3 +61,9 @@ sync_github_name = true
[people.pechnikov2026-star]
sync_github_name = true
[people.MortenHusted]
sync_github_name = true
[people.AganzeFelicite]
sync_github_name = true
+4 -1
View File
@@ -16,7 +16,7 @@ use figcraft_engine::live::{Event, Status};
use web_time::Instant;
use crate::inbox::Inbox;
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
use crate::join::{GuestConfig, Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
/// A WebSocket the driver opens, writes and closes; what it receives comes back through the
@@ -114,6 +114,9 @@ impl<S: Socket> Driver<S> {
/// A binary message from the host.
pub(crate) fn message(&mut self, bytes: &[u8], now: Instant) {
let Some(c) = self.conn.as_mut() else { return };
if bytes.len() > self.cfg.limits.max_server_message {
return self.end(too_large(&self.cfg.limits), now);
}
c.last_heard = now;
let taken = match message::decode::<ServerMsg>(bytes, self.cfg.limits.max_server_message) {
// The join code's answer: join the file it opens, on a new connection.
+10 -1
View File
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, Presence, ServerMsg};
use figcraft_collab::{Client, Limits, Outgoing, Server};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, Status};
use figcraft_engine::live::{Event, Status, TOO_LARGE_TO_JOIN};
use tungstenite::protocol::WebSocket;
use tungstenite::{Bytes, Message};
@@ -262,6 +262,15 @@ fn refusals_and_the_end_of_the_session_are_final() {
assert!(d.done());
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if reason.starts_with("The host refused")));
}
// A message over the size limit: the file is too large for this app, not refused by the host.
let inbox = Inbox::new(Arc::new(|| {}));
let mut cfg = GuestConfig::new(invite(), "Ada".into());
cfg.limits.max_server_message = 2 << 20;
let mut d = Driver::start(Script::default(), cfg, inbox.clone(), t0);
d.opened(t0);
d.message(&vec![0; (2 << 20) + 1], t0);
assert!(d.done());
assert!(matches!(statuses(&inbox.drain()).last(), Some(Status::Failed { reason }) if *reason == TOO_LARGE_TO_JOIN.replace("{mb}", "2")));
}
#[test]
+2 -2
View File
@@ -26,7 +26,7 @@ use tungstenite::{Bytes, Message};
use crate::inbox::Inbox;
use crate::invite::{Endpoint, Invite};
pub use crate::join::GuestConfig;
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, unresolved};
use crate::join::{Next, Outcome, Resuming, Seen, Taken, after, closed, hello, resolved, take, too_large, unresolved};
use crate::manage;
use crate::net::{Duplex, NetLimits, config, open, shutdown};
use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest};
@@ -367,7 +367,7 @@ fn read_loop(mut ws: WebSocket<Duplex>, ctx: &ReaderCtx) {
Ok(Message::Close(f)) => break f.map_or_else(|| closed(0, ""), |f| closed(f.code.into(), &f.reason)),
Ok(Message::Text(_)) => break Outcome::Refused("the host sent a text message".into()),
Ok(_) => {}
Err(tungstenite::Error::Capacity(_)) => break Outcome::Refused("the host sent a message over the size limit".into()),
Err(tungstenite::Error::Capacity(_)) => break too_large(&ctx.cfg.limits),
Err(e) => break Outcome::Retry(e.to_string()),
}
};
+5 -1
View File
@@ -220,7 +220,8 @@ fn valid_host(s: &str) -> bool {
return true;
}
let Some((name, port)) = s.rsplit_once(':') else { return false };
port.parse::<u16>().is_ok_and(|p| p != 0)
port.bytes().all(|b| b.is_ascii_digit())
&& port.parse::<u16>().is_ok_and(|p| p != 0)
&& (1..=253).contains(&name.len())
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
&& !name.starts_with(['-', '.'])
@@ -239,6 +240,9 @@ fn percent_decode(s: &str) -> Option<String> {
while let Some(&b) = bytes.get(i) {
if b == b'%' {
let hex = bytes.get(i + 1..i + 3)?;
if !hex.iter().all(u8::is_ascii_hexdigit) {
return None;
}
out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?);
i += 3;
} else {
+16
View File
@@ -123,3 +123,19 @@ proptest! {
prop_assert_eq!(Invite::parse(&i.url()), Ok(i));
}
}
#[test]
fn plus_or_sign_inside_a_percent_escape_is_broken() {
assert!(Endpoint::parse("ws://example.org/a/?key=%41").is_ok());
for bad in ["%+1", "%-1", "%1", "%+a"] {
assert!(Endpoint::parse(&format!("ws://example.org/a/?key={bad}")).is_err(), "{bad}");
}
}
#[test]
fn port_must_be_all_digits() {
assert!(Endpoint::parse("ws://localhost:80/").is_ok());
for bad in ["ws://localhost:+80/", "ws://localhost:-80/", "ws://localhost:/"] {
assert!(Endpoint::parse(bad).is_err(), "accepted {bad}");
}
}
+7 -1
View File
@@ -7,7 +7,7 @@ use std::time::Duration;
use figcraft_collab::Client;
use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg};
use figcraft_engine::live::{Event, Role, SessionInfo, Status};
use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN};
use crate::inbox::Inbox;
use crate::invite::Invite;
@@ -95,6 +95,12 @@ pub(crate) fn unresolved(cfg: &GuestConfig, outcome: Outcome) -> Outcome {
})
}
/// The host sent a message over this app's size limit (the file's welcome, usually): final, and
/// not the host's doing.
pub(crate) fn too_large(limits: &NetLimits) -> Outcome {
Outcome::Failed(TOO_LARGE_TO_JOIN.replace("{mb}", &(limits.max_server_message >> 20).to_string()))
}
/// The server's answer to the join code: the invite is whole now.
pub(crate) fn resolved(cfg: &mut GuestConfig, file: String, token: String) -> Result<(), Outcome> {
if !crate::invite::valid_id(&file, 1, 64) || !crate::invite::valid_id(&token, 16, 128) {
+29 -1
View File
@@ -10,7 +10,7 @@ use std::time::{Duration, Instant};
use figcraft_collab::message::{self, ClientMsg, Hello, Presence, ServerMsg, Signal};
use figcraft_collab::{Client, Limits};
use figcraft_doc::{Document, NodeId};
use figcraft_engine::live::{Event, PEOPLE, Status};
use figcraft_engine::live::{Event, PEOPLE, Status, TOO_LARGE_TO_JOIN};
use tungstenite::protocol::WebSocket;
use tungstenite::{Bytes, Message};
@@ -642,3 +642,31 @@ fn the_host_removes_someone_and_they_cant_come_back() {
host.stop();
assert_eq!(host.connections(), 0);
}
#[test]
fn a_welcome_over_the_size_limit_says_the_file_is_too_large() {
let mut doc = Document::new("B".repeat(1 << 20));
doc.repair();
let probe = Probe::new();
let cfg = HostConfig {
addr: SocketAddr::from(([127, 0, 0, 1], 0)),
any_port_if_taken: false,
file: FILE.into(),
token: TOKEN.into(),
doc,
name: "Hana".into(),
limits: fast_limits(),
core: Limits::default(),
};
let mut host = Host::start(cfg, probe.inbox.clone()).unwrap();
let invite = Invite::new(host.addr().to_string(), FILE, TOKEN);
let mut guest_cfg = quick(GuestConfig::new(invite, "Ada".into()));
guest_cfg.limits = NetLimits { max_server_message: 1 << 20, ..fast_limits() };
let mut gp = Probe::new();
let _g = Guest::start(guest_cfg, gp.inbox.clone()).unwrap();
let too_large = TOO_LARGE_TO_JOIN.replace("{mb}", "1");
gp.wait("the file too large to join", |e| e.iter().any(|ev| matches!(ev, Event::Status(Status::Failed { reason }) if *reason == too_large)));
assert!(!welcomed(&gp.events));
host.stop();
}
+3 -2
View File
@@ -87,8 +87,9 @@ macro_rules! node_props {
($($variant:ident => $field:ident),* $(,)?) => {
/// A node property: the tree position (`parent`, `position`) or one saved field of
/// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each,
/// replaced whole (three-way merges line fills, strokes and effects up item by item, see
/// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them).
/// replaced whole (three-way merges line the lists whose items stand on their own, such as
/// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the
/// binary form numbers them).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Prop {
+24 -8
View File
@@ -4,12 +4,13 @@
//!
//! A conflict is the same property (or document-level entry) changed on both sides to different
//! values, or a layer deleted on one side while the other side edited it or something inside it
//! (including adding layers to it or moving layers into or out of it). Fills, strokes and effects
//! merge item by item: both sides changing different items of the list (or one adding items at its
//! end) is no conflict, only the same item changed both ways or the list shortened on a side
//! while the other changed it. Nothing that conflicts is
//! merged until it is resolved: each conflict is resolved for one side, and the other side's
//! changes to it are left out. Everything else from both sides is kept.
//! (including adding layers to it or moving layers into or out of it). The layer lists whose items
//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype
//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides
//! changing different items of the list (or one adding items at its end) is no conflict, only the
//! same item changed both ways or the list shortened on a side while the other changed it. Nothing
//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the
//! other side's changes to it are left out. Everything else from both sides is kept.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
@@ -159,8 +160,23 @@ fn by_key(changes: &[Change]) -> BTreeMap<Key, Vec<usize>> {
out
}
/// List properties whose items merge one by one.
const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects];
/// List properties whose items merge one by one: the items stand on their own, so both sides
/// changing different ones (or one side adding at its end) is no conflict. Sets and keyed maps
/// (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) are not here: their items line up
/// by key, not by position.
const PER_ITEM: &[Prop] = &[
Prop::Fills,
Prop::Strokes,
Prop::Effects,
Prop::Grids,
Prop::Exports,
Prop::Guides,
Prop::Interactions,
Prop::Annotations,
// Not `CodeLinks`: there is one per language, and lining them up by position could leave two
// for one language (the second hidden), so they merge whole.
Prop::DevResources,
];
/// Both sides' writes `b` and `m` of list property `key` merged item by item against the base
/// (see [`merge_items`]); `None` when `key` isn't such a list or the writes conflict.
+107 -11
View File
@@ -1,8 +1,10 @@
//! Fills, strokes and effects merge item by item: different items changed on each side merge,
//! the same item changed both ways conflicts.
//! The layer lists whose items stand on their own (fills, strokes, effects, layout grids, export
//! settings, guides, prototype interactions, accessibility annotations and Dev Mode resources)
//! merge item by item: different items changed on each side merge, the same item changed both ways
//! conflicts.
use figcraft_color::{Color, Paint};
use figcraft_doc::{Document, Effect, NodeId, NodeKind};
use figcraft_doc::{Document, Effect, LayoutGrid, NodeId, NodeKind, proto};
use serde_json::{Value, json};
use crate::merge::{Key, Side, merge_items, plan};
@@ -100,19 +102,113 @@ fn the_same_item_changed_both_ways_conflicts() {
}
#[test]
fn other_lists_still_conflict_as_a_whole() {
// Docs silent: lists other than fills, strokes and effects (export settings here) still
// conflict whole when both sides change them.
fn the_other_item_lists_merge_one_by_one_too() {
// Layout grids, export settings, guides, accessibility annotations and Dev Mode resources are
// item lists like fills: both sides changing different items merges.
let (mut base, _, _, r) = files();
let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() };
base.get_mut(r).unwrap().exports = vec![export("@1"), export("@2")];
let guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset };
let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None };
let grid = |size: f64| LayoutGrid { size, ..Default::default() };
let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() };
let n = base.get_mut(r).unwrap();
n.exports = vec![export("@1"), export("@2")];
n.guides = vec![guide(true, 10.0), guide(false, 20.0)];
n.annotations = vec![annot("a1"), annot("a2")];
n.grids = vec![grid(10.0), grid(20.0)];
n.dev_resources = vec![res("https://a.test"), res("https://b.test")];
let (mut branch, mut main) = (base.clone(), base.clone());
branch.set_client_id(base.highest_id_space() + 1);
branch.get_mut(r).unwrap().exports[0] = export("@b");
main.get_mut(r).unwrap().exports[1] = export("@m");
// The branch changes the first item of each list; main the second.
let b = branch.get_mut(r).unwrap();
(b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.grids[0].size, b.dev_resources[0].url) =
("@b".into(), 11.0, "b1".into(), 12.0, "https://branch.test".into());
let m = main.get_mut(r).unwrap();
(m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.grids[1].size, m.dev_resources[1].url) =
("@m".into(), 22.0, "m2".into(), 24.0, "https://main.test".into());
both_ways(&base, &branch, &main, |doc| {
let n = doc.node(r).unwrap();
let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect();
assert_eq!(suffixes, ["@b", "@m"], "export settings");
let offsets: Vec<f64> = n.guides.iter().map(|g| g.offset).collect();
assert_eq!(offsets, [11.0, 22.0], "guides");
let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect();
assert_eq!(labels, ["b1", "m2"], "annotations");
let sizes: Vec<f64> = n.grids.iter().map(|g| g.size).collect();
assert_eq!(sizes, [12.0, 24.0], "layout grids");
let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect();
assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources");
});
}
#[test]
fn the_same_item_of_another_list_changed_both_ways_conflicts() {
let (mut base, _, _, r) = files();
let link = |language: &str| figcraft_doc::CodeLink { language: language.into(), ..Default::default() };
let n = base.get_mut(r).unwrap();
n.exports = vec![
figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() },
figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() },
];
n.code_links = vec![link("react"), link("vue")];
let (mut branch, mut main) = (base.clone(), base.clone());
branch.set_client_id(base.highest_id_space() + 1);
branch.get_mut(r).unwrap().exports[0].suffix = "@b".into();
main.get_mut(r).unwrap().exports[0].suffix = "@m".into();
// Code links are one per language, not an item list: different links changed still conflict.
branch.get_mut(r).unwrap().code_links[0].name = "B".into();
main.get_mut(r).unwrap().code_links[1].name = "M".into();
let p = plan(&base, &branch, &main).unwrap();
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
assert_eq!(keys, [format!("{r}/exports")]);
let mut keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
keys.sort();
let mut want = [format!("{r}/codeLinks"), format!("{r}/exports")];
want.sort();
assert_eq!(keys, want);
}
#[test]
fn interactions_merge_item_by_item() {
let ia = |duration_ms: u32| proto::Interaction {
trigger: Default::default(),
actions: vec![proto::Action::Back],
transition: Default::default(),
duration_ms,
easing: Default::default(),
delay_ms: 0,
};
let durations = |doc: &Document, r: NodeId| doc.node(r).unwrap().interactions.iter().map(|i| i.duration_ms).collect::<Vec<_>>();
let (mut base, _, _, r) = files();
base.get_mut(r).unwrap().interactions = vec![ia(100), ia(200)];
let fork = || {
let (mut branch, main) = (base.clone(), base.clone());
branch.set_client_id(base.highest_id_space() + 1);
(branch, main)
};
// The branch changes the first interaction and adds one; main changes the second.
let (mut branch, mut main) = fork();
branch.get_mut(r).unwrap().interactions[0].duration_ms = 150;
branch.get_mut(r).unwrap().interactions.push(ia(300));
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [150, 250, 300]));
// The branch deletes one while main leaves them alone.
let (mut branch, mut main) = fork();
branch.get_mut(r).unwrap().interactions.remove(0);
main.get_mut(r).unwrap().fills[0] = red();
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [200]));
// The same interaction changed both ways, or one deleted while the other side changed the
// list, conflicts.
for delete in [false, true] {
let (mut branch, mut main) = fork();
if delete {
branch.get_mut(r).unwrap().interactions.remove(0);
} else {
branch.get_mut(r).unwrap().interactions[1].duration_ms = 220;
}
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
let p = plan(&base, &branch, &main).unwrap();
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
assert_eq!(keys, [format!("{r}/interactions")], "delete: {delete}");
}
}
#[test]
+1 -1
View File
@@ -61,7 +61,7 @@ impl<'de> Deserialize<'de> for Color {
}
fn clamp01(v: f32) -> f32 {
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }
}
impl Default for Color {
+9 -1
View File
@@ -80,7 +80,7 @@ fn blend_names() {
#[test]
fn nan_inputs_are_sanitized() {
let c = Color::new(f32::NAN, 2.0, -1.0, f32::INFINITY);
assert_eq!(c.to_rgba8(), [0, 255, 0, 0]);
assert_eq!(c.to_rgba8(), [0, 255, 0, 255]);
}
#[test]
@@ -106,3 +106,11 @@ fn crop_defaults_to_fill_and_roundtrips() {
assert!((a - b).abs() < 1e-9);
}
}
#[test]
fn infinite_channels_clamp() {
let c = Color::parse_css("rgb(1e40, 0, 0)").unwrap();
assert_eq!(c.r, 1.0);
let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY);
assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0));
}
+28 -10
View File
@@ -949,21 +949,39 @@ impl Document {
self.styles.iter().find(|s| s.id == id || s.name == id)
}
/// The effective variable modes for a node (walking up its ancestors).
/// The effective variable modes for a node: the ones it sets over [`Self::inherited_modes`].
pub fn modes_for(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
let mut out = BTreeMap::new();
let mut chain = vec![id];
chain.extend(self.ancestors(id));
for c in chain.into_iter().rev() {
if let Some(n) = self.node(c) {
for (k, v) in &n.var_modes {
out.insert(k.clone(), v.clone());
}
}
let mut out = self.inherited_modes(id);
if let Some(n) = self.node(id) {
out.extend(n.var_modes.iter().map(|(k, v)| (k.clone(), v.clone())));
}
out
}
/// The modes a node is on where it sets none (Auto): its ancestors', and for an instance the
/// ones its main component sets, which are closer than any container's.
pub fn inherited_modes(&self, id: NodeId) -> BTreeMap<CollectionId, ModeId> {
let mut out = BTreeMap::new();
for c in self.ancestors(id).into_iter().rev() {
if let Some(n) = self.node(c) {
out.extend(self.main_modes(n).chain(&n.var_modes).map(|(k, v)| (k.clone(), v.clone())));
}
}
if let Some(n) = self.node(id) {
out.extend(self.main_modes(n).map(|(k, v)| (k.clone(), v.clone())));
}
out
}
/// The modes an instance's main component sets (none for other layers).
fn main_modes<'a>(&'a self, n: &Node) -> impl Iterator<Item = (&'a CollectionId, &'a ModeId)> {
let main = match n.kind {
NodeKind::Instance { component, .. } => self.node(component),
_ => None,
};
main.into_iter().flat_map(|m| &m.var_modes)
}
/// Check structural invariants (used by tests and after loading untrusted files).
pub fn validate(&self) -> Vec<String> {
let mut issues = Vec::new();
+48
View File
@@ -55,3 +55,51 @@ fn reduce_motion_is_a_preference_that_persists() {
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert!(!old.reduce_motion);
}
#[test]
fn interface_size_is_relative_to_the_system_and_persists() {
let mut s = Session::new();
assert_eq!(s.prefs.ui_scale, 1.0, "the system's own size by default");
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.0);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1.25})).unwrap()["scale"], 1.25);
assert_eq!(s.prefs.ui_scale, 1.25);
// Reading changes nothing; "auto" (older scripts) is the system's own size.
assert_eq!(s.execute("pref.uiScale", &json!({})).unwrap()["scale"], 1.25);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": "Auto"})).unwrap()["scale"], 1.0);
// Nothing to undo: the document didn't change.
assert!(!s.ds().map(|d| d.is_dirty()).unwrap_or(false));
// Saved with the other preferences and read back.
s.prefs.ui_scale = 0.9;
let v = serde_json::to_value(&s.prefs).unwrap();
assert_eq!(v["ui_scale"], 0.9);
let back: crate::Prefs = serde_json::from_value(v).unwrap();
assert_eq!(back.ui_scale, 0.9);
}
#[test]
fn interface_size_stops_at_the_ends_and_junk_never_changes_it() {
let mut s = Session::new();
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 0.01})).unwrap()["scale"], crate::UI_SCALE_MIN);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": -4})).unwrap()["scale"], crate::UI_SCALE_MIN);
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 1e308})).unwrap()["scale"], crate::UI_SCALE_MAX);
// The size is a factor (1.25), not a percentage: 125 is far past the end and stops there.
assert_eq!(s.execute("pref.uiScale", &json!({"scale": 125})).unwrap()["scale"], crate::UI_SCALE_MAX);
s.prefs.ui_scale = 1.5;
for junk in [json!({"scale": "NaN"}), json!({"scale": "big"}), json!({"scale": [1]}), json!({"scale": {"x": 1}})] {
assert!(s.execute("pref.uiScale", &junk).is_err(), "{junk}");
assert_eq!(s.prefs.ui_scale, 1.5, "{junk}");
}
}
#[test]
fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_size_and_keeps_the_rest() {
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert_eq!(old.ui_scale, 1.0);
for bad in [json!("big"), json!(null), json!([2]), json!(true)] {
let p: crate::Prefs = serde_json::from_value(json!({"theme": "dark", "ui_scale": bad})).unwrap();
assert_eq!((p.ui_scale, p.theme.as_str()), (1.0, "dark"), "{bad}");
}
// A number out of range is clamped rather than refused.
let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap();
assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark"));
}
+75 -1
View File
@@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() {
let fills = s.doc().unwrap().node(r).unwrap().fills.clone();
assert_eq!(fills.len(), 2, "the second fill stays");
assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) });
assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity");
assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included");
assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) });
// Removing the bound (first) fill takes the binding with it.
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
@@ -97,3 +97,77 @@ fn effect_moves_and_removals_reject_junk() {
ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7}));
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
}
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
}
fn brand_id(s: &Session) -> String {
s.doc().unwrap().variables.get("brand").unwrap().id.clone()
}
#[test]
fn explicit_paint_edits_detach_the_colour_binding() {
// An edit that reported success keeps its colour: the binding it overwrote lets go (as
// selection colours already do), instead of putting the variable's colour back.
let edits = [
("fill.set", json!({"color": "#00FF00"})),
("node.set", json!({"props": {"fill": "#00FF00"}})),
("node.set", json!({"props": {"fills": ["#00FF00"]}})),
];
for (cmd, mut p) in edits {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
p["ids"] = json!([r]);
ex(&mut s, cmd, p.clone());
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}");
assert!(bound(&s, r).is_empty(), "{cmd} {p}");
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable");
}
for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] {
let (mut s, r) = setup();
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"}));
p["ids"] = json!([r]);
ex(&mut s, cmd, p);
let n = s.doc().unwrap().node(r).unwrap();
assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}");
assert!(n.bound.is_empty(), "{cmd}");
}
}
#[test]
fn clearing_bound_fills_adds_no_paint_back() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}}));
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
assert!(bound(&s, r).is_empty());
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back");
}
#[test]
fn paint_opacity_edits_keep_the_colour_binding() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5}));
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"}));
assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable");
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5);
// The same colour with another opacity, through node.set, keeps it too.
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}}));
assert_eq!(bound(&s, r).len(), 1);
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25);
}
#[test]
fn paints_set_with_their_binding_in_the_same_call_keep_it() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
let brand = brand_id(&s);
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}}));
assert_eq!(bound(&s, r), [("fill".to_string(), brand)]);
assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies");
}
+1 -1
View File
@@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result
}
return Ok(json!({"ids": ids, "operation": op}));
}
let out = crate::cmd::structure::wrap_boolean(s, p, op)?;
let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?;
// Operands of a new group follow its operation.
if let Some(g) = crate::params::id_of(&out["id"]) {
let doc = s.doc_mut()?;
+131
View File
@@ -0,0 +1,131 @@
//! A colour variable's alpha applies once to the paint it is bound to (#802): a `#RRGGBBAA` fill keeps
//! its alpha in the paint opacity, and binding a variable with alpha hands the variable the whole
//! colour, so the two alphas don't multiply (canvas, SVG, `.fig` round trips, mode switches).
use figcraft_doc::NodeId;
use serde_json::json;
use crate::Session;
/// 0x80 / 255 and 0x40 / 255.
const HALF: f32 = 128.0 / 255.0;
const QUARTER: f32 = 64.0 / 255.0;
fn ex(s: &mut Session, cmd: &str, p: serde_json::Value) {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
}
/// A rectangle filled `#0D6EFD80` and a colour variable `tint` (Light `light`, Dark `dark`).
fn setup(light: &str, dark: &str) -> (Session, NodeId) {
let mut s = Session::new();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#0D6EFD80"}})).unwrap();
let r = NodeId(r["id"].as_u64().unwrap());
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "tint", "type": "color", "value": light}));
ex(&mut s, "variable.set", json!({"variable": "tint", "mode": "Dark", "value": dark}));
(s, r)
}
/// The fill's effective alpha (colour alpha times paint opacity).
fn alpha(s: &Session, r: NodeId) -> f32 {
s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().a
}
/// The alpha byte of the rectangle rendered on its own (transparent backdrop).
fn pixel_alpha(s: &Session, r: NodeId) -> u8 {
let pix = figcraft_render::render_node(s.doc().unwrap(), r, 1.0).unwrap();
let i = ((5 * pix.width() + 5) * 4 + 3) as usize;
pix.data()[i]
}
fn near(a: f32, b: f32) -> bool {
(a - b).abs() < 2e-3
}
#[test]
fn a_hex_with_alpha_keeps_its_alpha_in_the_opacity() {
let (s, r) = setup("#0D6EFD80", "#0D6EFD40");
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
assert!(near(p.opacity, HALF));
assert_eq!(pixel_alpha(&s, r), 128);
}
#[test]
fn a_bound_variable_alpha_applies_once() {
// The fill and the variable are both #0D6EFD80: the result is 50%, not 25%.
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
assert_eq!(pixel_alpha(&s, r), 128);
let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[r], &figcraft_svg::SvgOptions::default()).unwrap();
assert!(svg.contains("fill=\"#0D6EFD\" fill-opacity=\"0.502\""), "{svg}");
// Binding the same variable again changes nothing.
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert!(near(alpha(&s, r), HALF));
}
#[test]
fn unbinding_keeps_the_colour_with_its_alpha_in_the_opacity() {
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
assert_eq!(pixel_alpha(&s, r), 64);
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
assert!(near(p.opacity, QUARTER), "{}", p.opacity);
assert_eq!(pixel_alpha(&s, r), 64);
// Undo restores the binding and its paint.
ex(&mut s, "edit.undo", json!({}));
assert!(!s.doc().unwrap().node(r).unwrap().bound.is_empty());
assert_eq!(pixel_alpha(&s, r), 64);
}
#[test]
fn modes_switch_the_alpha_without_accumulating() {
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD80");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
for (mode, want) in [("Dark", HALF), ("Light", QUARTER), ("Dark", HALF), ("Light", QUARTER)] {
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": mode}));
assert!(near(alpha(&s, r), want), "{mode}: {}", alpha(&s, r));
}
// An opacity set while bound multiplies on top and survives mode switches.
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": "Dark"}));
assert!(near(alpha(&s, r), HALF * 0.5));
}
#[test]
fn node_set_and_style_bindings_apply_the_alpha_once() {
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
let var = s.doc().unwrap().variables.get("tint").unwrap().id.clone();
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": var}}}));
assert!(near(alpha(&s, r), HALF));
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": null}}}));
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, HALF));
// A paint style made from a `#RRGGBBAA` colour, bound to the variable.
let st = s.execute("style.create", &json!({"type": "paint", "name": "Tint", "value": "#0D6EFD80"})).unwrap()["id"].clone();
ex(&mut s, "style.bind", json!({"style": st, "field": "fills/0/color", "variable": "tint"}));
ex(&mut s, "style.apply", json!({"ids": [r.0], "style": st}));
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
}
#[test]
fn a_fig_round_trip_keeps_the_alpha_once() {
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
let back = crate::cmd::file::load_fig(&bytes, "x").unwrap().unwrap().doc;
let n = back.nodes().find(|n| !n.bound.is_empty()).unwrap();
let a = n.fills[0].solid_color().unwrap().a;
assert!(near(a, HALF * 0.5), "{a}");
}
#[test]
fn code_gives_a_translucent_stroke_its_alpha_once() {
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
ex(&mut s, "stroke.add", json!({"ids": [r.0], "color": "#FF000080"}));
let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string();
assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}");
}
+51
View File
@@ -27,6 +27,13 @@ pub fn specs() -> Vec<CommandSpec> {
spacing
),
spec!("arrange.tidy", "Tidy up", "{ids?, spacing?}", true, tidy),
spec!(
"arrange.reorder",
"Reorder selection",
"{ids?, source, target, swap?, fromGesture?} (insert at target's visual slot; no hierarchy changes)",
true,
reorder
),
spec!("arrange.packH", "Pack horizontal", "{ids?}", true, |s, p| pack(s, p, true)),
spec!("arrange.packV", "Pack vertical", "{ids?}", true, |s, p| pack(s, p, false)),
spec!("arrange.roundToPixels", "Round to pixels", "{ids?}", true, round_px),
@@ -528,6 +535,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0));
let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0);
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let n = doc.get(i)?;
let (ow, oh) = (n.width, n.height);
@@ -551,6 +559,49 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
}
super::props::restore_worlds(doc, &kept)?;
}
super::styles::detach_edited(doc, &before);
ok()
}
/// Reorder visual slots without rebuilding or reparenting layers.
fn reorder(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "arrange.reorder";
let ids = req_targets(cmd, s, p)?;
let source = req_id(cmd, p, "source")?;
let target = req_id(cmd, p, "target")?;
let ds = s.ds()?;
let base = ds.interaction.as_ref().filter(|_| boolean(p, "fromGesture") == Some(true)).map(|i| i.doc.clone()).unwrap_or_else(|| ds.doc.clone());
let layout =
crate::selection_reorder::ReorderLayout::new(&base, &ids).ok_or_else(|| bad(cmd, "select a regular row, column or complete aligned grid"))?;
let positions = layout
.positions(source, target, boolean(p, "swap") == Some(true))
.ok_or_else(|| bad(cmd, "source and target must belong to the selection"))?;
// Validate every delta before editing; absolute targets always come from the gesture's
// snapshot so crossing several slots and returning to the start does not accumulate edits.
let doc = s.doc()?;
let mut moves = Vec::new();
for (id, at) in positions {
let now = doc.world_bounds(id).ok_or_else(|| bad(cmd, "layer is no longer available"))?;
let (dx, dy) = (at.x - now.x0, at.y - now.y0);
if !dx.is_finite() || !dy.is_finite() {
return Err(bad(cmd, "non-finite reorder result"));
}
if dx.abs() > 1e-9 || dy.abs() > 1e-9 {
let node = doc.get(id)?;
let parent = node.parent.map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY);
let transform = parent.inverse() * Affine::translate((dx, dy)) * parent * node.transform;
if !transform.as_coeffs().iter().all(|v| v.is_finite()) {
return Err(bad(cmd, "non-finite local transform"));
}
moves.push((id, dx, dy));
}
}
if !moves.is_empty() {
let doc = s.doc_mut()?;
for (id, dx, dy) in moves {
move_world(doc, id, dx, dy)?;
}
}
ok()
}
+21 -2
View File
@@ -472,6 +472,7 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
// Own layers lose their link (slot content stays as it is); nested instances stay
// instances of their own mains.
let nested = unlink_layers(doc, i, Some(i));
keep_main_modes(doc, i)?;
let n = doc.get_mut(i)?;
n.kind = NodeKind::Frame;
n.source = None;
@@ -491,6 +492,19 @@ fn detach(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"ids": out}))
}
/// Make the variable modes instance `i` takes from its main its own (it's being detached).
fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> {
let modes = match doc.get(i)?.kind {
NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(),
_ => return Ok(()),
};
let own = &mut doc.get_mut(i)?.var_modes;
for (c, m) in modes {
own.entry(c).or_insert(m);
}
Ok(())
}
fn reset_instance(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.resetInstance";
let ids = req_targets(cmd, s, p)?;
@@ -608,7 +622,7 @@ fn make_slot(s: &mut Session, p: &Value) -> Result<Value> {
let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?;
let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name));
let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null);
add_prop(s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
}
fn remove_slot(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1039,7 +1053,12 @@ fn valid_prop_name(cmd: &str, name: &str) -> Result<String> {
}
fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "component.addProperty";
add_prop_for("component.addProperty", s, p)
}
/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its
/// slot property here).
fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?;
let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?;
let t = req_string(cmd, p, "type")?;
+60 -9
View File
@@ -73,9 +73,13 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| {
create_kind(s, p, "text")
}),
spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind(
s, p, "vector"
)),
spec!(
"vector.create",
"Vector",
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
true,
|s, p| create_kind(s, p, "vector")
),
spec!(
"image.place",
"Place image",
@@ -162,6 +166,37 @@ pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
figcraft_geom::BezPath::from_svg(d).ok()
}
/// How much a vector's path is scaled to take the size asked for: with both sides, each scales
/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on
/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0
/// or less on an axis it has is refused rather than flattening it.
fn vector_scale(width: Option<f64>, height: Option<f64>, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> {
let fit = |asked: Option<f64>, have: f64, axis: &str| match asked {
None => Ok(1.0),
Some(a) if have <= 1e-9 => {
if a == 0.0 {
Ok(1.0)
} else {
Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" }))
}
}
Some(a) if a > 0.0 && (a / have).is_finite() => Ok(a / have),
Some(_) => Err(format!("`{axis}` must be a positive, finite size: 0 or less would flatten {what}")),
};
Ok(match (width, height) {
(Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?),
(Some(_), None) => {
let s = fit(width, bw, "width")?;
(s, s)
}
(None, Some(_)) => {
let s = fit(height, bh, "height")?;
(s, s)
}
(None, None) => (1.0, 1.0),
})
}
pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "node.create";
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
@@ -187,20 +222,32 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
"Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)),
_ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
};
// Vectors: path data or points define the shape and its box.
// Vectors: path data or points define the shape and its box. `width`/`height`, when asked
// for, scale the geometry: both sides independently, one side proportionally.
let mut offset = (0.0, 0.0);
let asked = (num(p, "width"), num(p, "height"));
if let NodeKind::Vector { network } = &mut kind {
if let Some(d) = string(p, "path") {
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
use figcraft_geom::Shape;
let bb = path.bounding_box();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
if sx != 1.0 || sy != 1.0 {
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
}
*network = VectorNetwork::from_path(&path);
if network.segments.is_empty() {
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
}
if boolean(p, "closed") == Some(false) {
network.regions.clear();
}
w = bb.width();
h = bb.height();
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
} else if let Some(Value::Array(pts)) = get(p, "points") {
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
@@ -208,10 +255,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
}
let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default();
let local: Vec<Point> = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area"));
}
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a point set").map_err(|e| bad(cmd, e))?;
let local: Vec<Point> = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect();
*network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false));
w = bb.width();
h = bb.height();
w = bb.width() * sx;
h = bb.height() * sy;
offset = (bb.x0, bb.y0);
}
}
+45 -11
View File
@@ -18,13 +18,19 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"edit.duplicate",
"Duplicate",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)}",
"{ids?, dx?, dy? (page offset; default: the offset of the last duplicate when ids are its copies, still selected; else top-level frames to the right with a 100 gap, everything else in place)} a main component's copy is an instance of it (a variant stays a new variant in its set)",
true,
duplicate
),
spec!("edit.copy", "Copy", "{ids?}", false, copy),
spec!("edit.cut", "Cut", "{ids?}", true, cut),
spec!("edit.paste", "Paste", "{parent?, x?, y? (page coordinates of the pasted top-left)}", true, paste),
spec!(
"edit.paste",
"Paste",
"{parent?, x?, y? (page coordinates of the pasted top-left)} a copied main component pastes as an instance of it within its file (cut ones, and pastes into other files, keep the component)",
true,
paste
),
spec!(
"edit.pasteDesignLayers",
"Paste design layers",
@@ -261,7 +267,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
let i = req_index("effect.set", p, "index")?;
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
for id in req_targets("effect.set", s, p)? {
let n = s.doc_mut()?.get_mut(id)?;
if let Some(slot) = n.effects.get_mut(i) {
@@ -466,7 +472,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let top_frame = doc.node(parent).is_some_and(Node::is_page) && n.kind.is_frame_like();
let idx = doc.index_in_parent(i).map(|x| x + 1);
let w = n.width;
let nid = doc.duplicate_into(i, parent, idx)?;
let nid = crate::components::copy_in_file(doc, i, parent, idx)?;
let (dx, dy) = match (num(p, "dx"), num(p, "dy")) {
(Some(dx), dy) => (dx, dy.unwrap_or(0.0)),
(None, Some(dy)) => (0.0, dy),
@@ -481,7 +487,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
nn.transform = Affine::translate((dx, dy)) * nn.transform;
}
}
if rename {
// An instance keeps the name instances get by default.
if rename && !matches!(doc.get(nid)?.kind, NodeKind::Instance { .. }) {
let nn = doc.get_mut(nid)?;
nn.name = bump_name(&nn.name);
}
@@ -515,6 +522,13 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<(
s.clipboard.source = Some(source);
s.clipboard.source_path = source_path;
s.clipboard.source_doc = uid;
s.clipboard.copied_components = nodes
.iter()
.filter(|(_, sub)| {
cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))
})
.map(|(root, _)| *root)
.collect();
s.clipboard.nodes = nodes;
s.clipboard.images = images;
s.clipboard.bounds = bounds;
@@ -593,7 +607,8 @@ 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 };
s.clipboard =
crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() };
let mut out = paste(s, p)?;
if !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
@@ -623,6 +638,7 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
}
// From another file: its instances follow the mains this file can have.
let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc));
let copy_local = s.ds()?.uid == clip.source_doc;
let doc = s.doc_mut()?;
for (k, v) in &clip.images {
doc.images.entry(k.clone()).or_insert_with(|| v.clone());
@@ -636,6 +652,16 @@ pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Re
let mut out = Vec::new();
let mut all = BTreeMap::new();
for (root, nodes) in &clip.nodes {
if copy_local
&& clip.copied_components.contains(root)
&& let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. }))
&& doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))
{
let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?;
doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform;
out.push(instance);
continue;
}
let mut map = BTreeMap::new();
for n in nodes {
map.insert(n.id, doc.alloc_id());
@@ -661,10 +687,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
// else in the frame's centre; otherwise in place on the page (or the viewport centre).
let sel = s.selection();
let doc = s.doc()?;
let copy_local = s.ds()?.uid == s.clipboard.source_doc;
let target = id(p, "parent").or_else(|| {
sel.first()
.copied()
.filter(|i| sel.len() == 1 && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. })))
.filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i)))
});
let parent = target.unwrap_or(page);
let at = match (num(p, "x"), num(p, "y")) {
@@ -931,9 +959,13 @@ pub fn opacity_from_digits(digits: &str) -> Option<f32> {
fn opacity_keys(s: &mut Session, p: &Value) -> Result<Value> {
let digits = req_string("object.opacityKeys", p, "digits")?;
let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?;
for i in req_targets("object.opacityKeys", s, p)? {
s.doc_mut()?.get_mut(i)?.opacity = o;
let ids = req_targets("object.opacityKeys", s, p)?;
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
doc.get_mut(i)?.opacity = o;
}
super::styles::detach_edited(doc, &before);
Ok(json!({"opacity": o}))
}
@@ -1181,10 +1213,12 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
}
}
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let props = if skipped.contains(&i) { &without_layout } else { &props };
super::props::apply("node.set", doc, i, props)?;
}
super::styles::detach_edited(doc, &before);
if let Some(o) = pivot {
for (i, q) in pinned {
let d = o - doc.world_transform(i) * q;
@@ -1342,7 +1376,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
if let Some(b) = blend(p, "blend") {
slot.blend = b;
}
super::lists::remap_stop_bindings(n, fills, at, &old);
super::lists::rebind_edited_paint(n, fills, at, &old);
if fills {
n.styles.fill = None;
} else {
@@ -1376,8 +1410,8 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
}
fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
let e: Effect = match get(p, "effect") {
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?,
let e: Effect = match super::props::effect_from("effect.add", p, "effect")? {
Some(e) => e,
None => {
let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into());
Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))?
+40 -3
View File
@@ -92,6 +92,20 @@ pub fn specs() -> Vec<CommandSpec> {
),
spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image),
spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close),
spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| {
let from = req_index("file.moveTab", p, "from")?;
let to = req_index("file.moveTab", p, "to")?;
if from >= s.docs.len() || to >= s.docs.len() {
return Err(bad("file.moveTab", "no such document"));
}
if from != to {
let active_uid = s.docs.get(s.active).map(|d| d.uid);
let doc = s.docs.remove(from);
s.docs.insert(to, doc);
s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0);
}
Ok(json!({"index": to}))
}),
spec!(
"file.recover",
"Recover file",
@@ -548,6 +562,7 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
/// innermost first so outer instances settle last.
fn link_instances(doc: &mut Document) {
super::styles::unfold_imported_bound_alpha(doc);
crate::style_vars::after_load(doc);
super::styles::apply_paint_bindings_on_load(doc);
let mut insts: Vec<(usize, NodeId)> =
@@ -1373,14 +1388,36 @@ fn close(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into()));
}
let uid = ds.uid;
let active_uid = s.docs.get(s.active).map(|d| d.uid);
s.docs.remove(i);
if s.active >= s.docs.len() {
s.active = s.docs.len().saturating_sub(1);
}
// Removing a tab before the active one shifts its index; keep the same document selected.
s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1)));
crate::branches::close_tabs(s, uid, None);
ok()
}
#[cfg(test)]
mod tab_order_tests {
use super::*;
#[test]
fn moving_and_closing_inactive_tabs_preserves_the_active_document() {
let mut session = Session::new();
for name in ["Two", "Three", "Four"] {
session.execute("file.new", &json!({"name": name})).unwrap();
}
session.execute("doc.switch", &json!({"index": 2})).unwrap();
let active_uid = session.docs[session.active].uid;
let moved_uid = session.docs[0].uid;
session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap();
assert_eq!(session.docs[2].uid, moved_uid);
assert_eq!(session.docs[session.active].uid, active_uid);
session.execute("file.close", &json!({"index": 0})).unwrap();
assert_eq!(session.docs[session.active].uid, active_uid);
assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err());
}
}
/// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around.
fn cycle_doc(s: &mut Session, dir: i64) -> Result<Value> {
let n = s.docs.len() as i64;
+29
View File
@@ -149,6 +149,35 @@ mod tests {
assert!(s.execute("node.set", &json!({"id": b, "props": {"absolute": "maybe"}})).is_err());
}
/// Each side key writes its own padding slot and leaves the other three alone.
#[test]
fn each_side_padding_sets_its_side_alone() {
let mut s = Session::new();
let (f, _, _) = al_frame(&mut s);
// One side at a time: the other three stay 0 ([top, right, bottom, left]).
for (key, v, want) in [
("paddingTop", 8.0, [8.0, 0.0, 0.0, 0.0]),
("paddingRight", 12.0, [0.0, 12.0, 0.0, 0.0]),
("paddingBottom", 16.0, [0.0, 0.0, 16.0, 0.0]),
("paddingLeft", 20.0, [0.0, 0.0, 0.0, 20.0]),
] {
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": 0}}));
ex(&mut s, "node.set", json!({"id": f, "props": {key: v}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, want, "{key}");
}
// The [t, r, b, l] array and the axis pairs agree with the four sides.
ex(&mut s, "node.set", json!({"id": f, "props": {"padding": [8, 12, 16, 20]}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 12.0, 16.0, 20.0]);
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingHorizontal": 4}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [8.0, 4.0, 16.0, 4.0]);
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingVertical": 2}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding, [2.0, 4.0, 2.0, 4.0]);
// Negatives clamp to 0; junk is refused.
ex(&mut s, "node.set", json!({"id": f, "props": {"paddingTop": -5}}));
assert_eq!(node(&s, f).layout.as_ref().unwrap().padding[0], 0.0);
assert!(s.execute("node.set", &json!({"id": f, "props": {"paddingLeft": "wide"}})).is_err());
}
#[test]
fn baseline_alignment_lines_up_text() {
let mut s = Session::new();
+40 -3
View File
@@ -112,7 +112,8 @@ pub fn stop_binding(field: &str) -> Option<usize> {
/// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after
/// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's
/// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices.
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
/// Returns the removed stop's index.
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) -> Option<usize> {
use figcraft_color::PaintKind;
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) };
@@ -122,11 +123,11 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
// The first stop that differs is the removed one when the rest moved down unchanged.
let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len());
if a.stops.get(k + 1..) != b.stops.get(k..) {
return;
return None;
}
Some(k)
}
(_, Some(PaintKind::Gradient(_))) => return,
(_, Some(PaintKind::Gradient(_))) => return None,
_ => None,
};
let keys: Vec<(String, Option<String>)> = n
@@ -150,6 +151,42 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
}
}
n.bound.extend(kept);
removed
}
/// After an explicit edit changed paint `index` of `n`'s fills (or strokes) from `old`: its stop
/// bindings follow a removed stop, then each binding whose value the edit changed (the colour, the
/// opacity, a stop's colour) lets go, so the edit stays instead of being put back from the
/// variable. Bindings whose value the edit kept stay; a paint that is gone takes its bindings.
pub(crate) fn rebind_edited_paint(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
let removed = remap_stop_bindings(n, fills, index, old);
let mut old = old.clone();
if let (Some(k), figcraft_color::PaintKind::Gradient(g)) = (removed, &mut old.kind)
&& k < g.stops.len()
{
g.stops.remove(k);
}
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }.cloned();
n.bound.retain(|key, _| match list_binding(key) {
Some((l, i, field)) if l == list && i == index => new.as_ref().is_some_and(|new| paint_field_kept(&old, new, field)),
_ => true,
});
}
/// Whether a paint's bound `field` (`color`, `opacity`, `stops/<j>/color`, …) has the same value in
/// `new` as in `old`.
fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, field: &str) -> bool {
use figcraft_color::PaintKind;
match field {
"color" => matches!((&old.kind, &new.kind), (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) if a == b),
"opacity" => old.opacity == new.opacity,
_ => match (stop_binding(field), &old.kind, &new.kind) {
(Some(j), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(j).map(|s| s.color) == b.stops.get(j).map(|s| s.color),
(Some(_), _, _) => false,
_ => old == new,
},
}
}
/// Where item `k` lands when the item at `from` moves to `to`.
+88 -15
View File
@@ -61,6 +61,48 @@ pub fn snake(v: &Value) -> Value {
}
}
/// `v` as an effect list, refusing keys an effect doesn't read: serde dropped them without a word,
/// so a shadow given `offsetY` kept the default offset.
pub fn effects_from(cmd: &str, v: &Value) -> Result<Vec<Effect>> {
let effects: Vec<Effect> = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?;
for (i, given) in v.as_array().into_iter().flatten().enumerate() {
refuse_ignored_effect_keys(cmd, &format!("effects[{i}]"), given, |v| serde_json::from_value(snake(v)).ok())?;
}
Ok(effects)
}
/// `p[key]` as one effect, read as serde reads it (its aliases too), refusing keys it doesn't read.
pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result<Option<Effect>> {
let Some(v) = get(p, key) else { return Ok(None) };
let effect: Effect = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?;
refuse_ignored_effect_keys(cmd, &format!("`{key}`"), v, |v| serde_json::from_value(v.clone()).ok())?;
Ok(Some(effect))
}
/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to
/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing.
fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option<Effect>) -> Result<()> {
let Some(fields) = given.as_object() else { return Ok(()) };
let parsed = parse(given);
let ignored: Vec<&str> = fields
.iter()
.filter(|(_, v)| !v.is_null())
.filter(|(k, _)| {
let mut probe = fields.clone();
probe.insert((*k).clone(), Value::Null);
parse(&Value::Object(probe)).is_some_and(|e| Some(e) == parsed)
})
.map(|(k, _)| k.as_str())
.collect();
if ignored.is_empty() {
return Ok(());
}
let takes = parsed.and_then(|e| serde_json::to_value(e).ok()).map(crate::inspect::camel).unwrap_or_default();
let kind = takes.get("type").and_then(Value::as_str).unwrap_or("effect").to_string();
let names: Vec<&str> = takes.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
Err(bad(cmd, format!("{at}: unknown properties: {} (a {kind} takes {})", ignored.join(", "), names.join(", "))))
}
pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
let s = v.as_str()?;
// Accept camelCase, snake/upper-case and spaced variants.
@@ -297,6 +339,12 @@ pub const LAYOUT_KEYS: &[&str] = &[
"gridRowTracks",
];
/// Drop the bindings of auto layout fields (gap, padding…): they go with the auto layout, or
/// re-applying them would bring it back.
pub fn drop_layout_bindings(n: &mut Node) {
n.bound.retain(|k, _| !LAYOUT_KEYS.contains(&k.as_str()));
}
/// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an
/// auto layout: an auto layout key on them is refused, not stored where nothing reads it
/// (`node.set` on several layers skips them instead, see `edit::set`).
@@ -315,18 +363,32 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay
Ok(ensure_layout(n))
}
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
let mut unknown = Vec::new();
// Geometry keys first so rotation applies to the final size.
let order = |k: &str| match k {
/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list
/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`).
fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints);
for (i, p) in old.iter().enumerate() {
super::lists::rebind_edited_paint(n, fills, i, p);
}
}
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
/// bindings last so paints set in the same call don't detach them.
fn apply_order(k: &str) -> u8 {
match k {
"width" | "height" | "w" | "h" => 0,
"rotation" => 2,
"x" | "y" => 3,
"boundVariables" => 4,
_ => 1,
};
}
}
/// Apply a property map to one node. `x`/`y` are relative to the parent.
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
let mut unknown = Vec::new();
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
keys.sort_by_key(|(k, _)| order(k));
keys.sort_by_key(|(k, _)| apply_order(k));
for (k, v) in keys {
let k = k.as_str();
match k {
@@ -398,15 +460,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
doc.get_mut(id)?.blend = b;
}
"fills" => doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?,
"fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
"fill" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
doc.get_mut(id)?.fills = vec![Paint::solid(c)];
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
}
"strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?,
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
"stroke" => {
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
doc.get_mut(id)?.strokes = vec![Paint::solid(c)];
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
}
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
@@ -462,7 +524,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
}
}
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
"effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
"devStatus" => {
@@ -486,8 +548,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
return Err(bad(cmd, "auto layout needs a frame"));
}
match v {
Value::Null => n.layout = None,
Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None,
Value::Null => {
n.layout = None;
drop_layout_bindings(n);
}
Value::String(s) if s.eq_ignore_ascii_case("none") => {
n.layout = None;
drop_layout_bindings(n);
}
Value::String(s) => {
let mode = match s.to_ascii_lowercase().as_str() {
"horizontal" | "row" => LayoutMode::Horizontal,
@@ -775,10 +843,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
for (field, var) in o {
match var.as_str() {
Some(vid) => {
if n.bound.get(field).map(String::as_str) != Some(vid) {
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true);
}
n.bound.insert(field.clone(), vid.to_string());
}
None => {
n.bound.remove(field);
if n.bound.remove(field).is_some() {
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
}
}
}
}
+22 -17
View File
@@ -10,11 +10,13 @@ use super::{CommandSpec, ok};
use crate::params::*;
use crate::{EngineError, Result, Session, bad, spec};
/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout
/// add/remove, move to page and vector path edits.
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)),
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.group", Wrap::Group)),
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.frameSelection", Wrap::Frame)),
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
spec!(
"object.convertToGroup",
@@ -42,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Wrap in new section",
"{ids?, name?} (layers on the page or in a section; the section wraps them with some room)",
true,
|s, p| wrap(s, p, Wrap::Section)
|s, p| wrap(s, p, "object.wrapInSection", Wrap::Section)
),
spec!(
"object.resizeToFit",
@@ -71,7 +73,9 @@ pub fn specs() -> Vec<CommandSpec> {
// Hugging needs auto layout and the children that filled have nothing to fill: both
// become fixed (the frame keeps filling its own auto layout parent).
let doc = s.doc_mut()?;
doc.get_mut(i)?.layout = None;
let n = doc.get_mut(i)?;
n.layout = None;
super::props::drop_layout_bindings(n);
figcraft_layout::coherent_sizing(doc, i);
}
ok()
@@ -128,26 +132,27 @@ const SECTION_PAD: f64 = 40.0;
const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group";
/// Wrap the selection in a new boolean group doing `op`.
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result<Value> {
wrap(s, p, Wrap::Bool(op))
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result<Value> {
wrap(s, p, cmd, Wrap::Bool(op))
}
/// Wrap the selection in a new container at the topmost selected layer's place.
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
let ids = req_targets("object.group", s, p)?;
/// Wrap the selection in a new container at the topmost selected layer's place. Errors name `cmd`,
/// the command that asked.
fn wrap(s: &mut Session, p: &Value, cmd: &str, w: Wrap) -> Result<Value> {
let ids = req_targets(cmd, s, p)?;
let doc = s.doc_mut()?;
let ids = z_sorted(doc, &roots(doc, &ids));
let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?;
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "can't group a page"))?;
// Sections sit on the page or in sections, never in frames or groups.
let board = |doc: &Document, i: NodeId| {
doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
};
if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) {
return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section"));
return Err(bad(cmd, "only layers on the page or in a section can be wrapped in a section"));
}
if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) {
return Err(bad("object.group", "sections can't go in groups or frames"));
return Err(bad(cmd, "sections can't go in groups or frames"));
}
let index = doc.index_in_parent(top).map(|i| i + 1);
let mut bounds = doc.bounds_of(&ids).unwrap_or_default();
@@ -285,7 +290,7 @@ fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
n.grids.clear();
n.guides.clear();
n.styles.grid = None;
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
super::props::drop_layout_bindings(n);
let kids = n.children.clone();
for k in kids {
// Out of the flow there is nothing to fill or to be absolute against.
@@ -338,7 +343,7 @@ fn mask(s: &mut Session, p: &Value) -> Result<Value> {
return ok();
}
// Group, then the bottom-most child is the mask.
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?;
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), "object.mask", Wrap::Group)?;
let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?;
let doc = s.doc_mut()?;
if let Some(first) = doc.get(gid)?.children.first().copied() {
@@ -472,7 +477,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
let f = doc.get_mut(fill_id)?;
f.strokes.clear();
}
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?;
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), "vector.outlineStroke", Wrap::Group)?;
if let Some(g) = id_of(&r["id"]) {
out.push(g);
}
@@ -519,7 +524,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
ids
} else {
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?;
let r = wrap(s, &json!({"ids": ids}), "layout.add", Wrap::Frame)?;
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
let f = s.doc_mut()?.get_mut(fid)?;
f.fills.clear();
+200 -9
View File
@@ -110,16 +110,26 @@ pub fn specs() -> Vec<CommandSpec> {
))
}),
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
let name = req_string("variable.createCollection", p, "name")?;
let doc = s.doc_mut()?;
if doc.variables.collections.iter().any(|c| c.name == name) {
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
let cmd = "variable.createCollection";
let name = req_string(cmd, p, "name")?.trim().to_string();
if name.is_empty() {
return Err(bad(cmd, "name can't be empty"));
}
let id = doc.new_collection_id();
let modes: Vec<String> = match get(p, "modes") {
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(|m| m.trim().to_string())).collect(),
_ => vec!["Mode 1".into()],
};
if modes.is_empty() {
return Err(bad(cmd, "a collection needs at least one mode"));
}
if modes.iter().any(String::is_empty) {
return Err(bad(cmd, "mode names can't be empty"));
}
let doc = s.doc_mut()?;
if doc.variables.collections.iter().any(|c| c.name == name) {
return Err(bad(cmd, format!("a collection named `{name}` already exists")));
}
let id = doc.new_collection_id();
let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect();
doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None });
Ok(json!({"id": id}))
@@ -458,10 +468,19 @@ fn bind_style(s: &mut Session, p: &Value) -> Result<Value> {
match var {
Some((_, kind)) if kind != want => return Err(bad(cmd, format!("`{field}` takes a {} variable, not a {} one", want.id(), kind.id()))),
Some((id, _)) => {
if style.bound.get(&key) != Some(&id)
&& let StyleKind::Paint { paints } = &mut style.kind
{
rebind_paint_alpha(paints, &mut [], &key, true);
}
style.bound.insert(key, id);
}
None => {
style.bound.remove(&key);
if style.bound.remove(&key).is_some()
&& let StyleKind::Paint { paints } = &mut style.kind
{
rebind_paint_alpha(paints, &mut [], &key, false);
}
}
}
// The style takes its default-mode value, and each layer using it its own.
@@ -770,16 +789,41 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
let n = doc.get_mut(i)?;
match &vid {
Some(v) => {
if n.bound.get(&field) != Some(v) {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true);
}
n.bound.insert(field.clone(), v.clone());
}
None => {
n.bound.remove(&field);
if n.bound.remove(&field).is_some() {
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false);
}
}
}
}
ok()
}
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the
/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a
/// typed colour keeps it. An opacity set while bound multiplies on top and stays.
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
use super::lists::{BoundList, list_binding};
let Some((list, i, "color")) = list_binding(field) else { return };
let paints = match list {
BoundList::Fills => fills,
BoundList::Strokes => strokes,
_ => return,
};
if let Some(p) = paints.get_mut(i)
&& let PaintKind::Solid { color } = &mut p.kind
{
p.opacity = if bind { 1.0 } else { p.opacity * color.a };
color.a = 1.0;
}
}
/// `variable.setMode`: choose a collection's mode on frames (the page when none are given).
pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.setMode";
@@ -916,6 +960,147 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
apply_bindings_matching(doc, only, |_| true);
}
/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings).
pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec<std::sync::Arc<Node>> {
ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect()
}
/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit
/// changed drops its binding, which would otherwise write the variable's value back (#809).
/// Compared by value, so keys written unchanged keep their bindings and fields moved along with
/// another (proportions, min/max) lose theirs.
pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc<Node>]) {
for old in before {
let Some(n) = doc.node(old.id) else { continue };
let edited: Vec<String> = n
.bound
.keys()
// A binding the edit itself made (`boundVariables`) stays.
.filter(|f| old.bound.get(*f) == n.bound.get(*f))
.filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING))
.filter(|f| bound_field(old, f) != bound_field(n, f))
.cloned()
.collect();
if edited.is_empty() {
continue;
}
if let Ok(n) = doc.get_mut(old.id) {
for f in edited {
n.bound.remove(&f);
}
}
}
}
/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field).
fn bound_field(n: &Node, field: &str) -> Option<String> {
let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind {
NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::<Vec<_>>().join(",")),
_ => None,
};
let layout = n.layout.as_ref();
let cl = &n.child_layout;
let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied());
Some(match field {
"width" => format!("{}", n.width),
"height" => format!("{}", n.height),
"minWidth" => format!("{:?}", cl.min_w),
"maxWidth" => format!("{:?}", cl.max_w),
"minHeight" => format!("{:?}", cl.min_h),
"maxHeight" => format!("{:?}", cl.max_h),
"opacity" => format!("{}", n.opacity),
"visible" => format!("{}", n.visible),
"gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)),
"crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)),
"padding" => format!("{:?}", layout.map(|l| l.padding)),
"paddingTop" => format!("{:?}", pad(0)),
"paddingRight" => format!("{:?}", pad(1)),
"paddingBottom" => format!("{:?}", pad(2)),
"paddingLeft" => format!("{:?}", pad(3)),
"paddingHorizontal" => format!("{:?}", (pad(1), pad(3))),
"paddingVertical" => format!("{:?}", (pad(0), pad(2))),
"cornerRadius" => format!("{:?}", n.corners.radii),
"topLeftRadius" => format!("{:?}", n.corners.radii.first()),
"topRightRadius" => format!("{:?}", n.corners.radii.get(1)),
"bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)),
"bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)),
"strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)),
"strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides),
"arcStart" | "arcSweep" | "arcInner" => match &n.kind {
NodeKind::Ellipse { start, sweep, inner } => format!(
"{}",
if field == "arcStart" {
start
} else if field == "arcSweep" {
sweep
} else {
inner
}
),
_ => return None,
},
"characters" => match &n.kind {
NodeKind::Text(t) => t.characters.clone(),
_ => return None,
},
"paragraphSpacing" | "paragraphIndent" => match &n.kind {
NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }),
_ => return None,
},
"fontFamily" => text(&|s| s.font_family.clone())?,
"fontStyle" => text(&|s| s.font_style.clone())?,
"fontWeight" => text(&|s| s.weight().to_string())?,
"fontSize" => text(&|s| s.font_size.to_string())?,
"lineHeight" => text(&|s| format!("{:?}", s.line_height))?,
"letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?,
// shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice.
_ => return None,
})
}
/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too,
/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity
/// (styles' paints and layers') before the bindings are first resolved, so it applies once.
pub(crate) fn unfold_imported_bound_alpha(doc: &mut Document) {
use super::lists::{BoundList, list_binding};
fn alphas(vars: &Variables, bound: &BTreeMap<String, VariableId>, modes: &BTreeMap<CollectionId, ModeId>) -> Vec<(BoundList, usize, f32)> {
let found = bound.iter().filter_map(|(field, var)| match (list_binding(field)?, vars.resolve(var, modes)?) {
((list @ (BoundList::Fills | BoundList::Strokes), i, "color"), VariableValue::Color(c)) if c.a > 0.0 && c.a < 1.0 => Some((list, i, c.a)),
_ => None,
});
found.collect()
}
fn unfold(p: Option<&mut Paint>, a: f32) {
if let Some(p) = p
&& matches!(p.kind, PaintKind::Solid { .. })
{
p.opacity = (p.opacity / a).min(1.0);
}
}
let pending = doc.take_dirty();
for i in 0..doc.styles.len() {
let found = doc.styles.get(i).map(|s| alphas(&doc.variables, &s.bound, &BTreeMap::new())).unwrap_or_default();
if let Some(StyleKind::Paint { paints }) = doc.styles.get_mut(i).filter(|_| !found.is_empty()).map(|s| &mut s.kind) {
for (_, j, a) in found {
unfold(paints.get_mut(j), a);
}
}
}
let ids: Vec<NodeId> = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect();
for id in ids {
let found = doc.node(id).map(|n| alphas(&doc.variables, &n.bound, &doc.modes_for(id))).unwrap_or_default();
if found.is_empty() {
continue;
}
let Ok(n) = doc.get_mut(id) else { continue };
for (list, j, a) in found {
unfold(if list == BoundList::Fills { n.fills.get_mut(j) } else { n.strokes.get_mut(j) }, a);
}
}
doc.take_dirty();
doc.mark_dirty(pending);
}
/// Settle imported paint colours before the document is first shown. Other bindings can affect
/// layout or component structure, which must be resolved through the normal edit pipeline.
pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
@@ -930,6 +1115,8 @@ pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
doc.mark_dirty(pending);
}
/// Re-apply the variable bindings whose field passes `include`, on the `only` nodes (every bound
/// node when `None`). Auto layout fields wait while their node has no auto layout.
fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>, include: impl Fn(&str) -> bool) {
use super::lists::{BoundList, list_binding};
if doc.variables.variables.is_empty() {
@@ -944,6 +1131,7 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
let modes = doc.modes_for(id);
let Some(n) = doc.node(id) else { continue };
let binds = n.bound.clone();
let has_layout = n.layout.is_some();
let mut sets = serde_json::Map::new();
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
let mut paint_sets: Vec<(bool, usize, Option<usize>, Color)> = Vec::new();
@@ -953,6 +1141,10 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
if !include(&field) {
continue;
}
// A binding never creates an auto layout: without one it waits for the next.
if !has_layout && super::props::LAYOUT_KEYS.contains(&field.as_str()) {
continue;
}
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
// One list item's value: a paint's colour, an effect's or a layout grid's field.
if let Some((list, i, prop)) = list_binding(&field) {
@@ -1104,7 +1296,6 @@ pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> {
stack.extend(n.children.iter().copied());
for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) {
if let Some(c) = paint.solid_color() {
let c = c.with_alpha(c.a * paint.opacity);
counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1;
}
}
+48 -6
View File
@@ -18,6 +18,7 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
let ds = s.ds_mut()?;
let before = ds.doc.clone();
let doc = ds.doc_mut();
let bound = super::styles::bound_before(doc, &[id]);
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
if a == b {
t.restyle(0, t.char_count(), &f);
@@ -25,19 +26,21 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
t.restyle(a, b, &f);
}
}
super::styles::detach_edited(doc, &bound);
crate::normalize(doc, &before);
return ok();
}
let ids = req_targets("text", s, p)?;
let doc = s.doc_mut()?;
for i in ids {
for d in doc.subtree(i) {
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
let n = t.char_count();
t.restyle(0, n, &f);
}
let ids: Vec<NodeId> = ids.into_iter().flat_map(|i| doc.subtree(i)).collect();
let before = super::styles::bound_before(doc, &ids);
for d in ids {
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
let n = t.char_count();
t.restyle(0, n, &f);
}
}
super::styles::detach_edited(doc, &before);
ok()
}
@@ -348,11 +351,13 @@ pub fn specs() -> Vec<CommandSpec> {
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
let ids = req_targets("text.set", s, p)?;
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let mut m = Map::new();
m.insert("characters".into(), json!(c));
super::props::apply("text.set", doc, i, &m)?;
}
super::styles::detach_edited(doc, &before);
ok()
}),
spec!(
@@ -364,6 +369,7 @@ pub fn specs() -> Vec<CommandSpec> {
let ids = req_targets("text.style", s, p)?;
let m = p.as_object().cloned().unwrap_or_default();
let doc = s.doc_mut()?;
let before = super::styles::bound_before(doc, &ids);
for i in ids {
let n = doc.get_mut(i)?;
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
@@ -377,6 +383,7 @@ pub fn specs() -> Vec<CommandSpec> {
super::props::apply_text_style("text.style", t, a, b, k, v)?;
}
}
super::styles::detach_edited(doc, &before);
ok()
}
),
@@ -474,5 +481,40 @@ pub fn specs() -> Vec<CommandSpec> {
})
}
),
spec!(
"text.rescanFonts",
"Rescan installed fonts",
"{} walks the system font folders again, for fonts installed while the app runs; the open document's auto-sizing text in the families that gained faces (new ones, or styles installed since) fits them (text.refit) → {added: [families that were unknown], refitted: [ids]}",
true,
|s, _| {
let rescan = figcraft_text::rescan_system_fonts();
let refitted = crate::fonts::refit_open(s, None, Some(&rescan.changed));
Ok(json!({"added": rescan.added, "refitted": refitted}))
}
),
spec!(
"text.loadFont",
"Load font file",
"{path | data (base64)} adds a font file (.ttf, .otf, .ttc, .otc; every face of a collection) to this session's fonts without installing it; the open document's auto-sizing text in its families fits it (text.refit) → {families, refitted: [ids]}",
true,
|s, p| {
let cmd = "text.loadFont";
let bytes = if let Some(b64) = string(p, "data") {
use base64::Engine as _;
let b64 = b64.split_once(',').map(|(_, d)| d.to_string()).unwrap_or(b64);
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| bad(cmd, format!("`data`: {e}")))?
} else if let Some(path) = string(p, "path") {
super::file::read_capped(&path, figcraft_text::fontdb::MAX_FONT_FILE)?
} else {
return Err(bad(cmd, "pass `path` or `data` (base64)"));
};
let families = figcraft_text::load_font_bytes(bytes);
if families.is_empty() {
return Err(bad(cmd, "not a font file"));
}
let refitted = crate::fonts::refit_open(s, None, Some(&families));
Ok(json!({"families": families, "refitted": refitted}))
}
),
]
}
+5 -1
View File
@@ -288,7 +288,11 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>)
doc.variables.variables.retain(|v| !gone.contains(&v.id));
let users: Vec<NodeId> = doc.nodes().filter(|n| n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect();
for id in users {
doc.get_mut(id)?.bound.retain(|_, v| !gone.contains(v));
let n = doc.get_mut(id)?;
for (field, _) in n.bound.iter().filter(|(_, v)| gone.contains(*v)) {
super::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
}
n.bound.retain(|_, v| !gone.contains(v));
}
Ok(())
}
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.import",
"Import variables",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
true,
import
),
+40 -1
View File
@@ -370,7 +370,25 @@ pub fn specs() -> Vec<CommandSpec> {
spec!("pref.showDimensions", "Show dimensions on objects", "{on?}", false, |s, p| pref(s, p, |x| &mut x.show_dimensions)),
spec!("pref.numberKeysOpacity", "Use number keys for opacity", "{on?}", false, |s, p| pref(s, p, |x| &mut x.number_keys_opacity)),
spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)),
spec!(
"pref.uiScale",
"Interface size",
"{scale?: a size from 0.75 to 3 relative to the system's display scale, 1 being the system's own size, so 1.25 is 125 %, not 125 (outside the range it stops at the nearest end; \"auto\" is 1; omit: just read it)} → {scale}: how large the whole interface is drawn; the canvas zoom is separate",
false,
|s, p| {
match get(p, "scale") {
None => {}
Some(Value::String(v)) if v.trim().eq_ignore_ascii_case("auto") => s.prefs.ui_scale = 1.0,
Some(_) => {
let scale = req_num("pref.uiScale", p, "scale")?;
s.prefs.ui_scale = crate::clamp_ui_scale(scale).ok_or_else(|| bad("pref.uiScale", "`scale` must be a finite number"))?;
}
}
Ok(json!({"scale": s.prefs.ui_scale}))
}
),
spec!("pref.autoSave", "Save files automatically", "{on?}", false, |s, p| pref(s, p, |x| &mut x.auto_save)),
spec!("pref.startOnHome", "Start on the Home screen", "{on?}", false, |s, p| pref(s, p, |x| &mut x.start_on_home)),
spec!("pref.nudge", "Nudge amount", "{small?, big?}", false, |s, p| {
s.prefs.nudge_small = num(p, "small").unwrap_or(s.prefs.nudge_small).clamp(0.01, 1000.0);
s.prefs.nudge_big = num(p, "big").unwrap_or(s.prefs.nudge_big).clamp(0.01, 10000.0);
@@ -409,7 +427,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"pref.language",
"Interface language",
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ko|zh-Hans|…; else English}",
"{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}",
false,
|s, p| {
let lang = req_string("pref.language", p, "language")?;
@@ -522,3 +540,24 @@ pub fn specs() -> Vec<CommandSpec> {
#[allow(dead_code)]
fn unused(_: NodeId) {}
#[cfg(test)]
mod start_on_home_tests {
use serde_json::json;
use crate::Session;
#[test]
fn start_on_home_is_a_preference_that_persists() {
let mut s = Session::new();
assert!(!s.prefs.start_on_home);
assert_eq!(s.execute("pref.startOnHome", &json!({})).unwrap()["on"], true);
assert!(s.prefs.start_on_home);
// Saved with the other preferences and read back; older settings files without it read as off.
let saved: crate::Prefs = serde_json::from_value(serde_json::to_value(&s.prefs).unwrap()).unwrap();
assert!(saved.start_on_home);
assert_eq!(s.execute("pref.startOnHome", &json!({"on": false})).unwrap()["on"], false);
let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap();
assert!(!old.start_on_home);
}
}
+51 -6
View File
@@ -256,7 +256,26 @@ fn stroke_color(n: &Node) -> Option<Color> {
if !n.has_visible_stroke() {
return None;
}
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color().map(|c| c.with_alpha(c.a * p.opacity)))
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color())
}
/// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border
/// on each side that has a weight.
fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> {
let Some(c) = stroke_color(n) else { return Vec::new() };
let Some(sides) = n.stroke.sides else {
let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t);
return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))];
};
let color = tok(doc, n, &["stroke"], c.to_css(), t);
let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES);
named
.zip(sides)
.filter(|(_, weight)| *weight > 0.0)
.map(|((side, field), weight)| {
(format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t)))
})
.collect()
}
fn drop_shadow(n: &Node) -> Option<&Shadow> {
@@ -452,9 +471,8 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
}
}
if let Some(c) = stroke_color(n) {
let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t);
put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)));
for (key, value) in css_borders(doc, n, t) {
put(&key, value);
}
let shadows: Vec<String> = n
.effects
@@ -858,11 +876,16 @@ pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra
swift_layer(ctx, n, depth, out, extra);
}
/// A layer name safe for a single-line `//` comment: line breaks become spaces.
fn one_line(name: &str) -> String {
name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ")
}
fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
let doc = ctx.doc;
let pad = indent(depth);
if ctx.too_deep(depth) {
out.push_str(&format!("{pad}// {} …\n", n.name));
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
return;
}
// Nested instances are calls; an instance swap parameter is a view.
@@ -1035,7 +1058,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
let doc = ctx.doc;
let pad = indent(depth);
if ctx.too_deep(depth) {
out.push_str(&format!("{pad}// {} …\n", n.name));
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
return;
}
// Nested instances are calls; an instance swap parameter is a composable.
@@ -1168,4 +1191,26 @@ mod tests {
assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
}
#[test]
fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() {
let mut s = Session::new();
let name = "deep\nevil()\r\u{2028}more()";
let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap();
let root = parent;
for _ in 0..16 {
parent =
id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"])
.unwrap();
}
let doc = s.doc().unwrap();
for lang in [CodeLang::SwiftUi, CodeLang::Compose] {
let code = code_for(doc, root, lang);
assert!(code.contains("// deep evil() more() …\n"), "{code}");
for line in code.lines() {
let t = line.trim_start();
assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}");
}
}
}
}
+17
View File
@@ -285,11 +285,28 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId,
root.dev_resources.clear();
// The main's interactions play on its instances in prototypes; the instance's own start empty.
root.interactions.clear();
// So do the variable modes the main sets (see `Document::inherited_modes`).
root.var_modes.clear();
doc.insert(root, parent, index)?;
sync_instance(doc, id);
Ok(id)
}
/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file
/// has one main per component, so a main component's copy is an instance of it; a variant copied
/// into its set stays a new variant, and every other layer is deep-copied.
pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option<usize>) -> Result<NodeId, DocError> {
let n = doc.get(id)?;
let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. }));
if !matches!(n.kind, NodeKind::Component { .. }) || in_set {
return doc.duplicate_into(id, parent, index);
}
let transform = n.transform;
let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?;
doc.get_mut(instance)?.transform = transform;
Ok(instance)
}
/// Which part of the carried overrides to apply.
#[derive(Clone, Copy, PartialEq)]
enum Carry {
@@ -82,6 +82,179 @@ fn text(n: &Node) -> &TextData {
n.kind.text().unwrap()
}
#[test]
fn copying_a_main_component_pastes_a_linked_instance() {
for command in ["edit.paste", "edit.pasteOver", "edit.pasteReplace"] {
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, "select.set", json!({"ids": [k.card]}));
ex(&mut s, "edit.copy", json!({}));
if command != "edit.paste" {
ex(&mut s, "select.set", json!({"ids": [k.inst]}));
}
let before = undo_len(&s);
let pasted = crate::params::id_of(&ex(&mut s, command, json!({}))["ids"][0]).unwrap();
assert_eq!(component_of(&s, pasted), k.card, "{command}");
assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
assert_eq!(node(&s, pasted).source, Some(k.card));
assert_eq!(undo_len(&s), before + 1);
ex(&mut s, "edit.undo", json!({}));
assert!(s.doc().unwrap().node(pasted).is_none());
ex(&mut s, "edit.redo", json!({}));
assert_eq!(component_of(&s, pasted), k.card);
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
assert_eq!(chars(&s, copy_of(&s, pasted, k.title)), "Updated");
healthy(&s);
}
}
#[test]
fn copying_a_variant_pastes_an_instance_but_copying_its_set_keeps_the_components() {
let mut s = Session::new();
let (set, _, hover) = button_set(&mut s);
ex(&mut s, "select.set", json!({"ids": [hover]}));
ex(&mut s, "edit.copy", json!({}));
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
assert_eq!(component_of(&s, pasted), hover);
assert_eq!(node(&s, pasted).name, node(&s, set).name);
let NodeKind::Instance { props, .. } = &node(&s, pasted).kind else { panic!("pasted variant is not an instance") };
assert_eq!(chars(&s, layer(&s, pasted, "Label").unwrap()), "Hover");
assert_eq!(props["Show dot"], true);
assert_eq!(node(&s, pasted).parent, Some(s.page().unwrap()));
ex(&mut s, "select.set", json!({"ids": [set]}));
ex(&mut s, "edit.copy", json!({}));
let copied_set = crate::params::id_of(&ex(&mut s, "edit.paste", json!({}))["ids"][0]).unwrap();
assert!(matches!(node(&s, copied_set).kind, NodeKind::ComponentSet { .. }));
assert_eq!(node(&s, copied_set).parent, Some(s.page().unwrap()));
assert_eq!(kids(&s, copied_set).len(), 2);
for variant in kids(&s, copied_set) {
assert!(matches!(node(&s, variant).kind, NodeKind::Component { .. }));
assert!(!kids(&s, set).contains(&variant));
}
healthy(&s);
}
#[test]
fn cut_components_and_components_copied_to_another_file_remain_components() {
for (copy, undo_cut, new_file) in [("edit.cut", false, false), ("edit.cut", true, false), ("edit.copy", false, true)] {
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, copy, json!({"ids": [k.card]}));
if undo_cut {
ex(&mut s, "edit.undo", json!({}));
}
if new_file {
ex(&mut s, "file.new", json!({}));
}
let page = s.page().unwrap();
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }), "{copy}, undo={undo_cut}, new_file={new_file}");
assert_eq!(kids(&s, pasted).len(), 3);
healthy(&s);
}
}
#[test]
fn copying_a_component_does_not_link_to_an_id_reused_after_undo() {
let mut s = Session::new();
let frame = idv(&ex(&mut s, "frame.create", json!({"width": 120, "height": 60, "name": "Copied main"})));
let main = idv(&ex(&mut s, "component.create", json!({"ids": [frame]})));
ex(&mut s, "edit.copy", json!({"ids": [main]}));
s.undo().unwrap();
s.undo().unwrap();
let other = idv(&ex(&mut s, "frame.create", json!({"width": 300, "height": 200, "name": "Unrelated main"})));
ex(&mut s, "component.create", json!({"ids": [other]}));
assert_eq!(other, main, "undo makes the original id available again");
let page = s.page().unwrap();
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": page}))["ids"][0]).unwrap();
assert_eq!(node(&s, pasted).name, "Copied main");
assert_eq!((node(&s, pasted).width, node(&s, pasted).height), (120.0, 60.0));
assert!(matches!(node(&s, pasted).kind, NodeKind::Component { .. }));
healthy(&s);
}
#[test]
fn pasted_component_instances_keep_the_copied_transform_in_the_target_frame() {
let mut s = Session::new();
let k = kit(&mut s);
ex(&mut s, "node.set", json!({"id": k.card, "props": {"rotation": 30}}));
let original = s.doc().unwrap().world_transform(k.card);
let frame = idv(&ex(&mut s, "frame.create", json!({"x": -100, "y": -100, "width": 1000, "height": 1000})));
ex(&mut s, "edit.copy", json!({"ids": [k.card]}));
let pasted = crate::params::id_of(&ex(&mut s, "edit.paste", json!({"parent": frame}))["ids"][0]).unwrap();
assert_eq!(component_of(&s, pasted), k.card);
for (actual, expected) in s.doc().unwrap().world_transform(pasted).as_coeffs().into_iter().zip(original.as_coeffs()) {
assert!((actual - expected).abs() < 1e-9);
}
assert_eq!(node(&s, pasted).parent, Some(frame));
let before = s.doc().unwrap().clone();
assert!(s.execute("edit.paste", &json!({"parent": k.card})).is_err());
assert_eq!(s.doc().unwrap(), &before, "pasting into the main is refused atomically");
healthy(&s);
}
#[test]
fn duplicating_a_main_component_makes_a_linked_instance() {
for rename in [false, true] {
let mut s = Session::new();
ex(&mut s, "pref.renameDuplicates", json!({"on": rename}));
let k = kit(&mut s);
let rect = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 300, "width": 50, "height": 50, "props": {"name": "Box"}})));
let before = s.doc().unwrap().clone();
let out = ex(&mut s, "edit.duplicate", json!({"ids": [k.card, rect]}));
let ids: Vec<NodeId> = out["ids"].as_array().unwrap().iter().filter_map(crate::params::id_of).collect();
assert_eq!(s.selection(), ids);
let copy = *ids.iter().find(|i| matches!(node(&s, **i).kind, NodeKind::Instance { .. })).expect("the main's copy is an instance");
assert_eq!(component_of(&s, copy), k.card);
assert_eq!(node(&s, copy).name, "Card", "instances keep their default name (rename={rename})");
assert_eq!(node(&s, copy).parent, Some(s.page().unwrap()));
let (b, c) = (s.doc().unwrap().world_bounds(k.card).unwrap(), s.doc().unwrap().world_bounds(copy).unwrap());
assert_eq!((c.x0, c.y0, c.width(), c.height()), (b.x1 + 100.0, b.y0, b.width(), b.height()), "a top-level frame's offset");
let other = *ids.iter().find(|i| **i != copy).unwrap();
assert!(matches!(node(&s, other).kind, NodeKind::Rectangle), "other layers are still copied");
ex(&mut s, "node.set", json!({"id": k.title, "props": {"characters": "Updated"}}));
assert_eq!(chars(&s, copy_of(&s, copy, k.title)), "Updated", "the instance follows its main");
ex(&mut s, "edit.undo", json!({}));
ex(&mut s, "edit.undo", json!({}));
assert_eq!(*s.doc().unwrap(), before, "undo restores exactly");
healthy(&s);
}
}
#[test]
fn alt_dragging_a_main_component_copies_an_instance() {
use crate::tools::{PointerEvent, PointerKind};
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100, "name": "Tile"})));
let main = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
let ev = |kind, x| PointerEvent { alt: true, ..PointerEvent::at(kind, x, 50.0, 1.0) };
s.pointer(&ev(PointerKind::Down, 50.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 150.0)).unwrap();
s.pointer(&ev(PointerKind::Move, 250.0)).unwrap();
s.pointer(&ev(PointerKind::Up, 250.0)).unwrap();
let [copy] = s.selection()[..] else { panic!("one copy selected") };
assert_eq!(component_of(&s, copy), main);
assert_eq!(s.doc().unwrap().world_bounds(copy).unwrap().x0, 200.0);
assert_eq!(s.doc().unwrap().world_bounds(main).unwrap().x0, 0.0, "the main stays");
healthy(&s);
}
#[test]
fn duplicating_a_variant_adds_a_variant_to_its_set() {
let mut s = Session::new();
let (set, _, hover) = button_set(&mut s);
let copy = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [hover]}))["ids"][0]).unwrap();
assert!(matches!(node(&s, copy).kind, NodeKind::Component { .. }));
assert_eq!(node(&s, copy).parent, Some(set));
assert_eq!(kids(&s, set).len(), 3);
// A whole set duplicates as a new set of variants.
let set2 = crate::params::id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [set]}))["ids"][0]).unwrap();
assert!(matches!(node(&s, set2).kind, NodeKind::ComponentSet { .. }));
assert!(kids(&s, set2).iter().all(|v| matches!(node(&s, *v).kind, NodeKind::Component { .. })));
healthy(&s);
}
#[test]
fn overridable_properties_stay_through_main_edits_and_reset() {
// (what, layer, group, override on the instance, the same property edited on the main, read,
@@ -747,3 +747,29 @@ fn alt_dropping_a_library_component_swaps_to_it() {
assert!(doc.node(glyph).unwrap().overrides.is_empty());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_fill_set_on_a_bound_instance_layer_detaches_it_as_an_override() {
// Without unbinding first: the explicit colour detaches the variable on the instance layer only,
// and stays through main edits until the override is reset.
let mut s = Session::new();
let (chip, _) = bound_chip(&mut s);
let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap();
let bg = find_path(s.doc().unwrap(), i, &["Bg"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [bg], "props": {"fill": "#ff00ff"}}));
let n = s.doc().unwrap().node(bg).unwrap().clone();
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF00FF");
assert!(!n.bound.contains_key("fill"));
assert!(n.overrides.contains("fills"));
let main_bg = find_path(s.doc().unwrap(), chip, &["Bg"]).unwrap();
ex(&mut s, "node.set", json!({"ids": [main_bg], "props": {"height": 28}}));
ex(&mut s, "variable.set", json!({"variable": "Chip", "value": "#445566"}));
let doc = s.doc().unwrap();
healthy(doc);
assert_eq!(doc.node(bg).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF");
assert!(doc.node(main_bg).unwrap().bound.contains_key("fill"), "the main keeps its binding");
ex(&mut s, "component.resetOverride", json!({"id": bg, "group": "fills"}));
let n = s.doc().unwrap().node(bg).unwrap();
assert!(n.bound.contains_key("fill"));
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "445566");
}
@@ -194,3 +194,26 @@ fn only_frames_on_the_page_or_in_a_section_become_sections() {
assert!(err.contains("holds sections"), "{err}");
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
}
#[test]
fn wrapping_errors_name_the_command_that_was_run() {
// Grouping, framing, auto layout, masks and booleans share one wrapping step; a refusal names
// the command that asked, not `object.group`.
let mut s = Session::new();
let page = s.page().unwrap();
for cmd in ["object.group", "object.frameSelection", "layout.add", "bool.union"] {
let err = s.execute(cmd, &json!({"ids": [page]})).unwrap_err().to_string();
assert!(err.contains(&format!("`{cmd}`")), "{cmd}: {err}");
}
let r = rect(&mut s, page, 0.0, 0.0);
let err = s.execute("object.mask", &json!({"ids": [page, r]})).unwrap_err().to_string();
assert!(err.contains("`object.mask`"), "{err}");
let section = id(ex(&mut s, "node.create", json!({"type": "section", "x": 300, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "node.set", json!({"ids": [section], "props": {"fills": ["#FFFFFF"], "strokes": ["#000000"], "strokeWeight": 1}}));
let err = s.execute("vector.outlineStroke", &json!({"ids": [section]})).unwrap_err().to_string();
assert!(err.contains("`vector.outlineStroke`"), "{err}");
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100})));
let inner = rect(&mut s, f, 10.0, 10.0);
let err = s.execute("object.wrapInSection", &json!({"ids": [inner, r]})).unwrap_err().to_string();
assert!(err.contains("`object.wrapInSection`"), "{err}");
}
+26
View File
@@ -51,6 +51,32 @@ fn effect_set_edits_noise_texture_and_glass() {
assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "depth": "deep"}})).is_err());
}
/// A key an effect doesn't have is refused, naming what it takes: serde dropped it without a word,
/// so a shadow given `offsetX`/`offsetY` (the names variable bindings use) kept the default offset.
#[test]
fn effects_refuse_keys_they_dont_have() {
let mut s = Session::new();
let r = rect(&mut s);
let shadow = json!({"type": "dropShadow", "color": "#00000040", "offsetX": 0, "offsetY": 1, "radius": 3});
let err = s.execute("node.set", &json!({"ids": [r], "props": {"effects": [shadow]}})).unwrap_err().to_string();
assert!(err.contains("effects[0]") && err.contains("offsetX, offsetY") && err.contains("offset"), "{err}");
assert!(s.doc().unwrap().node(r).unwrap().effects.is_empty(), "nothing stored");
let err = s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radious": 8}})).unwrap_err().to_string();
assert!(err.contains("radious"), "{err}");
s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap();
let err = s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "spred": 2}})).unwrap_err().to_string();
assert!(err.contains("spred"), "{err}");
// Every kind's own keys still go in, camelCase or snake_case, and so does what inspect reports.
for ty in ["innerShadow", "layerBlur", "backgroundBlur", "noise", "texture", "glass"] {
s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap();
}
let reported = crate::inspect::node_json(s.doc().unwrap(), r, 0)["effects"].clone();
s.execute("node.set", &json!({"ids": [r], "props": {"effects": reported}})).unwrap();
let camel = json!({"type": "dropShadow", "offset": [0, 1], "showBehind": true, "visible": true});
let snake = json!({"type": "innerShadow", "offset": [0, 1], "show_behind": false});
s.execute("node.set", &json!({"ids": [r], "props": {"effects": [camel, snake]}})).unwrap();
}
#[test]
fn scaling_scales_grain_and_glass() {
let mut s = Session::new();
+17 -5
View File
@@ -62,7 +62,7 @@ impl Roots {
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
Ok(_) => {}
// Why a path can't be resolved says what exists there: only told for paths inside a root.
Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(_) => {}
}
if roots.is_empty() {
@@ -79,9 +79,10 @@ impl Roots {
}
}
/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one
/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix).
fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and
/// its longest leading part that exists resolved, so links on the way are followed as the roots'
/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it).
fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool {
let Ok(abs) = std::path::absolute(path) else { return false };
let mut lexical = PathBuf::new();
for c in abs.components() {
@@ -93,7 +94,18 @@ fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
c => lexical.push(c),
}
}
roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r)))
let mut rest: Vec<&OsStr> = Vec::new();
let mut at = lexical.as_path();
let resolved = loop {
if let Ok(mut base) = std::fs::canonicalize(at) {
base.extend(rest.iter().rev());
break base;
}
let (Some(name), Some(parent)) = (at.file_name(), at.parent()) else { return false };
rest.push(name);
at = parent;
};
roots.iter().any(|r| resolved.starts_with(r))
}
/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed);
+17
View File
@@ -348,6 +348,10 @@ fn outside_paths_are_refused_alike_whatever_exists_there() {
let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")];
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) {
outside.push(f.p("outside/dangling.txt"));
// Reached through a link in the root, it is outside all the same.
if symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) {
outside.push(f.p("root/escape/dangling.txt"));
}
}
for p in &outside {
let e = refusal(p);
@@ -359,6 +363,19 @@ fn outside_paths_are_refused_alike_whatever_exists_there() {
}
}
#[test]
fn a_root_given_through_a_link_tells_why_paths_in_it_are_refused() {
let f = Fixture::new("linked-root");
if !symlink_dir(&f.0.join("root"), &f.0.join("via")) || !symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
eprintln!("skipped: this system doesn't let tests make symbolic links");
return;
}
// Like `/tmp/work` on macOS, where `/tmp` leads to `/private/tmp`.
let r = Roots::new(&[], &[f.p("via")]).unwrap();
let e = r.check(Path::new(&f.p("via/dangling.txt")), Access::Write).unwrap_err();
assert!(e.contains("leads nowhere"), "{e}");
}
#[test]
fn only_resolved_paths_count_as_the_apps_own_folder() {
let f = Fixture::new("own");
+70 -10
View File
@@ -1,13 +1,15 @@
//! Fonts a document names that this machine doesn't have (#752): text layers, their styled
//! ranges, text styles and the string variables bound to a font family. Text in such a family
//! is drawn in a fallback font, so the user is told which ones are missing.
//! is drawn in a fallback font, so the user is told which ones are missing. Once such a font is
//! there, [`refit`] lets auto-sizing text measure its box in it again.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use figcraft_doc::{Document, NodeKind, StyleKind, VariableValue, Variables};
use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableValue, Variables};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::params::{get, req_targets};
use crate::{Result, Session, spec};
/// A family the document names that no installed or built-in font provides.
@@ -79,20 +81,78 @@ fn string_values(vars: &Variables, id: &str) -> Vec<String> {
out
}
/// Auto-sizing text layers measure their boxes again: a box measured while its family was missing
/// (in a fallback font) follows the real face once that's installed or loaded. Only layers under
/// `under` (every layer: `None`) that use one of `families` (any family: `None`). Returns the
/// layers whose size changed.
pub fn refit(doc: &mut Document, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
let want: Option<BTreeSet<String>> = families.map(|f| f.iter().map(|x| x.trim().to_lowercase()).collect());
let scope: Option<BTreeSet<NodeId>> = under.map(|ids| ids.iter().flat_map(|i| doc.subtree(*i)).collect());
let ids: Vec<NodeId> = doc
.nodes()
.filter(|n| scope.as_ref().is_none_or(|s| s.contains(&n.id)))
.filter(|n| {
let NodeKind::Text(t) = &n.kind else { return false };
let uses = |w: &BTreeSet<String>| {
w.contains(&t.style.font_family.trim().to_lowercase())
|| t.spans().iter().any(|(_, _, st)| w.contains(&st.font_family.trim().to_lowercase()))
};
want.as_ref().is_none_or(uses)
&& figcraft_text::auto_size(n).is_some_and(|(w, h)| (w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6)
})
.map(|n| n.id)
.collect();
if !ids.is_empty() {
let before = doc.clone();
doc.mark_dirty(ids.iter().copied());
crate::normalize(doc, &before);
}
ids
}
/// [`refit`] in the open document, if there is one.
pub(crate) fn refit_open(s: &mut Session, under: Option<&[NodeId]>, families: Option<&[String]>) -> Vec<NodeId> {
match s.doc_mut() {
Ok(doc) => refit(doc, under, families),
Err(_) => Vec::new(),
}
}
/// `text.refit`: the layers `ids` names (an `ids` that isn't a list of layers is refused), else the
/// whole document.
fn refit_text(s: &mut Session, p: &Value) -> Result<Value> {
let under = match get(p, "ids") {
None | Some(Value::Null) => None,
Some(_) => Some(req_targets("text.refit", s, p)?),
};
s.doc()?;
Ok(json!({"refitted": refit_open(s, under.as_deref(), None)}))
}
fn missing_fonts(s: &mut Session, _: &Value) -> Result<Value> {
let families: Vec<Value> = missing(s.doc()?).into_iter().map(|m| json!({"family": m.family, "layers": m.layers})).collect();
s.request_ui("dialog.missingFonts", json!({}));
Ok(json!({"families": families}))
}
/// The font commands: `text.missingFonts` and `text.refit`.
pub fn specs() -> Vec<CommandSpec> {
vec![spec!(
"text.missingFonts",
"Missing fonts",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
false,
missing_fonts
)]
vec![
spec!(
"text.missingFonts",
"Missing fonts",
"{} fonts the open document names that aren't installed (text layers, styled ranges, text styles, font family variables); the app shows them → {families: [{family, layers}]}",
false,
missing_fonts
),
spec!(
"text.refit",
"Fit text to fonts",
"{ids? (these layers and the text in them; default: the whole document)} auto-sizing text measures its box again, e.g. a box measured in a fallback font while its family was missing → {refitted: [ids whose size changed]}",
true,
refit_text
),
]
}
#[cfg(test)]
+89
View File
@@ -124,3 +124,92 @@ fn needs_an_open_document() {
let mut s = Session::empty();
assert!(s.execute("text.missingFonts", &json!({})).is_err());
}
/// Make `id`'s box stale, as if it was measured in another font: `dw` wider than its content.
fn stale(s: &mut Session, id: figcraft_doc::NodeId, dw: f64) -> f64 {
let doc = s.doc_mut().unwrap();
let n = doc.get_mut(id).unwrap();
let want = figcraft_text::auto_size(n).unwrap().0;
n.width = want + dw;
doc.take_dirty();
want
}
/// The width of layer `id`.
fn width(s: &Session, id: figcraft_doc::NodeId) -> f64 {
s.doc().unwrap().node(id).unwrap().width
}
/// The ids a refit reports.
fn refitted(r: &Value) -> Vec<u64> {
r["refitted"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect()
}
/// Stale boxes fit their text again, by family, by layer or everywhere; fixed boxes keep theirs.
#[test]
fn refit_measures_stale_text_boxes_again() {
let mut s = Session::new();
let a = text(&mut s, "FigCraft Refit A");
let b = text(&mut s, "FigCraft Refit B");
let fixed = text(&mut s, "FigCraft Refit A");
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(fixed).unwrap().kind {
t.auto_resize = figcraft_doc::TextAutoResize::None;
}
s.execute("select.none", &json!({})).unwrap();
let want_a = stale(&mut s, a, -30.0);
let want_b = stale(&mut s, b, 25.0);
s.doc_mut().unwrap().get_mut(fixed).unwrap().width = 7.0;
s.doc_mut().unwrap().take_dirty();
// Only the families asked for.
let only_a = super::refit(s.doc_mut().unwrap(), None, Some(&["figcraft refit a".to_string()]));
assert_eq!(only_a, vec![a]);
assert_eq!(width(&s, a), want_a);
assert_eq!(width(&s, b), want_b + 25.0);
// Only the layers asked for, then everything; a fixed box keeps its size.
stale(&mut s, a, 12.0);
assert!(refitted(&s.execute("text.refit", &json!({"ids": [b]})).unwrap()) == vec![b.0]);
assert_eq!((width(&s, a), width(&s, b)), (want_a + 12.0, want_b));
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()) == vec![a.0]);
assert_eq!(width(&s, a), want_a);
assert_eq!(width(&s, fixed), 7.0);
// Nothing stale: nothing changes.
assert!(refitted(&s.execute("text.refit", &json!({})).unwrap()).is_empty());
assert!(s.execute("text.refit", &json!({"ids": ["nope"]})).is_err());
// An `ids` that isn't a list is refused, not taken as the whole document.
stale(&mut s, a, 9.0);
assert!(s.execute("text.refit", &json!({"ids": a.0.to_string()})).is_err());
assert_eq!(width(&s, a), want_a + 9.0);
assert!(Session::empty().execute("text.refit", &json!({})).is_err());
}
/// Text inside an instance is refitted too.
#[test]
fn refit_reaches_text_in_instances() {
let mut s = Session::new();
let t = text(&mut s, "FigCraft Refit C");
let comp = id_of(&s.execute("component.create", &json!({"ids": [t]})).unwrap()["id"]).unwrap();
let inst = id_of(&s.execute("instance.create", &json!({"component": comp, "x": 300, "y": 0})).unwrap()["id"]).unwrap();
let copy = s.doc().unwrap().node(inst).unwrap().children[0];
let want = stale(&mut s, copy, -20.0);
let r = s.execute("text.refit", &json!({"ids": [inst]})).unwrap();
assert_eq!(refitted(&r), vec![copy.0]);
assert_eq!(width(&s, copy), want);
}
/// `text.loadFont` refuses what isn't a font file; `text.rescanFonts` reports what it added.
#[test]
fn fonts_load_from_files_and_rescans_report_new_families() {
let mut s = Session::new();
assert!(s.execute("text.loadFont", &json!({})).is_err());
assert!(s.execute("text.loadFont", &json!({"data": "bm90IGEgZm9udA=="})).is_err(), "not a font file");
assert!(s.execute("text.loadFont", &json!({"data": "%%%"})).is_err());
assert!(s.execute("text.loadFont", &json!({"path": "no-such-dir/x.ttf"})).is_err());
if let Some(f) = figcraft_text::CRAFT_FONTS.first() {
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(f.bytes);
let r = s.execute("text.loadFont", &json!({"data": data})).unwrap();
assert_eq!(r["families"].as_array().map(Vec::len), Some(1));
}
let r = s.execute("text.rescanFonts", &json!({})).unwrap();
assert!(r["added"].is_array());
}
+4
View File
@@ -165,6 +165,10 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
o.insert("operandOperation".into(), json!(op));
}
}
// Layer data written with `node.set {props: {pluginData}}` (pages too).
if !n.plugin_data.is_empty() {
o.insert("pluginData".into(), json!(n.plugin_data));
}
match &n.kind {
NodeKind::Text(t) => {
o.insert("characters".into(), json!(t.characters));
@@ -214,6 +214,26 @@ fn css_for_common_layers() {
}
}
/// Individual stroke sides are borders on those sides only: a header with a divider under it isn't
/// boxed in, and a side bound to a variable names it.
#[test]
fn css_borders_follow_individual_stroke_sides() {
let mut s = Session::new();
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
set(&mut s, &[r], json!({"strokes": ["#000000"], "strokeWeight": 1, "strokeSides": [0, 0, 2, 0]}));
let c = css(&mut s, r);
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
assert!(!c.contains("border:") && !c.contains("border-top") && !c.contains("border-right") && !c.contains("border-left"), "{c}");
let col = ex(&mut s, "variable.createCollection", json!({"name": "Borders"}));
let col = col["id"].as_str().unwrap().to_string();
ex(&mut s, "variable.create", json!({"collection": col, "name": "border/top", "type": "number", "value": 3}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"}));
let c = css(&mut s, r);
assert!(c.contains("border-top: var(--border-top, 3px) solid #000000;"), "{c}");
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
}
#[test]
fn native_code_for_a_simple_layer() {
let mut s = Session::new();
+128
View File
@@ -0,0 +1,128 @@
//! Variable modes on instances: an instance is on the modes its main component sets, under the
//! ones set on the instance itself, so switching variants or changing the main's mode reaches it.
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
use figcraft_doc::NodeId;
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn fill(s: &Session, n: NodeId) -> String {
s.doc().unwrap().node(n).unwrap().fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
}
fn set_mode(s: &mut Session, n: NodeId, mode: Value) {
run(s, "variable.setMode", json!({"ids": [n], "collection": "Tone", "mode": mode}));
}
/// A "Tone" collection whose variable `c` is red in mode A and blue in mode B.
fn tone(s: &mut Session) {
run(s, "variable.createCollection", json!({"name": "Tone", "modes": ["A", "B"]}));
run(s, "variable.create", json!({"collection": "Tone", "name": "c", "type": "color", "values": {"A": "#FF0000", "B": "#0000FF"}}));
}
/// A component filled from `c`, on `mode` when one is given.
fn swatch(s: &mut Session, name: &str, mode: Option<&str>) -> NodeId {
let f = id(run(s, "frame.create", json!({"x": 0, "y": 0, "width": 40, "height": 40, "name": name})));
run(s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "c"}));
if let Some(m) = mode {
set_mode(s, f, json!(m));
}
id(run(s, "component.create", json!({"ids": [f]})))
}
/// A set "Kind" whose variants A and B each set their own Tone mode.
fn kinds(s: &mut Session) -> (NodeId, NodeId) {
tone(s);
let a = swatch(s, "Kind=A", Some("A"));
let b = swatch(s, "Kind=B", Some("B"));
let set = id(run(s, "component.combineVariants", json!({"ids": [a, b]})));
(set, b)
}
fn instance(s: &mut Session, component: NodeId) -> NodeId {
id(run(s, "instance.create", json!({"component": component, "x": 200, "y": 0})))
}
fn switch(s: &mut Session, i: NodeId, kind: &str) {
run(s, "instance.setProperties", json!({"ids": [i], "properties": {"Kind": kind}}));
}
#[test]
fn switching_variants_takes_the_new_variants_mode() {
let mut s = Session::new();
let (set, _) = kinds(&mut s);
let i = instance(&mut s, set);
assert_eq!(fill(&s, i), "FF0000");
switch(&mut s, i, "B");
assert_eq!(fill(&s, i), "0000FF", "variant B sets mode B");
switch(&mut s, i, "A");
assert_eq!(fill(&s, i), "FF0000");
assert!(s.doc().unwrap().node(i).unwrap().var_modes.is_empty(), "the instance sets no mode of its own");
}
#[test]
fn instances_follow_a_mode_their_main_sets_later() {
let mut s = Session::new();
tone(&mut s);
let main = swatch(&mut s, "Chip", None);
let i = instance(&mut s, main);
assert_eq!(fill(&s, i), "FF0000", "no mode set: the collection's default");
set_mode(&mut s, main, json!("B"));
assert_eq!(fill(&s, i), "0000FF");
set_mode(&mut s, main, Value::Null);
assert_eq!(fill(&s, i), "FF0000", "the main back on Auto");
}
#[test]
fn a_mode_set_on_the_instance_wins_and_stays_through_variant_switches() {
let mut s = Session::new();
let (set, _) = kinds(&mut s);
let i = instance(&mut s, set);
set_mode(&mut s, i, json!("B"));
assert_eq!(fill(&s, i), "0000FF");
for kind in ["B", "A"] {
switch(&mut s, i, kind);
assert_eq!(fill(&s, i), "0000FF", "own mode B on variant {kind}");
}
set_mode(&mut s, i, Value::Null);
assert_eq!(fill(&s, i), "FF0000", "back on Auto: variant A's mode");
}
#[test]
fn a_main_mode_is_closer_than_the_frame_around_the_instance() {
let mut s = Session::new();
let (_, b) = kinds(&mut s);
let frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 200, "height": 100})));
set_mode(&mut s, frame, json!("A"));
let i = id(run(&mut s, "instance.create", json!({"component": b, "x": 10, "y": 10, "parent": frame})));
assert_eq!(fill(&s, i), "0000FF", "the main's mode B, not the frame's A");
// Nested: an outer component on mode A holding an instance of B.
let outer_frame = id(run(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 100, "height": 100, "name": "Outer"})));
run(&mut s, "instance.create", json!({"component": b, "x": 0, "y": 0, "parent": outer_frame}));
set_mode(&mut s, outer_frame, json!("A"));
let outer = id(run(&mut s, "component.create", json!({"ids": [outer_frame]})));
let copy = instance(&mut s, outer);
let nested = s.doc().unwrap().node(copy).unwrap().children[0];
assert_eq!(fill(&s, nested), "0000FF");
}
#[test]
fn detaching_keeps_the_mode_it_took_from_its_main() {
let mut s = Session::new();
let (set, b) = kinds(&mut s);
let i = instance(&mut s, set);
switch(&mut s, i, "B");
run(&mut s, "component.detach", json!({"ids": [i]}));
set_mode(&mut s, b, json!("A"));
assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B");
assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1);
}
+69 -4
View File
@@ -49,6 +49,7 @@ pub mod proto_run;
pub mod rename;
pub mod rotation_origin;
pub mod selection_colors;
pub mod selection_reorder;
pub mod selection_spacing;
pub mod shape_handles;
pub mod shapebuilder;
@@ -603,6 +604,23 @@ impl DocState {
}
}
/// The smallest and largest interface size, relative to the system's display scale (1.0 is the
/// system's own size).
pub const UI_SCALE_MIN: f64 = 0.75;
pub const UI_SCALE_MAX: f64 = 3.0;
/// `scale` kept between [`UI_SCALE_MIN`] and [`UI_SCALE_MAX`]; not a finite number: `None`.
pub fn clamp_ui_scale(scale: f64) -> Option<f64> {
scale.is_finite().then(|| scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX))
}
/// Reads the saved interface size. Anything but a number reads as 1 (the system's size) and a
/// number out of range is clamped, so a settings file edited by hand never costs the other
/// preferences.
fn read_ui_scale<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<f64, D::Error> {
Ok(Value::deserialize(d)?.as_f64().and_then(clamp_ui_scale).unwrap_or(1.0))
}
/// App-wide preferences that commands read (nudge amounts, behaviours).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
@@ -630,9 +648,16 @@ pub struct Prefs {
/// Reduce motion: the interface's own animations are off (the system's setting turns them off
/// too). Prototype transitions still play.
pub reduce_motion: bool,
/// How large the whole interface is drawn, relative to the system's display scale: 1.0 is the
/// system's own size, 1.25 a quarter larger (kept between [`UI_SCALE_MIN`] and
/// [`UI_SCALE_MAX`]). The canvas zoom is separate.
#[serde(deserialize_with = "read_ui_scale")]
pub ui_scale: f64,
/// Files that have a path are saved by themselves a few seconds after they change (the app's
/// timer; off: only File ▸ Save writes them).
pub auto_save: bool,
/// The desktop app starts on the Home screen instead of in a new file.
pub start_on_home: bool,
/// `figcraft://` invite links open this app (`collab.registerLinks`); `None`: on for installed
/// builds, off for development builds.
pub invite_links: Option<bool>,
@@ -698,7 +723,9 @@ impl Default for Prefs {
language: "system".into(),
user_name: cmd::comment::DEFAULT_AUTHOR.into(),
reduce_motion: false,
ui_scale: 1.0,
auto_save: false,
start_on_home: false,
invite_links: None,
plugins: Vec::new(),
plugin_settings: BTreeMap::new(),
@@ -735,6 +762,8 @@ pub struct Clipboard {
/// The page-space bounds of the copied selection.
pub bounds: Option<figcraft_geom::Rect>,
pub source_doc: u64,
/// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused).
pub copied_components: std::collections::BTreeSet<NodeId>,
/// The document copied from (pastes into other files follow its mains) and its file path.
pub source: Option<Arc<Document>>,
pub source_path: Option<String>,
@@ -1098,8 +1127,11 @@ impl Session {
let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) };
let mut doc = (*it.doc).clone();
let mut sel = it.selection.clone();
// What the gesture changes on the selected layers is an explicit edit.
let before = cmd::styles::bound_before(&doc, &it.selection);
let res = f(&mut doc, &mut sel);
if res.is_ok() {
cmd::styles::detach_edited(&mut doc, &before);
normalize(&mut doc, &it.doc);
// The same preview again (a handle held still) keeps the document: a new one would
// re-render the canvas for nothing.
@@ -1166,6 +1198,13 @@ impl Session {
}
ds.clean_selection();
ds.revision += 1;
let (uid, doc) = (ds.uid, ds.doc.clone());
if self.clipboard.source_doc == uid {
self.clipboard.copied_components.retain(|id| {
doc.node(*id)
.is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Component { .. } | figcraft_doc::NodeKind::ComponentSet { .. }))
});
}
Ok(true)
}
@@ -1460,21 +1499,35 @@ fn affects_layout(before: &Document, doc: &Document, id: NodeId) -> bool {
}
/// The layers whose variable bindings an edit may have changed: every bound layer when a variable
/// changed, else the touched layers and everything below those that moved, are new or chose
/// another variable mode (modes are inherited).
/// changed, else the touched layers and everything below those that moved, are new, chose
/// another variable mode or became an instance of another main (modes are inherited, an
/// instance's from its main too), and the instances of a main that chose another mode.
fn binding_scope(doc: &Document, before: &Document, touched: &BTreeSet<NodeId>) -> Option<BTreeSet<NodeId>> {
if doc.variables != before.variables {
return None;
}
let main = |n: &Node| match n.kind {
figcraft_doc::NodeKind::Instance { component, .. } => Some(component),
_ => None,
};
let mut out = BTreeSet::new();
for &id in touched {
let Some(n) = doc.node(id) else { continue };
let inherited = before.node(id).is_none_or(|b| b.var_modes != n.var_modes || b.parent != n.parent);
if inherited {
let Some(b) = before.node(id) else {
out.extend(doc.subtree(id));
continue;
};
let modes = b.var_modes != n.var_modes;
if modes || b.parent != n.parent || main(b) != main(n) {
out.extend(doc.subtree(id));
} else {
out.insert(id);
}
if modes && matches!(n.kind, figcraft_doc::NodeKind::Component { .. }) {
for i in components::instances_of(doc, id) {
out.extend(doc.subtree(i));
}
}
}
Some(out)
}
@@ -1554,6 +1607,8 @@ mod binding_lists_tests;
#[cfg(test)]
mod booleans_tests;
#[cfg(test)]
mod bound_alpha_tests;
#[cfg(test)]
mod branch_tests;
#[cfg(test)]
mod branch_versions_tests;
@@ -1650,6 +1705,8 @@ mod import_journal_tests;
#[cfg(test)]
mod inspect_behaviour_tests;
#[cfg(test)]
mod instance_modes_tests;
#[cfg(test)]
mod instance_resizing_tests;
#[cfg(test)]
mod interaction_rollback_tests;
@@ -1718,6 +1775,8 @@ mod pixel_preview_tests;
#[cfg(test)]
mod playground_tests;
#[cfg(test)]
mod plugin_data_tests;
#[cfg(test)]
mod plugin_directory_tests;
#[cfg(test)]
mod plugin_panels_tests;
@@ -1744,6 +1803,8 @@ mod proto_run_tests;
#[cfg(test)]
mod proto_tail_tests;
#[cfg(test)]
mod rest_slot_tests;
#[cfg(test)]
mod retained_walk_tests;
#[cfg(test)]
mod rotated_guides_tests;
@@ -1816,6 +1877,8 @@ mod variables_behaviour_tests;
#[cfg(test)]
mod variables_editor_tests;
#[cfg(test)]
mod variables_import_shapes_tests;
#[cfg(test)]
mod variables_io_tests;
#[cfg(test)]
mod vector_behaviour_tests;
@@ -1832,5 +1895,7 @@ mod view_behaviour_tests;
#[cfg(test)]
mod widgets_tests;
#[cfg(test)]
mod selection_reorder_tests;
#[cfg(test)]
mod selection_spacing_tests;
+5 -1
View File
@@ -81,11 +81,15 @@ pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D97
/// `#RRGGBB` → (r, g, b); `None` for anything else.
pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> {
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.is_ascii())?;
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok());
Some((byte(0)?, byte(2)?, byte(4)?))
}
/// Why a join failed when a message from the host is over this app's size limit (`{mb}`: the
/// limit in MB). The UI translates it.
pub const TOO_LARGE_TO_JOIN: &str = "This file is too large to join here (up to {mb} MB)";
/// The connection to a live session.
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
#[serde(rename_all = "camelCase", tag = "state")]
+8
View File
@@ -507,3 +507,11 @@ fn hosting_commands_check_what_they_are_given() {
assert!(e.contains("join a file"), "{e}");
assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words"));
}
#[test]
fn parse_color_rejects_signs_and_non_hex() {
use crate::live::parse_color;
assert_eq!(parse_color("#+1+2+3"), None);
assert_eq!(parse_color("#-1-2-3"), None);
assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255)));
}
+30
View File
@@ -0,0 +1,30 @@
//! Layer data (`pluginData`) reads back through `node.get` and `document.inspect` (#879): what
//! `node.set` writes on a layer or a page comes back as `{key: value}`, a removed key goes, and a
//! layer without any reports none.
use crate::Session;
use crate::components::tests::{ex, idv};
use serde_json::json;
#[test]
fn node_get_reports_the_layer_data_node_set_wrote() {
let mut s = Session::new();
let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100})));
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
let page = s.ds().unwrap().page;
assert!(ex(&mut s, "node.get", json!({"id": f})).get("pluginData").is_none(), "no data, no field");
ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"com.example.sync": "{\"v\":1}", "n": 2}}}));
ex(&mut s, "node.set", json!({"ids": [page], "props": {"pluginData": {"com.example.sync": "page"}}}));
let got = ex(&mut s, "node.get", json!({"id": f}))["pluginData"].clone();
assert_eq!(got, json!({"com.example.sync": "{\"v\":1}", "n": "2"}), "values read back as the strings stored");
assert_eq!(ex(&mut s, "node.get", json!({"id": page}))["pluginData"], json!({"com.example.sync": "page"}), "pages keep data too");
assert!(ex(&mut s, "node.get", json!({"id": r})).get("pluginData").is_none(), "other layers report none");
// The document tree reports it as well (the page and its children).
let tree = crate::inspect::document(&s, 2, false);
assert_eq!(tree["pages"][0]["pluginData"], json!({"com.example.sync": "page"}));
// A null removes a key; undo brings it back.
ex(&mut s, "node.set", json!({"ids": [f], "props": {"pluginData": {"n": null}}}));
assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"], json!({"com.example.sync": "{\"v\":1}"}));
ex(&mut s, "edit.undo", json!({}));
assert_eq!(ex(&mut s, "node.get", json!({"id": f}))["pluginData"]["n"], "2");
}
+91
View File
@@ -0,0 +1,91 @@
//! Slots opened from REST file JSON: instances keep their own slot content through edits and
//! moves of the mains, and through saving.
use crate::Session;
use crate::components::tests::{ex, healthy, idv};
use crate::components::*;
use base64::Engine as _;
use figcraft_doc::*;
use serde_json::{Value, json};
fn at(x: f64, y: f64) -> Value {
json!([[1, 0, x], [0, 1, y]])
}
fn text(id: &str, s: &str) -> Value {
json!({"id": id, "name": s, "type": "TEXT", "characters": s, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
}
/// A Chip main, a Container main with an empty "Slot", and an instance of Container whose slot
/// holds a text and a chip.
fn file() -> Value {
let slot = |id: &str, kids: Value| json!({"id": id, "name": "Slot", "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": kids});
let guid = |l: u32| json!({"guid": {"sessionID": 5, "localID": l}});
let chip = json!({"id": "2:3", "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20},
"children": [{"id": "I2:3;1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}]});
json!({"name": "Slots", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
{"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
]},
{"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": guid(1)}},
"children": [text("1:4", "Title"), slot("1:5", json!([]))]},
{"id": "2:1", "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(400.0, 0.0), "size": {"x": 300, "y": 160},
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": guid(7)}},
"children": [text("I2:1;1:4", "Title"), slot("I2:1;1:5", json!([text("2:2", "Reports in progress"), chip]))]}
]}]}})
}
fn named(doc: &Document, kind: fn(&NodeKind) -> bool, name: &str) -> NodeId {
doc.nodes().find(|n| n.name == name && kind(&n.kind)).map(|n| n.id).unwrap_or_else(|| panic!("no {name}"))
}
/// The instance's slot and the names of what it holds.
fn slot_content(s: &Session) -> (NodeId, Vec<String>) {
let doc = s.doc().unwrap();
let inst = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Container");
let (slot, _) = slots_of(doc, inst).into_iter().next().expect("the instance has its slot");
let names = doc.node(slot).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect();
(slot, names)
}
#[test]
fn rest_slot_content_survives_main_edits_moves_and_saving() {
let mut s = Session::new();
let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&file()).unwrap());
ex(&mut s, "file.open", json!({"data": data, "name": "slots.json"}));
healthy(&s);
let want = vec!["Reports in progress".to_string(), "Chip".to_string()];
let (slot, names) = slot_content(&s);
assert_eq!(names, want);
let doc = s.doc().unwrap();
assert_eq!(slot_name(doc, slot).as_deref(), Some("Slot"));
assert!(slot_edited(doc, slot), "the instance's own content");
let chip = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Chip");
assert!(!inside_instance(doc, chip), "content stands alone");
// Edit and move both mains (the container's and the one used in the content).
let main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Container");
let chip_main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Chip");
let title = doc.node(main).unwrap().children[0];
ex(&mut s, "node.set", json!({"id": title, "props": {"fill": "#ff0000"}}));
ex(&mut s, "node.set", json!({"id": main, "props": {"width": 320}}));
let holder = idv(&ex(&mut s, "frame.create", json!({"x": -1000, "y": 0, "width": 900, "height": 900, "name": "Design system"})));
ex(&mut s, "node.reparent", json!({"ids": [main, chip_main], "parent": holder}));
healthy(&s);
assert_eq!(slot_content(&s).1, want, "kept across syncs");
// Saving and opening again keeps it.
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
let mut again = Session::new();
ex(&mut again, "file.open", json!({"data": data, "name": "slots.figcraft"}));
healthy(&again);
let (slot, names) = slot_content(&again);
assert_eq!(names, want);
assert!(slot_edited(again.doc().unwrap(), slot));
// Resetting the slot shows the main's (empty) default content.
ex(&mut again, "instance.resetSlot", json!({"id": slot}));
assert!(slot_content(&again).1.is_empty());
}
+7 -34
View File
@@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::cmd::lists::{BoundList, list_binding};
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
use crate::params::*;
use crate::{CommandSpec, Result, Session, bad, spec};
@@ -153,37 +153,6 @@ fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>
Ok(ts)
}
/// Detach variables only where the explicit edit changed their value. In particular, an
/// opacity edit keeps the color binding and a range edit keeps other characters' node bindings.
fn unbind_changed(n: &mut figcraft_doc::Node, t: &PaintTarget, old: &Paint, new: &Paint) {
// Range fills override the node's paints; the node binding still belongs to other chars.
if t.range.is_some() {
return;
}
let list = if t.fills { BoundList::Fills } else { BoundList::Strokes };
n.bound.retain(|key, _| {
let Some((l, index, field)) = list_binding(key) else { return true };
if l != list || index != t.index {
return true;
}
match field {
"color" => match (&old.kind, &new.kind) {
(PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) => a == b,
_ => false,
},
"opacity" => old.opacity == new.opacity,
_ => {
let stop = field.strip_prefix("stops/").and_then(|s| s.strip_suffix("/color")).and_then(|s| s.parse::<usize>().ok());
match (stop, &old.kind, &new.kind) {
(Some(i), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(i).map(|s| s.color) == b.stops.get(i).map(|s| s.color),
(Some(_), _, _) => false,
_ => old == new,
}
}
}
});
}
fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.setPaint";
let targets = checked_targets(s, p, CMD)?;
@@ -197,18 +166,22 @@ fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
let old = slot.clone();
*slot = paint.clone();
let n = s.doc_mut()?.get_mut(target.id)?;
unbind_changed(n, target, &old, &paint);
// Variables detach only where the edit changed their value; range fills override the node's
// paints, whose bindings still belong to the other characters.
if let Some((start, end)) = target.range {
if let NodeKind::Text(t) = &mut n.kind {
t.restyle(start, end, |style| style.fills = Some(paints.clone()));
}
} else if target.fills {
continue;
}
if target.fills {
n.fills = paints;
n.styles.fill = None;
} else {
n.strokes = paints;
n.styles.stroke = None;
}
rebind_edited_paint(n, target.fills, target.index, &old);
}
Ok(json!({"ok": true}))
}
+106
View File
@@ -0,0 +1,106 @@
//! Geometry for reordering a smart selection. Only regular, non-overlapping rows, columns
//! and complete top-left-aligned grids have unambiguous insertion slots.
//! Marking several items and irregular grids are out of scope.
use figcraft_doc::{Document, NodeId};
use figcraft_geom::{Point, Rect};
use crate::selection_spacing::selection_spacing;
#[derive(Clone, Debug)]
pub struct ReorderLayout {
/// Visual order, independent of selection order and layer stacking.
pub slots: Vec<(NodeId, Rect)>,
columns: usize,
rows: usize,
gap_x: f64,
gap_y: f64,
}
impl ReorderLayout {
pub fn new(doc: &Document, ids: &[NodeId]) -> Option<Self> {
let layout = selection_spacing(doc, ids);
let h = layout.horizontal.as_ref();
let v = layout.vertical.as_ref();
let gap = |axis: Option<&crate::selection_spacing::Axis>| match axis {
Some(a) => a.value.filter(|v| *v >= 0.0),
None => Some(0.0),
};
let (gap_x, gap_y) = (gap(h)?, gap(v)?);
let columns = h.map_or(1, |a| a.tracks.len());
let rows = v.map_or(1, |a| a.tracks.len());
if ids.len() < 2 || columns.checked_mul(rows)? != ids.len() {
return None;
}
let mut cells = std::collections::BTreeMap::new();
for id in ids {
let col = match h {
Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?,
None => 0,
};
let row = match v {
Some(a) => a.tracks.iter().position(|t| t.ids.contains(id))?,
None => 0,
};
let b = doc.world_bounds(*id)?;
if columns > 1 && rows > 1 {
let x = h?.tracks.get(col)?.bounds.x0;
let y = v?.tracks.get(row)?.bounds.y0;
if (b.x0 - x).abs() > 0.01 || (b.y0 - y).abs() > 0.01 {
return None;
}
}
if cells.insert((row, col), (*id, b)).is_some() {
return None;
}
}
Some(Self { slots: cells.into_values().collect(), columns, rows, gap_x, gap_y })
}
/// Insertion shifts intervening items; swapping exchanges just two entries. Sizes and
/// original gaps are retained, so unequal-sized layers reflow instead of overlapping.
pub fn positions(&self, source: NodeId, target: NodeId, swap: bool) -> Option<Vec<(NodeId, Point)>> {
let from = self.slots.iter().position(|(id, _)| *id == source)?;
let to = self.slots.iter().position(|(id, _)| *id == target)?;
if from == to {
return Some(self.slots.iter().map(|(id, b)| (*id, Point::new(b.x0, b.y0))).collect());
}
let mut order = self.slots.clone();
if swap {
order.swap(from, to);
} else {
let item = order.remove(from);
order.insert(to, item);
}
let mut widths = vec![0.0_f64; self.columns];
let mut heights = vec![0.0_f64; self.rows];
for (i, (_, b)) in order.iter().enumerate() {
let w = widths.get_mut(i % self.columns)?;
*w = w.max(b.width());
let h = heights.get_mut(i / self.columns)?;
*h = h.max(b.height());
}
let x0 = self.slots.iter().map(|(_, b)| b.x0).fold(f64::INFINITY, f64::min);
let y0 = self.slots.iter().map(|(_, b)| b.y0).fold(f64::INFINITY, f64::min);
let starts = |sizes: &[f64], mut at: f64, gap: f64| {
sizes
.iter()
.map(|size| {
let start = at;
at += size + gap;
start
})
.collect::<Vec<_>>()
};
let xs = starts(&widths, x0, self.gap_x);
let ys = starts(&heights, y0, self.gap_y);
order
.iter()
.enumerate()
.map(|(i, (id, b))| {
let x = if self.columns == 1 { b.x0 } else { *xs.get(i % self.columns)? };
let y = if self.rows == 1 { b.y0 } else { *ys.get(i / self.columns)? };
Some((*id, Point::new(x, y)))
})
.collect()
}
}
@@ -0,0 +1,152 @@
use crate::{Session, selection_reorder::ReorderLayout};
use figcraft_doc::NodeId;
use serde_json::json;
fn rect(s: &mut Session, x: f64, y: f64, w: f64, h: f64, parent: Option<NodeId>) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x":x,"y":y,"width":w,"height":h,"parent":parent})).unwrap()["id"].as_u64().unwrap())
}
fn xy(s: &Session, id: NodeId) -> (f64, f64) {
let b = s.doc().unwrap().world_bounds(id).unwrap();
(b.x0, b.y0)
}
#[test]
fn insertion_reflows_unequal_sizes_with_original_gaps_and_hierarchy() {
for vertical in [false, true] {
let mut s = Session::new();
let mut ids = vec![];
for (at, size) in [(0., 40.), (50., 60.), (120., 20.)] {
ids.push(rect(
&mut s,
if vertical { 0. } else { at },
if vertical { at } else { 0. },
if vertical { 30. } else { size },
if vertical { size } else { 30. },
None,
));
}
let before = s.doc().unwrap().clone();
// Explicit selection order must not determine visual order.
s.execute("arrange.reorder", &json!({"ids":[ids[2],ids[0],ids[1]],"source":ids[0],"target":ids[2]})).unwrap();
for (id, at) in [(ids[1], 0.), (ids[2], 70.), (ids[0], 100.)] {
assert_eq!(xy(&s, id), if vertical { (0., at) } else { (at, 0.) });
let old = before.node(id).unwrap();
let new = s.doc().unwrap().node(id).unwrap();
assert_eq!((new.width, new.height, new.parent), (old.width, old.height, old.parent));
}
assert_eq!(s.doc().unwrap().node(s.ds().unwrap().page).unwrap().children, before.node(s.ds().unwrap().page).unwrap().children);
s.undo().unwrap();
assert_eq!(s.doc().unwrap(), &before);
s.redo().unwrap();
assert_eq!(xy(&s, ids[0]), if vertical { (0., 100.) } else { (100., 0.) });
}
}
#[test]
fn grids_insert_across_rows_and_swap_only_the_two_entries() {
let mut s = Session::new();
let mut ids = vec![];
for row in 0..2 {
for col in 0..3 {
ids.push(rect(&mut s, col as f64 * 70., row as f64 * 60., 50., 40., None));
}
}
let before = s.doc().unwrap().clone();
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4]})).unwrap();
assert_eq!(xy(&s, ids[0]), (70., 60.));
assert_eq!(xy(&s, ids[3]), (140., 0.));
assert_eq!(xy(&s, ids[5]), (140., 60.));
s.undo().unwrap();
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":ids[4],"swap":true})).unwrap();
assert_eq!(xy(&s, ids[0]), (70., 60.));
assert_eq!(xy(&s, ids[4]), (0., 0.));
for id in [ids[1], ids[2], ids[3], ids[5]] {
assert_eq!(s.doc().unwrap().node(id), before.node(id));
}
}
#[test]
fn grid_reflow_keeps_gaps_with_different_sizes() {
let mut s = Session::new();
let a = rect(&mut s, 0., 0., 40., 30., None);
let b = rect(&mut s, 50., 0., 60., 30., None);
let c = rect(&mut s, 0., 50., 40., 50., None);
let d = rect(&mut s, 50., 50., 60., 50., None);
s.execute("arrange.reorder", &json!({"ids":[a,b,c,d],"source":a,"target":d})).unwrap();
assert_eq!(xy(&s, b), (0., 0.));
assert_eq!(xy(&s, c), (70., 0.));
assert_eq!(xy(&s, d), (0., 70.));
assert_eq!(xy(&s, a), (70., 70.));
}
#[test]
fn previews_are_absolute_and_returning_to_start_is_a_noop() {
let mut s = Session::new();
let ids: Vec<_> = (0..3).map(|i| rect(&mut s, i as f64 * 70., 0., 50., 40., None)).collect();
s.execute("select.set", &json!({"ids":ids})).unwrap();
let before = s.doc().unwrap().clone();
let history = s.undo_depth().0;
s.begin_interaction("Reorder selection").unwrap();
for target in [ids[2], ids[1], ids[0]] {
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap();
}
s.commit_interaction().unwrap();
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_depth().0, history);
s.begin_interaction("Reorder selection").unwrap();
for target in [ids[1], ids[2]] {
s.execute("arrange.reorder", &json!({"ids":ids,"source":ids[0],"target":target,"fromGesture":true})).unwrap();
}
s.commit_interaction().unwrap();
assert_eq!(s.undo_depth().0, history + 1);
assert_eq!(s.selection(), ids);
s.undo().unwrap();
assert_eq!(s.doc().unwrap(), &before);
}
#[test]
fn invalid_selections_and_targets_do_not_edit_the_document() {
let mut s = Session::new();
let a = rect(&mut s, 0., 0., 50., 40., None);
let b = rect(&mut s, 70., 0., 50., 40., None);
let c = rect(&mut s, 160., 0., 50., 40., None);
for args in [
json!({"ids":[a,b,c],"source":a,"target":b}), // Mixed gaps.
json!({"ids":[a,b],"source":c,"target":b}),
json!({"ids":[a,b],"source":a,"target":999999}),
json!({"ids":[a,a,b],"source":a,"target":b}),
json!({"ids":[a,b],"source":[],"target":b}),
] {
let before = s.doc().unwrap().clone();
assert!(s.execute("arrange.reorder", &args).is_err());
assert_eq!(s.doc().unwrap(), &before);
}
for props in [json!({"locked":true}), json!({"visible":false})] {
s.execute("node.set", &json!({"id":b,"props":props})).unwrap();
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b]).is_none());
s.execute("node.set", &json!({"id":b,"props":{"locked":false,"visible":true}})).unwrap();
}
let d = rect(&mut s, 0., 70., 50., 40., None);
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, b, d]).is_none(), "an incomplete grid has no slots");
let frame =
NodeId(s.execute("frame.create", &json!({"width":300,"height":100,"props":{"layout":"horizontal"}})).unwrap()["id"].as_u64().unwrap());
let x = rect(&mut s, 0., 0., 50., 40., Some(frame));
let y = rect(&mut s, 70., 0., 50., 40., Some(frame));
assert!(ReorderLayout::new(s.doc().unwrap(), &[x, y]).is_none());
assert!(ReorderLayout::new(s.doc().unwrap(), &[a, x]).is_none());
}
#[test]
fn reordering_children_of_a_rotated_parent_uses_page_coordinates() {
let mut s = Session::new();
let frame = NodeId(
s.execute("frame.create", &json!({"x":200,"y":200,"width":300,"height":100,"props":{"rotation":90}})).unwrap()["id"].as_u64().unwrap(),
);
let a = rect(&mut s, 0., 0., 50., 40., Some(frame));
let b = rect(&mut s, 70., 0., 50., 40., Some(frame));
let (pa, pb) = (xy(&s, a), xy(&s, b));
s.execute("arrange.reorder", &json!({"ids":[a,b],"source":a,"target":b})).unwrap();
let (qa, qb) = (xy(&s, a), xy(&s, b));
assert!((qa.0 - pb.0).abs() < 1e-7 && (qa.1 - pb.1).abs() < 1e-7);
assert!((qb.0 - pa.0).abs() < 1e-7 && (qb.1 - pa.1).abs() < 1e-7);
}
+11
View File
@@ -41,6 +41,17 @@ fn props_of(s: &mut Session, id: NodeId) -> Vec<Value> {
ex(s, "component.properties", json!({"id": id}))["properties"].as_array().cloned().unwrap_or_default()
}
/// A refused slot is reported by `component.makeSlot`, the command that was run, not by the
/// property command it adds its slot property through.
#[test]
fn a_refused_slot_names_make_slot() {
let mut s = Session::new();
let (c, ..) = slot_card(&mut s);
let other = idv(&ex(&mut s, "frame.create", json!({"x": 150, "y": 10, "width": 40, "height": 30, "parent": c, "name": "Other"})));
let err = s.execute("component.makeSlot", &json!({"id": other, "name": "Content"})).unwrap_err().to_string();
assert!(err.contains("`component.makeSlot`") && err.contains("a property named `Content` already exists"), "{err}");
}
#[test]
fn slots_are_frames_of_a_main_with_a_slot_property() {
let mut s = Session::new();
+6
View File
@@ -376,6 +376,7 @@ fn pointer_draw_move_resize() {
assert_eq!(s.selection(), vec![r]);
}
/// No command panics on junk parameters.
#[test]
fn every_command_survives_junk_params() {
// A junk-parameter sweep: no command may panic (guard turns panics into Internal errors,
@@ -418,6 +419,9 @@ fn every_command_survives_junk_params() {
// Selection color slots: malformed indexes, ranges and paints.
json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}),
json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}),
// Smart-selection reorder targets and modes as junk.
json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}),
json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}),
// Rotation origins and placing with a tool as junk.
json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}),
json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}),
@@ -434,6 +438,8 @@ fn every_command_survives_junk_params() {
| "file.placeImage"
| "image.place"
| "file.revert"
// Takes no params and walks the font folders: one walk per junk is slow.
| "text.rescanFonts"
) {
continue;
}
+3 -1
View File
@@ -782,6 +782,7 @@ mod tests {
assert!(r.is_null() || r["axes"].is_array());
}
/// No text command panics on junk parameters while a text is being edited.
#[test]
fn text_commands_survive_junk_while_editing() {
let junk = [
@@ -793,7 +794,8 @@ mod tests {
json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}),
json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}),
];
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.")) {
// `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow.
for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") {
for j in &junk {
let (mut s, id) = setup("Hi there\nyou");
begin(&mut s, id, Some(2), false).unwrap();
+1 -1
View File
@@ -1300,7 +1300,7 @@ impl Session {
for i in &ids {
let parent = doc.get(*i)?.parent.unwrap_or(page);
let idx = doc.index_in_parent(*i).map(|x| x + 1);
copies.push(doc.duplicate_into(*i, parent, idx)?);
copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?);
}
moving = copies;
*sel = moving.clone();
@@ -279,6 +279,34 @@ fn bindings_by_field_and_type() {
}
}
#[test]
fn auto_layout_bindings_go_with_the_auto_layout() {
// Docs silent; pinned (#810): removing a frame's auto layout drops its gap and padding bindings,
// and a binding never brings an auto layout back. One made without auto layout waits for it.
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C"}));
run(&mut s, "variable.create", json!({"collection": "C", "name": "g", "type": "number", "value": 12}));
let removals =
[("layout.remove", json!({})), ("node.set", json!({"props": {"layout": null}})), ("node.set", json!({"props": {"layout": "none"}}))];
for (cmd, mut p) in removals {
let f = frame(&mut s, None);
run(&mut s, "layout.add", json!({"ids": [f]}));
for field in ["gap", "crossGap", "paddingTop"] {
run(&mut s, "variable.bind", json!({"ids": [f], "field": field, "variable": "g"}));
}
p["ids"] = json!([f]);
run(&mut s, cmd, p.clone());
let n = node(&s, f);
assert!(n.layout.is_none(), "{cmd} {p}: auto layout stays removed");
assert!(n.bound.is_empty(), "{cmd} {p}: layout bindings dropped");
}
let f = frame(&mut s, None);
run(&mut s, "variable.bind", json!({"ids": [f], "field": "gap", "variable": "g"}));
assert!(node(&s, f).layout.is_none(), "binding gap adds no auto layout");
run(&mut s, "layout.add", json!({"ids": [f]}));
assert_eq!(node(&s, f).layout.map(|l| l.gap), Some(12.0), "the waiting binding applies to the new auto layout");
}
#[test]
fn deleting_a_bound_variable_keeps_the_last_value() {
// Documented: deleting a variable unbinds what used it, which keeps showing its last value.
@@ -365,3 +393,90 @@ fn number_variables_are_pixels_and_edits_are_one_undo_step() {
s.undo().unwrap();
assert_eq!(lh(&s), LineHeight::Pixels(20.0), "the value and the bound layer go back together");
}
#[test]
fn create_collection_keeps_at_least_one_named_mode() {
// A collection keeps at least one mode (removeMode) and mode names can't be empty (renameMode),
// so createCollection refuses an empty mode list, blank mode names and a blank collection name.
let mut s = Session::new();
for p in [
json!({"name": "C", "modes": []}),
json!({"name": "C", "modes": [1]}),
json!({"name": "C", "modes": ["", " "]}),
json!({"name": "C", "modes": ["A", " "]}),
json!({"name": " "}),
json!({"name": ""}),
] {
assert!(s.execute("variable.createCollection", &p).is_err(), "{p} should be refused");
}
assert!(s.doc().unwrap().variables.collections.is_empty(), "a refused call creates nothing");
run(&mut s, "variable.createCollection", json!({"name": "C", "modes": [" Light ", "Dark"]}));
let names: Vec<String> = s.doc().unwrap().variables.collections[0].modes.iter().map(|m| m.name.clone()).collect();
assert_eq!(names, ["Light", "Dark"]);
}
#[test]
fn explicit_edits_of_bound_fields_stick_and_detach_that_binding() {
// #809: an edit of a bound field detaches its binding (as a paint edit does) instead of
// the variable's value coming back; bindings whose value the edit kept stay.
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C"}));
let w = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "w", "type": "number", "value": 48})));
let o = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "o", "type": "number", "value": 40})));
let r = rect(&mut s, None);
for (field, v) in [("width", &w), ("height", &w), ("opacity", &o)] {
run(&mut s, "variable.bind", json!({"ids": [r], "field": field, "variable": v}));
}
assert_eq!((node(&s, r).width, node(&s, r).opacity), (48.0, 0.4));
run(&mut s, "node.set", json!({"ids": [r], "width": 100, "opacity": 0.4}));
let n = node(&s, r);
assert_eq!(n.width, 100.0);
assert!(!n.bound.contains_key("width"));
assert!(n.bound.contains_key("height") && n.bound.contains_key("opacity"), "unchanged values keep their bindings");
run(&mut s, "transform.resize", json!({"ids": [r], "height": 30}));
assert_eq!(node(&s, r).height, 30.0);
assert!(!node(&s, r).bound.contains_key("height"));
run(&mut s, "object.opacityKeys", json!({"ids": [r], "digits": "9"}));
assert_eq!(node(&s, r).opacity, 0.9);
assert!(node(&s, r).bound.is_empty());
// A value set with its binding in the same call keeps the new binding.
run(&mut s, "node.set", json!({"ids": [r], "width": 5, "boundVariables": {"width": w}}));
assert_eq!((node(&s, r).width, node(&s, r).bound.get("width")), (48.0, Some(&w)));
}
#[test]
fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() {
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "C"}));
let v = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "r", "type": "number", "value": 8})));
let r = rect(&mut s, None);
run(&mut s, "variable.bind", json!({"ids": [r], "field": "cornerRadius", "variable": v}));
s.begin_interaction("Edit").unwrap();
let props = serde_json::Map::from_iter([("cornerRadius".to_string(), json!(2))]);
s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap();
s.commit_interaction().unwrap();
assert_eq!(node(&s, r).corners.radii, [2.0; 4]);
assert!(node(&s, r).bound.is_empty());
let t = id(run(&mut s, "text.create", json!({"x": 0, "y": 0, "text": "Hi"})));
run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v}));
run(&mut s, "text.style", json!({"id": t, "fontSize": 20}));
assert!(node(&s, t).bound.is_empty());
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 20.0);
// The font size/weight/tracking shortcuts and text content edits detach too.
run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v}));
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 8.0);
run(&mut s, "text.sizeUp", json!({"ids": [t]}));
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 9.0);
assert!(node(&s, t).bound.is_empty());
let c = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "c", "type": "string", "value": "Hello"})));
run(&mut s, "variable.bind", json!({"ids": [t], "field": "characters", "variable": c}));
run(&mut s, "text.set", json!({"id": t, "characters": "Bye"}));
assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye");
assert!(node(&s, t).bound.is_empty());
}
@@ -0,0 +1,60 @@
//! `variable.import` reads `variables` next to `variableCollections`, under `meta` or at the top
//! level, and never imports nothing without saying so (#790).
use serde_json::{Value, json};
use crate::Session;
fn collections() -> Value {
json!({"VC:1": {"id": "VC:1", "name": "Theme", "modes": [{"modeId": "1:0", "name": "Light"}], "defaultModeId": "1:0",
"variableIds": ["VariableID:1"]}})
}
fn variables() -> Value {
json!({"VariableID:1": {"id": "VariableID:1", "name": "bg", "variableCollectionId": "VC:1", "resolvedType": "COLOR",
"valuesByMode": {"1:0": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}}}})
}
fn import(json: Value) -> Value {
let mut s = Session::new();
match s.execute("variable.import", &json!({"variables": json})) {
Ok(r) => r,
Err(e) => panic!("{e}"),
}
}
#[test]
fn variables_import_reads_every_shape() {
for shape in [
json!({"meta": {"variableCollections": collections(), "variables": variables()}}),
json!({"variableCollections": collections(), "variables": variables()}),
json!({"meta": {"variableCollections": collections()}, "variables": variables()}),
json!({"variableCollections": collections(), "meta": {"variables": variables()}}),
] {
let r = import(shape.clone());
assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(1)), "{shape}: {r}");
assert_eq!(r["warnings"], json!([]), "{shape}: {r}");
}
}
#[test]
fn variables_import_never_imports_nothing_silently() {
// Collections without variables: they are added, with a warning saying where variables go.
let r = import(json!({"meta": {"variableCollections": collections()}}));
assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(0)), "{r}");
assert!(r["warnings"].to_string().contains("no variables found: expected `meta.variables`"), "{r}");
// An empty file's export reads back with the same warning, not an error.
let mut s = Session::new();
let text = s.execute("variable.export", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
let r = s.execute("variable.import", &json!({"variables": text})).unwrap();
assert!(r["warnings"].to_string().contains("no variables found"), "{r}");
// Collections listing variables that aren't there.
let mut colls = collections();
colls["VC:1"]["variableIds"] = json!(["VariableID:1", "VariableID:2"]);
let r = import(json!({"meta": {"variableCollections": colls, "variables": variables()}}));
assert_eq!(r["variables"].as_u64(), Some(1), "{r}");
assert!(r["warnings"].to_string().contains("1 of the 2 variables the collections list in `variableIds` weren't imported"), "{r}");
// No collections anywhere: an error naming the expected shape.
let e = Session::new().execute("variable.import", &json!({"variables": {"variables": variables()}})).unwrap_err().to_string();
assert!(e.contains("(`meta.variableCollections` and `meta.variables`)"), "{e}");
}
+26
View File
@@ -371,3 +371,29 @@ fn variant_properties_follow_string_variables() {
assert_eq!(main(&s), variants[1]);
healthy(&s);
}
#[test]
fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() {
let mut s = Session::new();
let r = gradient_rect(&mut s);
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/2/color", "variable": "brand"}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/1/color", "variable": "ink"}));
let cur = |s: &Session| s.doc().unwrap().node(r).unwrap().fills[0].clone();
// Removing the first stop moves both bindings down with their stops.
let mut p = cur(&s);
if let PaintKind::Gradient(g) = &mut p.kind {
g.stops.remove(0);
}
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
assert_eq!(bound_keys(&s, r), ["fills/0/stops/0/color", "fills/0/stops/1/color"]);
// Recolouring one bound stop detaches that stop only.
let mut p = cur(&s);
if let PaintKind::Gradient(g) = &mut p.kind {
g.stops[0].color = Color::WHITE;
}
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
assert_eq!(bound_keys(&s, r), ["fills/0/stops/1/color"]);
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000ff"}));
assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]);
healthy(&s);
}
@@ -439,3 +439,61 @@ fn bend_paint_bucket_cut_join_and_eraser_tools() {
assert_eq!(net(&s, id).segments.len(), 2, "the eraser removes what it passes over");
assert_eq!(s.doc().unwrap().node(page).unwrap().children, vec![id], "and the pieces stay one layer");
}
#[test]
fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() {
let mut s = Session::new();
let path = "M0 0 L24 0 L24 24 L0 24 Z";
let intrinsic = id_of(&ex(&mut s, "vector.create", json!({"path": path}))["id"]).unwrap();
let n = s.doc().unwrap().node(intrinsic).unwrap();
assert_eq!((n.width, n.height), (24.0, 24.0), "a path without a size keeps its intrinsic box");
// Both sides: each scales independently, like resizing a vector.
let both = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16, "height": 8}))["id"]).unwrap();
let n = s.doc().unwrap().node(both).unwrap();
assert_eq!((n.width, n.height), (16.0, 8.0));
let n = net(&s, both);
let max = n.vertices.iter().fold((0.0f64, 0.0f64), |(x, y), v| (x.max(v.x), y.max(v.y)));
assert!((max.0 - 16.0).abs() < 1e-9 && (max.1 - 8.0).abs() < 1e-9, "{max:?}: the geometry itself is scaled");
// One side: the other keeps its proportion.
let width = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16}))["id"]).unwrap();
let n = s.doc().unwrap().node(width).unwrap();
assert_eq!((n.width, n.height), (16.0, 16.0));
// A path or point set that draws nothing is an error, not an invisible zero-size layer.
let e = s.execute("vector.create", &json!({"path": "M5 5"})).unwrap_err().to_string();
assert!(e.contains("draws nothing"), "{e}");
let e = s.execute("vector.create", &json!({"points": [[5, 5], [5, 5]]})).unwrap_err().to_string();
assert!(e.contains("draws nothing"), "{e}");
// A size on an axis the geometry has none of is refused, not silently ignored.
let e = s.execute("vector.create", &json!({"path": "M0 0 L0 24", "width": 16})).unwrap_err().to_string();
assert!(e.contains("horizontal extent"), "{e}");
let e = s.execute("vector.create", &json!({"path": "M0 0 L24 0", "height": 16})).unwrap_err().to_string();
assert!(e.contains("vertical extent"), "{e}");
// But 0 on an axis it has none of is what it already is: a horizontal line with height 0.
let line = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L24 0", "width": 16, "height": 0}))["id"]).unwrap();
let n = s.doc().unwrap().node(line).unwrap();
assert_eq!((n.width, n.height), (16.0, 0.0));
// A size of 0 or less on an axis it has would flatten it, so it is refused, alone or with the other.
for p in [json!({"path": path, "width": 0}), json!({"path": path, "width": 16, "height": 0}), json!({"path": path, "width": -4, "height": 8})] {
let e = s.execute("vector.create", &p).unwrap_err().to_string();
assert!(e.contains("positive"), "{p}: {e}");
}
}
#[test]
fn vector_points_take_the_requested_size() {
let mut s = Session::new();
let points = json!([[10, 20], [34, 20], [34, 32]]);
let corners = |s: &Session, id: NodeId| net(s, id).vertices.iter().map(|v| (v.x, v.y)).collect::<Vec<_>>();
// Both sides: each axis scales on its own; the vertices move with the box.
let both = id_of(&ex(&mut s, "vector.create", json!({"points": points, "x": 10, "y": 20, "width": 48, "height": 6}))["id"]).unwrap();
let n = s.doc().unwrap().node(both).unwrap();
assert_eq!((n.width, n.height), (48.0, 6.0));
assert_eq!(corners(&s, both), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 6.0)]);
// One side: the other keeps its proportion.
let one = id_of(&ex(&mut s, "vector.create", json!({"points": points, "height": 24}))["id"]).unwrap();
let n = s.doc().unwrap().node(one).unwrap();
assert_eq!((n.width, n.height), (48.0, 24.0));
assert_eq!(corners(&s, one), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 24.0)]);
let e = s.execute("vector.create", &json!({"points": points, "width": 16, "height": -1})).unwrap_err().to_string();
assert!(e.contains("positive"), "{e}");
}
+64 -18
View File
@@ -368,6 +368,9 @@ struct Mapper<'a> {
blobs: Vec<&'a [u8]>,
/// Component property definitions: id → (REST name "Name#id", type).
prop_defs: BTreeMap<String, (String, &'a str)>,
/// The copies of a set's definitions each variant keeps (no name or type of their own; the
/// variant's layers and its instances' assignments use them): copy id → the definition's id.
prop_parents: BTreeMap<String, String>,
/// File nodes by asset `key` and `version`: references can name a collection, variable or
/// style as the asset instead of the guid, and the file carries the resources to resolve
/// them (the collection node itself says the same `key`).
@@ -402,6 +405,7 @@ impl<'a> Mapper<'a> {
let mut nodes = BTreeMap::new();
let mut kids: BTreeMap<String, Vec<(&'a str, String)>> = BTreeMap::new();
let mut prop_defs = BTreeMap::new();
let mut prop_parents = BTreeMap::new();
let changes = msg.get("nodeChanges").map(Kv::list).unwrap_or_default();
// The last change of a GUID wins; a REMOVED change deletes what came before it.
for n in changes {
@@ -423,10 +427,11 @@ impl<'a> Mapper<'a> {
kids.entry(parent).or_default().push((p.get("position").and_then(Kv::str).unwrap_or(""), id.clone()));
}
for d in n.get("componentPropDefs").map(Kv::list).unwrap_or_default() {
if let (Some(did), Some(name), Some(ty)) =
(d.get("id").and_then(guid), d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str))
{
let Some(did) = d.get("id").and_then(guid) else { continue };
if let (Some(name), Some(ty)) = (d.get("name").and_then(Kv::str), d.get("type").and_then(Kv::str)) {
prop_defs.insert(did.clone(), (format!("{name}#{did}"), ty));
} else if let Some(parent) = d.get("parentPropDefId").and_then(guid) {
prop_parents.insert(did, parent);
}
}
}
@@ -450,6 +455,7 @@ impl<'a> Mapper<'a> {
kids,
blobs,
prop_defs,
prop_parents,
assets,
styles: Obj::new(),
components: Obj::new(),
@@ -640,7 +646,7 @@ impl<'a> Mapper<'a> {
targets.extend(data.and_then(|d| d.get("symbolID")).and_then(guid));
for assignment in row.get("componentPropAssignments").map(Kv::list).unwrap_or_default() {
if assignment.get("defID").is_some_and(|def| self.is_instance_swap(def).is_some()) {
targets.extend(assignment.get("value").and_then(|v| v.get("guidValue")).and_then(guid));
targets.extend(assigned(assignment).and_then(prop_guid));
}
}
let overrides = data.and_then(|d| d.get("symbolOverrides")).map(Kv::list).unwrap_or_default();
@@ -651,7 +657,31 @@ impl<'a> Mapper<'a> {
}
fn is_instance_swap(&self, def: &Kv) -> Option<()> {
matches!(self.prop_defs.get(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(())
matches!(self.prop_def(&guid(def)?), Some((_, "INSTANCE_SWAP"))).then_some(())
}
/// The definition a copy of it was made from, followed to the original: the values an instance
/// sets are kept under it, since an assignment and the layers that read it may name different
/// copies of one definition.
fn prop_root(&self, def: &str) -> String {
let mut def = def;
for _ in 0..=MAX_DEPTH {
let Some(parent) = self.prop_parents.get(def) else { break };
def = parent;
}
def.to_string()
}
/// The definition `def` names: its own, or the set's for a variant's copy of it.
fn prop_def(&self, def: &str) -> Option<&(String, &'a str)> {
let mut def = def;
for _ in 0..=MAX_DEPTH {
if let Some(found) = self.prop_defs.get(def) {
return Some(found);
}
def = self.prop_parents.get(def)?;
}
None
}
/// The branches of the internal canvas `id` that hold the `required_mains` (with the frames
@@ -908,7 +938,7 @@ impl<'a> Mapper<'a> {
}
self.prop_refs(&mut o, &view, inst);
let slot_content = if own == "FRAME" {
prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&def).cloned()))
prop_ref(&view, "SLOT_CONTENT_ID").and_then(|def| inst.and_then(|i| i.slot_contents.get(&self.prop_root(&def)).cloned()))
} else {
None
};
@@ -1892,11 +1922,11 @@ impl<'a> Mapper<'a> {
// Expanded frames must follow their main frame's source link. A direct slot reference
// makes the engine resolve ownership against the instance, where the property is not defined.
if !(field == "slot" && inst.is_some())
&& let Some((name, _)) = self.prop_defs.get(&def)
&& let Some((name, _)) = self.prop_def(&def)
{
refs.insert(field.into(), json!(name));
}
let Some(val) = inst.and_then(|i| i.props.get(&def)) else { continue };
let Some(val) = inst.and_then(|i| i.props.get(&self.prop_root(&def))) else { continue };
match field {
"visible" => {
if let Some(b) = val.get("boolValue").and_then(Kv::bool) {
@@ -1911,7 +1941,7 @@ impl<'a> Mapper<'a> {
if own_text(v) != Some(t) {
o.remove("characterStyleOverrides");
o.remove("styleOverrideTable");
if let Some(tv) = val.get("textValue") {
if let Some(tv) = prop_text_data(val) {
let size = o.get("style").and_then(|s| s.get("fontSize")).and_then(Value::as_f64);
char_styles(o, tv, size, &mut |w| self.warn(w));
}
@@ -1933,7 +1963,7 @@ impl<'a> Mapper<'a> {
// An instance swap property value picks the main.
if let Some(i) = outer
&& let Some(def) = prop_ref(v, "OVERRIDDEN_SYMBOL_ID")
&& let Some(g) = i.props.get(&def).and_then(|p| p.get("guidValue")).and_then(guid)
&& let Some(g) = i.props.get(&self.prop_root(&def)).and_then(|p| prop_guid(p))
{
sym = Some(g);
}
@@ -1945,12 +1975,12 @@ impl<'a> Mapper<'a> {
let assignments = v.list("componentPropAssignments");
let mut props = Obj::new();
for a in assignments {
let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_defs.get(&d).cloned()) else { continue };
let val = a.get("value");
let Some((name, ty)) = a.get("defID").and_then(guid).and_then(|d| self.prop_def(&d).cloned()) else { continue };
let val = assigned(a);
let p = match ty {
"BOOL" => val.and_then(|x| x.get("boolValue")).and_then(Kv::bool).map(|b| json!({"type": "BOOLEAN", "value": b})),
"TEXT" => val.and_then(prop_text).map(|t| json!({"type": "TEXT", "value": t})),
"INSTANCE_SWAP" => val.and_then(|x| x.get("guidValue")).and_then(guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})),
"INSTANCE_SWAP" => val.and_then(prop_guid).map(|g| json!({"type": "INSTANCE_SWAP", "value": g})),
_ => None,
};
if let Some(p) = p {
@@ -2027,11 +2057,11 @@ impl<'a> Mapper<'a> {
let mut all_props: BTreeMap<String, &'a Kv<'a>> = outer.map(|i| (*i.props).clone()).unwrap_or_default();
let mut slot_contents: BTreeMap<String, String> = outer.map(|i| (*i.slot_contents).clone()).unwrap_or_default();
for a in assignments {
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), a.get("value")) {
all_props.entry(d).or_insert(val);
if let (Some(d), Some(val)) = (a.get("defID").and_then(guid), assigned(a)) {
all_props.entry(self.prop_root(&d)).or_insert(val);
}
let Some(def) = a.get("defID").and_then(guid) else { continue };
if self.prop_defs.get(&def).is_none_or(|(_, ty)| *ty != "SLOT") {
if self.prop_def(&def).is_none_or(|(_, ty)| *ty != "SLOT") {
continue;
}
let var_value = a.get("varValue");
@@ -2052,7 +2082,7 @@ impl<'a> Mapper<'a> {
self.warn("slot content assignment target is missing or is not slot content");
continue;
}
slot_contents.entry(def).or_insert(target);
slot_contents.entry(self.prop_root(&def)).or_insert(target);
}
let mut symbols = outer.map(|i| i.symbols.clone()).unwrap_or_default();
symbols.push(sym.clone());
@@ -2777,7 +2807,23 @@ fn char_styles(o: &mut Obj, td: &Kv, size: Option<f64>, warn: &mut dyn FnMut(Str
/// The text of a component property value.
fn prop_text<'k>(v: &'k Kv) -> Option<&'k str> {
v.get("textValue").and_then(|t| t.get("characters")).and_then(Kv::str)
prop_text_data(v).and_then(|t| t.get("characters")).and_then(Kv::str)
}
/// The text data of a component property value: `textValue`, or `textDataValue` in the variable form.
fn prop_text_data<'k, 's>(v: &'k Kv<'s>) -> Option<&'k Kv<'s>> {
v.get("textValue").or_else(|| v.get("textDataValue"))
}
/// The main an instance swap value names: `guidValue`, or `symbolIdValue` in the variable form.
fn prop_guid(v: &Kv) -> Option<String> {
v.get("guidValue").and_then(guid).or_else(|| v.get("symbolIdValue")?.get("guid").and_then(guid))
}
/// The value a component property assignment sets: `value`, or the variable form `varValue` holds
/// when `value` is empty, as current files write many booleans and instance swaps.
fn assigned<'k, 's>(assignment: &'k Kv<'s>) -> Option<&'k Kv<'s>> {
assignment.get("value").filter(|v| !matches!(v, Kv::Object(fields) if fields.is_empty())).or_else(|| assignment.get("varValue")?.get("value"))
}
/// The property a layer's `field` is bound to (`parameterConsumptionMap`).
+2
View File
@@ -26,6 +26,8 @@ pub mod kiwi;
mod map;
mod network;
#[cfg(test)]
mod prop_value_tests;
#[cfg(test)]
mod slot_tests;
#[cfg(test)]
mod source_identity_tests;
+188
View File
@@ -0,0 +1,188 @@
//! Component properties as current `.fig` files write them, on synthetic messages: the copy of a
//! set's definitions each variant keeps (`parentPropDefId`, no name or type of its own), and
//! assignments that carry their value only in the variable form (`varValue`).
use figcraft_doc::NodeKind;
use serde_json::Value;
use super::kiwi::Kv;
use super::map::to_rest;
fn g(local: u32) -> Kv<'static> {
Kv::Object(vec![("sessionID", Kv::Uint(9)), ("localID", Kv::Uint(u64::from(local)))])
}
fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn s(text: &str) -> Kv<'static> {
Kv::Str(text.into())
}
fn node(local: u32, kind: &'static str, name: &str, parent: Option<u32>, mut fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut all = vec![("guid", g(local)), ("type", Kv::Enum(kind)), ("name", s(name))];
if let Some(parent) = parent {
all.push(("parentIndex", o(vec![("guid", g(parent)), ("position", s("!"))])));
}
all.append(&mut fields);
o(all)
}
/// A layer's `field` bound to property definition `def`.
fn bound(field: &'static str, def: u32) -> (&'static str, Kv<'static>) {
let entry = o(vec![
("variableField", Kv::Enum(field)),
("variableData", o(vec![("value", o(vec![("propRefValue", o(vec![("defId", g(def))]))])), ("dataType", Kv::Enum("PROP_REF"))])),
]);
("parameterConsumptionMap", o(vec![("entries", Kv::Array(vec![entry]))]))
}
fn definition(id: u32, name: &str, kind: &'static str, initial: Kv<'static>) -> Kv<'static> {
o(vec![("id", g(id)), ("name", s(name)), ("type", Kv::Enum(kind)), ("initialValue", initial)])
}
/// An assignment of `def` that leaves `value` empty and says it in `varValue` only.
fn variable_assignment(def: u32, data_type: &'static str, value: Kv<'static>) -> Kv<'static> {
o(vec![
("defID", g(def)),
("value", o(vec![])),
("varValue", o(vec![("value", value), ("dataType", Kv::Enum(data_type)), ("resolvedDataType", Kv::Enum(data_type))])),
])
}
fn message(nodes: Vec<Kv<'static>>) -> Kv<'static> {
let mut all = vec![node(1, "DOCUMENT", "Document", None, vec![]), node(2, "CANVAS", "Page", Some(1), vec![])];
all.extend(nodes);
o(vec![("nodeChanges", Kv::Array(all))])
}
fn by_id<'v>(v: &'v Value, id: &str) -> &'v Value {
fn find<'v>(v: &'v Value, id: &str) -> Option<&'v Value> {
if v.get("id").and_then(Value::as_str) == Some(id) {
return Some(v);
}
v.get("children").and_then(Value::as_array).into_iter().flatten().find_map(|v| find(v, id))
}
find(v, id).unwrap_or_else(|| panic!("no layer {id}"))
}
fn instance_props(doc: &figcraft_doc::Document) -> &std::collections::BTreeMap<String, Value> {
let instance = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Instance").unwrap();
let NodeKind::Instance { props, .. } = &instance.kind else { panic!("not an instance") };
props
}
/// A "Field" set with a BOOL definition (9:70), a variant whose "Hint text" reads its copy (9:71),
/// and an instance of the variant that sets the property false through definition `assigned`.
fn field_set(assigned: u32) -> Kv<'static> {
message(vec![
node(
9,
"FRAME",
"Field",
Some(2),
vec![
("isStateGroup", Kv::Bool(true)),
("componentPropDefs", Kv::Array(vec![definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))]))])),
],
),
node(
10,
"SYMBOL",
"State=Default",
Some(9),
vec![("componentPropDefs", Kv::Array(vec![o(vec![("id", g(71)), ("parentPropDefId", g(70))])]))],
),
node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 71)]),
node(
20,
"INSTANCE",
"Instance",
Some(2),
vec![
("symbolData", o(vec![("symbolID", g(10))])),
("componentPropAssignments", Kv::Array(vec![o(vec![("defID", g(assigned)), ("value", o(vec![("boolValue", Kv::Bool(false))]))])])),
],
),
])
}
#[test]
fn variant_layers_bind_to_the_set_properties_their_copies_name() {
let msg = field_set(71);
let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES);
assert_eq!(by_id(&rest["document"], "9:9")["componentPropertyDefinitions"]["Hint#9:70"]["type"], "BOOLEAN");
assert_eq!(by_id(&rest["document"], "9:11")["componentPropertyReferences"]["visible"], "Hint#9:70");
assert_eq!(by_id(&rest["document"], "9:20")["componentProperties"]["Hint#9:70"]["value"], false);
let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
let hint = doc.ids().filter_map(|id| doc.node(id)).find(|n| n.name == "Hint text" && n.source.is_none()).unwrap();
assert_eq!(hint.prop_refs.get("visible").map(String::as_str), Some("Hint"));
assert_eq!(instance_props(&doc).get("Hint"), Some(&Value::Bool(false)));
}
/// An assignment reaches layers that read another copy of its definition: here the instance sets
/// the set's own definition while the variant's layer reads the variant's copy.
#[test]
fn assignments_reach_layers_that_read_another_copy_of_their_definition() {
let (rest, _, _) = to_rest(&field_set(70), "Synthetic", crate::rest::MAX_NODES);
let instance = by_id(&rest["document"], "9:20");
assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false);
assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false);
}
#[test]
fn property_values_written_only_as_variables_are_applied() {
let msg = message(vec![
node(
10,
"SYMBOL",
"Field",
Some(2),
vec![(
"componentPropDefs",
Kv::Array(vec![
definition(70, "Hint", "BOOL", o(vec![("boolValue", Kv::Bool(true))])),
definition(71, "Label", "TEXT", o(vec![("textValue", o(vec![("characters", s("Label"))]))])),
definition(72, "Icon", "INSTANCE_SWAP", o(vec![("guidValue", g(40))])),
]),
)],
),
node(11, "TEXT", "Hint text", Some(10), vec![bound("VISIBLE", 70)]),
node(12, "TEXT", "Label", Some(10), vec![("textData", o(vec![("characters", s("Label"))])), bound("TEXT_DATA", 71)]),
node(13, "INSTANCE", "Icon", Some(10), vec![("symbolData", o(vec![("symbolID", g(40))])), bound("OVERRIDDEN_SYMBOL_ID", 72)]),
node(40, "SYMBOL", "Mail", Some(2), vec![]),
node(41, "SYMBOL", "Card", Some(2), vec![]),
node(
20,
"INSTANCE",
"Instance",
Some(2),
vec![
("symbolData", o(vec![("symbolID", g(10))])),
(
"componentPropAssignments",
Kv::Array(vec![
variable_assignment(70, "BOOLEAN", o(vec![("boolValue", Kv::Bool(false))])),
variable_assignment(71, "TEXT_DATA", o(vec![("textDataValue", o(vec![("characters", s("Name on card"))]))])),
variable_assignment(72, "SYMBOL_ID", o(vec![("symbolIdValue", o(vec![("guid", g(41))]))])),
]),
),
],
),
]);
let (rest, _, _) = to_rest(&msg, "Synthetic", crate::rest::MAX_NODES);
let instance = by_id(&rest["document"], "9:20");
assert_eq!(instance["componentProperties"]["Hint#9:70"]["value"], false);
assert_eq!(instance["componentProperties"]["Label#9:71"]["value"], "Name on card");
assert_eq!(instance["componentProperties"]["Icon#9:72"]["value"], "9:41");
assert_eq!(by_id(instance, "I9:20;9:11")["visible"], false);
assert_eq!(by_id(instance, "I9:20;9:12")["characters"], "Name on card");
assert_eq!(by_id(instance, "I9:20;9:13")["componentId"], "9:41");
let (doc, _) = crate::rest::import_fig(&rest, &crate::rest::no_images).unwrap();
let props = instance_props(&doc);
assert_eq!(props.get("Hint"), Some(&Value::Bool(false)));
assert_eq!(props.get("Label"), Some(&Value::String("Name on card".into())));
}
+122 -13
View File
@@ -6,8 +6,8 @@
//! (`relativeTransform`, else `absoluteBoundingBox` + `rotation`), paints, strokes, effects,
//! corners, constraints, auto layout, layout grids, export settings, text with styled runs,
//! vectors from `fillGeometry` / `strokeGeometry`, components, component sets, instances and
//! component properties are imported. Image fills keep their `imageRef` as the image key and
//! show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched).
//! component properties (slots included) are imported. Image fills keep their `imageRef` as the
//! image key and show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched).
//! Prototyping comes along: interactions (triggers, actions, transitions, easing), flows,
//! scrolling and the prototype device. Variables come from the local variables response
//! (`GET /v1/files/:key/variables/local`), embedded next to the file JSON or passed separately
@@ -37,6 +37,8 @@ pub(crate) mod proto;
#[cfg(test)]
mod proto_variable_tests;
#[cfg(test)]
pub(crate) mod slot_tests;
#[cfg(test)]
mod tests;
mod text;
mod variables;
@@ -106,12 +108,25 @@ pub fn is_variables_json(v: &Value) -> bool {
}
/// The collections and variables of a local variables response (or its `meta`), checked like a
/// file's, with the warnings about what was skipped; None when `v` isn't one.
/// file's, with the warnings about what was skipped (and when it holds no variables at all, or
/// fewer than its collections list); None when `v` isn't one.
pub fn read_variables(v: &Value) -> Option<(figcraft_doc::Variables, Vec<String>)> {
let src = variables::source(v)?;
variables::source(v)?;
let mut cx = Ctx::new(Document::new("Variables"), &no_images);
cx.variables(src);
Some((std::mem::take(&mut cx.doc.variables), cx.warnings.finish()))
cx.variables(v);
let vars = std::mem::take(&mut cx.doc.variables);
let mut warnings = cx.warnings.finish();
if vars.variables.is_empty() {
warnings.push("no variables found: expected `meta.variables` (an object of variables by id, next to `meta.variableCollections`)".into());
} else {
let listed = variables::listed_ids(v);
let read: BTreeSet<&str> = vars.variables.iter().map(|x| x.id.as_str()).collect();
let missing = listed.difference(&read).count();
if missing > 0 {
warnings.push(format!("{missing} of the {} variables the collections list in `variableIds` weren't imported", listed.len()));
}
}
Some((vars, warnings))
}
/// Import `bytes` when they are REST API file JSON; `None` when they are something else (the
@@ -191,7 +206,7 @@ fn import_parts(
cx.collect_meta(e);
}
// Variables first: layers link to them as they are built.
for src in [variables::source(v), variables.and_then(variables::source)].into_iter().flatten() {
for src in [Some(v), variables].into_iter().flatten() {
cx.variables(src);
}
if variables.is_some_and(|x| variables::source(x).is_none()) {
@@ -368,6 +383,10 @@ pub(crate) struct Ctx<'a> {
style_meta: BTreeMap<&'a str, &'a Value>,
/// Instances (imported as frames until their main is resolved) with their JSON.
instances: Vec<(NodeId, &'a Value)>,
/// `SLOT` layers (imported as frames until their slot property is resolved) with their JSON.
slots: Vec<(NodeId, &'a Value)>,
/// Slot property defaults (the main's slot content id): (component or set, property) → value.
slot_defaults: BTreeMap<(NodeId, String), &'a Value>,
/// Instance-swap property defaults: (component or set, property, REST id of the default).
swap_defaults: Vec<(NodeId, String, String)>,
/// Property definitions of component sets, shared by their variants.
@@ -402,6 +421,8 @@ impl<'a> Ctx<'a> {
set_meta: BTreeMap::new(),
style_meta: BTreeMap::new(),
instances: Vec::new(),
slots: Vec::new(),
slot_defaults: BTreeMap::new(),
swap_defaults: Vec::new(),
set_defs: BTreeMap::new(),
flows: Vec::new(),
@@ -502,8 +523,10 @@ impl<'a> Ctx<'a> {
sources.record(id, j, primary);
}
}
if ty == "INSTANCE" {
self.instances.push((id, j));
match ty {
"INSTANCE" => self.instances.push((id, j)),
"SLOT" => self.slots.push((id, j)),
_ => {}
}
let kids = children(j);
if kids.is_empty() {
@@ -526,8 +549,8 @@ impl<'a> Ctx<'a> {
let point_count = || num(j, "pointCount").filter(|c| *c >= 3.0).map(|c| c.min(1000.0) as u32);
let has_geometry = !list(j, "fillGeometry").is_empty() || !list(j, "strokeGeometry").is_empty();
Some(match ty {
// Instances start as frames; `finish` links them to their main.
"FRAME" | "INSTANCE" => NodeKind::Frame,
// Instances and slots start as frames; `finish` links them to their main and property.
"FRAME" | "INSTANCE" | "SLOT" => NodeKind::Frame,
"GROUP" => NodeKind::Group,
"SECTION" => NodeKind::Section { contents_hidden: flag(j, "sectionContentsHidden").unwrap_or(false) },
"RECTANGLE" => NodeKind::Rectangle,
@@ -738,7 +761,7 @@ impl<'a> Ctx<'a> {
}
/// Component property definitions (variant ones are implied by the variant names).
fn prop_defs(&mut self, owner: NodeId, j: &Value) -> BTreeMap<String, PropDef> {
fn prop_defs(&mut self, owner: NodeId, j: &'a Value) -> BTreeMap<String, PropDef> {
let mut out = BTreeMap::new();
for (name, d) in j.get("componentPropertyDefinitions").and_then(Value::as_object).into_iter().flatten().take(256) {
let default = d.get("defaultValue");
@@ -752,7 +775,13 @@ impl<'a> Ctx<'a> {
continue;
}
Some("VARIANT") => continue,
Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() },
Some("SLOT") => {
if let Some(d) = default {
self.slot_defaults.insert((owner, name.clone()), d);
}
self.slot_notes(d);
PropDef::Slot { preferred: Vec::new() }
}
_ => {
self.warn("unknown component property types were skipped");
continue;
@@ -763,6 +792,20 @@ impl<'a> Ctx<'a> {
out
}
/// Notes on what a slot property sets that the editor doesn't keep: preferred components (keys
/// of library components, not layers of the file) and limits on its content.
fn slot_notes(&mut self, d: &Value) {
if !list(d, "preferredValues").is_empty() {
self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped");
}
// Insertion and empty-slot display only change editing aids, not what the file shows.
let s = d.get("slotSettings");
let limit = |k: &str| s.and_then(|s| num(s, k)).is_some_and(|n| n != 0.0);
if limit("minChildren") || limit("maxChildren") || s.and_then(|s| flag(s, "allowPreferredValuesOnly")) == Some(true) {
self.warn("non-default slot settings are not represented and were skipped");
}
}
/// Shared styles with their values (`styleDefinitions`, written by the `.fig` import: a list of
/// `{id, name, description, styleType, node}`, the node being a layer that shows the style), in
/// the file's order. Layers then link to them; styles that no layer uses come across too.
@@ -971,6 +1014,7 @@ impl<'a> Ctx<'a> {
/// outside components, and instances that would nest a component inside itself (directly or
/// through other components), stay frames.
fn resolve_instances(&mut self) {
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)) {
@@ -1000,6 +1044,7 @@ impl<'a> Ctx<'a> {
linked.push((inst, j));
}
}
self.resolve_slots(&frames, &linked);
// Overridden fields the file lists, on the instance and its layers.
for (inst, j) in linked {
for o in list(j, "overrides") {
@@ -1015,6 +1060,70 @@ impl<'a> Ctx<'a> {
}
}
/// `SLOT` layers. In a main component a slot is bound to its slot property: the one the layer
/// references, else the one named like it, else the main's only slot property left. In an
/// instance it follows its main's slot, and holds the instance's own content (kept across syncs)
/// unless the instance's value of the property is the main's default content (without the
/// values to compare, a slot holding layers keeps them as its own). A slot without a property
/// stays a frame. `frames`: every instance
/// layer of the file, `linked`: those that became instances.
fn resolve_slots(&mut self, frames: &BTreeSet<NodeId>, linked: &[(NodeId, &'a Value)]) {
let linked: BTreeMap<NodeId, &Value> = linked.iter().copied().collect();
let mut bound: BTreeSet<(NodeId, String)> = BTreeSet::new();
for (id, j) in std::mem::take(&mut self.slots) {
// The nearest main component or instance above the slot owns it; in an instance whose
// main isn't in the file (a frame), the slot stays a frame too.
let owner = self.doc.ancestors(id).into_iter().find_map(|a| match self.doc.node(a).map(|n| &n.kind) {
Some(NodeKind::Instance { component, .. }) => Some(linked.get(&a).map(|j| (*component, Some(*j)))),
Some(NodeKind::Component { .. }) => Some(Some((a, None))),
_ => frames.contains(&a).then_some(None),
});
let Some(owner) = owner else {
self.warn("slot layers without a matching slot property were imported as frames");
continue;
};
let Some((main, inst)) = owner else { continue };
let Some(NodeKind::Component { props, .. }) = self.doc.node(main).map(|n| &n.kind) else { continue };
let defs: Vec<&String> = props.iter().filter(|(_, d)| matches!(d, PropDef::Slot { .. })).map(|(k, _)| k).collect();
let layer = text(j, "name").unwrap_or_default();
let refs: Vec<&str> =
j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten().filter_map(|(_, v)| v.as_str()).collect();
// An instance's slot is matched against all of its main's slot properties.
let free: Vec<&&String> = defs.iter().filter(|d| inst.is_some() || !bound.contains(&(main, (**d).clone()))).collect();
let name = defs
.iter()
.find(|d| refs.contains(&d.as_str()))
.or_else(|| defs.iter().find(|d| short_prop_name(d) == layer))
.or_else(|| if free.len() == 1 { free.first().copied() } else { None })
.map(|d| (*d).clone());
let Some(n) = self.doc.node(id) else { continue };
let has_content = !n.children.is_empty();
let Some(inst) = inst else {
match name.filter(|p| bound.insert((main, p.clone()))) {
Some(p) => {
if let Some(n) = self.doc.node_mut(id) {
n.prop_refs.insert("slot".into(), p);
}
}
None => self.warn("slot layers without a matching slot property were imported as frames"),
}
continue;
};
let set = self.doc.node(main).and_then(|n| n.parent);
let default =
name.as_ref().and_then(|p| [Some(main), set].into_iter().flatten().find_map(|o| self.slot_defaults.get(&(o, p.clone())).copied()));
let value = name.as_ref().and_then(|p| inst.get("componentProperties")?.get(p)?.get("value"));
let own = name.is_some()
&& match (value, default) {
(Some(v), Some(d)) => v != d,
_ => has_content,
};
if own && let Some(n) = self.doc.node_mut(id) {
n.overrides.insert("slot".into());
}
}
}
/// An instance's property values (variant values are implied by the main it shows).
fn instance_props(&self, j: &Value) -> BTreeMap<String, Value> {
let mut out = BTreeMap::new();
+229
View File
@@ -0,0 +1,229 @@
//! `SLOT` layers of the REST file JSON: bound to their slot property in a main component, and
//! holding each instance's own content (synthetic files).
use super::*;
use serde_json::json;
fn at(x: f64, y: f64) -> Value {
json!([[1, 0, x], [0, 1, y]])
}
pub(crate) fn file(children: Value) -> Value {
json!({"name": "Slots", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": children}
]}})
}
fn content_id(local: u32) -> Value {
json!({"guid": {"sessionID": 5, "localID": local}})
}
fn slot(id: &str, name: &str, children: Value) -> Value {
json!({"id": id, "name": name, "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": children})
}
fn label(id: &str, text: &str) -> Value {
json!({"id": id, "name": text, "type": "TEXT", "characters": text, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
}
/// A "Chip" component (`1:9`), a "Container" component (`1:2`) with a "Title" text and an empty
/// "Slot" (property `Slot#1:3`, default content 5:1), and the given layers after them.
pub(crate) fn container_file(more: Vec<Value>) -> Value {
let mut layers = vec![
json!({"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
]}),
json!({"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": content_id(1)}},
"children": [label("1:4", "Title"), slot("1:5", "Slot", json!([]))]}),
];
layers.extend(more);
file(Value::Array(layers))
}
/// An instance `id` of Container at `x` whose slot holds `content` (property value `value`).
pub(crate) fn container(id: &str, x: f64, value: Value, content: Value) -> Value {
json!({"id": id, "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(x, 0.0), "size": {"x": 300, "y": 160},
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": value}},
"children": [label(&format!("I{id};1:4"), "Title"), slot(&format!("I{id};1:5"), "Slot", content)]})
}
pub(crate) fn chip(id: &str) -> Value {
json!({"id": id, "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20}, "children": [
{"id": format!("I{id};1:10"), "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
]})
}
fn by_rest<'d>(doc: &'d Document, v: &Value, rest: &str) -> &'d Node {
// Names repeat across copies: find by position in the file's tree instead.
fn path(v: &Value, rest: &str, out: &mut Vec<usize>) -> bool {
if v.get("id").and_then(Value::as_str) == Some(rest) {
return true;
}
for (i, c) in v.get("children").and_then(Value::as_array).into_iter().flatten().enumerate() {
out.push(i);
if path(c, rest, out) {
return true;
}
out.pop();
}
false
}
let mut p = Vec::new();
assert!(path(&v["document"]["children"][0], rest, &mut p), "no {rest} in the file");
let mut id = doc.pages[0];
for i in p {
id = doc.node(id).unwrap().children[i];
}
doc.node(id).unwrap()
}
fn names(doc: &Document, n: &Node) -> Vec<String> {
n.children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect()
}
#[test]
fn slot_layers_bind_to_their_property_and_keep_instance_content() {
let v = container_file(vec![
container("2:1", 400.0, content_id(7), json!([label("2:2", "Reports in progress"), chip("2:3")])),
container("3:1", 800.0, content_id(1), json!([])),
]);
let (doc, warnings) = import(&v, &no_images).unwrap();
assert!(warnings.iter().all(|w| !w.contains("SLOT") && !w.contains("slot")), "{warnings:?}");
let main = by_rest(&doc, &v, "1:2");
let NodeKind::Component { props, .. } = &main.kind else { panic!("{:?}", main.kind) };
assert_eq!(props.get("Slot"), Some(&PropDef::Slot { preferred: Vec::new() }), "named without its #id suffix");
let main_slot = by_rest(&doc, &v, "1:5");
assert!(matches!(main_slot.kind, NodeKind::Frame));
assert_eq!(main_slot.prop_refs.get("slot").map(String::as_str), Some("Slot"));
assert!(main_slot.children.is_empty(), "the main's slot stays empty");
let filled = by_rest(&doc, &v, "I2:1;1:5");
assert!(filled.overrides.contains("slot"), "the instance's own content");
assert!(filled.prop_refs.is_empty(), "an instance's slot follows its main's");
assert_eq!(names(&doc, filled), ["Reports in progress", "Chip"]);
let chip = by_rest(&doc, &v, "2:3");
assert!(matches!(chip.kind, NodeKind::Instance { .. }), "instances in slot content link to their own main");
let default = by_rest(&doc, &v, "I3:1;1:5");
assert!(!default.overrides.contains("slot"), "the main's default content follows the main");
}
#[test]
fn slot_ownership_without_values_follows_the_content() {
let mut owned = container("2:1", 400.0, json!(null), json!([label("2:2", "Own")]));
owned["componentProperties"] = json!({});
let mut empty = container("3:1", 800.0, json!(null), json!([]));
empty["componentProperties"] = json!({});
let v = container_file(vec![owned, empty]);
let (doc, _) = import(&v, &no_images).unwrap();
assert!(by_rest(&doc, &v, "I2:1;1:5").overrides.contains("slot"));
assert!(!by_rest(&doc, &v, "I3:1;1:5").overrides.contains("slot"));
}
#[test]
fn slots_match_by_reference_then_name_then_the_one_left() {
let comp = json!({"id": "1:2", "name": "Card", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 300},
"componentPropertyDefinitions": {
"Header#1:3": {"type": "SLOT", "defaultValue": content_id(1)},
"Body#1:4": {"type": "SLOT", "defaultValue": content_id(2)},
"Footer#1:5": {"type": "SLOT", "defaultValue": content_id(3)},
"Visible#1:6": {"type": "BOOLEAN", "defaultValue": true}
},
"children": [
{"id": "1:7", "name": "Top", "type": "SLOT", "componentPropertyReferences": {"visible": "Visible#1:6", "slot": "Header#1:3"},
"relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 50}},
{"id": "1:8", "name": "Body", "type": "SLOT", "relativeTransform": at(0.0, 50.0), "size": {"x": 300, "y": 50}},
{"id": "1:9", "name": "Bottom", "type": "SLOT", "relativeTransform": at(0.0, 100.0), "size": {"x": 300, "y": 50}}
]});
let v = file(json!([comp]));
let (doc, warnings) = import(&v, &no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let bound = |rest: &str| by_rest(&doc, &v, rest).prop_refs.get("slot").cloned();
assert_eq!(bound("1:7").as_deref(), Some("Header"));
assert_eq!(by_rest(&doc, &v, "1:7").prop_refs.get("visible").map(String::as_str), Some("Visible"));
assert_eq!(bound("1:8").as_deref(), Some("Body"));
assert_eq!(bound("1:9").as_deref(), Some("Footer"));
}
#[test]
fn variants_share_their_set_slot_property() {
let variant = |id: &str, name: &str, x: f64| {
json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at(x, 0.0), "size": {"x": 100, "y": 100},
"children": [slot(&format!("{id}s"), "Content", json!([]))]})
};
let set = json!({"id": "1:1", "name": "Panel", "type": "COMPONENT_SET", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 100},
"componentPropertyDefinitions": {"Size": {"type": "VARIANT", "defaultValue": "Small"}, "Content#2:1": {"type": "SLOT", "defaultValue": content_id(1)}},
"children": [variant("1:2", "Size=Small", 0.0), variant("1:3", "Size=Large", 150.0)]});
let inst = json!({"id": "4:1", "name": "Panel", "type": "INSTANCE", "componentId": "1:3", "relativeTransform": at(0.0, 300.0), "size": {"x": 100, "y": 100},
"componentProperties": {"Content#2:1": {"type": "SLOT", "value": content_id(9)}},
"children": [slot("I4:1;1:3s", "Content", json!([label("4:2", "Mine")]))]});
let v = file(json!([set, inst]));
let (doc, warnings) = import(&v, &no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
for rest in ["1:2s", "1:3s"] {
assert_eq!(by_rest(&doc, &v, rest).prop_refs.get("slot").map(String::as_str), Some("Content"), "{rest}");
}
assert!(by_rest(&doc, &v, "I4:1;1:3s").overrides.contains("slot"));
}
#[test]
fn nested_instances_own_the_slots_of_their_main() {
// A "Screen" main places a Container whose slot holds a chip; an instance of Screen places
// other content in that nested slot.
let screen = json!({"id": "6:1", "name": "Screen", "type": "COMPONENT", "relativeTransform": at(0.0, 600.0), "size": {"x": 300, "y": 160},
"children": [container("6:2", 0.0, content_id(7), json!([chip("6:3")]))]});
let nested = container("I7:1;6:2", 0.0, content_id(8), json!([label("7:2", "Other")]));
let outer = json!({"id": "7:1", "name": "Screen", "type": "INSTANCE", "componentId": "6:1", "relativeTransform": at(400.0, 600.0), "size": {"x": 300, "y": 160},
"children": [nested]});
let v = container_file(vec![screen, outer]);
let (doc, _) = import(&v, &no_images).unwrap();
let in_main = by_rest(&doc, &v, "I6:2;1:5");
assert!(in_main.overrides.contains("slot") && in_main.prop_refs.is_empty());
assert!(matches!(by_rest(&doc, &v, "6:3").kind, NodeKind::Instance { .. }));
let in_outer = by_rest(&doc, &v, "II7:1;6:2;1:5");
assert!(in_outer.overrides.contains("slot"));
assert_eq!(names(&doc, in_outer), ["Other"]);
}
#[test]
fn slots_outside_components_or_without_a_property_stay_frames() {
let mut no_def = container_file(Vec::new());
no_def["document"]["children"][0]["children"][1].as_object_mut().unwrap().remove("componentPropertyDefinitions");
let loose = slot("8:1", "Loose", json!([label("8:2", "Kept")]));
no_def["document"]["children"][0]["children"].as_array_mut().unwrap().push(loose);
let (doc, warnings) = import(&no_def, &no_images).unwrap();
assert!(warnings.iter().any(|w| w.starts_with("slot layers without a matching slot property were imported as frames (×2)")), "{warnings:?}");
let kept = by_rest(&doc, &no_def, "8:1");
assert!(matches!(kept.kind, NodeKind::Frame) && kept.prop_refs.is_empty());
assert_eq!(names(&doc, kept), ["Kept"]);
assert!(by_rest(&doc, &no_def, "1:5").prop_refs.is_empty());
}
#[test]
fn slot_settings_the_editor_cannot_keep_are_noted() {
let mut v = container_file(Vec::new());
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] =
json!({"stretchChildOnInsert": true, "displayEmptyByDefault": true, "minChildren": null, "maxChildren": 3});
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["preferredValues"] =
json!([{"type": "COMPONENT", "key": "abc"}]);
let (_, warnings) = import(&v, &no_images).unwrap();
assert!(warnings.iter().any(|w| w == "non-default slot settings are not represented and were skipped"), "{warnings:?}");
assert!(warnings.iter().any(|w| w == "preferred slot component keys cannot be resolved to file nodes and were skipped"), "{warnings:?}");
let mut plain = container_file(Vec::new());
plain["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] =
json!({"stretchChildOnInsert": true, "minChildren": null, "maxChildren": null, "allowPreferredValuesOnly": false});
assert!(import(&plain, &no_images).unwrap().1.is_empty());
}
#[test]
fn junk_slot_json_never_panics() {
for junk in [json!(null), json!(1), json!("x"), json!([]), json!({"guid": null}), json!({"type": "SLOT"})] {
let mut v = container_file(vec![container("2:1", 400.0, junk.clone(), json!([label("2:2", "A")]))]);
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"] =
json!({"type": "SLOT", "defaultValue": junk.clone(), "slotSettings": junk.clone(), "preferredValues": junk.clone()});
v["document"]["children"][0]["children"][1]["children"][1]["componentPropertyReferences"] = json!({"slot": junk});
assert!(import(&v, &no_images).is_ok());
}
}
+17 -3
View File
@@ -27,6 +27,16 @@ pub fn source(v: &Value) -> Option<&Value> {
[v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variableCollections").is_some_and(Value::is_object))
}
/// The variable ids the collections of a local variables response list in `variableIds`.
pub(super) fn listed_ids(v: &Value) -> BTreeSet<&str> {
source(v)
.into_iter()
.flat_map(|src| objects(src, "variableCollections", MAX_COLLECTIONS))
.flat_map(|(_, c)| list(c, "variableIds").iter().filter_map(Value::as_str).map(clip_str))
.take(MAX_VARIABLES)
.collect()
}
pub(super) fn kind_of(t: &str) -> Option<VariableType> {
Some(match t {
"COLOR" => VariableType::Color,
@@ -118,14 +128,18 @@ fn objects<'v>(v: &'v Value, key: &str, max: usize) -> impl Iterator<Item = (&'v
}
impl Ctx<'_> {
/// Add the collections and variables of a local variables response (or its `meta`).
pub(super) fn variables(&mut self, src: &Value) {
/// Add the collections and variables of a local variables response (or its `meta`); nothing
/// when `v` isn't one. The `variables` map is read next to `variableCollections`, or from
/// `meta` or the top level when it sits on the other side.
pub(super) fn variables(&mut self, v: &Value) {
let Some(src) = source(v) else { return };
for (key, c) in objects(src, "variableCollections", MAX_COLLECTIONS) {
self.collection(key, c);
}
let holder = [Some(src), v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variables").is_some_and(Value::is_object));
let mut found: Vec<Variable> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
for (key, v) in objects(src, "variables", MAX_VARIABLES) {
for (key, v) in holder.into_iter().flat_map(|h| objects(h, "variables", MAX_VARIABLES)) {
if let Some(var) = self.variable(key, v)
&& seen.insert(var.id.clone())
{
+1 -1
View File
@@ -147,7 +147,7 @@ pub fn tool_definitions() -> Vec<Value> {
"type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"},
"x": num("Left, relative to the parent frame (default 0)"),
"y": num("Top, relative to the parent frame (default 0)"),
"width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap)"),
"width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap; for a vector with path, width/height scale the path, one alone keeping its proportion, and without them the path's own box is used)"),
"height": num("Height (default 100; ignored for lines/arrows and auto-height text)"),
"parent": id_schema("Parent frame/group/component id (default: the current page)"),
"index": int("Z-index among the parent's children (default: on top)"),
+4 -3
View File
@@ -65,9 +65,10 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result<String, Stri
if cjk && std::env::var_os("FIGCRAFT_EMBED_CJK").is_none() {
continue;
}
// Web builds have a size budget: Inter, plus Noto Sans CJK (every CJK script, for CJK UI
// text) when asked.
if wasm && *family != "Inter" && !cjk {
// Web builds have a size budget: Inter, Noto Sans Georgian (small, for the Georgian UI), only the
// SemiBold wordmark, and optionally Noto Sans CJK (every CJK script, for CJK UI text).
let wordmark = *family == "Archivo" && *style == "SemiBold";
if wasm && !matches!(*family, "Inter" | "Noto Sans Georgian") && !wordmark && !cjk {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
+99 -1
View File
@@ -444,9 +444,22 @@ pub struct FontDb {
families: BTreeMap<String, (String, Vec<usize>)>,
/// System faces known by name but not loaded (lower-case family → entries).
pending: BTreeMap<String, (String, Vec<Pending>)>,
/// System faces read from disk (file, face index), so a rescan only offers new ones.
loaded_files: std::collections::BTreeSet<(std::path::PathBuf, u32)>,
/// Font files added as bytes (hash and length → families), so loading one again adds nothing.
loaded_bytes: std::collections::HashMap<(u64, usize), Vec<String>>,
system_scanned: bool,
}
/// What a later walk of the font folders found ([`rescan_system_fonts`]).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Rescan {
/// Families that were unknown before.
pub added: Vec<String>,
/// Every family that gained faces: the new ones and those with styles installed since.
pub changed: Vec<String>,
}
static DB: OnceLock<RwLock<FontDb>> = OnceLock::new();
/// Set by [`scan_system_fonts_on_demand`].
static ON_DEMAND: AtomicBool = AtomicBool::new(false);
@@ -580,6 +593,7 @@ impl FontDb {
};
if let Some(face) = Face::from_bytes(FontBytes::Shared(bytes), e.index, None, Some(&e.style)) {
self.add(face);
self.loaded_files.insert((e.path, e.index));
}
}
}
@@ -670,6 +684,35 @@ impl FontDb {
self.faces.len()
}
/// Offer the families of a later walk of the font folders: new families become pending, a
/// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets
/// the files it hasn't read (styles installed since) as pending, read when it is next used.
fn merge_rescan(&mut self, found: BTreeMap<String, (String, Vec<Pending>)>) -> Rescan {
let mut out = Rescan::default();
for (k, (display, entries)) in found {
if self.families.contains_key(&k) {
let waiting = self.pending.get(&k).map(|(_, v)| v.clone()).unwrap_or_default();
let new: Vec<Pending> = entries
.into_iter()
.filter(|e| {
!self.loaded_files.contains(&(e.path.clone(), e.index)) && !waiting.iter().any(|w| w.path == e.path && w.index == e.index)
})
.collect();
if !new.is_empty() {
out.changed.push(display.clone());
self.pending.entry(k).or_insert_with(|| (display, Vec::new())).1.extend(new);
}
continue;
}
if !self.pending.contains_key(&k) {
out.added.push(display.clone());
}
out.changed.push(display.clone());
self.pending.insert(k, (display, entries));
}
out
}
/// Index the system font directories (desktop only): family names are read now, faces loaded
/// when first used. Safe to call more than once.
pub fn scan_system(&mut self) {
@@ -776,13 +819,19 @@ fn scan_dir(dir: &std::path::Path, depth: usize, out: &mut BTreeMap<String, (Str
// Very large files are indexed all the same (Apple Color Emoji is ~180 MB and is where the
// system's colour emoji live): `index_bytes` reads headers and name tables out of a zeroed
// buffer, so only those parts are ever touched.
let Some(bytes) = usize::try_from(meta.len()).ok().filter(|l| *l <= 256 * 1024 * 1024).and_then(|l| index_bytes(&path, l)) else { continue };
if meta.len() > MAX_FONT_FILE {
continue;
}
let Some(bytes) = usize::try_from(meta.len()).ok().and_then(|l| index_bytes(&path, l)) else { continue };
for (index, family, style) in faces_in(&bytes) {
out.entry(family.to_lowercase()).or_insert_with(|| (family.clone(), Vec::new())).1.push(Pending { path: path.clone(), index, style });
}
}
}
/// The largest font file read (CJK collections run to ~120 MB, Apple Color Emoji ~180 MB).
pub const MAX_FONT_FILE: u64 = 256 * 1024 * 1024;
/// The parts of a font file the scan reads: headers, table directories and `name` tables, at their
/// offsets in a zeroed buffer of the file's length (reading whole files was most of the scan).
fn index_bytes(path: &std::path::Path, len: usize) -> Option<Vec<u8>> {
@@ -887,6 +936,55 @@ pub fn scan_system_fonts() {
});
}
/// Walk the system font folders again, for fonts installed while the app runs (the first scan is
/// once per process): the families that were unknown before, and every family that gained faces.
/// Before the first scan this is that scan; a no-op on the web or with `FIGCRAFT_NO_SYSTEM_FONTS`.
pub fn rescan_system_fonts() -> Rescan {
#[cfg(not(target_arch = "wasm32"))]
if !system_fonts_opted_out() {
if SCANNED.get().is_none() {
let before: std::collections::BTreeSet<String> =
raw_db().read().unwrap_or_else(std::sync::PoisonError::into_inner).families().into_iter().collect();
scan_system_fonts();
let added: Vec<String> = family_names().into_iter().filter(|f| !before.contains(f)).collect();
return Rescan { changed: added.clone(), added };
}
let mut found: BTreeMap<String, (String, Vec<Pending>)> = BTreeMap::new();
for dir in system_font_dirs() {
scan_dir(&dir, 0, &mut found);
}
let rescan = raw_db().write().unwrap_or_else(std::sync::PoisonError::into_inner).merge_rescan(found);
if !rescan.changed.is_empty() {
crate::clear_layout_cache();
}
return rescan;
}
Rescan::default()
}
/// Load font file bytes (every face of a collection) into the global db, for a font that isn't
/// installed. Returns the families of its faces (none: not a font file); the same file again
/// returns them without adding its faces twice.
pub fn load_font_bytes(bytes: Vec<u8>) -> Vec<String> {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
let key = (h.finish(), bytes.len());
let mut db = db().write().unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(families) = db.loaded_bytes.get(&key) {
return families.clone();
}
let faces = db.add_bytes(bytes);
let mut families: Vec<String> = faces.iter().map(|f| f.family.clone()).collect();
families.dedup();
if !families.is_empty() {
db.loaded_bytes.insert(key, families.clone());
drop(db);
crate::clear_layout_cache();
}
families
}
/// `FIGCRAFT_NO_SYSTEM_FONTS` is set (and not `0`): renders use only the embedded fonts.
#[cfg(not(target_arch = "wasm32"))]
fn system_fonts_opted_out() -> bool {
+46
View File
@@ -217,3 +217,49 @@ fn user_font_dirs_preserve_non_utf8_paths() {
assert_eq!(dirs[0].as_os_str().as_bytes(), b"/home/\xff/.fonts");
assert_eq!(dirs[1].as_os_str().as_bytes(), b"/data/\xfe/fonts");
}
/// A rescan offers new families and the styles installed since, and never a file twice.
#[test]
fn a_rescan_offers_fonts_installed_since() {
let entry = |s: &str| Pending { path: format!("no-such-dir/{s}.ttf").into(), index: 0, style: s.into() };
let mut db = pending(&["Regular"]);
let found = |fams: &[(&str, &[&str])]| -> BTreeMap<String, (String, Vec<Pending>)> {
fams.iter().map(|(f, styles)| (f.to_lowercase(), ((*f).to_string(), styles.iter().map(|s| entry(s)).collect()))).collect()
};
// A new family is added; a pending one takes the walk's files (a new style); both changed.
let r = db.merge_rescan(found(&[("CJK UI", &["Regular", "Bold"]), ("Brand Sans", &["Regular"])]));
assert_eq!(r.added, ["Brand Sans"]);
assert_eq!(r.changed, ["Brand Sans", "CJK UI"]);
assert!(db.has_family("brand sans"));
assert_eq!(left(&db), ["Regular", "Bold"]);
// A loaded family keeps its faces and gets only the files it hasn't read, once.
db.families.insert("brand sans".into(), ("Brand Sans".into(), Vec::new()));
db.pending.remove("brand sans");
db.loaded_files.insert(("no-such-dir/Regular.ttf".into(), 0));
let same = db.merge_rescan(found(&[("Brand Sans", &["Regular"])]));
assert_eq!(same, Rescan::default());
assert!(!db.pending.contains_key("brand sans"));
let bold = db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])]));
assert_eq!((bold.added.len(), bold.changed), (0, vec!["Brand Sans".to_string()]));
let waiting = |db: &FontDb| db.pending.get("brand sans").map(|(_, v)| v.iter().map(|e| e.style.clone()).collect::<Vec<_>>()).unwrap_or_default();
assert_eq!(waiting(&db), ["Black"]);
assert_eq!(db.merge_rescan(found(&[("Brand Sans", &["Regular", "Black"])])), Rescan::default());
assert_eq!(waiting(&db), ["Black"]);
}
/// Font file bytes add their faces once; junk adds nothing.
#[test]
fn font_bytes_load_into_the_db() {
assert!(load_font_bytes(b"not a font".to_vec()).is_empty());
assert!(load_font_bytes(Vec::new()).is_empty());
// With the embedded fonts (absent on CI builds without craft-fonts): a file's family is added,
// and loading the same file again returns it without adding its faces twice.
if let Some(f) = CRAFT_FONTS.first() {
let families = load_font_bytes(f.bytes.to_vec());
assert_eq!(families.len(), 1);
assert!(families.iter().all(|f| has_family(f)));
let faces = db().read().unwrap().face_count();
assert_eq!(load_font_bytes(f.bytes.to_vec()), families);
assert_eq!(db().read().unwrap().face_count(), faces);
}
}
+47 -23
View File
@@ -82,6 +82,9 @@ pub struct Line {
pub x: f64,
/// The line ends a paragraph (hard break or end of text).
pub para_end: bool,
/// The line ends with a line break char: a newline, or a line separator (U+2028) that breaks
/// the line inside its paragraph.
pub break_after: bool,
/// The paragraph runs right to left (its first strong character is right-to-left).
pub rtl: bool,
}
@@ -157,6 +160,15 @@ fn deco_len(m: DecorationLength, size: f64, auto: f64) -> f64 {
if v.is_finite() { v } else { auto }
}
/// The line separator (U+2028): a line break inside a paragraph, as `.fig` files store a soft
/// break (imported text keeps it; typed text has newlines only).
const LINE_SEPARATOR: char = '\u{2028}';
/// A char that ends its line: a newline (which also ends the paragraph) or a line separator.
fn is_line_break(c: char) -> bool {
c == '\n' || c == LINE_SEPARATOR
}
/// Opening punctuation, which hangs outside a line's start.
fn opens(c: char) -> bool {
matches!(c, '"' | '\'' | '\u{201C}' | '\u{2018}' | '\u{201E}' | '\u{201A}' | '\u{00AB}' | '\u{2039}' | '(' | '[' | '{' | '\u{00BF}' | '\u{00A1}')
@@ -332,7 +344,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
// Colour emoji are their own, large faces (Apple Color Emoji is ~180 MB): they are read only
// for a text with emoji, or with characters nothing above covers.
let covered = |c: char| c == '\n' || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c));
let covered = |c: char| is_line_break(c) || faces.iter().flatten().any(|f| f.has_char(c)) || fallbacks.iter().any(|f| f.has_char(c));
let emoji = if chars.iter().any(|c| is_emoji(*c) || !covered(*c)) { dbw.emoji_fallbacks() } else { Vec::new() };
fallbacks.extend(emoji.iter().cloned());
drop(dbw);
@@ -370,7 +382,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
{
return f.clone();
}
if c == '\n' || primary.has_char(c) {
if is_line_break(c) || primary.has_char(c) {
return primary.clone();
}
let fallbacks = style_fallbacks.get(st_idx).unwrap_or(&fallbacks);
@@ -382,13 +394,15 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let mut seg_end = seg_start + 1;
while seg_end < *end
&& chars.get(seg_end).is_some_and(|c| {
*c != '\n' && first != '\n' && (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id)
!is_line_break(*c)
&& !is_line_break(first)
&& (!cluster_start.get(seg_end).copied().unwrap_or(true) || face_for(*c).id == face.id)
})
&& level_at(seg_end) == level
{
seg_end += 1;
}
if first == '\n' {
if is_line_break(first) {
items.push(Item {
face: face.clone(),
coords: no_coords(),
@@ -401,7 +415,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
char_start: seg_start,
char_end: seg_start + 1,
style: st_idx,
ch: '\n',
ch: first,
level,
});
} else {
@@ -465,6 +479,17 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
para += 1;
continue;
}
// A line separator (a soft break) breaks the line inside its paragraph: no
// paragraph spacing and no new list marker after it.
if it.ch == LINE_SEPARATOR {
lines.push((line_start, i + 1, false, para));
i += 1;
line_start = i;
x = 0.0;
last_break = None;
para_first_line = false;
continue;
}
let line_indent = line_indent_of(para, para_first_line, it.char_start);
let w = it.adv;
// Hanging punctuation takes no room inside the box.
@@ -491,7 +516,8 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
}
i += 1;
}
if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2) {
let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch));
if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) {
lines.push((line_start, items.len(), true, para));
}
@@ -546,7 +572,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
let baseline = y + (lh - (asc + desc)) / 2.0 + asc;
// Trailing spaces don't count for width/alignment.
let mut vis_end = *e;
while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || it.ch == '\n') {
while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) {
vis_end -= 1;
}
// Letter spacing separates glyphs: none after the line's last visible one.
@@ -563,7 +589,9 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
// Justify: extra space per gap.
let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count();
let justify_extra = match (text.align_h, wrap) {
(TextAlignH::Justified, Some(maxw)) if !para_end && gaps > 0 => ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0),
(TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => {
((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0)
}
_ => 0.0,
};
// Where the visible text ends once justified.
@@ -574,7 +602,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
.get(vis_end..*e)
.unwrap_or(&[])
.iter()
.filter(|it| it.ch != '\n')
.filter(|it| !is_line_break(it.ch))
.map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 })
.sum();
let mut pen = if rtl_line { -trailing } else { line_indent - hang_l };
@@ -649,7 +677,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
(0..line_items.len()).collect()
};
for it in order.iter().filter_map(|k| line_items.get(*k)) {
if it.ch == '\n' {
if is_line_break(it.ch) {
continue;
}
let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
@@ -687,6 +715,7 @@ pub fn layout(text: &TextData, box_width: Option<f64>, box_height: Option<f64>)
width: width + line_indent - hang_l - hang_r,
x: 0.0,
para_end: *para_end,
break_after: break_after(*e),
rtl: rtl_line,
});
y += lh;
@@ -1142,16 +1171,12 @@ impl TextLayout {
pub fn caret(&self, index: usize) -> (f64, usize) {
for (li, line) in self.lines.iter().enumerate() {
let last_line = li + 1 == self.lines.len();
// A caret at a line's end belongs to the next line, whether the line wraps or ends with a
// line break (before the break it is still on this line: past its glyphs).
let in_line = index >= line.char_start && (index < line.char_end || (last_line && index <= line.char_end));
// A caret at the end of a soft-wrapped line belongs to the next line.
if !(in_line || index == line.char_end && line.para_end && !last_line) {
if !in_line {
continue;
}
if index == line.char_end && line.para_end && !last_line {
// Before the newline: end of this line.
let x = self.line_end_x(line);
return (x, li);
}
let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
for g in gl {
if g.char_end == g.char_start {
@@ -1179,8 +1204,8 @@ impl TextLayout {
if !line.rtl && !gl.iter().any(|g| g.rtl) {
return None;
}
// The caret can't sit after a paragraph's newline on its own line.
let end = if line.para_end && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end };
// The caret can't sit after a line's break on its own line.
let end = if line.break_after && li + 1 < self.lines.len() { line.char_end.saturating_sub(1) } else { line.char_end };
let next = (line.char_start..=end.max(line.char_start))
.filter(|i| *i != index)
.map(|i| (i, self.caret(i).0))
@@ -1247,7 +1272,7 @@ impl TextLayout {
}
}
let last_line = li + 1 == self.lines.len();
let end = if line.para_end && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end };
let end = if line.break_after && !last_line { line.char_end.saturating_sub(1).max(line.char_start) } else { line.char_end };
// Right of a right-to-left line is its start.
if line.rtl { line.char_start } else { end }
}
@@ -1274,12 +1299,11 @@ impl TextLayout {
out.extend(Self::mixed_selection(gl, a, b, line));
continue;
}
// From the line's own start: `caret(char_start)` after a hard break is the end of the
// line above.
// From the line's own start.
let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 };
let x1 = if b >= line.char_end {
// The last line has no paragraph break to show, so it ends at its glyphs.
self.line_end_x(line) + if line.para_end && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
self.line_end_x(line) + if line.break_after && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
} else {
self.caret(b).0
};
+5 -2
View File
@@ -16,8 +16,9 @@ mod onpath;
pub use breaks::grapheme_step;
pub use color::{ColorBitmap, ColorInk, ColorLayer};
pub use fontdb::{
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, axis_label, db, face_with_char, family_names, family_styles, find_face, has_family,
installed_fonts_known, no_coords, scan_system_fonts, scan_system_fonts_on_demand, system_fonts_scanned,
Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face,
has_family, installed_fonts_known, load_font_bytes, no_coords, rescan_system_fonts, scan_system_fonts, scan_system_fonts_on_demand,
system_fonts_scanned,
};
pub use layout::{Decoration, Glyph, Line, TextLayout, layout};
pub use onpath::OnPath;
@@ -150,6 +151,8 @@ mod behaviour_tests;
#[cfg(test)]
mod cache_tests;
#[cfg(test)]
mod line_separator_tests;
#[cfg(test)]
mod list_tests;
#[cfg(test)]
mod metrics_tests;
+97
View File
@@ -0,0 +1,97 @@
//! A line separator (U+2028: a soft break in `.fig` files, kept on import) breaks the
//! line inside its paragraph: no paragraph spacing or new list marker after it, and no glyph (#852).
use super::*;
use figcraft_doc::{LineHeight, ListStyle, TextAutoResize, TextData};
const EPS: f64 = 1e-6;
/// A 20 px text on 30 px lines in the default face, or None (skip) on a machine without fonts.
fn text(s: &str) -> Option<TextData> {
scan_system_fonts();
let f = db().write().unwrap().default_face()?;
let mut t = TextData::new(s);
(t.style.font_family, t.style.font_style) = (f.family.clone(), f.style.clone());
t.style.font_size = 20.0;
t.style.line_height = LineHeight::Pixels(30.0);
t.auto_resize = TextAutoResize::WidthAndHeight;
Some(t)
}
/// The chars of each line.
fn line_texts(t: &TextData, l: &TextLayout) -> Vec<String> {
let chars: Vec<char> = t.characters.chars().collect();
l.lines.iter().map(|ln| chars.get(ln.char_start..ln.char_end).unwrap_or(&[]).iter().collect::<String>()).collect()
}
#[test]
fn breaks_the_line_but_not_the_paragraph() {
let Some(mut t) = text("one\u{2028}two\nthree") else { return };
t.paragraph_spacing = 10.0;
let l = layout(&t, None, None);
assert_eq!(line_texts(&t, &l), ["one\u{2028}", "two\n", "three"]);
assert!(!l.lines[0].para_end && l.lines[0].break_after);
assert!(l.lines[1].para_end && l.lines[1].break_after);
assert!(!l.lines[2].break_after);
// Paragraph spacing after the newline only: 30, then 30 + 10.
assert!((l.lines[1].top - 30.0).abs() < EPS && (l.lines[2].top - 70.0).abs() < EPS, "{} {}", l.lines[1].top, l.lines[2].top);
// The separator draws nothing.
assert!(l.glyphs.iter().all(|g| !(g.char_start..g.char_end).contains(&3)));
}
#[test]
fn before_a_newline_or_at_the_end_it_leaves_an_empty_line() {
// "Hi,⏎¶Season" in #852: an empty line between the two.
let Some(t) = text("Hi,\u{2028}\nSeason") else { return };
let l = layout(&t, None, None);
assert_eq!(line_texts(&t, &l), ["Hi,\u{2028}", "\n", "Season"]);
let Some(t) = text("end\u{2028}") else { return };
let l = layout(&t, None, None);
assert_eq!(line_texts(&t, &l), ["end\u{2028}", ""]);
assert!((l.height - 60.0).abs() < EPS, "{}", l.height);
}
#[test]
fn a_list_item_continues_after_it_without_a_new_marker() {
let Some(mut t) = text("item\u{2028}more\nnext") else { return };
t.list = ListStyle::Bulleted;
let l = layout(&t, None, None);
assert_eq!(line_texts(&t, &l), ["item\u{2028}", "more\n", "next"]);
// Markers are the glyphs that cover no char: one per paragraph.
assert_eq!(l.glyphs.iter().filter(|g| g.char_start == g.char_end).count(), 2);
// The line after the separator lines up with the item's text, past the bullet.
let text_x = |li: usize| l.glyphs.iter().find(|g| g.char_start == l.lines[li].char_start && g.char_end > g.char_start).map(|g| g.x);
let (first, cont) = (text_x(0).unwrap(), text_x(1).unwrap());
assert!(first > 0.0 && (first - cont).abs() < 1e-3, "{first} {cont}");
}
#[test]
fn carets_and_clicks_treat_it_like_a_newline() {
let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return };
let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None));
for i in 0..=5 {
assert_eq!(ls.caret(i), ln.caret(i), "caret before char {i}");
}
for line in &ln.lines {
for x in [-10.0, 5.0, line.width + 50.0] {
assert_eq!(ls.hit(x, line.top + 5.0), ln.hit(x, line.top + 5.0), "click at {x} on the line at {}", line.top);
}
}
// Before the separator: the end of the first line; after it: the start of the second.
assert_eq!(ls.caret(2).1, 0);
assert!(ls.caret(2).0 > 0.0);
assert_eq!(ls.caret(3), (0.0, 1));
// A click right of the first line lands before the separator, not after it.
assert_eq!(ls.hit(ls.lines[0].width + 50.0, ls.lines[0].top + 5.0), 2);
}
#[test]
fn a_selection_across_it_shows_the_break_like_a_newline() {
let (Some(sep), Some(nl)) = (text("ab\u{2028}cd"), text("ab\ncd")) else { return };
let (ls, ln) = (layout(&sep, None, None), layout(&nl, None, None));
let (before, across) = (ls.selection_rects(0, 2), ls.selection_rects(0, 5));
assert_eq!((before.len(), across.len()), (1, 2));
// Reaching past the separator adds the 4 px break mark at the first line's end.
assert!((across[0].width() - before[0].width() - 4.0).abs() < EPS, "{} {}", across[0].width(), before[0].width());
assert_eq!(across, ln.selection_rects(0, 5));
}
+16
View File
@@ -481,3 +481,19 @@ fn selection_after_a_line_break_starts_at_the_line_start() {
// A break followed by text still doesn't highlight the next line's start.
assert!(rect_on(&l, &l.selection_rects(0, 6), 1).is_none());
}
#[test]
fn the_caret_after_a_line_break_sits_on_the_next_line() {
if !have_fonts() {
return;
}
// Before the break: the end of its line; after it: the start of the next (after Enter, the
// caret shows on the new line, and Up/Down count from there).
let l = layout(&TextData::new("ab\ncd"), None, None);
assert_eq!(l.caret(2).1, 0);
assert!(l.caret(2).0 > 0.0);
assert_eq!(l.caret(3), (0.0, 1));
// A trailing break: the caret sits on the empty last line.
let l = layout(&TextData::new("ab\n"), None, None);
assert_eq!(l.caret(3), (0.0, 1));
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -310,6 +310,7 @@ status = "draft — needs native review"
"Corner radius" = "Poloměr rohu"
"Corner smoothing" = "Vyhlazení rohů"
"Couldn't reach {server}: showing what this computer remembers." = "Server {server} není dostupný: zobrazuje se stav uložený v tomto počítači."
"Couldn't read {name}." = "Soubor {name} se nepodařilo přečíst."
"Count" = "Počet"
"Create" = "Vytvořit"
"Create a variable collection first" = "Nejprve vytvořte kolekci proměnných"
@@ -929,6 +930,7 @@ status = "draft — needs native review"
"PDF print settings" = "Nastavení tisku PDF"
"Padding" = "Vnitřní odsazení"
"Page" = "Stránka"
"Page deleted" = "Stránka smazána"
"Pages" = "Stránky"
"Pages added to the document" = "Stránky přidány do dokumentu"
"Placed on the current page" = "Umístěno na aktuální stránku"
@@ -1629,6 +1631,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "Zapomenuto v tomto počítači: {name}; soubor zůstává na serveru {server}"
"{name} is on the server." = "{name} je na serveru."
"{name} is spotlighting" = "{name} sdílí pohled"
"{name} is too large to open in the browser (over 512 MB)." = "Soubor {name} je příliš velký na otevření v prohlížeči (více než 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nKliknutím použijete na výběr"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPřetáhněte na plátno nebo kliknutím umístěte doprostřed zobrazení.\nPři puštění na instanci podržte Alt (Option) a vyměňte ji."
"{name} (from {library})" = "{name} (z {library})"
@@ -1849,6 +1852,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Exportovat vrstvy"
"file.exportSvg" = "Exportovat výběr jako SVG"
"file.import" = "Importovat"
"file.moveTab" = "Přesunout kartu"
"file.new" = "Nový soubor návrhu"
"file.newFromTemplate" = "Nový ze šablony"
"file.open" = "Otevřít"
+6
View File
@@ -306,6 +306,7 @@ status = "draft — needs native review"
"Corner smoothing" = "Eckenglättung"
"Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden"
"Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat."
"Couldn't read {name}." = "{name} konnte nicht gelesen werden."
"Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}"
"Count" = "Anzahl"
"Create" = "Erstellen"
@@ -898,6 +899,7 @@ status = "draft — needs native review"
"PDF print settings" = "PDF-Druckeinstellungen"
"Padding" = "Innenabstand"
"Page" = "Seite"
"Page deleted" = "Seite gelöscht"
"Pages" = "Seiten"
"Pages added to the document" = "Seiten zum Dokument hinzugefügt"
"Placed on the current page" = "Auf der aktuellen Seite platziert"
@@ -1224,6 +1226,7 @@ status = "draft — needs native review"
"Start ascending from / stop descending at" = "Aufwärts zählen ab / abwärts zählen bis"
"Start from a blank canvas" = "Mit einer leeren Arbeitsfläche beginnen"
"Start on path" = "Anfang auf dem Pfad"
"Start on the Home screen" = "Mit Start beginnen"
"Start point" = "Startpunkt"
"Start sharing" = "Teilen starten"
"Starting to share…" = "Teilen wird gestartet…"
@@ -1583,6 +1586,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} wurde auf diesem Computer vergessen; die Datei bleibt auf {server}"
"{name} is on the server." = "{name} liegt auf dem Server."
"{name} is spotlighting" = "{name} hat das Spotlight"
"{name} is too large to open in the browser (over 512 MB)." = "{name} ist zu groß, um im Browser geöffnet zu werden (über 512 MB)."
"{name} was removed from the server" = "{name} wurde vom Server entfernt"
"{name} · deleted on main" = "{name} · in der Hauptdatei gelöscht"
"{name} · deleted on the branch" = "{name} · im Branch gelöscht"
@@ -1795,6 +1799,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Ebenen exportieren"
"file.exportSvg" = "Auswahl als SVG exportieren"
"file.import" = "Importieren"
"file.moveTab" = "Tab verschieben"
"file.new" = "Neue Designdatei"
"file.open" = "Öffnen"
"file.openRecent" = "Zuletzt verwendete öffnen"
@@ -1932,6 +1937,7 @@ status = "draft — needs native review"
"pref.reduceMotion" = "Bewegung reduzieren"
"pref.renameDuplicates" = "Duplizierte Ebenen umbenennen"
"pref.showDimensions" = "Abmessungen an Objekten anzeigen"
"pref.startOnHome" = "Mit Start beginnen"
"pref.userName" = "Ihr Name in Kommentaren"
"proto.action.add" = "Prototyp-Aktion hinzufügen"
"proto.action.move" = "Prototyp-Aktion neu anordnen"
+18
View File
@@ -172,6 +172,7 @@ name = "English"
"Both directions" = "Both directions"
"Bottom" = "Bottom"
"Bottom left radius" = "Bottom left radius"
"Bottom padding" = "Bottom padding"
"Bottom right radius" = "Bottom right radius"
"Branch" = "Branch"
"Branch archived" = "Branch archived"
@@ -319,6 +320,7 @@ name = "English"
"Corner smoothing" = "Corner smoothing"
"Couldn't download the variables" = "Couldn't download the variables"
"Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers."
"Couldn't read {name}." = "Couldn't read {name}."
"Couldn't save automatically: {error}" = "Couldn't save automatically: {error}"
"Count" = "Count"
"Create" = "Create"
@@ -632,6 +634,7 @@ name = "English"
"Included" = "Included"
"Included in layout" = "Included in layout"
"Individual corners" = "Individual corners"
"Individual padding" = "Individual padding"
"Individual strokes" = "Individual strokes"
"Ink" = "Ink"
"Inner shadow" = "Inner shadow"
@@ -653,6 +656,7 @@ name = "English"
"Interaction" = "Interaction"
"Interactions" = "Interactions"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps."
"Interface size" = "Interface size"
"Intersect" = "Intersect"
"Intersects" = "Intersects"
"Invalid color" = "Invalid color"
@@ -702,6 +706,7 @@ name = "English"
"Leaves" = "Leaves"
"Left" = "Left"
"Left & right" = "Left & right"
"Left padding" = "Left padding"
"Left side" = "Left side"
"Left side width" = "Left side width"
"Letter case" = "Letter case"
@@ -950,6 +955,7 @@ name = "English"
"PDF print settings" = "PDF print settings"
"Padding" = "Padding"
"Page" = "Page"
"Page deleted" = "Page deleted"
"Pages" = "Pages"
"Pages added to the document" = "Pages added to the document"
"Placed on the current page" = "Placed on the current page"
@@ -1061,6 +1067,7 @@ name = "English"
"Reduce motion" = "Reduce motion"
"Refraction" = "Refraction"
"Regular expression" = "Regular expression"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate."
"Remove" = "Remove"
"Remove all" = "Remove all"
"Remove annotation" = "Remove annotation"
@@ -1099,6 +1106,7 @@ name = "English"
"Rename on the server…" = "Rename on the server…"
"Rename page" = "Rename page"
"Rename to" = "Rename to"
"Reorder selection" = "Reorder selection"
"Replace all" = "Replace all"
"Replace mixed export settings" = "Replace mixed export settings"
"Replace with" = "Replace with"
@@ -1134,6 +1142,7 @@ name = "English"
"Review and merge" = "Review and merge"
"Ribbon" = "Ribbon"
"Right" = "Right"
"Right padding" = "Right padding"
"Right side" = "Right side"
"Right side width" = "Right side width"
"Rotate 90°" = "Rotate 90°"
@@ -1184,6 +1193,7 @@ name = "English"
"Search results" = "Search results"
"Search fonts" = "Search fonts"
"Search layers" = "Search layers"
"Search pages" = "Search pages"
"Search shortcuts" = "Search shortcuts"
"Section" = "Section"
"Section color" = "Section color"
@@ -1298,6 +1308,7 @@ name = "English"
"Start from a blank canvas" = "Start from a blank canvas"
"Start from a template" = "Start from a template"
"Start on path" = "Start on path"
"Start on the Home screen" = "Start on the Home screen"
"Start point" = "Start point"
"Start sharing" = "Start sharing"
"Start sharing with the Share button, then pass on the code it gives" = "Start sharing with the Share button, then pass on the code it gives"
@@ -1371,6 +1382,7 @@ name = "English"
"The selection uses several text styles: click to apply one" = "The selection uses several text styles: click to apply one"
"The server deletes this file if nobody opens it before {date}." = "The server deletes this file if nobody opens it before {date}."
"The whole file" = "The whole file"
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%."
"Theme" = "Theme"
"There's no destination for that action" = "There's no destination for that action"
"Thickness" = "Thickness"
@@ -1381,6 +1393,7 @@ name = "English"
"This condition is edited as an expression" = "This condition is edited as an expression"
"This file" = "This file"
"This file has unsaved changes" = "This file has unsaved changes"
"This file is too large to join here (up to {mb} MB)" = "This file is too large to join here (up to {mb} MB)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code."
"This frame is new on the branch." = "This frame is new on the branch."
@@ -1402,6 +1415,7 @@ name = "English"
"Top" = "Top"
"Top & bottom" = "Top & bottom"
"Top left radius" = "Top left radius"
"Top padding" = "Top padding"
"Top right radius" = "Top right radius"
"Transform" = "Transform"
"Triangle arrow" = "Triangle arrow"
@@ -1450,6 +1464,7 @@ name = "English"
"Use number keys for opacity" = "Use number keys for opacity"
"Use the .figcraft or .fig extension." = "Use the .figcraft or .fig extension."
"Use the layer's fill" = "Use the layer's fill"
"Use your browser's zoom to change the interface size." = "Use your browser's zoom to change the interface size."
"Used in {files} · {instances}" = "Used in {files} · {instances}"
"Username" = "Username"
"Value" = "Value"
@@ -1625,6 +1640,7 @@ name = "English"
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"
"slot content assignment target is missing or is not slot content" = "slot content assignment target is missing or is not slot content"
"slot content assignment uses the empty GUID sentinel and was skipped" = "slot content assignment uses the empty GUID sentinel and was skipped"
"slot layers without a matching slot property were imported as frames" = "slot layers without a matching slot property were imported as frames"
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke"
"stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored"
@@ -1666,6 +1682,7 @@ name = "English"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} is forgotten on this computer; the file stays on {server}"
"{name} is on the server." = "{name} is on the server."
"{name} is spotlighting" = "{name} is spotlighting"
"{name} is too large to open in the browser (over 512 MB)." = "{name} is too large to open in the browser (over 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nClick to apply to the selection"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it."
"{name} (from {library})" = "{name} (from {library})"
@@ -1719,6 +1736,7 @@ name = "English"
"{n}m" = "{n}m"
"{n}w" = "{n}w"
"{page}, nothing selected" = "{page}, nothing selected"
"{percent}%" = "{percent}%"
"{profile} (current)" = "{profile} (current)"
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written."
"{server} can't say what became of your files (it is older than this app)." = "{server} can't say what became of your files (it is older than this app)."
+25
View File
@@ -175,6 +175,7 @@ status = "draft — needs native review"
"Both directions" = "Ambas direcciones"
"Bottom" = "Abajo"
"Bottom left radius" = "Radio inferior izquierdo"
"Bottom padding" = "Margen interno inferior"
"Bottom right radius" = "Radio inferior derecho"
"Branch" = "Rama"
"Branch archived" = "Rama archivada"
@@ -320,6 +321,7 @@ status = "draft — needs native review"
"Corner radius" = "Radio de esquina"
"Corner smoothing" = "Suavizado de esquinas"
"Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda."
"Couldn't read {name}." = "No se pudo leer {name}."
"Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}"
"Count" = "Número"
"Create" = "Crear"
@@ -633,6 +635,7 @@ status = "draft — needs native review"
"Included" = "Incluidos"
"Included in layout" = "Incluidos en el diseño"
"Individual corners" = "Esquinas individuales"
"Individual padding" = "Margen interno individual"
"Individual strokes" = "Trazos individuales"
"Ink" = "Tinta"
"Inner shadow" = "Sombra interior"
@@ -654,6 +657,7 @@ status = "draft — needs native review"
"Interaction" = "Interacción"
"Interactions" = "Interacciones"
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft."
"Interface size" = "Tamaño de la interfaz"
"Intersect" = "Intersecar"
"Intersects" = "Intersecciones"
"Invalid color" = "Color no válido"
@@ -703,6 +707,7 @@ status = "draft — needs native review"
"Leaves" = "Hojas"
"Left" = "Izquierda"
"Left & right" = "Izquierda y derecha"
"Left padding" = "Margen interno izquierdo"
"Left side" = "Lado izquierdo"
"Left side width" = "Ancho del lado izquierdo"
"Letter case" = "Mayúsculas y minúsculas"
@@ -951,6 +956,7 @@ status = "draft — needs native review"
"PDF print settings" = "Ajustes de impresión PDF"
"Padding" = "Margen interno"
"Page" = "Página"
"Page deleted" = "Página eliminada"
"Pages" = "Páginas"
"Pages added to the document" = "Páginas añadidas al documento"
"Placed on the current page" = "Colocado en la página actual"
@@ -1062,6 +1068,7 @@ status = "draft — needs native review"
"Reduce motion" = "Reducir movimiento"
"Refraction" = "Refracción"
"Regular expression" = "Expresión regular"
"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativo al escalado de pantalla del sistema (100% es el tamaño del propio sistema). El zoom del documento es independiente."
"Remove" = "Quitar"
"Remove all" = "Quitar todo"
"Remove annotation" = "Quitar anotación"
@@ -1100,6 +1107,7 @@ status = "draft — needs native review"
"Rename on the server…" = "Cambiar el nombre en el servidor…"
"Rename page" = "Cambiar nombre de página"
"Rename to" = "Nuevo nombre"
"Reorder selection" = "Reordenar selección"
"Replace all" = "Reemplazar todo"
"Replace mixed export settings" = "Reemplazar ajustes de exportación mixtos"
"Replace with" = "Reemplazar con"
@@ -1135,6 +1143,7 @@ status = "draft — needs native review"
"Review and merge" = "Revisar y fusionar"
"Ribbon" = "Cinta"
"Right" = "Derecha"
"Right padding" = "Margen interno derecho"
"Right side" = "Lado derecho"
"Right side width" = "Ancho del lado derecho"
"Rotate 90°" = "Girar 90°"
@@ -1185,6 +1194,7 @@ status = "draft — needs native review"
"Search results" = "Resultados de búsqueda"
"Search fonts" = "Buscar fuentes"
"Search layers" = "Buscar capas"
"Search pages" = "Buscar páginas"
"Search shortcuts" = "Buscar atajos"
"Section" = "Sección"
"Section color" = "Color de sección"
@@ -1299,6 +1309,7 @@ status = "draft — needs native review"
"Start from a blank canvas" = "Empieza con un lienzo en blanco"
"Start from a template" = "Empieza con una plantilla"
"Start on path" = "Inicio en el trazado"
"Start on the Home screen" = "Empezar en Inicio"
"Start point" = "Punto inicial"
"Start sharing" = "Empezar a compartir"
"Start sharing with the Share button, then pass on the code it gives" = "Empieza a compartir con el botón Compartir y pasa el código que te da"
@@ -1372,6 +1383,7 @@ status = "draft — needs native review"
"The selection uses several text styles: click to apply one" = "La selección usa varios estilos de texto: haz clic para aplicar uno"
"The server deletes this file if nobody opens it before {date}." = "El servidor elimina este archivo si nadie lo abre antes del {date}."
"The whole file" = "El archivo completo"
"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "La ventana es demasiado pequeña para {size}%, así que la interfaz se dibuja al {drawn}%. Agranda la ventana para usar {size}%."
"Theme" = "Tema"
"There's no destination for that action" = "No hay destino para esa acción"
"Thickness" = "Grosor"
@@ -1382,6 +1394,7 @@ status = "draft — needs native review"
"This condition is edited as an expression" = "Esta condición se edita como expresión"
"This file" = "Este archivo"
"This file has unsaved changes" = "Este archivo tiene cambios sin guardar"
"This file is too large to join here (up to {mb} MB)" = "Este archivo es demasiado grande para unirte aquí (hasta {mb} MB)"
"This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Este archivo usa fuentes que no están instaladas en este equipo. Su texto se dibuja con una fuente de reserva hasta que las instales."
"This file is on {server}, open only to you. Share it to let others in with a new code." = "Este archivo está en {server}, abierto solo para ti. Compártelo para dejar entrar a otros con un código nuevo."
"This frame is new on the branch." = "Este marco es nuevo en la rama."
@@ -1403,6 +1416,7 @@ status = "draft — needs native review"
"Top" = "Arriba"
"Top & bottom" = "Arriba y abajo"
"Top left radius" = "Radio superior izquierdo"
"Top padding" = "Margen interno superior"
"Top right radius" = "Radio superior derecho"
"Transform" = "Transformar"
"Triangle arrow" = "Flecha triangular"
@@ -1451,6 +1465,7 @@ status = "draft — needs native review"
"Use number keys for opacity" = "Usar teclas numéricas para la opacidad"
"Use the .figcraft or .fig extension." = "Usa la extensión .figcraft o .fig."
"Use the layer's fill" = "Usar el relleno de la capa"
"Use your browser's zoom to change the interface size." = "Usa el zoom del navegador para cambiar el tamaño de la interfaz."
"Used in {files} · {instances}" = "Usado en {files} · {instances}"
"Username" = "Usuario"
"Value" = "Valor"
@@ -1626,6 +1641,7 @@ status = "draft — needs native review"
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables"
"slot content assignment target is missing or is not slot content" = "el destino de la asignación de contenido de ranura falta o no es contenido de ranura"
"slot content assignment uses the empty GUID sentinel and was skipped" = "la asignación de contenido de ranura usa el GUID vacío como centinela y se omitió"
"slot layers without a matching slot property were imported as frames" = "las capas de ranura sin una propiedad de ranura correspondiente se importaron como marcos"
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples"
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa"
"stale or invalid grid track sizing entries were ignored" = "se ignoraron las entradas de tamaño de pista de cuadrícula obsoletas o no válidas"
@@ -1667,6 +1683,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en este ordenador; el archivo se queda en {server}"
"{name} is on the server." = "{name} está en el servidor."
"{name} is spotlighting" = "{name} está en el foco"
"{name} is too large to open in the browser (over 512 MB)." = "{name} es demasiado grande para abrirlo en el navegador (más de 512 MB)."
"{name}\nClick to apply to the selection" = "{name}\nHaz clic para aplicarlo a la selección"
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nArrastra al lienzo o haz clic para colocarlo en el centro de la vista.\nMantén Alt (Opción) al soltar sobre una instancia para intercambiarla."
"{name} (from {library})" = "{name} (de {library})"
@@ -1720,6 +1737,7 @@ status = "draft — needs native review"
"{n}m" = "{n} min"
"{n}w" = "{n} sem"
"{page}, nothing selected" = "{page}, nada seleccionado"
"{percent}%" = "{percent}%"
"{profile} (current)" = "{profile} (actual)"
"{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} es su valor; {property ? \"a\" : \"b\"} elige un texto. Cualquier otro {…} se queda tal cual."
"{server} can't say what became of your files (it is older than this app)." = "{server} no puede decir qué fue de tus archivos (es anterior a esta aplicación)."
@@ -1762,6 +1780,7 @@ status = "draft — needs native review"
"arrange.front" = "Traer al frente"
"arrange.packH" = "Agrupar en horizontal"
"arrange.packV" = "Agrupar en vertical"
"arrange.reorder" = "Reordenar selección"
"arrange.roundToPixels" = "Redondear a píxeles"
"arrange.spacing" = "Espaciado"
"arrange.tidy" = "Ordenar"
@@ -1889,6 +1908,7 @@ status = "draft — needs native review"
"file.exportLayers" = "Exportar capas"
"file.exportSvg" = "Exportar selección como SVG"
"file.import" = "Importar"
"file.moveTab" = "Mover pestaña"
"file.new" = "Nuevo archivo de diseño"
"file.newFromTemplate" = "Nuevo desde plantilla"
"file.open" = "Abrir"
@@ -2031,6 +2051,8 @@ status = "draft — needs native review"
"pref.reduceMotion" = "Reducir movimiento"
"pref.renameDuplicates" = "Renombrar capas duplicadas"
"pref.showDimensions" = "Mostrar dimensiones en los objetos"
"pref.startOnHome" = "Empezar en Inicio"
"pref.uiScale" = "Tamaño de la interfaz"
"pref.userName" = "Tu nombre en los comentarios"
"proto.action.add" = "Añadir acción de prototipo"
"proto.action.move" = "Reordenar acción de prototipo"
@@ -2136,6 +2158,7 @@ status = "draft — needs native review"
"text.listIndent" = "Aumentar sangría de la lista"
"text.listOutdent" = "Reducir sangría de la lista"
"text.listStyle" = "Estilo de lista"
"text.loadFont" = "Cargar archivo de fuente"
"text.maxLines" = "Máximo de líneas"
"text.missingFonts" = "Fuentes que faltan"
"text.numberList" = "Lista numerada"
@@ -2144,7 +2167,9 @@ status = "draft — needs native review"
"text.paragraphSpacing" = "Espacio entre párrafos"
"text.pathOptions" = "Opciones de texto en trazado"
"text.preview" = "Previsualizar cambio de texto"
"text.refit" = "Ajustar texto a las fuentes"
"text.removeFromPath" = "Quitar texto del trazado"
"text.rescanFonts" = "Volver a buscar fuentes instaladas"
"text.set" = "Establecer texto"
"text.setFeatures" = "Funciones OpenType"
"text.setVariations" = "Ejes de fuente variable"
File diff suppressed because it is too large Load Diff

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