Merge main into #1655

This commit is contained in:
bflatastic
2026-10-30 19:49:39 +07:00
187 changed files with 7598 additions and 941 deletions
Generated
+28 -1
View File
@@ -768,6 +768,15 @@ dependencies = [
"strsim",
]
[[package]]
name = "clap_complete"
version = "4.5.67"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1248e420506b523e97ebd7b610656114abadbe73b04279a86768a12ce1860837"
dependencies = [
"clap",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
@@ -786,6 +795,16 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "clap_mangen"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4"
dependencies = [
"clap",
"roff",
]
[[package]]
name = "clipboard-win"
version = "5.4.3"
@@ -1115,7 +1134,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
dependencies = [
"chrono",
"dunce",
@@ -1779,6 +1798,8 @@ name = "figcraft-cli"
version = "0.12.1"
dependencies = [
"clap",
"clap_complete",
"clap_mangen",
"figcraft-doc",
"figcraft-engine",
"figcraft-format",
@@ -4468,6 +4489,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "roff"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
[[package]]
name = "roxmltree"
version = "0.20.0"
+4 -3
View File
@@ -33,7 +33,8 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
# Keep coupled craft-libs crates on one rev. craft-update has no sibling-crate
# dependencies and is pinned separately for the suite updater rollout.
# Which paths stored in a document may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
@@ -41,8 +42,8 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# craft-update with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", features = ["egui"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+20 -2
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -24,3 +24,8 @@ figcraft-mcp = { workspace = true }
serde_json = { workspace = true }
clap = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
# 4.6 breaks Bash subcommand completion for hyphenated binary names (clap-rs/clap#6421).
clap_complete = "~4.5.67"
clap_mangen = "0.3"
@@ -0,0 +1,44 @@
//! Writes figcraft-cli's shell completions and recursive man pages into a directory:
//!
//! ```sh
//! cargo run -p figcraft-cli --example man-and-completions -- <out-dir>
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[path = "../src/args.rs"]
#[expect(dead_code, reason = "Generation uses clap's command metadata, not the parsed fields or runtime parser.")]
mod args;
use std::path::Path;
use std::process::ExitCode;
use clap::{CommandFactory, ValueEnum};
use clap_complete::Shell;
fn main() -> ExitCode {
let mut args = std::env::args_os().skip(1);
let (Some(dir), None) = (args.next(), args.next()) else {
eprintln!("usage: man-and-completions <out-dir>");
return ExitCode::from(2);
};
let dir = Path::new(&dir);
match generate(dir) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {}: {e}", dir.display());
ExitCode::FAILURE
}
}
}
pub(crate) fn generate(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let mut command = args::Cli::command();
let name = command.get_name().to_owned();
for &shell in Shell::value_variants() {
clap_complete::generate_to(shell, &mut command, &name, dir)?;
}
// Completions build the command and add synthetic help subcommands. A fresh command keeps
// those out of the man pages (such as figcraft-cli-help-export.1).
clap_mangen::generate_to(args::Cli::command(), dir)
}
+39 -1
View File
@@ -41,6 +41,11 @@ pub enum Command {
#[command(subcommand)]
command: PluginCommand,
},
/// Publish a code repository's component mappings (code connect) to a design file
Connect {
#[command(subcommand)]
command: ConnectCommand,
},
Perf(PerfArgs),
}
@@ -228,6 +233,31 @@ pub struct PublishArgs {
pub server: String,
}
#[derive(Subcommand)]
pub enum ConnectCommand {
Publish(ConnectPublishArgs),
}
/// Link a design file's components to their code as a figcraft.connect.json says, and save it
///
/// The mappings are what Dev Mode's Export code connections writes, kept in the code repository:
/// each links a component (or component set) of that name to its code component for a language.
/// Entries that don't fit (an unknown component, a bad template) are reported and skipped.
#[derive(Args)]
pub struct ConnectPublishArgs {
/// The design file (.figcraft), saved in place
#[arg(value_name = "FILE")]
pub file: String,
/// The mappings
#[arg(long, value_name = "FILE", default_value = "figcraft.connect.json")]
pub mappings: String,
/// Report what would be linked without saving the file
#[arg(long)]
pub dry_run: bool,
#[command(flatten)]
pub roots: RootArgs,
}
/// Measure the hot paths on synthetic documents and print Markdown tables
#[derive(Args)]
pub struct PerfArgs {
@@ -326,7 +356,7 @@ mod tests {
use clap::CommandFactory;
use clap::error::ErrorKind;
use super::{Cli, Command, PluginCommand, parse};
use super::{Cli, Command, ConnectCommand, PluginCommand, parse};
fn try_parse(args: &[&str]) -> Result<Cli, clap::Error> {
parse(std::iter::once("figcraft-cli").chain(args.iter().copied()).map(Into::into).collect())
@@ -434,6 +464,14 @@ mod tests {
panic!("plugin")
};
assert_eq!((a.folder.as_str(), a.server.as_str()), ("dist/counter", "ws://h:47900/"));
let Command::Connect { command: ConnectCommand::Publish(a) } = ok(&["connect", "publish", "ui.figcraft"]) else { panic!("connect") };
assert_eq!((a.file.as_str(), a.mappings.as_str(), a.dry_run), ("ui.figcraft", "figcraft.connect.json", false));
let Command::Connect { command: ConnectCommand::Publish(a) } =
ok(&["connect", "publish", "ui.figcraft", "--mappings", "code/map.json", "--dry-run"])
else {
panic!("connect")
};
assert_eq!((a.mappings.as_str(), a.dry_run), ("code/map.json", true));
}
#[test]
+84 -1
View File
@@ -402,8 +402,12 @@ pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
.collect()
}
/// The largest script `run` reads (one may carry images as base64): a device, a pipe or a larger
/// file is refused before it is read (#1114).
pub const MAX_SCRIPT: u64 = 1 << 30;
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
let bytes = figcraft_engine::file_access::read_capped(script, MAX_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, input_images_left_out) = match input {
@@ -455,6 +459,34 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
Ok(ExitCode::SUCCESS)
}
/// Link the components of `file` (a `.figcraft`) to their code as `mappings` (a
/// `figcraft.connect.json`) says, and save it in place unless `dry_run`. Prints what was linked;
/// entries that were skipped are warnings on stderr, and linking nothing fails (exit code 1).
pub fn connect_publish(file: &str, mappings: &str, dry_run: bool) -> CliResult<ExitCode> {
if ext(file) != "figcraft" {
return Err(CliError::Usage(format!("{file}: connect publish saves code links in a .figcraft file")));
}
let mut s = Session::empty();
s.execute("file.open", &json!({"path": file})).map_err(|e| CliError::Failed(format!("can't open {file}: {e}")))?;
let r = s.execute("codeConnect.import", &json!({"path": mappings})).map_err(|e| CliError::Failed(format!("{mappings}: {e}")))?;
for w in r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str) {
eprintln!("warning: {w}");
}
let linked = r["linked"].as_u64().unwrap_or(0);
if linked == 0 {
eprintln!("error: no component of {file} matches a link in {mappings}: nothing published");
return Ok(ExitCode::from(1));
}
if dry_run {
println!("{linked} component link(s) from {mappings} would be published to {file} (dry run: not saved)");
return Ok(ExitCode::SUCCESS);
}
// The engine's save: written whole or not at all, with the file's version history.
s.execute("file.save", &json!({})).map_err(|e| CliError::Failed(format!("can't save {file}: {e}")))?;
println!("published {linked} component link(s) from {mappings} to {file}");
Ok(ExitCode::SUCCESS)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -538,6 +570,57 @@ mod tests {
dir
}
/// A script path that is a folder or a device is refused before anything is read (#1114).
#[test]
fn a_script_that_isnt_a_regular_file_is_refused() {
let dir = temp_dir("script-caps");
let mut bad = vec![dir.to_string_lossy().into_owned()];
if cfg!(unix) {
bad.push("/dev/zero".into());
}
for path in bad {
let Err(CliError::Failed(e)) = run_script(&path, None, None) else { panic!("{path} ran") };
assert!(e.contains("is a folder, not a file") || e.contains("isn't a regular file"), "{path}: {e}");
}
std::fs::remove_dir_all(dir).unwrap();
}
/// `connect publish` links the file's components as the mappings say and saves the file in
/// place; a dry run saves nothing, and mappings that link nothing fail.
#[test]
fn connect_publish_links_components_and_saves_the_file() {
let dir = temp_dir("connect");
let file = dir.join("ui.figcraft");
let file = file.to_str().unwrap();
let mut s = Session::new();
let frame = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Card"})).unwrap();
s.execute("component.create", &json!({"ids": [frame["id"]]})).unwrap();
s.execute("file.saveAs", &json!({"path": file})).unwrap();
let mappings = dir.join("figcraft.connect.json");
let link =
|component: &str| json!({"component": component, "language": "react", "importPath": "@acme/ui", "name": "Card", "template": "<Card />"});
std::fs::write(&mappings, json!({"version": 1, "links": [link("Card"), link("Nope")]}).to_string()).unwrap();
let mappings = mappings.to_str().unwrap();
let links = || {
let mut s = Session::empty();
s.execute("file.open", &json!({"path": file})).unwrap();
let doc = s.doc().unwrap();
doc.nodes().filter(|n| n.name == "Card").map(|n| n.code_links.len()).sum::<usize>()
};
assert_eq!(connect_publish(file, mappings, true).unwrap(), ExitCode::SUCCESS);
assert_eq!(links(), 0, "a dry run saves nothing");
assert_eq!(connect_publish(file, mappings, false).unwrap(), ExitCode::SUCCESS, "an unknown component is only a warning");
assert_eq!(links(), 1, "Card is linked to its code in the saved file");
let none = dir.join("none.json");
std::fs::write(&none, json!({"version": 1, "links": [link("Nope")]}).to_string()).unwrap();
assert_eq!(connect_publish(file, none.to_str().unwrap(), false).unwrap(), ExitCode::from(1));
assert!(matches!(connect_publish(dir.join("ui.svg").to_str().unwrap(), mappings, false), Err(CliError::Usage(_))));
assert!(matches!(connect_publish(file, dir.join("missing.json").to_str().unwrap(), false), Err(CliError::Failed(_))));
std::fs::remove_dir_all(dir).unwrap();
}
/// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
/// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
/// as "no parent" and the layer landed on the page — with every step reporting success.
+3 -2
View File
@@ -1,6 +1,6 @@
//! `figcraft-cli`: FigCraft without a window. Inspect, export, convert and render documents, run
//! command scripts, list commands, serve MCP on stdio, publish plugins to a server's plugin
//! directory and measure performance.
//! directory, publish code connect mappings to a design file and measure performance.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod args;
@@ -10,7 +10,7 @@ mod perf;
use std::io::Write;
use std::process::ExitCode;
use args::{Cli, Command, PluginCommand, RootArgs};
use args::{Cli, Command, ConnectCommand, PluginCommand, RootArgs};
use serde_json::{Value, json};
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
@@ -93,6 +93,7 @@ fn run(cli: Cli) -> CliResult<ExitCode> {
// `--attach` conflicts with the roots: the app runs the commands, only its own roots can confine them.
Command::Mcp(a) => mcp(a.attach.as_deref(), roots_of(&a.roots)?),
Command::Plugin { command: PluginCommand::Publish(a) } => publish_plugin(&a.folder, &a.server),
Command::Connect { command: ConnectCommand::Publish(a) } => confined(&a.roots, || files::connect_publish(&a.file, &a.mappings, a.dry_run)),
Command::Perf(a) => {
let sizes: Vec<usize> = a.layers.into_iter().map(|n| n.clamp(10, 2_000_000)).collect();
let instances: Vec<usize> = a.instances.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect();
@@ -0,0 +1,164 @@
//! Generated CLI documentation uses the parser's command definition, without running commands.
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
#[path = "../../examples/man-and-completions.rs"]
#[expect(dead_code, reason = "These tests call generate directly; the example's main is smoke-tested separately.")]
mod generator;
struct TempDir(PathBuf);
impl TempDir {
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn tmp(name: &str) -> std::io::Result<TempDir> {
static NEXT: AtomicUsize = AtomicUsize::new(0);
loop {
let n = NEXT.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{n}-{name}", std::process::id()));
match fs::create_dir(&dir) {
Ok(()) => return Ok(TempDir(dir)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
}
#[test]
fn completions_name_the_cli_its_subcommands_and_options() -> std::io::Result<()> {
let temp = tmp("completions")?;
let dir = temp.path();
generator::generate(dir)?;
for name in ["figcraft-cli.bash", "figcraft-cli.elv", "figcraft-cli.fish", "_figcraft-cli.ps1", "_figcraft-cli"] {
let text = fs::read_to_string(dir.join(name))?;
for word in [
"figcraft-cli",
"info",
"export",
"convert",
"render",
"run",
"commands",
"mcp",
"plugin",
"perf",
"publish",
"no-system-fonts",
"format",
"automation-read-root",
"server",
] {
assert!(text.contains(word), "{name} does not contain {word}");
}
assert!(!text.contains("man-and-completions"), "{name} names the example instead of the CLI");
}
Ok(())
}
#[test]
fn bash_dispatch_states_have_matching_option_blocks() -> std::io::Result<()> {
let temp = tmp("bash-completion-states")?;
let dir = temp.path();
generator::generate(dir)?;
let text = fs::read_to_string(dir.join("figcraft-cli.bash"))?;
let states: Vec<_> =
text.lines().filter_map(|line| line.trim().strip_prefix("cmd=\"")?.strip_suffix('"')).filter(|state| !state.is_empty()).collect();
assert!(states.len() > 1, "the completion has no subcommand dispatch states");
for state in states {
let block = format!("{state})");
assert!(text.lines().any(|line| line.trim() == block), "Bash dispatch state {state} has no option block");
}
Ok(())
}
#[test]
fn man_pages_cover_every_subcommand_without_synthetic_help_pages() -> std::io::Result<()> {
let temp = tmp("man-pages")?;
let dir = temp.path();
generator::generate(dir)?;
let mut pages = Vec::new();
for entry in fs::read_dir(dir)? {
let path = entry?.path();
if path.extension().is_some_and(|ext| ext == "1") {
pages.push(path.file_name().unwrap_or_default().to_string_lossy().into_owned());
}
}
pages.sort();
assert_eq!(
pages,
[
"figcraft-cli-commands.1",
"figcraft-cli-connect-publish.1",
"figcraft-cli-connect.1",
"figcraft-cli-convert.1",
"figcraft-cli-export.1",
"figcraft-cli-info.1",
"figcraft-cli-mcp.1",
"figcraft-cli-perf.1",
"figcraft-cli-plugin-publish.1",
"figcraft-cli-plugin.1",
"figcraft-cli-render.1",
"figcraft-cli-run.1",
"figcraft-cli.1",
]
);
for name in &pages {
let text = fs::read_to_string(dir.join(name))?.replace("\\-", "-");
assert!(text.contains(".SH NAME"), "{name} has no NAME section");
assert!(text.contains(name.trim_end_matches(".1")), "{name} has the wrong command name");
assert!(!text.contains("man-and-completions"), "{name} names the example instead of the CLI");
}
for (page, option) in [
("figcraft-cli.1", "--no-system-fonts"),
("figcraft-cli-export.1", "--format"),
("figcraft-cli-export.1", "--automation-read-root"),
("figcraft-cli-plugin-publish.1", "--server"),
("figcraft-cli-connect-publish.1", "--dry-run"),
] {
let text = fs::read_to_string(dir.join(page))?.replace("\\-", "-");
assert!(text.contains(option), "{page} does not contain {option}");
}
Ok(())
}
#[test]
fn generation_creates_the_output_directory_and_can_run_again() -> std::io::Result<()> {
let temp = tmp("generated-docs-repeat")?;
let dir = temp.path();
let output = dir.join("new").join("docs");
assert!(!output.exists());
generator::generate(&output)?;
let page = output.join("figcraft-cli.1");
let first = fs::read(&page)?;
assert!(!first.is_empty());
fs::write(&page, "old page")?;
generator::generate(&output)?;
assert_eq!(fs::read(page)?, first);
assert_eq!(fs::read_dir(output)?.count(), 18);
Ok(())
}
#[test]
fn generation_returns_filesystem_errors() -> std::io::Result<()> {
let temp = tmp("generated-docs-errors")?;
let dir = temp.path();
let file = dir.join("not-a-directory");
fs::write(&file, "keep me")?;
assert!(generator::generate(&file).is_err());
assert_eq!(fs::read_to_string(&file)?, "keep me");
// A valid directory can still fail when writing one of the generated pages.
fs::create_dir(dir.join("figcraft-cli.1"))?;
assert!(generator::generate(dir).is_err());
Ok(())
}
+1
View File
@@ -6,6 +6,7 @@ mod cli;
mod export_depth;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod fonts;
mod generated_docs;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod pdf;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
+3
View File
@@ -98,3 +98,6 @@ display_name = "Filip Strajnar"
[people.Carbocode]
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
+144
View File
@@ -0,0 +1,144 @@
//! Where a connector runs between its ends: straight, in right angles or along one curve. Pure
//! geometry, shared by the engine (board connectors that follow their layers) and the importers
//! (connectors saved without their path).
use figcraft_geom::{Point, Rect, Vec2};
use crate::{Routing, Side};
/// One end of a route: a free point, or an object's box and the side the route leaves it by
/// ([`Side::Auto`]: the side facing the other end).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum RouteEnd {
Point(Point),
Box(Rect, Side),
}
impl RouteEnd {
fn centre(self) -> Point {
match self {
RouteEnd::Point(p) => p,
RouteEnd::Box(b, _) => b.center(),
}
}
}
/// How far an elbow connector going around the objects it joins keeps from them.
const ROOM: f64 = 24.0;
/// The points a connector runs through from `from` to `to`: from the middle of the side of each
/// box facing the other end (or the side it names), straight, in right angles, or (curved) its two
/// ends with the curve's two control points between them. `None` when a point isn't finite.
pub fn route_points(from: RouteEnd, to: RouteEnd, routing: Routing) -> Option<Vec<Point>> {
let (p0, s0) = attach(from, to.centre());
let (p1, s1) = attach(to, from.centre());
let boxes: Vec<Rect> = [from, to].into_iter().filter_map(|e| if let RouteEnd::Box(b, _) = e { Some(b) } else { None }).collect();
let pts = match routing {
Routing::Straight => tidy(vec![p0, p1]),
Routing::Elbow => elbow(p0, s0, p1, s1, &boxes),
Routing::Curved => curve(p0, s0, p1, s1),
};
pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts)
}
/// `pts` without repeats or turns that don't turn (three in a line across or down).
fn tidy(pts: Vec<Point>) -> Vec<Point> {
let mut out: Vec<Point> = Vec::with_capacity(pts.len());
for p in pts {
if out.last() == Some(&p) {
continue;
}
if let [.., a, b] = out.as_slice()
&& ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y))
{
out.pop();
}
out.push(p);
}
out
}
/// Where end `e` attaches (its point, or the middle of its box's side facing `other`) and that
/// side (`None`: a free point).
fn attach(e: RouteEnd, other: Point) -> (Point, Option<Side>) {
match e {
RouteEnd::Point(p) => (p, None),
RouteEnd::Box(b, side) => {
let side = match side {
Side::Auto => {
let d = other - b.center();
// Compare against the box's proportions: a wide box takes the sides more.
if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) {
if d.x >= 0.0 { Side::Right } else { Side::Left }
} else if d.y >= 0.0 {
Side::Bottom
} else {
Side::Top
}
}
s => s,
};
(side.point(b), Some(side))
}
}
}
/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no
/// way): the ends and the two control points between them.
fn curve(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>) -> Vec<Point> {
let reach = (p1 - p0).hypot() / 2.0;
let pull = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach);
vec![p0, pull(p0, s0), pull(p1, s1), p1]
}
/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a
/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`,
/// else the cheapest of a few ways around them (by length, and turns).
fn elbow(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>, boxes: &[Rect]) -> Vec<Point> {
let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal)));
let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct };
if !crosses(&direct, boxes) {
return direct;
}
// Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects.
let out = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM);
let (a, b) = (out(p0, s0), out(p1, s1));
let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]];
for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] {
ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]);
}
for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] {
ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]);
}
let cost = |r: &Vec<Point>| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::<f64>() + ROOM * r.len() as f64;
ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct)
}
/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge,
/// or out of one, is outside).
fn crosses(pts: &[Point], boxes: &[Rect]) -> bool {
const EDGE: f64 = 0.5;
pts.windows(2).any(|s| {
let (a, b) = (s[0], s[1]);
boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE)
})
}
/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end
/// follows the other): halfway across between two sides facing the same way, one turn otherwise.
fn short_elbow(p0: Point, h0: Option<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs());
let h1 = h1.unwrap_or(h0);
match (h0, h1) {
(true, true) => {
let mx = (p0.x + p1.x) / 2.0;
vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1]
}
(false, false) => {
let my = (p0.y + p1.y) / 2.0;
vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1]
}
(true, false) => vec![p0, Point::new(p1.x, p0.y), p1],
(false, true) => vec![p0, Point::new(p0.x, p1.y), p1],
}
}
+14 -2
View File
@@ -89,8 +89,20 @@ impl Style {
impl Variables {
pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) {
for v in self.variables.iter_mut().flat_map(|v| v.values.values_mut()) {
if let VariableValue::Color(c) = v {
f(c);
match v {
VariableValue::Color(c) => f(c),
// A mix's literals are stored colours too (a calculation's alpha is just a
// number): a profile conversion that left them behind would blend converted
// operands with unconverted ones, and the bound layers would silently shift
// relative to the rest of the document.
VariableValue::ColorMix { from, to, .. } => {
for op in [from, to] {
if let crate::ColorOperand::Literal { literal } = op {
f(literal);
}
}
}
_ => {}
}
}
}
+2
View File
@@ -9,6 +9,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod board;
mod board_route;
pub mod colors;
pub mod comments;
pub mod facilitation;
@@ -26,6 +27,7 @@ pub mod vector_faces;
pub mod vector_regions;
pub use board::*;
pub use board_route::{RouteEnd, route_points};
pub use colors::*;
pub use comments::*;
pub use facilitation::*;
+14 -10
View File
@@ -103,12 +103,14 @@ impl Expr {
Expr::Value(VariableValue::Alias(id)) => return vars.resolve(id, modes),
Expr::Value(VariableValue::Number(n)) => return n.is_finite().then_some(VariableValue::Number(*n)),
Expr::Value(v) => return Some(v.clone()),
Expr::Mode { collection } => return vars.collection(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone())),
Expr::Mode { collection } => {
return vars.collection_by_id(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone()));
}
Expr::InMode { variable, mode } => {
let v = vars.get(variable)?;
let c = vars.collection(&v.collection)?;
let v = vars.get_by_id(variable)?;
let c = vars.collection_by_id(&v.collection)?;
let mut at = modes.clone();
at.insert(c.id.clone(), c.find_mode(mode)?.id.clone());
at.insert(c.id.clone(), c.mode_by_id(mode)?.id.clone());
return vars.resolve(&v.id, &at);
}
Expr::Op { op, args } => (*op, args),
@@ -143,20 +145,22 @@ impl Expr {
return "…".into();
}
match self {
Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
Expr::Value(VariableValue::Alias(id)) => vars.get_by_id(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)),
Expr::Value(VariableValue::String(s)) => quote(s),
// Exact numbers and `#` colours, so the text reads back as the same expression.
Expr::Value(VariableValue::Number(n)) => format!("{n}"),
Expr::Value(VariableValue::Color(c)) => c.to_css_hex(),
Expr::Value(v) => v.display(),
Expr::Mode { collection } => {
let name = vars.collection(collection).map_or("(missing collection)", |c| c.name.as_str());
let name = vars.collection_by_id(collection).map_or("(missing collection)", |c| c.name.as_str());
format!("mode({})", name_arg(name))
}
Expr::InMode { variable, mode } => {
let var = vars.get(variable);
let mode =
var.and_then(|v| vars.collection(&v.collection)).and_then(|c| c.find_mode(mode)).map_or("(missing mode)", |m| m.name.as_str());
let var = vars.get_by_id(variable);
let mode = var
.and_then(|v| vars.collection_by_id(&v.collection))
.and_then(|c| c.mode_by_id(mode))
.map_or("(missing mode)", |m| m.name.as_str());
format!("{}[{}]", Expr::var(variable.clone()).display_at(vars, depth + 1), name_arg(mode))
}
Expr::Op { op, args } => {
@@ -230,7 +234,7 @@ fn as_text(v: &VariableValue) -> Option<String> {
match v {
VariableValue::String(s) => Some(s.clone()),
VariableValue::Number(_) | VariableValue::Boolean(_) => Some(v.display()),
VariableValue::Color(_) | VariableValue::Alias(_) => None,
VariableValue::Color(_) | VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => None,
}
}
+34
View File
@@ -138,3 +138,37 @@ fn lookups_evaluate_in_their_mode_and_modes_follow_the_layer() {
assert_eq!(mode_of("gone").eval(&vars, &light), None);
assert_eq!(in_mode("n", "dark").variables(), ["n"]);
}
#[test]
fn stored_variable_ids_keep_expression_evaluation_display_and_types() {
let mut vars = more_vars();
let mut shadow = vars.get_by_id("s").unwrap().clone();
shadow.id = "shadow".into();
shadow.name = "n".into();
vars.variables.insert(0, shadow);
let mut shadow = vars.collection("cs").unwrap().clone();
shadow.id = "shadow".into();
shadow.name = "c".into();
vars.collections.insert(0, shadow);
let modes = BTreeMap::new();
for e in [Expr::var("n"), in_mode("n", "dark")] {
assert_eq!(e.type_of(&vars), Ok(VariableType::Number));
assert!(e.display(&vars).starts_with("n-name"));
}
assert_eq!(Expr::var("n").eval(&vars, &modes), Some(VariableValue::Number(3.0)));
assert_eq!(in_mode("n", "dark").eval(&vars, &modes), Some(VariableValue::Number(7.0)));
assert_eq!(Expr::parse("n-name[Dark]", &vars), Ok(in_mode("n", "dark")));
assert_eq!(mode_of("c").eval(&vars, &modes), Some(VariableValue::String("Light".into())));
assert_eq!(mode_of("c").display(&vars), "mode(Theme)");
vars.variables.retain(|v| v.id != "n");
for e in [Expr::var("n"), in_mode("n", "dark")] {
assert_eq!(e.eval(&vars, &modes), None);
assert_eq!(e.type_of(&vars), Err("No variable `n`".into()));
assert!(e.display(&vars).contains("missing variable"));
}
vars.collections.retain(|c| c.id != "c");
assert_eq!(mode_of("c").eval(&vars, &modes), None);
assert_eq!(mode_of("c").type_of(&vars), Err("No variable collection `c`".into()));
assert_eq!(mode_of("c").display(&vars), "mode(\"(missing collection)\")");
}
+6 -6
View File
@@ -88,16 +88,16 @@ impl Expr {
return Err("The expression is nested too deeply".into());
}
let (op, args) = match self {
Expr::Value(VariableValue::Alias(id)) => return vars.get(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")),
Expr::Value(VariableValue::Alias(id)) => return vars.get_by_id(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")),
Expr::Value(VariableValue::Number(n)) if !n.is_finite() => return Err("Numbers must be finite".into()),
Expr::Value(v) => return v.type_of().ok_or_else(|| "Not a value".to_string()),
Expr::Mode { collection } => {
return vars.collection(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`"));
return vars.collection_by_id(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`"));
}
Expr::InMode { variable, mode } => {
let v = vars.get(variable).ok_or_else(|| format!("No variable `{variable}`"))?;
let c = vars.collection(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?;
return c.find_mode(mode).map(|_| v.kind).ok_or_else(|| format!("{} has no mode `{mode}`", c.name));
let v = vars.get_by_id(variable).ok_or_else(|| format!("No variable `{variable}`"))?;
let c = vars.collection_by_id(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?;
return c.mode_by_id(mode).map(|_| v.kind).ok_or_else(|| format!("{} has no mode `{mode}`", c.name));
}
Expr::Op { op, args } => (*op, args),
};
@@ -501,7 +501,7 @@ impl Parser<'_> {
self.i += 1;
let start = self.i;
let name = self.argument(']', "mode")?;
let coll = self.vars.get(&var).and_then(|v| self.vars.collection(&v.collection));
let coll = self.vars.get_by_id(&var).and_then(|v| self.vars.collection_by_id(&v.collection));
match coll.and_then(|c| c.find_mode(&name)) {
Some(m) => Ok(Expr::InMode { variable: var, mode: m.id.clone() }),
None => {
+275 -18
View File
@@ -82,6 +82,51 @@ pub enum VariableValue {
String(String),
Boolean(bool),
Alias(VariableId),
/// The referenced colour with its alpha channel replaced (a "50% variant"). Replace rather
/// than scale: applying it twice is the same value (stable under editing the opacity or the
/// chain it reads), and "at 50%" reads as the design ask.
ColorAlpha {
variable: VariableId,
opacity: f32,
},
/// A linear blend of two colour operands at `t` (0 = `from`, 1 = `to`). The space is the stored
/// one — non-premultiplied components in the document's colour profile, alpha blended like the
/// channels — deliberately not perceptual: the values a hex panel can show blend exactly, and
/// 8-bit rounding stays where it already was (display and export) rather than being done twice.
/// The trade-off: converting the profile keeps the ends exact but moves the colours in between.
ColorMix {
from: ColorOperand,
to: ColorOperand,
t: f32,
},
}
/// One operand of a colour calculation: a variable (resolved when read, so it follows its own modes,
/// aliases and calculations) or a fixed colour. Tagged by key, not by type: a bare string is a
/// colour name just as easily as a variable name.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum ColorOperand {
Variable { variable: VariableId },
Literal { literal: Color },
}
impl ColorOperand {
/// The variable it reads, if it reads one.
pub fn variable(&self) -> Option<&VariableId> {
match self {
ColorOperand::Variable { variable } => Some(variable),
ColorOperand::Literal { .. } => None,
}
}
/// The variable id it reads, for a remap (library import and swap re-point ids).
pub fn variable_mut(&mut self) -> Option<&mut VariableId> {
match self {
ColorOperand::Variable { variable } => Some(variable),
ColorOperand::Literal { .. } => None,
}
}
}
impl VariableValue {
@@ -95,22 +140,63 @@ impl VariableValue {
}
}
/// The type this value has on its own, which a typed literal needs. None for references
/// (aliases and colour calculations): what they read may be missing or loop, so only
/// resolving them gives a type, and an expression can't take one as a literal.
pub fn type_of(&self) -> Option<VariableType> {
match self {
VariableValue::Color(_) => Some(VariableType::Color),
VariableValue::Number(_) => Some(VariableType::Number),
VariableValue::String(_) => Some(VariableType::String),
VariableValue::Boolean(_) => Some(VariableType::Boolean),
VariableValue::Alias(_) => None,
VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => None,
}
}
/// The value on one line, as the panel shows it: `→ id` for an alias, `α 50% · id` and
/// `from + to @ 40%` for colour calculations, variables by id ([`Self::display_with`] names
/// them).
pub fn display(&self) -> String {
self.display_with(&|id| id.to_string())
}
/// As [`Self::display`], with the variables it reads shown by `name` (the panel shows their
/// names where ids would mean nothing to a reader).
pub fn display_with(&self, name: &dyn Fn(&str) -> String) -> String {
let percent = |x: f32| (x * 100.0).round();
let operand = |op: &ColorOperand| match op {
ColorOperand::Variable { variable } => name(variable),
// As the panel shows a colour (no `#`), with alpha digits when it isn't opaque.
ColorOperand::Literal { literal } => literal.to_css_hex().trim_start_matches('#').to_string(),
};
match self {
VariableValue::Color(c) => c.to_hex(),
VariableValue::Number(n) => crate::text::trim_num(*n),
VariableValue::String(s) => s.clone(),
VariableValue::Boolean(b) => b.to_string(),
VariableValue::Alias(a) => format!("→ {a}"),
VariableValue::Alias(a) => format!("→ {}", name(a)),
VariableValue::ColorAlpha { variable, opacity } => format!("α {}% · {}", percent(*opacity), name(variable)),
VariableValue::ColorMix { from, to, t } => format!("{} + {} @ {}%", operand(from), operand(to), percent(*t)),
}
}
/// The variable ids this value reads (its alias target, or its calculation's variable operands;
/// literal operands read nothing). Used for reference walks: cycles, deletion, library remaps.
pub fn references(&self) -> Vec<&VariableId> {
match self {
VariableValue::Alias(a) => vec![a],
VariableValue::ColorAlpha { variable, .. } => vec![variable],
VariableValue::ColorMix { from, to, .. } => from.variable().into_iter().chain(to.variable()).collect(),
_ => Vec::new(),
}
}
/// [`Self::references`], to re-point: library import and swap move them to other ids.
pub fn references_mut(&mut self) -> Vec<&mut VariableId> {
match self {
VariableValue::Alias(a) => vec![a],
VariableValue::ColorAlpha { variable, .. } => vec![variable],
VariableValue::ColorMix { from, to, .. } => from.variable_mut().into_iter().chain(to.variable_mut()).collect(),
_ => Vec::new(),
}
}
}
@@ -137,9 +223,14 @@ impl Collection {
self.modes.first().map(|m| &m.id)
}
/// The mode with id `key`, else the one named `key`.
/// The mode with id `key`, else the one named `key` (what someone typed).
pub fn find_mode(&self, key: &str) -> Option<&Mode> {
self.modes.iter().find(|m| m.id == key).or_else(|| self.modes.iter().find(|m| m.name == key))
self.mode_by_id(key).or_else(|| self.modes.iter().find(|m| m.name == key))
}
/// The mode with id `id` exactly: a stored reference, which no mode's name stands in for (#1613).
pub fn mode_by_id(&self, id: &str) -> Option<&Mode> {
self.modes.iter().find(|m| m.id == id)
}
/// The mode the collection is in under `modes` (collection → chosen mode): the chosen one if it
@@ -185,6 +276,12 @@ pub struct Variable {
pub const CODE_PLATFORMS: [&str; 3] = ["WEB", "iOS", "ANDROID"];
impl Variable {
/// Its value in `mode`, or its first value when it has none there: an export writes every
/// mode of the collection, given a value in each or not.
pub fn value_or_first(&self, mode: &str) -> Option<&VariableValue> {
self.values.get(mode).or_else(|| self.values.values().next())
}
/// Whether pickers offer this variable for properties of `scope`.
pub fn in_scope(&self, scope: &str) -> bool {
self.scopes.as_ref().is_none_or(|list| list.iter().any(|s| s == scope || s == ALL_SCOPES || (s == "ALL_FILLS" && scope.ends_with("_FILL"))))
@@ -264,8 +361,10 @@ pub struct Variables {
}
impl Variables {
pub fn get(&self, id: &str) -> Option<&Variable> {
self.variables.iter().find(|v| v.id == id || v.name == id)
/// The variable a person named: the one with id `key`, else the one named `key`. A stored
/// reference goes through [`get_by_id`](Self::get_by_id) instead.
pub fn get(&self, key: &str) -> Option<&Variable> {
self.get_by_id(key).or_else(|| self.variables.iter().find(|v| v.name == key))
}
/// Look up an already resolved variable id (a stored binding or alias): exact ids only, so a
@@ -274,23 +373,181 @@ impl Variables {
self.variables.iter().find(|v| v.id == id)
}
pub fn collection(&self, id: &str) -> Option<&Collection> {
self.collections.iter().find(|c| c.id == id || c.name == id)
/// The collection a person named: the one with id `key`, else the one named `key`. A stored
/// reference goes through [`collection_by_id`](Self::collection_by_id) instead.
pub fn collection(&self, key: &str) -> Option<&Collection> {
self.collection_by_id(key).or_else(|| self.collections.iter().find(|c| c.name == key))
}
/// [`get_by_id`](Self::get_by_id) for collections: a stored collection id (a variable's own, a
/// prototype action's, a library record's) never matches another collection's name (#1613).
pub fn collection_by_id(&self, id: &str) -> Option<&Collection> {
self.collections.iter().find(|c| c.id == id)
}
/// The distinct variables one resolve may consult. Calculations read trees, not chains, so the
/// cap counts variables rather than hops; with the memo each one is resolved at most once per
/// call, so the work is bounded by this however the graph is shaped (a diamond re-sharing a
/// subtree does not walk it twice).
pub const RESOLVE_LIMIT: usize = 32;
/// Resolve `id` in `modes` (collection → chosen mode; default mode otherwise), following aliases
/// (bounded, cycles return None).
/// and evaluating colour calculations (bounded, cycles and over-deep graphs return None).
pub fn resolve(&self, id: &str, modes: &BTreeMap<CollectionId, ModeId>) -> Option<VariableValue> {
let mut cur = id.to_string();
for _ in 0..32 {
let var = self.get_by_id(&cur)?;
let coll = self.collection(&var.collection)?;
let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?;
match var.values.get(mode).or_else(|| var.values.values().next())? {
VariableValue::Alias(next) => cur = next.clone(),
v => return Some(v.clone()),
self.resolve_memo(id, modes, &mut BTreeMap::new())
}
/// `val`, a value of `var` shown in `mode`, as a format without colour calculations writes it:
/// a calculation becomes the colour it evaluates to there (other collections in their default
/// modes), anything else stays as given. None: the calculation doesn't resolve.
pub fn flattened(&self, var: &Variable, mode: &str, val: &VariableValue) -> Option<VariableValue> {
self.flattened_as(&var.id, &var.collection, mode, val)
}
/// [`Self::flattened`] for a value of variable `id` in `collection`, which need not exist yet.
pub fn flattened_as(&self, id: &str, collection: &str, mode: &str, val: &VariableValue) -> Option<VariableValue> {
match val {
VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => {
// `id` is in flight from the start, so a calculation that reads it back resolves to
// nothing, as it would through `resolve`.
let mut done = BTreeMap::from([(id.to_string(), None)]);
self.eval(val, &BTreeMap::from([(collection.to_string(), mode.to_string())]), &mut done)
}
other => Some(other.clone()),
}
}
/// Values as an export without colour calculations writes them, references included: see
/// [`Flattener::value`].
pub fn flattener(&self) -> Flattener<'_> {
let mut out = BTreeSet::new();
for var in &self.variables {
let Some(coll) = self.collection_by_id(&var.collection) else { continue };
if coll.modes.iter().any(|m| var.value_or_first(&m.id).is_some_and(|v| self.flattened(var, &m.id, v).is_none())) {
out.insert(var.id.as_str());
}
}
None
// Then every alias that leads to one, back along alias chains.
let mut readers: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
for var in &self.variables {
for value in var.values.values() {
if let VariableValue::Alias(target) = value {
readers.entry(target.as_str()).or_default().push(var.id.as_str());
}
}
}
let mut stack: Vec<&str> = out.iter().copied().collect();
while let Some(id) = stack.pop() {
for reader in readers.get(id).into_iter().flatten() {
if out.insert(reader) {
stack.push(reader);
}
}
}
Flattener { vars: self, unreferenceable: out }
}
/// The stored value of `id` in its collection's current mode (the mode chosen when it has a
/// value for it, else the default mode, else any value).
fn value_in(&self, id: &str, modes: &BTreeMap<CollectionId, ModeId>) -> Option<&VariableValue> {
let var = self.get_by_id(id)?;
let coll = self.collection_by_id(&var.collection)?;
let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?;
var.values.get(mode).or_else(|| var.values.values().next())
}
/// [`Self::resolve`] with `done` shared by every variable one call reads: each is resolved once
/// and its outcome kept, one met again while still in flight is a cycle (None), and the call
/// reads at most [`Self::RESOLVE_LIMIT`] variables.
fn resolve_memo(
&self,
id: &str,
modes: &BTreeMap<CollectionId, ModeId>,
done: &mut BTreeMap<String, Option<VariableValue>>,
) -> Option<VariableValue> {
if let Some(hit) = done.get(id) {
return hit.clone();
}
if done.len() >= Self::RESOLVE_LIMIT {
return None;
}
// Mark as being resolved before recursing: meeting it again mid-flight is a cycle, and the
// stored None unwinds it. Overwritten with the outcome below (a cached failure stays cached:
// a variable resolves the same however deep it is reached from — the limit only bails, it
// does not cache).
done.insert(id.to_string(), None);
let out = self.value_in(id, modes).and_then(|val| self.eval(val, modes, done));
done.insert(id.to_string(), out.clone());
out
}
/// A stored value reduced to a plain one (aliases walked, calculations evaluated).
fn eval(
&self,
val: &VariableValue,
modes: &BTreeMap<CollectionId, ModeId>,
done: &mut BTreeMap<String, Option<VariableValue>>,
) -> Option<VariableValue> {
match val {
VariableValue::Alias(next) => self.resolve_memo(next, modes, done),
VariableValue::ColorAlpha { variable, opacity } => match self.resolve_memo(variable, modes, done)? {
VariableValue::Color(c) => Some(VariableValue::Color(c.with_alpha(*opacity))),
_ => None,
},
VariableValue::ColorMix { from, to, t } => {
let a = self.operand_color(from, modes, done)?;
let b = self.operand_color(to, modes, done)?;
Some(VariableValue::Color(mix_colors(&a, &b, *t)))
}
other => Some(other.clone()),
}
}
/// A colour operand: a literal as stored, or a variable that must itself resolve to a colour.
fn operand_color(
&self,
op: &ColorOperand,
modes: &BTreeMap<CollectionId, ModeId>,
done: &mut BTreeMap<String, Option<VariableValue>>,
) -> Option<Color> {
match op {
ColorOperand::Literal { literal } => Some(literal.sanitized()),
ColorOperand::Variable { variable } => match self.resolve_memo(variable, modes, done)? {
VariableValue::Color(c) => Some(c),
_ => None,
},
}
}
}
/// Linear per-channel interpolation (`t` clamped and NaN-guarded: a command validates, a crafted
/// file must still resolve to a sane colour).
/// Values as an export that leaves out what resolves to nothing writes them ([`Variables::flattener`]).
pub struct Flattener<'v> {
vars: &'v Variables,
/// Colour calculations that resolve to nothing in some mode of their collection, and every
/// alias that leads to one.
unreferenceable: BTreeSet<&'v str>,
}
impl Flattener<'_> {
/// `val`, a value of `var` in `mode`, as [`Variables::flattened`] writes it, and an alias that
/// leads to a calculation some mode leaves out also as the value it shows here: a reference to
/// it would read nothing whenever that mode applies. None: it shows nothing in this mode.
pub fn value(&self, var: &Variable, mode: &str, val: &VariableValue) -> Option<VariableValue> {
match self.vars.flattened(var, mode, val)? {
VariableValue::Alias(target) if self.unreferenceable.contains(target.as_str()) => {
self.vars.resolve(&var.id, &BTreeMap::from([(var.collection.clone(), mode.to_string())]))
}
other => Some(other),
}
}
}
fn mix_colors(a: &Color, b: &Color, t: f32) -> Color {
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
// Weighted at both ends so t = 0 and t = 1 give each operand exactly: `x + (y - x) * t` can
// miss `y` by a rounding step (from 1.0 to 1e-8 it lands on 0).
let lerp = |x: f32, y: f32| x * (1.0 - t) + y * t;
Color::new(lerp(a.r, b.r), lerp(a.g, b.g), lerp(a.b, b.b), lerp(a.a, b.a))
}
+204 -66
View File
@@ -249,64 +249,14 @@ fn vector_network_paths() {
#[test]
fn variables_resolve_modes_and_aliases() {
let mut v = Variables::default();
v.collections.push(Collection {
id: "c1".into(),
name: "Theme".into(),
modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }],
extends: None,
});
let mut vals = std::collections::BTreeMap::new();
vals.insert("light".to_string(), VariableValue::Color(Color::WHITE));
vals.insert("dark".to_string(), VariableValue::Color(Color::BLACK));
v.variables.push(Variable {
id: "bg".into(),
name: "bg".into(),
collection: "c1".into(),
kind: VariableType::Color,
values: vals,
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
});
let mut alias = std::collections::BTreeMap::new();
alias.insert("light".to_string(), VariableValue::Alias("bg".into()));
v.variables.push(Variable {
id: "surface".into(),
name: "surface".into(),
collection: "c1".into(),
kind: VariableType::Color,
values: alias,
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
});
let mut v = vars_with_colours(&[("bg", VariableValue::Color(Color::WHITE), VariableValue::Color(Color::BLACK))]);
add(&mut v, "surface", VariableValue::Alias("bg".into()));
let mut modes = std::collections::BTreeMap::new();
assert_eq!(v.resolve("surface", &modes), Some(VariableValue::Color(Color::WHITE)));
modes.insert("c1".to_string(), "dark".to_string());
assert_eq!(v.resolve("bg", &modes), Some(VariableValue::Color(Color::BLACK)));
// Cycle.
let mut cyc = std::collections::BTreeMap::new();
cyc.insert("light".to_string(), VariableValue::Alias("loop".into()));
v.variables.push(Variable {
id: "loop".into(),
name: "loop".into(),
collection: "c1".into(),
kind: VariableType::Color,
values: cyc,
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
});
add(&mut v, "loop", VariableValue::Alias("loop".into()));
assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None);
}
@@ -319,19 +269,7 @@ fn variables_resolve_stored_ids_never_by_name() {
modes: vec![Mode { id: "m".into(), name: "Mode 1".into() }],
extends: None,
});
let var = |id: &str, name: &str, value: VariableValue| Variable {
id: id.into(),
name: name.into(),
collection: "c1".into(),
kind: VariableType::Color,
values: std::collections::BTreeMap::from([("m".to_string(), value)]),
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
};
let var = |id: &str, name: &str, value: VariableValue| colour_var(id, name, &[("m", value)]);
let red = Color::from_hex("#FF0000").unwrap();
let blue = Color::from_hex("#0D99FF").unwrap();
// Named like the next variable's id, and like an id that no longer exists.
@@ -344,6 +282,26 @@ fn variables_resolve_stored_ids_never_by_name() {
// A deleted id stays unresolved instead of picking up the variable named like it.
assert_eq!(v.resolve("VariableID:9", &modes), None);
assert_eq!(v.resolve("VariableID:4", &modes), None);
// What a person types: an id wins over a name, and a name still finds its variable (#1613).
assert_eq!(v.get("VariableID:2").map(|x| x.name.as_str()), Some("surface"));
assert_eq!(v.get("surface").map(|x| x.id.as_str()), Some("VariableID:2"));
assert!(v.get_by_id("surface").is_none());
}
#[test]
fn collections_and_modes_resolve_stored_ids_never_by_name() {
let mut v = Variables::default();
let coll = |id: &str, name: &str, modes: Vec<Mode>| Collection { id: id.into(), name: name.into(), modes, extends: None };
let mode = |id: &str, name: &str| Mode { id: id.into(), name: name.into() };
v.collections.push(coll("c1", "c2", vec![mode("m1", "m2")]));
v.collections.push(coll("c2", "Theme", vec![mode("m2", "Dark"), mode("m3", "m9")]));
assert_eq!(v.collection("c2").map(|c| c.name.as_str()), Some("Theme"), "an id wins over a name");
assert_eq!(v.collection("Theme").map(|c| c.id.as_str()), Some("c2"));
assert!(v.collection_by_id("Theme").is_none() && v.collection_by_id("c9").is_none());
let theme = v.collection_by_id("c2").unwrap();
assert_eq!(theme.mode_by_id("m2").map(|m| m.name.as_str()), Some("Dark"));
assert!(theme.mode_by_id("m9").is_none(), "a deleted mode id isn't the mode named like it");
assert_eq!(theme.find_mode("Dark").map(|m| m.id.as_str()), Some("m2"));
}
#[test]
@@ -708,3 +666,183 @@ fn two_segment_closed_loop_rounded_corners() {
let path = net.fill_path();
assert!(!path.elements().is_empty());
}
#[test]
fn stored_variable_ids_resolve_only_their_exact_collection() {
let mut vars = crate::proto_expr_tests::vars();
// The wrong collection's modes force the old resolver's first-value fallback (dark).
let mut shadow = vars.collections[0].clone();
shadow.id = "shadow".into();
shadow.name = "c".into();
shadow.modes = vec![Mode { id: "other".into(), name: "Other".into() }];
vars.collections.insert(0, shadow);
assert_eq!(vars.resolve("n", &Default::default()), Some(VariableValue::Number(3.0)));
vars.collections.retain(|c| c.id != "c");
assert_eq!(vars.resolve("n", &Default::default()), None);
}
// ——— colour calculations ———
/// A colour variable of collection `c1` with `values` by mode id.
fn colour_var(id: &str, name: &str, values: &[(&str, VariableValue)]) -> Variable {
Variable {
id: id.into(),
name: name.into(),
collection: "c1".into(),
kind: VariableType::Color,
values: values.iter().map(|(m, v)| (m.to_string(), v.clone())).collect(),
description: String::new(),
scopes: None,
hidden_from_publishing: false,
extends: None,
overrides: Default::default(),
code_syntax: Default::default(),
}
}
/// `c1` with Light/Dark (Light is the default mode) and colour variables `("id", light, dark)`.
fn vars_with_colours(cols: &[(&str, VariableValue, VariableValue)]) -> Variables {
let mut v = Variables::default();
v.collections.push(Collection {
id: "c1".into(),
name: "C".into(),
modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }],
extends: None,
});
for (id, light, dark) in cols {
v.variables.push(colour_var(id, id, &[("light", light.clone()), ("dark", dark.clone())]));
}
v
}
/// Adds `id` with a value in the Light mode only.
fn add(v: &mut Variables, id: &str, light: VariableValue) {
v.variables.push(colour_var(id, id, &[("light", light)]));
}
fn fade(source: &str, opacity: f32) -> VariableValue {
VariableValue::ColorAlpha { variable: source.into(), opacity }
}
fn mix(from: ColorOperand, to: ColorOperand, t: f32) -> VariableValue {
VariableValue::ColorMix { from, to, t }
}
fn lit(hex: &str) -> ColorOperand {
ColorOperand::Literal { literal: Color::from_hex_or_name(hex).unwrap() }
}
fn src(id: &str) -> ColorOperand {
ColorOperand::Variable { variable: id.into() }
}
fn red() -> VariableValue {
VariableValue::Color(Color::from_rgb8_const(255, 0, 0))
}
fn resolved(v: &Variables, id: &str, mode: Option<&str>) -> Color {
let modes: std::collections::BTreeMap<String, String> =
mode.map(|m| std::collections::BTreeMap::from([("c1".to_string(), m.to_string())])).unwrap_or_default();
match v.resolve(id, &modes) {
Some(VariableValue::Color(c)) => c,
other => panic!("{id} did not resolve to a colour: {other:?}"),
}
}
#[test]
fn an_alpha_calculation_replaces_alpha_and_follows_its_source() {
let mut v = vars_with_colours(&[("base", red(), VariableValue::Color(Color::from_rgb8_const(0, 0, 255)))]);
add(&mut v, "fade", fade("base", 0.5));
assert_eq!(resolved(&v, "fade", None).to_rgba8(), [255, 0, 0, 128], "default mode: red at half alpha (replace)");
assert_eq!(resolved(&v, "fade", Some("dark")).to_rgba8(), [0, 0, 255, 128], "the source's value in the chosen mode is what fades");
}
#[test]
fn an_alpha_calculation_replaces_even_a_faded_sources_alpha() {
// Replace (not scale): a fade of a source that already has alpha lands on exactly the requested
// opacity, so re-editing the source's alpha cannot compound with the fade's.
let quarter = VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.25));
let mut v = vars_with_colours(&[("base", quarter.clone(), quarter)]);
add(&mut v, "fade", fade("base", 0.5));
assert_eq!(resolved(&v, "fade", None).a, 0.5, "not 0.25 * 0.5");
}
#[test]
fn an_alpha_calculation_reads_through_an_alias() {
let mut v = vars_with_colours(&[("base", red(), red())]);
add(&mut v, "mirror", VariableValue::Alias("base".into()));
add(&mut v, "faded", fade("mirror", 0.5));
assert_eq!(resolved(&v, "faded", None).to_rgba8(), [255, 0, 0, 128]);
}
#[test]
fn a_mix_blends_stored_components_with_exact_edges() {
let mut v = vars_with_colours(&[("base", red(), VariableValue::Color(Color::from_rgb8_const(0, 0, 255)))]);
add(&mut v, "t0", mix(src("base"), lit("#FFFFFF"), 0.0));
add(&mut v, "t1", mix(src("base"), lit("#FFFFFF"), 1.0));
add(&mut v, "grey", mix(lit("#000000"), lit("#FFFFFF"), 0.4));
add(&mut v, "afade", mix(lit("#FF0000"), lit("#FF000000"), 0.25));
assert_eq!(resolved(&v, "t0", None).to_rgba8(), [255, 0, 0, 255], "t=0 is exactly the first operand");
assert_eq!(resolved(&v, "t1", None).to_rgba8(), [255, 255, 255, 255], "t=1 is exactly the second");
assert_eq!(resolved(&v, "grey", None).to_rgba8(), [102, 102, 102, 255], "0.4 of white rounds to 102 (0x66)");
assert_eq!(resolved(&v, "afade", None).to_rgba8(), [255, 0, 0, 191], "alpha blends like the channels (0.75 -> 0xBF)");
assert_eq!(resolved(&v, "t0", Some("dark")).to_rgba8(), [0, 0, 255, 255], "the variable operand follows the mode");
// Exact in the stored components too, not only after 8-bit rounding.
let faint = ColorOperand::Literal { literal: Color::new(1e-8, 0.0, 0.0, 1.0) };
add(&mut v, "to_faint", mix(lit("#FF0000"), faint.clone(), 1.0));
add(&mut v, "from_faint", mix(faint, lit("#FF0000"), 0.0));
assert_eq!(resolved(&v, "to_faint", None).r, 1e-8, "t=1 is the second operand to the last bit");
assert_eq!(resolved(&v, "from_faint", None).r, 1e-8, "t=0 is the first operand to the last bit");
}
#[test]
fn calculations_compose_and_a_crafted_cycle_terminates() {
let mut v = vars_with_colours(&[("base", red(), red())]);
add(&mut v, "fade", fade("base", 0.5));
add(&mut v, "toast", mix(src("fade"), lit("#FFFFFF"), 0.0));
assert_eq!(resolved(&v, "toast", None).to_rgba8(), [255, 0, 0, 128], "a calculation may read a calculation");
// A hand-crafted cycle (commands refuse these) must unwind, never spin.
add(&mut v, "loop", mix(src("loop2"), lit("#FFFFFF"), 0.5));
add(&mut v, "loop2", fade("loop", 0.5));
assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None);
}
#[test]
fn calculation_resolution_is_memoised_and_bounded() {
// A doubling tree: each level reads the one below twice. Memoised per resolve, that is
// one pass over the levels; unmemoised it is 2^23 walks (the time bound catches a regression).
let mut v = vars_with_colours(&[("leaf", red(), red())]);
let mut prev = "leaf".to_string();
for i in 0..23 {
let id = format!("f{i}");
add(&mut v, &id, mix(src(&prev), src(&prev), 0.5));
prev = id;
}
let started = std::time::Instant::now();
assert_eq!(resolved(&v, &prev, None).to_rgba8(), [255, 0, 0, 255], "the tree reads the leaf");
assert!(started.elapsed().as_millis() < 200, "memoised: {:?}", started.elapsed());
// A diamond: two fades of one base under one mix (shared subtrees each resolved once).
let quarter = VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.25));
let mut d = vars_with_colours(&[("base", quarter.clone(), quarter)]);
add(&mut d, "fa", fade("base", 0.25));
add(&mut d, "fb", fade("base", 0.75));
add(&mut d, "dia", mix(src("fa"), src("fb"), 0.5));
assert_eq!(resolved(&d, "dia", None).a, 0.5, "0.25 + (0.75 - 0.25) * 0.5");
// One resolve reads at most 32 variables, however they are chained.
let mut long = vars_with_colours(&[("v0", red(), red())]);
for i in 1..=40 {
add(&mut long, &format!("v{i}"), fade(&format!("v{}", i - 1), 0.99));
}
assert_eq!(resolved(&long, "v31", None).to_rgba8(), [255, 0, 0, 252], "32 variables still resolve");
assert_eq!(long.resolve("v32", &std::collections::BTreeMap::new()), None, "a 33rd reads nothing");
}
#[test]
fn the_colour_walker_visits_calculation_literals() {
// Document::map_colors (profile conversion) must reach the colours stored inside a mix:
// leaving them behind would blend converted operands with unconverted ones.
let mut v = vars_with_colours(&[("base", red(), red())]);
add(&mut v, "mx", mix(lit("#804020"), src("base"), 0.5));
let mut doc = Document::new("x");
doc.variables = v;
doc.map_colors(|c| c.with_alpha(0.5));
let mx = doc.variables.variables.iter().find(|x| x.id == "mx").unwrap();
let VariableValue::ColorMix { from, .. } = &mx.values["light"] else { panic!("still the mix") };
let ColorOperand::Literal { literal } = from else { panic!("literal operand") };
assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour");
}
+7
View File
@@ -379,6 +379,13 @@ impl TextStyle {
/// Weight from a style name ("Semi Bold Italic" → 600).
pub fn style_weight(style: &str) -> u16 {
let s = style.to_ascii_lowercase().replace([' ', '-'], "");
// A weight number ("600", "600 Italic"), as some apps name styles.
let digits: String = s.chars().take_while(char::is_ascii_digit).collect();
if let Ok(n) = digits.parse::<u16>()
&& (1..=1000).contains(&n)
{
return n;
}
let table: [(&str, u16); 10] = [
("thin", 100),
("extralight", 200),
+3 -3
View File
@@ -6,8 +6,8 @@ allow-expect-in-tests = true
allow-panic-in-tests = true
too-many-arguments-threshold = 10
disallowed-methods = [
{ path = "std::fs::read", reason = "use file_access::read (automation roots)" },
{ path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" },
{ path = "std::fs::read", reason = "use file_access::read_capped (automation roots, size caps)" },
{ path = "std::fs::read_to_string", reason = "use file_access::read_capped (automation roots, size caps)" },
{ path = "std::fs::write", reason = "use file_access::write (automation roots)" },
{ path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" },
{ path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" },
@@ -35,5 +35,5 @@ disallowed-methods = [
{ path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" },
{ path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" },
{ path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" },
{ path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" },
{ path = "figcraft_format::load_file", reason = "use file_access::read_capped (automation roots, size caps)" },
]
+32 -21
View File
@@ -18,7 +18,7 @@ use figcraft_doc::proto::Action;
use figcraft_doc::{
Collection, CollectionId, Document, ImportedComponent, ImportedRecord, ImportedStyle, LibraryAsset, LibraryCollection, LibraryLink,
LibraryManifest, LibraryStyle, LibraryVariable, Node, NodeId, NodeKind, PropDef, PublishedLibrary, Style, StyleKind, Variable, VariableId,
VariableValue, Variables, node_images, paint_images, used_images,
Variables, node_images, paint_images, used_images,
};
use serde_json::Value;
@@ -240,24 +240,27 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele
.collections
.into_iter()
.filter(|c| {
pick(prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())), doc.variables.collection(c).map(|x| x.name.as_str()))
pick(
prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())),
doc.variables.collection_by_id(c).map(|x| x.name.as_str()),
)
})
.collect(),
}
}
/// `seeds` (variable ids) and every variable they alias, transitively, by id.
/// `seeds` (variable ids) and every variable they alias or calculate from, transitively, by id.
fn alias_closure(vars: &Variables, seeds: impl IntoIterator<Item = VariableId>) -> BTreeSet<VariableId> {
let mut out = BTreeSet::new();
let mut stack: Vec<VariableId> = seeds.into_iter().collect();
while let Some(cur) = stack.pop() {
let Some(v) = vars.get(&cur) else { continue };
let Some(v) = vars.get_by_id(&cur) else { continue };
if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) {
continue;
}
for val in v.values.values() {
if let VariableValue::Alias(a) = val {
stack.push(a.clone());
for r in val.references() {
stack.push(r.clone());
}
}
}
@@ -702,7 +705,7 @@ pub fn import_variables(
) -> VariableMap {
let (lid, m, src) = (lib.manifest.id.as_str(), &lib.manifest, &lib.doc.variables);
let vars = alias_closure(src, keys.iter().cloned());
let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get(v)).map(|v| &v.collection)).collect();
let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get_by_id(v)).map(|v| &v.collection)).collect();
let mut out = VariableMap::default();
// Collections first: the variables name theirs.
for key in colls {
@@ -755,15 +758,19 @@ pub fn import_variables(
let Some(collection) = out.collections.get(&v.collection).cloned() else { continue };
let mut values = BTreeMap::new();
for (mode, val) in &v.values {
let val = match val {
// An alias to a variable the library doesn't have resolves to nothing: dropped
// (never pointed at a variable of this file).
VariableValue::Alias(a) => match src.get(a).and_then(|t| out.variables.get(&t.id)) {
Some(l) => VariableValue::Alias(l.clone()),
None => continue,
},
other => other.clone(),
};
// A value reading a variable the library doesn't have resolves to nothing: dropped
// (never pointed at a variable of this file), for aliases and calculations alike.
let mut val = val.clone();
let remapped = val.references_mut().into_iter().all(|r| match src.get_by_id(r.as_str()).and_then(|t| out.variables.get(&t.id)) {
Some(here) => {
*r = here.clone();
true
}
None => false,
});
if !remapped {
continue;
}
values.insert(mode.clone(), val);
}
let copy = Variable { id: local.clone(), collection, values, extends: None, overrides: Default::default(), ..v.clone() };
@@ -955,7 +962,7 @@ pub fn pending(doc: &Document, lib: &PublishedLibrary) -> Pending {
.filter(|r| r.library == m.id)
.map(|r| r.key.as_str())
.collect();
let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get(&r.local).is_some());
let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get_by_id(&r.local).is_some());
variables.extend(published_variables(lib).filter(|v| extended.contains(v.collection.as_str()) && !copied(&v.id)).map(|v| ImportedRecord {
library: m.id.clone(),
key: v.id.clone(),
@@ -1041,10 +1048,14 @@ pub fn prune(doc: &mut Document, library: &str) -> Result<()> {
.flat_map(|n| n.bound.values().map(String::as_str).chain(action_variables(n)))
.chain(doc.variables.variables.iter().filter_map(|v| v.extends.as_deref()))
.map(str::to_string)
.chain(doc.variables.variables.iter().filter(|v| !is_remote_variable(doc, &v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x {
VariableValue::Alias(a) => Some(a.clone()),
_ => None,
}))
.chain(
doc.variables
.variables
.iter()
.filter(|v| !is_remote_variable(doc, &v.id))
.flat_map(|v| v.values.values())
.flat_map(|x| x.references().into_iter().cloned()),
)
.collect::<Vec<_>>();
let used = alias_closure(&doc.variables, seeds);
let unused: BTreeSet<String> =
+15 -14
View File
@@ -149,19 +149,22 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
out.styles = styles.len();
// Variables: by collection and variable name and type; collections and modes by name.
let coll_name = |d: &Document, id: &str| d.variables.collection(id).map(|c| c.name.clone()).unwrap_or_default();
// Variables the document's own layers bind or its own variables alias.
let coll_name = |d: &Document, id: &str| d.variables.collection_by_id(id).map(|c| c.name.clone()).unwrap_or_default();
// Variables the document's own layers bind or its own variables alias or calculate from.
let remote: BTreeSet<String> = doc.libraries.variables.iter().map(|r| r.local.clone()).collect();
let aliases = doc.variables.variables.iter().filter(|v| !remote.contains(&v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x {
VariableValue::Alias(a) => Some(a.clone()),
_ => None,
});
let aliases = doc
.variables
.variables
.iter()
.filter(|v| !remote.contains(&v.id))
.flat_map(|v| v.values.values())
.flat_map(|x| x.references().into_iter().cloned());
let used: BTreeSet<String> = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| n.bound.values().cloned()).chain(aliases).collect();
let var_recs: Vec<ImportedRecord> = doc.libraries.variables.iter().filter(|r| r.library == from && used.contains(&r.local)).cloned().collect();
let lib_coll = |name: &str| to.doc.variables.collections.iter().find(|c| c.name == name && m.collection(&c.id).is_some_and(|x| x.published));
let mut var_keys: BTreeMap<String, String> = BTreeMap::new();
for r in &var_recs {
let Some(v) = doc.variables.get(&r.local) else { continue };
let Some(v) = doc.variables.get_by_id(&r.local) else { continue };
let cname = coll_name(doc, &v.collection);
let found = lib_coll(&cname).and_then(|c| {
to.doc
@@ -189,10 +192,10 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
// A's collection → (B's, A's mode id → B's mode of the same name).
let mut colls: BTreeMap<String, (String, BTreeMap<String, String>)> = BTreeMap::new();
for (a, k) in &coll_keys {
let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection(a)) else { continue };
let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection_by_id(a)) else { continue };
let modes = doc
.variables
.collection(b)
.collection_by_id(b)
.map(|bc| {
ac.modes.iter().filter_map(|am| bc.modes.iter().find(|bm| bm.name == am.name).map(|bm| (am.id.clone(), bm.id.clone()))).collect()
})
@@ -233,11 +236,9 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
.collect();
}
for v in doc.variables.variables.iter_mut().filter(|v| !remote.contains(&v.id)) {
for val in v.values.values_mut() {
if let VariableValue::Alias(a) = val
&& let Some(b) = vars.get(a)
{
*a = b.clone();
for r in v.values.values_mut().flat_map(VariableValue::references_mut) {
if let Some(b) = vars.get(r.as_str()) {
*r = b.clone();
}
}
}
+71 -5
View File
@@ -57,17 +57,23 @@ fn main_of(s: &Session, inst: NodeId) -> NodeId {
}
}
#[test]
fn swap_moves_instances_styles_and_variables_by_name() {
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-{}", std::process::id())));
/// A session with libraries A (blue, plus `extra_a` components) and B (green) enabled, files in a
/// scratch directory named by `tag`; returns the directory guard, the session and both library ids.
fn two_libraries(tag: &str, extra_a: &[&str]) -> (Scratch, Session, String, String) {
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-{tag}-{}", std::process::id())));
std::fs::create_dir_all(&dir.0).unwrap();
let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned());
let a = library(&pa, "#0000ff", &["Only in A"]);
let a = library(&pa, "#0000ff", extra_a);
let b = library(&pb, "#00ff00", &[]);
let mut s = Session::new();
ex(&mut s, "library.add", json!({"path": pa}));
ex(&mut s, "library.add", json!({"path": pb}));
(dir, s, a, b)
}
#[test]
fn swap_moves_instances_styles_and_variables_by_name() {
let (_dir, mut s, a, b) = two_libraries("main", &["Only in A"]);
let inst = idv(&ex(&mut s, "library.place", json!({"library": a, "component": "Button", "x": 0, "y": 0})));
let label = s.doc().unwrap().node(inst).unwrap().children[0];
ex(&mut s, "text.set", json!({"id": label, "characters": "Buy"}));
@@ -114,3 +120,63 @@ fn swap_moves_instances_styles_and_variables_by_name() {
assert!(s.execute("library.swap", &json!({"from": b, "to": b})).is_err());
assert!(s.execute("library.swap", &json!({"from": a, "to": b})).is_err());
}
#[test]
fn stored_variable_ids_keep_library_swap_matches_on_the_real_copies() {
let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-ids-{}", std::process::id())));
std::fs::create_dir_all(&dir.0).unwrap();
let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned());
let a = library(&pa, "#0000ff", &[]);
let b = library(&pb, "#00ff00", &[]);
let mut s = Session::new();
let shadow_collection = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"}));
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#ff0000"}));
ex(&mut s, "library.add", json!({"path": pa}));
ex(&mut s, "library.add", json!({"path": pb}));
let dot = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
ex(&mut s, "library.bindVariable", json!({"library": a, "variable": "accent", "field": "fill", "ids": [dot]}));
ex(&mut s, "library.setVariableMode", json!({"library": a, "collection": "Theme", "mode": "Dark", "ids": [dot]}));
let doc = s.doc().unwrap();
let target = doc.libraries.variables[0].local.clone();
let collection = doc.libraries.collections[0].local.clone();
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": target}));
ex(&mut s, "variable.renameCollection", json!({"collection": shadow_collection["id"], "name": collection}));
let result = ex(&mut s, "library.swap", json!({"from": a, "to": b}));
assert_eq!(result["variables"], 1);
assert_eq!(result["unmatched"], json!([]));
let doc = s.doc().unwrap();
let node = doc.node(dot).unwrap();
assert_eq!(node.fills[0].solid_color().unwrap().to_hex(), "00FF00");
assert!(!node.bound.is_empty());
assert!(!node.var_modes.is_empty());
assert!(node.bound.values().all(|v| doc.libraries.variable_import(v).is_some_and(|r| r.library == b)));
assert!(node.var_modes.keys().all(|c| doc.libraries.collection_import(c).is_some_and(|r| r.library == b)));
}
/// A local colour calculation reading a library variable moves to the other library's variable of
/// the same name, as a binding does.
#[test]
fn swap_repoints_colour_calculations() {
let (_dir, mut s, a, b) = two_libraries("calc", &[]);
let accent = ex(&mut s, "library.importVariable", json!({"library": a, "variable": "accent"}))["variable"].clone();
ex(&mut s, "variable.createCollection", json!({"name": "App"}));
ex(
&mut s,
"variable.create",
json!({"collection": "App", "name": "veil", "type": "color", "value": {"alpha": {"variable": accent, "opacity": 0.5}}}),
);
let wash = json!({"mix": {"from": {"variable": accent}, "to": {"literal": "#ffffff"}, "t": 0.5}});
ex(&mut s, "variable.create", json!({"collection": "App", "name": "wash", "type": "color", "value": wash}));
let r = ex(&mut s, "library.swap", json!({"from": a, "to": b}));
assert_eq!(r["variables"].as_u64(), Some(1), "{r}");
let doc = s.doc().unwrap();
let vars = &doc.variables;
let shown = |name: &str| match vars.resolve(&vars.get(name).unwrap().id, &Default::default()) {
Some(VariableValue::Color(c)) => c.to_css_hex(),
other => panic!("{name}: {other:?}"),
};
assert_eq!((shown("veil"), shown("wash")), ("#00FF0080".to_string(), "#80FF80".to_string()));
let reads: Vec<&VariableId> = ["veil", "wash"].iter().flat_map(|n| vars.get(n).unwrap().values.values()).flat_map(|v| v.references()).collect();
assert!(reads.iter().all(|r| doc.libraries.variable_import(r).is_some_and(|i| i.library == b)), "{reads:?}");
}
+29 -8
View File
@@ -236,21 +236,42 @@ fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ {
move |e| EngineError::Other(format!("{}: {e}", path.display()))
}
/// The bytes of file `path`.
pub fn read(path: impl AsRef<Path>) -> Result<Vec<u8>> {
let path = path.as_ref();
std::fs::read(allow(path, Access::Read)?).map_err(io(path))
/// `bytes` for a person: "300 bytes", "12 MB", "6.5 GB".
fn size(bytes: u64) -> String {
match bytes {
b if b < 1 << 20 => format!("{b} bytes"),
b if b < 1 << 30 => format!("{} MB", b.div_ceil(1 << 20)),
b => format!("{:.1} GB", b as f64 / f64::from(1u32 << 30)),
}
}
/// The bytes of file `path`, refused when it isn't a file or is larger than `max`.
/// The bytes of file `path`, at most `max` of them. A folder, a device, a pipe or a file larger
/// than `max` is refused before anything is read, so a path a caller gives can't fill memory or
/// wait forever (#1114). Every whole-file read of a path goes through here.
pub fn read_capped(path: impl AsRef<Path>, max: u64) -> Result<Vec<u8>> {
use std::io::Read as _;
let path = path.as_ref();
let p = allow(path, Access::Read)?;
let refuse = |why: String| EngineError::Other(format!("{}: {why}", path.display()));
// Looked at before opening: opening a pipe waits for whoever writes it.
let meta = std::fs::metadata(&p).map_err(io(path))?;
if !meta.is_file() || meta.len() > max {
return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display())));
if meta.is_dir() {
return Err(refuse("is a folder, not a file".into()));
}
std::fs::read(p).map_err(io(path))
if !meta.is_file() {
return Err(refuse("isn't a regular file (a device or a pipe?), so it isn't read".into()));
}
if meta.len() > max {
return Err(refuse(format!("is {}, more than the {} allowed here", size(meta.len()), size(max))));
}
let f = std::fs::File::open(p).map_err(io(path))?;
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(io(path))?;
// It grew after it was looked at.
if bytes.len() as u64 > max {
return Err(refuse(format!("grew past the {} allowed here while it was read", size(max))));
}
Ok(bytes)
}
/// File `path` opened for reading.
@@ -198,10 +198,10 @@ fn a_scope_confines_the_thread_and_ends_with_it() {
assert!(current().is_none());
confined(Some(&roots), || {
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err());
assert!(super::read(f.p("outside/secret.txt")).is_err());
assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in");
assert!(super::read_capped(f.p("outside/secret.txt"), 64).is_err());
assert_eq!(super::read_capped(f.p("root/in.txt"), 64).unwrap(), b"in");
// Not confined: the app's own files.
assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret");
assert_eq!(super::app_owned(|| super::read_capped(f.p("outside/secret.txt"), 64).unwrap()), b"secret");
assert!(current().is_some(), "back in the scope after app_owned");
// Another thread isn't confined by this one's scope.
let p = f.p("outside/secret.txt");
@@ -386,3 +386,8 @@ fn only_resolved_paths_count_as_the_apps_own_folder() {
assert!(!super::within_app_folder(&install.join("missing.wasm"), &install));
assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder")));
}
#[test]
fn sizes_read_as_a_person_says_them() {
assert_eq!((super::size(300), super::size((12 << 20) - 5), super::size(13 << 29)), ("300 bytes".into(), "12 MB".into(), "6.5 GB".into()));
}
+334 -139
View File
@@ -5,32 +5,188 @@ use serde_json::{Value, json};
use crate::Session;
/// Top-level node keys accepted by `fields`, including the structure kept in every tree.
pub const NODE_KEYS: &[&str] = &[
"absolute",
"absoluteBounds",
"annotations",
"arc",
"background",
"blend",
"board",
"booleanOperation",
"boundVariables",
"brush",
"characters",
"childCount",
"children",
"clip",
"codeLinks",
"componentProperties",
"componentPropertyDefinitions",
"constraints",
"cornerRadius",
"cornerSmoothing",
"dashes",
"description",
"devChanged",
"devNote",
"devResources",
"devStatus",
"effects",
"exports",
"fills",
"flows",
"fontFamily",
"fontSize",
"fontStyle",
"fontVariations",
"gridCell",
"grids",
"hangingList",
"hangingPunctuation",
"height",
"id",
"innerRadius",
"interactions",
"isMask",
"layout",
"leadingTrim",
"letterSpacing",
"lineHeight",
"lineIndentations",
"lineTypes",
"listSpacing",
"listStartOffsets",
"listStyle",
"locked",
"mainComponent",
"mainComponentName",
"maskType",
"maxHeight",
"maxLines",
"maxWidth",
"measurements",
"minHeight",
"minWidth",
"miterAngle",
"name",
"opacity",
"operandOperation",
"overrides",
"pluginData",
"pointCount",
"rotation",
"sectionContentsHidden",
"segments",
"sizing",
"slot",
"strokeAlign",
"strokeJoin",
"strokeSides",
"strokeWeight",
"strokes",
"styledRuns",
"styles",
"textAlign",
"textAutoResize",
"textDecoration",
"textDecorationColor",
"textDecorationOffset",
"textDecorationSkipInk",
"textDecorationStyle",
"textDecorationThickness",
"textPath",
"textStyleRanges",
"type",
"vertices",
"visible",
"width",
"widthProfile",
"x",
"y",
];
/// Which layer properties to build. Missing options keep the full, sparse inspection output.
#[derive(Clone, Debug, Default)]
pub struct InspectOptions {
fields: Option<std::collections::BTreeSet<&'static str>>,
outline: bool,
}
impl InspectOptions {
/// Parse the same options for engine commands, control methods and MCP tools.
pub fn parse(p: &Value) -> Result<Self, String> {
let outline = match p.get("outline") {
None => false,
Some(Value::Bool(v)) => *v,
Some(_) => return Err("`outline` must be a boolean".into()),
};
if outline && p.get("fields").is_some() {
return Err("`outline: true` cannot be combined with `fields`".into());
}
let fields = match p.get("fields") {
None if outline => Some(["id", "name", "type", "characters", "mainComponentName", "visible"].into_iter().collect()),
None => None,
Some(Value::Array(values)) => {
let mut fields = std::collections::BTreeSet::new();
for value in values {
let key = value.as_str().ok_or("`fields` must be an array of strings")?;
let key = NODE_KEYS
.iter()
.copied()
.find(|k| *k == key)
.ok_or_else(|| format!("unknown field `{key}`; valid keys: {}", NODE_KEYS.join(", ")))?;
fields.insert(key);
}
Some(fields)
}
Some(_) => return Err("`fields` must be an array of strings".into()),
};
Ok(Self { fields, outline })
}
fn includes(&self, key: &str) -> bool {
self.fields.as_ref().is_none_or(|fields| fields.contains(key))
}
fn explicit(&self, key: &str) -> bool {
self.fields.as_ref().is_some_and(|fields| fields.contains(key))
}
}
/// A node and (to `depth`) its children, with the properties a designer would check.
pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
node_json_with_options(doc, id, depth, &InspectOptions::default())
}
/// A node's selected properties, applied at every level of its subtree.
pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options: &InspectOptions) -> Value {
let Some(n) = doc.node(id) else { return Value::Null };
let mut v = json!({
"id": n.id,
"name": n.name,
"type": n.kind.type_name(),
});
let mut v = json!({"id": n.id});
let Some(o) = v.as_object_mut() else { return v };
insert_prop(o, options, "name", || json!(n.name));
insert_prop(o, options, "type", || json!(n.kind.type_name()));
if options.explicit("visible") {
insert_prop(o, options, "visible", || json!(n.visible));
}
if !n.is_page() {
base_props(doc, n, id, o);
paint_props(n, o);
layout_props(doc, n, o);
dev_props(doc, n, o);
base_props(doc, n, id, o, options);
paint_props(n, o, options);
layout_props(doc, n, o, options);
dev_props(doc, n, o, options);
}
// Layer data written with `node.set {props: {pluginData}}` (pages too).
if !n.plugin_data.is_empty() {
o.insert("pluginData".into(), json!(n.plugin_data));
insert_prop(o, options, "pluginData", || json!(n.plugin_data));
}
match &n.kind {
NodeKind::Text(t) => text_props(t, o),
_ => kind_props(doc, n, o),
NodeKind::Text(t) => text_props(t, o, options),
_ => kind_props(doc, n, o, options),
}
if !n.children.is_empty() {
if depth > 0 {
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect()));
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json_with_options(doc, *c, depth - 1, options)).collect()));
} else {
o.insert("childCount".into(), json!(n.children.len()));
}
@@ -38,272 +194,301 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
v
}
fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map<String, Value>) {
let c = doc.frame_relative(id).as_coeffs();
o.insert("x".into(), json!(round(c[4])));
o.insert("y".into(), json!(round(c[5])));
o.insert("width".into(), json!(round(n.width)));
o.insert("height".into(), json!(round(n.height)));
let rot = figcraft_geom::rotation_degrees(n.transform);
if rot.abs() > 1e-6 {
o.insert("rotation".into(), json!(round(rot)));
}
if let Some(b) = doc.world_bounds(id) {
o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
}
if !n.visible {
o.insert("visible".into(), json!(false));
}
if n.locked {
o.insert("locked".into(), json!(true));
}
if n.opacity < 1.0 {
o.insert("opacity".into(), json!(n.opacity));
}
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
o.insert("blend".into(), json!(n.blend));
/// Build a property only when it was requested (or no field list was given).
fn insert_prop(o: &mut serde_json::Map<String, Value>, options: &InspectOptions, key: &str, value: impl FnOnce() -> Value) {
if options.includes(key) {
o.insert(key.into(), value());
}
}
fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>) {
fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
if options.includes("x") || options.includes("y") {
let c = doc.frame_relative(id).as_coeffs();
insert_prop(o, options, "x", || json!(round(c[4])));
insert_prop(o, options, "y", || json!(round(c[5])));
}
insert_prop(o, options, "width", || json!(round(n.width)));
insert_prop(o, options, "height", || json!(round(n.height)));
if options.includes("rotation") {
let rot = figcraft_geom::rotation_degrees(n.transform);
if rot.abs() > 1e-6 {
insert_prop(o, options, "rotation", || json!(round(rot)));
}
}
if let Some(b) = options.includes("absoluteBounds").then(|| doc.world_bounds(id)).flatten() {
insert_prop(
o,
options,
"absoluteBounds",
|| json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}),
);
}
if !n.visible {
insert_prop(o, options, "visible", || json!(false));
}
if n.locked {
insert_prop(o, options, "locked", || json!(true));
}
if n.opacity < 1.0 {
insert_prop(o, options, "opacity", || json!(n.opacity));
}
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
insert_prop(o, options, "blend", || json!(n.blend));
}
}
fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
if !n.fills.is_empty() {
o.insert("fills".into(), paints(&n.fills));
insert_prop(o, options, "fills", || paints(&n.fills));
}
if !n.strokes.is_empty() {
o.insert("strokes".into(), paints(&n.strokes));
o.insert("strokeWeight".into(), json!(n.stroke.weight));
o.insert("strokeAlign".into(), json!(n.stroke.align));
insert_prop(o, options, "strokes", || paints(&n.strokes));
insert_prop(o, options, "strokeWeight", || json!(n.stroke.weight));
insert_prop(o, options, "strokeAlign", || json!(n.stroke.align));
if n.stroke.sides.is_some() {
o.insert("strokeSides".into(), json!(n.stroke.sides));
insert_prop(o, options, "strokeSides", || json!(n.stroke.sides));
}
if !n.stroke.dashes.is_empty() {
o.insert("dashes".into(), json!(n.stroke.dashes));
insert_prop(o, options, "dashes", || json!(n.stroke.dashes));
}
if n.stroke.join != StrokeJoin::Miter {
o.insert("strokeJoin".into(), json!(n.stroke.join));
insert_prop(o, options, "strokeJoin", || json!(n.stroke.join));
} else if n.stroke.miter_limit != 4.0 {
o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
insert_prop(o, options, "miterAngle", || json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
}
if let Some(p) = n.stroke.profile() {
let points = crate::cmd::props::width_points_json(Some(p));
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
insert_prop(o, options, "widthProfile", || {
let points = crate::cmd::props::width_points_json(Some(p));
json!({"preset": p.preset().map(|x| x.id()), "points": points})
});
}
if let Some(b) = &n.stroke.brush {
o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null)));
insert_prop(o, options, "brush", || camel(serde_json::to_value(b).unwrap_or(Value::Null)));
}
}
if !n.effects.is_empty() {
o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
insert_prop(o, options, "effects", || camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
}
if n.corners.radii.iter().any(|r| *r > 0.0) {
o.insert(
"cornerRadius".into(),
match n.corners.uniform() {
Some(r) => json!(r),
None => json!(n.corners.radii),
},
);
insert_prop(o, options, "cornerRadius", || match n.corners.uniform() {
Some(r) => json!(r),
None => json!(n.corners.radii),
});
if n.corners.smoothing > 0.0 {
o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
insert_prop(o, options, "cornerSmoothing", || json!(n.corners.smoothing));
}
}
}
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
if let Some(l) = &n.layout {
o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
insert_prop(o, options, "layout", || camel(serde_json::to_value(l).unwrap_or(Value::Null)));
}
let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
insert_prop(o, options, "sizing", || json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
}
let cl = &n.child_layout;
if cl.absolute {
o.insert("absolute".into(), json!(true));
insert_prop(o, options, "absolute", || json!(true));
}
for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] {
if let Some(v) = v {
o.insert(k.into(), json!(v));
insert_prop(o, options, k, || json!(v));
}
}
if let Some(cell) = parent_al
if let Some(cell) = (parent_al && options.includes("gridCell"))
.then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
.flatten()
.and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
{
o.insert(
"gridCell".into(),
insert_prop(o, options, "gridCell", || {
json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}),
);
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v})
});
}
if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
insert_prop(o, options, "constraints", || serde_json::to_value(n.constraints).unwrap_or(Value::Null));
}
if n.kind.is_frame_like() {
o.insert("clip".into(), json!(n.clip));
insert_prop(o, options, "clip", || json!(n.clip));
}
}
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
if !n.dev_status.is_none() {
o.insert("devStatus".into(), json!(n.dev_status.id()));
insert_prop(o, options, "devStatus", || json!(n.dev_status.id()));
}
if n.dev_changed {
o.insert("devChanged".into(), json!(true));
insert_prop(o, options, "devChanged", || json!(true));
}
if !n.dev_note.is_empty() {
o.insert("devNote".into(), json!(n.dev_note));
insert_prop(o, options, "devNote", || json!(n.dev_note));
}
if let NodeKind::Section { contents_hidden } = n.kind {
o.insert("sectionContentsHidden".into(), json!(contents_hidden));
insert_prop(o, options, "sectionContentsHidden", || json!(contents_hidden));
}
if n.is_mask {
o.insert("isMask".into(), json!(true));
o.insert("maskType".into(), json!(n.mask_type.id()));
insert_prop(o, options, "isMask", || json!(true));
insert_prop(o, options, "maskType", || json!(n.mask_type.id()));
}
if !n.grids.is_empty() {
o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
insert_prop(o, options, "grids", || camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
}
if !n.exports.is_empty() {
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
insert_prop(o, options, "exports", || serde_json::to_value(&n.exports).unwrap_or(Value::Null));
}
if let Some(b) = &n.board {
o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null));
insert_prop(o, options, "board", || serde_json::to_value(b).unwrap_or(Value::Null));
}
if !n.annotations.is_empty() {
o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
insert_prop(o, options, "annotations", || serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
}
if !n.code_links.is_empty() {
o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
insert_prop(o, options, "codeLinks", || camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
}
if !n.measurements.is_empty() {
o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
insert_prop(o, options, "measurements", || serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
}
let dev = crate::dev_resources::list_json(doc, n.id);
if dev.as_array().is_some_and(|a| !a.is_empty()) {
o.insert("devResources".into(), dev);
if options.includes("devResources") {
let dev = crate::dev_resources::list_json(doc, n.id);
if dev.as_array().is_some_and(|a| !a.is_empty()) {
insert_prop(o, options, "devResources", || dev);
}
}
if !n.interactions.is_empty() {
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
insert_prop(o, options, "interactions", || serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
}
if !n.styles.is_empty() {
o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
insert_prop(o, options, "styles", || serde_json::to_value(&n.styles).unwrap_or(Value::Null));
}
if !n.bound.is_empty() {
o.insert("boundVariables".into(), json!(n.bound));
insert_prop(o, options, "boundVariables", || json!(n.bound));
}
if !n.overrides.is_empty() {
o.insert("overrides".into(), json!(n.overrides));
insert_prop(o, options, "overrides", || json!(n.overrides));
}
if let Some(slot) = crate::components::slot_name(doc, n.id) {
o.insert("slot".into(), json!(slot));
if let Some(slot) = options.includes("slot").then(|| crate::components::slot_name(doc, n.id)).flatten() {
insert_prop(o, options, "slot", || json!(slot));
}
// An operand combining by an operation of its own.
if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
o.insert("operandOperation".into(), json!(op));
if let Some(op) = n.bool_op.filter(|_| options.includes("operandOperation") && crate::booleans::group_of(doc, n.id).is_some()) {
insert_prop(o, options, "operandOperation", || json!(op));
}
}
fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>) {
o.insert("characters".into(), json!(t.characters));
o.insert("fontFamily".into(), json!(t.style.font_family));
o.insert("fontStyle".into(), json!(t.style.font_style));
o.insert("fontSize".into(), json!(t.style.font_size));
fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
insert_prop(o, options, "characters", || {
if options.outline {
let mut chars = t.characters.chars();
let mut text: String = chars.by_ref().take(120).collect();
if chars.next().is_some() {
text.push_str("...");
}
json!(text)
} else {
json!(t.characters)
}
});
insert_prop(o, options, "fontFamily", || json!(t.style.font_family));
insert_prop(o, options, "fontStyle", || json!(t.style.font_style));
insert_prop(o, options, "fontSize", || json!(t.style.font_size));
if !t.style.variations.is_empty() {
o.insert("fontVariations".into(), json!(t.style.variations));
insert_prop(o, options, "fontVariations", || json!(t.style.variations));
}
o.insert("lineHeight".into(), json!(t.style.line_height.label()));
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
o.insert("textAlign".into(), json!(t.align_h));
o.insert("textAutoResize".into(), json!(t.auto_resize));
insert_prop(o, options, "lineHeight", || json!(t.style.line_height.label()));
insert_prop(o, options, "letterSpacing", || json!(t.style.letter_spacing.label()));
insert_prop(o, options, "textAlign", || json!(t.align_h));
insert_prop(o, options, "textAutoResize", || json!(t.auto_resize));
if t.max_lines > 0 {
o.insert("maxLines".into(), json!(t.max_lines));
insert_prop(o, options, "maxLines", || json!(t.max_lines));
}
if let Some(tp) = &t.path {
o.insert("textPath".into(), json!(tp));
insert_prop(o, options, "textPath", || json!(tp));
}
if t.style.decoration != TextDecoration::None {
o.insert("textDecoration".into(), json!(t.style.decoration));
insert_prop(o, options, "textDecoration", || json!(t.style.decoration));
}
if let Some(l) = t.style.decoration_look.as_deref() {
o.insert("textDecorationStyle".into(), json!(l.style));
o.insert("textDecorationThickness".into(), json!(l.thickness.label()));
o.insert("textDecorationOffset".into(), json!(l.offset.label()));
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
insert_prop(o, options, "textDecorationStyle", || json!(l.style));
insert_prop(o, options, "textDecorationThickness", || json!(l.thickness.label()));
insert_prop(o, options, "textDecorationOffset", || json!(l.offset.label()));
insert_prop(o, options, "textDecorationSkipInk", || json!(l.skip_ink));
insert_prop(o, options, "textDecorationColor", || l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
}
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
if t.paragraphs.is_empty() {
if t.list != figcraft_doc::ListStyle::None {
o.insert("listStyle".into(), json!(t.list));
insert_prop(o, options, "listStyle", || json!(t.list));
}
} else {
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
insert_prop(o, options, "lineTypes", || t.paragraphs.iter().map(|p| json!(p.list)).collect());
insert_prop(o, options, "lineIndentations", || t.paragraphs.iter().map(|p| json!(p.indent)).collect());
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
insert_prop(o, options, "listStartOffsets", || t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
}
}
if t.list_spacing != 0.0 {
o.insert("listSpacing".into(), json!(t.list_spacing));
insert_prop(o, options, "listSpacing", || json!(t.list_spacing));
}
for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
if on {
o.insert(k.into(), json!(true));
insert_prop(o, options, k, || json!(true));
}
}
if t.leading_trim != figcraft_doc::LeadingTrim::None {
o.insert("leadingTrim".into(), json!(t.leading_trim));
insert_prop(o, options, "leadingTrim", || json!(t.leading_trim));
}
if !t.runs.is_empty() {
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
insert_prop(o, options, "styledRuns", || serde_json::to_value(&t.runs).unwrap_or(Value::Null));
}
// Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`).
let linked: Vec<Value> = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
if !linked.is_empty() {
o.insert("textStyleRanges".into(), Value::Array(linked));
if options.includes("textStyleRanges") {
let linked: Vec<Value> = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
if !linked.is_empty() {
insert_prop(o, options, "textStyleRanges", || Value::Array(linked));
}
}
}
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>, options: &InspectOptions) {
match &n.kind {
NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => {
o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
insert_prop(o, options, "arc", || json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
}
NodeKind::Polygon { count } => {
o.insert("pointCount".into(), json!(count));
insert_prop(o, options, "pointCount", || json!(count));
}
NodeKind::Star { count, inner_ratio } => {
o.insert("pointCount".into(), json!(count));
o.insert("innerRadius".into(), json!(inner_ratio));
insert_prop(o, options, "pointCount", || json!(count));
insert_prop(o, options, "innerRadius", || json!(inner_ratio));
}
NodeKind::BooleanOp { op } => {
o.insert("booleanOperation".into(), json!(op));
insert_prop(o, options, "booleanOperation", || json!(op));
}
NodeKind::Instance { component, props } => {
o.insert("mainComponent".into(), json!(component));
o.insert("mainComponentName".into(), json!(doc.node(*component).map(|c| c.name.clone())));
insert_prop(o, options, "mainComponent", || json!(component));
insert_prop(o, options, "mainComponentName", || json!(doc.node(*component).map(|c| c.name.clone())));
if !props.is_empty() {
o.insert("componentProperties".into(), json!(props));
insert_prop(o, options, "componentProperties", || json!(props));
}
}
NodeKind::Component { description, props, .. } => {
if !description.is_empty() {
o.insert("description".into(), json!(description));
insert_prop(o, options, "description", || json!(description));
}
if !props.is_empty() {
o.insert("componentPropertyDefinitions".into(), serde_json::to_value(props).unwrap_or(Value::Null));
insert_prop(o, options, "componentPropertyDefinitions", || serde_json::to_value(props).unwrap_or(Value::Null));
}
}
NodeKind::Vector { network } => {
o.insert("vertices".into(), json!(network.vertices.len()));
o.insert("segments".into(), json!(network.segments.len()));
insert_prop(o, options, "vertices", || json!(network.vertices.len()));
insert_prop(o, options, "segments", || json!(network.segments.len()));
}
NodeKind::Page { background, flows, .. } => {
o.insert("background".into(), json!(background.to_css_hex()));
insert_prop(o, options, "background", || json!(background.to_css_hex()));
if !flows.is_empty() {
o.insert("flows".into(), serde_json::to_value(flows).unwrap_or(Value::Null));
insert_prop(o, options, "flows", || serde_json::to_value(flows).unwrap_or(Value::Null));
}
}
_ => {}
@@ -339,10 +524,20 @@ fn paints(p: &[figcraft_color::Paint]) -> Value {
/// The whole document (current page in depth, other pages summarised).
pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value {
document_with_options(s, depth, all_pages, &InspectOptions::default())
}
/// The document metadata and a tree built with only the selected layer properties.
pub fn document_with_options(s: &Session, depth: usize, all_pages: bool, options: &InspectOptions) -> Value {
let Ok(ds) = s.ds() else { return json!({"documents": 0}) };
let doc = &ds.doc;
let pages: Vec<Value> =
doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect();
doc.pages
.iter()
.map(|p| {
if all_pages || *p == ds.page { node_json_with_options(doc, *p, depth, options) } else { node_json_with_options(doc, *p, 0, options) }
})
.collect();
json!({
"id": ds.uid,
"name": doc.name,
+106
View File
@@ -0,0 +1,106 @@
//! `board.addConnected` (#1597): the next sticky or shape on a side of the selected one, of the
//! same kind, size and colour, joined to it by a connector, in one undo step.
use figcraft_doc::{BoardItem, BoardShape, Connector, End, NodeId, Side};
use serde_json::json;
use crate::Session;
use crate::board::CONNECTED_GAP;
use crate::board_tests::{board, id, node};
fn connector(s: &Session, c: NodeId) -> Connector {
match &node(s, c).board {
Some(BoardItem::Connector(k)) => *k,
other => panic!("not a connector: {other:?}"),
}
}
fn at(s: &Session, n: NodeId) -> (f64, f64) {
let t = node(s, n).transform.translation();
(t.x, t.y)
}
#[test]
fn a_shape_gets_the_next_one_joined_on_each_side() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "width": 200, "height": 120, "color": "blue"})).unwrap(), "id");
let colour = |s: &Session, n: NodeId| node(s, node(s, n).children[0]).fills[0].solid_color();
let g = CONNECTED_GAP;
for (side, want, ends) in [
("right", (200.0 + g, 0.0), (Side::Right, Side::Left)),
("below", (0.0, 120.0 + g), (Side::Bottom, Side::Top)),
("left", (-200.0 - g, 0.0), (Side::Left, Side::Right)),
("above", (0.0, -120.0 - g), (Side::Top, Side::Bottom)),
] {
let r = s.execute("board.addConnected", &json!({"id": a, "side": side})).unwrap();
let (b, c) = (id(&r, "id"), id(&r, "connector"));
assert_eq!(at(&s, b), want, "{side}");
let nb = node(&s, b);
assert_eq!((nb.width, nb.height), (200.0, 120.0), "{side}: the same size");
assert!(matches!(nb.board, Some(BoardItem::Shape { shape: BoardShape::Diamond })), "{side}: the same shape");
assert_eq!(colour(&s, b), colour(&s, a), "{side}: the same colour");
let k = connector(&s, c);
assert_eq!((k.from, k.to), (End::Node { node: a, side: ends.0 }, End::Node { node: b, side: ends.1 }), "{side}");
assert_eq!(k.routing, s.tool.board.routing, "{side}: as the connector tool makes one");
assert_eq!(s.selection(), vec![b], "{side}: the new one is selected");
}
}
#[test]
fn a_sticky_gets_a_sticky_of_its_colour_and_typing_goes_on_in_it() {
let mut s = board();
let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "color": "pink", "text": "First"})).unwrap(), "id");
s.ui_requests.clear();
// The selection is the default, and editing is asked for.
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let r = s.execute("board.addConnected", &json!({"side": "below", "edit": true})).unwrap();
let b = id(&r, "id");
assert!(matches!(node(&s, b).board, Some(BoardItem::Sticky { .. })));
assert_eq!(node(&s, b).fills[0].solid_color(), node(&s, a).fills[0].solid_color());
assert_eq!(at(&s, b).1, node(&s, a).height + CONNECTED_GAP);
assert!(s.ui_requests.iter().any(|q| q.kind == "board.editText" && q.data["id"] == json!(b)));
}
#[test]
fn it_is_one_undo_step_and_refuses_what_it_cant_join() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"shape": "ellipse", "x": 0, "y": 0})).unwrap(), "id");
let before = s.doc().unwrap().ids().count();
let r = s.execute("board.addConnected", &json!({"id": a})).unwrap();
assert_eq!(at(&s, id(&r, "id")).0, 160.0 + CONNECTED_GAP, "right by default");
assert!(s.doc().unwrap().ids().count() > before);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().ids().count(), before, "the shape and its connector go together");
// Not a sticky or shape, not a side, nothing selected.
let c = id(&s.execute("board.stamp", &json!({"x": 400, "y": 0, "stamp": "star"})).unwrap(), "id");
assert!(s.execute("board.addConnected", &json!({"id": c})).is_err());
assert!(s.execute("board.addConnected", &json!({"id": a, "side": "diagonal"})).is_err());
s.execute("select.set", &json!({"ids": []})).unwrap();
assert!(s.execute("board.addConnected", &json!({})).is_err());
// Not on a design file.
let mut d = Session::new();
assert!(d.execute("board.addConnected", &json!({})).is_err());
}
#[test]
fn the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were() {
let mut s = board();
let a = id(&s.execute("board.shape", &json!({"shape": "rectangle", "x": 0, "y": 0, "width": 200, "height": 120})).unwrap(), "id");
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let undo = s.ds().unwrap().history.undo.len();
// Shift+Cmd/Ctrl+Enter on a shape: the next shape on the right, joined by a connector (#1597).
let r = s.execute("board.nextSticky", &json!({})).unwrap();
let (b, c) = (id(&r, "id"), id(&r, "connector"));
assert!(matches!(node(&s, b).board, Some(BoardItem::Shape { shape: BoardShape::Rectangle })));
assert_eq!(at(&s, b), (200.0 + CONNECTED_GAP, 0.0));
assert_eq!(connector(&s, c).from, End::Node { node: a, side: Side::Right });
assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1, "one undo step");
// Another side, as the command takes one.
let r = s.execute("board.nextSticky", &json!({"id": a, "side": "below"})).unwrap();
assert_eq!(at(&s, id(&r, "id")), (0.0, 120.0 + CONNECTED_GAP));
// A sticky still gets a sticky beside it, without a connector.
let st = id(&s.execute("board.sticky", &json!({"x": 1000, "y": 0})).unwrap(), "id");
let r = s.execute("board.nextSticky", &json!({"id": st})).unwrap();
assert!(r.get("connector").is_none(), "{r}");
assert!(matches!(node(&s, id(&r, "id")).board, Some(BoardItem::Sticky { .. })));
}
+11 -3
View File
@@ -13,7 +13,7 @@ use figcraft_doc::{
TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color,
};
use figcraft_geom::kurbo::PathEl;
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2};
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
@@ -36,8 +36,9 @@ pub use edit::label_of;
pub(crate) use edit::{copied_items, copy_map, rejoin};
use edit::{label_cmd, set_color, set_style};
pub(crate) use items::add_text;
pub use items::{CONNECTED_GAP, connected_place};
use items::{add_connected, ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd};
pub use items::{edit_text, owner_of_text, sticky_font_size, text_of};
use items::{ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd};
/// Why a board command refuses on a design file.
pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board";
@@ -103,10 +104,17 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"board.nextSticky",
"Next sticky note",
"{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it",
"{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text, connector?} a sticky of the same colour beside a sticky; after a shape, the next shape joined to it by a connector, as board.addConnected",
true,
next_sticky
),
spec!(
"board.addConnected",
"Add connected shape",
"{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector",
true,
add_connected
),
spec!(
"board.shape",
"Shape with text",
+9 -125
View File
@@ -1,8 +1,9 @@
use figcraft_doc::RouteEnd;
use super::edit::set_font_size;
use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of};
use super::{
Affine, BTreeSet, BezPath, BoardItem, Connector, Document, End, NodeId, NodeKind, PathEl, Point, Rect, Routing, Shape, Side, Vec2, VectorNetwork,
is_board,
Affine, BTreeSet, BezPath, BoardItem, Connector, Document, End, NodeId, NodeKind, PathEl, Point, Rect, Routing, Shape, VectorNetwork, is_board,
};
// ---------------------------------------------------------------------------------------------
@@ -214,129 +215,12 @@ pub(super) fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing
}
}
/// How far an elbow connector going around the objects it joins keeps from them.
const ROOM: f64 = 24.0;
/// The page points connector `c` runs through: from the middle of the side of each attached layer
/// facing the other end (or the side it names), straight, in right angles, or (curved) its two
/// ends with the curve's two control points between them.
/// The page points connector `c` runs through (see [`figcraft_doc::route_points`]): from the
/// middle of the side of each attached layer facing the other end (or the side it names).
pub fn route(doc: &Document, c: &Connector) -> Option<Vec<Point>> {
let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n));
let centre = |e: &End, b: Option<Rect>| match e {
End::Point { x, y } => Some(Point::new(*x, *y)),
End::Node { .. } => b.map(|b| b.center()),
let end = |e: &End| match e {
End::Point { x, y } => Some(RouteEnd::Point(Point::new(*x, *y))),
End::Node { node, side } => doc.world_bounds(*node).map(|b| RouteEnd::Box(b, *side)),
};
let (fb, tb) = (bounds(&c.from), bounds(&c.to));
let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?);
let (p0, s0) = attach(&c.from, fb, tc)?;
let (p1, s1) = attach(&c.to, tb, fc)?;
let pts = match c.routing {
Routing::Straight => tidy(vec![p0, p1]),
Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::<Vec<_>>()),
Routing::Curved => curve(p0, s0, p1, s1),
};
pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts)
}
/// `pts` without repeats or turns that don't turn (three in a line across or down).
fn tidy(pts: Vec<Point>) -> Vec<Point> {
let mut out: Vec<Point> = Vec::with_capacity(pts.len());
for p in pts {
if out.last() == Some(&p) {
continue;
}
if let [.., a, b] = out.as_slice()
&& ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y))
{
out.pop();
}
out.push(p);
}
out
}
/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that
/// side (`None`: a free point).
fn attach(e: &End, b: Option<Rect>, other: Point) -> Option<(Point, Option<Side>)> {
match e {
End::Point { x, y } => Some((Point::new(*x, *y), None)),
End::Node { side, .. } => {
let b = b?;
let side = match side {
Side::Auto => {
let d = other - b.center();
// Compare against the box's proportions: a wide box takes the sides more.
if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) {
if d.x >= 0.0 { Side::Right } else { Side::Left }
} else if d.y >= 0.0 {
Side::Bottom
} else {
Side::Top
}
}
s => *s,
};
Some((side.point(b), Some(side)))
}
}
}
/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no
/// way): the ends and the two control points between them.
fn curve(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>) -> Vec<Point> {
let reach = (p1 - p0).hypot() / 2.0;
let pull = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach);
vec![p0, pull(p0, s0), pull(p1, s1), p1]
}
/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a
/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`,
/// else the cheapest of a few ways around them (by length, and turns).
fn elbow(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>, boxes: &[Rect]) -> Vec<Point> {
let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal)));
let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct };
if !crosses(&direct, boxes) {
return direct;
}
// Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects.
let out = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM);
let (a, b) = (out(p0, s0), out(p1, s1));
let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]];
for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] {
ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]);
}
for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] {
ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]);
}
let cost = |r: &Vec<Point>| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::<f64>() + ROOM * r.len() as f64;
ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct)
}
/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge,
/// or out of one, is outside).
fn crosses(pts: &[Point], boxes: &[Rect]) -> bool {
const EDGE: f64 = 0.5;
pts.windows(2).any(|s| {
let (a, b) = (s[0], s[1]);
boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE)
})
}
/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end
/// follows the other): halfway across between two sides facing the same way, one turn otherwise.
fn short_elbow(p0: Point, h0: Option<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs());
let h1 = h1.unwrap_or(h0);
match (h0, h1) {
(true, true) => {
let mx = (p0.x + p1.x) / 2.0;
vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1]
}
(false, false) => {
let my = (p0.y + p1.y) / 2.0;
vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1]
}
(true, false) => vec![p0, Point::new(p1.x, p0.y), p1],
(false, true) => vec![p0, Point::new(p0.x, p1.y), p1],
}
figcraft_doc::route_points(end(&c.from)?, end(&c.to)?, c.routing)
}
+76 -7
View File
@@ -1,9 +1,10 @@
use super::connectors::{CAPS, make};
use super::{
AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Constraints, CrossAlign, Document, Effect, EffectKind, EngineError,
HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY, STICKY_GAP,
STICKY_PAD, Session, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, Value, VectorNetwork,
author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of, stamp_of, string,
textedit,
AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Connector, Constraints, CrossAlign, Document, Effect, EffectKind, End,
EngineError, HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY,
STICKY_GAP, STICKY_PAD, Session, Shadow, Side, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint,
Value, VectorNetwork, author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of,
stamp_of, string, textedit,
};
/// A new layer at the top of `parent`, at `at` in it, made by `f`.
@@ -144,10 +145,14 @@ pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result<Value> {
let from = id(p, "id")
.or_else(|| editing.and_then(|t| owner_of_text(doc, t)))
.or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i)))
.ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?;
.ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky or a shape first".into()))?;
let n = doc.get(from)?;
if matches!(n.board, Some(BoardItem::Shape { .. })) {
// A shape gets the next one joined to it, as the "+" beside it makes (#1597).
return add_connected(s, p);
}
if !matches!(n.board, Some(BoardItem::Sticky { .. })) {
return Err(bad(cmd, "that isn't a sticky"));
return Err(bad(cmd, "that isn't a sticky or a shape"));
}
let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky);
let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?;
@@ -172,6 +177,70 @@ pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id, "text": note}))
}
/// The room between a board object and the next one `board.addConnected` makes, joined by a
/// connector: enough for the arrow to read.
pub const CONNECTED_GAP: f64 = 80.0;
/// Where `board.addConnected` puts the next object on `side` of one at `at` sized `size`, `next`
/// big (its top-left), and the sides the connector joins (from, to). `None`: not a side.
pub fn connected_place(at: (f64, f64), size: (f64, f64), next: (f64, f64), side: &str) -> Option<((f64, f64), Side, Side)> {
let ((x, y), (w, h), (nw, nh)) = (at, size, next);
let (cx, cy) = (x + (w - nw) / 2.0, y + (h - nh) / 2.0);
Some(match side {
"right" => ((x + w + CONNECTED_GAP, cy), Side::Right, Side::Left),
"left" => ((x - CONNECTED_GAP - nw, cy), Side::Left, Side::Right),
"below" => ((cx, y + h + CONNECTED_GAP), Side::Bottom, Side::Top),
"above" => ((cx, y - CONNECTED_GAP - nh), Side::Top, Side::Bottom),
_ => return None,
})
}
/// `board.addConnected`: the next sticky or shape on a side of one, of the same kind, size and
/// colour, joined to it by a connector the way the connector tool makes one; one undo step.
pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.addConnected";
require(s, cmd)?;
let editing = s.ds()?.text_edit.as_ref().map(|t| t.id);
let doc = s.doc()?;
let from = id(p, "id")
.or_else(|| editing.and_then(|t| owner_of_text(doc, t)))
.or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i)))
.ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky or a shape first".into()))?;
let n = doc.get(from)?;
let shape = match &n.board {
Some(BoardItem::Sticky { .. }) => None,
Some(BoardItem::Shape { shape }) => Some(*shape),
_ => return Err(bad(cmd, "that isn't a sticky or a shape")),
};
let side = string(p, "side").unwrap_or_else(|| "right".into());
let parent = n.parent.ok_or_else(|| bad(cmd, "that object has no parent"))?;
let at = n.transform.translation();
let colour_of = |n: &Node| n.fills.iter().find_map(Paint::solid_color);
let (next, color) = match shape {
None => ((STICKY, STICKY), colour_of(n)),
Some(_) => ((n.width, n.height), n.children.first().and_then(|c| doc.node(*c)).and_then(colour_of)),
};
let color = color.unwrap_or(s.tool.board.sticky);
let (to_at, from_side, to_side) = connected_place((at.x, at.y), (n.width, n.height), next, &side)
.ok_or_else(|| bad(cmd, format!("`side` is right, below, left or above, not `{side}`")))?;
let (routing, author, page) = (s.tool.board.routing, author(s), s.page()?);
if editing.is_some() {
textedit::end(s)?;
}
let doc = s.doc_mut()?;
let (id, text) = match shape {
None => make_sticky(doc, parent, to_at, "", color, &author)?,
Some(shape) => make_shape(doc, parent, to_at, next, shape, "", color)?,
};
let connector = Connector { from: End::Node { node: from, side: from_side }, to: End::Node { node: id, side: to_side }, routing };
let line = make(doc, page, connector, CAPS)?;
s.set_selection(vec![id]);
if boolean(p, "edit") == Some(true) {
s.request_ui("board.editText", json!({"id": id}));
}
Ok(json!({"id": id, "text": text, "connector": line}))
}
/// A shape with text's layers: the frame, its outline and its text.
fn make_shape(
doc: &mut Document,
+22 -1
View File
@@ -12,7 +12,7 @@ use serde_json::Value;
use crate::{EngineError, Result, Session, bad, find_command, guard, normalize};
/// The commands that show or end a preview (they don't end the one showing).
pub const COMMANDS: [&str; 2] = ["text.preview", "text.endPreview"];
pub const COMMANDS: [&str; 3] = ["text.preview", "text.endPreview", "widget.hover"];
/// What can be previewed: text styles, font styles and axis values, layers' properties and the
/// blend of a paint on a layer, a selection or a style.
@@ -70,6 +70,27 @@ pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> {
}
}
/// Show what `change` does to the document as a live preview (in place of an earlier one), like
/// [`show`] does with a command's: no undo step, and the next command puts the document back first.
pub(crate) fn show_change(s: &mut Session, cmd: &str, change: impl FnOnce(&mut figcraft_doc::Document) -> Result<()>) -> Result<()> {
end(s);
let ds = s.ds_mut()?;
if ds.preview.is_some() {
return Err(EngineError::Disabled(cmd.into(), crate::PREVIEW_READ_ONLY.into()));
}
let base = ds.doc.clone();
let mut doc = (*base).clone();
guard::run(cmd, || {
change(&mut doc)?;
normalize(&mut doc, &base);
Ok(())
})?;
ds.doc = Arc::new(doc);
ds.text_preview = Some(base);
ds.revision += 1;
Ok(())
}
/// End the live preview, if any: the document is back as it was. Returns whether one was showing.
pub fn end(s: &mut Session) -> bool {
let Ok(ds) = s.ds_mut() else { return false };
+2
View File
@@ -578,6 +578,8 @@ pub struct ToolState {
pub pick_only: bool,
/// The colour the eyedropper sampled for a colour picker, until the picker takes it.
pub picked: Option<figcraft_color::Color>,
/// The widget layers whose hover styles show while the pointer is over them (a live preview).
pub(crate) widget_hover: Vec<NodeId>,
}
impl ToolState {
+24
View File
@@ -232,6 +232,8 @@ impl Session {
let mut in_flow = false;
// Placed in a row or column: (is it a column, its gap) for the insertion line.
let mut slot_axis: Option<(bool, f64)> = None;
// Only the selected layers moved, each by `d` (see `Interaction::moved_by`).
let mut shifted = !dup;
self.preview(|doc, sel| {
let mut moving = ids.clone();
if dup {
@@ -288,6 +290,7 @@ impl Session {
}
// A free move. Layers dragged out of an auto layout flow leave it (onto the page
// while dragging; the drop puts them in the frame under the pointer).
shifted &= only_shifted(doc, &moving);
for m in &moving {
let in_al = doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
if flow && in_al {
@@ -298,6 +301,9 @@ impl Session {
drop_target = (flow && target != page).then_some(target);
Ok(())
})?;
if let Some(it) = self.ds_mut()?.interaction.as_mut() {
it.moved_by = (shifted && !in_flow).then_some((d.x, d.y));
}
let placed = if dup { self.selection() } else { ids };
// Snapping guides mean nothing while the layout places the layers; with Alt held,
// live distances to the neighbours replace them.
@@ -628,3 +634,21 @@ impl Session {
Ok(())
}
}
/// Moving `moving` by the same offset moves nothing else and changes nothing about them but where
/// they are: none is inside another (it would move twice) or placed by its parent's auto layout
/// (the layout would put it back, or close the gap it leaves).
fn only_shifted(doc: &Document, moving: &[NodeId]) -> bool {
let set: std::collections::HashSet<NodeId> = moving.iter().copied().collect();
moving.iter().all(|m| {
let Some(n) = doc.node(*m) else { return false };
let in_flow = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute;
let mut up = n.parent;
let mut nested = false;
while let Some(p) = up {
nested |= set.contains(&p);
up = doc.node(p).and_then(|x| x.parent);
}
!in_flow && !nested
})
}
+21
View File
@@ -13,6 +13,24 @@ impl Session {
self.tool.overlay.measure_box = None;
}
self.vector_pointer_left();
self.follow_widget_hover(Vec::new());
}
/// Show the hover styles of the widget layers `ids` the pointer is over (none: end them), unless
/// another live preview (a hovered font, say) is showing.
fn follow_widget_hover(&mut self, ids: Vec<NodeId>) {
let ours = !self.tool.widget_hover.is_empty() && crate::textpreview::active(self);
if ids.is_empty() {
if ours {
crate::textpreview::end(self);
}
self.tool.widget_hover.clear();
return;
}
if ours && self.tool.widget_hover == ids || !ours && crate::textpreview::active(self) {
return;
}
self.tool.widget_hover = if self.widget_hover(&ids).unwrap_or(false) { ids } else { Vec::new() };
}
/// What a marquee starting at page point `p` selects inside: the top-level frame it starts in
@@ -194,6 +212,7 @@ impl Session {
cursor = Cursor::Text;
}
let mut hover = None;
let mut widget_hover = Vec::new();
let mut guides = Vec::new();
let mut measure_box = None;
let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None };
@@ -214,6 +233,7 @@ impl Session {
if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() {
cursor = Cursor::Pointer;
}
widget_hover = leaf.map(|l| crate::widgets::hovered_at(&doc, l, ev.p())).unwrap_or_default();
hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)));
// Alt over a selected layer: a drag from here duplicates it.
if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) {
@@ -260,6 +280,7 @@ impl Session {
if tool == ToolId::Connector {
self.connector_hover(ev);
}
self.follow_widget_hover(widget_hover);
let ds = self.ds_mut()?;
if ds.hover != hover {
ds.hover = hover;
+2 -1
View File
@@ -410,9 +410,10 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> {
})
}
/// An image file to place: no larger than a document takes an image from outside (#1114).
#[cfg(not(target_arch = "wasm32"))]
fn read_path(path: &str) -> Result<Vec<u8>> {
crate::file_access::read(path)
crate::file_access::read_capped(path, figcraft_doc::ImageData::MAX_BYTES)
}
#[cfg(target_arch = "wasm32")]
+4 -3
View File
@@ -127,7 +127,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"edit.pasteDesignLayers",
"Paste design layers",
"{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes",
"{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes; `missingImages`: the names of pasted layers whose image the clipboard didn't carry (they show the missing-image placeholder)",
true,
paste_design_layers
),
@@ -305,9 +305,10 @@ pub fn specs() -> Vec<CommandSpec> {
ok()
}),
spec!("node.set", "Set properties", super::props::KEYS, true, set),
spec!("node.get", "Get layer", "{id}", false, |s, p| {
spec!("node.get", "Get layer", "{id, fields?: [node keys], outline?: bool (not with fields)}", false, |s, p| {
let i = req_id("node.get", p, "id")?;
Ok(crate::inspect::node_json(s.doc()?, i, 1))
let options = crate::inspect::InspectOptions::parse(p).map_err(|e| bad("node.get", e))?;
Ok(crate::inspect::node_json_with_options(s.doc()?, i, 1, &options))
}),
spec!(
"node.find",
+27
View File
@@ -134,12 +134,39 @@ pub(super) fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
layers_generation,
};
let mut out = paste(s, p)?;
let pasted: Vec<NodeId> = out["ids"].as_array().map(|a| a.iter().filter_map(crate::params::id_of).collect()).unwrap_or_default();
let missing = layers_missing_images(s.doc()?, &pasted);
if !missing.is_empty() {
out["missingImages"] = json!(missing);
}
if !other.issues.is_empty() {
out["warnings"] = json!(other.issues);
}
Ok(out)
}
/// The most layers [`layers_missing_images`] names.
const MAX_MISSING_NAMED: usize = 20;
/// The names of the layers under `ids` with an image fill or stroke whose image the paste didn't
/// carry (#1646): it shows the importer's missing-image placeholder, or nothing at all.
fn layers_missing_images(doc: &figcraft_doc::Document, ids: &[NodeId]) -> Vec<String> {
let missing = |p: &figcraft_color::Paint| match &p.kind {
figcraft_color::PaintKind::Image(img) => doc.images.get(&img.image).is_none_or(figcraft_format::rest::is_missing_image),
_ => false,
};
let mut names: Vec<String> = Vec::new();
for n in ids.iter().flat_map(|i| doc.subtree(*i)).filter_map(|i| doc.node(i)) {
if n.fills.iter().chain(&n.strokes).any(missing) && !names.contains(&n.name) {
names.push(n.name.clone());
if names.len() == MAX_MISSING_NAMED {
break;
}
}
}
names
}
pub(super) fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("edit.copy", s, p)?;
copy_to_clipboard(s, "edit.copy", &ids)?;
+2 -2
View File
@@ -30,9 +30,9 @@ pub use export::{
};
use export::{export, export_frames_pdf, export_layers, pdf_bytes};
use load::font_report;
pub use load::{MAX_DOCUMENT_FILE, load, load_bytes, load_path};
#[cfg(test)]
pub(crate) use load::{installed_family, sketch_line};
pub use load::{load, load_bytes, load_path};
pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variables_param, write};
pub use open::MAX_RECENT;
#[cfg(test)]
@@ -203,7 +203,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"file.colorProfile",
"Color profile",
"{profile?: srgb|displayP3, convert? (default true: convert colour values so colours keep their appearance; false: keep the values and reinterpret them)} without `profile`, returns the current one (and the app opens the dialog). Returns {profile, changed}",
"{profile?: srgb|displayP3, convert? (default true: convert colour values so colours keep their appearance — a colour calculation's mix keeps its ends exact and its colours in between move slightly; false: keep the values and reinterpret them)} without `profile`, returns the current one (and the app opens the dialog). Returns {profile, changed}",
true,
color_profile
),
+17 -2
View File
@@ -14,9 +14,14 @@ pub(crate) fn recovered_from(bytes: &[u8]) -> Result<DocState> {
Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved())
}
/// The largest file opened or imported as a document. A `.figcraft` file keeps its images as
/// they are, so a real one can be far larger than it unpacks to (#1313's took 7.4 GB); twice that
/// opens, while a device, a pipe or an absurd file is refused before it is read (#1114).
pub const MAX_DOCUMENT_FILE: u64 = 16 << 30;
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn read(path: &str) -> Result<Vec<u8>> {
crate::file_access::read(path)
crate::file_access::read_capped(path, MAX_DOCUMENT_FILE)
}
#[cfg(target_arch = "wasm32")]
@@ -135,6 +140,7 @@ fn load_any(
if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) {
let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?;
link_instances(&mut doc);
installed_fonts_first();
return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true));
}
let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
@@ -376,11 +382,19 @@ pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option<Str
installed.iter().find(|f| key(f) == want).cloned()
}
/// An imported file names its fonts by family: the installed ones must be known before its text is
/// laid out, or it is measured (and reported missing) in a fallback font while the desktop's
/// background scan is still running (#1292).
fn installed_fonts_first() {
figcraft_text::scan_system_fonts();
}
/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try.
pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_format::Loaded>> {
let r = figcraft_format::fig::try_import(bytes, name)?;
Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| {
link_instances(&mut doc);
installed_fonts_first();
figcraft_format::Loaded { issues: warnings, ..doc.into() }
}))
}
@@ -557,7 +571,8 @@ fn font_report_with_families(doc: &Document, installed: &[String]) -> Value {
} else if known.is_empty() {
None
} else {
Some(known.contains(&lower))
// Also under the other name its fonts give the family ("Economia Medium").
Some(known.contains(&lower) || figcraft_text::has_family(&family))
};
json!({"family":family,"available":available,"layers":layers.into_iter().collect::<Vec<_>>()})
})
+5 -4
View File
@@ -245,7 +245,7 @@ fn alias(s: &mut Session, p: &Value) -> Result<Value> {
let kind = doc.variables.get(&own).filter(|v| !is_remote_variable(doc, &v.id)).map(|v| v.kind);
let kind = kind.ok_or_else(|| bad(cmd, format!("no local variable `{own}`")))?;
let (lib, key) = variable_arg(s, cmd, p, "alias")?;
if lib.doc.variables.get(&key).is_some_and(|v| v.kind != kind) {
if lib.doc.variables.get_by_id(&key).is_some_and(|v| v.kind != kind) {
return Err(bad(cmd, "the library variable has another type"));
}
let local = import_variable(s, &lib, &key)?;
@@ -262,7 +262,7 @@ fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
.or_else(|| published_collections(&lib).find(|c| c.name == key))
.ok_or_else(|| bad(cmd, format!("\"{}\" publishes no variable collection `{key}`", link.name)))?;
let m = req_string(cmd, p, "mode")?;
let mode = coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?;
let mode = coll.find_mode(&m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?;
let key = coll.id.clone();
let map = import_variables(s.doc_mut()?, &lib, &BTreeSet::new(), &BTreeSet::from([key.clone()]), false);
let local = map.collections.get(&key).cloned().ok_or_else(|| EngineError::Other("the collection didn't import".into()))?;
@@ -474,7 +474,8 @@ fn swap_cmd(s: &mut Session, p: &Value) -> Result<Value> {
fn assets_json(lib: &PublishedLibrary) -> Value {
let m = &lib.manifest;
let modes = |c: &str| lib.doc.variables.collection(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::<Vec<_>>());
let modes =
|c: &str| lib.doc.variables.collection_by_id(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::<Vec<_>>());
json!({
"components": m.components.iter().filter(|a| a.published).map(|a| json!({"id": a.id, "name": a.name, "set": a.set, "version": a.version})).collect::<Vec<_>>(),
"styles": m.styles.iter().filter(|x| x.published).map(|x| json!({"id": x.id, "name": x.name, "type": x.kind, "version": x.version})).collect::<Vec<_>>(),
@@ -520,7 +521,7 @@ fn list(s: &mut Session, p: &Value) -> Result<Value> {
res["publishable"] = json!({
"components": all.components.iter().filter_map(|c| doc.node(*c).map(|n| json!({"id": c, "name": n.name, "selected": def.components.contains(c)}))).collect::<Vec<_>>(),
"styles": all.styles.iter().filter_map(|x| doc.style(x).map(|st| json!({"id": st.id, "name": st.name, "selected": def.styles.contains(x)}))).collect::<Vec<_>>(),
"collections": all.collections.iter().filter_map(|x| doc.variables.collection(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::<Vec<_>>(),
"collections": all.collections.iter().filter_map(|x| doc.variables.collection_by_id(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::<Vec<_>>(),
});
}
Ok(res)
+1 -37
View File
@@ -308,40 +308,6 @@ pub(crate) fn check_action_dests(cmd: &str, doc: &Document, actions: &[Action])
Ok(())
}
/// Variables, collections and modes that actions name by name, stored by id.
fn canonical_ids(doc: &Document, it: &mut Interaction) {
canonical_actions(&doc.variables, &mut it.actions);
}
/// Variables (also those expressions read), collections and modes that `actions` name by name,
/// stored by id.
pub(crate) fn canonical_actions(vars: &Variables, actions: &mut [Action]) {
walk_mut(actions, &mut |a| match a {
Action::SetVariable { variable, expr, .. } => {
if let Some(v) = vars.get(variable) {
*variable = v.id.clone();
}
if let Some(e) = expr {
canonical_expr(vars, e, 0);
}
}
Action::SetVariableMode { collection, mode } => {
if let Some(c) = vars.collection(collection) {
if let Some(m) = c.find_mode(mode) {
*mode = m.id.clone();
}
*collection = c.id.clone();
}
}
Action::Conditional { blocks } => {
for e in blocks.iter_mut().filter_map(|b| b.condition.as_mut()) {
canonical_expr(vars, e, 0);
}
}
_ => {}
});
}
/// Variables, collections and modes an expression names by name, stored by id.
pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
match e {
@@ -357,7 +323,7 @@ pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) {
}
Expr::InMode { variable, mode } => {
if let Some(v) = vars.get(variable) {
if let Some(m) = vars.collection(&v.collection).and_then(|c| c.find_mode(mode)) {
if let Some(m) = vars.collection_by_id(&v.collection).and_then(|c| c.find_mode(mode)) {
*mode = m.id.clone();
}
*variable = v.id.clone();
@@ -425,7 +391,6 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let mut it = it;
it.delay_ms = delay(cmd, p)?.unwrap_or(0);
settle(&mut it);
canonical_ids(s.doc()?, &mut it);
check_dests(cmd, s, &it)?;
let mut index = 0;
for i in req_targets(cmd, s, p)? {
@@ -484,7 +449,6 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
if it.actions.is_empty() {
return Err(bad(cmd, "an interaction needs an action"));
}
canonical_ids(s.doc()?, &mut it);
check_dests(cmd, s, &it)?;
if let Some(slot) = s.doc_mut()?.get_mut(i)?.interactions.get_mut(k) {
*slot = it;
+64 -7
View File
@@ -159,15 +159,73 @@ pub fn default_condition(vars: &Variables) -> Expr {
}
}
/// `p` with every `expression` text in it read: on a set variable action it becomes `expr`
/// (checked against the variable), elsewhere (conditional blocks) the `condition` (checked to give
/// true or false).
/// `p` with incoming names stored as ids and every `expression` text read: on a set variable
/// action it becomes `expr` (checked against the variable), elsewhere (conditional blocks) the
/// `condition` (checked to give true or false). Parsed text already holds ids, so it is never
/// canonicalized again. Existing actions that a command leaves alone never pass through here.
pub fn with_expressions(cmd: &str, vars: &Variables, p: &Value) -> Result<Value> {
let mut v = p.clone();
canonical_inputs(cmd, vars, &mut v, 0)?;
resolve_expressions(cmd, vars, &mut v, 0)?;
Ok(v)
}
/// Visit input actions and conditional blocks, never literal values or parsed expressions.
fn canonical_inputs(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> {
if depth > 4 * MAX_ACTION_DEPTH {
return Err(bad(cmd, "actions are nested too deeply"));
}
match v {
Value::Array(a) => a.iter_mut().try_for_each(|x| canonical_inputs(cmd, vars, x, depth + 1)),
Value::Object(o) => {
canonical_action_names(vars, o);
canonical_json_exprs(cmd, vars, o)?;
for key in ["action", "actions", "interaction", "blocks"] {
if let Some(child) = o.get_mut(key) {
canonical_inputs(cmd, vars, child, depth + 1)?;
}
}
Ok(())
}
_ => Ok(()),
}
}
/// Only command input accepts names: settle action targets before reading text expressions.
fn canonical_action_names(vars: &Variables, o: &mut serde_json::Map<String, Value>) {
match o.get("type").and_then(Value::as_str) {
Some("setVariable") => {
if let Some(v) = o.get("variable").and_then(Value::as_str).and_then(|n| vars.get(n)) {
o.insert("variable".into(), json!(v.id));
}
}
Some("setVariableMode") => {
if let Some(c) = o.get("collection").and_then(Value::as_str).and_then(|n| vars.collection(n)) {
if let Some(m) = o.get("mode").and_then(Value::as_str).and_then(|n| c.find_mode(n)) {
o.insert("mode".into(), json!(m.id));
}
o.insert("collection".into(), json!(c.id));
}
}
_ => {}
}
}
/// JSON expressions accept names too; text replaces them and is parsed separately.
fn canonical_json_exprs(cmd: &str, vars: &Variables, o: &mut serde_json::Map<String, Value>) -> Result<()> {
if o.get("expression").is_some_and(Value::is_string) {
return Ok(());
}
for key in ["expr", "condition"] {
if let Some(value) = o.get_mut(key).filter(|v| !v.is_null()) {
let mut e: Expr = serde_json::from_value(value.clone()).map_err(|e| bad(cmd, format!("{key}: {e}")))?;
super::proto::canonical_expr(vars, &mut e, 0);
*value = expr_json(cmd, &e)?;
}
}
Ok(())
}
fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> {
if depth > 4 * MAX_ACTION_DEPTH {
return Err(bad(cmd, "actions are nested too deeply"));
@@ -181,7 +239,7 @@ fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize)
let var = o
.get("variable")
.and_then(Value::as_str)
.and_then(|n| vars.get(n))
.and_then(|n| vars.get_by_id(n))
.ok_or_else(|| bad(cmd, "setVariable: no such `variable`"))?;
e.check_value(vars, var.kind).map_err(|m| bad(cmd, m))?;
o.insert("expr".into(), expr_json(cmd, &e)?);
@@ -208,7 +266,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> {
for x in walk(std::slice::from_ref(a)) {
match x {
Action::SetVariable { variable, expr, .. } => {
let v = vars.get(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?;
let v = vars.get_by_id(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?;
if let Some(e) = expr {
e.check_value(vars, v.kind).map_err(|m| bad(cmd, m))?;
}
@@ -231,8 +289,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> {
fn action_param(cmd: &str, s: &Session, p: &Value) -> Result<Action> {
let doc = s.doc()?;
let p = with_expressions(cmd, &doc.variables, p)?;
let mut a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?;
super::proto::canonical_actions(&doc.variables, std::slice::from_mut(&mut a));
let a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?;
check_action(cmd, &doc.variables, &a)?;
super::proto::check_action_dests(cmd, doc, std::slice::from_ref(&a))?;
Ok(a)
+40 -16
View File
@@ -164,21 +164,21 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.create",
"Create variable",
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?}",
"{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?} a value may also be {alias: variable} (same type); a colour value may also be {alpha: {variable, opacity 0..1}} (that variable's colour with its alpha replaced by the opacity) or {mix: {from, to, t}} (operands {variable: variable} or {literal: colour}; t is 0..1, 0 gives from and 1 to; blended per channel over the stored values, in the document's colour profile, including alpha; converting the profile keeps a mix's ends exact and moves its colours in between slightly), each alone in its object",
true,
var_create
),
spec!(
"variable.set",
"Set variable value",
"{variable, mode? (name or id; default first), value | {alias: variable} (same type, no loops)}",
"{variable, mode? (name or id; default first), value | {alias: variable} (same type, no loops) | {alpha} | {mix} (colour variables; shapes as in variable.create; no loops)}",
true,
var_set
),
spec!(
"variable.delete",
"Delete variable",
"{variable} (bound layers keep their values; variables aliasing it take its values)",
"{variable} (bound layers keep their values; variables aliasing it take its values; colour calculations reading it keep the colour they show)",
true,
|s, p| {
let v = req_string("variable.delete", p, "variable")?;
@@ -881,18 +881,31 @@ pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
ok()
}
fn var_value(kind: VariableType, v: &Value) -> Option<VariableValue> {
if let Some(a) = v.get("alias").and_then(Value::as_str) {
return Some(VariableValue::Alias(a.to_string()));
/// A `value`/`values` entry of `kind`: a plain value of that type, `{alias: variable}`, or (colour
/// variables) an `{alpha}`/`{mix}` calculation. An alias stays a name here — the caller binds it to
/// an id; a calculation binds its own operands as they are checked. `for_id` is the variable being
/// set when it already exists (a calculation may not lead back to it).
fn var_value(cmd: &str, vars: &Variables, kind: VariableType, for_id: Option<&VariableId>, v: &Value) -> Result<VariableValue> {
// Calculations first: they refuse sibling keys, so `{alias, alpha}` is an error rather than
// quietly one of the two.
if v.get("alpha").is_some() || v.get("mix").is_some() {
return super::variables::colour_calc(cmd, vars, kind, for_id, v);
}
Some(match kind {
VariableType::Color => VariableValue::Color(color_of(v)?),
VariableType::Number => VariableValue::Number(f64_of(v)?),
if let Some(a) = v.get("alias").and_then(Value::as_str) {
return Ok(VariableValue::Alias(a.to_string()));
}
Ok(match kind {
VariableType::Color => VariableValue::Color(color_of(v).ok_or_else(|| bad(cmd, "invalid value"))?),
VariableType::Number => VariableValue::Number(f64_of(v).ok_or_else(|| bad(cmd, "invalid value"))?),
VariableType::String => VariableValue::String(v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())),
VariableType::Boolean => VariableValue::Boolean(v.as_bool()?),
VariableType::Boolean => VariableValue::Boolean(v.as_bool().ok_or_else(|| bad(cmd, "invalid value"))?),
})
}
/// `variable.create`: a variable with a value for every mode of its collection (`values` by mode,
/// then `value` for the modes left, then its type's default). Every value is checked before the
/// variable exists (alias targets, calculation operands, the 32-variable limit), so a refused one
/// leaves nothing behind.
fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.create";
let c = req_string(cmd, p, "collection")?;
@@ -915,11 +928,11 @@ fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
.find(|x| x.id == *mode || x.name == *mode)
.map(|x| x.id.clone())
.ok_or_else(|| bad(cmd, format!("no mode `{mode}`")))?;
values.insert(mid, var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?);
values.insert(mid, var_value(cmd, &doc.variables, kind, None, v)?);
}
}
if let Some(v) = get(p, "value") {
let val = var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?;
let val = var_value(cmd, &doc.variables, kind, None, v)?;
for m in &coll.modes {
values.entry(m.id.clone()).or_insert_with(|| val.clone());
}
@@ -936,6 +949,10 @@ fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
*a = t.id.clone();
}
}
// The new variable has no id yet and nothing reads it, so any stand-in id will do.
for (m, val) in &values {
super::variables::check_resolves(cmd, &doc.variables, "", &coll.id, m, val)?;
}
let id = doc.new_variable_id();
doc.variables.variables.push(Variable {
id: id.clone(),
@@ -953,20 +970,26 @@ fn var_create(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"id": id}))
}
/// `variable.set`: one mode's value (the default mode when none is named), checked against the
/// variable as it stands before anything changes: an alias or calculation may not lead back to it,
/// and a calculation must fit the 32-variable limit. On a variable an extended collection inherits,
/// the value becomes that mode's override.
pub fn var_set(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "variable.set";
let v = req_string(cmd, p, "variable")?;
let doc = s.doc_mut()?;
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
let coll = doc.variables.collection(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
let coll = doc.variables.collection_by_id(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?;
let mode = match string(p, "mode") {
Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?,
Some(m) => coll.find_mode(&m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?,
None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?,
};
let val = match get(p, "value").and_then(|x| var_value(var.kind, x)).ok_or_else(|| bad(cmd, "invalid value"))? {
let raw = get(p, "value").ok_or_else(|| bad(cmd, "invalid value"))?;
let val = match var_value(cmd, &doc.variables, var.kind, Some(&var.id), raw)? {
VariableValue::Alias(a) => VariableValue::Alias(super::variables::alias_target(&doc.variables, &var, &a).map_err(|e| bad(cmd, e))?),
v => v,
};
super::variables::check_resolves(cmd, &doc.variables, &var.id, &var.collection, &mode, &val)?;
if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) {
// An inherited variable's value of its own overrides the inherited one in that mode.
if slot.extends.is_some() {
@@ -1206,7 +1229,8 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
VariableValue::Number(x) => json!(x),
VariableValue::String(x) => json!(x),
VariableValue::Boolean(b) => json!(b),
VariableValue::Alias(_) => continue,
// Resolved values are plain, so these never occur here.
VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => continue,
};
sets.insert(field, jv);
}
+149 -25
View File
@@ -5,8 +5,8 @@
use std::collections::BTreeSet;
use figcraft_doc::{
CODE_PLATFORMS, Collection, CollectionId, Document, ModeId, NodeId, NodeKind, Variable, VariableId, VariableType, VariableValue, Variables,
normalize_scopes, scope_known,
CODE_PLATFORMS, Collection, CollectionId, ColorOperand, Document, ModeId, NodeId, NodeKind, Variable, VariableId, VariableType, VariableValue,
Variables, normalize_scopes, scope_known,
};
use serde_json::{Value, json};
@@ -32,7 +32,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.deleteCollection",
"Delete collection",
"{collection} (with its variables; bound layers keep their values)",
"{collection} (with its variables; bound layers keep their values; variables elsewhere aliasing them take their values and colour calculations reading them keep the colour they show)",
true,
delete_collection
),
@@ -89,7 +89,7 @@ fn inherited(cmd: &str, what: &str) -> crate::EngineError {
/// Refuse mode edits of extended collections (they have the modes of the collection they extend).
pub(crate) fn check_modes_editable(doc: &Document, cmd: &str, collection: &str) -> Result<()> {
match doc.variables.collection(collection) {
match doc.variables.collection_by_id(collection) {
Some(c) if c.extends.is_some() => Err(inherited(cmd, "an extended collection's modes")),
_ => Ok(()),
}
@@ -296,17 +296,21 @@ fn delete_collection(s: &mut Session, p: &Value) -> Result<Value> {
}
/// Delete variables `gone`: layers bound to them keep the values they show but no longer follow
/// them, and variables aliasing them take their values (per mode: the aliased variable's value in
/// that mode when it's in the same collection, else in its default mode).
/// them; a variable aliasing one takes its value and a colour calculation reading one keeps the
/// colour it shows (per mode: what the reader resolves to in that mode, other collections in their
/// default modes).
pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>) -> Result<()> {
// Every reader is resolved before anything is written, so a reader of a reader resolves
// through the values as they were.
let vars = &doc.variables;
let mut detached: Vec<(VariableId, ModeId, VariableValue)> = Vec::new();
for v in vars.variables.iter().filter(|v| !gone.contains(&v.id)) {
for (m, val) in &v.values {
let VariableValue::Alias(a) = val else { continue };
let Some(t) = vars.get(a).filter(|t| gone.contains(&t.id)) else { continue };
let modes = std::collections::BTreeMap::from([(t.collection.clone(), m.clone())]);
if let Some(r) = vars.resolve(&t.id, &modes) {
if !val.references().iter().any(|r| gone.contains(*r)) {
continue;
}
let modes = std::collections::BTreeMap::from([(v.collection.clone(), m.clone())]);
if let Some(r) = vars.resolve(&v.id, &modes) {
detached.push((v.id.clone(), m.clone(), r));
}
}
@@ -333,11 +337,11 @@ fn reset_override(s: &mut Session, p: &Value) -> Result<Value> {
let v = req_string(cmd, p, "variable")?;
let doc = s.doc_mut()?;
let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?;
let parent = var.extends.as_deref().and_then(|x| doc.variables.get(x)).cloned();
let parent = var.extends.as_deref().and_then(|x| doc.variables.get_by_id(x)).cloned();
let parent = parent.ok_or_else(|| bad(cmd, "only variables an extended collection inherits have overrides"))?;
let modes: Vec<ModeId> = match string(p, "mode") {
Some(m) => {
let coll = doc.variables.collection(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?;
let coll = doc.variables.collection_by_id(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?;
vec![coll.find_mode(&m).ok_or_else(|| bad(cmd, "no such mode"))?.id.clone()]
}
None => var.overrides.iter().cloned().collect(),
@@ -429,31 +433,151 @@ pub fn sync_extensions(doc: &mut Document) {
}
}
/// Why a variable can't be read by an alias or a calculation.
enum TargetError {
Missing,
Kind(VariableType),
Loop,
}
/// The id of `target` (the variable a value would read, None when there is none) as something a
/// value may read: it is of `kind` and doesn't lead back to `from` (the variable being set, once it
/// exists).
fn reference_target(
vars: &Variables,
target: Option<&Variable>,
kind: VariableType,
from: Option<&VariableId>,
) -> std::result::Result<VariableId, TargetError> {
let t = target.ok_or(TargetError::Missing)?;
if t.kind != kind {
return Err(TargetError::Kind(t.kind));
}
if reaches_back(vars, &t.id, from) {
return Err(TargetError::Loop);
}
Ok(t.id.clone())
}
/// Check that `var` may alias `target` (id or name): it exists, has the same type and doesn't
/// lead back to `var`. Returns the target's id.
pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::result::Result<VariableId, String> {
let t = vars.get(target).ok_or_else(|| format!("no variable `{target}` to alias"))?;
if t.kind != var.kind {
return Err(format!("can't alias a {} variable to a {} one", var.kind.id(), t.kind.id()));
}
let mut stack = vec![t.id.clone()];
alias_target_by_id(vars, var, &t.id)
}
/// Check an alias whose target is already a stored or remapped id, without matching names.
pub(crate) fn alias_target_by_id(vars: &Variables, var: &Variable, target: &str) -> std::result::Result<VariableId, String> {
reference_target(vars, vars.get_by_id(target), var.kind, Some(&var.id)).map_err(|e| match e {
TargetError::Missing => format!("no variable `{target}` to alias"),
TargetError::Kind(k) => format!("can't alias a {} variable to a {} one", var.kind.id(), k.id()),
TargetError::Loop => "the alias would refer back to itself".into(),
})
}
/// Whether what `seed` references (aliases and colour-calculation operands, in every mode) can
/// lead back to `only`. Bounded at 10,000 variables for hand-crafted files with bigger graphs.
fn reaches_back(vars: &Variables, seed: &VariableId, only: Option<&VariableId>) -> bool {
let Some(only) = only else { return false };
let mut stack = vec![seed.clone()];
let mut seen = BTreeSet::new();
while let Some(cur) = stack.pop() {
if cur == var.id {
return Err("the alias would refer back to itself".into());
if cur == *only {
return true;
}
if !seen.insert(cur.clone()) || seen.len() > 10_000 {
continue;
}
for v in vars.get(&cur).map(|x| x.values.values()).into_iter().flatten() {
if let VariableValue::Alias(a) = v
&& let Some(next) = vars.get(a)
{
stack.push(next.id.clone());
}
for v in vars.get_by_id(&cur).map(|x| x.values.values()).into_iter().flatten() {
stack.extend(v.references().into_iter().cloned());
}
}
Ok(t.id.clone())
false
}
/// `{"alpha": …}` / `{"mix": …}` values: a colour derived from what it reads, checked the moment it
/// is set (unknown operand, non-colour operand, bad parameter, a reference back to the variable
/// being defined). Names bind to ids here, the way aliases bind at the call sites.
pub(crate) fn colour_calc(cmd: &str, vars: &Variables, kind: VariableType, for_id: Option<&VariableId>, v: &Value) -> Result<VariableValue> {
if kind != VariableType::Color {
return Err(bad(cmd, "colour calculations only fit colour variables"));
}
// One operation per value: a second key beside it (another operation, `alias`, a typo) would
// otherwise be dropped without a word.
let obj = v.as_object().filter(|o| o.len() == 1).ok_or_else(|| bad(cmd, "a colour calculation is {alpha: …} or {mix: …} on its own"))?;
match obj.get("alpha") {
Some(a) => {
// Only these two keys: an extra one (a typo beside `opacity`, an option this doesn't take)
// must not be dropped without a word.
let a = a
.as_object()
.filter(|a| a.keys().all(|k| k == "variable" || k == "opacity"))
.ok_or_else(|| bad(cmd, "alpha: {variable, opacity}"))?;
let name = a.get("variable").and_then(Value::as_str).ok_or_else(|| bad(cmd, "alpha needs the variable to fade"))?;
// Parameters before operands: their errors don't depend on what the operands are.
let opacity = unit(cmd, a, "opacity")?;
Ok(VariableValue::ColorAlpha { variable: calc_operand(cmd, vars, for_id, name)?, opacity })
}
None => match obj.get("mix") {
Some(m) => {
let m = m
.as_object()
.filter(|m| m.keys().all(|k| matches!(k.as_str(), "from" | "to" | "t")))
.ok_or_else(|| bad(cmd, "mix: {from, to, t}"))?;
let t = unit(cmd, m, "t")?;
Ok(VariableValue::ColorMix {
from: mix_operand(cmd, vars, for_id, m.get("from"))?,
to: mix_operand(cmd, vars, for_id, m.get("to"))?,
t,
})
}
None => Err(bad(cmd, "not a colour calculation")),
},
}
}
/// A calculation whose operands all exist can still read more variables than one resolve may (a
/// mix of two long alias chains): refused when it is set, rather than left showing nothing. It is
/// checked in `mode` with every other collection in its default mode, the modes exports write it
/// in; another mode of a collection it reads, or a later change to what it reads, can still take
/// it past the limit, as an alias chain can.
pub(crate) fn check_resolves(cmd: &str, vars: &Variables, id: &str, collection: &str, mode: &str, val: &VariableValue) -> Result<()> {
let calc = matches!(val, VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. });
if calc && vars.flattened_as(id, collection, mode, val).is_none() {
return Err(bad(cmd, format!("the colour calculation reads more than {} variables", Variables::RESOLVE_LIMIT)));
}
Ok(())
}
/// `obj[key]` as a number from 0 to 1. Strict, unlike `fraction_of` (which reads numbers past 1 as
/// percentages): a mistyped 1.5 is refused instead of being stored as 1.5%. -0.0 is stored as 0.0
/// so saved files read cleanly.
fn unit(cmd: &str, obj: &serde_json::Map<String, Value>, key: &str) -> Result<f32> {
let x = obj.get(key).and_then(Value::as_f64).filter(|x| (0.0..=1.0).contains(x));
let x = x.ok_or_else(|| bad(cmd, format!("`{key}` must be a number from 0 to 1")))?;
Ok(if x == 0.0 { 0.0 } else { x as f32 })
}
/// A calculation operand given by name: an existing colour variable, never one that leads back to
/// the variable being set. Returns its id.
fn calc_operand(cmd: &str, vars: &Variables, for_id: Option<&VariableId>, name: &str) -> Result<VariableId> {
reference_target(vars, vars.get(name), VariableType::Color, for_id).map_err(|e| match e {
TargetError::Missing => bad(cmd, format!("no variable `{name}` to calculate from")),
TargetError::Kind(k) => bad(cmd, format!("colour calculations take colour variables: `{name}` is a {} one", k.id())),
TargetError::Loop => bad(cmd, "the colour calculation would refer back to itself"),
})
}
/// A mix operand: `{variable: name-or-id}` or `{literal: colour}` — a bare string is a colour name
/// as easily as a variable name, so the kind is always spelled out.
fn mix_operand(cmd: &str, vars: &Variables, for_id: Option<&VariableId>, v: Option<&Value>) -> Result<ColorOperand> {
let shape = || bad(cmd, "mix operands are {variable: …} or {literal: colour}");
let o = v.and_then(Value::as_object).filter(|o| o.len() == 1).ok_or_else(shape)?;
if let Some(name) = o.get("variable").and_then(Value::as_str) {
return Ok(ColorOperand::Variable { variable: calc_operand(cmd, vars, for_id, name)? });
}
let literal = crate::params::color_of(o.get("literal").ok_or_else(shape)?).ok_or_else(|| bad(cmd, "mix literal must be a colour"))?;
Ok(ColorOperand::Literal { literal })
}
/// The variable scope of bindable property `field` on `node` (None: any variable of the type fits).
+11 -6
View File
@@ -18,7 +18,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.export",
"Export variables",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file) | dart: a Dart file for Flutter, a class of constants per collection and mode (Color, double, String, bool), aliases resolved), path? (write it there)} → {text}, or {path} when written",
"{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads, colour calculations as the colour they show in each mode with other collections in their default modes | css: custom properties, the default mode on :root and each other mode on [data-<collection>=\"<mode>\"], colour calculations as the colour they show in that mode with other collections in their default modes; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file; each other mode as in css; colour calculations as the colour they show in that mode, other collections in their default modes) | dart: a Dart file for Flutter, a class of constants per collection and mode (Color, double, String, bool), aliases and colour calculations resolved), path? (write it there)} → {text}, or {path} when written",
false,
export
),
@@ -67,9 +67,10 @@ fn css_safe(text: &str) -> String {
/// Each collection's variables as custom properties (named as generated code names them): the
/// default mode's values on `:root`, every other mode's on `[data-<collection>="<mode>"]`;
/// aliases are `var(--name)`.
/// aliases are `var(--name)` and colour calculations resolve to their colour in the mode.
fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
let vars = &doc.variables;
let flattener = vars.flattener();
// Tailwind theme names, unique per file and the same ones page code reads.
let theme = crate::tailwind::Theme::new(doc);
let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) };
@@ -89,17 +90,21 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String {
};
out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name)));
for v in vars.variables.iter().filter(|v| v.collection == c.id) {
let value = match v.values.get(&m.id) {
// A calculation is written as the colour it resolves to in this mode (no color-mix()
// or relative-colour output), and so is an alias to one some mode leaves out:
// `var()` of a property no block defines would invalidate whatever reads it.
let flat = v.values.get(&m.id).and_then(|x| flattener.value(v, &m.id, x));
let value = match flat.as_ref() {
Some(VariableValue::Color(col)) => col.to_css(),
Some(VariableValue::Number(x)) if roles.role(v) == crate::token_roles::NumberRole::Unitless => trim_num(*x),
Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)),
Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)),
Some(VariableValue::Boolean(b)) => b.to_string(),
Some(VariableValue::Alias(a)) => match vars.get(a) {
Some(VariableValue::Alias(a)) => match vars.get_by_id(a) {
Some(t) => format!("var({})", name(t)),
None => continue,
},
None => continue,
Some(VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. }) | None => continue,
};
out.push_str(&format!(" {}: {value};\n", name(v)));
}
@@ -330,7 +335,7 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> {
VariableValue::Alias(a) => {
let Some(var) = doc.variables.variables.iter().find(|x| x.id == *local) else { continue };
let target = ids.get(a).ok_or_else(|| "its alias target wasn't imported".to_string());
match target.and_then(|t| super::variables::alias_target(&doc.variables, var, t)) {
match target.and_then(|t| super::variables::alias_target_by_id(&doc.variables, var, t)) {
Ok(t) => VariableValue::Alias(t),
Err(e) => {
warnings.push(format!("`{}`: {e}", v.name));
+1 -1
View File
@@ -372,7 +372,7 @@ pub fn bound_variable<'d>(doc: &'d Document, n: &Node, field: &str) -> Option<&'
_ => &[],
};
let found = if forms.is_empty() { n.bound.get(field) } else { forms.iter().find_map(|k| n.bound.get(*k)) };
found.and_then(|v| doc.variables.get(v))
found.and_then(|v| doc.variables.get_by_id(v))
}
/// The variable bound to `field` on `n`, by name.
+19
View File
@@ -58,6 +58,25 @@ fn properties_group_layout_auto_layout_paints_and_tokens() {
assert!(s.execute("inspect.properties", &json!({"id": 999_999})).is_err());
}
#[test]
fn stored_variable_ids_keep_their_dev_inspect_token() {
let mut s = Session::new();
s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap();
let shadow = s.execute("variable.create", &json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})).unwrap();
let surface = s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})).unwrap();
let r = id(s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": surface["id"]}}})).unwrap());
s.execute("variable.update", &json!({"variable": "surface", "codeSyntax": {"WEB": "--real-surface"}})).unwrap();
s.execute("variable.update", &json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}})).unwrap();
let code = s.execute("inspect.code", &json!({"id": r, "language": "css"})).unwrap();
assert!(code["code"].as_str().unwrap().contains("var(--real-surface"));
assert!(!code["code"].as_str().unwrap().contains("--shadow"));
let out = s.execute("inspect.properties", &json!({"id": r})).unwrap();
let fills = out["sections"].as_array().unwrap().iter().find(|s| s["title"] == "Fills").unwrap();
assert_eq!(fills["rows"][0]["color"], "#0D99FF");
assert_eq!(fills["rows"][0]["token"], "surface");
}
#[test]
fn typography_section_for_text() {
let mut s = Session::new();
+4
View File
@@ -26,6 +26,10 @@ pub struct Interaction {
pub label: String,
pub doc: Arc<Document>,
pub selection: Vec<NodeId>,
/// The gesture so far only moved the selected layers by this much (page units) from `doc`:
/// their outlines, box and everything else that doesn't depend on where they are can follow
/// by shifting what was worked out from `doc` (#1571). `None` for any other change.
pub moved_by: Option<(f64, f64)>,
}
/// The text being edited on the canvas.
+1 -5
View File
@@ -324,11 +324,7 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I
/// Read a file of at most `max` bytes.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
let meta = crate::file_access::metadata(path)?;
if !meta.is_file() || meta.len() > max as u64 {
return Err(EngineError::Other(format!("{}: not a file of at most {} KB", path.display(), max / 1024)));
}
crate::file_access::read(path)
crate::file_access::read_capped(path, max as u64)
}
/// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute
+72 -1
View File
@@ -16,7 +16,7 @@ use figcraft_color::{ImageScaleMode, Paint};
use figcraft_doc::{Document, Node, NodeId, NodeKind, TextData};
use figcraft_geom::{Affine, Point};
use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_STATE_BYTES, check_menu, check_state, clean_field};
use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetKind, WidgetNode, WidgetRender};
use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetHover, WidgetKind, WidgetNode, WidgetRender};
pub use figcraft_plugin::{MenuItem, MenuKind};
use serde_json::{Map, Value, json};
@@ -36,6 +36,8 @@ const MENU: &str = "figcraft.widget.menu";
const TARGET: &str = "figcraft.widget.target";
/// On a text layer inside a widget: the name of the editable field it is.
const FIELD: &str = "figcraft.widget.field";
/// On a widget or a layer inside one: how it looks while the pointer is over it (JSON).
const HOVER: &str = "figcraft.widget.hover";
/// Images in a widget are at most this many pixels on a side.
const MAX_IMAGE_SIDE: u32 = 4096;
@@ -79,6 +81,54 @@ pub fn field_of(doc: &Document, id: NodeId) -> Option<(NodeId, String)> {
Some((widget_of(doc, id)?, field))
}
/// The layers of a widget under page point `p` that change while the pointer is over them (the
/// widget itself among them), given the layer the pointer hits, outermost first.
pub fn hovered_at(doc: &Document, hit: NodeId, p: Point) -> Vec<NodeId> {
let Some(w) = widget_of(doc, hit) else { return Vec::new() };
doc.subtree(w)
.into_iter()
.filter(|i| doc.node(*i).is_some_and(|n| n.visible && n.plugin_data.contains_key(HOVER)))
.filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p)))
.collect()
}
/// What layer `n` sets while the pointer is over it, as `node.set` properties (none when it has no
/// usable hover style: the layer data may come from a file).
fn hover_props(n: &Node) -> Option<Map<String, Value>> {
let text = n.plugin_data.get(HOVER).filter(|s| s.len() <= MAX_STATE_BYTES)?;
let mut h: WidgetHover = serde_json::from_str(text).ok()?;
h.check(if matches!(n.kind, NodeKind::Text(_)) { WidgetKind::Text } else { WidgetKind::Rectangle }).ok()?;
let mut p = Map::new();
if let Some(c) = h.fill.or(h.color) {
p.insert("fills".into(), json!([c]));
}
if let Some(c) = h.stroke {
p.insert("strokes".into(), json!([c]));
}
if let Some(o) = h.opacity {
p.insert("opacity".into(), json!(o));
}
Some(p)
}
impl Session {
/// Show the hover styles of widget layers `ids` as a live preview (none: end the one showing):
/// nothing is undone, saved or sent to a live session, and the next command puts the document
/// back first. Layers without a hover style are left as they are.
pub fn widget_hover(&mut self, ids: &[NodeId]) -> Result<bool> {
let cmd = "widget.hover";
crate::textpreview::end(self);
let doc = self.doc()?;
let changes: Vec<(NodeId, Map<String, Value>)> =
ids.iter().filter(|i| widget_of(doc, **i).is_some()).filter_map(|i| Some((*i, hover_props(doc.node(*i)?)?))).collect();
if changes.is_empty() {
return Ok(false);
}
crate::textpreview::show_change(self, cmd, |doc| changes.iter().try_for_each(|(id, p)| crate::cmd::props::apply(cmd, doc, *id, p)))?;
Ok(true)
}
}
/// Widget `w`'s property menu (empty when it has none or it's unusable: it may come from a file).
pub fn menu_of(doc: &Document, w: NodeId) -> Vec<MenuItem> {
doc.node(w)
@@ -172,6 +222,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<(
Ok(menu) if !r.menu.is_empty() => n.plugin_data.insert(MENU.into(), menu),
_ => n.plugin_data.remove(MENU),
};
set_hover(n, r.root.hover.as_ref());
for c in &r.root.children {
add(doc, w, c, cmd)?;
}
@@ -199,6 +250,7 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<
if !d.field.is_empty() {
n.plugin_data.insert(FIELD.into(), d.field.clone());
}
set_hover(&mut n, d.hover.as_ref());
if d.kind == WidgetKind::Image {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD.decode(d.data.trim()).map_err(|e| bad(cmd, format!("an image's `data`: {e}")))?;
@@ -218,6 +270,14 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result<
d.children.iter().try_for_each(|c| add(doc, id, c, cmd))
}
/// Keep layer `n`'s hover style (validated already) with it, or drop the one it had.
fn set_hover(n: &mut Node, hover: Option<&WidgetHover>) {
match hover.and_then(|h| serde_json::to_string(h).ok()) {
Some(h) => n.plugin_data.insert(HOVER.into(), h),
None => n.plugin_data.remove(HOVER),
};
}
/// `node.set` properties for described layer `d` (validated already); `root`: the widget itself,
/// which keeps its place and size unless the description gives one.
fn props(d: &WidgetNode, root: bool) -> Map<String, Value> {
@@ -462,6 +522,17 @@ pub fn specs() -> Vec<CommandSpec> {
false,
menu
),
spec!(
"widget.hover",
"Hover widget",
"{ids? [layers of widgets]} shows their hover styles, as the canvas does while the pointer is over a widget: a live preview without an undo step, which the next command (or no ids) ends → {hovering}",
false,
|s, p| {
let ids: Vec<NodeId> =
p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(crate::params::id_of).collect()).unwrap_or_default();
Ok(json!({"hovering": s.widget_hover(&ids)?}))
}
),
spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets, fields, menu}]}", false, list),
]
}
@@ -206,3 +206,26 @@ fn nested_instances_of_variants_link_to_their_mains_layers() {
let _ = std::fs::remove_file(&path);
assert_eq!(outline(s.doc().unwrap()), before);
}
#[test]
fn stored_variable_ids_keep_exported_binding_identity() {
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let shadow = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
let surface = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"}));
let r = id(&run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}})));
run(&mut s, "select.set", json!({"ids": [r]}));
run(&mut s, "style.create", json!({"type": "paint", "name": "Surface"}));
run(&mut s, "node.set", json!({"id": r, "props": {"name": "Styled"}}));
run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}}));
run(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
let (back, _) = figcraft_format::fig::import(&bytes, "identity").unwrap();
let bound = back.nodes().find(|n| n.name == "Bound").unwrap().bound.values().next().unwrap();
assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface");
let style = back.styles.iter().find(|s| s.name == "Surface").unwrap();
assert!(!style.bound.is_empty());
for bound in style.bound.values() {
assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface");
}
}
+38
View File
@@ -0,0 +1,38 @@
//! A `.fig` file whose text names an installed family another way than the font list does (#1292)
//! isn't reported missing and draws in that family: by the legacy name of one of its weights
//! ("<Family> Bold", style Regular) or spelt with other case and spaces. Through the real import
//! (a file the app exported), with an embedded family standing in for an installed one.
use base64::Engine as _;
use figcraft_doc::NodeKind;
use serde_json::json;
use crate::Session;
fn text(s: &mut Session, family: &str) {
let id = s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"].as_u64().unwrap();
if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(figcraft_doc::NodeId(id)).unwrap().kind {
t.style.font_family = family.into();
}
}
#[test]
fn an_imported_file_finds_installed_families_by_their_other_names() {
let Some(family) = figcraft_text::family_names().into_iter().find(|f| figcraft_text::family_styles(f).iter().any(|s| s == "Bold")) else {
return;
};
let legacy = format!("{family} Bold");
let squashed = family.to_uppercase().replace(' ', "");
let mut s = Session::new();
text(&mut s, &legacy);
text(&mut s, &squashed);
text(&mut s, "FigCraft Surely Missing");
let r = s.execute("file.export", &json!({"format": "FIG"})).unwrap();
let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap();
let (doc, _) = crate::cmd::file::load_bytes(&bytes, "names.fig").unwrap();
let missing: Vec<String> = crate::fonts::missing(&doc).into_iter().map(|m| m.family).collect();
assert_eq!(missing, ["FigCraft Surely Missing"], "{family}");
let bold = figcraft_text::find_face(&legacy, "Regular").unwrap();
assert_eq!((bold.family.to_lowercase(), bold.weight), (family.to_lowercase(), 700));
assert_eq!(figcraft_text::find_face(&squashed, "Bold").unwrap().family.to_lowercase(), family.to_lowercase());
}
+1 -1
View File
@@ -111,7 +111,7 @@ fn string_values(vars: &Variables, id: &str) -> Vec<String> {
if !seen.insert(id.clone()) {
continue;
}
let Some(var) = vars.get(&id) else { continue };
let Some(var) = vars.get_by_id(&id) else { continue };
for v in var.values.values() {
match v {
VariableValue::String(s) => out.push(s.clone()),
+149
View File
@@ -0,0 +1,149 @@
//! A panic in a grouped closure, outside any command, ends the group like a failure (#1085): the
//! documents, selection, undo and redo, history limit and journal are as they were, and the panic
//! goes on to the caller.
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::Arc;
use serde_json::json;
use crate::{HistoryEntry, NodeId, Session};
fn rect(s: &mut Session, x: i64) -> NodeId {
s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().map(NodeId).unwrap()
}
fn labels(entries: &[HistoryEntry]) -> Vec<String> {
entries.iter().map(|e| e.label.clone()).collect()
}
#[test]
fn a_grouped_closure_that_panics_leaves_the_session_as_before() {
let mut s = Session::new();
let first = rect(&mut s, 0);
let second = rect(&mut s, 20);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [first]})).unwrap();
let ds = s.ds().unwrap();
let (doc, selection, page) = (ds.doc.clone(), ds.selection.clone(), ds.page);
let (undo, redo, limit) = (labels(&ds.history.undo), labels(&ds.history.redo), ds.history.limit);
assert_eq!(redo.len(), 1);
let journal = s.journal.clone();
let depth = s.undo_depth();
let unwound = catch_unwind(AssertUnwindSafe(|| {
s.grouped("Edit then panic", |s| -> crate::Result<()> {
s.execute("node.set", &json!({"id": first, "props": {"name": "Renamed"}}))?;
rect(s, 40);
s.execute("select.set", &json!({"ids": []}))?;
panic!("a bug in the closure");
})
}));
let payload = unwound.unwrap_err();
assert_eq!(payload.downcast_ref::<&str>(), Some(&"a bug in the closure"), "the original panic goes on to the caller");
let ds = s.ds().unwrap();
assert!(Arc::ptr_eq(&ds.doc, &doc));
assert_eq!(*ds.doc, *doc);
assert_eq!(ds.selection, selection);
assert_eq!(ds.page, page);
assert_eq!(labels(&ds.history.undo), undo);
assert_eq!(labels(&ds.history.redo), redo);
assert_eq!(ds.history.limit, limit);
assert_eq!(s.journal, journal);
assert_eq!(s.undo_depth(), depth);
assert_eq!(s.group_depth, 0);
assert!(s.closed_in_group.is_empty());
// The redo step survived, and the next edit pushes one step of its own.
s.execute("edit.redo", &json!({})).unwrap();
assert!(s.doc().unwrap().contains(second));
rect(&mut s, 60);
assert_eq!(s.undo_depth(), (depth.0 + 2, 0));
}
#[test]
fn a_grouped_closure_that_panics_releases_the_live_hold_so_later_edits_sync() {
use crate::live_tests::{push, relay, serve, session};
let (mut s, script) = session();
let first = rect(&mut s, 0);
s.execute("collab.host", &json!({})).unwrap();
let (mut server, id, welcome) = serve(s.doc().unwrap());
push(&script, vec![welcome]);
s.live_poll();
assert!(s.live_doc(s.ds().unwrap().uid));
let before = s.doc().unwrap().clone();
script.lock().unwrap().msgs.clear();
let unwound = catch_unwind(AssertUnwindSafe(|| {
s.grouped("Rename then panic", |s| -> crate::Result<()> {
s.execute("node.set", &json!({"id": first, "props": {"name": "Rolled back"}}))?;
s.live_sync();
assert!(script.lock().unwrap().msgs.is_empty(), "the edit waits for the group to end");
panic!("a bug in the live closure");
})
}));
assert!(unwound.is_err());
assert_eq!(s.doc().unwrap(), &before);
s.live_sync();
assert!(script.lock().unwrap().msgs.is_empty(), "the rolled-back edit never goes out");
s.execute("node.set", &json!({"id": first, "props": {"name": "Sent afterwards"}})).unwrap();
s.live_sync();
assert_eq!(script.lock().unwrap().msgs.len(), 1, "the later edit goes out");
let replies = relay(&script, &mut server, id);
push(&script, replies);
s.live_poll();
assert_eq!(server.document().node(first).unwrap().name, "Sent afterwards");
assert_eq!(s.doc().unwrap(), server.document());
}
#[test]
fn a_panic_in_an_inner_group_rolls_back_through_the_outer_one() {
let mut s = Session::new();
let unwound = catch_unwind(AssertUnwindSafe(|| {
s.grouped("outer", |s| {
s.execute("comment.add", &json!({"x": 1, "y": 2, "text": "Rolled back"}))?;
s.grouped("inner", |s| -> crate::Result<()> {
s.execute("comment.add", &json!({"x": 3, "y": 4, "text": "Also rolled back"}))?;
panic!("a bug in the inner closure");
})
})
}));
assert!(unwound.is_err());
assert!(s.doc().unwrap().comments.is_empty());
assert!(s.pending_events.is_empty());
assert_eq!(s.events.latest(), 0);
assert_eq!(s.group_depth, 0);
assert_eq!(s.undo_depth(), (0, 0));
// The session still groups normally afterwards.
s.grouped("again", |s| s.execute("comment.add", &json!({"x": 5, "y": 6, "text": "Kept"})).map(|_| ())).unwrap();
assert_eq!(s.doc().unwrap().comments.len(), 1);
assert_eq!(s.undo_depth(), (1, 0));
}
#[test]
fn a_group_that_closes_and_opens_tabs_then_panics_brings_the_tabs_back() {
let mut s = Session::new();
s.execute("file.new", &json!({"name": "Second"})).unwrap();
let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect();
let active = s.active;
let unwound = catch_unwind(AssertUnwindSafe(|| {
s.grouped("Close, open, panic", |s| -> crate::Result<()> {
s.execute("file.close", &json!({"index": 1, "force": true}))?;
s.execute("file.new", &json!({"name": "Temporary"}))?;
panic!("a bug after switching tabs");
})
}));
assert!(unwound.is_err());
assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::<Vec<_>>(), uids);
assert_eq!(s.active, active);
assert!(s.closed_in_group.is_empty());
assert!(s.docs.iter().all(|ds| ds.history.limit != usize::MAX));
}
+4 -1
View File
@@ -128,7 +128,10 @@ fn an_explicit_variables_path_is_capped_like_the_sibling_file() {
for cmd in ["file.open", "file.import"] {
let before = state(&s);
let e = s.execute(cmd, &json!({"path": path, "variables": bad})).unwrap_err().to_string();
assert!(e.contains("not a file of at most 268435456 bytes"), "{cmd} {bad}: {e}");
assert!(
["more than the 256 MB allowed here", "is a folder, not a file", "isn't a regular file"].iter().any(|w| e.contains(w)),
"{cmd} {bad}: {e}"
);
assert_eq!(state(&s), before, "{cmd} {bad}");
}
}
+507
View File
@@ -0,0 +1,507 @@
//! Compact inspection keeps the tree useful without building every layer property.
use figcraft_doc::{NodeId, NodeKind};
use serde_json::{Value, json};
use crate::Session;
use crate::inspect::{InspectOptions, document, document_with_options, node_json, node_json_with_options};
fn run(s: &mut Session, command: &str, params: Value) -> Value {
s.execute(command, &params).unwrap_or_else(|e| panic!("{command}: {e}"))
}
fn create(s: &mut Session, params: Value) -> NodeId {
crate::params::id_of(&run(s, "node.create", params)["id"]).unwrap()
}
fn tree() -> (Session, NodeId, NodeId) {
let mut s = Session::new();
let frame = create(&mut s, json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200}));
let text = create(&mut s, json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "x": 10, "y": 20}));
create(&mut s, json!({"type": "rectangle", "parent": frame, "name": "Hidden", "props": {"visible": false, "fill": "#123456"}}));
(s, frame, text)
}
fn node(s: &Session, id: NodeId, depth: usize, params: Value) -> Value {
node_json_with_options(s.doc().unwrap(), id, depth, &InspectOptions::parse(&params).unwrap())
}
fn keys(v: &Value, allowed: &[&str]) {
for (key, value) in v.as_object().unwrap() {
assert!(allowed.contains(&key.as_str()), "unexpected {key}: {v}");
if key == "children" {
for child in value.as_array().unwrap() {
keys(child, allowed);
}
}
}
}
fn golden_tree() -> (Session, NodeId) {
let mut s = Session::new();
let frame = create(
&mut s,
json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200,
"props": {"layout": "horizontal", "padding": 8, "gap": 4, "fill": "#123456", "stroke": "#abcdef", "strokeWeight": 2,
"effects": [{"type": "dropShadow", "offset": [2, 3], "radius": 4}], "cornerRadius": 12}}),
);
create(
&mut s,
json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "width": 80, "height": 24,
"props": {"fontFamily": "Synthetic", "fontSize": 18, "fontStyle": "Bold", "letterSpacing": 1, "lineHeight": 24,
"textAlign": "center", "textDecoration": "underline", "fill": "#fedcba"}}),
);
create(
&mut s,
json!({"type": "rectangle", "parent": frame, "name": "Hidden", "width": 20, "height": 30,
"props": {"visible": false, "locked": true, "fill": "#654321"}}),
);
let main = create(&mut s, json!({"type": "component", "name": "Button", "x": 400, "y": 200, "width": 80, "height": 40}));
run(&mut s, "instance.create", json!({"component": main, "x": 500, "y": 200}));
run(&mut s, "page.add", json!({"name": "Second"}));
(s, frame)
}
#[test]
fn empty_options_keep_the_main_tree_and_document_output() {
let (s, frame) = golden_tree();
// Captured with this builder on origin/main (43af145a12eacd1e87f5f5e6984c49b7da040288), plus the
// document `id` that #1533 added. That id comes from a process-wide counter, so it is the
// session's own.
let expected = json!({
"colorProfile": "srgb",
"currentPage": 7,
"dirty": true,
"history": {
"redo": 0,
"undo": [
"Add page",
"Create instance",
"Create layer",
"Create layer",
"Create layer",
"Create layer"
]
},
"id": s.ds().unwrap().uid,
"images": 0,
"kind": "design",
"name": "Untitled",
"pages": [
{
"background": "#F5F5F5",
"children": [
{
"absoluteBounds": {
"height": 200.0,
"width": 300.0,
"x": 100.0,
"y": 200.0
},
"children": [
{
"absoluteBounds": {
"height": 24.0,
"width": 80.0,
"x": 110.0,
"y": 210.0
},
"characters": "Hello",
"fills": [
{
"color": "#FEDCBA",
"type": "solid"
}
],
"fontFamily": "Synthetic",
"fontSize": 18.0,
"fontStyle": "Bold",
"height": 24.0,
"id": 3,
"letterSpacing": "1",
"lineHeight": "24",
"name": "Title",
"sizing": {
"horizontal": "fixed",
"vertical": "hug"
},
"textAlign": "center",
"textAutoResize": "height",
"textDecoration": "underline",
"type": "TEXT",
"width": 80.0,
"x": 10.0,
"y": 10.0
},
{
"absoluteBounds": {
"height": 30.0,
"width": 20.0,
"x": 100.0,
"y": 200.0
},
"fills": [
{
"color": "#654321",
"type": "solid"
}
],
"height": 30.0,
"id": 4,
"locked": true,
"name": "Hidden",
"sizing": {
"horizontal": "fixed",
"vertical": "fixed"
},
"type": "RECTANGLE",
"visible": false,
"width": 20.0,
"x": 0.0,
"y": 0.0
}
],
"clip": true,
"cornerRadius": 12.0,
"effects": [
{
"blend": "normal",
"color": {
"a": 0.25,
"b": 0.0,
"g": 0.0,
"r": 0.0
},
"offset": [
2.0,
3.0
],
"radius": 4.0,
"showBehind": false,
"spread": 0.0,
"type": "dropShadow",
"visible": true
}
],
"fills": [
{
"color": "#123456",
"type": "solid"
}
],
"height": 200.0,
"id": 2,
"layout": {
"columnTracks": [],
"contentAlign": "auto",
"crossAlign": "min",
"crossGap": 10.0,
"gap": 4.0,
"gridColumns": 2,
"gridRows": 2,
"mainAlign": "min",
"mode": "horizontal",
"padding": [
8.0,
8.0,
8.0,
8.0
],
"reverseZ": false,
"rowTracks": [],
"strokesIncluded": true,
"wrap": false
},
"name": "Card",
"sizing": {
"horizontal": "fixed",
"vertical": "fixed"
},
"strokeAlign": "inside",
"strokeWeight": 2.0,
"strokes": [
{
"color": "#ABCDEF",
"type": "solid"
}
],
"type": "FRAME",
"width": 300.0,
"x": 100.0,
"y": 200.0
},
{
"absoluteBounds": {
"height": 40.0,
"width": 80.0,
"x": 400.0,
"y": 200.0
},
"clip": true,
"fills": [
{
"color": "#FFFFFF",
"type": "solid"
}
],
"height": 40.0,
"id": 5,
"name": "Button",
"type": "COMPONENT",
"width": 80.0,
"x": 400.0,
"y": 200.0
},
{
"absoluteBounds": {
"height": 40.0,
"width": 80.0,
"x": 500.0,
"y": 200.0
},
"clip": true,
"fills": [
{
"color": "#FFFFFF",
"type": "solid"
}
],
"height": 40.0,
"id": 6,
"mainComponent": 5,
"mainComponentName": "Button",
"name": "Button",
"type": "INSTANCE",
"width": 80.0,
"x": 500.0,
"y": 200.0
}
],
"id": 1,
"name": "Page 1",
"type": "PAGE"
},
{
"background": "#F5F5F5",
"id": 7,
"name": "Second",
"type": "PAGE"
}
],
"path": null,
"selection": [],
"styles": [],
"textEdit": null,
"variables": 0
});
let expected_node = &expected["pages"][0]["children"][0];
assert_eq!(node_json(s.doc().unwrap(), frame, 4).to_string(), expected_node.to_string());
assert_eq!(document(&s, 4, true).to_string(), expected.to_string());
for params in [json!({}), json!({"outline": false})] {
let options = InspectOptions::parse(&params).unwrap();
assert_eq!(node_json_with_options(s.doc().unwrap(), frame, 4, &options).to_string(), expected_node.to_string());
assert_eq!(document_with_options(&s, 4, true, &options).to_string(), expected.to_string());
}
}
#[test]
fn fields_filter_recursively_including_pages_without_changing_metadata_or_history() {
let (s, _, _) = tree();
let before = document(&s, 4, true);
let selection = s.selection();
let history = s.undo_depth();
let options = InspectOptions::parse(&json!({"fields": ["name", "characters", "name"], "outline": false})).unwrap();
let compact = document_with_options(&s, 4, true, &options);
let mut metadata = before.clone();
metadata["pages"] = compact["pages"].clone();
assert_eq!(compact, metadata);
keys(&compact["pages"][0], &["id", "name", "characters", "children", "childCount"]);
assert_eq!(compact["pages"][0]["children"][0]["children"][0]["characters"], "Hello");
assert_eq!(s.selection(), selection);
assert_eq!(s.undo_depth(), history);
assert_eq!(document(&s, 4, true), before);
}
#[test]
fn an_empty_field_list_keeps_ids_and_structure_at_depth_boundaries() {
let (s, frame, _) = tree();
let compact = node(&s, frame, 4, json!({"fields": []}));
keys(&compact, &["id", "children", "childCount"]);
assert_eq!(compact["children"].as_array().unwrap().len(), 2);
assert_eq!(node(&s, frame, 0, json!({"fields": []})), json!({"id": frame, "childCount": 2}));
let options = InspectOptions::parse(&json!({"fields": []})).unwrap();
let summary = document_with_options(&s, 0, false, &options);
assert_eq!(summary["pages"][0], json!({"id": s.page().unwrap(), "childCount": 1}));
}
#[test]
fn geometry_keys_are_independently_selectable() {
let (s, frame, text) = tree();
let fields = ["x", "y", "width", "height", "absoluteBounds"];
let compact = node(&s, frame, 2, json!({"fields": fields}));
keys(&compact, &["id", "children", "childCount", "x", "y", "width", "height", "absoluteBounds"]);
assert_eq!((compact["x"].as_f64(), compact["y"].as_f64()), (Some(100.0), Some(200.0)));
assert_eq!(compact["children"][0]["x"], 10.0);
assert_eq!(compact["children"][0]["absoluteBounds"]["x"], 110.0);
for key in fields {
let one = node(&s, text, 0, json!({"fields": [key]}));
assert_eq!(one.as_object().unwrap().len(), 2, "{one}");
assert_eq!(one[key], compact["children"][0][key]);
}
}
#[test]
fn outline_truncates_only_after_120_unicode_scalars() {
let (mut s, _, text) = tree();
for unit in ["a", "界", "\u{1f980}"] {
for len in [0, 119, 120, 121] {
let content = unit.repeat(len);
s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().characters = content.clone();
let outline = node(&s, text, 0, json!({"outline": true}));
let want = if len > 120 { format!("{}...", unit.repeat(120)) } else { content.clone() };
assert_eq!(outline["characters"], want);
assert_eq!(node(&s, text, 0, json!({"fields": ["characters"]}))["characters"], content);
keys(&outline, &["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount"]);
}
}
}
#[test]
fn selecting_visible_makes_it_explicit_for_every_node_including_pages() {
let (s, frame, _) = tree();
for params in [json!({"fields": ["visible"]}), json!({"outline": true})] {
let options = InspectOptions::parse(&params).unwrap();
let tree = document_with_options(&s, 4, true, &options);
assert_eq!(tree["pages"][0]["visible"], true);
let card = &tree["pages"][0]["children"][0];
assert_eq!(card["visible"], true);
assert_eq!(card["children"][0]["visible"], true);
assert_eq!(card["children"][1]["visible"], false);
}
let no_visible = node(&s, frame, 4, json!({"fields": ["name"]}));
assert!(no_visible["children"][1].get("visible").is_none());
}
#[test]
fn component_names_are_selected_without_other_instance_properties_even_with_a_missing_main() {
let mut s = Session::new();
let main = create(&mut s, json!({"type": "component", "name": "Button"}));
let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap();
for params in [json!({"fields": ["mainComponentName"]}), json!({"outline": true})] {
let compact = node(&s, instance, 0, params);
assert_eq!(compact["mainComponentName"], "Button");
assert!(compact.get("mainComponent").is_none());
}
if let NodeKind::Instance { component, .. } = &mut s.doc_mut().unwrap().get_mut(instance).unwrap().kind {
*component = NodeId(u64::MAX);
}
let compact = node(&s, instance, 0, json!({"outline": true}));
assert_eq!(compact.get("mainComponentName"), Some(&Value::Null));
}
#[test]
fn page_properties_and_other_page_summaries_respect_fields() {
let (mut s, _, _) = tree();
let first = s.page().unwrap();
run(&mut s, "node.set", json!({"ids": [first], "props": {"pluginData": {"source": "synthetic"}}}));
run(&mut s, "page.add", json!({"name": "Second"}));
let options = InspectOptions::parse(&json!({"fields": ["pluginData", "background"]})).unwrap();
let compact = document_with_options(&s, 4, false, &options);
assert_eq!(compact["pages"].as_array().unwrap().len(), 2);
let page = &compact["pages"][0];
keys(page, &["id", "pluginData", "background", "childCount", "children"]);
assert_eq!(page["pluginData"]["source"], "synthetic");
assert_eq!(page["childCount"], 1);
assert!(page["background"].is_string());
assert!(page.get("name").is_none());
}
#[test]
fn the_catalogue_accepts_emitted_keys_and_structure_keys() {
let (mut s, frame, text) = tree();
run(&mut s, "node.set", json!({"ids": [frame], "props": {"layout": "grid", "fill": "#123456", "stroke": "#abcdef"}}));
s.doc_mut().unwrap().get_mut(frame).unwrap().dev_note = "Ready for review".into();
s.doc_mut().unwrap().get_mut(frame).unwrap().dev_changed = true;
let main = create(&mut s, json!({"type": "component", "name": "Button"}));
let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap();
s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().style.style_id = Some("synthetic-style".into());
// Exercise each of the six property helpers, including their more expensive values.
for (id, key, sibling) in [
(frame, "absoluteBounds", "width"),
(frame, "fills", "strokes"),
(text, "gridCell", "sizing"),
(frame, "devNote", "devChanged"),
(text, "textStyleRanges", "fontFamily"),
(instance, "mainComponentName", "mainComponent"),
] {
let full = node_json(s.doc().unwrap(), id, 0);
assert!(full.get(key).is_some(), "fixture must emit {key}: {full}");
assert!(full.get(sibling).is_some(), "fixture must emit {sibling}: {full}");
let selected = node(&s, id, 0, json!({"fields": [key]}));
assert_eq!(selected[key], full[key]);
assert!(selected.get(sibling).is_none(), "unrequested {sibling}: {selected}");
keys(&selected, &["id", "children", "childCount", key]);
}
let full = node_json(s.doc().unwrap(), s.page().unwrap(), 3);
fn check(s: &Session, full: &Value) {
let id = crate::params::id_of(&full["id"]).unwrap();
for (key, value) in full.as_object().unwrap() {
let selected = node(s, id, 2, json!({"fields": [key]}));
if key == "children" {
assert_eq!(selected[key].as_array().map(Vec::len), value.as_array().map(Vec::len));
} else {
assert_eq!(&selected[key], value, "{key}");
}
}
for child in full["children"].as_array().into_iter().flatten() {
check(s, child);
}
}
check(&s, &full);
let structural = node(&s, frame, 0, json!({"fields": ["id", "children", "childCount"]}));
assert_eq!(structural, json!({"id": frame, "childCount": 2}));
}
#[test]
fn invalid_options_are_errors_and_unknown_names_list_the_catalogue() {
for params in [
json!({"fields": null}),
json!({"fields": "name"}),
json!({"fields": {"name": true}}),
json!({"fields": [false]}),
json!({"fields": [null]}),
json!({"fields": ["name", 1]}),
json!({"outline": null}),
json!({"outline": "true"}),
json!({"outline": 1}),
json!({"outline": []}),
json!({"outline": true, "fields": []}),
json!({"outline": true, "fields": ["name"]}),
] {
assert!(InspectOptions::parse(&params).is_err(), "{params}");
}
for key in ["Name", "absolute_bounds", "noSuchField", "truncated"] {
let error = InspectOptions::parse(&json!({"fields": [key]})).unwrap_err();
assert!(error.contains(key), "{error}");
for valid in crate::inspect::NODE_KEYS {
assert!(error.contains(valid), "missing {valid}: {error}");
}
}
}
#[test]
fn node_get_uses_options_but_keeps_its_existing_depth_and_selection() {
let (mut s, frame, _) = tree();
let selection = s.selection();
let history = s.undo_depth();
let default = node_json(s.doc().unwrap(), frame, 1);
assert_eq!(run(&mut s, "node.get", json!({"id": frame})), default);
let compact = run(&mut s, "node.get", json!({"id": frame, "fields": []}));
assert!(compact["children"].is_array(), "the engine command still inspects to depth 1");
let error = s.execute("node.get", &json!({"id": frame, "fields": [], "depth": 0})).unwrap_err();
assert!(error.to_string().contains("unknown parameter `depth`"), "{error}");
keys(&compact, &["id", "children", "childCount"]);
assert!(s.execute("node.get", &json!({"id": frame, "outline": true, "fields": []})).is_err());
assert_eq!(s.selection(), selection);
assert_eq!(s.undo_depth(), history);
}
+1 -1
View File
@@ -201,7 +201,7 @@ pub static KEYS: &[Binding] = &[
k(Tools, "tool.highlighter", &["U"]),
k(Tools, "tool.eraser", &["E"]),
k(Tools, "tool.stamp", &["W"]),
k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one").both(),
k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one, or the next shape joined to it").both(),
// View
// The documentation gives both keys for the pixel grid in different places.
k(View, "view.pixelGrid", &["Shift+'", "Cmd+'"]),
+20
View File
@@ -201,6 +201,8 @@ mod batch_undo_snapshot_tests;
#[cfg(test)]
mod binding_lists_tests;
#[cfg(test)]
mod board_connected_tests;
#[cfg(test)]
mod board_connector_tests;
#[cfg(test)]
mod board_grid_tests;
@@ -307,6 +309,8 @@ mod fig_export_assets_tests;
#[cfg(test)]
mod fig_export_tests;
#[cfg(test)]
mod fig_font_names_tests;
#[cfg(test)]
mod fig_import_tests;
#[cfg(test)]
mod fill_none_tests;
@@ -327,6 +331,8 @@ mod gesture_preview_tests;
#[cfg(test)]
mod gradient_shorthand_tests;
#[cfg(test)]
mod group_panic_tests;
#[cfg(test)]
mod guide_snap_tests;
#[cfg(test)]
mod hide_publishing_tests;
@@ -349,6 +355,8 @@ mod import_journal_tests;
#[cfg(test)]
mod inspect_behaviour_tests;
#[cfg(test)]
mod inspect_fields_tests;
#[cfg(test)]
mod instance_modes_tests;
#[cfg(test)]
mod instance_resizing_tests;
@@ -391,6 +399,8 @@ mod measure_tests;
#[cfg(test)]
mod menu_gaps_tests;
#[cfg(test)]
mod moved_by_tests;
#[cfg(test)]
mod nested_override_tests;
#[cfg(test)]
mod node_find_tests;
@@ -419,6 +429,10 @@ mod paint_tests;
#[cfg(test)]
mod panels_behaviour_tests;
#[cfg(test)]
mod paste_missing_image_tests;
#[cfg(test)]
mod path_read_caps_tests;
#[cfg(test)]
mod pdf_print_tests;
#[cfg(test)]
mod pen_end_tests;
@@ -511,6 +525,8 @@ mod sketch_import_tests;
#[cfg(test)]
mod slot_tests;
#[cfg(test)]
mod stored_ids_tests;
#[cfg(test)]
mod stroke_style_tests;
#[cfg(test)]
mod style_tests;
@@ -555,6 +571,8 @@ mod variable_tests;
#[cfg(test)]
mod variables_behaviour_tests;
#[cfg(test)]
mod variables_colour_tests;
#[cfg(test)]
mod variables_editor_tests;
#[cfg(test)]
mod variables_import_shapes_tests;
@@ -583,6 +601,8 @@ mod view_behaviour_tests;
#[cfg(test)]
mod vue_codegen_tests;
#[cfg(test)]
mod widget_hover_tests;
#[cfg(test)]
mod widget_input_tests;
#[cfg(test)]
mod widgets_tests;
@@ -264,6 +264,67 @@ fn bind_alias_mode_read_only_and_updates() {
let _ = std::fs::remove_dir_all(&d);
}
/// A library's colour calculations come in reading this file's copies of their operands (the
/// library's ids mean nothing here), keep following the library's updates, and keep the copies they
/// read when the library is removed.
#[test]
fn library_colour_calculations_read_the_copies() {
let d = dir("calc");
let lib_path = path_str(&d.join("theme.figcraft"));
let mut lib = Session::new();
theme(&mut lib);
ex(
&mut lib,
"variable.create",
json!({"collection": "Theme", "name": "hover", "type": "color", "value": {"alpha": {"variable": "base", "opacity": 0.5}}}),
);
let tint = json!({"mix": {"from": {"variable": "primary"}, "to": {"literal": "#ffffff"}, "t": 0.5}});
ex(&mut lib, "variable.create", json!({"collection": "Theme", "name": "tint", "type": "color", "value": tint}));
let lid = publish_to(&mut lib, &lib_path);
let lib_ids: Vec<String> = lib.doc().unwrap().variables.variables.iter().map(|v| v.id.clone()).collect();
let mut s = Session::new();
// Local variables first, so the copies get ids the library's don't share.
ex(&mut s, "variable.createCollection", json!({"name": "App"}));
for name in ["one", "two", "three", "four", "five", "six", "seven"] {
ex(&mut s, "variable.create", json!({"collection": "App", "name": name, "type": "number", "value": 1}));
}
ex(&mut s, "library.add", json!({"path": lib_path}));
let a = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})));
let b = idv(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})));
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "hover", "field": "fill", "ids": [a]}));
ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "tint", "field": "fill", "ids": [b]}));
let shown = |s: &Session, id: NodeId| Some(crate::paint_tests::fill(s, id).to_css_hex());
assert_eq!((shown(&s, a), shown(&s, b)), (Some("#00FF0080".into()), Some("#8080FF".into())));
let (base, primary) = (copy_of(&s, "base").id, copy_of(&s, "primary").id);
assert!(!lib_ids.contains(&base) && !lib_ids.contains(&primary), "copies have ids of their own");
let reads = |name: &str| copy_of(&s, name).values.values().flat_map(|v| v.references().into_iter().cloned()).collect::<Vec<_>>();
assert_eq!(reads("hover"), [base.clone(), base]);
assert_eq!(reads("tint"), [primary.clone(), primary.clone()]);
ex(&mut lib, "variable.set", json!({"variable": "base", "value": "#ff0000"}));
ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#000000"}));
republish(&mut lib);
ex(&mut s, "library.acceptUpdates", json!({}));
assert_eq!((shown(&s, a), shown(&s, b)), (Some("#FF000080".into()), Some("#808080".into())));
// A local calculation is the only reader of `primary` once the layers let go of the library.
ex(
&mut s,
"variable.create",
json!({"collection": "App", "name": "wash", "type": "color", "value": {"mix": {"from": {"variable": primary}, "to": {"literal": "#ffffff"}, "t": 0.5}}}),
);
ex(&mut s, "variable.bind", json!({"ids": [a, b], "field": "fill", "variable": null}));
ex(&mut s, "library.remove", json!({"id": lid}));
let doc = s.doc().unwrap();
assert!(doc.variables.get_by_id(&primary).is_some(), "the copy a local calculation reads stays");
let wash = doc.variables.get("wash").unwrap();
let Some(VariableValue::Color(c)) = doc.variables.resolve(&wash.id, &Default::default()) else { panic!("wash resolves to a colour") };
assert_eq!(c.to_rgba8(), [128, 128, 128, 255]);
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn library_components_keep_their_variable_bindings() {
let d = dir("components");
@@ -428,3 +489,36 @@ fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_ne
healthy(&s);
let _ = std::fs::remove_dir_all(&d);
}
#[test]
fn stored_variable_ids_keep_library_alias_dependencies() {
let mut s = Session::new();
for name in ["Shadow", "Base", "Theme"] {
ex(&mut s, "variable.createCollection", json!({"name": name}));
}
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#FF0000"}));
let surface = ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "color", "value": "#0D99FF"}));
let alias = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
let f = idv(&ex(&mut s, "frame.create", json!({"name": "Chip", "x": 0, "y": 0, "width": 100, "height": 40})));
ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": alias["id"]}));
let chip = idv(&ex(&mut s, "component.create", json!({"ids": [f]})));
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
let doc = s.doc().unwrap();
let selection = Selection { components: vec![chip], ..Default::default() };
let lib = publish(doc, None, &selection, None, 0).unwrap();
let target = surface["id"].as_str().unwrap();
assert!(lib.doc.variables.get_by_id(target).is_some());
assert!(lib.doc.variables.get_by_id(shadow["id"].as_str().unwrap()).is_none());
assert!(lib.doc.variables.collections.iter().any(|c| c.name == "Base"));
// Import from a snapshot that retains both the dependency and its namesake.
let lib = publish(doc, None, &publishable(doc), None, 1).unwrap();
let mut dest = Document::new("consumer");
let key = alias["id"].as_str().unwrap().to_string();
let map = import_variables(&mut dest, &lib, &std::collections::BTreeSet::from([key.clone()]), &Default::default(), false);
assert!(map.variables.contains_key(target));
let copy = dest.variables.get_by_id(&map.variables[&key]).unwrap();
assert!(!copy.values.is_empty());
assert!(copy.values.values().all(|v| *v == VariableValue::Alias(map.variables[target].clone())));
assert!(dest.variables.collections.iter().any(|c| c.name == "Base"));
}
+87
View File
@@ -0,0 +1,87 @@
//! `Interaction::moved_by` (#1571): set while a drag has only moved the selected layers, each by the
//! same offset from the document the gesture started from, so the UI can shift what it worked out
//! from that document; `None` for anything else (a layer in an auto layout flow, a copy made with
//! Alt), and gone with the gesture.
use figcraft_doc::NodeId;
use figcraft_geom::Point;
use serde_json::json;
use crate::Session;
use crate::params::id_of;
use crate::tools::{PointerEvent, PointerKind};
fn rect(s: &mut Session, parent: Option<NodeId>, x: f64, y: f64) -> NodeId {
let mut p = json!({"x": x, "y": y, "width": 40, "height": 40});
if let Some(parent) = parent {
p["parent"] = json!(parent);
}
id_of(&s.execute("shape.rectangle", &p).unwrap()["id"]).unwrap()
}
fn ev(kind: PointerKind, x: f64, y: f64, alt: bool) -> PointerEvent {
PointerEvent { alt, ..PointerEvent::at(kind, x, y, 1.0) }
}
/// Press at `from` and move to `to`, holding the button: what the interaction says of it.
fn moved_by(s: &mut Session, from: (f64, f64), to: (f64, f64), alt: bool) -> Option<(f64, f64)> {
s.pointer(&ev(PointerKind::Down, from.0, from.1, alt)).unwrap();
s.pointer(&ev(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, alt)).unwrap();
s.pointer(&ev(PointerKind::Move, to.0, to.1, alt)).unwrap();
s.ds().unwrap().interaction.as_ref().and_then(|it| it.moved_by)
}
fn release(s: &mut Session, at: (f64, f64)) {
s.pointer(&ev(PointerKind::Up, at.0, at.1, false)).unwrap();
}
fn world(s: &Session, id: NodeId) -> Point {
let t = s.doc().unwrap().world_transform(id).translation();
Point::new(t.x, t.y)
}
#[test]
fn a_plain_move_says_how_far_and_matches_the_document() {
let mut s = Session::new();
let a = rect(&mut s, None, 0.0, 0.0);
let b = rect(&mut s, None, 100.0, 0.0);
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
let (before_a, before_b) = (world(&s, a), world(&s, b));
let d = moved_by(&mut s, (20.0, 20.0), (57.0, 81.0), false).expect("a plain move");
assert_eq!(d, (37.0, 61.0));
for (id, was) in [(a, before_a), (b, before_b)] {
assert_eq!(world(&s, id), Point::new(was.x + d.0, was.y + d.1), "each layer moved by it");
}
release(&mut s, (57.0, 81.0));
assert!(s.ds().unwrap().interaction.is_none(), "gone with the gesture");
}
#[test]
fn a_layer_selected_with_its_frame_moves_with_it_once() {
let mut s = Session::new();
let g = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()["id"]).unwrap();
let inner = rect(&mut s, Some(g), 20.0, 20.0);
s.execute("select.set", &json!({"ids": [g, inner]})).unwrap();
let was = world(&s, inner);
let d = moved_by(&mut s, (25.0, 25.0), (80.0, 90.0), false).expect("the frame moves, and the layer in it with it");
assert_eq!(world(&s, inner), Point::new(was.x + d.0, was.y + d.1));
}
#[test]
fn other_moves_say_nothing() {
// A layer placed by its frame's auto layout.
let mut s = Session::new();
let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap()["id"]).unwrap();
let a = rect(&mut s, Some(f), 10.0, 10.0);
rect(&mut s, Some(f), 60.0, 10.0);
s.execute("layout.add", &json!({"ids": [f]})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
let at = world(&s, a);
assert_eq!(moved_by(&mut s, (at.x + 5.0, at.y + 5.0), (at.x + 9.0, at.y + 200.0), false), None, "in an auto layout flow");
release(&mut s, (at.x + 9.0, at.y + 200.0));
// A copy dragged out with Alt.
let mut s = Session::new();
let a = rect(&mut s, None, 0.0, 0.0);
s.execute("select.set", &json!({"ids": [a]})).unwrap();
assert_eq!(moved_by(&mut s, (20.0, 20.0), (90.0, 20.0), true), None, "a copy");
}
+1 -1
View File
@@ -13,7 +13,7 @@ fn red_rect(s: &mut Session) -> figcraft_doc::NodeId {
r
}
fn fill(s: &Session, id: figcraft_doc::NodeId) -> Color {
pub(crate) fn fill(s: &Session, id: figcraft_doc::NodeId) -> Color {
s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap()
}
@@ -0,0 +1,54 @@
//! Layers pasted from another design app whose image the clipboard doesn't carry (#1646): the
//! paste names them (`missingImages`), and they keep their image's name. Design data made by our
//! own `.fig` export: its canvas alone, as a clipboard carries it, and the whole file with images.
use std::io::Read;
use base64::Engine as _;
use serde_json::{Value, json};
use crate::Session;
use crate::params::id_of;
/// A `.fig` export of a page with "Photo" (an image fill) and "Plain" (a solid fill).
fn exported() -> Vec<u8> {
let mut s = Session::new();
let photo = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap();
let plain = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap();
let mut png = Vec::new();
image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 40, 40, 255]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
let doc = s.doc_mut().unwrap();
let (hash, _, _) = figcraft_format::add_image(doc, png).unwrap();
let n = doc.get_mut(photo).unwrap();
n.name = "Photo".into();
n.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)];
doc.get_mut(plain).unwrap().name = "Plain".into();
let r = s.execute("file.export", &json!({"format": "fig"})).unwrap();
base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap()
}
/// Just the canvas of a `.fig` file: the design data a clipboard carries, without the images.
fn canvas_only(fig: &[u8]) -> Vec<u8> {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(fig)).unwrap();
let mut out = Vec::new();
z.by_name("canvas.fig").unwrap().read_to_end(&mut out).unwrap();
out
}
fn paste(data: &[u8]) -> Value {
let mut s = Session::new();
s.execute("edit.pasteDesignLayers", &json!({"data": base64::engine::general_purpose::STANDARD.encode(data)})).unwrap()
}
#[test]
fn layers_whose_image_the_clipboard_lacks_are_named() {
let fig = exported();
let r = paste(&canvas_only(&fig));
assert_eq!(r["missingImages"], json!(["Photo"]), "{r}");
assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("missing-image placeholder")), "{r}");
// With its images, nothing is missing.
let r = paste(&fig);
assert!(r.get("missingImages").is_none(), "{r}");
}
+96
View File
@@ -0,0 +1,96 @@
//! Paths callers give are read only when they are regular files within a cap that fits their
//! kind (#1114): a folder, a device or an oversized file is refused with a clear error before
//! anything is read, and the session is left as it was.
use std::path::PathBuf;
use serde_json::{Value, json};
use crate::Session;
use crate::file_access::read_capped;
struct Dir(PathBuf);
impl Dir {
fn new(name: &str) -> Self {
let d = std::env::temp_dir().join(format!("figcraft-caps-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("folder")).unwrap();
Self(d)
}
fn p(&self, rel: &str) -> String {
self.0.join(rel).to_string_lossy().into_owned()
}
/// A sparse file of `len` bytes (nothing is written).
fn sized(&self, rel: &str, len: u64) -> String {
std::fs::File::create(self.0.join(rel)).unwrap().set_len(len).unwrap();
self.p(rel)
}
}
impl Drop for Dir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// `cmd` with `params` fails with a message containing `why`, and changes no document.
fn refused(s: &mut Session, cmd: &str, params: Value, why: &str) {
let before = (format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::<Vec<_>>()), s.active);
let e = s.execute(cmd, &params).unwrap_err().to_string();
assert!(e.contains(why), "{cmd} {params}: {e}");
assert_eq!((format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::<Vec<_>>()), s.active), before, "{cmd} {params}");
}
const FOLDER: &str = "is a folder, not a file";
const DEVICE: &str = "isn't a regular file";
#[test]
fn the_capped_read_refuses_folders_devices_and_oversized_files() {
let d = Dir::new("read");
std::fs::write(d.0.join("four.txt"), b"four").unwrap();
assert_eq!(read_capped(d.p("four.txt"), 4).unwrap(), b"four");
let e = read_capped(d.p("four.txt"), 3).unwrap_err().to_string();
assert!(e.contains("is 4 bytes, more than the 3 bytes allowed here"), "{e}");
assert!(read_capped(d.p("folder"), u64::MAX).unwrap_err().to_string().contains(FOLDER));
if cfg!(unix) {
// An endless source: refused, not read until memory runs out.
assert!(read_capped("/dev/zero", u64::MAX).unwrap_err().to_string().contains(DEVICE));
}
}
#[test]
fn documents_are_refused_as_folders_and_devices() {
let d = Dir::new("docs");
let mut s = Session::new();
for cmd in ["file.open", "file.import"] {
refused(&mut s, cmd, json!({"path": d.p("folder")}), FOLDER);
if cfg!(unix) {
refused(&mut s, cmd, json!({"path": "/dev/zero"}), DEVICE);
}
}
const { assert!(crate::cmd::file::MAX_DOCUMENT_FILE > 7_400_000_000, "a real 7.4 GB file still opens") };
}
#[test]
fn placed_images_are_capped_at_what_a_document_takes() {
let d = Dir::new("images");
let mut s = Session::new();
let big = d.sized("huge.png", figcraft_doc::ImageData::MAX_BYTES + 1);
refused(&mut s, "image.place", json!({"path": big}), "more than the 256 MB allowed here");
refused(&mut s, "image.place", json!({"path": d.p("folder")}), FOLDER);
if cfg!(unix) {
refused(&mut s, "image.place", json!({"path": "/dev/zero"}), DEVICE);
}
}
#[test]
fn fonts_and_plugins_are_refused_as_folders() {
let d = Dir::new("plugins");
let mut s = Session::new();
refused(&mut s, "text.loadFont", json!({"path": d.p("folder")}), FOLDER);
// A plugin folder whose manifest is a folder.
std::fs::create_dir_all(d.0.join("plugin").join("figcraft-plugin.json")).unwrap();
refused(&mut s, "plugin.import", json!({"path": d.p("plugin")}), FOLDER);
assert!(s.prefs.plugins.is_empty());
}
+14 -4
View File
@@ -42,10 +42,11 @@ fn fit(kind: VariableType, v: VariableValue) -> Option<VariableValue> {
}
}
/// Set `variable` to `value`, or to `expr` evaluated in the modes of `node` (the layer whose
/// interaction runs), in every mode, and re-apply bindings. False when nothing could be set.
/// Set stored variable id `variable` to `value`, or to `expr` evaluated in the modes of `node`
/// (the layer whose interaction runs), in every mode, and re-apply bindings. False when nothing
/// could be set.
pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Value, expr: Option<&Expr>) -> bool {
let Some(var) = doc.variables.get(variable) else { return false };
let Some(var) = doc.variables.get_by_id(variable) else { return false };
let (id, kind) = (var.id.clone(), var.kind);
let v = match expr {
Some(e) => e.eval(&doc.variables, &doc.modes_for(node)).and_then(|v| fit(kind, v)),
@@ -66,7 +67,16 @@ pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Va
/// win), then re-apply bindings. False for an unknown collection or mode.
pub fn set_mode(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool {
let Some(c) = doc.variables.collection(collection) else { return false };
let Some(mode) = c.find_mode(mode).map(|m| m.id.clone()) else {
let Some(m) = c.find_mode(mode) else { return false };
let (collection, mode) = (c.id.clone(), m.id.clone());
set_mode_by_id(doc, frame, &collection, &mode)
}
/// Switch a stored collection id to a stored mode id (a prototype action's), without matching
/// another collection's or mode's name (#1613). Otherwise behaves like [`set_mode`].
pub fn set_mode_by_id(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool {
let Some(c) = doc.variables.collection_by_id(collection) else { return false };
let Some(mode) = c.mode_by_id(mode).map(|m| m.id.clone()) else {
return false;
};
let cid = c.id.clone();
+142
View File
@@ -207,3 +207,145 @@ fn junk_conditional_actions_never_panic() {
}
}
}
#[test]
fn stored_variable_ids_set_only_the_prototype_action_target() {
let (mut s, frame, _, _, _) = setup();
let shadow = var_id(s.doc().unwrap(), "count");
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap();
let target = target["id"].as_str().unwrap().to_string();
s.execute("variable.update", &json!({"variable": shadow, "name": target})).unwrap();
let mut doc = s.doc().unwrap().clone();
assert!(set_variable(&mut doc, frame, &target, &json!(9), None));
assert_eq!(doc.variables.resolve(&target, &Default::default()), Some(VariableValue::Number(9.0)));
assert_eq!(doc.variables.resolve(&shadow, &Default::default()), Some(VariableValue::Number(0.0)));
doc.variables.variables.retain(|v| v.id != target);
let before = doc.clone();
assert!(!set_variable(&mut doc, frame, &target, &json!(11), None));
assert_eq!(doc, before);
}
#[test]
fn stored_variable_ids_survive_unrelated_interaction_edits() {
let (mut s, frame, _, _, _) = setup();
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
s.execute(
"proto.interaction.add",
&json!({"id": frame, "actions": [
{"type": "back"},
{"type": "setVariable", "variable": "target", "expression": "target + 1"},
{"type": "setVariableMode", "collection": "Theme", "mode": "Dark"}
]}),
)
.unwrap();
let theme = s.doc().unwrap().variables.collections[0].id.clone();
s.execute("variable.createCollection", &json!({"name": "shadow", "modes": ["Light", "Dark"]})).unwrap();
s.execute("variable.renameCollection", &json!({"collection": "shadow", "name": theme})).unwrap();
// Put the namesake before the referenced collection, as variable lookup already does.
s.doc_mut().unwrap().variables.collections.swap(0, 1);
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
let actions = s.doc().unwrap().node(frame).unwrap().interactions[0].actions.clone();
for patch in [json!({"durationMs": 450}), json!({"action": {"type": "closeOverlay"}})] {
let mut p = json!({"id": frame, "index": 0});
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
s.execute("proto.interaction.set", &p).unwrap();
let it = &s.doc().unwrap().node(frame).unwrap().interactions[0];
assert_eq!(it.duration_ms, 450);
assert_eq!(it.actions[1..], actions[1..], "only the supplied fields change");
}
}
#[test]
fn stored_variable_ids_from_text_are_not_resolved_twice() {
let (mut s, frame, _, _, _) = setup();
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap();
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
let action = json!({"type": "setVariable", "variable": "target", "expression": "target + 1"});
s.execute("proto.interaction.add", &json!({"id": frame, "action": action})).unwrap();
for (cmd, patch) in [
("proto.interaction.add", json!({"action": action})),
("proto.interaction.set", json!({"action": action})),
("proto.interaction.set", json!({"interaction": {"actions": [action]}})),
("proto.action.add", json!({"path": [0], "action": action})),
("proto.action.set", json!({"path": [0], "action": action})),
] {
let mut p = json!({"id": frame});
if cmd != "proto.interaction.add" {
p["index"] = json!(0);
}
p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone());
let r = s.execute(cmd, &p).unwrap();
let index = if cmd == "proto.interaction.add" { r["index"].as_u64().unwrap() as usize } else { 0 };
let Action::SetVariable { variable, expr: Some(expr), .. } = &s.doc().unwrap().node(frame).unwrap().interactions[index].actions[0] else {
panic!("{cmd}: no set variable expression")
};
assert_eq!(variable, &target, "{cmd}");
assert_eq!(expr.eval(&s.doc().unwrap().variables, &Default::default()), Some(VariableValue::Number(6.0)), "{cmd}");
}
}
#[test]
fn stored_variable_ids_resolve_only_incoming_action_and_expression_names() {
let (mut s, frame, _, _, _) = setup();
let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"]
.as_str()
.unwrap()
.to_string();
s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap();
s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap();
let value = json!({"type": "setVariable", "variable": "target"});
s.execute(
"proto.interaction.add",
&json!({"id": frame, "actions": [
{"type": "setVariable", "variable": "target", "value": null, "expr": Expr::var(&target)},
{"type": "conditional", "blocks": [{"expression": "target == 5", "actions": [
{"type": "setVariable", "variable": "label", "value": value}
]}]}
]}),
)
.unwrap();
let doc = s.doc().unwrap();
let actions = &doc.node(frame).unwrap().interactions[0].actions;
let Action::SetVariable { variable, expr: Some(expr), .. } = &actions[0] else { panic!("no set variable") };
assert_eq!(variable, &target);
// A JSON expression is command input: it accepts names, but an exact id comes first, so the
// variable renamed to target's id doesn't take its place (#1613).
assert_eq!(expr.eval(&doc.variables, &Default::default()), Some(VariableValue::Number(5.0)));
let Action::Conditional { blocks } = &actions[1] else { panic!("no conditional") };
assert_eq!(blocks[0].condition.as_ref().unwrap().eval(&doc.variables, &Default::default()), Some(VariableValue::Boolean(true)));
let Action::SetVariable { value: stored, .. } = &blocks[0].actions[0] else { panic!("no nested set variable") };
assert_eq!(stored, &value, "a literal value is not an action to canonicalize");
}
#[test]
fn stored_variable_ids_switch_only_the_prototype_collection() {
let (s, frame, _, _, r) = setup();
let mut doc = s.doc().unwrap().clone();
let theme = doc.variables.collections[0].clone();
let mut shadow = theme.clone();
shadow.id = "shadow".into();
shadow.name = theme.id.clone();
doc.variables.collections.insert(0, shadow);
let dark = theme.find_mode("Dark").unwrap().id.clone();
assert!(!crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark"), "a stored mode is an id, not a name");
assert!(crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, &dark));
assert_eq!(fill_hex(&doc, r).as_deref(), Some("000000"));
let page = doc.page_of(frame).unwrap();
assert!(!doc.node(page).unwrap().var_modes.contains_key("shadow"));
// The name-accepting wrapper takes an exact id before a name (#1613).
assert!(set_mode(&mut doc, frame, &theme.id, "Light"));
assert!(!doc.node(page).unwrap().var_modes.contains_key("shadow"));
assert!(set_mode(&mut doc, frame, "shadow", "Light"), "the other collection by its id");
assert!(doc.node(page).unwrap().var_modes.contains_key("shadow"));
doc.variables.collections.retain(|c| c.id != theme.id);
let before = doc.clone();
assert!(!crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, &dark));
assert_eq!(doc, before);
}
+12 -3
View File
@@ -57,7 +57,7 @@ impl Session {
return Err(EngineError::Disabled(label.to_string(), PREVIEW_READ_ONLY.into()));
}
if ds.interaction.is_none() {
ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone() });
ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone(), moved_by: None });
}
Ok(())
}
@@ -251,7 +251,8 @@ impl Session {
}
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
/// own, and when it fails the documents stay at their group-start state.
/// own, and when it fails or panics the documents stay at their group-start state (a panic
/// then carries on unwinding).
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
self.ds()?;
self.checkpointed(Some(label), f)
@@ -261,7 +262,12 @@ impl Session {
/// A checkpoint also works between closing the last tab and opening another one.
fn checkpointed<T>(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
let before = self.group_checkpoint(label.is_some());
let result = f(self);
// A panic in `f` ends the group like a failure, then goes on to the caller's guard (#1085);
// the stand-in error only drives the rollback and is never returned.
let (result, panic) = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) {
Ok(result) => (result, None),
Err(payload) => (Err(EngineError::Other("the group panicked".into())), Some(payload)),
};
self.group_depth -= 1;
let at_root = self.group_depth == 0;
for ds in &before.docs {
@@ -297,6 +303,9 @@ impl Session {
self.closed_in_group.clear();
self.flush_pending_events();
}
if let Some(payload) = panic {
std::panic::resume_unwind(payload);
}
result
}
+58
View File
@@ -0,0 +1,58 @@
//! A variable renamed to another one's id never takes its place (#1613), beside #1618's tests:
//! an id a person types (`variable.bind`, `variable.set`) finds the variable with that exact id
//! before one named like it, and the Tailwind export and expressions read the stored id. Names still
//! work where a person types them.
use figcraft_doc::VariableValue;
use figcraft_doc::proto::Expr;
use serde_json::{Value, json};
use crate::Session;
use crate::components::tests::ex;
/// The issue's document: Theme with `shadow`, `surface` and `alias` (→ surface), then `shadow`
/// renamed to surface's id with Web code syntax `--shadow`. Ids of shadow, surface and alias.
fn renamed_like_an_id() -> (Session, String, String, String) {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let id = |v: Value| v["id"].as_str().unwrap().to_string();
let shadow = id(ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})));
let surface = id(ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})));
let alias = id(ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": "surface"}})));
ex(&mut s, "variable.update", json!({"variable": shadow, "name": surface, "codeSyntax": {"WEB": "--shadow"}}));
(s, shadow, surface, alias)
}
#[test]
fn exported_aliases_point_at_the_stored_id() {
let (mut s, ..) = renamed_like_an_id();
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap().to_string();
assert!(css.contains("--alias: var(--surface);"), "{css}");
let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap().to_string();
assert!(tw.contains("var(--color-surface)") && !tw.contains("var(--shadow)"), "{tw}");
}
#[test]
fn dev_inspect_names_the_bound_variable() {
let (mut s, _, surface, _) = renamed_like_an_id();
let r = ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}));
let r = crate::params::id_of(&r["id"]).unwrap();
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": surface}));
let doc = s.doc().unwrap();
let found = crate::devinspect::bound_variable(doc, doc.node(r).unwrap(), "fill").unwrap();
assert_eq!(found.name, "surface");
}
#[test]
fn expressions_read_and_show_the_stored_variable() {
let (mut s, _, surface, _) = renamed_like_an_id();
ex(&mut s, "variable.addMode", json!({"collection": "Theme", "name": "Dark"}));
ex(&mut s, "variable.set", json!({"variable": surface, "mode": "Dark", "value": "#00FF00"}));
let vars = &s.doc().unwrap().variables;
let theme = vars.collection("Theme").unwrap().clone();
let dark = theme.find_mode("Dark").unwrap().id.clone();
let e = Expr::InMode { variable: surface.clone(), mode: dark };
assert!(matches!(e.eval(vars, &Default::default()), Some(VariableValue::Color(c)) if c.to_hex() == "00FF00"));
assert_eq!(e.display(vars), "surface[Dark]");
assert_eq!(Expr::var(surface).display(vars), "surface");
}
+6
View File
@@ -459,6 +459,12 @@ fn every_command_survives_junk_params() {
// would write to it.)
json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}),
json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}),
// Compact inspection: fields and outline with malformed types and unknown keys.
json!({"id": 2, "fields": "name", "outline": true}),
json!({"id": 2, "fields": ["name", 5]}),
json!({"id": 2, "fields": ["Name", "noSuchField"]}),
json!({"id": 2, "fields": null, "outline": []}),
json!({"id": 2, "outline": "yes"}),
// Layer search roots, filters and limits as junk.
json!({"root": {"id": 2}, "name": [1], "type": "nope", "text": true, "component": [], "limit": -1}),
json!({"root": 999999, "limit": 1e308}),
+22 -1
View File
@@ -15,7 +15,7 @@ fn id(v: Value) -> NodeId {
id_of(&v["id"]).unwrap()
}
fn vid(v: Value) -> String {
pub(crate) fn vid(v: Value) -> String {
v["id"].as_str().unwrap().to_string()
}
@@ -498,3 +498,24 @@ fn a_translucent_variable_bound_to_an_empty_fill_applies_its_alpha_once() {
assert_eq!(node(&s, r).fills[0].solid_color().unwrap().to_css_hex(), "#00FF0040");
assert_eq!(node(&s, r).fills[0].opacity, 1.0);
}
#[test]
fn stored_variable_ids_cannot_hide_an_alias_cycle() {
for link in ["a", "b"] {
let mut s = Session::new();
run(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let mut ids = std::collections::BTreeMap::new();
for name in ["shadow", "a", "b"] {
let v = run(&mut s, "variable.create", json!({"collection": "Theme", "name": name, "type": "number", "value": 1}));
ids.insert(name, v["id"].clone());
}
run(&mut s, "variable.set", json!({"variable": "b", "value": {"alias": "a"}}));
run(&mut s, "variable.update", json!({"variable": ids["shadow"], "name": ids[link]}));
let before = s.doc().unwrap().clone();
let undo = s.ds().unwrap().history.undo.len();
let error = s.execute("variable.set", &json!({"variable": "a", "value": {"alias": "b"}})).unwrap_err();
assert!(error.to_string().contains("refer back to itself"), "{link}: {error}");
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.ds().unwrap().history.undo.len(), undo);
}
}
+510
View File
@@ -0,0 +1,510 @@
//! Colour calculations: `{alpha: …}` (a referenced colour with its alpha replaced) and `{mix: …}`
//! (a blend of two colour operands) as per-mode variable values — set and create shapes, lazy
//! propagation to bound layers and mode switches, composition over aliases and other
//! calculations, set-time refusal of cycles and bad operands (leaving the variable untouched),
//! deletion flattening, undo/redo exactness, save/load round trip, exports and junk input.
use serde_json::{Value, json};
use crate::Session;
use crate::components::tests::{ex, idv};
use crate::error::EngineError;
use crate::paint_tests::fill;
use crate::plugins_tests::rect;
use crate::variables_behaviour_tests::vid;
use crate::widgets_tests::err;
use figcraft_color::Color;
/// A "C" collection (modes M) with `ink` #FF0000 and `edge` #00FF00 (the error-case set's seeds).
fn seeds() -> Session {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "C", "modes": ["M"]}));
ex(&mut s, "variable.create", json!({"collection": "C", "name": "ink", "type": "color", "value": "#FF0000"}));
ex(&mut s, "variable.create", json!({"collection": "C", "name": "edge", "type": "color", "value": "#00FF00"}));
ex(&mut s, "variable.create", json!({"collection": "C", "name": "size", "type": "number", "value": 5}));
s
}
#[test]
fn create_accepts_alpha_and_mix_and_they_propagate_to_bound_layers() {
let mut s = seeds();
let hover = vid(ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "hover", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
));
let box_id = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [box_id], "field": "fill", "variable": "hover"}));
assert_eq!(fill(&s, box_id).to_rgba8(), [255, 0, 0, 128], "alpha replaces the source's alpha");
ex(&mut s, "variable.set", json!({"variable": "ink", "value": "#00AA00"}));
assert_eq!(fill(&s, box_id).to_rgba8(), [0, 170, 0, 128], "the bound layer follows the source, no re-set needed");
// A mix of two variables, one literal, and the t edges.
ex(&mut s, "variable.set", json!({"variable": hover, "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 0.0}}}));
assert_eq!(fill(&s, box_id).to_rgba8(), [0, 170, 0, 255], "t=0 gives from exactly");
ex(
&mut s,
"variable.set",
json!({"variable": hover, "value": {"mix": {"from": {"literal": "#000000"}, "to": {"literal": "#FFFFFF"}, "t": 0.4}}}),
);
assert_eq!(fill(&s, box_id).to_rgba8(), [102, 102, 102, 255], "literal operands blend (0.4 -> 0x66)");
// Compose: a mix reading the calculation, and alpha over an alias.
ex(&mut s, "variable.create", json!({"collection": "C", "name": "mirror", "type": "color", "value": {"alias": "ink"}}));
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "faded-mirror", "type": "color", "value": {"alpha": {"variable": "mirror", "opacity": 0.5}}}),
);
let b2 = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b2], "field": "fill", "variable": "faded-mirror"}));
assert_eq!(fill(&s, b2).to_rgba8(), [0, 170, 0, 128], "alpha over an alias reads the aliased value");
let toast = vid(ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "toast", "type": "color", "value": {"alpha": {"variable": "hover", "opacity": 0.25}}}),
));
let b3 = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b3], "field": "fill", "variable": toast}));
assert_eq!(fill(&s, b3).to_rgba8(), [102, 102, 102, 64], "a calculation may read a calculation");
}
#[test]
fn per_mode_operands_follow_each_collection_chosen_mode() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Core", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.createCollection", json!({"name": "Extra", "modes": ["Light", "Dark"]}));
ex(&mut s, "variable.create", json!({"collection": "Core", "name": "p", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}}));
ex(&mut s, "variable.create", json!({"collection": "Extra", "name": "s", "type": "color", "values": {"Light": "#000000", "Dark": "#FFFFFF"}}));
ex(
&mut s,
"variable.create",
json!({"collection": "Core", "name": "panel", "type": "color",
"values": {"Light": {"mix": {"from": {"variable": "p"}, "to": {"variable": "s"}, "t": 0.5}},
"Dark": {"mix": {"from": {"variable": "p"}, "to": {"variable": "s"}, "t": 0.5}}}}),
);
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "panel"}));
assert_eq!(fill(&s, b).to_rgba8(), [128, 0, 0, 255], "Light mixes Light values of BOTH collections");
ex(&mut s, "variable.setMode", json!({"ids": [b], "collection": "Core", "mode": "Dark"}));
assert_eq!(fill(&s, b).to_rgba8(), [0, 0, 128, 255], "a Core switch moves only the Core operand (Extra stays Light)");
ex(&mut s, "variable.setMode", json!({"ids": [b], "collection": "Extra", "mode": "Dark"}));
assert_eq!(fill(&s, b).to_rgba8(), [128, 128, 255, 255], "the Extra switch then moves its operand, independently");
}
#[test]
fn bad_shapes_and_operands_are_refused_without_touching_the_variable() {
let mut s = seeds();
let before = json!(s.doc().unwrap().variables);
let want_t = "`t` must be a number from 0 to 1";
let want_opacity = "`opacity` must be a number from 0 to 1";
let want_operand = "mix operands are {variable: …} or {literal: colour}";
let cases: Vec<(&str, Value, &str)> = vec![
(
"self alpha",
json!({"variable": "ink", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
"the colour calculation would refer back to itself",
),
(
"unknown operand",
json!({"variable": "ink", "value": {"alpha": {"variable": "ghost", "opacity": 0.5}}}),
"no variable `ghost` to calculate from",
),
(
"number operand",
json!({"variable": "ink", "value": {"alpha": {"variable": "size", "opacity": 0.5}}}),
"colour calculations take colour variables: `size` is a number one",
),
(
"number mix operand",
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "size"}, "to": {"variable": "edge"}, "t": 0.5}}}),
"colour calculations take colour variables: `size` is a number one",
),
(
"calculation on a number variable",
json!({"variable": "size", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
"colour calculations only fit colour variables",
),
("t too big", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 1.5}}}), want_t),
("t missing", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}}}}), want_t),
(
"t as text",
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": "half"}}}),
want_t,
),
("opacity above 1", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opacity": 2}}}), want_opacity),
("opacity missing", json!({"variable": "ink", "value": {"alpha": {"variable": "edge"}}}), want_opacity),
("alpha on a literal", json!({"variable": "ink", "value": {"alpha": {"literal": "#FF0000", "opacity": 0.5}}}), "alpha: {variable, opacity}"),
("mix with a bare string", json!({"variable": "ink", "value": {"mix": {"from": "ink", "to": {"variable": "edge"}, "t": 0.5}}}), want_operand),
(
"mix operand with two keys",
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "ink", "literal": "#000000"}, "to": {"variable": "edge"}, "t": 0.5}}}),
want_operand,
),
("unknown key in alpha", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opactiy": 0.5}}}), "alpha: {variable, opacity}"),
// A bad parameter is named even beside an operand that loops: it is checked first.
(
"bad t beside a loop",
json!({"variable": "ink", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 2}}}),
want_t,
),
("bad opacity beside a loop", json!({"variable": "ink", "value": {"alpha": {"variable": "ink", "opacity": -1}}}), want_opacity),
];
for (label, p, want) in cases {
let msg = err(&mut s, "variable.set", p.clone());
assert!(msg.contains(want), "{label}: {msg}");
}
// A second key beside the operation is refused, not dropped.
let alpha = json!({"variable": "edge", "opacity": 0.5});
let mix = json!({"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 0.5});
for (label, value) in [
("alpha and mix", json!({"alpha": alpha.clone(), "mix": mix.clone()})),
("alias beside alpha", json!({"alias": "edge", "alpha": alpha})),
("unknown key beside mix", json!({"mix": mix, "space": "oklch"})),
] {
let msg = err(&mut s, "variable.set", json!({"variable": "ink", "value": value}));
assert!(msg.contains("on its own"), "{label}: {msg}");
let msg = err(&mut s, "variable.create", json!({"collection": "C", "name": "extra", "type": "color", "value": value}));
assert!(msg.contains("on its own"), "{label} on create: {msg}");
}
assert_eq!(json!(s.doc().unwrap().variables), before, "the refusals left every variable as it was");
// A cycle reached through binary operands too: ink mixes edge; then edge must not read ink.
ex(&mut s, "variable.set", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}}}));
let msg = err(&mut s, "variable.set", json!({"variable": "edge", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}));
assert!(msg.contains("the colour calculation would refer back to itself"), "cycle named as such: {msg}");
let msg = err(
&mut s,
"variable.set",
json!({"variable": "edge", "value": {"mix": {"from": {"variable": "ink"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}}}),
);
assert!(msg.contains("the colour calculation would refer back to itself"), "mix-cycle named as such: {msg}");
// None of the refusals touched the variables: ink still holds its last accepted value, and a
// fresh calculation on it works.
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "ink"}));
assert_eq!(fill(&s, b).to_rgba8(), [128, 255, 128, 255], "ink keeps the accepted mix (edge + white at 0.5)");
ex(&mut s, "variable.set", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opacity": 0.5}}}));
assert_eq!(fill(&s, b).to_rgba8(), [0, 255, 0, 128], "and calculations still set afterwards");
}
#[test]
fn undo_and_redo_are_exact_through_calculations() {
let mut s = seeds();
let hover = vid(ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "hover", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
));
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": hover}));
let first = fill(&s, b).to_rgba8();
ex(&mut s, "variable.set", json!({"variable": "hover", "value": {"alpha": {"variable": "edge", "opacity": 0.25}}}));
let second = fill(&s, b).to_rgba8();
assert_ne!(first, second);
ex(&mut s, "edit.undo", json!({}));
assert_eq!(fill(&s, b).to_rgba8(), first, "undo restores the stored alpha calculation exactly");
ex(&mut s, "edit.redo", json!({}));
assert_eq!(fill(&s, b).to_rgba8(), second, "redo replays the new one exactly");
}
#[test]
fn deleting_a_source_flattens_its_calculations() {
let mut s = seeds();
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "fade", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
);
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "fade"}));
let before = json!(s.doc().unwrap().variables);
ex(&mut s, "variable.delete", json!({"variable": "ink"}));
assert_eq!(fill(&s, b).to_rgba8(), [255, 0, 0, 128], "the bound layer keeps the colour it showed");
let fade: Vec<_> = s.doc().unwrap().variables.get("fade").unwrap().values.values().cloned().collect();
assert_eq!(fade, [figcraft_doc::VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.5))], "the calculation became the colour it showed");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(json!(s.doc().unwrap().variables), before, "undo restores every variable exactly");
}
/// Deleting a collection at once flattens every reader outside it — an alias of a calculation, a
/// calculation of two deleted variables — and deleting a calculation flattens a calculation that
/// reads it; undo restores the variables exactly each time.
#[test]
fn deleting_sources_together_flattens_every_reader() {
let mut s = seeds();
let calc = |name: &str, value: Value| json!({"collection": "C", "name": name, "type": "color", "value": value});
ex(&mut s, "variable.create", calc("fade", json!({"alpha": {"variable": "ink", "opacity": 0.5}})));
ex(&mut s, "variable.create", calc("wash", json!({"mix": {"from": {"variable": "fade"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}})));
ex(&mut s, "variable.createCollection", json!({"name": "App"}));
ex(&mut s, "variable.create", json!({"collection": "App", "name": "echo", "type": "color", "value": {"alias": "wash"}}));
let tint = json!({"mix": {"from": {"variable": "edge"}, "to": {"variable": "ink"}, "t": 0.5}});
ex(&mut s, "variable.create", json!({"collection": "App", "name": "tint", "type": "color", "value": tint}));
let stored = |s: &Session, name: &str| s.doc().unwrap().variables.get(name).unwrap().values.values().next().cloned().unwrap();
let plain = |s: &Session, name: &str| match stored(s, name) {
figcraft_doc::VariableValue::Color(c) => c.to_rgba8(),
other => panic!("{name} is not a plain colour: {other:?}"),
};
let before = json!(s.doc().unwrap().variables);
ex(&mut s, "variable.deleteCollection", json!({"collection": "C"}));
assert_eq!(plain(&s, "echo"), [255, 128, 128, 191], "the alias of a calculation keeps what it showed");
assert_eq!(plain(&s, "tint"), [128, 128, 0, 255], "a calculation of two deleted variables keeps its colour");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(json!(s.doc().unwrap().variables), before, "undo restores the collection and its readers exactly");
ex(&mut s, "variable.delete", json!({"variable": "fade"}));
assert_eq!(plain(&s, "wash"), [255, 128, 128, 191], "a calculation of the deleted calculation keeps its colour");
assert!(matches!(stored(&s, "echo"), figcraft_doc::VariableValue::Alias(_)), "an alias of a surviving variable stays an alias");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(json!(s.doc().unwrap().variables), before);
}
/// One resolve reads at most `Variables::RESOLVE_LIMIT` variables: a mix of two alias chains that
/// would read more is refused when it is set rather than left showing nothing.
#[test]
fn a_calculation_reading_too_many_variables_is_refused() {
let mut s = seeds();
for side in ["a", "b"] {
ex(&mut s, "variable.create", json!({"collection": "C", "name": format!("{side}0"), "type": "color", "value": {"alias": "ink"}}));
for i in 1..16 {
let value = json!({"alias": format!("{side}{}", i - 1)});
ex(&mut s, "variable.create", json!({"collection": "C", "name": format!("{side}{i}"), "type": "color", "value": value}));
}
}
// Reading `a{k}` and `b{k}` takes 2k + 4 variables: itself, both chains and the `ink` they share.
let mix = |k: usize| json!({"mix": {"from": {"variable": format!("a{k}")}, "to": {"variable": format!("b{k}")}, "t": 0.5}});
ex(&mut s, "variable.create", json!({"collection": "C", "name": "fits", "type": "color", "value": mix(14)}));
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "fits"}));
assert_eq!(fill(&s, b).to_rgba8(), [255, 0, 0, 255], "32 variables still resolve");
let too_many = "the colour calculation reads more than 32 variables";
let msg = err(&mut s, "variable.create", json!({"collection": "C", "name": "over", "type": "color", "value": mix(15)}));
assert!(msg.contains(too_many), "create: {msg}");
let msg = err(&mut s, "variable.set", json!({"variable": "fits", "value": mix(15)}));
assert!(msg.contains(too_many), "set: {msg}");
}
/// Junk anywhere inside a calculation is refused cleanly: the variables exist, so it reaches the
/// calculation parsers (the command-wide sweep starts from a document without variables).
#[test]
fn calculation_parsers_survive_junk() {
let junk = [
json!(null),
json!(1e308),
json!(-0.0),
json!("NaN"),
json!("\u{0}"),
json!([]),
json!({}),
json!([1e308, -1e308, "x"]),
json!({"variable": [1]}),
json!({"variable": "ink", "literal": "#000"}),
json!({"literal": {"r": 1e308, "g": "x"}}),
json!({"mix": {"from": {"literal": {"mix": 1}}}}),
json!({"alpha": {"variable": "ink", "opacity": 0.5}}),
];
for j in &junk {
for value in [
json!({"alpha": j}),
json!({"mix": j}),
json!({"mix": {"from": j, "to": j, "t": j}}),
json!({"alpha": {"variable": j, "opacity": j}}),
json!({"alpha": {"variable": "ink", "opacity": j}}),
json!({"mix": {"from": {"variable": "ink"}, "to": {"literal": j}, "t": 0.5}}),
] {
for (cmd, p) in [
("variable.set", json!({"variable": "edge", "value": value})),
("variable.set", json!({"variable": "size", "value": value})),
("variable.create", json!({"collection": "C", "name": "junk", "type": "color", "values": {"M": value}})),
] {
let mut s = seeds();
if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, &p) {
panic!("{cmd} panicked on {p}: {msg}");
}
}
}
}
}
#[test]
fn calculations_survive_save_and_load() {
let mut s = seeds();
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "mixv", "type": "color", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 0.4}}}),
);
let dir = std::env::temp_dir().join(format!("figcraft-colour-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("d.figcraft");
ex(&mut s, "file.save", json!({"path": path.to_string_lossy()}));
let mut t = Session::new();
ex(&mut t, "file.open", json!({"path": path.to_string_lossy()}));
let _ = std::fs::remove_dir_all(&dir);
let list = ex(&mut t, "variable.list", json!({}));
let mixv = list["variables"].as_array().unwrap().iter().find(|v| v["name"] == "mixv").unwrap();
assert_eq!(mixv["values"].as_object().unwrap().values().next().unwrap()["type"], "colorMix", "still a calculation after a round trip");
// And it still propagates in the reloaded document.
let b = idv(&ex(&mut t, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4})));
ex(&mut t, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "mixv"}));
assert_eq!(fill(&t, b).to_rgba8(), [153, 102, 0, 255], "ink + edge at 0.4: r=255*(1-0.4), g=255*0.4");
}
#[test]
fn rest_and_css_exports_write_calculations_as_their_colours() {
let mut s = seeds();
// The exported REST variable `name` of `collections`, and the CSS text for them.
let json_variable = |s: &mut Session, collections: Value, name: &str| -> Value {
let text = ex(s, "variable.export", json!({"format": "json", "collections": collections}));
let doc: Value = serde_json::from_str(text["text"].as_str().unwrap()).unwrap();
let meta = doc.get("meta").unwrap_or(&doc);
meta["variables"].as_object().unwrap().values().find(|v| v["name"] == name).unwrap().clone()
};
let css = |s: &mut Session, collections: Value| {
ex(s, "variable.export", json!({"format": "css", "collections": collections}))["text"].as_str().unwrap().to_string()
};
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "fade", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}),
);
let fade = json_variable(&mut s, json!(["C"]), "fade");
let value = fade["valuesByMode"].as_object().unwrap().values().next().unwrap();
assert_eq!(value["r"], 1.0);
assert_eq!(value["a"], 0.5);
assert!(value.get("type").is_none(), "no calculation shape leaks into the REST JSON: {value}");
let text = css(&mut s, json!(["C"]));
assert!(text.contains("rgba(255, 0, 0, 0.5"), "the CSS export writes the colour it resolves to: {text}");
// Each mode is written as the colour it shows in that mode.
ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
ex(
&mut s,
"variable.create",
json!({"collection": "Theme", "name": "brand", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}}),
);
let veil = json!({"alpha": {"variable": "brand", "opacity": 0.5}});
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "veil", "type": "color", "value": veil}));
let veil = json_variable(&mut s, json!(["Theme"]), "veil");
let mut per_mode: Vec<(f64, f64, f64)> = veil["valuesByMode"]
.as_object()
.unwrap()
.values()
.map(|v| (v["r"].as_f64().unwrap(), v["b"].as_f64().unwrap(), v["a"].as_f64().unwrap()))
.collect();
per_mode.sort_by(|x, y| x.partial_cmp(y).unwrap());
assert_eq!(per_mode, [(0.0, 1.0, 0.5), (1.0, 0.0, 0.5)], "Light and Dark each written as their own colour");
let text = css(&mut s, json!(["Theme"]));
assert!(text.contains("rgba(255, 0, 0, 0.5") && text.contains("rgba(0, 0, 255, 0.5"), "both modes in CSS: {text}");
}
/// A hand-edited file can hold a calculation that resolves to nothing (one reading a variable
/// that isn't there). The CSS export leaves it out, so no property may name it: an alias to it
/// goes out as the colour it shows, or is left out when that is nothing.
#[test]
fn css_never_names_a_calculation_it_leaves_out() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "T", "modes": ["Light", "Dark"]}));
let ink = json!({"collection": "T", "name": "ink", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}});
ex(&mut s, "variable.create", ink);
for name in ["lost", "half"] {
ex(&mut s, "variable.create", json!({"collection": "T", "name": name, "type": "color", "value": "#000000"}));
}
for (name, to) in [("lost-too", "lost"), ("half-too", "half"), ("ink-too", "ink")] {
ex(&mut s, "variable.create", json!({"collection": "T", "name": name, "type": "color", "value": {"alias": to}}));
}
// As a hand-edited file could hold them: `lost` reads a variable that isn't there in both
// modes, `half` only in Dark.
let doc = s.doc_mut().unwrap();
let ink = doc.variables.get("ink").unwrap().id.clone();
let modes: Vec<String> = doc.variables.collection("T").unwrap().modes.iter().map(|m| m.id.clone()).collect();
let gone = figcraft_doc::VariableValue::ColorAlpha { variable: "gone".into(), opacity: 0.5 };
let half = figcraft_doc::VariableValue::ColorAlpha { variable: ink, opacity: 0.5 };
for v in &mut doc.variables.variables {
match v.name.as_str() {
"lost" => v.values = modes.iter().map(|m| (m.clone(), gone.clone())).collect(),
"half" => v.values = [(modes[0].clone(), half.clone()), (modes[1].clone(), gone.clone())].into_iter().collect(),
_ => {}
}
}
let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap().to_string();
let (light, dark) = css.split_once("[data-").unwrap();
assert!(!css.contains("var(--lost)") && !css.contains("var(--half)"), "no var() of a property a block leaves out: {css}");
assert!(!css.contains("--lost-too"), "an alias to what shows nothing is left out: {css}");
assert!(light.contains("--half-too: rgba(255, 0, 0, 0.5") && !dark.contains("--half-too"), "the colour it shows, where it shows one: {css}");
assert!(css.contains("--ink-too: var(--ink);"), "an alias that always resolves stays a reference: {css}");
}
/// A calculation is a variable's value, not a literal an expression may hold: nothing would check
/// what it reads. Every way an expression arrives refuses one.
#[test]
fn prototype_expressions_refuse_a_calculation_literal() {
let mut s = seeds();
let btn = rect(&mut s, 0.0);
let dest = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100})));
ex(&mut s, "proto.interaction.add", json!({"id": btn, "dest": dest}));
let calc = json!({"value": {"type": "colorAlpha", "value": {"variable": "ink", "opacity": 9}}});
let msg = err(&mut s, "proto.evaluate", json!({"expr": calc}));
assert!(msg.contains("Not a value"), "evaluate: {msg}");
let set = json!({"type": "setVariable", "variable": "edge", "value": null, "expr": calc});
let msg = err(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "action": set}));
assert!(msg.contains("Not a value"), "set variable action: {msg}");
let added = ex(
&mut s,
"proto.action.add",
json!({"id": btn, "index": 0, "action": {"type": "conditional", "blocks": [{"expression": "size > 1", "actions": []}]}}),
);
let same = json!({"op": {"op": "equals", "args": [{"value": {"type": "alias", "value": "ink"}}, calc]}});
let msg = err(&mut s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": added["path"], "block": 0, "condition": same}));
assert!(msg.contains("Not a value"), "condition: {msg}");
}
#[test]
fn rest_import_skips_a_hand_written_calculation_with_a_warning() {
// The variables JSON has no shape for calculations; a hand-written one must not import or corrupt.
let mut s = seeds();
let text = json!({
"variableCollections": {"COL:X": {"id": "COL:X", "name": "Imp", "modes": [{"modeId": "M:1", "name": "M"}], "defaultModeId": "M:1", "variableIds": ["V:9"]}},
"variables": {"V:9": {"id": "V:9", "name": "ghost", "variableCollectionId": "COL:X", "resolvedType": "COLOR",
"valuesByMode": {"M:1": {"type": "colorAlpha", "value": {"variable": "V:1", "opacity": 0.5}}}}}
});
let out = ex(&mut s, "variable.import", json!({"variables": text}));
assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("composed colours")), "skip warning present: {out}");
let list = ex(&mut s, "variable.list", json!({}));
assert!(list["variables"].as_array().unwrap().iter().all(|v| v["name"] != "ghost"), "the calculation was skipped");
}
#[test]
fn profile_conversion_reaches_the_colours_stored_in_a_calculation() {
// A mix of identical literals shows that colour; after a converting profile change the
// mix-bound layer must match a layer bound to the plain colour (the literal converted too).
let mut s = seeds();
ex(&mut s, "variable.create", json!({"collection": "C", "name": "p", "type": "color", "value": "#804020"}));
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "dl", "type": "color", "value": {"mix": {"from": {"literal": "#804020"}, "to": {"literal": "#804020"}, "t": 0.5}}}),
);
let a = rect(&mut s, 0.0);
let b = rect(&mut s, 0.0);
ex(&mut s, "variable.bind", json!({"ids": [a], "field": "fill", "variable": "p"}));
ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "dl"}));
ex(&mut s, "file.colorProfile", json!({"profile": "displayP3"}));
let (fa, fb) = (fill(&s, a), fill(&s, b));
assert_ne!(fa.to_rgba8(), [128, 64, 32, 255], "the profile change converted the plain colour");
assert_eq!(fa.to_rgba8(), fb.to_rgba8(), "the colour stored inside the calculation converted the same way");
}
#[test]
fn negative_zero_bounds_are_stored_as_plain_zero() {
let mut s = seeds();
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "n0", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": -0.0}}}),
);
ex(
&mut s,
"variable.create",
json!({"collection": "C", "name": "m0", "type": "color", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": -0.0}}}),
);
let list = serde_json::to_string(&ex(&mut s, "variable.list", json!({}))).unwrap();
assert!(!list.contains("-0"), "no negative zeros persisted: {list}");
}
@@ -125,3 +125,66 @@ fn create_collection_refuses_a_taken_name() {
ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"}));
assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1);
}
#[test]
fn stored_variable_ids_detach_deleted_aliases_per_mode() {
for collection in [false, true] {
let mut s = Session::new();
for name in ["Base", "Theme"] {
ex(&mut s, "variable.createCollection", json!({"name": name, "modes": ["Light", "Dark"]}));
}
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
let surface = ex(
&mut s,
"variable.create",
json!({"collection": "Base", "name": "surface", "type": "color", "values": {"Light": "#0D99FF", "Dark": "#000000"}}),
);
// Use a different collection only when deleting the whole target collection.
let home = if collection { "Theme" } else { "Base" };
let alias = ex(&mut s, "variable.create", json!({"collection": home, "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
let aid = alias["id"].as_str().unwrap();
let before = s.doc().unwrap().variables.get_by_id(aid).unwrap().clone();
let expected: std::collections::BTreeMap<_, _> = before
.values
.keys()
.map(|m| {
let modes = std::collections::BTreeMap::from([(before.collection.clone(), m.clone())]);
(m.clone(), s.doc().unwrap().variables.resolve(aid, &modes).unwrap())
})
.collect();
if collection {
ex(&mut s, "variable.deleteCollection", json!({"collection": "Base"}));
} else {
ex(&mut s, "variable.delete", json!({"variable": "surface"}));
}
assert_eq!(s.doc().unwrap().variables.get_by_id(aid).unwrap().values, expected);
}
}
#[test]
fn stored_variable_ids_keep_collection_edits_on_the_resolved_collection() {
let mut s = Session::new();
let shadow = ex(&mut s, "variable.createCollection", json!({"name": "Shadow", "modes": ["Only"]}));
let theme = ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]}));
let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "number", "value": 1}));
ex(&mut s, "variable.renameCollection", json!({"collection": shadow["id"], "name": theme["id"]}));
ex(&mut s, "variable.set", json!({"variable": "surface", "mode": "Dark", "value": 7}));
let doc = s.doc().unwrap();
let coll = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap();
let mode = coll.find_mode("Dark").unwrap().id.clone();
let modes = std::collections::BTreeMap::from([(coll.id.clone(), mode)]);
assert_eq!(doc.variables.resolve(surface["id"].as_str().unwrap(), &modes), Some(figcraft_doc::VariableValue::Number(7.0)));
// Mode commands resolve the user's collection argument before checking its stored id.
let doc = s.doc_mut().unwrap();
let mut extended = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap().clone();
extended.id = "extended".into();
extended.name = "Extended".into();
extended.extends = Some(theme["id"].as_str().unwrap().into());
doc.variables.collections.push(extended);
doc.variables.collections[0].name = "extended".into();
let before = s.doc().unwrap().clone();
assert!(s.execute("variable.removeMode", &json!({"collection": "Extended", "mode": "Dark"})).is_err());
assert_eq!(s.doc().unwrap(), &before);
}
+85 -1
View File
@@ -231,6 +231,40 @@ fn variables_export_and_import_round_trip() {
let _ = std::fs::remove_file(&path);
}
#[test]
fn stored_variable_ids_keep_remapped_import_aliases() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "number", "value": 100}))["id"]
.as_str()
.unwrap()
.to_string();
let target = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "target", "type": "number", "value": 5}))["id"]
.as_str()
.unwrap()
.to_string();
ex(&mut s, "variable.update", json!({"variable": shadow, "name": target}));
let imported = json!({"meta": {
"variableCollections": {"c": {"id": "c", "name": "Theme", "modes": [{"modeId": "m", "name": "Mode 1"}]}},
"variables": {
"t": {"id": "t", "name": "target", "variableCollectionId": "c", "resolvedType": "FLOAT", "valuesByMode": {"m": 5}},
"a": {"id": "a", "name": "alias", "variableCollectionId": "c", "resolvedType": "FLOAT",
"valuesByMode": {"m": {"type": "VARIABLE_ALIAS", "id": "t"}}}
}
}});
let result = ex(&mut s, "variable.import", json!({"variables": imported}));
assert_eq!(result["warnings"], json!([]));
let vars = &s.doc().unwrap().variables;
let alias = vars.get("alias").unwrap();
assert_eq!(alias.values.values().next(), Some(&VariableValue::Alias(target.clone())));
assert_eq!(vars.resolve(&alias.id, &Default::default()), Some(VariableValue::Number(5.0)));
// The command's alias argument accepts names, but an exact id comes first (#1613): the
// variable renamed to target's id doesn't take its place.
ex(&mut s, "variable.set", json!({"variable": "alias", "value": {"alias": target}}));
let vars = &s.doc().unwrap().variables;
assert_eq!(vars.resolve(&vars.get("alias").unwrap().id, &Default::default()), Some(VariableValue::Number(5.0)));
}
#[test]
fn variables_import_checks_what_it_reads() {
let mut s = Session::new();
@@ -291,7 +325,7 @@ fn variables_import_caps_the_file_it_reads() {
for bad in &refused {
let before = (format!("{:?}", t.docs), t.active);
let e = t.execute("variable.import", &json!({"variables": bad})).unwrap_err().to_string();
assert!(e.contains("not a file of at most 268435456 bytes"), "{bad}: {e}");
assert!(["more than the 256 MB allowed here", "is a folder, not a file", "isn't a regular file"].iter().any(|w| e.contains(w)), "{bad}: {e}");
assert_eq!((format!("{:?}", t.docs), t.active), before, "{bad}");
}
// A file under the limit still imports.
@@ -555,3 +589,53 @@ fn css_export_neutralises_every_css_newline_in_strings() {
assert!(css.contains("\"a b c\""), "{css:?}");
}
}
#[test]
fn stored_variable_ids_keep_exported_css_alias_names() {
let mut s = Session::new();
ex(&mut s, "variable.createCollection", json!({"name": "Theme"}));
let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}));
let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"}));
ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}}));
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}}));
let css = ex(&mut s, "variable.export", json!({"format": "css"}));
assert!(css["text"].as_str().unwrap().contains("--alias: var(--surface);"));
}
#[test]
fn stored_variable_ids_reset_only_the_inherited_parent_and_collection() {
for collection_shadow in [false, true] {
let mut s = Session::new();
let shadow_coll = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"}));
ex(&mut s, "variable.createCollection", json!({"name": "Base", "modes": ["Light", "Dark"]}));
let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "number", "value": 0}));
let surface =
ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "number", "values": {"Light": 4, "Dark": 8}}));
if collection_shadow {
ex(&mut s, "variable.renameCollection", json!({"collection": shadow_coll["id"], "name": "extended"}));
} else {
ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]}));
}
let doc = s.doc_mut().unwrap();
let parent = doc.variables.get_by_id(surface["id"].as_str().unwrap()).unwrap().clone();
let mut coll = doc.variables.collection_by_id(&parent.collection).unwrap().clone();
let (light, dark) = (coll.find_mode("Light").unwrap().id.clone(), coll.find_mode("Dark").unwrap().id.clone());
coll.extends = Some(coll.id.clone());
coll.id = "extended".into();
coll.name = "Extended".into();
let mut inherited = parent.clone();
inherited.id = "inherited".into();
inherited.name = "inherited".into();
inherited.collection = coll.id.clone();
inherited.extends = Some(parent.id);
inherited.overrides = inherited.values.keys().cloned().collect();
inherited.values.values_mut().for_each(|v| *v = VariableValue::Number(2.0));
doc.variables.collections.push(coll);
doc.variables.variables.push(inherited);
ex(&mut s, "variable.resetOverride", json!({"variable": "inherited", "mode": "Dark"}));
let result = s.doc().unwrap().variables.get_by_id("inherited").unwrap();
assert_eq!(result.values[&light], VariableValue::Number(2.0));
assert_eq!(result.values[&dark], VariableValue::Number(8.0));
assert_eq!(result.overrides, std::collections::BTreeSet::from([light]));
}
}
+129
View File
@@ -0,0 +1,129 @@
//! Widgets' hover styles: while the pointer is over a widget layer that has one, the canvas shows
//! it as a live preview (no undo step, nothing saved or synced); moving away, leaving the canvas or
//! any command puts the layer back, and a click on a hovered part acts on the document itself.
use serde_json::{Value, json};
use crate::components::tests::{ex, idv, node};
use crate::plugins_tests::{install, undo_len};
use crate::tools::{PointerEvent, PointerKind};
use crate::widgets_tests::{W, widget_manifest};
use crate::{Session, textpreview};
use figcraft_doc::NodeId;
use figcraft_geom::Point;
const BLUE: &str = "0D99FF";
const DARK: &str = "0B7FD6";
/// A widget whose button darkens and whose label turns white while hovered; the widget fades a
/// little. A click on the button counts.
fn button() -> Value {
json!({"root": {"type": "frame", "name": "Hover", "layout": "horizontal", "padding": 10, "fill": "#FFFFFF", "hover": {"opacity": 0.9}, "children": [
{"type": "rectangle", "name": "Button", "target": "go", "width": 40, "height": 20, "fill": format!("#{BLUE}"), "hover": {"fill": format!("#{DARK}"), "stroke": "#000000"}},
{"type": "text", "name": "Label", "text": "Go", "color": "#111111", "hover": {"color": "#FFFFFF"}}]}})
}
fn session() -> (Session, NodeId) {
let mut s = Session::new();
let mut w = W::default();
let body = w.render(&button());
install(&mut s, widget_manifest("org.test.hover", "read"), &w.build(&body));
let id = idv(&ex(&mut s, "widget.insert", json!({"plugin": "org.test.hover", "x": 50, "y": 60})));
ex(&mut s, "select.set", json!({"ids": []}));
(s, id)
}
fn fill(s: &Session, id: NodeId) -> String {
node(s, id).fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default()
}
fn hover(s: &mut Session, p: Point) {
s.pointer(&PointerEvent::at(PointerKind::Hover, p.x, p.y, 1.0)).unwrap();
}
fn hover_over(s: &mut Session, id: NodeId) {
let p = centre(s, id);
hover(s, p);
}
fn centre(s: &Session, id: NodeId) -> Point {
s.doc().unwrap().world_bounds(id).unwrap().center()
}
#[test]
fn hovering_a_widget_shows_its_hover_styles_without_an_edit() {
let (mut s, w) = session();
let kids = node(&s, w).children.clone();
let (btn, label) = (kids[0], kids[1]);
let undo = undo_len(&s);
hover_over(&mut s, btn);
assert_eq!(fill(&s, btn), DARK, "the hovered button");
assert!(!node(&s, btn).strokes.is_empty());
assert!((node(&s, w).opacity - 0.9).abs() < 1e-6, "the widget the pointer is in");
assert_eq!(fill(&s, label), "111111", "the label isn't under the pointer");
let itself = s.ds().unwrap().document_itself().clone();
assert_eq!(itself.node(btn).unwrap().fills[0].solid_color().unwrap().to_hex(), BLUE, "the document itself is unchanged");
assert_eq!(undo_len(&s), undo, "no undo step");
// Over the label: the label's style, not the button's.
hover_over(&mut s, label);
assert_eq!((fill(&s, btn), fill(&s, label)), (BLUE.into(), "FFFFFF".into()));
// Away from the widget, and leaving the canvas: as it was.
hover(&mut s, Point::new(-400.0, -400.0));
assert_eq!((fill(&s, btn), fill(&s, label)), (BLUE.into(), "111111".into()));
assert!((node(&s, w).opacity - 1.0).abs() < 1e-6);
hover_over(&mut s, btn);
s.pointer_left();
assert_eq!(fill(&s, btn), BLUE);
assert!(!textpreview::active(&s));
}
#[test]
fn a_click_on_a_hovered_part_edits_the_document_itself() {
let (mut s, w) = session();
let btn = node(&s, w).children[0];
let at = centre(&s, btn);
hover(&mut s, at);
assert_eq!(fill(&s, btn), DARK);
let undo = undo_len(&s);
s.pointer(&PointerEvent::at(PointerKind::Down, at.x, at.y, 1.0)).unwrap();
s.pointer(&PointerEvent::at(PointerKind::Up, at.x, at.y, 1.0)).unwrap();
assert_eq!(undo_len(&s), undo + 1, "the click is one undo step");
// The widget was redrawn from the document itself: no hover style was kept in it.
let btn = node(&s, w).children[0];
assert_eq!(fill(&s, btn), BLUE);
ex(&mut s, "edit.undo", json!({}));
let btn = node(&s, w).children[0];
assert_eq!(fill(&s, btn), BLUE, "undo brings back the document, not a hovered one");
// Hovering again shows it again.
hover(&mut s, at);
assert_eq!(fill(&s, btn), DARK);
}
#[test]
fn widget_hover_is_a_command_any_other_command_ends() {
let (mut s, w) = session();
let btn = node(&s, w).children[0];
assert_eq!(ex(&mut s, "widget.hover", json!({"ids": [btn]}))["hovering"], true);
assert_eq!(fill(&s, btn), DARK);
ex(&mut s, "select.set", json!({"ids": [w]}));
assert_eq!(fill(&s, btn), BLUE, "another command puts the document back first");
// Layers without a hover style, and layers outside widgets, show nothing.
let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 10, "height": 10})));
assert_eq!(ex(&mut s, "widget.hover", json!({"ids": [r]}))["hovering"], false);
assert_eq!(ex(&mut s, "widget.hover", json!({}))["hovering"], false);
// A hover style damaged in a file is ignored.
let doc = s.ds_mut().unwrap().doc_mut();
doc.get_mut(btn).unwrap().plugin_data.insert("figcraft.widget.hover".into(), "{\"fill\": \"not a colour\"}".into());
assert_eq!(ex(&mut s, "widget.hover", json!({"ids": [btn]}))["hovering"], false);
}
#[test]
fn a_widget_doesnt_replace_another_live_preview() {
let (mut s, w) = session();
let btn = node(&s, w).children[0];
let other = idv(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 10, "height": 10})));
ex(&mut s, "text.preview", json!({"command": "node.set", "params": {"ids": [other], "props": {"opacity": 0.5}}}));
hover_over(&mut s, btn);
assert!((node(&s, other).opacity - 0.5).abs() < 1e-6, "the preview showing stays");
assert_eq!(fill(&s, btn), BLUE);
}
+69 -3
View File
@@ -30,8 +30,12 @@ pub mod kiwi;
#[cfg(test)]
mod layer_cap_tests;
mod map;
#[cfg(test)]
mod missing_image_tests;
mod network;
#[cfg(test)]
mod paste_style_tests;
#[cfg(test)]
mod prop_value_tests;
#[cfg(test)]
mod slot_tests;
@@ -50,7 +54,10 @@ mod text_skip_ink_tests;
mod text_style_tests;
#[cfg(test)]
mod text_wrap_tests;
#[cfg(test)]
mod value_budget_tests;
use std::cell::Cell;
use std::collections::BTreeMap;
use std::io::Read;
use std::sync::Arc;
@@ -67,6 +74,13 @@ const MAX_CANVAS: u64 = 256 * 1024 * 1024;
const MAX_CHUNK: u64 = 512 * 1024 * 1024;
/// Largest image member read.
const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES;
/// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on
/// 64-bit targets, lower on 32-bit targets to leave address space for the canvas, layers and
/// decoded pixels.
#[cfg(not(target_pointer_width = "32"))]
const MAX_IMAGE_BYTES: u64 = crate::IMAGE_BUDGET.bytes;
#[cfg(target_pointer_width = "32")]
const MAX_IMAGE_BYTES: u64 = 1024 * 1024 * 1024;
/// Most chunks read from `canvas.fig`.
const MAX_CHUNKS: usize = 16;
/// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its
@@ -105,7 +119,12 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option<Result<(Document, Vec<Stri
/// Build a document from a `.fig` file. Returns it with the warnings about what was skipped or
/// approximated. `name`: the document name when the file doesn't carry one.
pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), FormatError> {
import_with_image_limit(bytes, name, MAX_IMAGE_BYTES)
}
fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result<(Document, Vec<String>), FormatError> {
let mut archive = Archive::open(bytes)?;
archive.image_budget.remaining.set(image_limit);
// The budgets follow the compressed canvas, which the file itself bounds (its zip member may
// inflate too).
let packed = archive.canvas.len().min(bytes.len());
@@ -125,6 +144,10 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec<String>), Forma
drop(msg);
let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec()));
let (mut doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?;
let skipped = archive.image_budget.skipped.get();
if skipped > 0 {
warnings.push(format!("{skipped} ZIP image(s) skipped by the total ZIP image byte budget ({image_limit} bytes)"));
}
if board {
doc.kind = FileKind::Board;
}
@@ -218,18 +241,27 @@ fn blob<'k>(blobs: &'k [Kv], index: usize) -> Option<&'k [u8]> {
blobs.get(index)?.get("bytes").and_then(Kv::bytes)
}
/// Bytes left for ZIP image reads, including damaged images. The REST resolver caches references,
/// including failures, so each referenced member is read or counted as skipped only once.
struct ZipImageBudget {
remaining: Cell<u64>,
skipped: Cell<usize>,
}
/// The parts of a `.fig` archive the import reads.
struct Archive<'b> {
canvas: Vec<u8>,
file_name: Option<String>,
/// `images/<hash>` members (or nothing for a bare canvas).
zip: Option<zip::ZipArchive<std::io::Cursor<&'b [u8]>>>,
image_budget: ZipImageBudget,
}
impl<'b> Archive<'b> {
fn open(bytes: &'b [u8]) -> Result<Self, FormatError> {
let image_budget = ZipImageBudget { remaining: Cell::new(MAX_IMAGE_BYTES), skipped: Cell::new(0) };
if bytes.starts_with(b"fig-") {
return Ok(Archive { canvas: bytes.to_vec(), file_name: None, zip: None });
return Ok(Archive { canvas: bytes.to_vec(), file_name: None, zip: None, image_budget });
}
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?;
let canvas = crate::read_member(&mut z, "canvas.fig", MAX_CANVAS)?;
@@ -237,17 +269,51 @@ impl<'b> Archive<'b> {
.ok()
.and_then(|m| serde_json::from_slice::<serde_json::Value>(&m).ok())
.and_then(|m| m.get("file_name").and_then(serde_json::Value::as_str).map(str::to_string));
Ok(Archive { canvas, file_name, zip: Some(z) })
Ok(Archive { canvas, file_name, zip: Some(z), image_budget })
}
/// Bytes of image `hash` (lowercase hex) from `images/`.
fn image(&self, hash: &str) -> Option<Vec<u8>> {
let budget = &self.image_budget;
if hash.is_empty() || hash.len() > 128 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
// A clone shares the parsed directory and the bytes; the resolver only has shared access.
let mut z = self.zip.clone()?;
crate::read_member(&mut z, &format!("images/{hash}"), MAX_IMAGE).ok()
let f = z.by_name(&format!("images/{hash}")).ok()?;
let size = f.size();
if size > MAX_IMAGE {
return None;
}
let remaining = budget.remaining.get();
if size > remaining {
budget.skipped.set(budget.skipped.get() + 1);
return None;
}
let checksum = f.crc32();
let mut data = Vec::new();
// Probe past the per-image cap only when the total budget has room. Even a dishonest
// size cannot make the read exceed the remaining total; failed reads keep their charge.
let limit = remaining.min(MAX_IMAGE + 1);
let read = f.take(limit).read_to_end(&mut data);
let used = data.len() as u64;
budget.remaining.set(remaining - used);
if used > size && used == remaining {
budget.skipped.set(budget.skipped.get() + 1);
}
if read.is_err() || used != size {
return None;
}
// `take` can stop exactly at the budget without asking ZIP for EOF (where it checks CRC).
// Check it here too, without spending an extra byte on an overflow probe.
if used == limit {
let mut crc = flate2::Crc::new();
crc.update(&data);
if crc.sum() != checksum {
return None;
}
}
Some(data)
}
}
+31 -1
View File
@@ -5,7 +5,7 @@
//! laid out for it. Everything written reads back; what the format can't say is written as its
//! value with a warning; damaged files never panic.
use figcraft_doc::{Collection, Mode, Style, StyleKind, Variable, VariableType, VariableValue};
use figcraft_doc::{Collection, ColorOperand, Mode, Style, StyleKind, Variable, VariableType, VariableValue};
use serde_json::json;
use super::*;
@@ -383,6 +383,36 @@ fn bindings_the_format_cant_say_keep_their_value_with_a_warning() {
assert!(back.variables.get("lost").is_none());
}
/// A colour calculation has no encoding here: it is written as the colour it shows in each mode,
/// and one that can't resolve is left out with a warning rather than written as something else.
#[test]
fn colour_calculations_are_written_as_the_colour_they_show() {
let mut doc = themed();
let fade = |source: &str| VariableValue::ColorAlpha { variable: source.into(), opacity: 0.5 };
let mix = VariableValue::ColorMix {
from: ColorOperand::Variable { variable: "v-fg".into() },
to: ColorOperand::Literal { literal: Color::new(1.0, 0.0, 0.0, 1.0) },
t: 0.5,
};
doc.variables.variables.push(var("v-veil", "color/veil", "c-theme", VariableType::Color, &[("m-light", fade("v-bg")), ("m-dark", mix)]));
doc.variables.variables.push(var("v-broken", "color/broken", "c-theme", VariableType::Color, &[("m-light", fade("v-gone"))]));
let (bytes, warnings) = export(&doc, None).unwrap();
assert!(warnings.iter().any(|w| w == crate::CALCULATIONS_LEFT_OUT), "{warnings:?}");
let (back, _) = import(&bytes, "x").unwrap();
assert!(back.variables.get("color/broken").is_none_or(|v| v.values.is_empty()), "left out, not written as something else");
let theme = back.variables.collections.iter().find(|c| c.name == "Theme").unwrap();
let veil = back.variables.get("color/veil").unwrap();
let shown: Vec<String> = theme
.modes
.iter()
.map(|m| match veil.values.get(&m.id) {
Some(VariableValue::Color(c)) => c.to_css_hex(),
other => panic!("{}: {other:?}", m.name),
})
.collect();
assert_eq!(shown, ["#FFFFFF80", "#DF6060"]);
}
/// A style of each kind linked from a layer, and one that nothing uses.
fn styled() -> Document {
let mut doc = Document::new("Styled");
+1 -1
View File
@@ -216,7 +216,7 @@ fn too_large() -> FormatError {
}
/// `canvas.fig`: the magic, the version and the two deflated chunks, each after its length.
fn canvas(schema: &[u8], message: &[u8]) -> Result<Vec<u8>, FormatError> {
pub(super) fn canvas(schema: &[u8], message: &[u8]) -> Result<Vec<u8>, FormatError> {
let mut out = b"fig-kiwi".to_vec();
out.extend_from_slice(&VERSION.to_le_bytes());
for chunk in [schema, message] {
+11 -3
View File
@@ -211,7 +211,13 @@ impl<'d> Exporter<'d> {
let mut values = Vec::new();
for m in &c.modes {
let (Some(mode), Some(val)) = (self.mode_ids.get(&(c.id.as_str(), m.id.as_str())), v.values.get(&m.id)) else { continue };
let (ty, value) = match val {
// The Figma binary has no encoding FigCraft writes for calculations: the colour
// they show in this mode goes out as a plain colour value.
let Some(val) = doc.variables.flattened(v, &m.id, val) else {
self.warn(crate::CALCULATIONS_LEFT_OUT);
continue;
};
let (ty, value) = match &val {
VariableValue::Color(x) => ("COLOR", ("colorValue", color(*x))),
VariableValue::Number(x) => ("FLOAT", ("floatValue", fl(*x))),
// NULs can't be stored in a string.
@@ -224,6 +230,8 @@ impl<'d> Exporter<'d> {
continue;
}
},
// `flattened` leaves none: this format has no shape for a calculation.
VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => continue,
};
let data = o(vec![("value", o(vec![value])), ("dataType", e(ty)), ("resolvedDataType", e(resolved))]);
values.push(o(vec![("modeID", guid(AUX, *mode)), ("variableData", data)]));
@@ -251,9 +259,9 @@ impl<'d> Exporter<'d> {
}
}
/// A reference to variable `var` (its id or name) as variable data.
/// A reference to variable `var` (a stored id: no name stands in for it, #1613) as variable data.
fn alias(&mut self, var: &str) -> Option<Kv<'static>> {
let found = self.doc.variables.get(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?)));
let found = self.doc.variables.get_by_id(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?)));
let Some((kind, local)) = found else {
self.warn("bindings to variables that aren't in the document were written as their values");
return None;
+14 -5
View File
@@ -11,11 +11,20 @@ use std::fmt;
/// Deepest struct/message nesting decoded.
const MAX_DEPTH: usize = 64;
/// Most values decoded from one message (every scalar, array element and field counts): at least
/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a
/// large real file decodes while a small one can't expand into a huge tree, however far its
/// chunks inflate. 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is
/// lower: past it the file is refused cleanly instead of running out of memory.
/// [`MIN_VALUES`], and [`VALUES_PER_PACKED_BYTE`] per byte of the file's compressed canvas up to
/// [`MAX_VALUES`], so a large real file decodes while a small one can't expand into a huge tree,
/// however far its chunks inflate. 32-bit targets (wasm32) have a 4 GB address space in all, so
/// their ceiling is lower: past it the file is refused cleanly instead of running out of memory.
const MIN_VALUES: usize = 20_000_000;
/// Value budget per compressed canvas byte. The real files measured in
/// `large_files_get_a_larger_value_budget` hold up to 0.75 values per compressed byte without their
/// single scalar fields; counting those too adds 15–43% on the generated files in
/// `value_budget_tests` and up to 3.2 times on a rectangle carrying only geometry and paint, so a
/// dense file may reach about 2.4 per byte. Four leaves two-thirds more room than that. The
/// trade-off: from 32 MiB of canvas (8 MiB on 32-bit targets) a file gets all of [`MAX_VALUES`],
/// and above the floor it may decode four values for every byte it carries; [`MIN_VALUES`] and
/// [`MAX_VALUES`] still bound every file.
const VALUES_PER_PACKED_BYTE: usize = 4;
#[cfg(not(target_pointer_width = "32"))]
const MAX_VALUES: usize = 128 << 20;
#[cfg(target_pointer_width = "32")]
@@ -306,7 +315,7 @@ impl Schema {
/// Most values decoded from a file whose compressed canvas is `packed` bytes.
pub(crate) fn value_budget(packed: usize) -> usize {
packed.clamp(MIN_VALUES, MAX_VALUES)
packed.saturating_mul(VALUES_PER_PACKED_BYTE).clamp(MIN_VALUES, MAX_VALUES)
}
struct Decoder<'s, 'b> {
+4 -5
View File
@@ -91,10 +91,6 @@ pub(super) fn adapt_warning(w: String) -> Option<String> {
if w.contains("geometry=paths") {
return None;
}
if w.contains("without image data") {
let count = w.split_whitespace().next().unwrap_or("Some");
return Some(format!("{count} image fill(s) whose image isn't in the file show a grey placeholder"));
}
Some(w.replace("the variables JSON", "the file").replace("the file JSON", "the file"))
}
@@ -351,7 +347,10 @@ impl<'a> Mapper<'a> {
/// the reference has neither or the asset names nothing (or ambiguously) in the file.
/// Original code for this importer: the field names are those of the schema each file embeds.
fn resource_id(&self, v: &Kv) -> Option<String> {
if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE) {
// A guid names the resource when this file holds a node by it. One from another file (the
// library a style or collection came from, as a paste carries) names nothing here: the
// asset it also names finds the file's copy (#1638).
if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE && self.nodes.contains_key(g)) {
return Some(g);
}
let asset = v.get("assetRef")?;
+68 -17
View File
@@ -1,9 +1,12 @@
use super::layer::{cap_name, own_text, svg_path, valid_size};
use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, guid, json, matrix, network, num, rows};
use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape};
use figcraft_doc::{RouteEnd, Routing, Side, route_points};
impl<'a> Mapper<'a> {
/// A connector's end caps, and when it has no saved outline or network a straight line
/// between its ends: free points, or a point (the centre unless given) of a sibling layer.
/// A connector's end caps, and when it has no saved outline or network its line between its
/// ends (free points, or a sibling layer): straight to the point given on the layer (its
/// centre unless given), or elbowed or curved from a side of it, as a board connector runs.
pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) {
for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] {
if let Some(c) = v.str(from) {
@@ -16,10 +19,15 @@ impl<'a> Mapper<'a> {
if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) {
return;
}
let routing = match v.str("connectorLineStyle") {
Some("ELBOWED") => Routing::Elbow,
Some("CURVED") => Routing::Curved,
_ => Routing::Straight,
};
let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid);
let end = |k: &str| -> Option<Point> {
let end = |k: &str| -> Option<RouteEnd> {
let e = v.get(k)?;
let p = match e.get("endpointNodeID").and_then(guid).filter(|g| g != NO_STYLE) {
let end = match e.get("endpointNodeID").and_then(guid).filter(|g| g != NO_STYLE) {
Some(id) => {
let n = *self.nodes.get(&id)?;
// Only siblings share the connector's coordinates.
@@ -27,27 +35,52 @@ impl<'a> Mapper<'a> {
return None;
}
let (w, h) = n.get("size").map_or((0.0, 0.0), |s| (num(s, "x").unwrap_or(0.0), num(s, "y").unwrap_or(0.0)));
let (rx, ry) = e.get("relativePosition").and_then(|r| Some((num(r, "x")?, num(r, "y")?))).unwrap_or((0.5, 0.5));
n.get("transform").and_then(matrix).unwrap_or_default() * Point::new(w * rx, h * ry)
let at = e.get("relativePosition").and_then(|r| Some((num(r, "x")?, num(r, "y")?)));
let t = n.get("transform").and_then(matrix).unwrap_or_default();
match routing {
Routing::Straight => {
let (rx, ry) = at.unwrap_or((0.5, 0.5));
RouteEnd::Point(t * Point::new(w * rx, h * ry))
}
_ => RouteEnd::Box(t.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), side(e, at)),
}
}
None => e.get("position").and_then(|p| Some(Point::new(num(p, "x")?, num(p, "y")?)))?,
None => RouteEnd::Point(e.get("position").and_then(|p| Some(Point::new(num(p, "x")?, num(p, "y")?)))?),
};
(p.x.is_finite() && p.y.is_finite()).then_some(p)
let finite = |p: Point| p.x.is_finite() && p.y.is_finite();
let ok = match end {
RouteEnd::Point(p) => finite(p),
RouteEnd::Box(b, _) => finite(b.origin()) && finite(Point::new(b.x1, b.y1)),
};
ok.then_some(end)
};
let (Some(a), Some(b)) = (end("connectorStart"), end("connectorEnd")) else {
let Some(pts) = end("connectorStart").zip(end("connectorEnd")).and_then(|(a, b)| route_points(a, b, routing)) else {
self.warn("connectors whose ends can't be placed are empty");
return;
};
if v.str("connectorLineStyle").is_some_and(|s| s != "STRAIGHT") {
self.warn("elbowed and curved connectors without a saved path import as straight lines");
if routing != Routing::Straight {
self.warn("elbowed and curved connectors without a saved path follow FigCraft's own routing");
}
let (x0, y0) = (a.x.min(b.x), a.y.min(b.y));
// A curved route is one curve: its ends and the two control points between them.
let (path, vertices) = match (routing, pts.as_slice()) {
(Routing::Curved, [p0, c0, c1, p1]) => {
let mut path = BezPath::new();
path.move_to(*p0);
path.curve_to(*c0, *c1, *p1);
(path, vec![*p0, *p1])
}
_ => (pts.iter().enumerate().map(|(i, p)| if i == 0 { PathEl::MoveTo(*p) } else { PathEl::LineTo(*p) }).collect(), pts.clone()),
};
let b = path.bounding_box();
let (x0, y0) = (b.x0, b.y0);
o.insert("relativeTransform".into(), rows(space * Affine::translate((x0, y0))));
o.insert("size".into(), json!({"x": (a.x - b.x).abs(), "y": (a.y - b.y).abs()}));
o.insert(
"vectorNetwork".into(),
json!({"vertices": [{"x": a.x - x0, "y": a.y - y0}, {"x": b.x - x0, "y": b.y - y0}], "segments": [{"a": 0, "b": 1}]}),
);
o.insert("size".into(), json!({"x": b.width(), "y": b.height()}));
let segments: Vec<Value> = match (routing, pts.as_slice()) {
(Routing::Curved, [p0, c0, c1, p1]) => vec![json!({"a": 0, "b": 1, "ta": [c0.x - p0.x, c0.y - p0.y], "tb": [c1.x - p1.x, c1.y - p1.y]})],
_ => (1..vertices.len()).map(|i| json!({"a": i - 1, "b": i})).collect(),
};
let vertices: Vec<Value> = vertices.iter().map(|p| json!({"x": p.x - x0, "y": p.y - y0})).collect();
o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments}));
}
pub(super) fn connector_snapshot_supported(&self, v: &View<'a>) -> bool {
@@ -260,3 +293,21 @@ pub(super) fn connector_has_saved_label(v: &View<'_>) -> bool {
.and_then(Kv::str)
.is_some_and(|s| !s.trim().is_empty())
}
/// The side of its layer a connector end leaves from: the `magnet` the file names, else the edge
/// its point on the layer (`relativePosition`, 0–1) lies on, else the side facing the other end.
fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side {
match e.get("magnet").and_then(Kv::str) {
Some("TOP") => Side::Top,
Some("RIGHT") => Side::Right,
Some("BOTTOM") => Side::Bottom,
Some("LEFT") => Side::Left,
_ => match at {
Some((x, _)) if x <= 0.0 => Side::Left,
Some((x, _)) if x >= 1.0 => Side::Right,
Some((_, y)) if y <= 0.0 => Side::Top,
Some((_, y)) if y >= 1.0 => Side::Bottom,
_ => Side::Auto,
},
}
}
+10 -3
View File
@@ -33,10 +33,17 @@ impl<'a> Mapper<'a> {
/// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST
/// `styles` map under `slot`.
/// A link to a style the file doesn't hold leaves the layer its own values (the colours it
/// stored with it), with a note.
pub(super) fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> {
let id = self.style_link(v, key, legacy)?;
let node = *self.nodes.get(&id)?;
node.get("styleType").and_then(Kv::str)?;
let found = self.style_link(v, key, legacy).and_then(|id| Some((self.nodes.get(&id).copied()?, id)));
let Some((node, id)) = found.filter(|(n, _)| n.get("styleType").and_then(Kv::str).is_some()) else {
let named = |l: &Kv| l.get("assetRef").is_some() || l.get("guid").and_then(guid).is_some_and(|g| g != NO_STYLE);
if v.get(key).is_some_and(named) || v.get(legacy).and_then(guid).is_some_and(|g| g != NO_STYLE) {
self.warn("links to shared styles that aren't in the file kept the layers' own values");
}
return None;
};
out.insert(slot.into(), json!(id));
if !self.styles.contains_key(&id) {
let name = node.get("name").and_then(Kv::str).unwrap_or("Style");
@@ -0,0 +1,60 @@
//! Design data whose image fill names an image it doesn't carry, as a clipboard copied from
//! another design app does (#1646): the fill keeps the image's name, so the image can be put back
//! under it; the layer shows the missing-image placeholder (light grey with darker stripes, not a
//! grey a design could have chosen); and the import says so, once per image. Built in code and
//! read as `edit.pasteDesignLayers` reads a clipboard's design data.
use figcraft_doc::Document;
use figcraft_doc::color::PaintKind;
use super::kiwi::Kv;
use super::tests::{arr, canvas, e, node, o, schema, xy};
use super::try_import;
use crate::rest::{is_missing_image, missing_image_placeholder};
const HASH: [u8; 20] = [0x5a; 20];
fn image_fill() -> Kv<'static> {
o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))])), ("imageScaleMode", e("FILL"))])
}
/// Two layers filled with the same image, which the data doesn't carry.
fn pasted() -> (Document, Vec<String>) {
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
node(10, Some((1, "!")), "RECTANGLE", "Hero", vec![("size", xy(200.0, 100.0)), ("fillPaints", arr(vec![image_fill()]))]),
node(11, Some((1, "\"")), "RECTANGLE", "Hero copy", vec![("size", xy(200.0, 100.0)), ("fillPaints", arr(vec![image_fill()]))]),
];
let bytes = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))]));
try_import(&bytes, "Pasted layers").expect("design data").expect("it imports")
}
#[test]
fn a_missing_image_keeps_its_name_and_shows_as_missing() {
let (doc, warnings) = pasted();
let hex: String = HASH.iter().map(|b| format!("{b:02x}")).collect();
for name in ["Hero", "Hero copy"] {
let layer = doc.nodes().find(|n| n.name == name).unwrap();
let Some(PaintKind::Image(img)) = layer.fills.first().map(|p| &p.kind) else { panic!("{name}: {:?}", layer.fills) };
assert_eq!(img.image, hex, "{name}: the image's own name, so it can come back under it");
assert!(doc.images.get(&img.image).is_some_and(is_missing_image), "{name}: the missing-image placeholder");
}
let note = "image fills whose image isn't in the file show a missing-image placeholder and keep its name";
assert_eq!(warnings.iter().filter(|w| w.starts_with(note)).count(), 1, "{warnings:?}");
assert!(!warnings.iter().any(|w| w.contains("without image data")), "the REST JSON note isn't given too: {warnings:?}");
}
#[test]
fn the_placeholder_reads_as_missing_not_as_grey() {
let png = image::load_from_memory(missing_image_placeholder().unwrap()).unwrap().to_rgba8();
// Two tones (their anti-aliased edges between): darker stripes over a light ground, not one
// flat grey.
let count = |c: u8| png.pixels().filter(|p| p.0 == [c, c, c, 0xFF]).count();
let all = png.pixels().count();
let (ground, stripe) = (count(0xE6), count(0xB4));
assert!(ground > all / 2 && stripe > all / 8 && ground + stripe > all * 3 / 4, "{ground} ground, {stripe} stripe of {all}");
// A real image of one colour is never taken for the placeholder.
let flat = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![1, 2, 3]), width: 64, height: 64 };
assert!(!is_missing_image(&flat));
}
@@ -0,0 +1,82 @@
//! Layers pasted from another design app whose paints come from a shared paint style (#1638): the
//! clipboard links the style by the guid it has in the library it came from, which this payload
//! doesn't hold, and by its library asset, whose copy it does hold. The layers (a component's
//! glyph and the same glyph in an instance of it) take the style's colour and stay linked to it.
//! A link to a style the payload doesn't hold at all keeps the colour stored with the layer, with
//! a note. Built in code and read as `edit.pasteDesignLayers` reads a clipboard's design data.
use figcraft_doc::{Document, Node};
use super::kiwi::Kv;
use super::tests::{arr, canvas, e, g, node, o, s, schema, solid, xy};
use super::try_import;
/// A style link by `guid` (absent from the payload) and, given, the library asset `key`.
fn link(guid: u64, asset: Option<&str>) -> Kv<'static> {
let mut fields = vec![("guid", g(guid))];
if let Some(key) = asset {
fields.push(("assetRef", o(vec![("key", s(key)), ("version", s("7"))])));
}
o(fields)
}
/// The pasted payload: on the internal page, the file's copy of library style "Red" (#FF4040);
/// on the page, component "Icon" whose "Glyph" stores stale black and links the style, an
/// instance of it, and "Lost", which stores #1F1F1F and links a style the payload doesn't hold.
fn pasted() -> (Document, Vec<String>) {
let nodes = vec![
node(0, None, "DOCUMENT", "Document", vec![]),
node(1, Some((0, "!")), "CANVAS", "Page", vec![]),
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
node(
10,
Some((2, "!")),
"RECTANGLE",
"Red",
vec![("styleType", e("FILL")), ("key", s("lib-red")), ("version", s("7")), ("fillPaints", arr(vec![solid(1.0, 0.25, 0.25)]))],
),
node(20, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(24.0, 24.0))]),
node(
21,
Some((20, "!")),
"RECTANGLE",
"Glyph",
vec![("size", xy(16.0, 16.0)), ("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])), ("styleIdForFill", link(99, Some("lib-red")))],
),
node(30, Some((1, "\"")), "INSTANCE", "Icon copy", vec![("size", xy(24.0, 24.0)), ("symbolData", o(vec![("symbolID", g(20))]))]),
node(
40,
Some((1, "#")),
"RECTANGLE",
"Lost",
vec![("size", xy(16.0, 16.0)), ("fillPaints", arr(vec![solid(0.12, 0.12, 0.12)])), ("styleIdForFill", link(98, None))],
),
];
let bytes = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))]));
try_import(&bytes, "Pasted layers").expect("design data").expect("it imports")
}
fn fill_hex(n: &Node) -> Option<String> {
n.fills.first()?.solid_color().map(|c| c.to_hex())
}
#[test]
fn a_library_paint_style_colours_the_component_and_its_instance() {
let (doc, _) = pasted();
let glyphs: Vec<&Node> = doc.nodes().filter(|n| n.name == "Glyph").collect();
assert_eq!(glyphs.len(), 2, "the component's glyph and the instance's");
for glyph in glyphs {
assert_eq!(fill_hex(glyph).as_deref(), Some("FF4040"), "the style's colour, not the stale black");
let style = glyph.styles.fill.as_deref().and_then(|id| doc.style(id)).expect("linked to the style");
assert_eq!(style.name, "Red");
}
}
#[test]
fn a_style_the_paste_doesnt_hold_keeps_the_stored_colour() {
let (doc, warnings) = pasted();
let lost = doc.nodes().find(|n| n.name == "Lost").unwrap();
assert_eq!(fill_hex(lost).as_deref(), Some("1F1F1F"), "the colour stored with the layer, not black");
assert!(lost.styles.fill.is_none());
assert!(warnings.iter().any(|w| w.contains("shared styles that aren't in the file")), "{warnings:?}");
}
+41 -2
View File
@@ -613,12 +613,13 @@ fn other_layer_kinds_import_as_the_closest_layers() {
let kids: Vec<&figcraft_doc::Node> = bubble.children.iter().map(|c| doc.node(*c).unwrap()).collect();
assert!(matches!(kids[0].kind, NodeKind::Vector { .. }) && kids[0].fills.len() == 1);
assert_eq!(text_of(bubble.children[1]), "Hi");
// A connector from a free point to the centre of a sibling, with its end cap.
// An elbowed connector from a free point to a sibling: in right angles, into the side of the
// box facing the point, with its end cap.
let link = named(&doc, "Link");
let world = doc.world_transform(link.id);
let ends: Vec<(f64, f64)> =
network_of(&doc, "Link").vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
assert_eq!(ends, [(0.0, 300.0), (450.0, 50.0)]);
assert_eq!(ends, [(0.0, 300.0), (200.0, 300.0), (200.0, 50.0), (400.0, 50.0)]);
assert_eq!(link.stroke.cap_end, StrokeCap::TriangleArrow);
// A table: a frame.
assert!(matches!(named(&doc, "Grid").kind, NodeKind::Frame));
@@ -627,6 +628,44 @@ fn other_layer_kinds_import_as_the_closest_layers() {
}
}
/// Connectors saved without their path: straight to the point on the layer, elbowed and curved
/// from the side the end's point lies on, as a board connector would run.
#[test]
fn connectors_without_a_saved_path_follow_their_line_style() {
let mut message = tail_message();
let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() };
let box_at = |id, name: &str, x| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, 600.0))]);
let joined = |id, name: &str, style: &'static str| {
let side = |node, x| o(vec![("endpointNodeID", g(node)), ("relativePosition", xy(x, 0.5))]);
node(
id,
Some((1, "+")),
"CONNECTOR",
name,
vec![
("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])),
("connectorStart", side(60, 1.0)),
("connectorEnd", side(61, 0.0)),
("connectorLineStyle", e(style)),
],
)
};
nodes.extend([box_at(60, "A", 0.0), box_at(61, "B", 300.0), joined(62, "Curve", "CURVED"), joined(63, "Line", "STRAIGHT")]);
let (doc, warnings) = import(&canvas(&schema(), &message), "Routes").unwrap();
let world = |name: &str| doc.world_transform(named(&doc, name).id);
// Curved: one curve from A's right side to B's left, leaving and arriving level.
let curve = network_of(&doc, "Curve");
let ends: Vec<_> = curve.vertices.iter().map(|v| world("Curve") * figcraft_doc::geom::Point::new(v.x, v.y)).collect();
assert_eq!(ends.iter().map(|p| (p.x, p.y)).collect::<Vec<_>>(), [(100.0, 650.0), (300.0, 650.0)]);
assert_eq!(curve.segments.len(), 1);
assert_eq!((curve.segments[0].ta, curve.segments[0].tb), ((100.0, 0.0), (-100.0, 0.0)), "the curve leaves and arrives along the sides");
// Straight: unchanged, to the points the file gives on the layers.
let line: Vec<_> =
network_of(&doc, "Line").vertices.iter().map(|v| world("Line") * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect();
assert_eq!(line, [(100.0, 650.0), (300.0, 650.0)]);
assert!(warnings.iter().any(|w| w.contains("FigCraft's own routing")), "{warnings:?}");
}
#[test]
fn variable_prototype_actions_import() {
let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap();
+251 -16
View File
@@ -59,7 +59,7 @@ message TextData string characters, uint[] characterStyleIDs, NodeChange[] style
message Path WindingRule windingRule, uint commandsBlob
message GUIDPath GUID[] guids
message SymbolData GUID symbolID, NodeChange[] symbolOverrides
message StyleId GUID guid
message StyleId GUID guid, AssetRef assetRef
message PropValue bool boolValue, TextData textValue
message PropDef GUID id, string name, PropValue initialValue, PropType type
message PropAssignment GUID defID, PropValue value
@@ -79,41 +79,41 @@ message Blob byte[] bytes
message Message NodeChange[] nodeChanges, Blob[] blobs
";
fn schema() -> Schema {
pub(super) fn schema() -> Schema {
Schema::parse(SCHEMA).unwrap()
}
fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
pub(super) fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
Kv::Object(fields)
}
fn f(x: f64) -> Kv<'static> {
pub(super) fn f(x: f64) -> Kv<'static> {
Kv::Float(x as f32)
}
fn u(x: u64) -> Kv<'static> {
pub(super) fn u(x: u64) -> Kv<'static> {
Kv::Uint(x)
}
fn e(n: &'static str) -> Kv<'static> {
pub(super) fn e(n: &'static str) -> Kv<'static> {
Kv::Enum(n)
}
fn s(x: &str) -> Kv<'static> {
pub(super) fn s(x: impl Into<String>) -> Kv<'static> {
Kv::Str(x.into())
}
fn g(l: u64) -> Kv<'static> {
pub(super) fn g(l: u64) -> Kv<'static> {
o(vec![("sessionID", u(1)), ("localID", u(l))])
}
fn arr(v: Vec<Kv<'static>>) -> Kv<'static> {
pub(super) fn arr(v: Vec<Kv<'static>>) -> Kv<'static> {
Kv::Array(v)
}
fn xy(x: f64, y: f64) -> Kv<'static> {
pub(super) fn xy(x: f64, y: f64) -> Kv<'static> {
o(vec![("x", f(x)), ("y", f(y))])
}
fn at(x: f64, y: f64) -> Kv<'static> {
pub(super) fn at(x: f64, y: f64) -> Kv<'static> {
o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))])
}
fn rgb(r: f64, gr: f64, b: f64) -> Kv<'static> {
o(vec![("r", f(r)), ("g", f(gr)), ("b", f(b)), ("a", f(1.0))])
}
fn solid(r: f64, gr: f64, b: f64) -> Kv<'static> {
pub(super) fn solid(r: f64, gr: f64, b: f64) -> Kv<'static> {
o(vec![("type", e("SOLID")), ("color", rgb(r, gr, b)), ("opacity", f(1.0)), ("visible", Kv::Bool(true))])
}
fn root_guid() -> Kv<'static> {
@@ -128,7 +128,7 @@ fn prop_ref(def: u64, field: &'static str) -> Kv<'static> {
}
/// A node: id (0: the document), parent (id, position), type, name and further fields.
fn node(id: u64, par: Option<(u64, &str)>, ty: &'static str, name: &str, mut more: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
pub(super) fn node(id: u64, par: Option<(u64, &str)>, ty: &'static str, name: &str, mut more: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> {
let mut v = vec![("guid", if id == 0 { root_guid() } else { g(id) })];
if let Some((p, pos)) = par {
v.push(("parentIndex", o(vec![("guid", if p == 0 { root_guid() } else { g(p) }), ("position", s(pos))])));
@@ -795,6 +795,233 @@ fn hashes_naming_one_blob_share_one_copy_of_it() {
assert_eq!(*copies[&"63".repeat(20)], [7; 16]);
}
mod image_budget {
use super::*;
fn image(hash: u8) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([hash, 0, 0, 255]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
fn file(references: &[u8], members: &[(u8, Vec<u8>)]) -> Vec<u8> {
let mut nodes = vec![node(0, None, "DOCUMENT", "Document", vec![]), node(1, Some((0, "!")), "CANVAS", "Page", vec![])];
for (i, hash) in references.iter().enumerate() {
nodes.push(node(
i as u64 + 2,
Some((1, &format!("{i:04}"))),
"RECTANGLE",
&format!("Image {i}"),
vec![
("size", xy(10.0, 10.0)),
("fillPaints", arr(vec![o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(vec![*hash; 20]))]))])])),
],
));
}
let canvas = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))]));
let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
z.start_file("canvas.fig", deflated).unwrap();
z.write_all(&canvas).unwrap();
for (hash, bytes) in members {
z.start_file(format!("images/{}", format!("{hash:02x}").repeat(20)), deflated).unwrap();
z.write_all(bytes).unwrap();
}
z.finish().unwrap().into_inner()
}
fn bytes(doc: &Document, index: usize) -> &[u8] {
let node = named(doc, &format!("Image {index}"));
let figcraft_doc::color::PaintKind::Image(fill) = &node.fills[0].kind else { panic!("not an image fill") };
&doc.images[&fill.image].bytes
}
fn budget_warnings(warnings: &[String]) -> Vec<&str> {
warnings.iter().filter(|w| w.contains("total ZIP image byte budget")).map(String::as_str).collect()
}
fn change_member_field(zip: &mut [u8], hash: u8, offset: usize, value: u32) {
let start = {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&*zip)).unwrap();
z.by_name(&format!("images/{}", format!("{hash:02x}").repeat(20))).unwrap().central_header_start() as usize
};
zip[start + offset..start + offset + 4].copy_from_slice(&value.to_le_bytes());
}
#[test]
fn exact_total_limit_keeps_all_images_and_each_import_gets_its_own_budget() {
let members = [(1, image(1)), (2, image(2))];
let limit = members.iter().map(|(_, b)| b.len() as u64).sum();
let zip = file(&[1, 2], &members);
for _ in 0..2 {
let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap();
assert_eq!(bytes(&doc, 0), members[0].1);
assert_eq!(bytes(&doc, 1), members[1].1);
assert!(warnings.is_empty(), "{warnings:?}");
}
}
#[test]
fn individually_acceptable_images_overflow_the_total_and_leave_placeholders() {
let members = [(1, image(1)), (2, image(2))];
let limit = members.iter().map(|(_, b)| b.len() as u64).sum::<u64>() - 1;
assert!(members.iter().all(|(_, b)| b.len() as u64 <= limit));
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap();
assert_eq!(bytes(&doc, 0), members[0].1);
assert_ne!(bytes(&doc, 1), members[1].1);
assert!(!doc.images.contains_key(&crate::image_hash(&members[1].1)));
let notes = budget_warnings(&warnings);
assert_eq!(notes.len(), 1, "{warnings:?}");
assert!(notes[0].contains("1 ZIP image") && notes[0].contains(&limit.to_string()), "{warnings:?}");
}
#[test]
fn repeated_references_do_not_charge_or_count_a_rejection_twice() {
let members = [(1, image(1)), (2, image(2))];
let (doc, warnings) = import_with_image_limit(&file(&[1, 1, 2, 2, 1], &members), "Images", members[0].1.len() as u64).unwrap();
for i in [0, 1, 4] {
assert_eq!(bytes(&doc, i), members[0].1);
}
assert_eq!(bytes(&doc, 2), bytes(&doc, 3));
assert_ne!(bytes(&doc, 2), members[1].1);
let notes = budget_warnings(&warnings);
assert_eq!(notes.len(), 1, "{warnings:?}");
assert!(notes[0].contains("1 ZIP image"), "{warnings:?}");
}
#[test]
fn distinct_members_with_identical_contents_still_use_the_read_budget() {
let png = image(1);
let members = [(1, png.clone()), (2, png.clone())];
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap();
assert_eq!(bytes(&doc, 0), png);
assert_ne!(bytes(&doc, 1), png);
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
}
#[test]
fn rejecting_a_member_before_reading_leaves_room_for_a_smaller_one() {
let png = image(1);
let members = [(1, vec![0; png.len() + 1]), (2, png.clone())];
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap();
assert_ne!(bytes(&doc, 0), members[0].1);
assert_eq!(bytes(&doc, 1), png);
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
}
#[test]
fn failed_crc_reads_still_consume_the_bytes_read() {
let members = [(1, image(1)), (2, image(2))];
let mut zip = file(&[1, 2], &members);
change_member_field(&mut zip, 1, 16, 0);
let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64 + 1).unwrap();
assert_ne!(bytes(&doc, 0), members[0].1);
assert_ne!(bytes(&doc, 1), members[1].1);
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
}
#[test]
fn a_truncated_deflate_read_keeps_its_partial_charge() {
let members = [(1, image(1)), (2, image(2))];
let mut zip = file(&[1, 2], &members);
// Shorten the first member's compressed size, cutting its deflate stream mid-image.
change_member_field(&mut zip, 1, 20, 15);
let limit = members[0].1.len() as u64;
let archive = Archive::open(&zip).unwrap();
archive.image_budget.remaining.set(limit);
assert!(archive.image(&"01".repeat(20)).is_none());
let remaining = archive.image_budget.remaining.get();
assert!(remaining > 0 && remaining < limit, "partial read left {remaining} of {limit} bytes");
assert!(archive.image(&"02".repeat(20)).is_none());
assert_eq!(archive.image_budget.remaining.get(), remaining);
let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap();
assert_ne!(bytes(&doc, 1), members[1].1);
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
}
#[test]
fn the_individual_image_cap_is_preserved_without_reading_a_large_member() {
let members = [(1, image(1)), (2, image(2))];
let mut zip = file(&[1, 2], &members);
change_member_field(&mut zip, 1, 24, (MAX_IMAGE + 1) as u32);
let (doc, warnings) = import_with_image_limit(&zip, "Images", members[1].1.len() as u64).unwrap();
assert_ne!(bytes(&doc, 0), members[0].1);
assert_eq!(bytes(&doc, 1), members[1].1);
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
}
#[test]
fn crc_is_checked_even_when_the_read_ends_exactly_at_the_budget() {
let members = [(1, image(1))];
let mut zip = file(&[1], &members);
change_member_field(&mut zip, 1, 16, 0);
let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64).unwrap();
assert_ne!(bytes(&doc, 0), members[0].1);
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
}
#[test]
fn an_understated_size_cannot_read_past_the_remaining_budget() {
let members = [(1, image(1)), (2, image(2))];
let mut zip = file(&[1, 2], &members);
change_member_field(&mut zip, 1, 24, 1);
let archive = Archive::open(&zip).unwrap();
archive.image_budget.remaining.set(members[0].1.len() as u64 - 1);
assert!(archive.image(&"01".repeat(20)).is_none());
assert_eq!(archive.image_budget.remaining.get(), 0);
assert_eq!(archive.image_budget.skipped.get(), 1);
assert!(archive.image(&"02".repeat(20)).is_none());
assert_eq!(archive.image_budget.remaining.get(), 0);
assert_eq!(archive.image_budget.skipped.get(), 2);
}
#[test]
fn invalid_image_contents_are_charged_before_image_validation() {
let members = [(1, vec![0; 8]), (2, image(2))];
let limit = members[1].1.len() as u64 + 7;
let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap();
assert_ne!(bytes(&doc, 1), members[1].1);
assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}");
}
#[test]
fn missing_and_unreferenced_members_do_not_consume_the_budget() {
let members = [(1, image(1)), (2, vec![0; 128])];
let (doc, warnings) = import_with_image_limit(&file(&[9, 1], &members), "Images", members[0].1.len() as u64).unwrap();
assert_eq!(bytes(&doc, 1), members[0].1);
assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
}
#[test]
fn embedded_image_fallback_is_independent_of_the_zip_budget() {
let canvas = canvas(&schema(), &message_with_image_in_blobs(0));
for bytes in [&canvas, &zip_file(&canvas)] {
let (doc, warnings) = import_with_image_limit(bytes, "Images", 0).unwrap();
let photo = named(&doc, "Photo");
let figcraft_doc::color::PaintKind::Image(fill) = &photo.fills[0].kind else { panic!("not an image fill") };
assert_eq!(*doc.images[&fill.image].bytes, png());
assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}");
assert_eq!(budget_warnings(&warnings).len(), usize::from(bytes.starts_with(b"PK")), "{warnings:?}");
}
}
#[test]
fn default_import_preserves_image_bytes_dimensions_and_existing_warnings() {
let members = [(1, image(1)), (2, image(2))];
let (doc, warnings) = import(&file(&[1, 2], &members), "Images").unwrap();
assert_eq!(bytes(&doc, 0), members[0].1);
assert_eq!(bytes(&doc, 1), members[1].1);
assert!(doc.images.values().all(|img| (img.width, img.height) == (2, 1)));
assert!(warnings.is_empty(), "{warnings:?}");
let (doc, warnings) = import(&fixture(), "Fixture").unwrap();
assert!(doc.images.values().any(|img| *img.bytes == png() && (img.width, img.height) == (2, 1)));
assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}");
}
}
#[test]
fn other_files_are_not_fig() {
assert!(try_import(b"{\"document\": {}}", "x").is_none());
@@ -803,10 +1030,10 @@ fn other_files_are_not_fig() {
assert!(try_import(&native, "x").is_none());
}
/// A cheap deterministic generator for the mangle tests.
struct Lcg(u64);
/// A cheap deterministic generator (mangled inputs, generated fixtures).
pub(super) struct Lcg(pub(super) u64);
impl Lcg {
fn next(&mut self) -> u64 {
pub(super) fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
self.0 >> 33
}
@@ -889,6 +1116,14 @@ fn large_files_get_a_larger_value_budget() {
assert!(kiwi::value_budget(39_528_420) > 29_703_730);
// Still bounded by the largest canvas read.
assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20);
// Counting every scalar field, a dense file may reach about 2.4 values per compressed byte (the
// 15 MB text-heavy fixture in `value_budget_tests` measures 1.8): below the ceiling the budget must
// cover that, the floor still holds up to 5 MB of canvas, and 32 MiB is enough for the ceiling.
assert!(kiwi::value_budget(8 << 20) >= (8 << 20) * 12 / 5);
#[cfg(not(target_pointer_width = "32"))]
assert!(kiwi::value_budget(30_000_000) >= 30_000_000 * 12 / 5);
assert_eq!(kiwi::value_budget(5_000_000), 20_000_000);
assert_eq!(kiwi::value_budget(32 << 20), kiwi::value_budget(MAX_CANVAS as usize));
}
#[test]
+165
View File
@@ -0,0 +1,165 @@
//! Dense legitimate canvases must open whole within the value budget their size gives them, and a
//! small canvas that unpacks past the floor (tens of millions of values) must stay refused. The dense ones take seconds and gigabytes:
//! `cargo test -p figcraft-format --release --lib value_budget -- --ignored --test-threads=1`.
use super::kiwi::{Kv, Schema, encode};
use super::tests::{Lcg, arr, at, f, g, node, o, s, schema, solid, u, xy};
use super::{decode_canvas, export, import, kiwi};
fn frac(r: &mut Lcg, hi: f64) -> f64 {
(r.next() % 100_000) as f64 / 100_000.0 * hi
}
fn hex(r: &mut Lcg, nibbles: usize) -> String {
(0..nibbles).map(|_| format!("{:x}", r.next() % 16)).collect()
}
fn word(r: &mut Lcg) -> &'static str {
const WORDS: &[&str] = &[
"frame", "card", "hero", "row", "cell", "icon", "badge", "button", "input", "label", "group", "section", "header", "footer", "nav", "list",
"item", "menu", "panel", "view", "page", "modal", "form", "field", "text", "title", "body", "caption", "avatar", "thumb", "chart", "graph",
"axis", "grid", "slot", "chip", "tag", "pill", "note", "hint", "tip", "alert", "toast", "banner", "strip", "rail", "tabs", "tab", "step",
"flow", "state", "theme", "mode", "token",
];
WORDS[(r.next() as usize) % WORDS.len()]
}
/// Strings and GUIDs with real entropy on every record (a key, a version, a path of GUIDs), so
/// deflate packs the fixture about as tightly as a real file rather than far tighter.
fn identity(r: &mut Lcg, depth: usize) -> Vec<(&'static str, Kv<'static>)> {
let path = (0..depth).map(|_| g(r.next() % (1 << 30))).collect();
vec![
("key", s(format!("{}:{}{}", hex(r, 4), hex(r, 4), r.next() % 997))),
("version", s(format!("{}:0", hex(r, 6)))),
("guidPath", o(vec![("guids", arr(path))])),
]
}
/// Somewhere on a page the size of a large design file.
fn placed(r: &mut Lcg) -> (&'static str, Kv<'static>) {
("transform", at(frac(r, 12_000.0), frac(r, 8_000.0)))
}
fn rect(id: u64, page: u64, r: &mut Lcg) -> Kv<'static> {
let name = format!("{} {} {}", word(r), word(r), r.next() % 9_999);
let mut fields = vec![
("size", xy(24.0 + frac(r, 300.0), 16.0 + frac(r, 200.0))),
placed(r),
("fillPaints", arr(vec![solid(frac(r, 1.0), frac(r, 1.0), frac(r, 1.0))])),
("strokeWeight", f(1.0 + frac(r, 3.0))),
("cornerRadius", f(frac(r, 24.0))),
("opacity", f(0.5 + frac(r, 0.5))),
("visible", Kv::Bool(true)),
];
fields.extend(identity(r, 3));
node(id, Some((page, "A")), "RECTANGLE", &name, fields)
}
/// Text saved with per-character styles: its characters and one style id per character.
fn text(id: u64, page: u64, chars: usize, r: &mut Lcg) -> Kv<'static> {
let mut characters = String::new();
while characters.len() < chars {
characters.push_str(word(r));
characters.push_str(if r.next().is_multiple_of(5) { ". " } else { " " });
}
characters.truncate(chars);
let styles = arr((0..characters.chars().count()).map(|_| u(0)).collect());
let mut fields = vec![
("size", xy(320.0, 18.0)),
placed(r),
("fontSize", f(11.0 + frac(r, 26.0))),
("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])),
("fillPaints", arr(vec![solid(0.1, 0.1, 0.1)])),
("textData", o(vec![("characters", s(characters)), ("characterStyleIDs", styles)])),
];
fields.extend(identity(r, 4));
node(id, Some((page, "A")), "TEXT", "Body", fields)
}
/// A canvas whose message writes one list field (field 1) of `count` already-encoded elements.
fn one_list_canvas(sc: &Schema, count: u32, elements: impl IntoIterator<Item = u8>) -> Vec<u8> {
let mut head = encode::Writer::default();
head.var_uint(1);
head.var_uint(count);
let mut msg = head.0;
msg.extend(elements);
msg.push(0);
export::canvas(&encode::schema(sc), &msg).unwrap()
}
/// `pages` pages of `rects` rectangles and `texts` texts of `chars` characters, framed the way the
/// exporter frames a file.
fn canvas(pages: usize, rects: usize, texts: usize, chars: usize) -> Vec<u8> {
let sc = schema();
let nc = sc.find("NodeChange").unwrap();
let mut r = Lcg(0x5eed_1234_abcd_9999);
// Layers are encoded one at a time into the one field written (`nodeChanges`): one Kv tree of
// the whole message would take gigabytes of its own.
let mut layers = Vec::new();
let mut count = 0u32;
let mut add = |n: Kv<'static>| {
layers.extend(encode::message(&sc, nc, &n).unwrap());
count += 1;
};
add(node(0, None, "DOCUMENT", "Fixture", vec![]));
let mut id = 10;
for p in 0..pages {
let page = id;
id += 1;
add(node(page, Some((0, "A")), "CANVAS", &format!("Page {p}"), vec![]));
for _ in 0..rects {
add(rect(id, page, &mut r));
id += 1;
}
for _ in 0..texts {
add(text(id, page, chars, &mut r));
id += 1;
}
}
one_list_canvas(&sc, count, layers)
}
/// Every layer comes in and nothing is reported — and the fixture makes its point: one value per
/// compressed byte refuses it, and it decodes within 90% of its budget. Returns the canvas size.
fn opens_whole(label: &str, pages: usize, rects: usize, texts: usize, chars: usize) -> usize {
let file = canvas(pages, rects, texts, chars);
let (sc, msg) = decode_canvas(&file).unwrap();
let root = sc.find("Message").unwrap();
let one_per_byte = file.len().clamp(kiwi::value_budget(0), kiwi::value_budget(usize::MAX));
let refused = sc.decode_message_within(root, &msg, one_per_byte).is_err_and(|e| e.0 == "message: too many values");
assert!(refused, "{label}: one value per compressed byte no longer refuses it");
let (doc, warnings) = import(&file, label).unwrap_or_else(|e| panic!("{label}: refused: {e}"));
assert_eq!(doc.node_count(), pages * (rects + texts) + pages, "{label}: layers");
assert!(warnings.is_empty(), "{label}: {warnings:?}");
println!("{label}: {} canvas bytes, {} layers", file.len(), doc.node_count());
drop(doc);
assert!(sc.decode_message_within(root, &msg, kiwi::value_budget(file.len()) / 10 * 9).is_ok(), "{label}: not within 90% of its budget");
file.len()
}
#[test]
#[ignore = "heavy: --release --ignored, about 4.5 GB"]
fn dense_canvases_open_whole() {
opens_whole("21 MB design-shape canvas", 44, 3000, 2000, 140);
opens_whole("15 MB text-heavy canvas", 12, 4500, 4500, 400);
}
/// From 32 MiB of canvas the budget is the ceiling. Its 83 million values are past the 32-bit
/// ceiling, so this one is for 64-bit targets.
#[cfg(not(target_pointer_width = "32"))]
#[test]
#[ignore = "heavy: --release --ignored, about 9 GB"]
fn a_canvas_budgeted_at_the_ceiling_opens_whole() {
let packed = opens_whole("50 MB text-heavy canvas", 106, 0, 5000, 100);
assert_eq!(kiwi::value_budget(packed), kiwi::value_budget(usize::MAX), "budgeted at the ceiling");
}
/// However far a small canvas inflates, the floor bounds what it may decode.
#[test]
fn a_small_canvas_that_unpacks_to_millions_of_values_is_refused() {
let n = 30_000_000;
let file = one_list_canvas(&Schema::parse("message Message uint[] xs").unwrap(), n, std::iter::repeat_n(0, n as usize));
assert!(file.len() < 100_000, "{} bytes", file.len());
let Err(e) = import(&file, "x") else { panic!("a {} byte canvas holding {n} values opened", file.len()) };
assert_eq!(e.to_string(), "not a valid FigCraft file: .fig file: message: too many values");
}
+3
View File
@@ -56,6 +56,9 @@ const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024;
const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024;
/// Most versions a file keeps: saving more is refused, extra entries in a file are skipped.
pub const MAX_VERSIONS: usize = 100;
/// The export warning for colour calculations that resolve to nothing (an operand missing, or more
/// than 32 variables to read), shared by the Penpot and `.fig` exports.
pub(crate) const CALCULATIONS_LEFT_OUT: &str = "colour calculations that don't resolve were left out";
/// Longest version name and description kept (chars).
pub const MAX_VERSION_NAME: usize = 200;
pub const MAX_VERSION_DESCRIPTION: usize = 2000;
+18 -3
View File
@@ -1657,7 +1657,7 @@ impl<'a> Exporter<'a> {
if let Some(name) = self.token_names.get(variable) {
return Some(name.clone());
}
let var = self.doc.variables.get(variable)?;
let var = self.doc.variables.get_by_id(variable)?;
let name = if variable.starts_with("penpot:tokens:") {
var.name.split_once('/').map_or(var.name.clone(), |(_, n)| n.to_owned())
} else {
@@ -1701,6 +1701,11 @@ impl<'a> Exporter<'a> {
(!out.is_empty()).then_some(Value::Object(out))
}
/// The archive's `tokens.json`: the file's own Penpot tokens when it came from Penpot and its
/// variables are unchanged since, else one token set per collection (per mode, with a theme
/// for each, when it has several). Aliases go out as `{name}` references and colour
/// calculations as the colour they show; a token that resolves to nothing is left out with a
/// warning, so no reference may name it.
fn tokens_json(&mut self) -> Option<String> {
let first = self.doc.pages.first().and_then(|p| self.node(*p));
let source = first.and_then(|p| p.plugin_data.iter().find(|(k, _)| k.starts_with("penpot.tokens.")).map(|(_, v)| v.clone()));
@@ -1715,6 +1720,7 @@ impl<'a> Exporter<'a> {
if variables.variables.is_empty() {
return None;
}
let flattener = variables.flattener();
let mut root = Map::new();
let mut themes = Vec::new();
let mut order = Vec::new();
@@ -1726,8 +1732,15 @@ impl<'a> Exporter<'a> {
let set_name = if many { format!("{}/{}", collection.name, mode.name) } else { collection.name.clone() };
let mut tokens = Map::new();
for var in variables.variables.iter().filter(|v| v.collection == collection.id) {
let Some(value) = var.values.get(&mode.id).or_else(|| var.values.values().next()) else { continue };
let (kind, value) = match value {
let Some(value) = var.value_or_first(&mode.id) else { continue };
// Penpot tokens have no shape for colour calculations: the colour they resolve
// to in this mode goes out as a plain colour token, and so does an alias to one
// some set leaves out (its `{name}` would dangle whenever that set is active).
let Some(value) = flattener.value(var, &mode.id, value) else {
self.warn(crate::CALCULATIONS_LEFT_OUT);
continue;
};
let (kind, value) = match &value {
VariableValue::Alias(target) => {
let Some(target_name) = self.token_name(target) else { continue };
let kind = match var.kind {
@@ -1742,6 +1755,8 @@ impl<'a> Exporter<'a> {
let [r, g, b, a] = c.to_rgba8();
("color", json!(if a == 255 { format!("#{r:02x}{g:02x}{b:02x}") } else { format!("#{r:02x}{g:02x}{b:02x}{a:02x}") }))
}
// `flattened` leaves none: this format has no shape for a calculation.
VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => continue,
VariableValue::Number(n) => ("number", json!(num_text(*n))),
VariableValue::String(s) => ("string", json!(s)),
VariableValue::Boolean(b) => ("boolean", json!(b.to_string())),
+104 -12
View File
@@ -5,8 +5,9 @@ use figcraft_doc::color::{Color, ColorStop, Gradient, GradientKind, ImageScaleMo
use figcraft_doc::geom::{Affine, Point, Shape};
use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, OverlayPosition, Transition, Trigger};
use figcraft_doc::{
AutoLayout, Blur, Collection, CrossAlign, Document, Effect, EffectKind, GridTrack, LayoutMode, LineHeight, MainAlign, Mode, NodeId, NodeKind,
Shadow, Sizing, StrokeAlign, StrokeCap, StyleRun, TextAlignH, TextAutoResize, TextData, Variable, VariableType, VariableValue, VectorNetwork,
AutoLayout, Blur, Collection, ColorOperand, CrossAlign, Document, Effect, EffectKind, GridTrack, LayoutMode, LineHeight, MainAlign, Mode, NodeId,
NodeKind, Shadow, Sizing, StrokeAlign, StrokeCap, StyleRun, TextAlignH, TextAutoResize, TextData, Variable, VariableType, VariableValue,
VectorNetwork,
};
use serde_json::{Value, json};
@@ -523,15 +524,25 @@ fn variable(id: &str, name: &str, collection: &str, kind: VariableType, values:
}
}
#[test]
fn native_variables_become_token_sets_themes_and_applied_tokens() {
let mut doc = Document::new("Tokens");
doc.variables.collections.push(Collection {
/// A "Color" collection `c1` with Light and Dark modes.
fn light_dark_colors() -> Collection {
Collection {
id: "c1".into(),
name: "Color".into(),
modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }],
extends: None,
});
}
}
/// The export's `tokens.json`.
fn tokens(bytes: &[u8]) -> Value {
entries(bytes).into_iter().find(|(name, _)| name.ends_with("tokens.json")).map(|(_, v)| v).unwrap()
}
#[test]
fn native_variables_become_token_sets_themes_and_applied_tokens() {
let mut doc = Document::new("Tokens");
doc.variables.collections.push(light_dark_colors());
doc.variables.collections.push(Collection {
id: "c2".into(),
name: "Size".into(),
@@ -560,11 +571,7 @@ fn native_variables_become_token_sets_themes_and_applied_tokens() {
doc.node_mut(id).unwrap().bound.insert("width".into(), "v2".into());
let (bytes, _) = export(&doc).unwrap();
let mut z = zip::ZipArchive::new(Cursor::new(&bytes[..])).unwrap();
let tokens_path = z.file_names().find(|n| n.ends_with("tokens.json")).unwrap().to_owned();
let mut text = String::new();
z.by_name(&tokens_path).unwrap().read_to_string(&mut text).unwrap();
let tokens: Value = serde_json::from_str(&text).unwrap();
let tokens = tokens(&bytes);
assert_eq!(tokens["Color/Light"]["brand"]["primary"]["$value"], "#ff0000");
assert_eq!(tokens["Color/Dark"]["brand"]["primary"]["$value"], "#880000");
assert_eq!(tokens["Color/Light"]["brand"]["alias"]["$value"], "{brand.primary}");
@@ -580,6 +587,67 @@ fn native_variables_become_token_sets_themes_and_applied_tokens() {
assert!(bound.bound.contains_key("width"));
}
/// Penpot tokens have no shape for a colour calculation: each mode's token is the colour it shows,
/// and one that resolves to nothing is left out with a warning. No reference names a token a set
/// leaves out: an alias to one goes out as the colour it shows, or is left out with it.
#[test]
fn colour_calculations_export_as_the_colour_they_show() {
let mut doc = Document::new("Calculated");
doc.variables.collections.push(light_dark_colors());
let hex = |h: &str| Color::from_hex(h).unwrap();
doc.variables.variables.push(variable(
"v1",
"brand/primary",
"c1",
VariableType::Color,
&[("light", VariableValue::Color(hex("#ff0000"))), ("dark", VariableValue::Color(hex("#0000ff")))],
));
let mix = VariableValue::ColorMix {
from: ColorOperand::Variable { variable: "v1".into() },
to: ColorOperand::Literal { literal: hex("#00ff0080") },
t: 0.5,
};
doc.variables.variables.push(variable(
"v2",
"brand/veil",
"c1",
VariableType::Color,
&[("light", VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 }), ("dark", mix)],
));
let gone = VariableValue::ColorAlpha { variable: "v-gone".into(), opacity: 0.5 };
doc.variables.variables.push(variable("v3", "brand/lost", "c1", VariableType::Color, &[("light", gone.clone()), ("dark", gone.clone())]));
// Aliases: to the lost one (same collection), to one lost only in Dark (from a collection with
// one mode, which reads Color in its default mode, Light), to an alias that is left out, and to
// one that always resolves.
let alias = |to: &str| VariableValue::Alias(to.into());
doc.variables.variables.push(variable("v4", "brand/lost-too", "c1", VariableType::Color, &[("light", alias("v3")), ("dark", alias("v3"))]));
let half = VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 };
doc.variables.variables.push(variable("v5", "brand/half", "c1", VariableType::Color, &[("light", half), ("dark", gone)]));
doc.variables.collections.push(Collection {
id: "c2".into(),
name: "Theme".into(),
modes: vec![Mode { id: "one".into(), name: "One".into() }],
extends: None,
});
doc.variables.variables.push(variable("v6", "theme/accent", "c2", VariableType::Color, &[("one", alias("v5"))]));
doc.variables.variables.push(variable("v7", "theme/veil", "c2", VariableType::Color, &[("one", alias("v2"))]));
doc.variables.variables.push(variable("v8", "theme/deep", "c2", VariableType::Color, &[("one", alias("v4"))]));
let (bytes, warnings) = export(&doc).unwrap();
assert!(warnings.iter().any(|w| w == crate::CALCULATIONS_LEFT_OUT), "{warnings:?}");
let tokens = tokens(&bytes);
assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost").is_none()), "left out, not written");
assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost-too").is_none()), "an alias to it too");
assert!(tokens["Color/Dark"]["brand"].get("half").is_none() && tokens["Color/Light"]["brand"]["half"]["$value"] == "#ff000080");
assert_eq!(tokens["Theme"]["theme"]["accent"]["$value"], "#ff000080", "the colour it shows, not {{brand.half}}");
assert_eq!(tokens["Theme"]["theme"]["veil"]["$value"], "{brand.veil}", "a reference that always resolves stays one");
assert!(tokens["Theme"]["theme"].get("deep").is_none(), "an alias to an alias that is left out is left out too");
let text = tokens.to_string();
assert!(!text.contains("{brand.lost") && !text.contains("{brand.half}"), "no reference to a token a set leaves out: {text}");
assert_eq!(tokens["Color/Light"]["brand"]["veil"]["$value"], "#ff000080");
assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$value"], "#008080c0");
assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$type"], "color");
}
#[test]
fn imported_penpot_tokens_are_written_back_verbatim() {
let mut doc = Document::new("Kept tokens");
@@ -909,3 +977,27 @@ fn page_guides_reloaded_from_a_figcraft_file_are_exported() {
let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1;
assert_eq!(page_json["guides"].as_object().unwrap().values().next().unwrap()["position"], 410);
}
#[test]
fn stored_variable_ids_keep_penpot_token_identity() {
for missing in [false, true] {
let mut doc = Document::new("Token identity");
doc.variables.collections.push(Collection {
id: "c".into(),
name: "Theme".into(),
modes: vec![Mode { id: "m".into(), name: "Default".into() }],
extends: None,
});
doc.variables.variables.push(variable("shadow", "target", "c", VariableType::Number, &[("m", VariableValue::Number(20.0))]));
if !missing {
doc.variables.variables.push(variable("target", "surface", "c", VariableType::Number, &[("m", VariableValue::Number(40.0))]));
}
let p = page(&doc);
let r = add(&mut doc, p, NodeKind::Rectangle, "Bound", 0.0, 0.0, 40.0, 10.0);
doc.node_mut(r).unwrap().bound.insert("width".into(), "target".into());
let exported = entries(&export(&doc).unwrap().0);
let width = &shape(&exported, "Bound")["appliedTokens"]["width"];
let expected = if missing { Value::Null } else { json!("surface") };
assert_eq!(width, &expected);
}
}
+43 -13
View File
@@ -7,7 +7,7 @@
//! corners, constraints, auto layout, layout grids, export settings, text with styled runs,
//! vectors from `fillGeometry` / `strokeGeometry`, components, component sets, instances and
//! 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).
//! image key and show a missing-image 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
@@ -1003,7 +1003,7 @@ impl<'a> Ctx<'a> {
}
/// The document image key for an `imageRef`: its bytes when the resolver has them, else a
/// grey placeholder stored under the `imageRef` itself when that name can legitimately be an
/// missing-image placeholder stored under the `imageRef` itself when that name can legitimately be an
/// archive member (`is_valid_key`) and claims no content hash (`claims_content_hash`: the
/// placeholder's bytes don't hash to it); any other name shares the `rest-image-missing`
/// placeholder.
@@ -1016,6 +1016,11 @@ impl<'a> Ctx<'a> {
Some((hash, _, _)) => hash,
None => {
self.missing_images += 1;
// A design file's (or a paste's) image fill names its image by hash: one note per
// fill, counted, read the same in every language.
if self.fig_sources.is_some() {
self.warn("image fills whose image isn't in the file show a missing-image placeholder and keep its name");
}
// An `imageRef` arrives from an untrusted file, and keys become archive member
// names on save: a name that can't legitimately be one must not reach the map,
// nor one claiming a content hash the placeholder's bytes don't have.
@@ -1024,9 +1029,11 @@ impl<'a> Ctx<'a> {
let key = if named { candidate } else { MISSING_IMAGE };
let key = key.to_string();
if !self.doc.images.contains_key(&key)
&& let Some(png) = placeholder_png()
&& let Some(png) = missing_image_placeholder()
{
self.doc.images.insert(key.clone(), ImageData { bytes: Arc::new(png), width: PLACEHOLDER_SIDE, height: PLACEHOLDER_SIDE });
self.doc
.images
.insert(key.clone(), ImageData { bytes: Arc::new(png.to_vec()), width: PLACEHOLDER_SIDE, height: PLACEHOLDER_SIDE });
}
key
}
@@ -1046,6 +1053,8 @@ impl<'a> Ctx<'a> {
/// Cross-references that need every layer imported first.
fn finish(&mut self) {
// A `.fig` import (its sources are taken below) noted each missing image already.
let fig = self.fig_sources.is_some();
self.resolve_swap_defaults();
// Variants share their set's property definitions.
for (set, defs) in std::mem::take(&mut self.set_defs) {
@@ -1079,11 +1088,11 @@ impl<'a> Ctx<'a> {
flows.push(Flow { name, start: id, description: String::new() });
}
}
if self.missing_images > 0 {
if self.missing_images > 0 && !fig {
let n = self.missing_images;
let (s, verb) = if n == 1 { ("", "shows") } else { ("s", "show") };
self.warn(format!(
"{n} image{s} without image data {verb} a grey placeholder, named by its imageRef when that can legitimately name a file and by the shared name otherwise (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
"{n} image{s} without image data {verb} a missing-image placeholder, named by its imageRef when that can legitimately name a file and by the shared name otherwise (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)"
));
}
let unused = self.style_meta.keys().filter(|k| !self.doc.styles.iter().any(|s| s.id == **k)).count();
@@ -1465,12 +1474,33 @@ fn sane(p: &BezPath) -> bool {
}
/// Side of the placeholder image (large enough that scaling it up stays flat).
const PLACEHOLDER_SIDE: u32 = 64;
const PLACEHOLDER_SIDE: u32 = 256;
/// A light-grey PNG standing in for image fills whose bytes aren't available.
fn placeholder_png() -> Option<Vec<u8>> {
let img = image::RgbaImage::from_pixel(PLACEHOLDER_SIDE, PLACEHOLDER_SIDE, image::Rgba([0xD9, 0xD9, 0xD9, 0xFF]));
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
/// The PNG standing in for image fills whose bytes aren't available (#1646): light grey crossed
/// by darker diagonal stripes, so it reads as an image that is missing, not as a grey the design
/// chose. Made once; [`is_missing_image`] recognises it.
pub fn missing_image_placeholder() -> Option<&'static [u8]> {
static PNG: std::sync::OnceLock<Option<Vec<u8>>> = std::sync::OnceLock::new();
PNG.get_or_init(|| {
// Stripes 6 px wide every 18 px across the diagonal, their edges anti-aliased, so they
// stay clean when a fill scales the picture up.
let (period, width) = (18.0f32, 6.0f32);
let (ground, stripe) = ([0xE6u8, 0xE6, 0xE6], [0xB4u8, 0xB4, 0xB4]);
let img = image::RgbaImage::from_fn(PLACEHOLDER_SIDE, PLACEHOLDER_SIDE, |x, y| {
let t = ((x + y) as f32 + 1.0) / std::f32::consts::SQRT_2 % period;
let inside = if t < width { t.min(width - t) + 0.5 } else { 0.5 - (t - width).min(period - t) };
let k = inside.clamp(0.0, 1.0);
let mix = |i: usize| (f32::from(ground[i]) + (f32::from(stripe[i]) - f32::from(ground[i])) * k).round() as u8;
image::Rgba([mix(0), mix(1), mix(2), 0xFF])
});
let mut out = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?;
Some(out)
})
.as_deref()
}
/// `data` is the placeholder an import stored for an image it didn't have.
pub fn is_missing_image(data: &ImageData) -> bool {
missing_image_placeholder().is_some_and(|png| data.bytes.as_slice() == png)
}
@@ -248,3 +248,34 @@ fn junk_in_conditional_actions_never_panics() {
}
}
}
#[test]
fn stored_variable_ids_keep_imported_mode_lookup_identity() {
for collection in [false, true] {
let mut vars = variables_json();
if collection {
let mut shadow = vars["meta"]["variableCollections"][THEME].clone();
shadow["id"] = json!("VariableCollectionId:0:0");
shadow["name"] = json!(THEME);
shadow["modes"] = json!([{"modeId": "other", "name": "Other"}]);
vars["meta"]["variableCollections"]["VariableCollectionId:0:0"] = shadow;
} else {
let mut shadow = vars["meta"]["variables"][SPACE].clone();
shadow["id"] = json!("VariableID:0:0");
shadow["name"] = json!(LABEL);
vars["meta"]["variables"]["VariableID:0:0"] = shadow;
}
let lookup = expr("VAR_MODE_LOOKUP", vec![var(LABEL), data("STRING", json!("1:0"))]);
let (doc, warnings) = import_with(&file(vec![on("ON_CLICK", vec![set(LABEL, lookup)])]), Some(&vars), &no_images).unwrap();
let actions = &button(&doc).interactions[0].actions;
assert_eq!(
actions[0],
Action::SetVariable {
variable: LABEL.into(),
value: Value::Null,
expr: Some(Expr::InMode { variable: LABEL.into(), mode: "1:0".into() }),
},
"{warnings:?}"
);
}
}
+3 -3
View File
@@ -355,7 +355,7 @@ impl Ctx<'_> {
Some(VariableValue::Number(n)) => json!(n),
Some(VariableValue::String(s)) => json!(s),
Some(VariableValue::Boolean(b)) => json!(b),
Some(VariableValue::Alias(_)) | None => {
Some(VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. }) | None => {
self.warn("set-variable actions with values that don't fit the variable were skipped");
return None;
}
@@ -442,8 +442,8 @@ impl Ctx<'_> {
};
let vars = &self.doc.variables;
let found = parts.filter(|(id, _)| self.var_kinds.contains_key(*id)).and_then(|(id, mode)| {
let v = vars.get(id)?;
let m = vars.collection(&v.collection)?.modes.iter().find(|m| m.id == mode)?;
let v = vars.get_by_id(id)?;
let m = vars.collection_by_id(&v.collection)?.mode_by_id(mode)?;
Some(Expr::InMode { variable: v.id.clone(), mode: m.id.clone() })
});
if found.is_none() {
+9 -3
View File
@@ -67,7 +67,8 @@ pub(super) fn value(kind: VariableType, v: &Value) -> Option<VariableValue> {
/// `collections` of `vars` (with their variables) as a local variables response, which
/// [`Ctx::variables`] reads back. Aliases to variables outside them are written as the value they
/// resolve to in the default modes.
/// resolve to in the default modes. Colour calculations have no shape in this JSON, so they go out
/// as the colour they resolve to in the mode they are written for.
pub fn export(vars: &Variables, collections: &[&Collection]) -> Value {
let ids: BTreeSet<&str> = collections.iter().map(|c| c.id.as_str()).collect();
let members: Vec<&Variable> = vars.variables.iter().filter(|v| ids.contains(v.collection.as_str())).collect();
@@ -91,7 +92,10 @@ pub fn export(vars: &Variables, collections: &[&Collection]) -> Value {
Some(r) => r,
None => continue,
},
other => other.clone(),
other => match vars.flattened(v, mode, other) {
Some(flat) => flat,
None => continue,
},
};
values.insert(mode.clone(), raw(&val));
}
@@ -112,7 +116,8 @@ pub fn export(vars: &Variables, collections: &[&Collection]) -> Value {
json!({"status": 200, "error": false, "meta": {"variableCollections": colls, "variables": out}})
}
/// A value as the variables JSON writes it (the inverse of [`value`]).
/// A value as the variables JSON writes it (the inverse of [`value`]). The export resolves
/// calculations before calling this, so they have no shape of their own here.
fn raw(v: &VariableValue) -> Value {
match v {
VariableValue::Color(c) => json!({"r": c.r, "g": c.g, "b": c.b, "a": c.a}),
@@ -120,6 +125,7 @@ fn raw(v: &VariableValue) -> Value {
VariableValue::String(s) => json!(s),
VariableValue::Boolean(b) => json!(b),
VariableValue::Alias(a) => json!({"type": "VARIABLE_ALIAS", "id": a}),
VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => Value::Null,
}
}
+4 -2
View File
@@ -252,7 +252,8 @@ impl Headless {
)),
"document.inspect" => {
let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize;
Ok(figcraft_engine::inspect::document(&self.session, depth, b(&p, "allPages")))
let options = figcraft_engine::inspect::InspectOptions::parse(&p)?;
Ok(figcraft_engine::inspect::document_with_options(&self.session, depth, b(&p, "allPages"), &options))
}
"node.get" => {
let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required (a node id)")?;
@@ -261,7 +262,8 @@ impl Headless {
if !doc.contains(id) {
return Err(format!("no such node {id}"));
}
Ok(figcraft_engine::inspect::node_json(doc, id, depth))
let options = figcraft_engine::inspect::InspectOptions::parse(&p)?;
Ok(figcraft_engine::inspect::node_json_with_options(doc, id, depth, &options))
}
"ui.inspect" => Ok(self.inspect_ui()),
"ui.pointer" => self.pointer(&p),
+35 -8
View File
@@ -64,6 +64,15 @@ fn id_schema(desc: &str) -> Value {
fn ids_schema(desc: &str) -> Value {
json!({"type": "array", "description": desc, "items": {"anyOf": [{"type": "integer"}, {"type": "string"}]}})
}
fn fields_schema() -> Value {
json!({"type": "array", "items": {"type": "string", "enum": figcraft_engine::inspect::NODE_KEYS},
"description": "Exact, case-sensitive top-level node keys to include at every level, pages too (e.g. name, type, x, fills, characters, pluginData). Omit for all properties; [] keeps only ids and structure. id, children, childCount and the transport's truncated marker are always kept. Selecting visible emits an explicit boolean for every node. Unknown keys are an error."})
}
fn outline_schema() -> Value {
boolean(
"Compact tree: id, name, type, characters (first 120 characters, then ... only if longer), mainComponentName and explicit visible booleans, plus structure. Cannot combine true with fields; false permits fields.",
)
}
fn obj(props: Value, required: &[&str]) -> Value {
let mut o = json!({"type": "object", "properties": props});
if !required.is_empty() {
@@ -213,6 +222,8 @@ fn tools() -> Vec<Value> {
obj(
json!({
"depth": int("Tree depth for the current page (default 4)"),
"fields": fields_schema(),
"outline": outline_schema(),
"allPages": boolean("Expand every page, not only the current one"),
"maxBytes": int(
"Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id"
@@ -230,6 +241,8 @@ fn tools() -> Vec<Value> {
json!({
"id": id_schema("Layer id"),
"depth": int("Child depth (default 1)"),
"fields": fields_schema(),
"outline": outline_schema(),
"offset": int("First child to report (default 0), for reading a node with more children than fit in one message"),
"maxBytes": int(
"Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id"
@@ -751,19 +764,31 @@ fn wire_size(value: &Value) -> usize {
/// The document at `depth`, and what its text block would take on the wire. The tree is sent as a
/// text block, so it is measured pretty-printed *and* escaped -- escaping adds about a tenth.
fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool) -> Result<(Value, usize), String> {
let value = b.call("document.inspect", json!({"depth": depth, "allPages": all}))?;
fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool, options: &Value) -> Result<(Value, usize), String> {
let mut params = options.clone();
params["depth"] = json!(depth);
params["allPages"] = json!(all);
let value = b.call("document.inspect", params)?;
let wire = wire_size(&value);
Ok((value, wire))
}
/// One layer's subtree at `depth`, and what its text block would take on the wire.
fn node_at(b: &mut dyn Backend, id: u64, depth: u64) -> Result<(Value, usize), String> {
let value = b.call("node.get", json!({"id": id, "depth": depth}))?;
fn node_at(b: &mut dyn Backend, id: u64, depth: u64, options: &Value) -> Result<(Value, usize), String> {
let mut params = options.clone();
params["id"] = json!(id);
params["depth"] = json!(depth);
let value = b.call("node.get", params)?;
let wire = wire_size(&value);
Ok((value, wire))
}
fn inspection_options(a: &Map<String, Value>) -> Result<Value, String> {
let options = Value::Object(a.iter().filter(|(k, _)| matches!(k.as_str(), "fields" | "outline")).map(|(k, v)| (k.clone(), v.clone())).collect());
figcraft_engine::inspect::InspectOptions::parse(&options)?;
Ok(options)
}
fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, String> {
let depth = n_arg(a, "depth")?.unwrap_or(4.0).clamp(0.0, 64.0) as u64;
let all = match a.get("allPages") {
@@ -771,9 +796,10 @@ fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolR
Some(Value::Bool(x)) => *x,
Some(_) => return Err("`allPages` must be a boolean".into()),
};
let options = inspection_options(a)?;
let (max_bytes, budget) = budget(a)?;
let (value, wire) = inspect_at(b, depth, all)?;
let (value, wire) = inspect_at(b, depth, all, &options)?;
if wire <= budget {
return Ok(ToolResult::json(&value));
}
@@ -791,7 +817,7 @@ fn inspect_document(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolR
let mut best: Option<(u64, Value, usize)> = None;
while lo <= hi {
let mid = lo + (hi - lo) / 2;
let (v, w) = inspect_at(b, mid, all)?;
let (v, w) = inspect_at(b, mid, all, &options)?;
if w + MARKER_RESERVE <= budget {
best = Some((mid, v, w));
if mid == hi {
@@ -840,9 +866,10 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
let id = id_arg(a.get("id").ok_or("`id` is required")?)?;
let depth = n_arg(a, "depth")?.unwrap_or(1.0).clamp(0.0, 64.0) as u64;
let offset = n_arg(a, "offset")?.unwrap_or(0.0).max(0.0).min(u32::MAX as f64) as usize;
let options = inspection_options(a)?;
let (max_bytes, budget) = budget(a)?;
let (value, wire) = node_at(b, id, depth)?;
let (value, wire) = node_at(b, id, depth, &options)?;
if value.is_null() {
return Err(format!("no such layer {id}"));
}
@@ -858,7 +885,7 @@ fn get_node(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<ToolResult, S
let (mut lo, mut hi) = (1u64, depth - 1);
while lo <= hi {
let mid = lo + (hi - lo) / 2;
let (v, w) = node_at(b, id, mid)?;
let (v, w) = node_at(b, id, mid, &options)?;
if w + MARKER_RESERVE <= budget {
(used, value, used_wire) = (mid, v, w);
if mid == hi {
+18
View File
@@ -64,6 +64,24 @@ fn attached_backend_forwards_everything() {
assert_eq!(err["isError"], true);
}
#[test]
fn attached_inspection_forwards_compact_options_to_the_app() {
let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap()));
let created = call(&mut s, "create_node", json!({"type": "text", "text": "界".repeat(121), "name": "Title"}));
let id = serde_json::from_str::<Value>(created["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone();
let r = call(&mut s, "get_node", json!({"id": id, "outline": true}));
assert_eq!(r["isError"], false, "{r}");
let outline: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(outline["characters"], format!("{}...", "界".repeat(120)));
assert_eq!(outline["visible"], true);
assert!(outline.get("fontSize").is_none());
let r = call(&mut s, "inspect_document", json!({"fields": [], "outline": false}));
assert_eq!(r["isError"], false, "{r}");
let compact: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(compact["pages"][0]["children"][0], json!({"id": id}));
assert!(compact["pages"][0].get("name").is_none());
}
#[test]
fn an_attached_agent_comments_under_its_own_name() {
let mut s = Server::new(Box::new(Attached::port(fake_app()).unwrap()));
+212
View File
@@ -0,0 +1,212 @@
//! Compact trees are built by the backend, including every size-budget retry.
use crate::common::Client;
use figcraft_mcp::{Backend, Headless, call_tool};
use serde_json::{Value, json};
fn tree(mut create: impl FnMut(Value) -> Value) -> Value {
let root = create(json!({"type": "frame", "name": "Product list", "width": 1200, "height": 2400}));
for i in 0..24 {
let card = create(json!({"type": "frame", "parent": root, "name": format!("Product card {i}"), "width": 280, "height": 160,
"props": {"fill": "#F0F0F0", "cornerRadius": 12, "pluginData": {"source": "synthetic".repeat(40)}}}));
for j in 0..6 {
create(json!({"type": "text", "parent": card, "name": format!("Description {j}"), "text": "Synthetic product description. ".repeat(8),
"x": 12, "y": j * 20, "width": 240}));
}
}
root
}
fn compact_keys(node: &Value, outline: bool) {
let allowed: &[&str] = if outline {
&["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount", "truncated"]
} else {
&["id", "name", "children", "childCount", "truncated"]
};
for key in node.as_object().unwrap().keys() {
assert!(allowed.contains(&key.as_str()), "unexpected {key}: {node}");
}
for child in node["children"].as_array().into_iter().flatten() {
compact_keys(child, outline);
}
}
#[test]
fn tool_schemas_describe_compact_inspection_options() {
let mut c = Client::new();
let tools = c.rpc("tools/list", json!({}));
for name in ["inspect_document", "get_node"] {
let tool = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == name).unwrap();
let props = &tool["inputSchema"]["properties"];
assert_eq!(props["fields"]["type"], "array");
assert_eq!(props["fields"]["items"]["type"], "string");
assert!(props["fields"]["items"]["enum"].as_array().unwrap().contains(&json!("absoluteBounds")));
assert_eq!(props["outline"]["type"], "boolean");
assert!(props["fields"]["description"].as_str().unwrap().contains("case-sensitive"));
assert!(props["outline"]["description"].as_str().unwrap().contains("120"));
}
}
#[test]
fn a_realistic_nested_tree_is_much_smaller_with_fields_or_outline() {
let mut c = Client::new();
let root = tree(|params| c.create(params));
for (tool, mut args) in [("inspect_document", json!({"depth": 6})), ("get_node", json!({"id": root, "depth": 6}))] {
let full = c.ok(tool, args.clone());
let full_bytes = full.to_string().len();
args["outline"] = json!(true);
let outline = c.ok(tool, args.clone());
assert!(outline.to_string().len() * 2 < full_bytes, "{tool}: outline {} vs full {full_bytes}", outline.to_string().len());
args.as_object_mut().unwrap().remove("outline");
args["fields"] = json!([]);
let ids = c.ok(tool, args);
assert!(ids.to_string().len() * 5 < full_bytes, "{tool}: ids {} vs full {full_bytes}", ids.to_string().len());
if tool == "inspect_document" {
assert_eq!(outline["history"], full["history"]);
assert_eq!(ids["selection"], full["selection"]);
compact_keys(&outline["pages"][0], true);
} else {
compact_keys(&outline, true);
}
}
}
struct Recording {
inner: Headless,
calls: Vec<(String, Value)>,
}
impl Backend for Recording {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
self.calls.push((method.into(), params.clone()));
self.inner.call(method, params)
}
fn has_ui(&self) -> bool {
false
}
fn describe(&self) -> String {
"recording headless".into()
}
}
#[test]
fn fields_and_outline_survive_every_depth_retry() {
let mut backend = Recording { inner: Headless::new(), calls: Vec::new() };
let root = tree(|params| {
let result = call_tool(&mut backend, "create_node", &params);
assert!(!result.is_error, "{}", result.first_text());
serde_json::from_str::<Value>(result.first_text()).unwrap()["id"].clone()
});
for options in [json!({"fields": ["name"], "outline": false}), json!({"outline": true})] {
for (tool, method) in [("inspect_document", "document.inspect"), ("get_node", "node.get")] {
backend.calls.clear();
let mut args = options.clone();
if tool == "get_node" {
args["id"] = root.clone();
}
args["depth"] = json!(6);
args["maxBytes"] = json!(6000);
let result = call_tool(&mut backend, tool, &args);
assert!(!result.is_error, "{}", result.first_text());
let value: Value = serde_json::from_str(result.first_text()).unwrap();
assert!(value["truncated"].is_object(), "{tool}: {value}");
assert!(backend.calls.len() > 1, "must exercise retries: {tool}");
for (called, params) in &backend.calls {
assert_eq!(called, method);
for key in ["fields", "outline"] {
assert_eq!(params.get(key), options.get(key), "{tool}: {params}");
}
}
let outline = options["outline"] == true;
if tool == "inspect_document" {
compact_keys(&value["pages"][0], outline);
} else {
compact_keys(&value, outline);
}
}
}
}
#[test]
fn compact_children_keep_window_markers_and_page_in_order() {
let mut c = Client::new();
let root = tree(|params| c.create(params));
let full = c.ok("get_node", json!({"id": root, "depth": 1, "fields": []}));
let expected = full["children"].as_array().unwrap();
let wire = serde_json::to_string(&serde_json::to_string_pretty(&full).unwrap()).unwrap().len();
let limit = wire + 1024 - 1;
assert!(limit > 2048, "room for both the envelope and the marker");
let mut seen = Vec::new();
let mut offset = Some(0);
let mut pages = 0;
while let Some(at) = offset {
let page = c.ok("get_node", json!({"id": root, "depth": 1, "fields": [], "offset": at, "maxBytes": limit}));
assert!(page["truncated"]["children"].is_object(), "{page}");
let wire = serde_json::to_string(&serde_json::to_string_pretty(&page).unwrap()).unwrap().len();
assert!(wire + 1024 <= limit, "page at {at} exceeds maxBytes: {wire} + envelope > {limit}");
assert_eq!(page["truncated"]["children"]["from"], at);
seen.extend(page["children"].as_array().unwrap().iter().cloned());
offset = page["truncated"]["children"]["nextOffset"].as_u64();
pages += 1;
assert!(pages <= expected.len());
}
assert!(pages > 1, "the fixture must require a child window");
assert_eq!(&seen, expected);
}
/// `text` with Unix line ends: a Windows checkout with `core.autocrlf` has the docs with CRLF.
fn lf(text: &str) -> String {
text.replace("\r\n", "\n")
}
fn json_example(section: &str) -> &str {
section.split("```json\n").nth(1).unwrap().split("```").next().unwrap()
}
#[test]
fn compact_examples_run_verbatim_from_the_docs() {
let mut c = Client::new();
for _ in 0..2 {
c.create(json!({"type": "rectangle"}));
}
let docs = lf(include_str!("../../../../docs/mcp.md"));
let section = docs.split("### Compact layer trees\n").nth(1).unwrap();
for line in json_example(section).lines() {
let reply: Value = serde_json::from_str(&c.server.handle_line(line).unwrap()).unwrap();
assert_eq!(reply["result"]["isError"], false, "{reply}");
}
let mut h = Headless::new();
for _ in 0..2 {
h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap();
}
let docs = lf(include_str!("../../../../docs/control-protocol.md"));
let section = docs.split("### `node.get ").nth(1).unwrap();
for line in json_example(section).lines() {
let request: Value = serde_json::from_str(line).unwrap();
h.call(request["method"].as_str().unwrap(), request["params"].clone()).unwrap();
}
}
#[test]
fn invalid_compact_options_fail_in_tools_and_headless_control() {
let mut c = Client::new();
let id = c.create(json!({"type": "rectangle"}));
let mut h = Headless::new();
let created = h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap();
assert_eq!(created["id"], id);
for mut args in [
json!({"fields": [1]}),
json!({"fields": null}),
json!({"fields": ["Name"]}),
json!({"outline": "yes"}),
json!({"outline": true, "fields": []}),
] {
args["id"] = id.clone();
for tool in ["inspect_document", "get_node"] {
assert!(!c.err(tool, args.clone()).is_empty());
}
for method in ["document.inspect", "node.get"] {
assert!(h.call(method, args.clone()).is_err(), "{method}: {args}");
}
}
}
+2
View File
@@ -17,6 +17,8 @@ mod export_xml;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod garbage;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod inspect_fields;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod inspect_response_limit;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod overlay_anchor;
+1 -1
View File
@@ -11,7 +11,7 @@ pub mod widget;
pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, Permissions, PluginCommand};
pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run};
pub use widget::{MenuItem, MenuKind, WidgetKind, WidgetNode, WidgetRender};
pub use widget::{MenuItem, MenuKind, WidgetHover, WidgetKind, WidgetNode, WidgetRender};
#[cfg(test)]
mod bundle_tests;

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