mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-13 17:44:40 +00:00
Merge main into #1655
This commit is contained in:
Generated
+28
-1
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -98,3 +98,6 @@ display_name = "Filip Strajnar"
|
||||
|
||||
[people.Carbocode]
|
||||
sync_github_name = true
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -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],
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)\")");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)" },
|
||||
]
|
||||
|
||||
@@ -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> =
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 { .. })));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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<_>>()})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, ¶ms).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(¶ms).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(¶ms).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(¶ms).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(¶ms).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);
|
||||
}
|
||||
@@ -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+'"]),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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, ¶ms).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());
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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", ¶ms);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user