mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-14 06:44:43 +00:00
Compare commits
138
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18958ed987 | ||
|
|
44a504ddcf | ||
|
|
4d44d676f0 | ||
|
|
466e54a373 | ||
|
|
5d16327f68 | ||
|
|
eac1bf2501 | ||
|
|
4b1ec35739 | ||
|
|
18a862eb55 | ||
|
|
dff7cd1c6c | ||
|
|
0c5ea9833a | ||
|
|
a0f4189b62 | ||
|
|
190a125014 | ||
|
|
615c9ab8ec | ||
|
|
c9a758a4f5 | ||
|
|
0a30807152 | ||
|
|
6b6bf4be89 | ||
|
|
99a99a25f5 | ||
|
|
e1da3f36bb | ||
|
|
94291f13e8 | ||
|
|
624795ba38 | ||
|
|
3d109bae1e | ||
|
|
80c8c7fa87 | ||
|
|
b744ff3e14 | ||
|
|
bafc814712 | ||
|
|
5738ce673c | ||
|
|
0008fc7901 | ||
|
|
479313bf67 | ||
|
|
07eb207d7c | ||
|
|
25899735a8 | ||
|
|
09427e72be | ||
|
|
2f14eeda60 | ||
|
|
46ce6102d8 | ||
|
|
1e1b59280d | ||
|
|
918a28a4f4 | ||
|
|
97cd06fd14 | ||
|
|
ee99a89e08 | ||
|
|
9c7b315301 | ||
|
|
b55f009198 | ||
|
|
ca3832c0d9 | ||
|
|
258d6f61c0 | ||
|
|
df42e380f2 | ||
|
|
ccddaa74cd | ||
|
|
6a163721dc | ||
|
|
b7c9c712b1 | ||
|
|
40d7ce14ce | ||
|
|
6f90717e71 | ||
|
|
147a374350 | ||
|
|
f97789ade1 | ||
|
|
544b2455c9 | ||
|
|
63dc418cc2 | ||
|
|
79ac16a256 | ||
|
|
85c5286d39 | ||
|
|
a0d1162976 | ||
|
|
23d0d9e49e | ||
|
|
35cc685e90 | ||
|
|
39f30d63c8 | ||
|
|
7fcb193f2a | ||
|
|
c3cb8c3e0e | ||
|
|
309bac6185 | ||
|
|
72990373c4 | ||
|
|
b778ba17cd | ||
|
|
4506f49ad4 | ||
|
|
d8ff1441b1 | ||
|
|
d71f24741a | ||
|
|
c7261cb907 | ||
|
|
d3327d79f3 | ||
|
|
4707cb1579 | ||
|
|
018b8166b2 | ||
|
|
86e91d0b71 | ||
|
|
d1dad2e1c0 | ||
|
|
fec902b7d5 | ||
|
|
057bf9a289 | ||
|
|
f1d336249a | ||
|
|
55aa0165fd | ||
|
|
836d6f2fa6 | ||
|
|
1c3e175f66 | ||
|
|
aff3c4f18b | ||
|
|
adb23f0ff1 | ||
|
|
53c8308b66 | ||
|
|
a4c7e259ab | ||
|
|
de2915908c | ||
|
|
34b08ac8bf | ||
|
|
e9565813ac | ||
|
|
c87baa37cc | ||
|
|
911831fb89 | ||
|
|
be47f99534 | ||
|
|
0b08ea1127 | ||
|
|
61ccfe18a8 | ||
|
|
d1125130f6 | ||
|
|
ddd42b36bd | ||
|
|
6743c68203 | ||
|
|
caa3c5afce | ||
|
|
8d3a3e6171 | ||
|
|
d7a27c593e | ||
|
|
7d51d0515d | ||
|
|
13fdae3d44 | ||
|
|
ef535e49b0 | ||
|
|
e35f8eb1b7 | ||
|
|
55fbcd0d7e | ||
|
|
8629a6f586 | ||
|
|
99e5db399b | ||
|
|
247530b60e | ||
|
|
ae89635e00 | ||
|
|
633455c380 | ||
|
|
846a9f7214 | ||
|
|
1c158ed81b | ||
|
|
c3a21a6ab3 | ||
|
|
976bbb84ef | ||
|
|
78fd30ab5f | ||
|
|
4458eb8802 | ||
|
|
d1619f57bd | ||
|
|
eac260f160 | ||
|
|
a819e0a188 | ||
|
|
3a9610d480 | ||
|
|
ac606a2d43 | ||
|
|
f90becadab | ||
|
|
6a334fa354 | ||
|
|
2a3ea27605 | ||
|
|
d654a14720 | ||
|
|
d828e6379e | ||
|
|
e46dda1036 | ||
|
|
1768be2965 | ||
|
|
0f8e29bc9f | ||
|
|
47c2b228a5 | ||
|
|
d712f2ffd1 | ||
|
|
9b489a052e | ||
|
|
c8e93ddaaa | ||
|
|
52e3fe2967 | ||
|
|
78222e35d2 | ||
|
|
e9be17427f | ||
|
|
1fe78aec66 | ||
|
|
1f0d9a9107 | ||
|
|
dd980c884f | ||
|
|
1e7983a550 | ||
|
|
bc5a2b8fa7 | ||
|
|
7b9ebaacbb | ||
|
|
1c16aeae09 | ||
|
|
a672a6650f |
No files matched your search
@@ -0,0 +1,32 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directories:
|
||||
- "/"
|
||||
- "/plugins"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
name: test (ubuntu-latest)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Install Linux system dependencies (egui/winit/rfd)
|
||||
run: |
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
name: wasm32 check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<h1 align="center">FigCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Interface design; an open-source, clean-room reimplementation of Figma, rebuilt in pure Rust.</b><br>
|
||||
<b>Interface design and prototyping: open source, collaborative and built in pure Rust.</b><br>
|
||||
Frames, auto layout, components and variants, vector networks, styles, variables and prototypes,<br>
|
||||
in a native app that opens instantly and works offline. Open source, and yours.
|
||||
</p>
|
||||
@@ -62,7 +62,7 @@
|
||||
<tr>
|
||||
<td width="25%" valign="top">
|
||||
<h3>🎛️ Familiar by design</h3>
|
||||
The toolbar, layers panel, Design panel, menus and shortcuts sit where your hands expect them. If you know Figma, you already know FigCraft.
|
||||
The toolbar, layers panel, Design panel, menus and shortcuts sit right where your hands expect them.
|
||||
</td>
|
||||
<td width="25%" valign="top">
|
||||
<h3>⚡ Native and fast</h3>
|
||||
@@ -262,7 +262,7 @@ New contributors and AI agents: start with [`AGENTS.md`](AGENTS.md), then [`docs
|
||||
Installers for macOS (universal, signed and notarized), Windows (x64, x86 and arm64), Linux (AppImage, `.deb`, `.rpm`, Flatpak and tarball), FreeBSD and the web are attached to each [GitHub release](https://github.com/storytold/figcraft/releases). Maintainers: [`docs/releasing.md`](docs/releasing.md) explains how releases are built, signed and published.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing Figma for real work, by our own estimate. Nearly all of Figma's main menu is wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins rather than Figma's, and hasn't been checked against real Figma documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
> **Status:** FigCraft is in **alpha**: about 49% of the way to replacing proprietary tools for real work, by our own estimate. Nearly all tracked interface design actions in the menu checklist are wired ([`docs/parity-checklist.md`](docs/parity-checklist.md)) and the core editor works end to end, but FigCraft opens `.fig` files only best effort, has only a first preview of multiplayer (live sessions hosted from the app or a self-hosted server, with per-person undo and share links to edit or to view; no accounts, teams or cloud yet), runs its own WebAssembly plugins, and hasn't been checked against real design documents. The honest, area-by-area picture is in the [roadmap](ROADMAP.md) and [`docs/target-app-parity.md`](docs/target-app-parity.md). Expect rough edges and please file issues, or tell us what broke on [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Privacy and online sharing
|
||||
|
||||
@@ -348,8 +348,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
|
||||
part of this repository and FigCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Figma is a trademark of Figma, Inc. FigCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Figma, Inc.; the name is used only to describe the workflows FigCraft is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="https://raw.githubusercontent.com/storytold/vectorcraft/main/docs/brand/artcraft-mark.png" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
@@ -29,9 +29,20 @@ fn image_format(e: &str) -> Option<ExportFormat> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
|
||||
/// confined run can only write inside a write root (#833).
|
||||
///
|
||||
/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
|
||||
/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
|
||||
/// writing through it to its target -- a change of behaviour for anyone whose output path is a
|
||||
/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
|
||||
/// `Roots::check` has already refused a link that leads out of the root.
|
||||
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
|
||||
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
|
||||
std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?;
|
||||
figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
}
|
||||
if figcraft_engine::file_access::current().is_some() {
|
||||
return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
|
||||
}
|
||||
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
|
||||
}
|
||||
@@ -244,7 +255,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
|
||||
println!("{out}");
|
||||
return Ok(ExitCode::SUCCESS);
|
||||
}
|
||||
std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
|
||||
figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let ids = match target {
|
||||
Some(t) => vec![t],
|
||||
None => {
|
||||
@@ -329,7 +340,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
|
||||
}
|
||||
|
||||
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
|
||||
let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
|
||||
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
|
||||
let mut s = match input {
|
||||
Some(path) => {
|
||||
|
||||
@@ -22,6 +22,7 @@ USAGE:
|
||||
figcraft-cli convert <in> <out>
|
||||
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
|
||||
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
|
||||
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
|
||||
figcraft-cli commands [--json] [--filter TEXT]
|
||||
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
|
||||
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
|
||||
@@ -42,12 +43,33 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines)
|
||||
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
|
||||
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
|
||||
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
|
||||
`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files the agent's commands touch: reads only inside a read or write root,
|
||||
writes only inside a write root, everything else refused. A running app confines itself: start it
|
||||
with `figcraft --control PORT` and the same flags (`--attach` refuses them).
|
||||
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
|
||||
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
|
||||
only inside a write root, everything else refused -- so giving no write root denies writing
|
||||
altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`,
|
||||
`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes
|
||||
as the user who started it, as before. A running app confines itself: start it with
|
||||
`figcraft --control PORT` and the same flags (`--attach` refuses them).
|
||||
See docs/cli.md and docs/mcp.md.";
|
||||
|
||||
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
|
||||
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
|
||||
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
|
||||
|
||||
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
if read.is_empty() && write.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
|
||||
}
|
||||
|
||||
/// Runs `f` confined to the roots `a` carries.
|
||||
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
|
||||
let roots = roots_of(a)?.map(std::sync::Arc::new);
|
||||
figcraft_engine::file_access::confined(roots.as_ref(), f)
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
match run(&argv) {
|
||||
@@ -98,35 +120,38 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"info" => {
|
||||
let a = Args::parse(rest, &[], &["json"])?;
|
||||
let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?;
|
||||
let file = a.one_positional("info <file>")?;
|
||||
files::info(&file, a.flag("json"))
|
||||
confined(&a, || files::info(&file, a.flag("json")))
|
||||
}
|
||||
"export" => {
|
||||
let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?;
|
||||
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
|
||||
let file = a.one_positional("export <file> -o <out>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
|
||||
let target =
|
||||
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
|
||||
files::export(&file, &out, &target)
|
||||
confined(&a, || files::export(&file, &out, &target))
|
||||
}
|
||||
"convert" => {
|
||||
let a = Args::parse(rest, &[], &[])?;
|
||||
let a = Args::parse(rest, &ROOT_FLAGS, &[])?;
|
||||
match a.positional.as_slice() {
|
||||
[i, o] => files::convert(i, o),
|
||||
[i, o] => confined(&a, || files::convert(i, o)),
|
||||
_ => Err(CliError::Usage("convert <in> <out>".into())),
|
||||
}
|
||||
}
|
||||
"render" => {
|
||||
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
|
||||
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &[])?;
|
||||
let file = a.one_positional("render <file> -o <out.png>")?;
|
||||
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
|
||||
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
|
||||
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
|
||||
}
|
||||
"run" => {
|
||||
let a = Args::parse(rest, &["in", "out"], &[])?;
|
||||
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &[])?;
|
||||
let script = a.one_positional("run <script.json|.jsonl>")?;
|
||||
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
|
||||
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
|
||||
}
|
||||
"commands" => {
|
||||
let a = Args::parse(rest, &["filter"], &["json"])?;
|
||||
@@ -137,23 +162,20 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
"mcp" => {
|
||||
let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?;
|
||||
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
|
||||
let a = Args::parse(rest, &valued, &[])?;
|
||||
if !a.positional.is_empty() {
|
||||
return Err(CliError::Usage("mcp takes no positional arguments".into()));
|
||||
}
|
||||
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
|
||||
let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) {
|
||||
(true, _) => None,
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
(false, Some(_)) => {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
(false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?),
|
||||
};
|
||||
mcp(a.value("attach").as_deref(), roots)
|
||||
let attach = a.value("attach");
|
||||
// The app runs the commands: only its own roots can confine them.
|
||||
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
|
||||
return Err(CliError::Usage(
|
||||
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
mcp(attach.as_deref(), roots_of(&a)?)
|
||||
}
|
||||
"plugin" => {
|
||||
let a = Args::parse(rest, &["server"], &[])?;
|
||||
|
||||
@@ -172,3 +172,98 @@ fn commands_json_lists_everything() {
|
||||
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
|
||||
assert!(v.as_array().unwrap().len() > 200);
|
||||
}
|
||||
|
||||
/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a
|
||||
/// root, and a write outside a write root, are refused and leave no file behind. Without the flags
|
||||
/// the same commands behave as they always did.
|
||||
#[test]
|
||||
fn file_subcommands_are_confined_to_automation_roots() {
|
||||
let d = tmp("file-roots");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
for sub in ["in", "out"] {
|
||||
std::fs::create_dir_all(d.join(sub)).unwrap();
|
||||
}
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
|
||||
// A document to work on, made unconfined.
|
||||
std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
|
||||
let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap();
|
||||
assert!(made.status.success(), "{}", out_of(&made));
|
||||
|
||||
// Inside the roots: renders as usual.
|
||||
let ok = bin()
|
||||
.args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(ok.status.success(), "{}", out_of(&ok));
|
||||
assert!(d.join("out/ok.png").is_file());
|
||||
|
||||
// A write outside the write root is refused, and nothing is written.
|
||||
let escaped = d.join("escaped.png");
|
||||
let refused = bin()
|
||||
.args([
|
||||
"render",
|
||||
&p("in/doc.figcraft"),
|
||||
"-o",
|
||||
&escaped.to_string_lossy(),
|
||||
"--automation-read-root",
|
||||
&p("in"),
|
||||
"--automation-write-root",
|
||||
&p("out"),
|
||||
])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!refused.status.success());
|
||||
assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused));
|
||||
assert!(!escaped.exists(), "the refused render wrote a file anyway");
|
||||
|
||||
// No write root at all: writing is refused outright, reading still works.
|
||||
let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap();
|
||||
assert!(!no_write.status.success());
|
||||
assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write));
|
||||
|
||||
// A read outside every root is refused: `info` on the document with only `out` readable.
|
||||
let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap();
|
||||
assert!(!read_refused.status.success());
|
||||
assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused));
|
||||
|
||||
// `convert` and `run` take them as well, and a root that is not a folder is refused.
|
||||
let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap();
|
||||
assert!(!missing.status.success());
|
||||
assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing));
|
||||
|
||||
// Unconfined, the same render still writes wherever it is told.
|
||||
let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap();
|
||||
assert!(plain.status.success(), "{}", out_of(&plain));
|
||||
assert!(escaped.is_file());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
/// An output path that is a symbolic link is written through, to its target, as `std::fs::write`
|
||||
/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have
|
||||
/// replaced the link with a regular file, because that write stages the bytes and renames them
|
||||
/// into place.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_unconfined_write_follows_a_symbolic_link() {
|
||||
let d = tmp("symlink-out");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(d.join("real")).unwrap();
|
||||
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
|
||||
std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
|
||||
let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap();
|
||||
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
|
||||
|
||||
let target = d.join("real/target.png");
|
||||
std::fs::write(&target, b"placeholder").unwrap();
|
||||
let link = d.join("link.png");
|
||||
std::os::unix::fs::symlink(&target, &link).unwrap();
|
||||
|
||||
let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link");
|
||||
let written = std::fs::read(&target).unwrap();
|
||||
assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder");
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
@@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The browser shell: web `Services` and the eframe web runner.
|
||||
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_ui_egui::{FigApp, Services};
|
||||
use figcraft_ui_egui::{ContentsFuture, FigApp, Services};
|
||||
use wasm_bindgen::JsCast as _;
|
||||
|
||||
const CANVAS_ID: &str = "figcraft_canvas";
|
||||
@@ -75,9 +75,10 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
|
||||
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
|
||||
/// events with their text (a page can't read the clipboard when it likes).
|
||||
/// Browser services. Files are opened and imported through the browser's file picker, which hands
|
||||
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
|
||||
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
|
||||
/// paste events with their text (a page can't read the clipboard when it likes).
|
||||
struct WebServices {
|
||||
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
|
||||
reduce_motion: Option<web_sys::MediaQueryList>,
|
||||
@@ -85,6 +86,16 @@ struct WebServices {
|
||||
}
|
||||
|
||||
impl Services for WebServices {
|
||||
/// An `<input type="file">` taking `exts`, clicked; its file is read once picked.
|
||||
fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option<ContentsFuture> {
|
||||
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
|
||||
Some(Box::pin(async move {
|
||||
pick_file(&accept).await.unwrap_or_else(|e| {
|
||||
log::error!("couldn't open a file: {e:?}");
|
||||
None
|
||||
})
|
||||
}))
|
||||
}
|
||||
fn downloads(&self) -> bool {
|
||||
true
|
||||
}
|
||||
@@ -136,6 +147,90 @@ impl Services for WebServices {
|
||||
}
|
||||
}
|
||||
|
||||
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
|
||||
/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file
|
||||
/// can't be read.
|
||||
async fn pick_file(accept: &str) -> Result<Option<Result<(String, Vec<u8>), String>>, wasm_bindgen::JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
let body = document.body().ok_or("no body")?;
|
||||
let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?;
|
||||
input.set_type("file");
|
||||
input.set_accept(accept);
|
||||
input.set_hidden(true);
|
||||
// Some browsers (older Safari) open the picker only for an input in the page.
|
||||
body.append_child(&input)?;
|
||||
let (closed, picker) = PickerClosed::new(&window, input.clone());
|
||||
input.click();
|
||||
let result = wasm_bindgen_futures::JsFuture::from(closed).await;
|
||||
let file = input.files().and_then(|files| files.get(0));
|
||||
drop(picker);
|
||||
result?;
|
||||
let Some(file) = file else { return Ok(None) };
|
||||
let name = file.name();
|
||||
if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) {
|
||||
return Ok(Some(Err(why)));
|
||||
}
|
||||
Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
|
||||
Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())),
|
||||
Err(e) => {
|
||||
log::error!("couldn't read {name}: {e:?}");
|
||||
Err(figcraft_ui_egui::unreadable(&name))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// How long after the page gets focus back the file picker counts as closed (milliseconds): a
|
||||
/// picked file's `change` can come just after the focus, and must win. A `change` later than this
|
||||
/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel.
|
||||
const PICKER_FOCUS_SETTLE_MS: i32 = 1_000;
|
||||
|
||||
/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed
|
||||
/// without one) or shortly after the page gets focus back, for browsers that never send `cancel`
|
||||
/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one.
|
||||
/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer.
|
||||
struct PickerClosed {
|
||||
window: web_sys::Window,
|
||||
input: web_sys::HtmlInputElement,
|
||||
_settle: wasm_bindgen::closure::Closure<dyn FnMut()>,
|
||||
on_focus: wasm_bindgen::closure::Closure<dyn FnMut()>,
|
||||
timer: std::rc::Rc<std::cell::Cell<Option<i32>>>,
|
||||
}
|
||||
|
||||
impl PickerClosed {
|
||||
fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) {
|
||||
let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::<js_sys::Function>));
|
||||
let slot = resolve.clone();
|
||||
let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res));
|
||||
let settle = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
|
||||
if let Some(res) = resolve.borrow_mut().take() {
|
||||
let _ = res.call0(&wasm_bindgen::JsValue::NULL);
|
||||
}
|
||||
});
|
||||
let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref());
|
||||
let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref());
|
||||
let timer = std::rc::Rc::new(std::cell::Cell::new(None));
|
||||
let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::<js_sys::Function>().clone());
|
||||
let on_focus = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
|
||||
if t.get().is_none() {
|
||||
t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok());
|
||||
}
|
||||
});
|
||||
let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref());
|
||||
(closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PickerClosed {
|
||||
fn drop(&mut self) {
|
||||
if let Some(t) = self.timer.take() {
|
||||
self.window.clear_timeout_with_handle(t);
|
||||
}
|
||||
let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref());
|
||||
self.input.remove();
|
||||
}
|
||||
}
|
||||
|
||||
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let document = window.document().ok_or("no document")?;
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
//! [--automation-write-root <dir>]… [files…] [figcraft://join?…]`. An invite link
|
||||
//! (what the system passes when a `figcraft://` link is opened) joins that session once the window
|
||||
//! is up; everything else that isn't a flag is a file to open, as a path or a `file://` URL (the
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links).
|
||||
//! Linux desktop entry passes URLs, `%U`, so that it can also hand over invite links). Any other
|
||||
//! `--flag` is unknown and stops the app at startup (#836): a mistyped root flag must never leave
|
||||
//! the app running unconfined. After a bare `--`, every argument is a file.
|
||||
|
||||
/// What the app was started with.
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct Args {
|
||||
/// `--control <port>`: the JSON-lines control server.
|
||||
/// `--control <port>` (or `--control=<port>`): the JSON-lines control server.
|
||||
pub control: Option<u16>,
|
||||
pub files: Vec<String>,
|
||||
/// The last invite link given.
|
||||
@@ -17,6 +19,8 @@ pub struct Args {
|
||||
/// which is refused at startup.
|
||||
pub read_roots: Vec<String>,
|
||||
pub write_roots: Vec<String>,
|
||||
/// `--flags` the app doesn't know, as given.
|
||||
pub unknown: Vec<String>,
|
||||
}
|
||||
|
||||
/// An invite link rather than a file (`figcraft:` links, in any case).
|
||||
@@ -55,6 +59,10 @@ pub fn parse(args: &[String]) -> Args {
|
||||
let mut out = Args::default();
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
if a == "--" {
|
||||
out.files.extend(it.by_ref().cloned());
|
||||
break;
|
||||
}
|
||||
let (flag, inline) = a.split_once('=').map_or((a.as_str(), None), |(f, v)| (f, Some(v.to_string())));
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => Some(&mut out.read_roots),
|
||||
@@ -63,13 +71,15 @@ pub fn parse(args: &[String]) -> Args {
|
||||
};
|
||||
if let Some(roots) = roots {
|
||||
roots.push(inline.or_else(|| it.next().cloned()).unwrap_or_default());
|
||||
} else if a == "--control" {
|
||||
out.control = it.next().and_then(|p| p.parse().ok());
|
||||
} else if flag == "--control" {
|
||||
out.control = inline.or_else(|| it.next().cloned()).and_then(|p| p.parse().ok());
|
||||
} else if is_invite(a) {
|
||||
out.join = Some(a.clone());
|
||||
} else if let Some(path) = file_url_path(a) {
|
||||
out.files.push(path);
|
||||
} else if !a.starts_with("--") {
|
||||
} else if a.starts_with("--") {
|
||||
out.unknown.push(a.clone());
|
||||
} else {
|
||||
out.files.push(a.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,21 @@ fn args(a: &[&str]) -> Vec<String> {
|
||||
#[test]
|
||||
fn files_and_the_control_port() {
|
||||
assert_eq!(parse(&args(&[])), Args::default());
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "--verbose", "b.svg"]));
|
||||
let a = parse(&args(&["--control", "4242", "a.figcraft", "b.svg"]));
|
||||
assert_eq!(a, Args { control: Some(4242), files: args(&["a.figcraft", "b.svg"]), ..Args::default() });
|
||||
// A missing or bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control"])), Args::default());
|
||||
assert_eq!(parse(&args(&["--control", "nope"])).control, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_control_port_accepts_the_equals_form() {
|
||||
let a = parse(&args(&["--control=7980", "a.figcraft"]));
|
||||
assert_eq!(a, Args { control: Some(7980), files: args(&["a.figcraft"]), ..Args::default() });
|
||||
// A bad port is ignored, not taken as a file.
|
||||
assert_eq!(parse(&args(&["--control=nope"])), Args::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn automation_roots_repeat_and_are_never_files() {
|
||||
let a = parse(&args(&["--automation-read-root", "in", "--automation-write-root=out", "--automation-read-root=more", "a.figcraft"]));
|
||||
@@ -26,6 +34,19 @@ fn automation_roots_repeat_and_are_never_files() {
|
||||
assert_eq!(parse(&args(&["--automation-write-root"])).write_roots, args(&[""]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_flags_are_refused_never_ignored() {
|
||||
// #836: a mistyped root flag (or one an older release lacks) must stop the app, not start it
|
||||
// unconfined. Both forms, with and without `=`.
|
||||
let a = parse(&args(&["--automation-write-roots", "out", "--automation-write-dir=out", "--verbose", "a.figcraft"]));
|
||||
assert_eq!(a.unknown, args(&["--automation-write-roots", "--automation-write-dir=out", "--verbose"]));
|
||||
assert!(a.write_roots.is_empty());
|
||||
// Single-dash arguments are left as they were (files); after `--` everything is a file.
|
||||
let a = parse(&args(&["--control", "1", "--", "--odd.figcraft", "--automation-write-root=x"]));
|
||||
assert_eq!(a.files, args(&["--odd.figcraft", "--automation-write-root=x"]));
|
||||
assert!(a.unknown.is_empty() && a.write_roots.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invite_links_join_instead_of_opening() {
|
||||
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
//! `--automation-read-root <dir>` / `--automation-write-root <dir>` (each repeatable) confine the
|
||||
//! files the window and the control channel read and write (`figcraft_engine::file_access`); the
|
||||
//! app's own folders (settings, recovery copies, installed plugins) and opening the files given on
|
||||
//! the command line are not. A root that isn't an existing folder stops the app at startup.
|
||||
//! the command line are not. A root that isn't an existing folder stops the app at startup, and so
|
||||
//! does any flag the app doesn't know (a mistyped root flag must not start the app unconfined).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
|
||||
@@ -107,10 +108,13 @@ impl eframe::App for App {
|
||||
}
|
||||
|
||||
fn on_exit(&mut self) {
|
||||
with_app(|fig| services::save_state(fig));
|
||||
if let Some(r) = recovery::lock(&self.recovery).take() {
|
||||
r.finish();
|
||||
}
|
||||
// With the app out of reach (busy), its recovery copies stay for the next start.
|
||||
let copies = recovery::lock(&self.recovery).take();
|
||||
with_app(|fig| services::shutdown(fig, services::config_dir().as_deref(), copies));
|
||||
// Everything is kept: end before the windows' teardown, where AppKit's Touch Bar support
|
||||
// throws on the content view AccessKit restores (craft-libs#3, #781).
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +130,12 @@ fn main() -> eframe::Result {
|
||||
return Ok(());
|
||||
}
|
||||
let args = args::parse(&args);
|
||||
if let Some(flag) = args.unknown.first() {
|
||||
eprintln!(
|
||||
"FigCraft: unknown option {flag}\nUsage: figcraft [--control PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... [FILE | figcraft://join?...]..."
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
let roots = match (
|
||||
args.read_roots.is_empty() && args.write_roots.is_empty(),
|
||||
figcraft_engine::file_access::Roots::new(&args.read_roots, &args.write_roots),
|
||||
|
||||
@@ -120,8 +120,7 @@ impl Recovery {
|
||||
(0..10).find_map(|n| {
|
||||
let dir = root.join(if n == 0 { pid.to_string() } else { format!("{pid}-{n}") });
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).ok()?;
|
||||
lock.try_lock().ok()?;
|
||||
let lock = lock_folder(&dir).ok()?;
|
||||
Some(Self { dir, _lock: lock, written: HashMap::new(), next: 0.0, waiting: Vec::new() })
|
||||
})
|
||||
}
|
||||
@@ -250,6 +249,17 @@ impl Recovery {
|
||||
self.written.insert(uid, Copy { done: None, flight: Some((vec![revision], writer)) });
|
||||
}
|
||||
|
||||
/// Quitting: a normal quit ([`finish`](Self::finish)) when the unsaved-changes question was
|
||||
/// `asked` or nothing is unsaved. Otherwise (macOS's Quit menu ends the app without asking) the
|
||||
/// unsaved documents are copied and the copies stay for the next start, as after a crash.
|
||||
pub fn quit(mut self, s: &Session, asked: bool) {
|
||||
if asked || !s.docs.iter().any(|d| needs_copy(s, d)) {
|
||||
return self.finish();
|
||||
}
|
||||
self.write(s, true);
|
||||
self.settle(s);
|
||||
}
|
||||
|
||||
/// A normal quit: the documents were saved or their changes discarded on purpose. (Earlier
|
||||
/// runs' copies still waiting for their outcome stay for the next start.)
|
||||
pub fn finish(self) {
|
||||
@@ -282,6 +292,17 @@ fn remove_kept(w: Waiting) {
|
||||
/// The extension of the note beside the copy of a document that is one of your online files.
|
||||
const ONLINE: &str = "online";
|
||||
|
||||
/// How long the startup waits, once, for folder locks that may free themselves (#334).
|
||||
#[cfg(unix)]
|
||||
const LOCK_WAIT: std::time::Duration = std::time::Duration::from_millis(100);
|
||||
|
||||
/// Lock the folder `dir` (`WouldBlock` while another process holds it).
|
||||
fn lock_folder(dir: &Path) -> Result<File, std::fs::TryLockError> {
|
||||
let lock = File::options().write(true).create(true).truncate(false).open(dir.join(LOCK)).map_err(std::fs::TryLockError::Error)?;
|
||||
lock.try_lock()?;
|
||||
Ok(lock)
|
||||
}
|
||||
|
||||
/// The copy at `file` is of one of your online files: join that file again as its owner, keeping
|
||||
/// the copy to compare with what arrives. The session, or `None` when the copy isn't one (or its
|
||||
/// file isn't recorded any more, or is open already): then it opens as any other copy.
|
||||
@@ -351,6 +372,7 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
}
|
||||
}
|
||||
};
|
||||
let (mut free, mut held) = (Vec::new(), Vec::new());
|
||||
for e in entries.flatten() {
|
||||
let path = e.path();
|
||||
if !path.is_dir() {
|
||||
@@ -358,10 +380,22 @@ pub fn recover_all(root: &Path, s: &mut Session) -> Recovered {
|
||||
continue;
|
||||
}
|
||||
// Still running (another window of the app), or not ours to take.
|
||||
let Ok(lock) = File::options().write(true).create(true).truncate(false).open(path.join(LOCK)) else { continue };
|
||||
if lock.try_lock().is_err() {
|
||||
continue;
|
||||
match lock_folder(&path) {
|
||||
Ok(lock) => free.push((path, lock)),
|
||||
Err(std::fs::TryLockError::WouldBlock) => held.push(path),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
// A child between its `fork` and its `exec` shares the run's open descriptors, and a `flock`
|
||||
// lasts while any of them is open, so an ended run's folder can look held for a moment. Wait
|
||||
// once, however many folders are held, and try those again (#334). Windows doesn't pass the
|
||||
// lock to child processes.
|
||||
#[cfg(unix)]
|
||||
if !held.is_empty() {
|
||||
std::thread::sleep(LOCK_WAIT);
|
||||
free.extend(held.into_iter().filter_map(|path| lock_folder(&path).ok().map(|lock| (path, lock))));
|
||||
}
|
||||
for (path, lock) in free {
|
||||
let lock = Arc::new(lock);
|
||||
let files: Vec<PathBuf> = std::fs::read_dir(&path).into_iter().flatten().flatten().map(|f| f.path()).collect();
|
||||
for f in &files {
|
||||
|
||||
@@ -167,6 +167,23 @@ fn a_normal_quit_removes_the_recovered_copies_it_took_over() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_without_the_unsaved_question_keeps_copies_for_the_next_start() {
|
||||
let root = root("quitunasked");
|
||||
// macOS's Quit menu: nothing asked, so the unsaved document comes back next time.
|
||||
let s = edited();
|
||||
Recovery::start(&root).unwrap().quit(&s, false);
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(&root, &mut next), 1);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
// Asked (saved or discarded on purpose), or nothing unsaved: nothing is left.
|
||||
for (s, asked) in [(edited(), true), (Session::new(), false)] {
|
||||
Recovery::start(&root).unwrap().quit(&s, asked);
|
||||
assert!(std::fs::read_dir(&root).unwrap().next().is_none(), "asked: {asked}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let root = root("pidreuse");
|
||||
@@ -186,6 +203,29 @@ fn a_run_whose_folder_name_is_still_held_takes_another() {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// #334: a lock that frees itself is not a run that is still going. A child between its `fork` and
|
||||
/// its `exec` inherits the run's open descriptors and a `flock` lasts while any descriptor for the
|
||||
/// file is open, so a crashed run's copies must still come back once that child has exec'd.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_lock_freed_a_moment_later_is_not_a_run_that_is_still_going() {
|
||||
let root = root("fork-window");
|
||||
crashed(&edited(), &root);
|
||||
// Hold the crashed run's folder lock on a second descriptor, the way a child forked before its
|
||||
// exec holds it, and free it shortly after.
|
||||
let lock = std::fs::File::options().write(true).create(true).truncate(false).open(root.join("crashed").join("lock")).unwrap();
|
||||
lock.try_lock().unwrap();
|
||||
let freed = std::thread::spawn(move || {
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
drop(lock);
|
||||
});
|
||||
// The next start waits for that lock rather than reading the run as still going.
|
||||
let mut next = Session::empty();
|
||||
assert_eq!(recover(&root, &mut next), 1, "the copies come back once the lock frees itself");
|
||||
freed.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// The rectangles in `doc`.
|
||||
fn rectangles(doc: &figcraft_engine::doc::Document) -> usize {
|
||||
doc.nodes().filter(|n| matches!(n.kind, figcraft_engine::doc::NodeKind::Rectangle)).count()
|
||||
|
||||
@@ -167,10 +167,18 @@ fn reveal(path: &Path) -> std::io::Result<()> {
|
||||
/// The preferences' file in the config folder.
|
||||
const STATE: &str = "state.json";
|
||||
|
||||
pub fn save_state(app: &FigApp) {
|
||||
if let Some(dir) = config_dir() {
|
||||
write_prefs(&dir, &app.session.prefs);
|
||||
/// Everything quitting keeps, all done when it returns (macOS then ends the process): the
|
||||
/// preferences in `dir`, this run's recovery copies ([`Recovery::quit`](crate::recovery::Recovery::quit))
|
||||
/// and the live sessions' ends (one this app hosts tells everyone and stops; a joined one leaves).
|
||||
pub fn shutdown(app: &mut FigApp, dir: Option<&Path>, recovery: Option<crate::recovery::Recovery>) {
|
||||
if let Some(dir) = dir {
|
||||
write_prefs(dir, &app.session.prefs);
|
||||
}
|
||||
// Before the sessions end: a recovered online file's copy waits for its session's outcome.
|
||||
if let Some(r) = recovery {
|
||||
r.quit(&app.session, app.ui.quit);
|
||||
}
|
||||
drop(std::mem::take(&mut app.session.live));
|
||||
}
|
||||
|
||||
/// Write `prefs` to `dir` atomically (a temporary file renamed over the old one: a crash leaves
|
||||
|
||||
@@ -82,3 +82,38 @@ fn preferences_are_readable_by_their_owner_only() {
|
||||
assert_eq!(mode(&dir.join("state.json")), 0o600);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A live link that only records the sessions it was told to leave.
|
||||
struct Leaves(Arc<Mutex<Vec<u64>>>);
|
||||
|
||||
impl figcraft_engine::live::LiveLink for Leaves {
|
||||
fn host(&mut self, _: u64, _: figcraft_engine::live::HostOptions) -> Result<figcraft_engine::live::SessionInfo, String> {
|
||||
Err("not in this test".into())
|
||||
}
|
||||
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
fn leave(&mut self, session: u64) {
|
||||
self.0.lock().unwrap().push(session);
|
||||
}
|
||||
fn presence(&mut self, _: u64, _: &figcraft_engine::live::Presence) {}
|
||||
fn send(&mut self, _: u64, _: figcraft_engine::figcraft_collab::message::ClientMsg) {}
|
||||
fn poll(&mut self) -> Vec<(u64, figcraft_engine::live::Event)> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quitting_keeps_the_preferences_and_ends_the_live_sessions() {
|
||||
let dir = root("shutdown");
|
||||
let mut fig = start(&dir);
|
||||
let left = Arc::new(Mutex::new(Vec::new()));
|
||||
fig.session.live.install(Box::new(Leaves(left.clone())));
|
||||
let joined = fig.run("collab.join", json!({"link": "ws://127.0.0.1:9/?file=f1&token=EditSecretEditSecret00"})).unwrap();
|
||||
fig.session.prefs.user_name = "Ada".into();
|
||||
crate::services::shutdown(&mut fig, Some(&dir), None);
|
||||
assert_eq!(*left.lock().unwrap(), [joined["session"].as_u64().unwrap()], "the session was left");
|
||||
assert!(!fig.session.live.available(), "nothing is left to send on");
|
||||
assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -53,3 +53,9 @@ sync_github_name = false
|
||||
[people.krinry]
|
||||
display_name = "krinry"
|
||||
sync_github_name = true
|
||||
|
||||
[people.pechnikov2026-star]
|
||||
sync_github_name = true
|
||||
|
||||
[people.MortenHusted]
|
||||
sync_github_name = true
|
||||
@@ -220,7 +220,8 @@ fn valid_host(s: &str) -> bool {
|
||||
return true;
|
||||
}
|
||||
let Some((name, port)) = s.rsplit_once(':') else { return false };
|
||||
port.parse::<u16>().is_ok_and(|p| p != 0)
|
||||
port.bytes().all(|b| b.is_ascii_digit())
|
||||
&& port.parse::<u16>().is_ok_and(|p| p != 0)
|
||||
&& (1..=253).contains(&name.len())
|
||||
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
|
||||
&& !name.starts_with(['-', '.'])
|
||||
@@ -239,6 +240,9 @@ fn percent_decode(s: &str) -> Option<String> {
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
if b == b'%' {
|
||||
let hex = bytes.get(i + 1..i + 3)?;
|
||||
if !hex.iter().all(u8::is_ascii_hexdigit) {
|
||||
return None;
|
||||
}
|
||||
out.push(u8::from_str_radix(std::str::from_utf8(hex).ok()?, 16).ok()?);
|
||||
i += 3;
|
||||
} else {
|
||||
|
||||
@@ -123,3 +123,19 @@ proptest! {
|
||||
prop_assert_eq!(Invite::parse(&i.url()), Ok(i));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_must_be_all_digits() {
|
||||
assert!(Endpoint::parse("ws://localhost:80/").is_ok());
|
||||
for bad in ["ws://localhost:+80/", "ws://localhost:-80/", "ws://localhost:/"] {
|
||||
assert!(Endpoint::parse(bad).is_err(), "accepted {bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plus_or_sign_inside_a_percent_escape_is_broken() {
|
||||
assert!(Endpoint::parse("ws://example.org/a/?key=%41").is_ok());
|
||||
for bad in ["%+1", "%-1", "%1", "%+a"] {
|
||||
assert!(Endpoint::parse(&format!("ws://example.org/a/?key={bad}")).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
@@ -87,8 +87,9 @@ macro_rules! node_props {
|
||||
($($variant:ident => $field:ident),* $(,)?) => {
|
||||
/// A node property: the tree position (`parent`, `position`) or one saved field of
|
||||
/// [`Node`]. List-valued fields (fills, text runs, vector networks…) are one property each,
|
||||
/// replaced whole (three-way merges line fills, strokes and effects up item by item, see
|
||||
/// [`merge`](crate::merge)). New properties go at the end (the binary form numbers them).
|
||||
/// replaced whole (three-way merges line the lists whose items stand on their own, such as
|
||||
/// fills, up item by item, see [`merge`](crate::merge)). New properties go at the end (the
|
||||
/// binary form numbers them).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Prop {
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
//!
|
||||
//! A conflict is the same property (or document-level entry) changed on both sides to different
|
||||
//! values, or a layer deleted on one side while the other side edited it or something inside it
|
||||
//! (including adding layers to it or moving layers into or out of it). Fills, strokes and effects
|
||||
//! merge item by item: both sides changing different items of the list (or one adding items at its
|
||||
//! end) is no conflict, only the same item changed both ways or the list shortened on a side
|
||||
//! while the other changed it. Nothing that conflicts is
|
||||
//! merged until it is resolved: each conflict is resolved for one side, and the other side's
|
||||
//! changes to it are left out. Everything else from both sides is kept.
|
||||
//! (including adding layers to it or moving layers into or out of it). The layer lists whose items
|
||||
//! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype
|
||||
//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides
|
||||
//! changing different items of the list (or one adding items at its end) is no conflict, only the
|
||||
//! same item changed both ways or the list shortened on a side while the other changed it. Nothing
|
||||
//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the
|
||||
//! other side's changes to it are left out. Everything else from both sides is kept.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
@@ -159,8 +160,23 @@ fn by_key(changes: &[Change]) -> BTreeMap<Key, Vec<usize>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// List properties whose items merge one by one.
|
||||
const PER_ITEM: &[Prop] = &[Prop::Fills, Prop::Strokes, Prop::Effects];
|
||||
/// List properties whose items merge one by one: the items stand on their own, so both sides
|
||||
/// changing different ones (or one side adding at its end) is no conflict. Sets and keyed maps
|
||||
/// (`bound`, `varModes`, `overrides`, `propRefs`, `pluginData`) are not here: their items line up
|
||||
/// by key, not by position.
|
||||
const PER_ITEM: &[Prop] = &[
|
||||
Prop::Fills,
|
||||
Prop::Strokes,
|
||||
Prop::Effects,
|
||||
Prop::Grids,
|
||||
Prop::Exports,
|
||||
Prop::Guides,
|
||||
Prop::Interactions,
|
||||
Prop::Annotations,
|
||||
// Not `CodeLinks`: there is one per language, and lining them up by position could leave two
|
||||
// for one language (the second hidden), so they merge whole.
|
||||
Prop::DevResources,
|
||||
];
|
||||
|
||||
/// Both sides' writes `b` and `m` of list property `key` merged item by item against the base
|
||||
/// (see [`merge_items`]); `None` when `key` isn't such a list or the writes conflict.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Fills, strokes and effects merge item by item: different items changed on each side merge,
|
||||
//! the same item changed both ways conflicts.
|
||||
//! The layer lists whose items stand on their own (fills, strokes, effects, layout grids, export
|
||||
//! settings, guides, prototype interactions, accessibility annotations and Dev Mode resources)
|
||||
//! merge item by item: different items changed on each side merge, the same item changed both ways
|
||||
//! conflicts.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::{Document, Effect, NodeId, NodeKind};
|
||||
use figcraft_doc::{Document, Effect, LayoutGrid, NodeId, NodeKind, proto};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::merge::{Key, Side, merge_items, plan};
|
||||
@@ -100,19 +102,113 @@ fn the_same_item_changed_both_ways_conflicts() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_lists_still_conflict_as_a_whole() {
|
||||
// Docs silent: lists other than fills, strokes and effects (export settings here) still
|
||||
// conflict whole when both sides change them.
|
||||
fn the_other_item_lists_merge_one_by_one_too() {
|
||||
// Layout grids, export settings, guides, accessibility annotations and Dev Mode resources are
|
||||
// item lists like fills: both sides changing different items merges.
|
||||
let (mut base, _, _, r) = files();
|
||||
let export = |suffix: &str| figcraft_doc::ExportSetting { suffix: suffix.into(), ..Default::default() };
|
||||
base.get_mut(r).unwrap().exports = vec![export("@1"), export("@2")];
|
||||
let guide = |vertical: bool, offset: f64| figcraft_doc::Guide { vertical, offset };
|
||||
let annot = |label: &str| figcraft_doc::Annotation { label: label.into(), properties: Vec::new(), category: None };
|
||||
let grid = |size: f64| LayoutGrid { size, ..Default::default() };
|
||||
let res = |url: &str| figcraft_doc::DevResource { url: url.into(), name: String::new() };
|
||||
let n = base.get_mut(r).unwrap();
|
||||
n.exports = vec![export("@1"), export("@2")];
|
||||
n.guides = vec![guide(true, 10.0), guide(false, 20.0)];
|
||||
n.annotations = vec![annot("a1"), annot("a2")];
|
||||
n.grids = vec![grid(10.0), grid(20.0)];
|
||||
n.dev_resources = vec![res("https://a.test"), res("https://b.test")];
|
||||
let (mut branch, mut main) = (base.clone(), base.clone());
|
||||
branch.set_client_id(base.highest_id_space() + 1);
|
||||
branch.get_mut(r).unwrap().exports[0] = export("@b");
|
||||
main.get_mut(r).unwrap().exports[1] = export("@m");
|
||||
// The branch changes the first item of each list; main the second.
|
||||
let b = branch.get_mut(r).unwrap();
|
||||
(b.exports[0].suffix, b.guides[0].offset, b.annotations[0].label, b.grids[0].size, b.dev_resources[0].url) =
|
||||
("@b".into(), 11.0, "b1".into(), 12.0, "https://branch.test".into());
|
||||
let m = main.get_mut(r).unwrap();
|
||||
(m.exports[1].suffix, m.guides[1].offset, m.annotations[1].label, m.grids[1].size, m.dev_resources[1].url) =
|
||||
("@m".into(), 22.0, "m2".into(), 24.0, "https://main.test".into());
|
||||
both_ways(&base, &branch, &main, |doc| {
|
||||
let n = doc.node(r).unwrap();
|
||||
let suffixes: Vec<&str> = n.exports.iter().map(|e| e.suffix.as_str()).collect();
|
||||
assert_eq!(suffixes, ["@b", "@m"], "export settings");
|
||||
let offsets: Vec<f64> = n.guides.iter().map(|g| g.offset).collect();
|
||||
assert_eq!(offsets, [11.0, 22.0], "guides");
|
||||
let labels: Vec<&str> = n.annotations.iter().map(|a| a.label.as_str()).collect();
|
||||
assert_eq!(labels, ["b1", "m2"], "annotations");
|
||||
let sizes: Vec<f64> = n.grids.iter().map(|g| g.size).collect();
|
||||
assert_eq!(sizes, [12.0, 24.0], "layout grids");
|
||||
let urls: Vec<&str> = n.dev_resources.iter().map(|d| d.url.as_str()).collect();
|
||||
assert_eq!(urls, ["https://branch.test", "https://main.test"], "dev resources");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_item_of_another_list_changed_both_ways_conflicts() {
|
||||
let (mut base, _, _, r) = files();
|
||||
let link = |language: &str| figcraft_doc::CodeLink { language: language.into(), ..Default::default() };
|
||||
let n = base.get_mut(r).unwrap();
|
||||
n.exports = vec![
|
||||
figcraft_doc::ExportSetting { suffix: "@1".into(), ..Default::default() },
|
||||
figcraft_doc::ExportSetting { suffix: "@2".into(), ..Default::default() },
|
||||
];
|
||||
n.code_links = vec![link("react"), link("vue")];
|
||||
let (mut branch, mut main) = (base.clone(), base.clone());
|
||||
branch.set_client_id(base.highest_id_space() + 1);
|
||||
branch.get_mut(r).unwrap().exports[0].suffix = "@b".into();
|
||||
main.get_mut(r).unwrap().exports[0].suffix = "@m".into();
|
||||
// Code links are one per language, not an item list: different links changed still conflict.
|
||||
branch.get_mut(r).unwrap().code_links[0].name = "B".into();
|
||||
main.get_mut(r).unwrap().code_links[1].name = "M".into();
|
||||
let p = plan(&base, &branch, &main).unwrap();
|
||||
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
assert_eq!(keys, [format!("{r}/exports")]);
|
||||
let mut keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
keys.sort();
|
||||
let mut want = [format!("{r}/codeLinks"), format!("{r}/exports")];
|
||||
want.sort();
|
||||
assert_eq!(keys, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactions_merge_item_by_item() {
|
||||
let ia = |duration_ms: u32| proto::Interaction {
|
||||
trigger: Default::default(),
|
||||
actions: vec![proto::Action::Back],
|
||||
transition: Default::default(),
|
||||
duration_ms,
|
||||
easing: Default::default(),
|
||||
delay_ms: 0,
|
||||
};
|
||||
let durations = |doc: &Document, r: NodeId| doc.node(r).unwrap().interactions.iter().map(|i| i.duration_ms).collect::<Vec<_>>();
|
||||
let (mut base, _, _, r) = files();
|
||||
base.get_mut(r).unwrap().interactions = vec![ia(100), ia(200)];
|
||||
let fork = || {
|
||||
let (mut branch, main) = (base.clone(), base.clone());
|
||||
branch.set_client_id(base.highest_id_space() + 1);
|
||||
(branch, main)
|
||||
};
|
||||
// The branch changes the first interaction and adds one; main changes the second.
|
||||
let (mut branch, mut main) = fork();
|
||||
branch.get_mut(r).unwrap().interactions[0].duration_ms = 150;
|
||||
branch.get_mut(r).unwrap().interactions.push(ia(300));
|
||||
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
|
||||
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [150, 250, 300]));
|
||||
// The branch deletes one while main leaves them alone.
|
||||
let (mut branch, mut main) = fork();
|
||||
branch.get_mut(r).unwrap().interactions.remove(0);
|
||||
main.get_mut(r).unwrap().fills[0] = red();
|
||||
both_ways(&base, &branch, &main, |doc| assert_eq!(durations(doc, r), [200]));
|
||||
// The same interaction changed both ways, or one deleted while the other side changed the
|
||||
// list, conflicts.
|
||||
for delete in [false, true] {
|
||||
let (mut branch, mut main) = fork();
|
||||
if delete {
|
||||
branch.get_mut(r).unwrap().interactions.remove(0);
|
||||
} else {
|
||||
branch.get_mut(r).unwrap().interactions[1].duration_ms = 220;
|
||||
}
|
||||
main.get_mut(r).unwrap().interactions[1].duration_ms = 250;
|
||||
let p = plan(&base, &branch, &main).unwrap();
|
||||
let keys: Vec<String> = p.conflicts.iter().map(|c| c.key.to_string()).collect();
|
||||
assert_eq!(keys, [format!("{r}/interactions")], "delete: {delete}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -61,7 +61,7 @@ impl<'de> Deserialize<'de> for Color {
|
||||
}
|
||||
|
||||
fn clamp01(v: f32) -> f32 {
|
||||
if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }
|
||||
if v.is_nan() { 0.0 } else { v.clamp(0.0, 1.0) }
|
||||
}
|
||||
|
||||
impl Default for Color {
|
||||
|
||||
@@ -80,7 +80,7 @@ fn blend_names() {
|
||||
#[test]
|
||||
fn nan_inputs_are_sanitized() {
|
||||
let c = Color::new(f32::NAN, 2.0, -1.0, f32::INFINITY);
|
||||
assert_eq!(c.to_rgba8(), [0, 255, 0, 0]);
|
||||
assert_eq!(c.to_rgba8(), [0, 255, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -106,3 +106,11 @@ fn crop_defaults_to_fill_and_roundtrips() {
|
||||
assert!((a - b).abs() < 1e-9);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infinite_channels_clamp() {
|
||||
let c = Color::parse_css("rgb(1e40, 0, 0)").unwrap();
|
||||
assert_eq!(c.r, 1.0);
|
||||
let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY);
|
||||
assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0));
|
||||
}
|
||||
@@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() {
|
||||
let fills = s.doc().unwrap().node(r).unwrap().fills.clone();
|
||||
assert_eq!(fills.len(), 2, "the second fill stays");
|
||||
assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) });
|
||||
assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity");
|
||||
assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included");
|
||||
assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) });
|
||||
// Removing the bound (first) fill takes the binding with it.
|
||||
ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0}));
|
||||
@@ -97,3 +97,77 @@ fn effect_moves_and_removals_reject_junk() {
|
||||
ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7}));
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
|
||||
}
|
||||
|
||||
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
|
||||
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
|
||||
}
|
||||
|
||||
fn brand_id(s: &Session) -> String {
|
||||
s.doc().unwrap().variables.get("brand").unwrap().id.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_paint_edits_detach_the_colour_binding() {
|
||||
// An edit that reported success keeps its colour: the binding it overwrote lets go (as
|
||||
// selection colours already do), instead of putting the variable's colour back.
|
||||
let edits = [
|
||||
("fill.set", json!({"color": "#00FF00"})),
|
||||
("node.set", json!({"props": {"fill": "#00FF00"}})),
|
||||
("node.set", json!({"props": {"fills": ["#00FF00"]}})),
|
||||
];
|
||||
for (cmd, mut p) in edits {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
p["ids"] = json!([r]);
|
||||
ex(&mut s, cmd, p.clone());
|
||||
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}");
|
||||
assert!(bound(&s, r).is_empty(), "{cmd} {p}");
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
|
||||
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable");
|
||||
}
|
||||
for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"}));
|
||||
p["ids"] = json!([r]);
|
||||
ex(&mut s, cmd, p);
|
||||
let n = s.doc().unwrap().node(r).unwrap();
|
||||
assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}");
|
||||
assert!(n.bound.is_empty(), "{cmd}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_bound_fills_adds_no_paint_back() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}}));
|
||||
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
|
||||
assert!(bound(&s, r).is_empty());
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
|
||||
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_opacity_edits_keep_the_colour_binding() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"}));
|
||||
assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable");
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5);
|
||||
// The same colour with another opacity, through node.set, keeps it too.
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}}));
|
||||
assert_eq!(bound(&s, r).len(), 1);
|
||||
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_set_with_their_binding_in_the_same_call_keep_it() {
|
||||
let (mut s, r) = setup();
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
|
||||
let brand = brand_id(&s);
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}}));
|
||||
assert_eq!(bound(&s, r), [("fill".to_string(), brand)]);
|
||||
assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies");
|
||||
}
|
||||
@@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result
|
||||
}
|
||||
return Ok(json!({"ids": ids, "operation": op}));
|
||||
}
|
||||
let out = crate::cmd::structure::wrap_boolean(s, p, op)?;
|
||||
let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?;
|
||||
// Operands of a new group follow its operation.
|
||||
if let Some(g) = crate::params::id_of(&out["id"]) {
|
||||
let doc = s.doc_mut()?;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
//! A colour variable's alpha applies once to the paint it is bound to (#802): a `#RRGGBBAA` fill keeps
|
||||
//! its alpha in the paint opacity, and binding a variable with alpha hands the variable the whole
|
||||
//! colour, so the two alphas don't multiply (canvas, SVG, `.fig` round trips, mode switches).
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// 0x80 / 255 and 0x40 / 255.
|
||||
const HALF: f32 = 128.0 / 255.0;
|
||||
const QUARTER: f32 = 64.0 / 255.0;
|
||||
|
||||
fn ex(s: &mut Session, cmd: &str, p: serde_json::Value) {
|
||||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"));
|
||||
}
|
||||
|
||||
/// A rectangle filled `#0D6EFD80` and a colour variable `tint` (Light `light`, Dark `dark`).
|
||||
fn setup(light: &str, dark: &str) -> (Session, NodeId) {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10, "props": {"fill": "#0D6EFD80"}})).unwrap();
|
||||
let r = NodeId(r["id"].as_u64().unwrap());
|
||||
ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light", "Dark"]}));
|
||||
ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "tint", "type": "color", "value": light}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "tint", "mode": "Dark", "value": dark}));
|
||||
(s, r)
|
||||
}
|
||||
|
||||
/// The fill's effective alpha (colour alpha times paint opacity).
|
||||
fn alpha(s: &Session, r: NodeId) -> f32 {
|
||||
s.doc().unwrap().node(r).unwrap().fills[0].solid_color().unwrap().a
|
||||
}
|
||||
|
||||
/// The alpha byte of the rectangle rendered on its own (transparent backdrop).
|
||||
fn pixel_alpha(s: &Session, r: NodeId) -> u8 {
|
||||
let pix = figcraft_render::render_node(s.doc().unwrap(), r, 1.0).unwrap();
|
||||
let i = ((5 * pix.width() + 5) * 4 + 3) as usize;
|
||||
pix.data()[i]
|
||||
}
|
||||
|
||||
fn near(a: f32, b: f32) -> bool {
|
||||
(a - b).abs() < 2e-3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hex_with_alpha_keeps_its_alpha_in_the_opacity() {
|
||||
let (s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
|
||||
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
|
||||
assert!(near(p.opacity, HALF));
|
||||
assert_eq!(pixel_alpha(&s, r), 128);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_variable_alpha_applies_once() {
|
||||
// The fill and the variable are both #0D6EFD80: the result is 50%, not 25%.
|
||||
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
|
||||
assert_eq!(pixel_alpha(&s, r), 128);
|
||||
let svg = figcraft_svg::export_svg(s.doc().unwrap(), &[r], &figcraft_svg::SvgOptions::default()).unwrap();
|
||||
assert!(svg.contains("fill=\"#0D6EFD\" fill-opacity=\"0.502\""), "{svg}");
|
||||
// Binding the same variable again changes nothing.
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
assert!(near(alpha(&s, r), HALF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbinding_keeps_the_colour_with_its_alpha_in_the_opacity() {
|
||||
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
assert_eq!(pixel_alpha(&s, r), 64);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null}));
|
||||
let p = &s.doc().unwrap().node(r).unwrap().fills[0];
|
||||
assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0x0D, 0x6E, 0xFD) });
|
||||
assert!(near(p.opacity, QUARTER), "{}", p.opacity);
|
||||
assert_eq!(pixel_alpha(&s, r), 64);
|
||||
// Undo restores the binding and its paint.
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(!s.doc().unwrap().node(r).unwrap().bound.is_empty());
|
||||
assert_eq!(pixel_alpha(&s, r), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modes_switch_the_alpha_without_accumulating() {
|
||||
let (mut s, r) = setup("#0D6EFD40", "#0D6EFD80");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
for (mode, want) in [("Dark", HALF), ("Light", QUARTER), ("Dark", HALF), ("Light", QUARTER)] {
|
||||
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": mode}));
|
||||
assert!(near(alpha(&s, r), want), "{mode}: {}", alpha(&s, r));
|
||||
}
|
||||
// An opacity set while bound multiplies on top and survives mode switches.
|
||||
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
|
||||
ex(&mut s, "variable.setMode", json!({"collection": "Tokens", "mode": "Dark"}));
|
||||
assert!(near(alpha(&s, r), HALF * 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_set_and_style_bindings_apply_the_alpha_once() {
|
||||
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
let var = s.doc().unwrap().variables.get("tint").unwrap().id.clone();
|
||||
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": var}}}));
|
||||
assert!(near(alpha(&s, r), HALF));
|
||||
ex(&mut s, "node.set", json!({"id": r.0, "props": {"boundVariables": {"fill": null}}}));
|
||||
assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, HALF));
|
||||
// A paint style made from a `#RRGGBBAA` colour, bound to the variable.
|
||||
let st = s.execute("style.create", &json!({"type": "paint", "name": "Tint", "value": "#0D6EFD80"})).unwrap()["id"].clone();
|
||||
ex(&mut s, "style.bind", json!({"style": st, "field": "fills/0/color", "variable": "tint"}));
|
||||
ex(&mut s, "style.apply", json!({"ids": [r.0], "style": st}));
|
||||
assert!(near(alpha(&s, r), HALF), "{}", alpha(&s, r));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fig_round_trip_keeps_the_alpha_once() {
|
||||
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"}));
|
||||
ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5}));
|
||||
let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap();
|
||||
let back = crate::cmd::file::load_fig(&bytes, "x").unwrap().unwrap().doc;
|
||||
let n = back.nodes().find(|n| !n.bound.is_empty()).unwrap();
|
||||
let a = n.fills[0].solid_color().unwrap().a;
|
||||
assert!(near(a, HALF * 0.5), "{a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_gives_a_translucent_stroke_its_alpha_once() {
|
||||
let (mut s, r) = setup("#0D6EFD80", "#0D6EFD40");
|
||||
ex(&mut s, "stroke.add", json!({"ids": [r.0], "color": "#FF000080"}));
|
||||
let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string();
|
||||
assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}");
|
||||
}
|
||||
@@ -528,6 +528,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (col, row) = string(p, "anchor").and_then(|a| crate::tools::anchor_of(&a)).unwrap_or((0, 0));
|
||||
let (ax, ay) = (f64::from(col) / 2.0, f64::from(row) / 2.0);
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let n = doc.get(i)?;
|
||||
let (ow, oh) = (n.width, n.height);
|
||||
@@ -551,6 +552,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
super::props::restore_worlds(doc, &kept)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
|
||||
|
||||
@@ -608,7 +608,7 @@ fn make_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?;
|
||||
let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name));
|
||||
let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null);
|
||||
add_prop(s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
|
||||
add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred}))
|
||||
}
|
||||
|
||||
fn remove_slot(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1039,7 +1039,12 @@ fn valid_prop_name(cmd: &str, name: &str) -> Result<String> {
|
||||
}
|
||||
|
||||
fn add_prop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.addProperty";
|
||||
add_prop_for("component.addProperty", s, p)
|
||||
}
|
||||
|
||||
/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its
|
||||
/// slot property here).
|
||||
fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?;
|
||||
let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?;
|
||||
let t = req_string(cmd, p, "type")?;
|
||||
|
||||
@@ -73,9 +73,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!("text.create", "Text", "{text, x, y, parent?, width? (fixed width wraps), props? (fontSize, fontFamily, …)}", true, |s, p| {
|
||||
create_kind(s, p, "text")
|
||||
}),
|
||||
spec!("vector.create", "Vector", "{path (SVG path data) | points [[x,y],..], closed?, x?, y?, parent?, props?}", true, |s, p| create_kind(
|
||||
s, p, "vector"
|
||||
)),
|
||||
spec!(
|
||||
"vector.create",
|
||||
"Vector",
|
||||
"{path (SVG path data) | points [[x,y],..], closed?, x?, y?, width?, height? (scale the path; one keeps its proportion), parent?, props?}",
|
||||
true,
|
||||
|s, p| create_kind(s, p, "vector")
|
||||
),
|
||||
spec!(
|
||||
"image.place",
|
||||
"Place image",
|
||||
@@ -162,6 +166,37 @@ pub fn parse_path_data(d: &str) -> Option<figcraft_geom::BezPath> {
|
||||
figcraft_geom::BezPath::from_svg(d).ok()
|
||||
}
|
||||
|
||||
/// How much a vector's path is scaled to take the size asked for: with both sides, each scales
|
||||
/// independently (as resizing a vector does); with one, the other keeps its proportion. A size on
|
||||
/// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0
|
||||
/// or less on an axis it has is refused rather than flattening it.
|
||||
fn vector_scale(width: Option<f64>, height: Option<f64>, bw: f64, bh: f64, what: &str) -> std::result::Result<(f64, f64), String> {
|
||||
let fit = |asked: Option<f64>, have: f64, axis: &str| match asked {
|
||||
None => Ok(1.0),
|
||||
Some(a) if have <= 1e-9 => {
|
||||
if a == 0.0 {
|
||||
Ok(1.0)
|
||||
} else {
|
||||
Err(format!("`{axis}` can't size {what} with no {} extent", if axis == "width" { "horizontal" } else { "vertical" }))
|
||||
}
|
||||
}
|
||||
Some(a) if a > 0.0 && (a / have).is_finite() => Ok(a / have),
|
||||
Some(_) => Err(format!("`{axis}` must be a positive, finite size: 0 or less would flatten {what}")),
|
||||
};
|
||||
Ok(match (width, height) {
|
||||
(Some(_), Some(_)) => (fit(width, bw, "width")?, fit(height, bh, "height")?),
|
||||
(Some(_), None) => {
|
||||
let s = fit(width, bw, "width")?;
|
||||
(s, s)
|
||||
}
|
||||
(None, Some(_)) => {
|
||||
let s = fit(height, bh, "height")?;
|
||||
(s, s)
|
||||
}
|
||||
(None, None) => (1.0, 1.0),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "node.create";
|
||||
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
|
||||
@@ -187,20 +222,32 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"Section" => (num_or(p, "width", 400.0), num_or(p, "height", 300.0)),
|
||||
_ => (num_or(p, "width", 100.0), num_or(p, "height", 100.0)),
|
||||
};
|
||||
// Vectors: path data or points define the shape and its box.
|
||||
// Vectors: path data or points define the shape and its box. `width`/`height`, when asked
|
||||
// for, scale the geometry: both sides independently, one side proportionally.
|
||||
let mut offset = (0.0, 0.0);
|
||||
let asked = (num(p, "width"), num(p, "height"));
|
||||
if let NodeKind::Vector { network } = &mut kind {
|
||||
if let Some(d) = string(p, "path") {
|
||||
let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?;
|
||||
use figcraft_geom::Shape;
|
||||
let bb = path.bounding_box();
|
||||
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
|
||||
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
|
||||
}
|
||||
path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0)));
|
||||
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?;
|
||||
if sx != 1.0 || sy != 1.0 {
|
||||
path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy));
|
||||
}
|
||||
*network = VectorNetwork::from_path(&path);
|
||||
if network.segments.is_empty() {
|
||||
return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area"));
|
||||
}
|
||||
if boolean(p, "closed") == Some(false) {
|
||||
network.regions.clear();
|
||||
}
|
||||
w = bb.width();
|
||||
h = bb.height();
|
||||
w = bb.width() * sx;
|
||||
h = bb.height() * sy;
|
||||
offset = (bb.x0, bb.y0);
|
||||
} else if let Some(Value::Array(pts)) = get(p, "points") {
|
||||
let pts: Vec<Point> = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect();
|
||||
@@ -208,10 +255,14 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "`points` needs at least two [x, y] pairs"));
|
||||
}
|
||||
let bb = figcraft_geom::points_bounds(pts.iter().copied()).unwrap_or_default();
|
||||
let local: Vec<Point> = pts.iter().map(|q| Point::new(q.x - bb.x0, q.y - bb.y0)).collect();
|
||||
if bb.width() <= 1e-9 && bb.height() <= 1e-9 {
|
||||
return Err(bad(cmd, "`points` draws nothing: a vector needs a visible line or area"));
|
||||
}
|
||||
let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a point set").map_err(|e| bad(cmd, e))?;
|
||||
let local: Vec<Point> = pts.iter().map(|q| Point::new((q.x - bb.x0) * sx, (q.y - bb.y0) * sy)).collect();
|
||||
*network = VectorNetwork::from_points(&local, boolean(p, "closed").unwrap_or(false));
|
||||
w = bb.width();
|
||||
h = bb.height();
|
||||
w = bb.width() * sx;
|
||||
h = bb.height() * sy;
|
||||
offset = (bb.x0, bb.y0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +261,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}),
|
||||
spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| {
|
||||
let i = req_index("effect.set", p, "index")?;
|
||||
let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||||
let e = super::props::effect_from("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?;
|
||||
for id in req_targets("effect.set", s, p)? {
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
if let Some(slot) = n.effects.get_mut(i) {
|
||||
@@ -931,9 +931,13 @@ pub fn opacity_from_digits(digits: &str) -> Option<f32> {
|
||||
fn opacity_keys(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let digits = req_string("object.opacityKeys", p, "digits")?;
|
||||
let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?;
|
||||
for i in req_targets("object.opacityKeys", s, p)? {
|
||||
s.doc_mut()?.get_mut(i)?.opacity = o;
|
||||
let ids = req_targets("object.opacityKeys", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
doc.get_mut(i)?.opacity = o;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
Ok(json!({"opacity": o}))
|
||||
}
|
||||
|
||||
@@ -1181,10 +1185,12 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let props = if skipped.contains(&i) { &without_layout } else { &props };
|
||||
super::props::apply("node.set", doc, i, props)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
if let Some(o) = pivot {
|
||||
for (i, q) in pinned {
|
||||
let d = o - doc.world_transform(i) * q;
|
||||
@@ -1342,7 +1348,7 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
|
||||
if let Some(b) = blend(p, "blend") {
|
||||
slot.blend = b;
|
||||
}
|
||||
super::lists::remap_stop_bindings(n, fills, at, &old);
|
||||
super::lists::rebind_edited_paint(n, fills, at, &old);
|
||||
if fills {
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
@@ -1376,8 +1382,8 @@ fn pattern_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn effect_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let e: Effect = match get(p, "effect") {
|
||||
Some(v) => serde_json::from_value(v.clone()).map_err(|e| bad("effect.add", e.to_string()))?,
|
||||
let e: Effect = match super::props::effect_from("effect.add", p, "effect")? {
|
||||
Some(e) => e,
|
||||
None => {
|
||||
let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into());
|
||||
Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))?
|
||||
|
||||
@@ -548,6 +548,7 @@ pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option<Result<figcraft_forma
|
||||
/// Link imported instances to their mains (layer sources, overrides found by comparing values),
|
||||
/// innermost first so outer instances settle last.
|
||||
fn link_instances(doc: &mut Document) {
|
||||
super::styles::unfold_imported_bound_alpha(doc);
|
||||
crate::style_vars::after_load(doc);
|
||||
super::styles::apply_paint_bindings_on_load(doc);
|
||||
let mut insts: Vec<(usize, NodeId)> =
|
||||
|
||||
@@ -112,7 +112,8 @@ pub fn stop_binding(field: &str) -> Option<usize> {
|
||||
/// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after
|
||||
/// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's
|
||||
/// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices.
|
||||
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
|
||||
/// Returns the removed stop's index.
|
||||
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) -> Option<usize> {
|
||||
use figcraft_color::PaintKind;
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) };
|
||||
@@ -122,11 +123,11 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
|
||||
// The first stop that differs is the removed one when the rest moved down unchanged.
|
||||
let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len());
|
||||
if a.stops.get(k + 1..) != b.stops.get(k..) {
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
Some(k)
|
||||
}
|
||||
(_, Some(PaintKind::Gradient(_))) => return,
|
||||
(_, Some(PaintKind::Gradient(_))) => return None,
|
||||
_ => None,
|
||||
};
|
||||
let keys: Vec<(String, Option<String>)> = n
|
||||
@@ -150,6 +151,42 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
|
||||
}
|
||||
}
|
||||
n.bound.extend(kept);
|
||||
removed
|
||||
}
|
||||
|
||||
/// After an explicit edit changed paint `index` of `n`'s fills (or strokes) from `old`: its stop
|
||||
/// bindings follow a removed stop, then each binding whose value the edit changed (the colour, the
|
||||
/// opacity, a stop's colour) lets go, so the edit stays instead of being put back from the
|
||||
/// variable. Bindings whose value the edit kept stay; a paint that is gone takes its bindings.
|
||||
pub(crate) fn rebind_edited_paint(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
|
||||
let removed = remap_stop_bindings(n, fills, index, old);
|
||||
let mut old = old.clone();
|
||||
if let (Some(k), figcraft_color::PaintKind::Gradient(g)) = (removed, &mut old.kind)
|
||||
&& k < g.stops.len()
|
||||
{
|
||||
g.stops.remove(k);
|
||||
}
|
||||
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }.cloned();
|
||||
n.bound.retain(|key, _| match list_binding(key) {
|
||||
Some((l, i, field)) if l == list && i == index => new.as_ref().is_some_and(|new| paint_field_kept(&old, new, field)),
|
||||
_ => true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether a paint's bound `field` (`color`, `opacity`, `stops/<j>/color`, …) has the same value in
|
||||
/// `new` as in `old`.
|
||||
fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, field: &str) -> bool {
|
||||
use figcraft_color::PaintKind;
|
||||
match field {
|
||||
"color" => matches!((&old.kind, &new.kind), (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) if a == b),
|
||||
"opacity" => old.opacity == new.opacity,
|
||||
_ => match (stop_binding(field), &old.kind, &new.kind) {
|
||||
(Some(j), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(j).map(|s| s.color) == b.stops.get(j).map(|s| s.color),
|
||||
(Some(_), _, _) => false,
|
||||
_ => old == new,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Where item `k` lands when the item at `from` moves to `to`.
|
||||
|
||||
@@ -61,6 +61,48 @@ pub fn snake(v: &Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` as an effect list, refusing keys an effect doesn't read: serde dropped them without a word,
|
||||
/// so a shadow given `offsetY` kept the default offset.
|
||||
pub fn effects_from(cmd: &str, v: &Value) -> Result<Vec<Effect>> {
|
||||
let effects: Vec<Effect> = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?;
|
||||
for (i, given) in v.as_array().into_iter().flatten().enumerate() {
|
||||
refuse_ignored_effect_keys(cmd, &format!("effects[{i}]"), given, |v| serde_json::from_value(snake(v)).ok())?;
|
||||
}
|
||||
Ok(effects)
|
||||
}
|
||||
|
||||
/// `p[key]` as one effect, read as serde reads it (its aliases too), refusing keys it doesn't read.
|
||||
pub fn effect_from(cmd: &str, p: &Value, key: &str) -> Result<Option<Effect>> {
|
||||
let Some(v) = get(p, key) else { return Ok(None) };
|
||||
let effect: Effect = serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("`{key}`: {e}")))?;
|
||||
refuse_ignored_effect_keys(cmd, &format!("`{key}`"), v, |v| serde_json::from_value(v.clone()).ok())?;
|
||||
Ok(Some(effect))
|
||||
}
|
||||
|
||||
/// Refuse the keys of effect `given` that `parse` ignores, naming the keys the effect takes. Set to
|
||||
/// null, a key it reads fails to parse or changes the effect; a key it ignores changes nothing.
|
||||
fn refuse_ignored_effect_keys(cmd: &str, at: &str, given: &Value, parse: impl Fn(&Value) -> Option<Effect>) -> Result<()> {
|
||||
let Some(fields) = given.as_object() else { return Ok(()) };
|
||||
let parsed = parse(given);
|
||||
let ignored: Vec<&str> = fields
|
||||
.iter()
|
||||
.filter(|(_, v)| !v.is_null())
|
||||
.filter(|(k, _)| {
|
||||
let mut probe = fields.clone();
|
||||
probe.insert((*k).clone(), Value::Null);
|
||||
parse(&Value::Object(probe)).is_some_and(|e| Some(e) == parsed)
|
||||
})
|
||||
.map(|(k, _)| k.as_str())
|
||||
.collect();
|
||||
if ignored.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let takes = parsed.and_then(|e| serde_json::to_value(e).ok()).map(crate::inspect::camel).unwrap_or_default();
|
||||
let kind = takes.get("type").and_then(Value::as_str).unwrap_or("effect").to_string();
|
||||
let names: Vec<&str> = takes.as_object().into_iter().flatten().map(|(k, _)| k.as_str()).collect();
|
||||
Err(bad(cmd, format!("{at}: unknown properties: {} (a {kind} takes {})", ignored.join(", "), names.join(", "))))
|
||||
}
|
||||
|
||||
pub(crate) fn enum_of<T: serde::de::DeserializeOwned>(v: &Value) -> Option<T> {
|
||||
let s = v.as_str()?;
|
||||
// Accept camelCase, snake/upper-case and spaced variants.
|
||||
@@ -297,6 +339,12 @@ pub const LAYOUT_KEYS: &[&str] = &[
|
||||
"gridRowTracks",
|
||||
];
|
||||
|
||||
/// Drop the bindings of auto layout fields (gap, padding…): they go with the auto layout, or
|
||||
/// re-applying them would bring it back.
|
||||
pub fn drop_layout_bindings(n: &mut Node) {
|
||||
n.bound.retain(|k, _| !LAYOUT_KEYS.contains(&k.as_str()));
|
||||
}
|
||||
|
||||
/// The auto layout of a frame-like node, created when it has none yet. Other nodes can't hold an
|
||||
/// auto layout: an auto layout key on them is refused, not stored where nothing reads it
|
||||
/// (`node.set` on several layers skips them instead, see `edit::set`).
|
||||
@@ -315,18 +363,32 @@ fn layout_mut<'a>(cmd: &str, k: &str, n: &'a mut Node) -> Result<&'a mut AutoLay
|
||||
Ok(ensure_layout(n))
|
||||
}
|
||||
|
||||
/// Apply a property map to one node. `x`/`y` are relative to the parent.
|
||||
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
|
||||
let mut unknown = Vec::new();
|
||||
// Geometry keys first so rotation applies to the final size.
|
||||
let order = |k: &str| match k {
|
||||
/// Replace a node's fills (or strokes). Bindings on the paints whose bound values the new list
|
||||
/// changed let go, as an explicit edit does (see `lists::rebind_edited_paint`).
|
||||
fn set_paints(n: &mut Node, fills: bool, paints: Vec<Paint>) {
|
||||
let old = std::mem::replace(if fills { &mut n.fills } else { &mut n.strokes }, paints);
|
||||
for (i, p) in old.iter().enumerate() {
|
||||
super::lists::rebind_edited_paint(n, fills, i, p);
|
||||
}
|
||||
}
|
||||
|
||||
/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable
|
||||
/// bindings last so paints set in the same call don't detach them.
|
||||
fn apply_order(k: &str) -> u8 {
|
||||
match k {
|
||||
"width" | "height" | "w" | "h" => 0,
|
||||
"rotation" => 2,
|
||||
"x" | "y" => 3,
|
||||
"boundVariables" => 4,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a property map to one node. `x`/`y` are relative to the parent.
|
||||
pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Value>) -> Result<()> {
|
||||
let mut unknown = Vec::new();
|
||||
let mut keys: Vec<(&String, &Value)> = props.iter().collect();
|
||||
keys.sort_by_key(|(k, _)| order(k));
|
||||
keys.sort_by_key(|(k, _)| apply_order(k));
|
||||
for (k, v) in keys {
|
||||
let k = k.as_str();
|
||||
match k {
|
||||
@@ -398,15 +460,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
|
||||
doc.get_mut(id)?.blend = b;
|
||||
}
|
||||
"fills" => doc.get_mut(id)?.fills = paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?,
|
||||
"fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
|
||||
"fill" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?;
|
||||
doc.get_mut(id)?.fills = vec![Paint::solid(c)];
|
||||
set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
|
||||
}
|
||||
"strokes" => doc.get_mut(id)?.strokes = paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?,
|
||||
"strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
|
||||
"stroke" => {
|
||||
let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?;
|
||||
doc.get_mut(id)?.strokes = vec![Paint::solid(c)];
|
||||
set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
|
||||
}
|
||||
"strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
|
||||
"strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
|
||||
@@ -462,7 +524,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
}
|
||||
}
|
||||
"cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
"effects" => doc.get_mut(id)?.effects = serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("effects: {e}")))?,
|
||||
"effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
|
||||
"clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
|
||||
"isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
|
||||
"devStatus" => {
|
||||
@@ -486,8 +548,14 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
return Err(bad(cmd, "auto layout needs a frame"));
|
||||
}
|
||||
match v {
|
||||
Value::Null => n.layout = None,
|
||||
Value::String(s) if s.eq_ignore_ascii_case("none") => n.layout = None,
|
||||
Value::Null => {
|
||||
n.layout = None;
|
||||
drop_layout_bindings(n);
|
||||
}
|
||||
Value::String(s) if s.eq_ignore_ascii_case("none") => {
|
||||
n.layout = None;
|
||||
drop_layout_bindings(n);
|
||||
}
|
||||
Value::String(s) => {
|
||||
let mode = match s.to_ascii_lowercase().as_str() {
|
||||
"horizontal" | "row" => LayoutMode::Horizontal,
|
||||
@@ -775,10 +843,15 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map<String, Valu
|
||||
for (field, var) in o {
|
||||
match var.as_str() {
|
||||
Some(vid) => {
|
||||
if n.bound.get(field).map(String::as_str) != Some(vid) {
|
||||
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, true);
|
||||
}
|
||||
n.bound.insert(field.clone(), vid.to_string());
|
||||
}
|
||||
None => {
|
||||
n.bound.remove(field);
|
||||
if n.bound.remove(field).is_some() {
|
||||
crate::cmd::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,13 @@ use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout
|
||||
/// add/remove, move to page and vector path edits.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Group)),
|
||||
spec!("object.group", "Group selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.group", Wrap::Group)),
|
||||
spec!("object.ungroup", "Ungroup selection", "{ids?}", true, ungroup),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, Wrap::Frame)),
|
||||
spec!("object.frameSelection", "Frame selection", "{ids?, name?}", true, |s, p| wrap(s, p, "object.frameSelection", Wrap::Frame)),
|
||||
spec!("object.removeFrame", "Remove frame", "{ids?}", true, ungroup),
|
||||
spec!(
|
||||
"object.convertToGroup",
|
||||
@@ -42,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Wrap in new section",
|
||||
"{ids?, name?} (layers on the page or in a section; the section wraps them with some room)",
|
||||
true,
|
||||
|s, p| wrap(s, p, Wrap::Section)
|
||||
|s, p| wrap(s, p, "object.wrapInSection", Wrap::Section)
|
||||
),
|
||||
spec!(
|
||||
"object.resizeToFit",
|
||||
@@ -71,7 +73,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
// Hugging needs auto layout and the children that filled have nothing to fill: both
|
||||
// become fixed (the frame keeps filling its own auto layout parent).
|
||||
let doc = s.doc_mut()?;
|
||||
doc.get_mut(i)?.layout = None;
|
||||
let n = doc.get_mut(i)?;
|
||||
n.layout = None;
|
||||
super::props::drop_layout_bindings(n);
|
||||
figcraft_layout::coherent_sizing(doc, i);
|
||||
}
|
||||
ok()
|
||||
@@ -128,26 +132,27 @@ const SECTION_PAD: f64 = 40.0;
|
||||
const BOOL_PARAMS: &str = "{ids?} — layers inside boolean groups: sets their own operation (how each combines with the operands below it); one boolean group: sets its operation (every operand follows it); otherwise wraps the layers in a new boolean group";
|
||||
|
||||
/// Wrap the selection in a new boolean group doing `op`.
|
||||
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, op: BoolOp) -> Result<Value> {
|
||||
wrap(s, p, Wrap::Bool(op))
|
||||
pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result<Value> {
|
||||
wrap(s, p, cmd, Wrap::Bool(op))
|
||||
}
|
||||
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place.
|
||||
fn wrap(s: &mut Session, p: &Value, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets("object.group", s, p)?;
|
||||
/// Wrap the selection in a new container at the topmost selected layer's place. Errors name `cmd`,
|
||||
/// the command that asked.
|
||||
fn wrap(s: &mut Session, p: &Value, cmd: &str, w: Wrap) -> Result<Value> {
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let ids = z_sorted(doc, &roots(doc, &ids));
|
||||
let top = *ids.last().ok_or_else(|| bad("object.group", "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad("object.group", "can't group a page"))?;
|
||||
let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?;
|
||||
let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "can't group a page"))?;
|
||||
// Sections sit on the page or in sections, never in frames or groups.
|
||||
let board = |doc: &Document, i: NodeId| {
|
||||
doc.node(i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.is_page() || matches!(p.kind, NodeKind::Section { .. }))
|
||||
};
|
||||
if matches!(w, Wrap::Section) && !ids.iter().all(|i| board(doc, *i)) {
|
||||
return Err(bad("object.wrapInSection", "only layers on the page or in a section can be wrapped in a section"));
|
||||
return Err(bad(cmd, "only layers on the page or in a section can be wrapped in a section"));
|
||||
}
|
||||
if !matches!(w, Wrap::Section) && ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))) {
|
||||
return Err(bad("object.group", "sections can't go in groups or frames"));
|
||||
return Err(bad(cmd, "sections can't go in groups or frames"));
|
||||
}
|
||||
let index = doc.index_in_parent(top).map(|i| i + 1);
|
||||
let mut bounds = doc.bounds_of(&ids).unwrap_or_default();
|
||||
@@ -285,7 +290,7 @@ fn convert_to_section(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
n.grids.clear();
|
||||
n.guides.clear();
|
||||
n.styles.grid = None;
|
||||
n.bound.retain(|k, _| !k.starts_with("padding") && !matches!(k.as_str(), "gap" | "counterAxisSpacing"));
|
||||
super::props::drop_layout_bindings(n);
|
||||
let kids = n.children.clone();
|
||||
for k in kids {
|
||||
// Out of the flow there is nothing to fill or to be absolute against.
|
||||
@@ -338,7 +343,7 @@ fn mask(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return ok();
|
||||
}
|
||||
// Group, then the bottom-most child is the mask.
|
||||
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), Wrap::Group)?;
|
||||
let r = wrap(s, &json!({"ids": ids, "name": "Mask group"}), "object.mask", Wrap::Group)?;
|
||||
let gid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("group failed".into()))?;
|
||||
let doc = s.doc_mut()?;
|
||||
if let Some(first) = doc.get(gid)?.children.first().copied() {
|
||||
@@ -472,7 +477,7 @@ fn outline_stroke(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let f = doc.get_mut(fill_id)?;
|
||||
f.strokes.clear();
|
||||
}
|
||||
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), Wrap::Group)?;
|
||||
let r = wrap(s, &json!({"ids": [fill_id, vid], "name": n.name}), "vector.outlineStroke", Wrap::Group)?;
|
||||
if let Some(g) = id_of(&r["id"]) {
|
||||
out.push(g);
|
||||
}
|
||||
@@ -519,7 +524,7 @@ fn layout_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ids
|
||||
} else {
|
||||
// Wrap non-frames in a new frame without a fill, then add auto layout to it.
|
||||
let r = wrap(s, &json!({"ids": ids}), Wrap::Frame)?;
|
||||
let r = wrap(s, &json!({"ids": ids}), "layout.add", Wrap::Frame)?;
|
||||
let fid = id_of(&r["id"]).ok_or_else(|| EngineError::Other("wrap failed".into()))?;
|
||||
let f = s.doc_mut()?.get_mut(fid)?;
|
||||
f.fills.clear();
|
||||
|
||||
@@ -110,16 +110,26 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
))
|
||||
}),
|
||||
spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| {
|
||||
let name = req_string("variable.createCollection", p, "name")?;
|
||||
let doc = s.doc_mut()?;
|
||||
if doc.variables.collections.iter().any(|c| c.name == name) {
|
||||
return Err(bad("variable.createCollection", format!("a collection named `{name}` already exists")));
|
||||
let cmd = "variable.createCollection";
|
||||
let name = req_string(cmd, p, "name")?.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(bad(cmd, "name can't be empty"));
|
||||
}
|
||||
let id = doc.new_collection_id();
|
||||
let modes: Vec<String> = match get(p, "modes") {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(),
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(|m| m.trim().to_string())).collect(),
|
||||
_ => vec!["Mode 1".into()],
|
||||
};
|
||||
if modes.is_empty() {
|
||||
return Err(bad(cmd, "a collection needs at least one mode"));
|
||||
}
|
||||
if modes.iter().any(String::is_empty) {
|
||||
return Err(bad(cmd, "mode names can't be empty"));
|
||||
}
|
||||
let doc = s.doc_mut()?;
|
||||
if doc.variables.collections.iter().any(|c| c.name == name) {
|
||||
return Err(bad(cmd, format!("a collection named `{name}` already exists")));
|
||||
}
|
||||
let id = doc.new_collection_id();
|
||||
let modes = modes.into_iter().enumerate().map(|(i, m)| Mode { id: format!("{id}/mode{}", i + 1), name: m }).collect();
|
||||
doc.variables.collections.push(Collection { id: id.clone(), name, modes, extends: None });
|
||||
Ok(json!({"id": id}))
|
||||
@@ -458,10 +468,19 @@ fn bind_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
match var {
|
||||
Some((_, kind)) if kind != want => return Err(bad(cmd, format!("`{field}` takes a {} variable, not a {} one", want.id(), kind.id()))),
|
||||
Some((id, _)) => {
|
||||
if style.bound.get(&key) != Some(&id)
|
||||
&& let StyleKind::Paint { paints } = &mut style.kind
|
||||
{
|
||||
rebind_paint_alpha(paints, &mut [], &key, true);
|
||||
}
|
||||
style.bound.insert(key, id);
|
||||
}
|
||||
None => {
|
||||
style.bound.remove(&key);
|
||||
if style.bound.remove(&key).is_some()
|
||||
&& let StyleKind::Paint { paints } = &mut style.kind
|
||||
{
|
||||
rebind_paint_alpha(paints, &mut [], &key, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The style takes its default-mode value, and each layer using it its own.
|
||||
@@ -770,16 +789,41 @@ pub fn bind(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let n = doc.get_mut(i)?;
|
||||
match &vid {
|
||||
Some(v) => {
|
||||
if n.bound.get(&field) != Some(v) {
|
||||
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true);
|
||||
}
|
||||
n.bound.insert(field.clone(), v.clone());
|
||||
}
|
||||
None => {
|
||||
n.bound.remove(&field);
|
||||
if n.bound.remove(&field).is_some() {
|
||||
rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts
|
||||
/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the
|
||||
/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a
|
||||
/// typed colour keeps it. An opacity set while bound multiplies on top and stays.
|
||||
pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
let Some((list, i, "color")) = list_binding(field) else { return };
|
||||
let paints = match list {
|
||||
BoundList::Fills => fills,
|
||||
BoundList::Strokes => strokes,
|
||||
_ => return,
|
||||
};
|
||||
if let Some(p) = paints.get_mut(i)
|
||||
&& let PaintKind::Solid { color } = &mut p.kind
|
||||
{
|
||||
p.opacity = if bind { 1.0 } else { p.opacity * color.a };
|
||||
color.a = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
/// `variable.setMode`: choose a collection's mode on frames (the page when none are given).
|
||||
pub fn set_mode(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "variable.setMode";
|
||||
@@ -916,6 +960,147 @@ pub fn apply_bindings_to(doc: &mut Document, only: Option<&std::collections::BTr
|
||||
apply_bindings_matching(doc, only, |_| true);
|
||||
}
|
||||
|
||||
/// The layers `ids` before an explicit edit, for [`detach_edited`] (only those with bindings).
|
||||
pub(crate) fn bound_before(doc: &Document, ids: &[NodeId]) -> Vec<std::sync::Arc<Node>> {
|
||||
ids.iter().filter_map(|i| doc.node_arc(*i)).filter(|n| !n.bound.is_empty()).cloned().collect()
|
||||
}
|
||||
|
||||
/// An explicit edit sticks: each layer's own bound field (not a list item's) whose value the edit
|
||||
/// changed drops its binding, which would otherwise write the variable's value back (#809).
|
||||
/// Compared by value, so keys written unchanged keep their bindings and fields moved along with
|
||||
/// another (proportions, min/max) lose theirs.
|
||||
pub(crate) fn detach_edited(doc: &mut Document, before: &[std::sync::Arc<Node>]) {
|
||||
for old in before {
|
||||
let Some(n) = doc.node(old.id) else { continue };
|
||||
let edited: Vec<String> = n
|
||||
.bound
|
||||
.keys()
|
||||
// A binding the edit itself made (`boundVariables`) stays.
|
||||
.filter(|f| old.bound.get(*f) == n.bound.get(*f))
|
||||
.filter(|f| super::lists::list_binding(f).is_none() && !f.starts_with(crate::components::PROP_BINDING))
|
||||
.filter(|f| bound_field(old, f) != bound_field(n, f))
|
||||
.cloned()
|
||||
.collect();
|
||||
if edited.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(n) = doc.get_mut(old.id) {
|
||||
for f in edited {
|
||||
n.bound.remove(&f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored value a layer-level binding writes, comparable (`None`: the layer has no such field).
|
||||
fn bound_field(n: &Node, field: &str) -> Option<String> {
|
||||
let text = |f: &dyn Fn(&TextStyle) -> String| match &n.kind {
|
||||
NodeKind::Text(t) => Some(std::iter::once(&t.style).chain(t.runs.iter().map(|r| &r.style)).map(f).collect::<Vec<_>>().join(",")),
|
||||
_ => None,
|
||||
};
|
||||
let layout = n.layout.as_ref();
|
||||
let cl = &n.child_layout;
|
||||
let pad = |i: usize| layout.and_then(|l| l.padding.get(i).copied());
|
||||
Some(match field {
|
||||
"width" => format!("{}", n.width),
|
||||
"height" => format!("{}", n.height),
|
||||
"minWidth" => format!("{:?}", cl.min_w),
|
||||
"maxWidth" => format!("{:?}", cl.max_w),
|
||||
"minHeight" => format!("{:?}", cl.min_h),
|
||||
"maxHeight" => format!("{:?}", cl.max_h),
|
||||
"opacity" => format!("{}", n.opacity),
|
||||
"visible" => format!("{}", n.visible),
|
||||
"gap" | "columnGap" => format!("{:?}", layout.map(|l| l.gap)),
|
||||
"crossGap" | "rowGap" => format!("{:?}", layout.map(|l| l.cross_gap)),
|
||||
"padding" => format!("{:?}", layout.map(|l| l.padding)),
|
||||
"paddingTop" => format!("{:?}", pad(0)),
|
||||
"paddingRight" => format!("{:?}", pad(1)),
|
||||
"paddingBottom" => format!("{:?}", pad(2)),
|
||||
"paddingLeft" => format!("{:?}", pad(3)),
|
||||
"paddingHorizontal" => format!("{:?}", (pad(1), pad(3))),
|
||||
"paddingVertical" => format!("{:?}", (pad(0), pad(2))),
|
||||
"cornerRadius" => format!("{:?}", n.corners.radii),
|
||||
"topLeftRadius" => format!("{:?}", n.corners.radii.first()),
|
||||
"topRightRadius" => format!("{:?}", n.corners.radii.get(1)),
|
||||
"bottomRightRadius" => format!("{:?}", n.corners.radii.get(2)),
|
||||
"bottomLeftRadius" => format!("{:?}", n.corners.radii.get(3)),
|
||||
"strokeWeight" => format!("{:?}", (n.stroke.weight, n.stroke.sides)),
|
||||
"strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => format!("{:?}", n.stroke.sides),
|
||||
"arcStart" | "arcSweep" | "arcInner" => match &n.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } => format!(
|
||||
"{}",
|
||||
if field == "arcStart" {
|
||||
start
|
||||
} else if field == "arcSweep" {
|
||||
sweep
|
||||
} else {
|
||||
inner
|
||||
}
|
||||
),
|
||||
_ => return None,
|
||||
},
|
||||
"characters" => match &n.kind {
|
||||
NodeKind::Text(t) => t.characters.clone(),
|
||||
_ => return None,
|
||||
},
|
||||
"paragraphSpacing" | "paragraphIndent" => match &n.kind {
|
||||
NodeKind::Text(t) => format!("{}", if field == "paragraphSpacing" { t.paragraph_spacing } else { t.paragraph_indent }),
|
||||
_ => return None,
|
||||
},
|
||||
"fontFamily" => text(&|s| s.font_family.clone())?,
|
||||
"fontStyle" => text(&|s| s.font_style.clone())?,
|
||||
"fontWeight" => text(&|s| s.weight().to_string())?,
|
||||
"fontSize" => text(&|s| s.font_size.to_string())?,
|
||||
"lineHeight" => text(&|s| format!("{:?}", s.line_height))?,
|
||||
"letterSpacing" => text(&|s| format!("{:?}", s.letter_spacing))?,
|
||||
// shortcut: other layer-level fields keep their binding on edits; add a reader when one is bound in practice.
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Imported files (`.fig`, REST) keep a solid paint's alpha in its opacity, a bound paint's too,
|
||||
/// while the bound colour brings its own alpha: take the variable's alpha back out of the opacity
|
||||
/// (styles' paints and layers') before the bindings are first resolved, so it applies once.
|
||||
pub(crate) fn unfold_imported_bound_alpha(doc: &mut Document) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
fn alphas(vars: &Variables, bound: &BTreeMap<String, VariableId>, modes: &BTreeMap<CollectionId, ModeId>) -> Vec<(BoundList, usize, f32)> {
|
||||
let found = bound.iter().filter_map(|(field, var)| match (list_binding(field)?, vars.resolve(var, modes)?) {
|
||||
((list @ (BoundList::Fills | BoundList::Strokes), i, "color"), VariableValue::Color(c)) if c.a > 0.0 && c.a < 1.0 => Some((list, i, c.a)),
|
||||
_ => None,
|
||||
});
|
||||
found.collect()
|
||||
}
|
||||
fn unfold(p: Option<&mut Paint>, a: f32) {
|
||||
if let Some(p) = p
|
||||
&& matches!(p.kind, PaintKind::Solid { .. })
|
||||
{
|
||||
p.opacity = (p.opacity / a).min(1.0);
|
||||
}
|
||||
}
|
||||
let pending = doc.take_dirty();
|
||||
for i in 0..doc.styles.len() {
|
||||
let found = doc.styles.get(i).map(|s| alphas(&doc.variables, &s.bound, &BTreeMap::new())).unwrap_or_default();
|
||||
if let Some(StyleKind::Paint { paints }) = doc.styles.get_mut(i).filter(|_| !found.is_empty()).map(|s| &mut s.kind) {
|
||||
for (_, j, a) in found {
|
||||
unfold(paints.get_mut(j), a);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ids: Vec<NodeId> = doc.nodes().filter(|n| !n.bound.is_empty()).map(|n| n.id).collect();
|
||||
for id in ids {
|
||||
let found = doc.node(id).map(|n| alphas(&doc.variables, &n.bound, &doc.modes_for(id))).unwrap_or_default();
|
||||
if found.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(n) = doc.get_mut(id) else { continue };
|
||||
for (list, j, a) in found {
|
||||
unfold(if list == BoundList::Fills { n.fills.get_mut(j) } else { n.strokes.get_mut(j) }, a);
|
||||
}
|
||||
}
|
||||
doc.take_dirty();
|
||||
doc.mark_dirty(pending);
|
||||
}
|
||||
|
||||
/// Settle imported paint colours before the document is first shown. Other bindings can affect
|
||||
/// layout or component structure, which must be resolved through the normal edit pipeline.
|
||||
pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
|
||||
@@ -930,6 +1115,8 @@ pub(crate) fn apply_paint_bindings_on_load(doc: &mut Document) {
|
||||
doc.mark_dirty(pending);
|
||||
}
|
||||
|
||||
/// Re-apply the variable bindings whose field passes `include`, on the `only` nodes (every bound
|
||||
/// node when `None`). Auto layout fields wait while their node has no auto layout.
|
||||
fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::BTreeSet<NodeId>>, include: impl Fn(&str) -> bool) {
|
||||
use super::lists::{BoundList, list_binding};
|
||||
if doc.variables.variables.is_empty() {
|
||||
@@ -944,6 +1131,7 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
|
||||
let modes = doc.modes_for(id);
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
let binds = n.bound.clone();
|
||||
let has_layout = n.layout.is_some();
|
||||
let mut sets = serde_json::Map::new();
|
||||
let mut effect_sets: Vec<(usize, String, VariableValue)> = Vec::new();
|
||||
let mut paint_sets: Vec<(bool, usize, Option<usize>, Color)> = Vec::new();
|
||||
@@ -953,6 +1141,10 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B
|
||||
if !include(&field) {
|
||||
continue;
|
||||
}
|
||||
// A binding never creates an auto layout: without one it waits for the next.
|
||||
if !has_layout && super::props::LAYOUT_KEYS.contains(&field.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let Some(val) = doc.variables.resolve(&var, &modes) else { continue };
|
||||
// One list item's value: a paint's colour, an effect's or a layout grid's field.
|
||||
if let Some((list, i, prop)) = list_binding(&field) {
|
||||
@@ -1104,7 +1296,6 @@ pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> {
|
||||
stack.extend(n.children.iter().copied());
|
||||
for paint in n.fills.iter().chain(n.strokes.iter()).filter(|p| p.visible) {
|
||||
if let Some(c) = paint.solid_color() {
|
||||
let c = c.with_alpha(c.a * paint.opacity);
|
||||
counts.entry(c.to_rgba8()).or_insert((c, 0)).1 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
|
||||
let ds = s.ds_mut()?;
|
||||
let before = ds.doc.clone();
|
||||
let doc = ds.doc_mut();
|
||||
let bound = super::styles::bound_before(doc, &[id]);
|
||||
if let NodeKind::Text(t) = &mut doc.get_mut(id)?.kind {
|
||||
if a == b {
|
||||
t.restyle(0, t.char_count(), &f);
|
||||
@@ -25,19 +26,21 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result<Value
|
||||
t.restyle(a, b, &f);
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &bound);
|
||||
crate::normalize(doc, &before);
|
||||
return ok();
|
||||
}
|
||||
let ids = req_targets("text", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
for i in ids {
|
||||
for d in doc.subtree(i) {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
||||
let n = t.char_count();
|
||||
t.restyle(0, n, &f);
|
||||
}
|
||||
let ids: Vec<NodeId> = ids.into_iter().flat_map(|i| doc.subtree(i)).collect();
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for d in ids {
|
||||
if let Some(NodeKind::Text(t)) = doc.node_mut(d).map(|n| &mut n.kind) {
|
||||
let n = t.char_count();
|
||||
t.restyle(0, n, &f);
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
|
||||
@@ -348,11 +351,13 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let c = string(p, "characters").or_else(|| string(p, "text")).ok_or_else(|| bad("text.set", "`characters` required"))?;
|
||||
let ids = req_targets("text.set", s, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let mut m = Map::new();
|
||||
m.insert("characters".into(), json!(c));
|
||||
super::props::apply("text.set", doc, i, &m)?;
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}),
|
||||
spec!(
|
||||
@@ -364,6 +369,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let ids = req_targets("text.style", s, p)?;
|
||||
let m = p.as_object().cloned().unwrap_or_default();
|
||||
let doc = s.doc_mut()?;
|
||||
let before = super::styles::bound_before(doc, &ids);
|
||||
for i in ids {
|
||||
let n = doc.get_mut(i)?;
|
||||
let NodeKind::Text(t) = &mut n.kind else { return Err(bad("text.style", format!("{i} is not text"))) };
|
||||
@@ -377,6 +383,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
super::props::apply_text_style("text.style", t, a, b, k, v)?;
|
||||
}
|
||||
}
|
||||
super::styles::detach_edited(doc, &before);
|
||||
ok()
|
||||
}
|
||||
),
|
||||
|
||||
@@ -288,7 +288,11 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet<VariableId>)
|
||||
doc.variables.variables.retain(|v| !gone.contains(&v.id));
|
||||
let users: Vec<NodeId> = doc.nodes().filter(|n| n.bound.values().any(|v| gone.contains(v))).map(|n| n.id).collect();
|
||||
for id in users {
|
||||
doc.get_mut(id)?.bound.retain(|_, v| !gone.contains(v));
|
||||
let n = doc.get_mut(id)?;
|
||||
for (field, _) in n.bound.iter().filter(|(_, v)| gone.contains(*v)) {
|
||||
super::styles::rebind_paint_alpha(&mut n.fills, &mut n.strokes, field, false);
|
||||
}
|
||||
n.bound.retain(|_, v| !gone.contains(v));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"variable.import",
|
||||
"Import variables",
|
||||
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}",
|
||||
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
|
||||
true,
|
||||
import
|
||||
),
|
||||
|
||||
@@ -256,7 +256,26 @@ fn stroke_color(n: &Node) -> Option<Color> {
|
||||
if !n.has_visible_stroke() {
|
||||
return None;
|
||||
}
|
||||
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color().map(|c| c.with_alpha(c.a * p.opacity)))
|
||||
n.strokes.iter().filter(|p| p.visible).find_map(|p| p.solid_color())
|
||||
}
|
||||
|
||||
/// The CSS borders a solid stroke makes: one `border`, or with individual stroke sides a border
|
||||
/// on each side that has a weight.
|
||||
fn css_borders(doc: &Document, n: &Node, t: &mut Tokens) -> Vec<(String, String)> {
|
||||
let Some(c) = stroke_color(n) else { return Vec::new() };
|
||||
let Some(sides) = n.stroke.sides else {
|
||||
let weight = tok(doc, n, &["strokeWeight"], format!("{}px", num(n.stroke.weight)), t);
|
||||
return vec![("border".into(), format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)))];
|
||||
};
|
||||
let color = tok(doc, n, &["stroke"], c.to_css(), t);
|
||||
let named = ["top", "right", "bottom", "left"].into_iter().zip(crate::cmd::props::STROKE_SIDES);
|
||||
named
|
||||
.zip(sides)
|
||||
.filter(|(_, weight)| *weight > 0.0)
|
||||
.map(|((side, field), weight)| {
|
||||
(format!("border-{side}"), format!("{} solid {color}", tok(doc, n, &[field], format!("{}px", num(weight)), t)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn drop_shadow(n: &Node) -> Option<&Shadow> {
|
||||
@@ -452,9 +471,8 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
||||
PaintKind::Pattern(_) => put("background", "url(pattern.png) repeat".into()),
|
||||
}
|
||||
}
|
||||
if let Some(c) = stroke_color(n) {
|
||||
let weight = tok(doc, n, &["strokeWeight"], px(n.stroke.weight), t);
|
||||
put("border", format!("{weight} solid {}", tok(doc, n, &["stroke"], c.to_css(), t)));
|
||||
for (key, value) in css_borders(doc, n, t) {
|
||||
put(&key, value);
|
||||
}
|
||||
let shadows: Vec<String> = n
|
||||
.effects
|
||||
@@ -858,11 +876,16 @@ pub(crate) fn swift(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra
|
||||
swift_layer(ctx, n, depth, out, extra);
|
||||
}
|
||||
|
||||
/// A layer name safe for a single-line `//` comment: line breaks become spaces.
|
||||
fn one_line(name: &str) -> String {
|
||||
name.replace(['\n', '\r', '\u{85}', '\u{2028}', '\u{2029}'], " ")
|
||||
}
|
||||
|
||||
fn swift_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
|
||||
let doc = ctx.doc;
|
||||
let pad = indent(depth);
|
||||
if ctx.too_deep(depth) {
|
||||
out.push_str(&format!("{pad}// {} …\n", n.name));
|
||||
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
|
||||
return;
|
||||
}
|
||||
// Nested instances are calls; an instance swap parameter is a view.
|
||||
@@ -1035,7 +1058,7 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) {
|
||||
let doc = ctx.doc;
|
||||
let pad = indent(depth);
|
||||
if ctx.too_deep(depth) {
|
||||
out.push_str(&format!("{pad}// {} …\n", n.name));
|
||||
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
|
||||
return;
|
||||
}
|
||||
// Nested instances are calls; an instance swap parameter is a composable.
|
||||
@@ -1168,4 +1191,26 @@ mod tests {
|
||||
assert!(kt.contains("Modifier.offset(7.dp, 9.dp)"));
|
||||
assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() {
|
||||
let mut s = Session::new();
|
||||
let name = "deep\nevil()\r\u{2028}more()";
|
||||
let mut parent = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 50, "name": name})).unwrap()["id"]).unwrap();
|
||||
let root = parent;
|
||||
for _ in 0..16 {
|
||||
parent =
|
||||
id_of(&s.execute("frame.create", &json!({"x": 1, "y": 1, "width": 40, "height": 40, "parent": parent, "name": name})).unwrap()["id"])
|
||||
.unwrap();
|
||||
}
|
||||
let doc = s.doc().unwrap();
|
||||
for lang in [CodeLang::SwiftUi, CodeLang::Compose] {
|
||||
let code = code_for(doc, root, lang);
|
||||
assert!(code.contains("// deep evil() more() …\n"), "{code}");
|
||||
for line in code.lines() {
|
||||
let t = line.trim_start();
|
||||
assert!(!t.starts_with("evil()") && !t.starts_with("more()"), "layer name leaked as code:\n{code}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -747,3 +747,29 @@ fn alt_dropping_a_library_component_swaps_to_it() {
|
||||
assert!(doc.node(glyph).unwrap().overrides.is_empty());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fill_set_on_a_bound_instance_layer_detaches_it_as_an_override() {
|
||||
// Without unbinding first: the explicit colour detaches the variable on the instance layer only,
|
||||
// and stays through main edits until the override is reset.
|
||||
let mut s = Session::new();
|
||||
let (chip, _) = bound_chip(&mut s);
|
||||
let i = crate::params::id_of(&ex(&mut s, "instance.create", json!({"component": chip, "x": 200, "y": 0}))["id"]).unwrap();
|
||||
let bg = find_path(s.doc().unwrap(), i, &["Bg"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [bg], "props": {"fill": "#ff00ff"}}));
|
||||
let n = s.doc().unwrap().node(bg).unwrap().clone();
|
||||
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "FF00FF");
|
||||
assert!(!n.bound.contains_key("fill"));
|
||||
assert!(n.overrides.contains("fills"));
|
||||
let main_bg = find_path(s.doc().unwrap(), chip, &["Bg"]).unwrap();
|
||||
ex(&mut s, "node.set", json!({"ids": [main_bg], "props": {"height": 28}}));
|
||||
ex(&mut s, "variable.set", json!({"variable": "Chip", "value": "#445566"}));
|
||||
let doc = s.doc().unwrap();
|
||||
healthy(doc);
|
||||
assert_eq!(doc.node(bg).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF00FF");
|
||||
assert!(doc.node(main_bg).unwrap().bound.contains_key("fill"), "the main keeps its binding");
|
||||
ex(&mut s, "component.resetOverride", json!({"id": bg, "group": "fills"}));
|
||||
let n = s.doc().unwrap().node(bg).unwrap();
|
||||
assert!(n.bound.contains_key("fill"));
|
||||
assert_eq!(n.fills[0].solid_color().unwrap().to_hex(), "445566");
|
||||
}
|
||||
@@ -194,3 +194,26 @@ fn only_frames_on_the_page_or_in_a_section_become_sections() {
|
||||
assert!(err.contains("holds sections"), "{err}");
|
||||
assert!(matches!(kind(&s, sec), NodeKind::Section { .. }), "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapping_errors_name_the_command_that_was_run() {
|
||||
// Grouping, framing, auto layout, masks and booleans share one wrapping step; a refusal names
|
||||
// the command that asked, not `object.group`.
|
||||
let mut s = Session::new();
|
||||
let page = s.page().unwrap();
|
||||
for cmd in ["object.group", "object.frameSelection", "layout.add", "bool.union"] {
|
||||
let err = s.execute(cmd, &json!({"ids": [page]})).unwrap_err().to_string();
|
||||
assert!(err.contains(&format!("`{cmd}`")), "{cmd}: {err}");
|
||||
}
|
||||
let r = rect(&mut s, page, 0.0, 0.0);
|
||||
let err = s.execute("object.mask", &json!({"ids": [page, r]})).unwrap_err().to_string();
|
||||
assert!(err.contains("`object.mask`"), "{err}");
|
||||
let section = id(ex(&mut s, "node.create", json!({"type": "section", "x": 300, "y": 0, "width": 100, "height": 100})));
|
||||
ex(&mut s, "node.set", json!({"ids": [section], "props": {"fills": ["#FFFFFF"], "strokes": ["#000000"], "strokeWeight": 1}}));
|
||||
let err = s.execute("vector.outlineStroke", &json!({"ids": [section]})).unwrap_err().to_string();
|
||||
assert!(err.contains("`vector.outlineStroke`"), "{err}");
|
||||
let f = id(ex(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100})));
|
||||
let inner = rect(&mut s, f, 10.0, 10.0);
|
||||
let err = s.execute("object.wrapInSection", &json!({"ids": [inner, r]})).unwrap_err().to_string();
|
||||
assert!(err.contains("`object.wrapInSection`"), "{err}");
|
||||
}
|
||||
@@ -51,6 +51,32 @@ fn effect_set_edits_noise_texture_and_glass() {
|
||||
assert!(s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "glass", "depth": "deep"}})).is_err());
|
||||
}
|
||||
|
||||
/// A key an effect doesn't have is refused, naming what it takes: serde dropped it without a word,
|
||||
/// so a shadow given `offsetX`/`offsetY` (the names variable bindings use) kept the default offset.
|
||||
#[test]
|
||||
fn effects_refuse_keys_they_dont_have() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s);
|
||||
let shadow = json!({"type": "dropShadow", "color": "#00000040", "offsetX": 0, "offsetY": 1, "radius": 3});
|
||||
let err = s.execute("node.set", &json!({"ids": [r], "props": {"effects": [shadow]}})).unwrap_err().to_string();
|
||||
assert!(err.contains("effects[0]") && err.contains("offsetX, offsetY") && err.contains("offset"), "{err}");
|
||||
assert!(s.doc().unwrap().node(r).unwrap().effects.is_empty(), "nothing stored");
|
||||
let err = s.execute("effect.add", &json!({"ids": [r], "effect": {"type": "layerBlur", "radious": 8}})).unwrap_err().to_string();
|
||||
assert!(err.contains("radious"), "{err}");
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": "dropShadow"})).unwrap();
|
||||
let err = s.execute("effect.set", &json!({"ids": [r], "index": 0, "effect": {"type": "dropShadow", "spred": 2}})).unwrap_err().to_string();
|
||||
assert!(err.contains("spred"), "{err}");
|
||||
// Every kind's own keys still go in, camelCase or snake_case, and so does what inspect reports.
|
||||
for ty in ["innerShadow", "layerBlur", "backgroundBlur", "noise", "texture", "glass"] {
|
||||
s.execute("effect.add", &json!({"ids": [r], "type": ty})).unwrap();
|
||||
}
|
||||
let reported = crate::inspect::node_json(s.doc().unwrap(), r, 0)["effects"].clone();
|
||||
s.execute("node.set", &json!({"ids": [r], "props": {"effects": reported}})).unwrap();
|
||||
let camel = json!({"type": "dropShadow", "offset": [0, 1], "showBehind": true, "visible": true});
|
||||
let snake = json!({"type": "innerShadow", "offset": [0, 1], "show_behind": false});
|
||||
s.execute("node.set", &json!({"ids": [r], "props": {"effects": [camel, snake]}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scaling_scales_grain_and_glass() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -62,7 +62,7 @@ impl Roots {
|
||||
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
|
||||
Ok(_) => {}
|
||||
// Why a path can't be resolved says what exists there: only told for paths inside a root.
|
||||
Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
|
||||
Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
|
||||
Err(_) => {}
|
||||
}
|
||||
if roots.is_empty() {
|
||||
@@ -79,9 +79,10 @@ impl Roots {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one
|
||||
/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix).
|
||||
fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
|
||||
/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and
|
||||
/// its longest leading part that exists resolved, so links on the way are followed as the roots'
|
||||
/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it).
|
||||
fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool {
|
||||
let Ok(abs) = std::path::absolute(path) else { return false };
|
||||
let mut lexical = PathBuf::new();
|
||||
for c in abs.components() {
|
||||
@@ -93,7 +94,18 @@ fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
|
||||
c => lexical.push(c),
|
||||
}
|
||||
}
|
||||
roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r)))
|
||||
let mut rest: Vec<&OsStr> = Vec::new();
|
||||
let mut at = lexical.as_path();
|
||||
let resolved = loop {
|
||||
if let Ok(mut base) = std::fs::canonicalize(at) {
|
||||
base.extend(rest.iter().rev());
|
||||
break base;
|
||||
}
|
||||
let (Some(name), Some(parent)) = (at.file_name(), at.parent()) else { return false };
|
||||
rest.push(name);
|
||||
at = parent;
|
||||
};
|
||||
roots.iter().any(|r| resolved.starts_with(r))
|
||||
}
|
||||
|
||||
/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed);
|
||||
|
||||
@@ -348,6 +348,10 @@ fn outside_paths_are_refused_alike_whatever_exists_there() {
|
||||
let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")];
|
||||
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) {
|
||||
outside.push(f.p("outside/dangling.txt"));
|
||||
// Reached through a link in the root, it is outside all the same.
|
||||
if symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) {
|
||||
outside.push(f.p("root/escape/dangling.txt"));
|
||||
}
|
||||
}
|
||||
for p in &outside {
|
||||
let e = refusal(p);
|
||||
@@ -359,6 +363,19 @@ fn outside_paths_are_refused_alike_whatever_exists_there() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_root_given_through_a_link_tells_why_paths_in_it_are_refused() {
|
||||
let f = Fixture::new("linked-root");
|
||||
if !symlink_dir(&f.0.join("root"), &f.0.join("via")) || !symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
|
||||
eprintln!("skipped: this system doesn't let tests make symbolic links");
|
||||
return;
|
||||
}
|
||||
// Like `/tmp/work` on macOS, where `/tmp` leads to `/private/tmp`.
|
||||
let r = Roots::new(&[], &[f.p("via")]).unwrap();
|
||||
let e = r.check(Path::new(&f.p("via/dangling.txt")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("leads nowhere"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_resolved_paths_count_as_the_apps_own_folder() {
|
||||
let f = Fixture::new("own");
|
||||
|
||||
@@ -214,6 +214,26 @@ fn css_for_common_layers() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Individual stroke sides are borders on those sides only: a header with a divider under it isn't
|
||||
/// boxed in, and a side bound to a variable names it.
|
||||
#[test]
|
||||
fn css_borders_follow_individual_stroke_sides() {
|
||||
let mut s = Session::new();
|
||||
let r = rect(&mut s, 0.0, 0.0, 100.0, 50.0, None);
|
||||
set(&mut s, &[r], json!({"strokes": ["#000000"], "strokeWeight": 1, "strokeSides": [0, 0, 2, 0]}));
|
||||
let c = css(&mut s, r);
|
||||
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
|
||||
assert!(!c.contains("border:") && !c.contains("border-top") && !c.contains("border-right") && !c.contains("border-left"), "{c}");
|
||||
|
||||
let col = ex(&mut s, "variable.createCollection", json!({"name": "Borders"}));
|
||||
let col = col["id"].as_str().unwrap().to_string();
|
||||
ex(&mut s, "variable.create", json!({"collection": col, "name": "border/top", "type": "number", "value": 3}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "strokeTopWeight", "variable": "border/top"}));
|
||||
let c = css(&mut s, r);
|
||||
assert!(c.contains("border-top: var(--border-top, 3px) solid #000000;"), "{c}");
|
||||
assert!(c.contains("border-bottom: 2px solid #000000;"), "{c}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_code_for_a_simple_layer() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -1098,8 +1098,11 @@ impl Session {
|
||||
let Some(it) = ds.interaction.clone() else { return Err(EngineError::Other("no gesture in progress".into())) };
|
||||
let mut doc = (*it.doc).clone();
|
||||
let mut sel = it.selection.clone();
|
||||
// What the gesture changes on the selected layers is an explicit edit.
|
||||
let before = cmd::styles::bound_before(&doc, &it.selection);
|
||||
let res = f(&mut doc, &mut sel);
|
||||
if res.is_ok() {
|
||||
cmd::styles::detach_edited(&mut doc, &before);
|
||||
normalize(&mut doc, &it.doc);
|
||||
// The same preview again (a handle held still) keeps the document: a new one would
|
||||
// re-render the canvas for nothing.
|
||||
@@ -1554,6 +1557,8 @@ mod binding_lists_tests;
|
||||
#[cfg(test)]
|
||||
mod booleans_tests;
|
||||
#[cfg(test)]
|
||||
mod bound_alpha_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_tests;
|
||||
#[cfg(test)]
|
||||
mod branch_versions_tests;
|
||||
@@ -1744,6 +1749,8 @@ mod proto_run_tests;
|
||||
#[cfg(test)]
|
||||
mod proto_tail_tests;
|
||||
#[cfg(test)]
|
||||
mod rest_slot_tests;
|
||||
#[cfg(test)]
|
||||
mod retained_walk_tests;
|
||||
#[cfg(test)]
|
||||
mod rotated_guides_tests;
|
||||
@@ -1816,6 +1823,8 @@ mod variables_behaviour_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_editor_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_import_shapes_tests;
|
||||
#[cfg(test)]
|
||||
mod variables_io_tests;
|
||||
#[cfg(test)]
|
||||
mod vector_behaviour_tests;
|
||||
|
||||
@@ -81,7 +81,7 @@ pub const PEOPLE: [&str; 8] = ["#0D99FF", "#9747FF", "#14AE5C", "#F24822", "#D97
|
||||
|
||||
/// `#RRGGBB` → (r, g, b); `None` for anything else.
|
||||
pub fn parse_color(s: &str) -> Option<(u8, u8, u8)> {
|
||||
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.is_ascii())?;
|
||||
let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?;
|
||||
let byte = |i: usize| hex.get(i..i + 2).and_then(|b| u8::from_str_radix(b, 16).ok());
|
||||
Some((byte(0)?, byte(2)?, byte(4)?))
|
||||
}
|
||||
|
||||
@@ -507,3 +507,11 @@ fn hosting_commands_check_what_they_are_given() {
|
||||
assert!(e.contains("join a file"), "{e}");
|
||||
assert!(s.execute("collab.report", &json!({"reason": " \n "})).unwrap_err().to_string().contains("few words"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_color_rejects_signs_and_non_hex() {
|
||||
use crate::live::parse_color;
|
||||
assert_eq!(parse_color("#+1+2+3"), None);
|
||||
assert_eq!(parse_color("#-1-2-3"), None);
|
||||
assert_eq!(parse_color("#0D99FF"), Some((13, 153, 255)));
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Slots opened from REST file JSON: instances keep their own slot content through edits and
|
||||
//! moves of the mains, and through saving.
|
||||
|
||||
use crate::Session;
|
||||
use crate::components::tests::{ex, healthy, idv};
|
||||
use crate::components::*;
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn at(x: f64, y: f64) -> Value {
|
||||
json!([[1, 0, x], [0, 1, y]])
|
||||
}
|
||||
|
||||
fn text(id: &str, s: &str) -> Value {
|
||||
json!({"id": id, "name": s, "type": "TEXT", "characters": s, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
|
||||
}
|
||||
|
||||
/// A Chip main, a Container main with an empty "Slot", and an instance of Container whose slot
|
||||
/// holds a text and a chip.
|
||||
fn file() -> Value {
|
||||
let slot = |id: &str, kids: Value| json!({"id": id, "name": "Slot", "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": kids});
|
||||
let guid = |l: u32| json!({"guid": {"sessionID": 5, "localID": l}});
|
||||
let chip = json!({"id": "2:3", "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20},
|
||||
"children": [{"id": "I2:3;1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}]});
|
||||
json!({"name": "Slots", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
|
||||
{"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
|
||||
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
|
||||
]},
|
||||
{"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": guid(1)}},
|
||||
"children": [text("1:4", "Title"), slot("1:5", json!([]))]},
|
||||
{"id": "2:1", "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(400.0, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": guid(7)}},
|
||||
"children": [text("I2:1;1:4", "Title"), slot("I2:1;1:5", json!([text("2:2", "Reports in progress"), chip]))]}
|
||||
]}]}})
|
||||
}
|
||||
|
||||
fn named(doc: &Document, kind: fn(&NodeKind) -> bool, name: &str) -> NodeId {
|
||||
doc.nodes().find(|n| n.name == name && kind(&n.kind)).map(|n| n.id).unwrap_or_else(|| panic!("no {name}"))
|
||||
}
|
||||
|
||||
/// The instance's slot and the names of what it holds.
|
||||
fn slot_content(s: &Session) -> (NodeId, Vec<String>) {
|
||||
let doc = s.doc().unwrap();
|
||||
let inst = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Container");
|
||||
let (slot, _) = slots_of(doc, inst).into_iter().next().expect("the instance has its slot");
|
||||
let names = doc.node(slot).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect();
|
||||
(slot, names)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rest_slot_content_survives_main_edits_moves_and_saving() {
|
||||
let mut s = Session::new();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&file()).unwrap());
|
||||
ex(&mut s, "file.open", json!({"data": data, "name": "slots.json"}));
|
||||
healthy(&s);
|
||||
let want = vec!["Reports in progress".to_string(), "Chip".to_string()];
|
||||
let (slot, names) = slot_content(&s);
|
||||
assert_eq!(names, want);
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(slot_name(doc, slot).as_deref(), Some("Slot"));
|
||||
assert!(slot_edited(doc, slot), "the instance's own content");
|
||||
let chip = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Chip");
|
||||
assert!(!inside_instance(doc, chip), "content stands alone");
|
||||
|
||||
// Edit and move both mains (the container's and the one used in the content).
|
||||
let main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Container");
|
||||
let chip_main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Chip");
|
||||
let title = doc.node(main).unwrap().children[0];
|
||||
ex(&mut s, "node.set", json!({"id": title, "props": {"fill": "#ff0000"}}));
|
||||
ex(&mut s, "node.set", json!({"id": main, "props": {"width": 320}}));
|
||||
let holder = idv(&ex(&mut s, "frame.create", json!({"x": -1000, "y": 0, "width": 900, "height": 900, "name": "Design system"})));
|
||||
ex(&mut s, "node.reparent", json!({"ids": [main, chip_main], "parent": holder}));
|
||||
healthy(&s);
|
||||
assert_eq!(slot_content(&s).1, want, "kept across syncs");
|
||||
|
||||
// Saving and opening again keeps it.
|
||||
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
let mut again = Session::new();
|
||||
ex(&mut again, "file.open", json!({"data": data, "name": "slots.figcraft"}));
|
||||
healthy(&again);
|
||||
let (slot, names) = slot_content(&again);
|
||||
assert_eq!(names, want);
|
||||
assert!(slot_edited(again.doc().unwrap(), slot));
|
||||
|
||||
// Resetting the slot shows the main's (empty) default content.
|
||||
ex(&mut again, "instance.resetSlot", json!({"id": slot}));
|
||||
assert!(slot_content(&again).1.is_empty());
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::lists::{BoundList, list_binding};
|
||||
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
|
||||
use crate::params::*;
|
||||
use crate::{CommandSpec, Result, Session, bad, spec};
|
||||
|
||||
@@ -153,37 +153,6 @@ fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>
|
||||
Ok(ts)
|
||||
}
|
||||
|
||||
/// Detach variables only where the explicit edit changed their value. In particular, an
|
||||
/// opacity edit keeps the color binding and a range edit keeps other characters' node bindings.
|
||||
fn unbind_changed(n: &mut figcraft_doc::Node, t: &PaintTarget, old: &Paint, new: &Paint) {
|
||||
// Range fills override the node's paints; the node binding still belongs to other chars.
|
||||
if t.range.is_some() {
|
||||
return;
|
||||
}
|
||||
let list = if t.fills { BoundList::Fills } else { BoundList::Strokes };
|
||||
n.bound.retain(|key, _| {
|
||||
let Some((l, index, field)) = list_binding(key) else { return true };
|
||||
if l != list || index != t.index {
|
||||
return true;
|
||||
}
|
||||
match field {
|
||||
"color" => match (&old.kind, &new.kind) {
|
||||
(PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) => a == b,
|
||||
_ => false,
|
||||
},
|
||||
"opacity" => old.opacity == new.opacity,
|
||||
_ => {
|
||||
let stop = field.strip_prefix("stops/").and_then(|s| s.strip_suffix("/color")).and_then(|s| s.parse::<usize>().ok());
|
||||
match (stop, &old.kind, &new.kind) {
|
||||
(Some(i), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(i).map(|s| s.color) == b.stops.get(i).map(|s| s.color),
|
||||
(Some(_), _, _) => false,
|
||||
_ => old == new,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "selection.setPaint";
|
||||
let targets = checked_targets(s, p, CMD)?;
|
||||
@@ -197,18 +166,22 @@ fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let old = slot.clone();
|
||||
*slot = paint.clone();
|
||||
let n = s.doc_mut()?.get_mut(target.id)?;
|
||||
unbind_changed(n, target, &old, &paint);
|
||||
// Variables detach only where the edit changed their value; range fills override the node's
|
||||
// paints, whose bindings still belong to the other characters.
|
||||
if let Some((start, end)) = target.range {
|
||||
if let NodeKind::Text(t) = &mut n.kind {
|
||||
t.restyle(start, end, |style| style.fills = Some(paints.clone()));
|
||||
}
|
||||
} else if target.fills {
|
||||
continue;
|
||||
}
|
||||
if target.fills {
|
||||
n.fills = paints;
|
||||
n.styles.fill = None;
|
||||
} else {
|
||||
n.strokes = paints;
|
||||
n.styles.stroke = None;
|
||||
}
|
||||
rebind_edited_paint(n, target.fills, target.index, &old);
|
||||
}
|
||||
Ok(json!({"ok": true}))
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ fn props_of(s: &mut Session, id: NodeId) -> Vec<Value> {
|
||||
ex(s, "component.properties", json!({"id": id}))["properties"].as_array().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A refused slot is reported by `component.makeSlot`, the command that was run, not by the
|
||||
/// property command it adds its slot property through.
|
||||
#[test]
|
||||
fn a_refused_slot_names_make_slot() {
|
||||
let mut s = Session::new();
|
||||
let (c, ..) = slot_card(&mut s);
|
||||
let other = idv(&ex(&mut s, "frame.create", json!({"x": 150, "y": 10, "width": 40, "height": 30, "parent": c, "name": "Other"})));
|
||||
let err = s.execute("component.makeSlot", &json!({"id": other, "name": "Content"})).unwrap_err().to_string();
|
||||
assert!(err.contains("`component.makeSlot`") && err.contains("a property named `Content` already exists"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_are_frames_of_a_main_with_a_slot_property() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -661,6 +661,9 @@ fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool
|
||||
/// handles: the canvas draws just its outline (the handles still answer just outside it).
|
||||
pub const SMALL_BOX_PX: f64 = 16.0;
|
||||
|
||||
/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same).
|
||||
pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0);
|
||||
|
||||
/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl).
|
||||
pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) {
|
||||
let [a, b, _, d] = corners;
|
||||
@@ -2241,8 +2244,8 @@ impl Session {
|
||||
let mut local = fitted.clone();
|
||||
local.apply_affine(pw.inverse());
|
||||
let _ = d;
|
||||
let mut props =
|
||||
json!({"fills": [], "strokes": ["#000000"], "strokeWeight": 2, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"});
|
||||
let (color, weight) = PENCIL_STROKE;
|
||||
let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"});
|
||||
// Pen pressure becomes the stroke's width profile (brushes follow it too).
|
||||
if let Some(points) = pressure_profile(points, pressure) {
|
||||
props["widthProfile"] = json!(points);
|
||||
|
||||
@@ -279,6 +279,34 @@ fn bindings_by_field_and_type() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_layout_bindings_go_with_the_auto_layout() {
|
||||
// Docs silent; pinned (#810): removing a frame's auto layout drops its gap and padding bindings,
|
||||
// and a binding never brings an auto layout back. One made without auto layout waits for it.
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "C"}));
|
||||
run(&mut s, "variable.create", json!({"collection": "C", "name": "g", "type": "number", "value": 12}));
|
||||
let removals =
|
||||
[("layout.remove", json!({})), ("node.set", json!({"props": {"layout": null}})), ("node.set", json!({"props": {"layout": "none"}}))];
|
||||
for (cmd, mut p) in removals {
|
||||
let f = frame(&mut s, None);
|
||||
run(&mut s, "layout.add", json!({"ids": [f]}));
|
||||
for field in ["gap", "crossGap", "paddingTop"] {
|
||||
run(&mut s, "variable.bind", json!({"ids": [f], "field": field, "variable": "g"}));
|
||||
}
|
||||
p["ids"] = json!([f]);
|
||||
run(&mut s, cmd, p.clone());
|
||||
let n = node(&s, f);
|
||||
assert!(n.layout.is_none(), "{cmd} {p}: auto layout stays removed");
|
||||
assert!(n.bound.is_empty(), "{cmd} {p}: layout bindings dropped");
|
||||
}
|
||||
let f = frame(&mut s, None);
|
||||
run(&mut s, "variable.bind", json!({"ids": [f], "field": "gap", "variable": "g"}));
|
||||
assert!(node(&s, f).layout.is_none(), "binding gap adds no auto layout");
|
||||
run(&mut s, "layout.add", json!({"ids": [f]}));
|
||||
assert_eq!(node(&s, f).layout.map(|l| l.gap), Some(12.0), "the waiting binding applies to the new auto layout");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_bound_variable_keeps_the_last_value() {
|
||||
// Documented: deleting a variable unbinds what used it, which keeps showing its last value.
|
||||
@@ -365,3 +393,90 @@ fn number_variables_are_pixels_and_edits_are_one_undo_step() {
|
||||
s.undo().unwrap();
|
||||
assert_eq!(lh(&s), LineHeight::Pixels(20.0), "the value and the bound layer go back together");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_edits_of_bound_fields_stick_and_detach_that_binding() {
|
||||
// #809: an edit of a bound field detaches its binding (as a paint edit does) instead of
|
||||
// the variable's value coming back; bindings whose value the edit kept stay.
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "C"}));
|
||||
let w = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "w", "type": "number", "value": 48})));
|
||||
let o = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "o", "type": "number", "value": 40})));
|
||||
let r = rect(&mut s, None);
|
||||
for (field, v) in [("width", &w), ("height", &w), ("opacity", &o)] {
|
||||
run(&mut s, "variable.bind", json!({"ids": [r], "field": field, "variable": v}));
|
||||
}
|
||||
assert_eq!((node(&s, r).width, node(&s, r).opacity), (48.0, 0.4));
|
||||
|
||||
run(&mut s, "node.set", json!({"ids": [r], "width": 100, "opacity": 0.4}));
|
||||
let n = node(&s, r);
|
||||
assert_eq!(n.width, 100.0);
|
||||
assert!(!n.bound.contains_key("width"));
|
||||
assert!(n.bound.contains_key("height") && n.bound.contains_key("opacity"), "unchanged values keep their bindings");
|
||||
|
||||
run(&mut s, "transform.resize", json!({"ids": [r], "height": 30}));
|
||||
assert_eq!(node(&s, r).height, 30.0);
|
||||
assert!(!node(&s, r).bound.contains_key("height"));
|
||||
|
||||
run(&mut s, "object.opacityKeys", json!({"ids": [r], "digits": "9"}));
|
||||
assert_eq!(node(&s, r).opacity, 0.9);
|
||||
assert!(node(&s, r).bound.is_empty());
|
||||
|
||||
// A value set with its binding in the same call keeps the new binding.
|
||||
run(&mut s, "node.set", json!({"ids": [r], "width": 5, "boundVariables": {"width": w}}));
|
||||
assert_eq!((node(&s, r).width, node(&s, r).bound.get("width")), (48.0, Some(&w)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() {
|
||||
let mut s = Session::new();
|
||||
run(&mut s, "variable.createCollection", json!({"name": "C"}));
|
||||
let v = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "r", "type": "number", "value": 8})));
|
||||
let r = rect(&mut s, None);
|
||||
run(&mut s, "variable.bind", json!({"ids": [r], "field": "cornerRadius", "variable": v}));
|
||||
s.begin_interaction("Edit").unwrap();
|
||||
let props = serde_json::Map::from_iter([("cornerRadius".to_string(), json!(2))]);
|
||||
s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap();
|
||||
s.commit_interaction().unwrap();
|
||||
assert_eq!(node(&s, r).corners.radii, [2.0; 4]);
|
||||
assert!(node(&s, r).bound.is_empty());
|
||||
|
||||
let t = id(run(&mut s, "text.create", json!({"x": 0, "y": 0, "text": "Hi"})));
|
||||
run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v}));
|
||||
run(&mut s, "text.style", json!({"id": t, "fontSize": 20}));
|
||||
assert!(node(&s, t).bound.is_empty());
|
||||
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 20.0);
|
||||
|
||||
// The font size/weight/tracking shortcuts and text content edits detach too.
|
||||
run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v}));
|
||||
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 8.0);
|
||||
run(&mut s, "text.sizeUp", json!({"ids": [t]}));
|
||||
assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 9.0);
|
||||
assert!(node(&s, t).bound.is_empty());
|
||||
let c = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "c", "type": "string", "value": "Hello"})));
|
||||
run(&mut s, "variable.bind", json!({"ids": [t], "field": "characters", "variable": c}));
|
||||
run(&mut s, "text.set", json!({"id": t, "characters": "Bye"}));
|
||||
assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye");
|
||||
assert!(node(&s, t).bound.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_collection_keeps_at_least_one_named_mode() {
|
||||
// A collection keeps at least one mode (removeMode) and mode names can't be empty (renameMode),
|
||||
// so createCollection refuses an empty mode list, blank mode names and a blank collection name.
|
||||
let mut s = Session::new();
|
||||
for p in [
|
||||
json!({"name": "C", "modes": []}),
|
||||
json!({"name": "C", "modes": [1]}),
|
||||
json!({"name": "C", "modes": ["", " "]}),
|
||||
json!({"name": "C", "modes": ["A", " "]}),
|
||||
json!({"name": " "}),
|
||||
json!({"name": ""}),
|
||||
] {
|
||||
assert!(s.execute("variable.createCollection", &p).is_err(), "{p} should be refused");
|
||||
}
|
||||
assert!(s.doc().unwrap().variables.collections.is_empty(), "a refused call creates nothing");
|
||||
run(&mut s, "variable.createCollection", json!({"name": "C", "modes": [" Light ", "Dark"]}));
|
||||
let names: Vec<String> = s.doc().unwrap().variables.collections[0].modes.iter().map(|m| m.name.clone()).collect();
|
||||
assert_eq!(names, ["Light", "Dark"]);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! `variable.import` reads `variables` next to `variableCollections`, under `meta` or at the top
|
||||
//! level, and never imports nothing without saying so (#790).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn collections() -> Value {
|
||||
json!({"VC:1": {"id": "VC:1", "name": "Theme", "modes": [{"modeId": "1:0", "name": "Light"}], "defaultModeId": "1:0",
|
||||
"variableIds": ["VariableID:1"]}})
|
||||
}
|
||||
|
||||
fn variables() -> Value {
|
||||
json!({"VariableID:1": {"id": "VariableID:1", "name": "bg", "variableCollectionId": "VC:1", "resolvedType": "COLOR",
|
||||
"valuesByMode": {"1:0": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}}}})
|
||||
}
|
||||
|
||||
fn import(json: Value) -> Value {
|
||||
let mut s = Session::new();
|
||||
match s.execute("variable.import", &json!({"variables": json})) {
|
||||
Ok(r) => r,
|
||||
Err(e) => panic!("{e}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_import_reads_every_shape() {
|
||||
for shape in [
|
||||
json!({"meta": {"variableCollections": collections(), "variables": variables()}}),
|
||||
json!({"variableCollections": collections(), "variables": variables()}),
|
||||
json!({"meta": {"variableCollections": collections()}, "variables": variables()}),
|
||||
json!({"variableCollections": collections(), "meta": {"variables": variables()}}),
|
||||
] {
|
||||
let r = import(shape.clone());
|
||||
assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(1)), "{shape}: {r}");
|
||||
assert_eq!(r["warnings"], json!([]), "{shape}: {r}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_import_never_imports_nothing_silently() {
|
||||
// Collections without variables: they are added, with a warning saying where variables go.
|
||||
let r = import(json!({"meta": {"variableCollections": collections()}}));
|
||||
assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(0)), "{r}");
|
||||
assert!(r["warnings"].to_string().contains("no variables found: expected `meta.variables`"), "{r}");
|
||||
// An empty file's export reads back with the same warning, not an error.
|
||||
let mut s = Session::new();
|
||||
let text = s.execute("variable.export", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
|
||||
let r = s.execute("variable.import", &json!({"variables": text})).unwrap();
|
||||
assert!(r["warnings"].to_string().contains("no variables found"), "{r}");
|
||||
// Collections listing variables that aren't there.
|
||||
let mut colls = collections();
|
||||
colls["VC:1"]["variableIds"] = json!(["VariableID:1", "VariableID:2"]);
|
||||
let r = import(json!({"meta": {"variableCollections": colls, "variables": variables()}}));
|
||||
assert_eq!(r["variables"].as_u64(), Some(1), "{r}");
|
||||
assert!(r["warnings"].to_string().contains("1 of the 2 variables the collections list in `variableIds` weren't imported"), "{r}");
|
||||
// No collections anywhere: an error naming the expected shape.
|
||||
let e = Session::new().execute("variable.import", &json!({"variables": {"variables": variables()}})).unwrap_err().to_string();
|
||||
assert!(e.contains("(`meta.variableCollections` and `meta.variables`)"), "{e}");
|
||||
}
|
||||
@@ -371,3 +371,29 @@ fn variant_properties_follow_string_variables() {
|
||||
assert_eq!(main(&s), variants[1]);
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() {
|
||||
let mut s = Session::new();
|
||||
let r = gradient_rect(&mut s);
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/2/color", "variable": "brand"}));
|
||||
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/1/color", "variable": "ink"}));
|
||||
let cur = |s: &Session| s.doc().unwrap().node(r).unwrap().fills[0].clone();
|
||||
// Removing the first stop moves both bindings down with their stops.
|
||||
let mut p = cur(&s);
|
||||
if let PaintKind::Gradient(g) = &mut p.kind {
|
||||
g.stops.remove(0);
|
||||
}
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
|
||||
assert_eq!(bound_keys(&s, r), ["fills/0/stops/0/color", "fills/0/stops/1/color"]);
|
||||
// Recolouring one bound stop detaches that stop only.
|
||||
let mut p = cur(&s);
|
||||
if let PaintKind::Gradient(g) = &mut p.kind {
|
||||
g.stops[0].color = Color::WHITE;
|
||||
}
|
||||
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
|
||||
assert_eq!(bound_keys(&s, r), ["fills/0/stops/1/color"]);
|
||||
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000ff"}));
|
||||
assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]);
|
||||
healthy(&s);
|
||||
}
|
||||
@@ -439,3 +439,61 @@ fn bend_paint_bucket_cut_join_and_eraser_tools() {
|
||||
assert_eq!(net(&s, id).segments.len(), 2, "the eraser removes what it passes over");
|
||||
assert_eq!(s.doc().unwrap().node(page).unwrap().children, vec![id], "and the pieces stay one layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_paths_take_the_requested_size_and_refuse_empty_geometry() {
|
||||
let mut s = Session::new();
|
||||
let path = "M0 0 L24 0 L24 24 L0 24 Z";
|
||||
let intrinsic = id_of(&ex(&mut s, "vector.create", json!({"path": path}))["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(intrinsic).unwrap();
|
||||
assert_eq!((n.width, n.height), (24.0, 24.0), "a path without a size keeps its intrinsic box");
|
||||
// Both sides: each scales independently, like resizing a vector.
|
||||
let both = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16, "height": 8}))["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(both).unwrap();
|
||||
assert_eq!((n.width, n.height), (16.0, 8.0));
|
||||
let n = net(&s, both);
|
||||
let max = n.vertices.iter().fold((0.0f64, 0.0f64), |(x, y), v| (x.max(v.x), y.max(v.y)));
|
||||
assert!((max.0 - 16.0).abs() < 1e-9 && (max.1 - 8.0).abs() < 1e-9, "{max:?}: the geometry itself is scaled");
|
||||
// One side: the other keeps its proportion.
|
||||
let width = id_of(&ex(&mut s, "vector.create", json!({"path": path, "width": 16}))["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(width).unwrap();
|
||||
assert_eq!((n.width, n.height), (16.0, 16.0));
|
||||
// A path or point set that draws nothing is an error, not an invisible zero-size layer.
|
||||
let e = s.execute("vector.create", &json!({"path": "M5 5"})).unwrap_err().to_string();
|
||||
assert!(e.contains("draws nothing"), "{e}");
|
||||
let e = s.execute("vector.create", &json!({"points": [[5, 5], [5, 5]]})).unwrap_err().to_string();
|
||||
assert!(e.contains("draws nothing"), "{e}");
|
||||
// A size on an axis the geometry has none of is refused, not silently ignored.
|
||||
let e = s.execute("vector.create", &json!({"path": "M0 0 L0 24", "width": 16})).unwrap_err().to_string();
|
||||
assert!(e.contains("horizontal extent"), "{e}");
|
||||
let e = s.execute("vector.create", &json!({"path": "M0 0 L24 0", "height": 16})).unwrap_err().to_string();
|
||||
assert!(e.contains("vertical extent"), "{e}");
|
||||
// But 0 on an axis it has none of is what it already is: a horizontal line with height 0.
|
||||
let line = id_of(&ex(&mut s, "vector.create", json!({"path": "M0 0 L24 0", "width": 16, "height": 0}))["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(line).unwrap();
|
||||
assert_eq!((n.width, n.height), (16.0, 0.0));
|
||||
// A size of 0 or less on an axis it has would flatten it, so it is refused, alone or with the other.
|
||||
for p in [json!({"path": path, "width": 0}), json!({"path": path, "width": 16, "height": 0}), json!({"path": path, "width": -4, "height": 8})] {
|
||||
let e = s.execute("vector.create", &p).unwrap_err().to_string();
|
||||
assert!(e.contains("positive"), "{p}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vector_points_take_the_requested_size() {
|
||||
let mut s = Session::new();
|
||||
let points = json!([[10, 20], [34, 20], [34, 32]]);
|
||||
let corners = |s: &Session, id: NodeId| net(s, id).vertices.iter().map(|v| (v.x, v.y)).collect::<Vec<_>>();
|
||||
// Both sides: each axis scales on its own; the vertices move with the box.
|
||||
let both = id_of(&ex(&mut s, "vector.create", json!({"points": points, "x": 10, "y": 20, "width": 48, "height": 6}))["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(both).unwrap();
|
||||
assert_eq!((n.width, n.height), (48.0, 6.0));
|
||||
assert_eq!(corners(&s, both), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 6.0)]);
|
||||
// One side: the other keeps its proportion.
|
||||
let one = id_of(&ex(&mut s, "vector.create", json!({"points": points, "height": 24}))["id"]).unwrap();
|
||||
let n = s.doc().unwrap().node(one).unwrap();
|
||||
assert_eq!((n.width, n.height), (48.0, 24.0));
|
||||
assert_eq!(corners(&s, one), vec![(0.0, 0.0), (48.0, 0.0), (48.0, 24.0)]);
|
||||
let e = s.execute("vector.create", &json!({"points": points, "width": 16, "height": -1})).unwrap_err().to_string();
|
||||
assert!(e.contains("positive"), "{e}");
|
||||
}
|
||||
+122
-13
@@ -6,8 +6,8 @@
|
||||
//! (`relativeTransform`, else `absoluteBoundingBox` + `rotation`), paints, strokes, effects,
|
||||
//! corners, constraints, auto layout, layout grids, export settings, text with styled runs,
|
||||
//! vectors from `fillGeometry` / `strokeGeometry`, components, component sets, instances and
|
||||
//! component properties are imported. Image fills keep their `imageRef` as the image key and
|
||||
//! show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched).
|
||||
//! component properties (slots included) are imported. Image fills keep their `imageRef` as the
|
||||
//! image key and show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched).
|
||||
//! Prototyping comes along: interactions (triggers, actions, transitions, easing), flows,
|
||||
//! scrolling and the prototype device. Variables come from the local variables response
|
||||
//! (`GET /v1/files/:key/variables/local`), embedded next to the file JSON or passed separately
|
||||
@@ -37,6 +37,8 @@ pub(crate) mod proto;
|
||||
#[cfg(test)]
|
||||
mod proto_variable_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod slot_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod text;
|
||||
mod variables;
|
||||
@@ -106,12 +108,25 @@ pub fn is_variables_json(v: &Value) -> bool {
|
||||
}
|
||||
|
||||
/// The collections and variables of a local variables response (or its `meta`), checked like a
|
||||
/// file's, with the warnings about what was skipped; None when `v` isn't one.
|
||||
/// file's, with the warnings about what was skipped (and when it holds no variables at all, or
|
||||
/// fewer than its collections list); None when `v` isn't one.
|
||||
pub fn read_variables(v: &Value) -> Option<(figcraft_doc::Variables, Vec<String>)> {
|
||||
let src = variables::source(v)?;
|
||||
variables::source(v)?;
|
||||
let mut cx = Ctx::new(Document::new("Variables"), &no_images);
|
||||
cx.variables(src);
|
||||
Some((std::mem::take(&mut cx.doc.variables), cx.warnings.finish()))
|
||||
cx.variables(v);
|
||||
let vars = std::mem::take(&mut cx.doc.variables);
|
||||
let mut warnings = cx.warnings.finish();
|
||||
if vars.variables.is_empty() {
|
||||
warnings.push("no variables found: expected `meta.variables` (an object of variables by id, next to `meta.variableCollections`)".into());
|
||||
} else {
|
||||
let listed = variables::listed_ids(v);
|
||||
let read: BTreeSet<&str> = vars.variables.iter().map(|x| x.id.as_str()).collect();
|
||||
let missing = listed.difference(&read).count();
|
||||
if missing > 0 {
|
||||
warnings.push(format!("{missing} of the {} variables the collections list in `variableIds` weren't imported", listed.len()));
|
||||
}
|
||||
}
|
||||
Some((vars, warnings))
|
||||
}
|
||||
|
||||
/// Import `bytes` when they are REST API file JSON; `None` when they are something else (the
|
||||
@@ -191,7 +206,7 @@ fn import_parts(
|
||||
cx.collect_meta(e);
|
||||
}
|
||||
// Variables first: layers link to them as they are built.
|
||||
for src in [variables::source(v), variables.and_then(variables::source)].into_iter().flatten() {
|
||||
for src in [Some(v), variables].into_iter().flatten() {
|
||||
cx.variables(src);
|
||||
}
|
||||
if variables.is_some_and(|x| variables::source(x).is_none()) {
|
||||
@@ -368,6 +383,10 @@ pub(crate) struct Ctx<'a> {
|
||||
style_meta: BTreeMap<&'a str, &'a Value>,
|
||||
/// Instances (imported as frames until their main is resolved) with their JSON.
|
||||
instances: Vec<(NodeId, &'a Value)>,
|
||||
/// `SLOT` layers (imported as frames until their slot property is resolved) with their JSON.
|
||||
slots: Vec<(NodeId, &'a Value)>,
|
||||
/// Slot property defaults (the main's slot content id): (component or set, property) → value.
|
||||
slot_defaults: BTreeMap<(NodeId, String), &'a Value>,
|
||||
/// Instance-swap property defaults: (component or set, property, REST id of the default).
|
||||
swap_defaults: Vec<(NodeId, String, String)>,
|
||||
/// Property definitions of component sets, shared by their variants.
|
||||
@@ -402,6 +421,8 @@ impl<'a> Ctx<'a> {
|
||||
set_meta: BTreeMap::new(),
|
||||
style_meta: BTreeMap::new(),
|
||||
instances: Vec::new(),
|
||||
slots: Vec::new(),
|
||||
slot_defaults: BTreeMap::new(),
|
||||
swap_defaults: Vec::new(),
|
||||
set_defs: BTreeMap::new(),
|
||||
flows: Vec::new(),
|
||||
@@ -502,8 +523,10 @@ impl<'a> Ctx<'a> {
|
||||
sources.record(id, j, primary);
|
||||
}
|
||||
}
|
||||
if ty == "INSTANCE" {
|
||||
self.instances.push((id, j));
|
||||
match ty {
|
||||
"INSTANCE" => self.instances.push((id, j)),
|
||||
"SLOT" => self.slots.push((id, j)),
|
||||
_ => {}
|
||||
}
|
||||
let kids = children(j);
|
||||
if kids.is_empty() {
|
||||
@@ -526,8 +549,8 @@ impl<'a> Ctx<'a> {
|
||||
let point_count = || num(j, "pointCount").filter(|c| *c >= 3.0).map(|c| c.min(1000.0) as u32);
|
||||
let has_geometry = !list(j, "fillGeometry").is_empty() || !list(j, "strokeGeometry").is_empty();
|
||||
Some(match ty {
|
||||
// Instances start as frames; `finish` links them to their main.
|
||||
"FRAME" | "INSTANCE" => NodeKind::Frame,
|
||||
// Instances and slots start as frames; `finish` links them to their main and property.
|
||||
"FRAME" | "INSTANCE" | "SLOT" => NodeKind::Frame,
|
||||
"GROUP" => NodeKind::Group,
|
||||
"SECTION" => NodeKind::Section { contents_hidden: flag(j, "sectionContentsHidden").unwrap_or(false) },
|
||||
"RECTANGLE" => NodeKind::Rectangle,
|
||||
@@ -738,7 +761,7 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
|
||||
/// Component property definitions (variant ones are implied by the variant names).
|
||||
fn prop_defs(&mut self, owner: NodeId, j: &Value) -> BTreeMap<String, PropDef> {
|
||||
fn prop_defs(&mut self, owner: NodeId, j: &'a Value) -> BTreeMap<String, PropDef> {
|
||||
let mut out = BTreeMap::new();
|
||||
for (name, d) in j.get("componentPropertyDefinitions").and_then(Value::as_object).into_iter().flatten().take(256) {
|
||||
let default = d.get("defaultValue");
|
||||
@@ -752,7 +775,13 @@ impl<'a> Ctx<'a> {
|
||||
continue;
|
||||
}
|
||||
Some("VARIANT") => continue,
|
||||
Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() },
|
||||
Some("SLOT") => {
|
||||
if let Some(d) = default {
|
||||
self.slot_defaults.insert((owner, name.clone()), d);
|
||||
}
|
||||
self.slot_notes(d);
|
||||
PropDef::Slot { preferred: Vec::new() }
|
||||
}
|
||||
_ => {
|
||||
self.warn("unknown component property types were skipped");
|
||||
continue;
|
||||
@@ -763,6 +792,20 @@ impl<'a> Ctx<'a> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Notes on what a slot property sets that the editor doesn't keep: preferred components (keys
|
||||
/// of library components, not layers of the file) and limits on its content.
|
||||
fn slot_notes(&mut self, d: &Value) {
|
||||
if !list(d, "preferredValues").is_empty() {
|
||||
self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped");
|
||||
}
|
||||
// Insertion and empty-slot display only change editing aids, not what the file shows.
|
||||
let s = d.get("slotSettings");
|
||||
let limit = |k: &str| s.and_then(|s| num(s, k)).is_some_and(|n| n != 0.0);
|
||||
if limit("minChildren") || limit("maxChildren") || s.and_then(|s| flag(s, "allowPreferredValuesOnly")) == Some(true) {
|
||||
self.warn("non-default slot settings are not represented and were skipped");
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared styles with their values (`styleDefinitions`, written by the `.fig` import: a list of
|
||||
/// `{id, name, description, styleType, node}`, the node being a layer that shows the style), in
|
||||
/// the file's order. Layers then link to them; styles that no layer uses come across too.
|
||||
@@ -971,6 +1014,7 @@ impl<'a> Ctx<'a> {
|
||||
/// outside components, and instances that would nest a component inside itself (directly or
|
||||
/// through other components), stay frames.
|
||||
fn resolve_instances(&mut self) {
|
||||
let frames: BTreeSet<NodeId> = self.instances.iter().map(|(i, _)| *i).collect();
|
||||
let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new();
|
||||
for (inst, j) in std::mem::take(&mut self.instances) {
|
||||
match text(j, "componentId").and_then(|r| self.component(r)) {
|
||||
@@ -1000,6 +1044,7 @@ impl<'a> Ctx<'a> {
|
||||
linked.push((inst, j));
|
||||
}
|
||||
}
|
||||
self.resolve_slots(&frames, &linked);
|
||||
// Overridden fields the file lists, on the instance and its layers.
|
||||
for (inst, j) in linked {
|
||||
for o in list(j, "overrides") {
|
||||
@@ -1015,6 +1060,70 @@ impl<'a> Ctx<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `SLOT` layers. In a main component a slot is bound to its slot property: the one the layer
|
||||
/// references, else the one named like it, else the main's only slot property left. In an
|
||||
/// instance it follows its main's slot, and holds the instance's own content (kept across syncs)
|
||||
/// unless the instance's value of the property is the main's default content (without the
|
||||
/// values to compare, a slot holding layers keeps them as its own). A slot without a property
|
||||
/// stays a frame. `frames`: every instance
|
||||
/// layer of the file, `linked`: those that became instances.
|
||||
fn resolve_slots(&mut self, frames: &BTreeSet<NodeId>, linked: &[(NodeId, &'a Value)]) {
|
||||
let linked: BTreeMap<NodeId, &Value> = linked.iter().copied().collect();
|
||||
let mut bound: BTreeSet<(NodeId, String)> = BTreeSet::new();
|
||||
for (id, j) in std::mem::take(&mut self.slots) {
|
||||
// The nearest main component or instance above the slot owns it; in an instance whose
|
||||
// main isn't in the file (a frame), the slot stays a frame too.
|
||||
let owner = self.doc.ancestors(id).into_iter().find_map(|a| match self.doc.node(a).map(|n| &n.kind) {
|
||||
Some(NodeKind::Instance { component, .. }) => Some(linked.get(&a).map(|j| (*component, Some(*j)))),
|
||||
Some(NodeKind::Component { .. }) => Some(Some((a, None))),
|
||||
_ => frames.contains(&a).then_some(None),
|
||||
});
|
||||
let Some(owner) = owner else {
|
||||
self.warn("slot layers without a matching slot property were imported as frames");
|
||||
continue;
|
||||
};
|
||||
let Some((main, inst)) = owner else { continue };
|
||||
let Some(NodeKind::Component { props, .. }) = self.doc.node(main).map(|n| &n.kind) else { continue };
|
||||
let defs: Vec<&String> = props.iter().filter(|(_, d)| matches!(d, PropDef::Slot { .. })).map(|(k, _)| k).collect();
|
||||
let layer = text(j, "name").unwrap_or_default();
|
||||
let refs: Vec<&str> =
|
||||
j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten().filter_map(|(_, v)| v.as_str()).collect();
|
||||
// An instance's slot is matched against all of its main's slot properties.
|
||||
let free: Vec<&&String> = defs.iter().filter(|d| inst.is_some() || !bound.contains(&(main, (**d).clone()))).collect();
|
||||
let name = defs
|
||||
.iter()
|
||||
.find(|d| refs.contains(&d.as_str()))
|
||||
.or_else(|| defs.iter().find(|d| short_prop_name(d) == layer))
|
||||
.or_else(|| if free.len() == 1 { free.first().copied() } else { None })
|
||||
.map(|d| (*d).clone());
|
||||
let Some(n) = self.doc.node(id) else { continue };
|
||||
let has_content = !n.children.is_empty();
|
||||
let Some(inst) = inst else {
|
||||
match name.filter(|p| bound.insert((main, p.clone()))) {
|
||||
Some(p) => {
|
||||
if let Some(n) = self.doc.node_mut(id) {
|
||||
n.prop_refs.insert("slot".into(), p);
|
||||
}
|
||||
}
|
||||
None => self.warn("slot layers without a matching slot property were imported as frames"),
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let set = self.doc.node(main).and_then(|n| n.parent);
|
||||
let default =
|
||||
name.as_ref().and_then(|p| [Some(main), set].into_iter().flatten().find_map(|o| self.slot_defaults.get(&(o, p.clone())).copied()));
|
||||
let value = name.as_ref().and_then(|p| inst.get("componentProperties")?.get(p)?.get("value"));
|
||||
let own = name.is_some()
|
||||
&& match (value, default) {
|
||||
(Some(v), Some(d)) => v != d,
|
||||
_ => has_content,
|
||||
};
|
||||
if own && let Some(n) = self.doc.node_mut(id) {
|
||||
n.overrides.insert("slot".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An instance's property values (variant values are implied by the main it shows).
|
||||
fn instance_props(&self, j: &Value) -> BTreeMap<String, Value> {
|
||||
let mut out = BTreeMap::new();
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
//! `SLOT` layers of the REST file JSON: bound to their slot property in a main component, and
|
||||
//! holding each instance's own content (synthetic files).
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn at(x: f64, y: f64) -> Value {
|
||||
json!([[1, 0, x], [0, 1, y]])
|
||||
}
|
||||
|
||||
pub(crate) fn file(children: Value) -> Value {
|
||||
json!({"name": "Slots", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [
|
||||
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": children}
|
||||
]}})
|
||||
}
|
||||
|
||||
fn content_id(local: u32) -> Value {
|
||||
json!({"guid": {"sessionID": 5, "localID": local}})
|
||||
}
|
||||
|
||||
fn slot(id: &str, name: &str, children: Value) -> Value {
|
||||
json!({"id": id, "name": name, "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": children})
|
||||
}
|
||||
|
||||
fn label(id: &str, text: &str) -> Value {
|
||||
json!({"id": id, "name": text, "type": "TEXT", "characters": text, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
|
||||
}
|
||||
|
||||
/// A "Chip" component (`1:9`), a "Container" component (`1:2`) with a "Title" text and an empty
|
||||
/// "Slot" (property `Slot#1:3`, default content 5:1), and the given layers after them.
|
||||
pub(crate) fn container_file(more: Vec<Value>) -> Value {
|
||||
let mut layers = vec![
|
||||
json!({"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
|
||||
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
|
||||
]}),
|
||||
json!({"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": content_id(1)}},
|
||||
"children": [label("1:4", "Title"), slot("1:5", "Slot", json!([]))]}),
|
||||
];
|
||||
layers.extend(more);
|
||||
file(Value::Array(layers))
|
||||
}
|
||||
|
||||
/// An instance `id` of Container at `x` whose slot holds `content` (property value `value`).
|
||||
pub(crate) fn container(id: &str, x: f64, value: Value, content: Value) -> Value {
|
||||
json!({"id": id, "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(x, 0.0), "size": {"x": 300, "y": 160},
|
||||
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": value}},
|
||||
"children": [label(&format!("I{id};1:4"), "Title"), slot(&format!("I{id};1:5"), "Slot", content)]})
|
||||
}
|
||||
|
||||
pub(crate) fn chip(id: &str) -> Value {
|
||||
json!({"id": id, "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20}, "children": [
|
||||
{"id": format!("I{id};1:10"), "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
|
||||
]})
|
||||
}
|
||||
|
||||
fn by_rest<'d>(doc: &'d Document, v: &Value, rest: &str) -> &'d Node {
|
||||
// Names repeat across copies: find by position in the file's tree instead.
|
||||
fn path(v: &Value, rest: &str, out: &mut Vec<usize>) -> bool {
|
||||
if v.get("id").and_then(Value::as_str) == Some(rest) {
|
||||
return true;
|
||||
}
|
||||
for (i, c) in v.get("children").and_then(Value::as_array).into_iter().flatten().enumerate() {
|
||||
out.push(i);
|
||||
if path(c, rest, out) {
|
||||
return true;
|
||||
}
|
||||
out.pop();
|
||||
}
|
||||
false
|
||||
}
|
||||
let mut p = Vec::new();
|
||||
assert!(path(&v["document"]["children"][0], rest, &mut p), "no {rest} in the file");
|
||||
let mut id = doc.pages[0];
|
||||
for i in p {
|
||||
id = doc.node(id).unwrap().children[i];
|
||||
}
|
||||
doc.node(id).unwrap()
|
||||
}
|
||||
|
||||
fn names(doc: &Document, n: &Node) -> Vec<String> {
|
||||
n.children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_layers_bind_to_their_property_and_keep_instance_content() {
|
||||
let v = container_file(vec![
|
||||
container("2:1", 400.0, content_id(7), json!([label("2:2", "Reports in progress"), chip("2:3")])),
|
||||
container("3:1", 800.0, content_id(1), json!([])),
|
||||
]);
|
||||
let (doc, warnings) = import(&v, &no_images).unwrap();
|
||||
assert!(warnings.iter().all(|w| !w.contains("SLOT") && !w.contains("slot")), "{warnings:?}");
|
||||
|
||||
let main = by_rest(&doc, &v, "1:2");
|
||||
let NodeKind::Component { props, .. } = &main.kind else { panic!("{:?}", main.kind) };
|
||||
assert_eq!(props.get("Slot"), Some(&PropDef::Slot { preferred: Vec::new() }), "named without its #id suffix");
|
||||
let main_slot = by_rest(&doc, &v, "1:5");
|
||||
assert!(matches!(main_slot.kind, NodeKind::Frame));
|
||||
assert_eq!(main_slot.prop_refs.get("slot").map(String::as_str), Some("Slot"));
|
||||
assert!(main_slot.children.is_empty(), "the main's slot stays empty");
|
||||
|
||||
let filled = by_rest(&doc, &v, "I2:1;1:5");
|
||||
assert!(filled.overrides.contains("slot"), "the instance's own content");
|
||||
assert!(filled.prop_refs.is_empty(), "an instance's slot follows its main's");
|
||||
assert_eq!(names(&doc, filled), ["Reports in progress", "Chip"]);
|
||||
let chip = by_rest(&doc, &v, "2:3");
|
||||
assert!(matches!(chip.kind, NodeKind::Instance { .. }), "instances in slot content link to their own main");
|
||||
|
||||
let default = by_rest(&doc, &v, "I3:1;1:5");
|
||||
assert!(!default.overrides.contains("slot"), "the main's default content follows the main");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_ownership_without_values_follows_the_content() {
|
||||
let mut owned = container("2:1", 400.0, json!(null), json!([label("2:2", "Own")]));
|
||||
owned["componentProperties"] = json!({});
|
||||
let mut empty = container("3:1", 800.0, json!(null), json!([]));
|
||||
empty["componentProperties"] = json!({});
|
||||
let v = container_file(vec![owned, empty]);
|
||||
let (doc, _) = import(&v, &no_images).unwrap();
|
||||
assert!(by_rest(&doc, &v, "I2:1;1:5").overrides.contains("slot"));
|
||||
assert!(!by_rest(&doc, &v, "I3:1;1:5").overrides.contains("slot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_match_by_reference_then_name_then_the_one_left() {
|
||||
let comp = json!({"id": "1:2", "name": "Card", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 300},
|
||||
"componentPropertyDefinitions": {
|
||||
"Header#1:3": {"type": "SLOT", "defaultValue": content_id(1)},
|
||||
"Body#1:4": {"type": "SLOT", "defaultValue": content_id(2)},
|
||||
"Footer#1:5": {"type": "SLOT", "defaultValue": content_id(3)},
|
||||
"Visible#1:6": {"type": "BOOLEAN", "defaultValue": true}
|
||||
},
|
||||
"children": [
|
||||
{"id": "1:7", "name": "Top", "type": "SLOT", "componentPropertyReferences": {"visible": "Visible#1:6", "slot": "Header#1:3"},
|
||||
"relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 50}},
|
||||
{"id": "1:8", "name": "Body", "type": "SLOT", "relativeTransform": at(0.0, 50.0), "size": {"x": 300, "y": 50}},
|
||||
{"id": "1:9", "name": "Bottom", "type": "SLOT", "relativeTransform": at(0.0, 100.0), "size": {"x": 300, "y": 50}}
|
||||
]});
|
||||
let v = file(json!([comp]));
|
||||
let (doc, warnings) = import(&v, &no_images).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
let bound = |rest: &str| by_rest(&doc, &v, rest).prop_refs.get("slot").cloned();
|
||||
assert_eq!(bound("1:7").as_deref(), Some("Header"));
|
||||
assert_eq!(by_rest(&doc, &v, "1:7").prop_refs.get("visible").map(String::as_str), Some("Visible"));
|
||||
assert_eq!(bound("1:8").as_deref(), Some("Body"));
|
||||
assert_eq!(bound("1:9").as_deref(), Some("Footer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variants_share_their_set_slot_property() {
|
||||
let variant = |id: &str, name: &str, x: f64| {
|
||||
json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at(x, 0.0), "size": {"x": 100, "y": 100},
|
||||
"children": [slot(&format!("{id}s"), "Content", json!([]))]})
|
||||
};
|
||||
let set = json!({"id": "1:1", "name": "Panel", "type": "COMPONENT_SET", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 100},
|
||||
"componentPropertyDefinitions": {"Size": {"type": "VARIANT", "defaultValue": "Small"}, "Content#2:1": {"type": "SLOT", "defaultValue": content_id(1)}},
|
||||
"children": [variant("1:2", "Size=Small", 0.0), variant("1:3", "Size=Large", 150.0)]});
|
||||
let inst = json!({"id": "4:1", "name": "Panel", "type": "INSTANCE", "componentId": "1:3", "relativeTransform": at(0.0, 300.0), "size": {"x": 100, "y": 100},
|
||||
"componentProperties": {"Content#2:1": {"type": "SLOT", "value": content_id(9)}},
|
||||
"children": [slot("I4:1;1:3s", "Content", json!([label("4:2", "Mine")]))]});
|
||||
let v = file(json!([set, inst]));
|
||||
let (doc, warnings) = import(&v, &no_images).unwrap();
|
||||
assert!(warnings.is_empty(), "{warnings:?}");
|
||||
for rest in ["1:2s", "1:3s"] {
|
||||
assert_eq!(by_rest(&doc, &v, rest).prop_refs.get("slot").map(String::as_str), Some("Content"), "{rest}");
|
||||
}
|
||||
assert!(by_rest(&doc, &v, "I4:1;1:3s").overrides.contains("slot"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_instances_own_the_slots_of_their_main() {
|
||||
// A "Screen" main places a Container whose slot holds a chip; an instance of Screen places
|
||||
// other content in that nested slot.
|
||||
let screen = json!({"id": "6:1", "name": "Screen", "type": "COMPONENT", "relativeTransform": at(0.0, 600.0), "size": {"x": 300, "y": 160},
|
||||
"children": [container("6:2", 0.0, content_id(7), json!([chip("6:3")]))]});
|
||||
let nested = container("I7:1;6:2", 0.0, content_id(8), json!([label("7:2", "Other")]));
|
||||
let outer = json!({"id": "7:1", "name": "Screen", "type": "INSTANCE", "componentId": "6:1", "relativeTransform": at(400.0, 600.0), "size": {"x": 300, "y": 160},
|
||||
"children": [nested]});
|
||||
let v = container_file(vec![screen, outer]);
|
||||
let (doc, _) = import(&v, &no_images).unwrap();
|
||||
let in_main = by_rest(&doc, &v, "I6:2;1:5");
|
||||
assert!(in_main.overrides.contains("slot") && in_main.prop_refs.is_empty());
|
||||
assert!(matches!(by_rest(&doc, &v, "6:3").kind, NodeKind::Instance { .. }));
|
||||
let in_outer = by_rest(&doc, &v, "II7:1;6:2;1:5");
|
||||
assert!(in_outer.overrides.contains("slot"));
|
||||
assert_eq!(names(&doc, in_outer), ["Other"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slots_outside_components_or_without_a_property_stay_frames() {
|
||||
let mut no_def = container_file(Vec::new());
|
||||
no_def["document"]["children"][0]["children"][1].as_object_mut().unwrap().remove("componentPropertyDefinitions");
|
||||
let loose = slot("8:1", "Loose", json!([label("8:2", "Kept")]));
|
||||
no_def["document"]["children"][0]["children"].as_array_mut().unwrap().push(loose);
|
||||
let (doc, warnings) = import(&no_def, &no_images).unwrap();
|
||||
assert!(warnings.iter().any(|w| w.starts_with("slot layers without a matching slot property were imported as frames (×2)")), "{warnings:?}");
|
||||
let kept = by_rest(&doc, &no_def, "8:1");
|
||||
assert!(matches!(kept.kind, NodeKind::Frame) && kept.prop_refs.is_empty());
|
||||
assert_eq!(names(&doc, kept), ["Kept"]);
|
||||
assert!(by_rest(&doc, &no_def, "1:5").prop_refs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_settings_the_editor_cannot_keep_are_noted() {
|
||||
let mut v = container_file(Vec::new());
|
||||
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] =
|
||||
json!({"stretchChildOnInsert": true, "displayEmptyByDefault": true, "minChildren": null, "maxChildren": 3});
|
||||
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["preferredValues"] =
|
||||
json!([{"type": "COMPONENT", "key": "abc"}]);
|
||||
let (_, warnings) = import(&v, &no_images).unwrap();
|
||||
assert!(warnings.iter().any(|w| w == "non-default slot settings are not represented and were skipped"), "{warnings:?}");
|
||||
assert!(warnings.iter().any(|w| w == "preferred slot component keys cannot be resolved to file nodes and were skipped"), "{warnings:?}");
|
||||
let mut plain = container_file(Vec::new());
|
||||
plain["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] =
|
||||
json!({"stretchChildOnInsert": true, "minChildren": null, "maxChildren": null, "allowPreferredValuesOnly": false});
|
||||
assert!(import(&plain, &no_images).unwrap().1.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_slot_json_never_panics() {
|
||||
for junk in [json!(null), json!(1), json!("x"), json!([]), json!({"guid": null}), json!({"type": "SLOT"})] {
|
||||
let mut v = container_file(vec![container("2:1", 400.0, junk.clone(), json!([label("2:2", "A")]))]);
|
||||
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"] =
|
||||
json!({"type": "SLOT", "defaultValue": junk.clone(), "slotSettings": junk.clone(), "preferredValues": junk.clone()});
|
||||
v["document"]["children"][0]["children"][1]["children"][1]["componentPropertyReferences"] = json!({"slot": junk});
|
||||
assert!(import(&v, &no_images).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,16 @@ pub fn source(v: &Value) -> Option<&Value> {
|
||||
[v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variableCollections").is_some_and(Value::is_object))
|
||||
}
|
||||
|
||||
/// The variable ids the collections of a local variables response list in `variableIds`.
|
||||
pub(super) fn listed_ids(v: &Value) -> BTreeSet<&str> {
|
||||
source(v)
|
||||
.into_iter()
|
||||
.flat_map(|src| objects(src, "variableCollections", MAX_COLLECTIONS))
|
||||
.flat_map(|(_, c)| list(c, "variableIds").iter().filter_map(Value::as_str).map(clip_str))
|
||||
.take(MAX_VARIABLES)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) fn kind_of(t: &str) -> Option<VariableType> {
|
||||
Some(match t {
|
||||
"COLOR" => VariableType::Color,
|
||||
@@ -118,14 +128,18 @@ fn objects<'v>(v: &'v Value, key: &str, max: usize) -> impl Iterator<Item = (&'v
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
/// Add the collections and variables of a local variables response (or its `meta`).
|
||||
pub(super) fn variables(&mut self, src: &Value) {
|
||||
/// Add the collections and variables of a local variables response (or its `meta`); nothing
|
||||
/// when `v` isn't one. The `variables` map is read next to `variableCollections`, or from
|
||||
/// `meta` or the top level when it sits on the other side.
|
||||
pub(super) fn variables(&mut self, v: &Value) {
|
||||
let Some(src) = source(v) else { return };
|
||||
for (key, c) in objects(src, "variableCollections", MAX_COLLECTIONS) {
|
||||
self.collection(key, c);
|
||||
}
|
||||
let holder = [Some(src), v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variables").is_some_and(Value::is_object));
|
||||
let mut found: Vec<Variable> = Vec::new();
|
||||
let mut seen: BTreeSet<String> = BTreeSet::new();
|
||||
for (key, v) in objects(src, "variables", MAX_VARIABLES) {
|
||||
for (key, v) in holder.into_iter().flat_map(|h| objects(h, "variables", MAX_VARIABLES)) {
|
||||
if let Some(var) = self.variable(key, v)
|
||||
&& seen.insert(var.id.clone())
|
||||
{
|
||||
|
||||
@@ -147,7 +147,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
"type": {"type": "string", "enum": ["frame", "rectangle", "ellipse", "polygon", "star", "line", "arrow", "text", "vector", "section", "component", "slice"], "description": "Layer type"},
|
||||
"x": num("Left, relative to the parent frame (default 0)"),
|
||||
"y": num("Top, relative to the parent frame (default 0)"),
|
||||
"width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap)"),
|
||||
"width": num("Width (default 100; text hugs its content unless width is set, which makes it wrap; for a vector with path, width/height scale the path, one alone keeping its proportion, and without them the path's own box is used)"),
|
||||
"height": num("Height (default 100; ignored for lines/arrows and auto-height text)"),
|
||||
"parent": id_schema("Parent frame/group/component id (default: the current page)"),
|
||||
"index": int("Z-index among the parent's children (default: on top)"),
|
||||
|
||||
@@ -310,6 +310,7 @@ status = "draft — needs native review"
|
||||
"Corner radius" = "Poloměr rohu"
|
||||
"Corner smoothing" = "Vyhlazení rohů"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Server {server} není dostupný: zobrazuje se stav uložený v tomto počítači."
|
||||
"Couldn't read {name}." = "Soubor {name} se nepodařilo přečíst."
|
||||
"Count" = "Počet"
|
||||
"Create" = "Vytvořit"
|
||||
"Create a variable collection first" = "Nejprve vytvořte kolekci proměnných"
|
||||
@@ -634,6 +635,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Výměna instance (žádná jiná komponenta)"
|
||||
"Instances" = "Instance"
|
||||
"Instant" = "Okamžitě"
|
||||
"Intensity" = "Intenzita"
|
||||
"Interaction" = "Interakce"
|
||||
"Interactions" = "Interakce"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Návrh rozhraní a prototypování. Svobodný nástroj s otevřeným zdrojovým kódem, napsaný od základu v jazyce Rust nezávisle na kódu jiných produktů. Součást sady ArtCraft Crafting Apps."
|
||||
@@ -1628,6 +1630,7 @@ status = "draft — needs native review"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "Zapomenuto v tomto počítači: {name}; soubor zůstává na serveru {server}"
|
||||
"{name} is on the server." = "{name} je na serveru."
|
||||
"{name} is spotlighting" = "{name} sdílí pohled"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "Soubor {name} je příliš velký na otevření v prohlížeči (více než 512 MB)."
|
||||
"{name}\nClick to apply to the selection" = "{name}\nKliknutím použijete na výběr"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPřetáhněte na plátno nebo kliknutím umístěte doprostřed zobrazení.\nPři puštění na instanci podržte Alt (Option) a vyměňte ji."
|
||||
"{name} (from {library})" = "{name} (z {library})"
|
||||
|
||||
@@ -306,6 +306,7 @@ status = "draft — needs native review"
|
||||
"Corner smoothing" = "Eckenglättung"
|
||||
"Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat."
|
||||
"Couldn't read {name}." = "{name} konnte nicht gelesen werden."
|
||||
"Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}"
|
||||
"Count" = "Anzahl"
|
||||
"Create" = "Erstellen"
|
||||
@@ -619,6 +620,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Instanztausch (keine andere Komponente)"
|
||||
"Instances" = "Instanzen"
|
||||
"Instant" = "Sofort"
|
||||
"Intensity" = "Intensität"
|
||||
"Interaction" = "Interaktion"
|
||||
"Interactions" = "Interaktionen"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface-Design und Prototyping. Ein kostenloses Open-Source-Designwerkzeug, als Clean-Room-Entwicklung von Grund auf in Rust geschrieben, Teil der ArtCraft Crafting Apps."
|
||||
@@ -1582,6 +1584,7 @@ status = "draft — needs native review"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} wurde auf diesem Computer vergessen; die Datei bleibt auf {server}"
|
||||
"{name} is on the server." = "{name} liegt auf dem Server."
|
||||
"{name} is spotlighting" = "{name} hat das Spotlight"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "{name} ist zu groß, um im Browser geöffnet zu werden (über 512 MB)."
|
||||
"{name} was removed from the server" = "{name} wurde vom Server entfernt"
|
||||
"{name} · deleted on main" = "{name} · in der Hauptdatei gelöscht"
|
||||
"{name} · deleted on the branch" = "{name} · im Branch gelöscht"
|
||||
|
||||
@@ -319,6 +319,7 @@ name = "English"
|
||||
"Corner smoothing" = "Corner smoothing"
|
||||
"Couldn't download the variables" = "Couldn't download the variables"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers."
|
||||
"Couldn't read {name}." = "Couldn't read {name}."
|
||||
"Couldn't save automatically: {error}" = "Couldn't save automatically: {error}"
|
||||
"Count" = "Count"
|
||||
"Create" = "Create"
|
||||
@@ -649,6 +650,7 @@ name = "English"
|
||||
"Instance swap (no other component)" = "Instance swap (no other component)"
|
||||
"Instances" = "Instances"
|
||||
"Instant" = "Instant"
|
||||
"Intensity" = "Intensity"
|
||||
"Interaction" = "Interaction"
|
||||
"Interactions" = "Interactions"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps."
|
||||
@@ -833,6 +835,7 @@ name = "English"
|
||||
"No contributor data was built into this copy." = "No contributor data was built into this copy."
|
||||
"No device" = "No device"
|
||||
"No fonts match" = "No fonts match"
|
||||
"No files or templates match your search" = "No files or templates match your search"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "No join code here (the server is older than join codes, or this address can't be put in one): share the link instead."
|
||||
"No layer changes to show." = "No layer changes to show."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link."
|
||||
@@ -1178,6 +1181,8 @@ name = "English"
|
||||
"Search actions, layers and tools" = "Search actions, layers and tools"
|
||||
"Search assets" = "Search assets"
|
||||
"Search comments" = "Search comments"
|
||||
"Search files and templates" = "Search files and templates"
|
||||
"Search results" = "Search results"
|
||||
"Search fonts" = "Search fonts"
|
||||
"Search layers" = "Search layers"
|
||||
"Search shortcuts" = "Search shortcuts"
|
||||
@@ -1407,6 +1412,7 @@ name = "English"
|
||||
"Truncate with an ellipsis" = "Truncate with an ellipsis"
|
||||
"Try again" = "Try again"
|
||||
"Try another search or filter." = "Try another search or filter."
|
||||
"Try another search, or browse templates." = "Try another search, or browse templates."
|
||||
"Try another word, or look at every kind." = "Try another word, or look at every kind."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Turn a layer into a component, reuse it, and change every copy at once"
|
||||
"Two digits" = "Two digits"
|
||||
@@ -1620,6 +1626,7 @@ name = "English"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"
|
||||
"slot content assignment target is missing or is not slot content" = "slot content assignment target is missing or is not slot content"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "slot content assignment uses the empty GUID sentinel and was skipped"
|
||||
"slot layers without a matching slot property were imported as frames" = "slot layers without a matching slot property were imported as frames"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke"
|
||||
"stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored"
|
||||
@@ -1661,6 +1668,7 @@ name = "English"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} is forgotten on this computer; the file stays on {server}"
|
||||
"{name} is on the server." = "{name} is on the server."
|
||||
"{name} is spotlighting" = "{name} is spotlighting"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "{name} is too large to open in the browser (over 512 MB)."
|
||||
"{name}\nClick to apply to the selection" = "{name}\nClick to apply to the selection"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it."
|
||||
"{name} (from {library})" = "{name} (from {library})"
|
||||
|
||||
@@ -320,6 +320,7 @@ status = "draft — needs native review"
|
||||
"Corner radius" = "Radio de esquina"
|
||||
"Corner smoothing" = "Suavizado de esquinas"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "No se pudo contactar con {server}: se muestra lo que este ordenador recuerda."
|
||||
"Couldn't read {name}." = "No se pudo leer {name}."
|
||||
"Couldn't save automatically: {error}" = "No se pudo guardar automáticamente: {error}"
|
||||
"Count" = "Número"
|
||||
"Create" = "Crear"
|
||||
@@ -650,6 +651,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Intercambio de instancia (no hay otro componente)"
|
||||
"Instances" = "Instancias"
|
||||
"Instant" = "Instantánea"
|
||||
"Intensity" = "Intensidad"
|
||||
"Interaction" = "Interacción"
|
||||
"Interactions" = "Interacciones"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Diseño de interfaces y prototipos. Una herramienta de diseño gratuita y de código abierto, de implementación independiente (clean-room), escrita desde cero en Rust, parte de las Crafting Apps de ArtCraft."
|
||||
@@ -834,6 +836,7 @@ status = "draft — needs native review"
|
||||
"No contributor data was built into this copy." = "Esta copia no incluye datos de colaboradores."
|
||||
"No device" = "Sin dispositivo"
|
||||
"No fonts match" = "Ninguna fuente coincide"
|
||||
"No files or templates match your search" = "Ningún archivo ni plantilla coincide con tu búsqueda"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Aquí no hay código para unirse (el servidor es anterior a los códigos, o esta dirección no cabe en uno): comparte el enlace."
|
||||
"No layer changes to show." = "No hay cambios de capas que mostrar."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Aún no hay bibliotecas. Añade un archivo .figcraft publicado como biblioteca o pega el enlace de una biblioteca de equipo."
|
||||
@@ -1179,6 +1182,8 @@ status = "draft — needs native review"
|
||||
"Search actions, layers and tools" = "Buscar acciones, capas y herramientas"
|
||||
"Search assets" = "Buscar recursos"
|
||||
"Search comments" = "Buscar comentarios"
|
||||
"Search files and templates" = "Buscar archivos y plantillas"
|
||||
"Search results" = "Resultados de búsqueda"
|
||||
"Search fonts" = "Buscar fuentes"
|
||||
"Search layers" = "Buscar capas"
|
||||
"Search shortcuts" = "Buscar atajos"
|
||||
@@ -1408,6 +1413,7 @@ status = "draft — needs native review"
|
||||
"Truncate with an ellipsis" = "Truncar con puntos suspensivos"
|
||||
"Try again" = "Reintentar"
|
||||
"Try another search or filter." = "Prueba otra búsqueda u otro filtro."
|
||||
"Try another search, or browse templates." = "Prueba otra búsqueda o explora las plantillas."
|
||||
"Try another word, or look at every kind." = "Prueba con otra palabra o mira todos los tipos."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Convierte una capa en componente, reutilízalo y cambia todas las copias a la vez"
|
||||
"Two digits" = "Dos dígitos"
|
||||
@@ -1621,6 +1627,7 @@ status = "draft — needs native review"
|
||||
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables"
|
||||
"slot content assignment target is missing or is not slot content" = "el destino de la asignación de contenido de ranura falta o no es contenido de ranura"
|
||||
"slot content assignment uses the empty GUID sentinel and was skipped" = "la asignación de contenido de ranura usa el GUID vacío como centinela y se omitió"
|
||||
"slot layers without a matching slot property were imported as frames" = "las capas de ranura sin una propiedad de ranura correspondiente se importaron como marcos"
|
||||
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples"
|
||||
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa"
|
||||
"stale or invalid grid track sizing entries were ignored" = "se ignoraron las entradas de tamaño de pista de cuadrícula obsoletas o no válidas"
|
||||
@@ -1662,6 +1669,7 @@ status = "draft — needs native review"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} se olvidó en este ordenador; el archivo se queda en {server}"
|
||||
"{name} is on the server." = "{name} está en el servidor."
|
||||
"{name} is spotlighting" = "{name} está en el foco"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "{name} es demasiado grande para abrirlo en el navegador (más de 512 MB)."
|
||||
"{name}\nClick to apply to the selection" = "{name}\nHaz clic para aplicarlo a la selección"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nArrastra al lienzo o haz clic para colocarlo en el centro de la vista.\nMantén Alt (Opción) al soltar sobre una instancia para intercambiarla."
|
||||
"{name} (from {library})" = "{name} (de {library})"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -19,7 +19,10 @@ status = "draft — needs native review"
|
||||
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "Une collection de ce fichier avec ces modes et ces variables : remplacez leurs valeurs par mode et ajoutez vos propres variables"
|
||||
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Un fichier .figcraft portant ce nom existe déjà. Choisissez son nom complet pour confirmer le remplacement."
|
||||
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Un calque seul s’aligne sur son cadre : sélectionnez d’autres calques, ou un calque dans un cadre"
|
||||
"A responsive product page: hero, features, figures, a quote and a footer" = "Une page produit responsive : héros, fonctionnalités, chiffres, une citation et un pied de page"
|
||||
"A spring plays until it settles: its mass, stiffness and damping set how long" = "Un ressort joue jusqu’à ce qu’il se stabilise : sa masse, sa rigidité et son amortissement en fixent la durée"
|
||||
"A title, an agenda, a big figure, a chart and a closing slide" = "Un titre, un sommaire, un grand chiffre, un graphique et une diapositive de clôture"
|
||||
"A to Z" = "A à Z"
|
||||
"About" = "À propos"
|
||||
"About FigCraft" = "À propos de FigCraft"
|
||||
"About this server" = "À propos de ce serveur"
|
||||
@@ -65,6 +68,7 @@ status = "draft — needs native review"
|
||||
"Add variant with a new value" = "Ajouter une variante avec une nouvelle valeur"
|
||||
"Added" = "Ajouté"
|
||||
"Added the library \"{name}\"" = "Bibliothèque « {name} » ajoutée"
|
||||
"Adding to {name}" = "Ajout dans {name}"
|
||||
"Adjust" = "Réglages"
|
||||
"Adjustment presets" = "Préréglages"
|
||||
"Advanced" = "Avancé"
|
||||
@@ -90,7 +94,9 @@ status = "draft — needs native review"
|
||||
"All supported properties" = "Toutes les propriétés prises en charge"
|
||||
"All variables" = "Toutes les variables"
|
||||
"Alpha" = "Alpha"
|
||||
"Alphabetical" = "Alphabétique"
|
||||
"Alt+drag" = "Alt+glisser"
|
||||
"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "Un écran d’analyse sur une grille : chiffres, graphiques tracés en vectoriel et un tableau"
|
||||
"An invite link that opens FigCraft and joins" = "Un lien d’invitation qui ouvre FigCraft et rejoint la session"
|
||||
"Anchor" = "Ancre"
|
||||
"Android" = "Android"
|
||||
@@ -109,6 +115,8 @@ status = "draft — needs native review"
|
||||
"Apply variable" = "Appliquer une variable"
|
||||
"Apply variable to text" = "Appliquer une variable au texte"
|
||||
"Apply variable to visibility" = "Appliquer une variable à la visibilité"
|
||||
"Apps" = "Applications"
|
||||
"Apr" = "avr."
|
||||
"Arc" = "Arc"
|
||||
"Arc ratio" = "Rapport de l’arc"
|
||||
"Arc start" = "Début de l’arc"
|
||||
@@ -120,6 +128,7 @@ status = "draft — needs native review"
|
||||
"As typed" = "Tel que saisi"
|
||||
"Asking the server…" = "Interrogation du serveur…"
|
||||
"Assets" = "Ressources"
|
||||
"Aug" = "août"
|
||||
"Auto" = "Auto"
|
||||
"Auto (space around)" = "Auto (espace autour)"
|
||||
"Auto (space between)" = "Auto (espace entre)"
|
||||
@@ -164,6 +173,7 @@ status = "draft — needs native review"
|
||||
"Bottom" = "Bas"
|
||||
"Bottom left radius" = "Rayon inférieur gauche"
|
||||
"Bottom right radius" = "Rayon inférieur droit"
|
||||
"Bound to {name}. Click to detach the variable." = "Lié à {name}. Cliquez pour détacher la variable."
|
||||
"Branch" = "Branche"
|
||||
"Branch archived" = "Branche archivée"
|
||||
"Branch merged" = "Branche fusionnée"
|
||||
@@ -250,6 +260,7 @@ status = "draft — needs native review"
|
||||
"Color values in this file are in this color space." = "Les valeurs de couleur de ce fichier sont dans cet espace colorimétrique."
|
||||
"Color variables" = "Variables de couleur"
|
||||
"Colour" = "Couleur"
|
||||
"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "Styles de couleur et de texte, variables Clair et Sombre, boutons, champs et cartes"
|
||||
"Column" = "Colonne"
|
||||
"Column gap" = "Espacement des colonnes"
|
||||
"Column sizes" = "Tailles des colonnes"
|
||||
@@ -286,6 +297,8 @@ status = "draft — needs native review"
|
||||
"Contributors" = "Contributeurs"
|
||||
"Contributors: {people} · Commits: {commits}" = "Contributeurs : {people} · Commits : {commits}"
|
||||
"Controller" = "Manette"
|
||||
"Convert to frame" = "Convertir en cadre"
|
||||
"Convert to section" = "Convertir en section"
|
||||
"Cool" = "Froid"
|
||||
"Copied as CSS" = "Copié en CSS"
|
||||
"Copied as PNG" = "Copié en PNG"
|
||||
@@ -305,6 +318,7 @@ status = "draft — needs native review"
|
||||
"Corner smoothing" = "Lissage des angles"
|
||||
"Couldn't download the variables" = "Impossible de télécharger les variables"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Impossible de joindre {server} : affichage de ce dont cet ordinateur se souvient."
|
||||
"Couldn't read {name}." = "Impossible de lire {name}."
|
||||
"Couldn't save automatically: {error}" = "Impossible d’enregistrer automatiquement : {error}"
|
||||
"Count" = "Nombre"
|
||||
"Create" = "Créer"
|
||||
@@ -319,6 +333,7 @@ status = "draft — needs native review"
|
||||
"Create property…" = "Créer une propriété…"
|
||||
"Create style" = "Créer un style"
|
||||
"Create style from the selected text" = "Créer un style à partir du texte sélectionné"
|
||||
"Created" = "Création"
|
||||
"Crop" = "Recadrer"
|
||||
"Crop marks" = "Traits de coupe"
|
||||
"Ctrl+arrows" = "Ctrl+flèches"
|
||||
@@ -333,6 +348,9 @@ status = "draft — needs native review"
|
||||
"Dark" = "Sombre"
|
||||
"Darken" = "Assombrir"
|
||||
"Dash" = "Tirets"
|
||||
"Dashboard" = "Tableau de bord"
|
||||
"Date created" = "Date de création"
|
||||
"Dec" = "déc."
|
||||
"Decoration style" = "Style de décoration"
|
||||
"Decoration" = "Décoration"
|
||||
"Deep select" = "Sélection en profondeur"
|
||||
@@ -359,6 +377,7 @@ status = "draft — needs native review"
|
||||
"Description" = "Description"
|
||||
"Description (optional)" = "Description (facultative)"
|
||||
"Design" = "Design"
|
||||
"Design system starter" = "Base de système de design"
|
||||
"Desktop" = "Ordinateur"
|
||||
"Destination" = "Destination"
|
||||
"Detach alias" = "Détacher l’alias"
|
||||
@@ -394,8 +413,12 @@ status = "draft — needs native review"
|
||||
"Drafts" = "Brouillons"
|
||||
"Drag the stroke to add a width point" = "Faites glisser le contour pour ajouter un point de largeur"
|
||||
"Draw" = "Dessin"
|
||||
"Draw a frame" = "Tracer un cadre"
|
||||
"Drawing tools" = "Outils de dessin"
|
||||
"Drop a .figcraft, .fig, .json, .svg or image file." = "Déposez un fichier .figcraft, .fig, .json, .svg ou une image."
|
||||
"Drop shadow" = "Ombre portée"
|
||||
"Drop to import" = "Déposer pour importer"
|
||||
"Drop to open" = "Déposer pour ouvrir"
|
||||
"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Double licence MIT OR Apache-2.0. Réalisé par l’équipe et la communauté ArtCraft."
|
||||
"Duo" = "Duo"
|
||||
"Duplicate" = "Dupliquer"
|
||||
@@ -412,6 +435,7 @@ status = "draft — needs native review"
|
||||
"Edit style" = "Modifier le style"
|
||||
"Edit variable" = "Modifier la variable"
|
||||
"Edit with the builder" = "Modifier avec l’assistant"
|
||||
"Edited {when}" = "Modifié {when}"
|
||||
"Effect settings" = "Réglages de l’effet"
|
||||
"Effect styles" = "Styles d’effet"
|
||||
"Effects" = "Effets"
|
||||
@@ -442,6 +466,7 @@ status = "draft — needs native review"
|
||||
"Expand" = "Développer"
|
||||
"Export" = "Exporter"
|
||||
"Export all" = "Tout exporter"
|
||||
"Export frames and layers as PNG, JPG, SVG or PDF" = "Exporter les cadres et les calques en PNG, JPG, SVG ou PDF"
|
||||
"Export page as PNG" = "Exporter la page en PNG"
|
||||
"Export settings" = "Réglages d’exportation"
|
||||
"Export variables" = "Exporter les variables"
|
||||
@@ -461,11 +486,13 @@ status = "draft — needs native review"
|
||||
"F" = "F"
|
||||
"Fade" = "Fondu"
|
||||
"False" = "Faux"
|
||||
"Feb" = "févr."
|
||||
"Fetching from the server…" = "Récupération depuis le serveur…"
|
||||
"FigCraft couldn't open its window: {error}" = "FigCraft n’a pas pu ouvrir sa fenêtre : {error}"
|
||||
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft ne prend pas encore en charge toutes les fonctionnalités de ces fichiers. Les notes indiquent ce qui a été omis ou modifié dans la copie importée ; le fichier d’origine reste inchangé."
|
||||
"File" = "Fichier"
|
||||
"File name" = "Nom du fichier"
|
||||
"File not found" = "Fichier introuvable"
|
||||
"File published" = "Fichier publié"
|
||||
"Files aren't end-to-end encrypted: the server's operator can read them." = "Les fichiers ne sont pas chiffrés de bout en bout : l’opérateur du serveur peut les lire."
|
||||
"Files nobody opens for {n} days are deleted." = { one = "Les fichiers que personne n’ouvre pendant {n} jour sont supprimés.", other = "Les fichiers que personne n’ouvre pendant {n} jours sont supprimés." }
|
||||
@@ -512,6 +539,7 @@ status = "draft — needs native review"
|
||||
"Font size" = "Taille de police"
|
||||
"Font style" = "Style de police"
|
||||
"Forget on this computer" = "Oublier sur cet ordinateur"
|
||||
"Four screens of a trail app with components and a prototype flow" = "Quatre écrans d’une application de randonnée avec des composants et un flux de prototype"
|
||||
"Fractions" = "Fractions"
|
||||
"Frame" = "Cadre"
|
||||
"Frame selection" = "Encadrer la sélection"
|
||||
@@ -525,6 +553,7 @@ status = "draft — needs native review"
|
||||
"Gap between columns" = "Espacement entre les colonnes"
|
||||
"Gap between items" = "Espacement entre les éléments"
|
||||
"Gap between rows" = "Espacement entre les lignes"
|
||||
"Getting started" = "Premiers pas"
|
||||
"Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "Indiquez l’adresse d’un serveur ouvert au public, ou l’adresse d’un serveur avec sa clé (`figcraft-server key` l’affiche)."
|
||||
"Glass" = "Verre"
|
||||
"Go to main component" = "Aller au composant principal"
|
||||
@@ -533,6 +562,7 @@ status = "draft — needs native review"
|
||||
"Gradient" = "Dégradé"
|
||||
"Gray" = "Gris"
|
||||
"Green" = "Vert"
|
||||
"Greyscale components and two example screens to sketch a flow with" = "Composants en niveaux de gris et deux écrans d’exemple pour esquisser un flux"
|
||||
"Grid" = "Grille"
|
||||
"Grid ({size})" = "Grille ({size})"
|
||||
"Grid styles" = "Styles de grille"
|
||||
@@ -554,6 +584,8 @@ status = "draft — needs native review"
|
||||
"Hide contents" = "Masquer le contenu"
|
||||
"Hide from publishing" = "Exclure de la publication"
|
||||
"Hide section contents" = "Masquer le contenu de la section"
|
||||
"Hide these tips (Getting started in the Help menu shows them again)" = "Masquer ces astuces (Premiers pas dans le menu Aide les affiche de nouveau)"
|
||||
"Hide when publishing" = "Masquer à la publication"
|
||||
"Highlight layers on hover" = "Mettre en évidence les calques au survol"
|
||||
"Highlights" = "Hautes lumières"
|
||||
"Hold Alt" = "Maintenir Alt"
|
||||
@@ -575,6 +607,8 @@ status = "draft — needs native review"
|
||||
"Hue" = "Teinte"
|
||||
"Hug" = "Ajuster"
|
||||
"Hug contents" = "Ajuster au contenu"
|
||||
"Icon components on a 24 px grid with keylines and a size preview" = "Composants d’icônes sur une grille de 24 px avec repères et aperçu de la taille"
|
||||
"Icon set starter" = "Base de jeu d’icônes"
|
||||
"If it keeps happening, please report it at {link} with this message." = "Si cela se reproduit, signalez-le sur {link} avec ce message."
|
||||
"If" = "Si"
|
||||
"If {condition}" = "Si {condition}"
|
||||
@@ -615,6 +649,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Permutation d’instance (aucun autre composant)"
|
||||
"Instances" = "Instances"
|
||||
"Instant" = "Instantané"
|
||||
"Intensity" = "Intensité"
|
||||
"Interaction" = "Interaction"
|
||||
"Interactions" = "Interactions"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design d’interfaces et prototypage. Un outil de design libre et gratuit, développé en salle blanche (clean-room) et écrit de zéro en Rust, membre des Crafting Apps d’ArtCraft."
|
||||
@@ -624,6 +659,7 @@ status = "draft — needs native review"
|
||||
"Invite link" = "Lien d’invitation"
|
||||
"Invite link copied" = "Lien d’invitation copié"
|
||||
"It asks permission to:" = "Il demande l’autorisation de :"
|
||||
"Jan" = "janv."
|
||||
"Join" = "Rejoindre"
|
||||
"Join code copied" = "Code d’accès copié"
|
||||
"Join code or invite link" = "Code d’accès ou lien d’invitation"
|
||||
@@ -634,6 +670,8 @@ status = "draft — needs native review"
|
||||
"Joined {file}" = "Connecté à {file}"
|
||||
"Joining…" = "Connexion…"
|
||||
"Join|stroke" = "Jointure"
|
||||
"Jul" = "juil."
|
||||
"Jun" = "juin"
|
||||
"Just now" = "À l’instant"
|
||||
"Justify" = "Justifier"
|
||||
"Keep appearance" = "Conserver l’apparence"
|
||||
@@ -643,11 +681,14 @@ status = "draft — needs native review"
|
||||
"Key/Gamepad" = "Touche/manette"
|
||||
"Key/Gamepad {key}" = "Touche/manette {key}"
|
||||
"Keyboard shortcuts" = "Raccourcis clavier"
|
||||
"Kits" = "Kits"
|
||||
"Landing page" = "Page de destination"
|
||||
"Language" = "Langue"
|
||||
"Lasso" = "Lasso"
|
||||
"Last" = "Dernier"
|
||||
"Last change: {note}" = "Dernière modification : {note}"
|
||||
"Last commit" = "Dernier commit"
|
||||
"Last modified" = "Dernière modification"
|
||||
"Last on top" = "Dernier au-dessus"
|
||||
"Layer blur" = "Flou de calque"
|
||||
"Layer name" = "Nom du calque"
|
||||
@@ -686,6 +727,7 @@ status = "draft — needs native review"
|
||||
"Link" = "Lien"
|
||||
"Link copied" = "Lien copié"
|
||||
"Link docs, code or tickets for developers." = "Liez de la documentation, du code ou des tickets pour les développeurs."
|
||||
"Link frames on the Prototype tab, then present the flow" = "Reliez les cadres dans l’onglet Prototype, puis présentez le flux"
|
||||
"Link this component to its code component: instances then show your real snippet with their values." = "Reliez ce composant à son composant de code : les instances affichent alors votre véritable extrait avec leurs valeurs."
|
||||
"List spacing" = "Espacement de liste"
|
||||
"List style" = "Style de liste"
|
||||
@@ -703,7 +745,9 @@ status = "draft — needs native review"
|
||||
"Main" = "Principal"
|
||||
"Main component deleted" = "Composant principal supprimé"
|
||||
"Main menu" = "Menu principal"
|
||||
"Make a component" = "Créer un composant"
|
||||
"Make slot" = "Créer un emplacement"
|
||||
"Mar" = "mars"
|
||||
"Margin" = "Marge"
|
||||
"Mark all as read" = "Tout marquer comme lu"
|
||||
"Mark as resolved" = "Marquer comme résolu"
|
||||
@@ -716,6 +760,7 @@ status = "draft — needs native review"
|
||||
"Max W" = "L max."
|
||||
"Max lines" = "Lignes max."
|
||||
"Maximize" = "Agrandir"
|
||||
"May" = "mai"
|
||||
"Measure to the selection" = "Mesurer jusqu’à la sélection"
|
||||
"Merge into main" = "Fusionner dans le principal"
|
||||
"Merge regions" = "Fusionner les régions"
|
||||
@@ -737,6 +782,7 @@ status = "draft — needs native review"
|
||||
"Mixed" = "Mixte"
|
||||
"Mixed fonts" = "Polices mixtes"
|
||||
"Mixed interactions: click + to replace them" = "Interactions mixtes : cliquez sur + pour les remplacer"
|
||||
"Mobile app" = "Application mobile"
|
||||
"Mode" = "Mode"
|
||||
"Model" = "Modèle"
|
||||
"Models" = "Modèles"
|
||||
@@ -766,6 +812,7 @@ status = "draft — needs native review"
|
||||
"Navigate to" = "Naviguer vers"
|
||||
"Nested instance" = "Instance imbriquée"
|
||||
"New design file" = "Nouveau fichier de design"
|
||||
"Newest first" = "Plus récent d’abord"
|
||||
"Next frame" = "Cadre suivant"
|
||||
"Next or previous control in the focused region" = "Contrôle suivant ou précédent dans la région active"
|
||||
"Next ready for dev" = "Prêt pour le dev suivant"
|
||||
@@ -799,6 +846,7 @@ status = "draft — needs native review"
|
||||
"No model credits were built into this copy." = "Cette copie ne contient aucun crédit de modèle."
|
||||
"No number variables fit here." = "Aucune variable numérique ne convient ici."
|
||||
"No number variables yet. Create them in Local variables." = "Aucune variable numérique pour l’instant. Créez-en dans Variables locales."
|
||||
"No other pages" = "Aucune autre page"
|
||||
"No paints" = "Aucune peinture"
|
||||
"No plugins on {server}." = "Aucun plugin sur {server}."
|
||||
"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Aucun plugin pour l’instant. Importez un plugin en développement depuis son figcraft-plugin.json."
|
||||
@@ -812,6 +860,7 @@ status = "draft — needs native review"
|
||||
"No string variables yet. Create them in Local variables." = "Aucune variable de chaîne pour l’instant. Créez-en dans Variables locales."
|
||||
"No styles yet." = "Aucun style pour l’instant."
|
||||
"No styles yet. Click + to create one from the selection." = "Aucun style pour l’instant. Cliquez sur + pour en créer un à partir de la sélection."
|
||||
"No templates match your search" = "Aucun modèle ne correspond à votre recherche"
|
||||
"No text styles yet. Click + to create one from the selected text." = "Aucun style de texte pour l’instant. Cliquez sur + pour en créer un à partir du texte sélectionné."
|
||||
"No variables fit here." = "Aucune variable ne convient ici."
|
||||
"No variables yet. Create them in Local variables." = "Aucune variable pour l’instant. Créez-en dans Variables locales."
|
||||
@@ -831,6 +880,7 @@ status = "draft — needs native review"
|
||||
"Nothing here yet. Set a frame's status to list it." = "Rien pour l’instant. Attribuez un statut à un cadre pour le lister ici."
|
||||
"Nothing online yet. Use Share on a file to put it online and it is listed here." = "Rien en ligne pour l’instant. Utilisez Partager sur un fichier pour le mettre en ligne et il apparaîtra ici."
|
||||
"Nothing to export" = "Rien à exporter"
|
||||
"Nov" = "nov."
|
||||
"Now" = "Maintenant"
|
||||
"Nudge (Shift: big nudge)" = "Décaler (Maj : grand décalage)"
|
||||
"Nudge amount" = "Pas de décalage"
|
||||
@@ -839,13 +889,16 @@ status = "draft — needs native review"
|
||||
"Number ↓" = "Numéro ↓"
|
||||
"Numbered list" = "Liste numérotée"
|
||||
"Object" = "Objet"
|
||||
"Oct" = "oct."
|
||||
"Off" = "Non"
|
||||
"Offered first when filling the slot (any content is allowed)" = "Proposés en premier pour remplir l’emplacement (tout contenu est autorisé)"
|
||||
"Offered first when swapping the instance (any component is allowed)" = "Proposés en premier pour permuter l’instance (tout composant est autorisé)"
|
||||
"Offset" = "Décalage"
|
||||
"Oldest first" = "Plus ancien d’abord"
|
||||
"On click" = "Au clic"
|
||||
"On drag" = "Au glissement"
|
||||
"On this page" = "Sur cette page"
|
||||
"One announcement in five common sizes: square, portrait, story, link and banner" = "Une annonce en cinq formats courants : carré, portrait, story, lien et bannière"
|
||||
"Online" = "En ligne"
|
||||
"Online files" = "Fichiers en ligne"
|
||||
"Online sharing is off: files are shared on the local network only." = "Le partage en ligne est désactivé : les fichiers ne sont partagés que sur le réseau local."
|
||||
@@ -870,9 +923,13 @@ status = "draft — needs native review"
|
||||
"Open variables ({count})" = "Ouvrir les variables ({count})"
|
||||
"Opened as a new document" = "Ouvert comme nouveau document"
|
||||
"Opened “{name}” in a new tab" = "« {name} » ouvert dans un nouvel onglet"
|
||||
"Opening the file…" = "Ouverture du fichier…"
|
||||
"Opening {n} files as new files" = { one = "Ouverture comme nouveau fichier", other = "Ouverture de {n} fichiers comme nouveaux fichiers" }
|
||||
"Open|comments" = "Ouverts"
|
||||
"Options" = "Options"
|
||||
"Orange" = "Orange"
|
||||
"Order" = "Ordre"
|
||||
"Other" = "Autre"
|
||||
"Other apps may not open every .fig file exported here." = "D’autres applications risquent de ne pas ouvrir tous les fichiers .fig exportés ici."
|
||||
"Others lose access and keep their copy; the file stays on the server for you" = "Les autres perdent l’accès et conservent leur copie ; le fichier reste sur le serveur pour vous"
|
||||
"Others type this under Share ▸ Join with a code" = "Les autres le saisissent dans Partager ▸ Rejoindre avec un code"
|
||||
@@ -893,6 +950,7 @@ status = "draft — needs native review"
|
||||
"Page" = "Page"
|
||||
"Pages" = "Pages"
|
||||
"Pages added to the document" = "Pages ajoutées au document"
|
||||
"Pick the Frame tool, then drag on the canvas or choose a device size" = "Choisissez l’outil Cadre, puis faites glisser sur le canevas ou choisissez une taille d’appareil"
|
||||
"Placed on the current page" = "Placé sur la page actuelle"
|
||||
"Pages: {pages}" = "Pages : {pages}"
|
||||
"Paint bucket" = "Pot de peinture"
|
||||
@@ -930,7 +988,10 @@ status = "draft — needs native review"
|
||||
"Place a shape or frame (its tool chosen)" = "Placer une forme ou un cadre (outil choisi)"
|
||||
"Place automatically" = "Placer automatiquement"
|
||||
"Place image" = "Placer une image"
|
||||
"Playground" = "Bac à sable"
|
||||
"Plugin" = "Plugin"
|
||||
"Plugins" = "Plugins"
|
||||
"Plugins & widgets" = "Plugins et widgets"
|
||||
"Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "Les plugins s’exécutent dans le bac à sable de FigCraft, sans fichiers, réseau ni horloge. N’installez que des plugins de confiance."
|
||||
"Plus darker" = "Plus foncé"
|
||||
"Plus lighter" = "Plus clair"
|
||||
@@ -947,6 +1008,7 @@ status = "draft — needs native review"
|
||||
"Present or preview" = "Présenter ou prévisualiser"
|
||||
"Present this flow" = "Présenter ce flux"
|
||||
"Presentation" = "Présentation"
|
||||
"Presentation slides" = "Diapositives de présentation"
|
||||
"Presenting: comments" = "Présentation : commentaires"
|
||||
"Presenting: cycle the size options" = "Présentation : parcourir les options de taille"
|
||||
"Presenting: full screen" = "Présentation : plein écran"
|
||||
@@ -1003,6 +1065,7 @@ status = "draft — needs native review"
|
||||
"Remove auto layout" = "Retirer la mise en page auto"
|
||||
"Remove dev resource" = "Retirer la ressource de développement"
|
||||
"Remove export setting" = "Retirer le réglage d’exportation"
|
||||
"Remove from recents" = "Retirer des récents"
|
||||
"Remove from server" = "Retirer du serveur"
|
||||
"Remove from server…" = "Retirer du serveur…"
|
||||
"Remove guide" = "Retirer le repère"
|
||||
@@ -1115,15 +1178,18 @@ status = "draft — needs native review"
|
||||
"Search layers" = "Rechercher des calques"
|
||||
"Search plugins" = "Rechercher des plugins"
|
||||
"Search shortcuts" = "Rechercher des raccourcis"
|
||||
"Search templates" = "Rechercher des modèles"
|
||||
"Section" = "Section"
|
||||
"Section color" = "Couleur de la section"
|
||||
"Sections" = "Sections"
|
||||
"See all templates" = "Voir tous les modèles"
|
||||
"Select a frame and add a starting point to present it as a flow." = "Sélectionnez un cadre et ajoutez un point de départ pour le présenter comme un flux."
|
||||
"Select a frame to preview it" = "Sélectionnez un cadre pour le prévisualiser"
|
||||
"Select a layer to see its properties and code" = "Sélectionnez un calque pour voir ses propriétés et son code"
|
||||
"Select all" = "Tout sélectionner"
|
||||
"Select all with same" = "Sélectionner tout ce qui a le même"
|
||||
"Select layer" = "Sélectionner le calque"
|
||||
"Select layers and they space themselves as their content changes" = "Sélectionnez des calques et leur espacement s’ajuste quand leur contenu change"
|
||||
"Select layers to apply the style to" = "Sélectionnez les calques auxquels appliquer le style"
|
||||
"Select layers with this color" = "Sélectionner les calques de cette couleur"
|
||||
"Select overlapping shapes" = "Sélectionnez des formes qui se chevauchent"
|
||||
@@ -1136,6 +1202,7 @@ status = "draft — needs native review"
|
||||
"Send backward" = "Reculer"
|
||||
"Send report" = "Envoyer le signalement"
|
||||
"Send to back" = "Mettre à l’arrière-plan"
|
||||
"Sep" = "sept."
|
||||
"Server address" = "Adresse du serveur"
|
||||
"Server address (ws://host:47900/)" = "Adresse du serveur (ws://host:47900/)"
|
||||
"Server for online sharing" = "Serveur pour le partage en ligne"
|
||||
@@ -1150,6 +1217,7 @@ status = "draft — needs native review"
|
||||
"Shape tools" = "Outils de forme"
|
||||
"Share" = "Partager"
|
||||
"Share this file" = "Partager ce fichier"
|
||||
"Share with a code" = "Partager avec un code"
|
||||
"Share: live session, export" = "Partager : session en direct, exportation"
|
||||
"Shared" = "Partagé"
|
||||
"Shared: copy its code from the file's menu" = "Partagé : copiez son code depuis le menu du fichier"
|
||||
@@ -1159,6 +1227,7 @@ status = "draft — needs native review"
|
||||
"Sharing online · {people}" = "Partage en ligne · {people}"
|
||||
"Sharing stopped: the file stays on the server, open only to you" = "Partage arrêté : le fichier reste sur le serveur, accessible à vous seul"
|
||||
"Show" = "Afficher"
|
||||
"Show all templates" = "Afficher tous les modèles"
|
||||
"Show as grid" = "Afficher en grille"
|
||||
"Show as list" = "Afficher en liste"
|
||||
"Show autosaves" = "Afficher les sauvegardes auto"
|
||||
@@ -1167,6 +1236,9 @@ status = "draft — needs native review"
|
||||
"Show device frame" = "Afficher le cadre de l’appareil"
|
||||
"Show dimensions on objects" = "Afficher les dimensions sur les objets"
|
||||
"Show hotspot hints" = "Afficher les zones interactives"
|
||||
"Show in Explorer" = "Afficher dans l’Explorateur"
|
||||
"Show in Finder" = "Afficher dans le Finder"
|
||||
"Show in folder" = "Afficher dans le dossier"
|
||||
"Show only this on the canvas" = "Afficher uniquement ceci sur le canevas"
|
||||
"Show open" = "Afficher les ouverts"
|
||||
"Show section contents" = "Afficher le contenu de la section"
|
||||
@@ -1191,13 +1263,17 @@ status = "draft — needs native review"
|
||||
"Small caps" = "Petites capitales"
|
||||
"Smart animate" = "Animation intelligente"
|
||||
"Snap to pixel grid" = "Aimanter à la grille de pixels"
|
||||
"Social and slides" = "Réseaux sociaux et diapositives"
|
||||
"Social media" = "Réseaux sociaux"
|
||||
"Social post sizes" = "Formats de publications sociales"
|
||||
"Soft light" = "Lumière douce"
|
||||
"Solid" = "Continu"
|
||||
"Some variants have the same property values: give each one a unique combination" = "Certaines variantes ont les mêmes valeurs de propriété : donnez à chacune une combinaison unique"
|
||||
"Sort" = "Trier"
|
||||
"Sort by" = "Trier par"
|
||||
"Sort by date" = "Trier par date"
|
||||
"Sort by page" = "Trier par page"
|
||||
"Sort by {column}" = "Trier par {column}"
|
||||
"Space around" = "Espace autour"
|
||||
"Space between" = "Espace entre"
|
||||
"Space drag" = "Espace et glisser"
|
||||
@@ -1216,9 +1292,11 @@ status = "draft — needs native review"
|
||||
"Start" = "Début"
|
||||
"Start ascending from / stop descending at" = "Compter vers le haut depuis / vers le bas jusqu’à"
|
||||
"Start from a blank canvas" = "Commencer avec un canevas vide"
|
||||
"Start from a template" = "Commencer avec un modèle"
|
||||
"Start on path" = "Début sur le tracé"
|
||||
"Start point" = "Point de départ"
|
||||
"Start sharing" = "Commencer le partage"
|
||||
"Start sharing with the Share button, then pass on the code it gives" = "Commencez à partager avec le bouton Partager, puis communiquez le code qu’il donne"
|
||||
"Starting to share…" = "Démarrage du partage…"
|
||||
"Status" = "Statut"
|
||||
"Sticky" = "Collant"
|
||||
@@ -1267,6 +1345,7 @@ status = "draft — needs native review"
|
||||
"Tell the server's operator what is wrong with it" = "Indiquez à l’opérateur du serveur ce qui ne va pas"
|
||||
"Temperature" = "Température"
|
||||
"Template" = "Modèle"
|
||||
"Templates" = "Modèles"
|
||||
"Terms" = "Conditions"
|
||||
"Text" = "Texte"
|
||||
"Text align" = "Alignement du texte"
|
||||
@@ -1276,6 +1355,9 @@ status = "draft — needs native review"
|
||||
"Texts" = "Textes"
|
||||
"Texture" = "Texture"
|
||||
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Le canevas affiche les couleurs en sRGB ; les fichiers PNG, JPG et WebP exportés sont balisés Display P3."
|
||||
"The destination document is unavailable. Nothing was imported." = "Le document de destination est indisponible. Rien n’a été importé."
|
||||
"The destination document was closed. Nothing was imported." = "Le document de destination a été fermé. Rien n’a été importé."
|
||||
"The destination page was removed. Nothing was imported." = "La page de destination a été supprimée. Rien n’a été importé."
|
||||
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Le fichier est supprimé du serveur : plus personne ne pourra l’ouvrir ni le rejoindre depuis celui-ci, et c’est irréversible. Les copies ouvertes restent à ceux qui les ont."
|
||||
"The file now holds another library" = "Le fichier contient désormais une autre bibliothèque"
|
||||
"The file now holds another library (\"{name}\")" = "Le fichier contient désormais une autre bibliothèque (« {name} »)"
|
||||
@@ -1324,6 +1406,8 @@ status = "draft — needs native review"
|
||||
"Truncate with an ellipsis" = "Tronquer avec des points de suspension"
|
||||
"Try again" = "Réessayer"
|
||||
"Try another search or filter." = "Essayez une autre recherche ou un autre filtre."
|
||||
"Try another word, or look at every kind." = "Essayez un autre mot, ou regardez chaque type."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Transformez un calque en composant, réutilisez-le et modifiez toutes les copies d’un coup"
|
||||
"Two digits" = "Deux chiffres"
|
||||
"Type" = "Type"
|
||||
"Type settings" = "Réglages typographiques"
|
||||
@@ -1362,6 +1446,7 @@ status = "draft — needs native review"
|
||||
"Use the layer's fill" = "Utiliser le remplissage du calque"
|
||||
"Used in {files} · {instances}" = "Utilisé dans {files} · {instances}"
|
||||
"Username" = "Nom d’utilisateur"
|
||||
"Uses variables: each layer shows them in its own mode" = "Utilise des variables : chaque calque les affiche dans son propre mode"
|
||||
"Value" = "Valeur"
|
||||
"Variable" = "Variable"
|
||||
"Variable collections" = "Collections de variables"
|
||||
@@ -1400,21 +1485,25 @@ status = "draft — needs native review"
|
||||
"Web" = "Web"
|
||||
"WebSocket URL" = "URL WebSocket"
|
||||
"WebSocket URL copied" = "URL WebSocket copiée"
|
||||
"Websites" = "Sites web"
|
||||
"Wedge" = "Coin"
|
||||
"Weight" = "Graisse"
|
||||
"What changed since it was marked ready for dev" = "Ce qui a changé depuis le marquage prêt pour le dev"
|
||||
"What is wrong with this file? The server's operator gets your report." = "Qu’est-ce qui ne va pas avec ce fichier ? L’opérateur du serveur reçoit votre signalement."
|
||||
"What it's for" = "À quoi il sert"
|
||||
"What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "L’état d’origine de cette modification est inconnu : elle date d’avant que FigCraft ne conserve une copie des cadres prêts pour le dev. Résolvez la modification (ou marquez-la à nouveau comme prête) pour comparer à partir de maintenant."
|
||||
"What you try here changes this preview and the code below, not the file" = "Ce que vous essayez ici change cet aperçu et le code ci-dessous, pas le fichier"
|
||||
"While hovering" = "Pendant le survol"
|
||||
"While pressing" = "Pendant l’appui"
|
||||
"White" = "Blanc"
|
||||
"Who runs it, its terms and how long it keeps files" = "Qui le gère, ses conditions et combien de temps il conserve les fichiers"
|
||||
"Why it changed" = "Pourquoi cela a changé"
|
||||
"Widget" = "Widget"
|
||||
"Widgets" = "Widgets"
|
||||
"Width" = "Largeur"
|
||||
"Width point" = "Point de largeur"
|
||||
"Width profile" = "Profil de largeur"
|
||||
"Wireframe kit" = "Kit de wireframes"
|
||||
"With the layers' export settings" = "Avec les réglages d’exportation des calques"
|
||||
"Wrap" = "Renvoi à la ligne"
|
||||
"Wrap in new section" = "Envelopper dans une nouvelle section"
|
||||
@@ -1429,6 +1518,7 @@ status = "draft — needs native review"
|
||||
"Your name in comments" = "Votre nom dans les commentaires"
|
||||
"Your system asks for reduced motion: animations stay off." = "Votre système demande de réduire les animations : elles restent désactivées."
|
||||
"Z" = "Z"
|
||||
"Z to A" = "Z à A"
|
||||
"Zoom" = "Zoom"
|
||||
"Zoom around the pointer" = "Zoomer autour du pointeur"
|
||||
"Zoom device to fill window" = "Zoomer l’appareil pour remplir la fenêtre"
|
||||
@@ -1492,6 +1582,7 @@ status = "draft — needs native review"
|
||||
"invalid internal .fig text path data was ignored" = "les données internes non valides de texte sur tracé du .fig ont été ignorées"
|
||||
"invalid or oversized vector networks import as outlines" = "les réseaux vectoriels non valides ou trop grands sont importés comme tracés"
|
||||
"invalid or oversized vector path data was skipped" = "les données de tracé vectoriel non valides ou trop grandes ont été ignorées"
|
||||
"just now" = "à l’instant"
|
||||
"key triggers without a supported key were skipped" = "les déclencheurs de touche sans touche prise en charge ont été ignorés"
|
||||
"key up triggers import as key presses" = "les déclencheurs au relâchement d’une touche sont importés comme des appuis sur la touche"
|
||||
"layers without a position were placed at their parent's origin" = "les calques sans position ont été placés à l’origine de leur parent"
|
||||
@@ -1559,9 +1650,11 @@ status = "draft — needs native review"
|
||||
"very long vector paths were skipped" = "les tracés vectoriels très longs ont été ignorés"
|
||||
"video actions are not supported and were skipped" = "les actions vidéo ne sont pas prises en charge et ont été ignorées"
|
||||
"video triggers are not supported and were skipped" = "les déclencheurs vidéo ne sont pas pris en charge et ont été ignorés"
|
||||
"yesterday" = "hier"
|
||||
"{instances} in {files}" = "{instances} dans {files}"
|
||||
"{language}: draft translation, waiting for a native speaker's review." = "{language} : traduction provisoire, en attente de relecture par un locuteur natif."
|
||||
"{length} bleed" = "fond perdu de {length}"
|
||||
"{month} {day}, {year}" = "{day} {month} {year}"
|
||||
"{name}\nClick to apply to the selection" = "{name}\nCliquez pour l’appliquer à la sélection"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nFaites glisser sur le canevas, ou cliquez pour le placer au centre de la vue.\nMaintenez Alt (Option) en le déposant sur une instance pour la permuter."
|
||||
"{name} (from {library})" = "{name} (de {library})"
|
||||
@@ -1573,6 +1666,7 @@ status = "draft — needs native review"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} est oublié sur cet ordinateur ; le fichier reste sur {server}"
|
||||
"{name} is on the server." = "{name} est sur le serveur."
|
||||
"{name} is spotlighting" = "{name} est sous les projecteurs"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "{name} est trop volumineux pour être ouvert dans le navigateur (plus de 512 Mo)."
|
||||
"{name} was removed from the server" = "{name} a été retiré du serveur"
|
||||
"{name} · deleted on main" = "{name} · supprimé dans le principal"
|
||||
"{name} · deleted on the branch" = "{name} · supprimé dans la branche"
|
||||
@@ -1726,8 +1820,10 @@ status = "draft — needs native review"
|
||||
"component.editProperty" = "Modifier la propriété de composant"
|
||||
"component.exposeInstance" = "Exposer l’instance imbriquée"
|
||||
"component.goToMain" = "Aller au composant principal"
|
||||
"component.hideWhenPublishing" = "Masquer à la publication"
|
||||
"component.list" = "Lister les composants"
|
||||
"component.makeSlot" = "Créer un emplacement"
|
||||
"component.playground" = "Bac à sable de composant"
|
||||
"component.properties" = "Propriétés du composant"
|
||||
"component.pushChanges" = "Transmettre les modifications au composant principal"
|
||||
"component.removeSlot" = "Retirer l’emplacement"
|
||||
@@ -1786,6 +1882,7 @@ status = "draft — needs native review"
|
||||
"file.exportSvg" = "Exporter la sélection en SVG"
|
||||
"file.import" = "Importer"
|
||||
"file.new" = "Nouveau fichier de design"
|
||||
"file.newFromTemplate" = "Nouveau à partir d’un modèle"
|
||||
"file.open" = "Ouvrir"
|
||||
"file.openRecent" = "Ouvrir un fichier récent"
|
||||
"file.placeImage" = "Placer une image"
|
||||
@@ -1813,6 +1910,7 @@ status = "draft — needs native review"
|
||||
"guide.remove" = "Retirer le repère"
|
||||
"guide.select" = "Sélectionner le repère"
|
||||
"help.about" = "À propos de FigCraft"
|
||||
"help.gettingStarted" = "Premiers pas"
|
||||
"help.page" = "Page d’aide"
|
||||
"help.shortcuts" = "Raccourcis clavier"
|
||||
"image.adjust" = "Régler l’image"
|
||||
@@ -1866,6 +1964,9 @@ status = "draft — needs native review"
|
||||
"node.reparent" = "Déplacer le calque dans"
|
||||
"node.set" = "Définir les propriétés"
|
||||
"object.batchRename" = "Renommer par lot"
|
||||
"object.convertToFrame" = "Convertir en cadre"
|
||||
"object.convertToGroup" = "Convertir en groupe"
|
||||
"object.convertToSection" = "Convertir en section"
|
||||
"object.flipH" = "Retourner horizontalement"
|
||||
"object.flipV" = "Retourner verticalement"
|
||||
"object.frameSelection" = "Encadrer la sélection"
|
||||
@@ -1986,6 +2087,7 @@ status = "draft — needs native review"
|
||||
"stroke.set" = "Définir le contour"
|
||||
"stroke.widthProfile" = "Profil de largeur"
|
||||
"style.apply" = "Appliquer le style"
|
||||
"style.bind" = "Lier une variable dans un style"
|
||||
"style.create" = "Créer un style"
|
||||
"style.delete" = "Supprimer le style"
|
||||
"style.detach" = "Détacher le style"
|
||||
@@ -1994,6 +2096,7 @@ status = "draft — needs native review"
|
||||
"style.move" = "Déplacer les styles"
|
||||
"style.renameFolder" = "Renommer le dossier de styles"
|
||||
"style.update" = "Modifier le style"
|
||||
"template.list" = "Lister les modèles"
|
||||
"text.alignCenter" = "Centrer"
|
||||
"text.alignJustify" = "Justifier"
|
||||
"text.alignLeft" = "Aligner à gauche"
|
||||
@@ -2159,8 +2262,11 @@ status = "draft — needs native review"
|
||||
"view.nextPage" = "Page suivante"
|
||||
"view.nextRegion" = "Activer la région suivante"
|
||||
"view.outlines" = "Contours"
|
||||
"view.outlinesHiddenLayers" = "Inclure les calques masqués dans les contours"
|
||||
"view.outlinesObjectBounds" = "Inclure les limites des objets dans les contours"
|
||||
"view.outlinesSelection" = "Contours de la sélection"
|
||||
"view.pan" = "Faire défiler la vue"
|
||||
"view.panelWidths" = "Réinitialiser la largeur des panneaux"
|
||||
"view.pixelGrid" = "Grille de pixels"
|
||||
"view.pixelPreview" = "Aperçu des pixels"
|
||||
"view.prevFrame" = "Zoomer sur le cadre précédent"
|
||||
@@ -2218,6 +2324,8 @@ status = "draft — needs native review"
|
||||
"view.design" = "Design"
|
||||
"view.layers" = "Calques"
|
||||
"view.outlines" = "Afficher les contours"
|
||||
"view.outlinesHiddenLayers" = "Inclure les calques masqués"
|
||||
"view.outlinesObjectBounds" = "Inclure les limites des objets"
|
||||
"view.outlinesSelection" = "Afficher les contours de la sélection"
|
||||
"view.prototype" = "Prototype"
|
||||
"view.themeDark" = "Sombre"
|
||||
|
||||
@@ -314,6 +314,7 @@ status = "draft — needs native review"
|
||||
"Corner radius" = "Radius sudut"
|
||||
"Corner smoothing" = "Penghalusan sudut"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Tidak dapat terhubung ke {server}: menampilkan data yang diingat komputer ini."
|
||||
"Couldn't read {name}." = "Tidak dapat membaca {name}."
|
||||
"Count" = "Jumlah"
|
||||
"Create" = "Buat"
|
||||
"Create a variable collection first" = "Buat koleksi variabel terlebih dahulu"
|
||||
@@ -638,6 +639,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Tukar instance (tidak ada component lain)"
|
||||
"Instances" = "Instance"
|
||||
"Instant" = "Instan"
|
||||
"Intensity" = "Intensitas"
|
||||
"Interaction" = "Interaksi"
|
||||
"Interactions" = "Interaksi"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Desain antarmuka dan prototyping. Alat desain clean-room gratis dan open-source yang ditulis dari nol dengan Rust, bagian dari ArtCraft Crafting Apps."
|
||||
@@ -1632,6 +1634,7 @@ status = "draft — needs native review"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} dilupakan di komputer ini; file tetap ada di {server}"
|
||||
"{name} is on the server." = "{name} ada di server."
|
||||
"{name} is spotlighting" = "{name} sedang spotlight"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "{name} terlalu besar untuk dibuka di browser (lebih dari 512 MB)."
|
||||
"{name}\nClick to apply to the selection" = "{name}\nKlik untuk menerapkan ke pilihan"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nSeret ke kanvas, atau klik untuk menaruh di tengah tampilan.\nTahan Alt (Option) saat melepas di atas instance untuk menukarnya."
|
||||
"{name} (from {library})" = "{name} (dari {library})"
|
||||
|
||||
@@ -316,6 +316,7 @@ status = "draft — needs native review"
|
||||
"Corner radius" = "角の半径"
|
||||
"Corner smoothing" = "角のスムージング"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server} に接続できない:このコンピューターが覚えている内容を表示中。"
|
||||
"Couldn't read {name}." = "{name} を読み込めませんでした。"
|
||||
"Couldn't save automatically: {error}" = "自動保存できませんでした:{error}"
|
||||
"Count" = "数"
|
||||
"Create" = "作成"
|
||||
@@ -643,6 +644,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "インスタンスの入れ替え(他のコンポーネントなし)"
|
||||
"Instances" = "インスタンス"
|
||||
"Instant" = "即時"
|
||||
"Intensity" = "強さ"
|
||||
"Interaction" = "インタラクション"
|
||||
"Interactions" = "インタラクション"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "インターフェースデザインとプロトタイピング。Rust でゼロから書かれた、無料でオープンソースのクリーンルーム設計のデザインツール。ArtCraft Crafting Apps の一つ。"
|
||||
@@ -1649,6 +1651,7 @@ status = "draft — needs native review"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} の記録をこのコンピューターから消去済み。ファイルは {server} に残る"
|
||||
"{name} is on the server." = "{name} はサーバーにある。"
|
||||
"{name} is spotlighting" = "{name} がスポットライト中"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "{name} は大きすぎるため、ブラウザーで開けません(512 MB 超)。"
|
||||
"{name} was removed from the server" = "{name} をサーバーから削除済み"
|
||||
"{name} · deleted on main" = "{name} · メインで削除"
|
||||
"{name} · deleted on the branch" = "{name} · ブランチで削除"
|
||||
|
||||
@@ -305,6 +305,7 @@ status = "draft — needs native review"
|
||||
"Corner radius" = "모서리 반경"
|
||||
"Corner smoothing" = "모서리 스무딩"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server}에 연결할 수 없음: 이 컴퓨터가 기억하는 내용을 표시함."
|
||||
"Couldn't read {name}." = "{name}을(를) 읽을 수 없습니다."
|
||||
"Couldn't save automatically: {error}" = "자동으로 저장할 수 없음: {error}"
|
||||
"Count" = "개수"
|
||||
"Create" = "만들기"
|
||||
@@ -615,6 +616,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "인스턴스 교체(다른 컴포넌트 없음)"
|
||||
"Instances" = "인스턴스"
|
||||
"Instant" = "즉시"
|
||||
"Intensity" = "강도"
|
||||
"Interaction" = "인터랙션"
|
||||
"Interactions" = "인터랙션"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "인터페이스 디자인과 프로토타이핑. Rust로 처음부터 작성한 무료 오픈 소스 클린룸 디자인 도구이며 ArtCraft Crafting Apps의 하나."
|
||||
@@ -1573,6 +1575,7 @@ status = "draft — needs native review"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name}의 기록을 이 컴퓨터에서 지움. 파일은 {server}에 남음"
|
||||
"{name} is on the server." = "{name}이(가) 서버에 있음."
|
||||
"{name} is spotlighting" = "{name} 님이 스포트라이트 중"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "{name}은(는) 너무 커서 브라우저에서 열 수 없습니다(512MB 초과)."
|
||||
"{name} was removed from the server" = "{name}을(를) 서버에서 제거함"
|
||||
"{name} · deleted on main" = "{name} · 메인에서 삭제됨"
|
||||
"{name} · deleted on the branch" = "{name} · 브랜치에서 삭제됨"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -304,6 +304,7 @@ status = "draft — needs native review"
|
||||
"Corner smoothing" = "Suavização de cantos"
|
||||
"Couldn't download the variables" = "Não foi possível baixar as variáveis"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Não foi possível alcançar {server}: mostrando o que este computador lembra."
|
||||
"Couldn't read {name}." = "Não foi possível ler {name}."
|
||||
"Couldn't save automatically: {error}" = "Não foi possível salvar automaticamente: {error}"
|
||||
"Count" = "Quantidade"
|
||||
"Create" = "Criar"
|
||||
@@ -614,6 +615,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "Troca de instância (nenhum outro componente)"
|
||||
"Instances" = "Instâncias"
|
||||
"Instant" = "Instantâneo"
|
||||
"Intensity" = "Brilho"
|
||||
"Interaction" = "Interação"
|
||||
"Interactions" = "Interações"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Design de interfaces e prototipagem. Uma ferramenta de design gratuita e de código aberto, com implementação independente (clean-room), escrita do zero em Rust, parte dos Crafting Apps da ArtCraft."
|
||||
@@ -1572,6 +1574,7 @@ status = "draft — needs native review"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} foi esquecido neste computador; o arquivo continua em {server}"
|
||||
"{name} is on the server." = "{name} está no servidor."
|
||||
"{name} is spotlighting" = "{name} está em destaque"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "{name} é grande demais para abrir no navegador (mais de 512 MB)."
|
||||
"{name} was removed from the server" = "{name} foi removido do servidor"
|
||||
"{name} · deleted on main" = "{name} · excluído no principal"
|
||||
"{name} · deleted on the branch" = "{name} · excluído na branch"
|
||||
|
||||
@@ -311,6 +311,7 @@ name = "Русский"
|
||||
"Corner radius" = "Радиус углов"
|
||||
"Corner smoothing" = "Сглаживание углов"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Не удалось связаться с {server}: показаны данные, сохранённые на этом компьютере."
|
||||
"Couldn't read {name}." = "Не удалось прочитать {name}."
|
||||
"Count" = "Количество"
|
||||
"Create" = "Создать"
|
||||
"Create a variable collection first" = "Сначала создайте коллекцию переменных"
|
||||
@@ -635,6 +636,7 @@ name = "Русский"
|
||||
"Instance swap (no other component)" = "Замена экземпляра (других компонентов нет)"
|
||||
"Instances" = "Экземпляры"
|
||||
"Instant" = "Мгновенно"
|
||||
"Intensity" = "Яркость"
|
||||
"Interaction" = "Взаимодействие"
|
||||
"Interactions" = "Взаимодействия"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Дизайн интерфейсов и прототипирование. Бесплатный инструмент с открытым исходным кодом, независимо созданный с нуля на Rust, из семейства ArtCraft Crafting Apps."
|
||||
@@ -818,6 +820,7 @@ name = "Русский"
|
||||
"No contributor data was built into this copy." = "В этой сборке нет данных об участниках."
|
||||
"No device" = "Без устройства"
|
||||
"No fonts match" = "Подходящих шрифтов нет"
|
||||
"No files or templates match your search" = "Файлы и шаблоны по запросу не найдены"
|
||||
"No join code here (the server is older than join codes, or this address can't be put in one): share the link instead." = "Код подключения недоступен (сервер не поддерживает коды или адрес нельзя включить в код): поделитесь ссылкой."
|
||||
"No layer changes to show." = "Нет изменений слоёв для показа."
|
||||
"No libraries yet. Add a .figcraft file that was published as a library, or paste a team library's link." = "Библиотек пока нет. Добавьте файл .figcraft, опубликованный как библиотека, или вставьте ссылку на командную библиотеку."
|
||||
@@ -1156,6 +1159,8 @@ name = "Русский"
|
||||
"Search actions, layers and tools" = "Поиск действий, слоёв и инструментов"
|
||||
"Search assets" = "Поиск ресурсов"
|
||||
"Search comments" = "Поиск комментариев"
|
||||
"Search files and templates" = "Поиск файлов и шаблонов"
|
||||
"Search results" = "Результаты поиска"
|
||||
"Search fonts" = "Поиск шрифтов"
|
||||
"Search layers" = "Поиск слоёв"
|
||||
"Search shortcuts" = "Поиск сочетаний клавиш"
|
||||
@@ -1381,6 +1386,7 @@ name = "Русский"
|
||||
"Truncate with an ellipsis" = "Обрезать с многоточием"
|
||||
"Try again" = "Повторить"
|
||||
"Try another search or filter." = "Попробуйте другой запрос или фильтр."
|
||||
"Try another search, or browse templates." = "Попробуйте другой запрос или просмотрите шаблоны."
|
||||
"Try another word, or look at every kind." = "Попробуйте другое слово или посмотрите все категории."
|
||||
"Turn a layer into a component, reuse it, and change every copy at once" = "Превратите слой в компонент, используйте повторно и изменяйте все копии сразу"
|
||||
"Two digits" = "Две цифры"
|
||||
@@ -1630,6 +1636,7 @@ name = "Русский"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} забыт на этом компьютере; файл остаётся на {server}"
|
||||
"{name} is on the server." = "{name} находится на сервере."
|
||||
"{name} is spotlighting" = "{name} показывает экран всем"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "Файл {name} слишком велик для открытия в браузере (больше 512 МБ)."
|
||||
"{name}\nClick to apply to the selection" = "{name}\nНажмите, чтобы применить к выделению"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nПеретащите на холст или нажмите, чтобы разместить в центре вида.\nУдерживайте Alt (Option) при переносе на экземпляр, чтобы заменить его."
|
||||
"{name} (from {library})" = "{name} (из {library})"
|
||||
|
||||
@@ -315,6 +315,7 @@ name = "Svenska"
|
||||
"Corner smoothing" = "Hörnutjämning"
|
||||
"Couldn't download the variables" = "Det gick inte att hämta variablerna"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns."
|
||||
"Couldn't read {name}." = "Det gick inte att läsa {name}."
|
||||
"Couldn't save automatically: {error}" = "Det gick inte att spara automatiskt: {error}"
|
||||
"Count" = "Antal"
|
||||
"Create" = "Skapa"
|
||||
@@ -645,6 +646,7 @@ name = "Svenska"
|
||||
"Instance swap (no other component)" = "Instansbyte (ingen annan komponent)"
|
||||
"Instances" = "Instanser"
|
||||
"Instant" = "Direkt"
|
||||
"Intensity" = "Intensitet"
|
||||
"Interaction" = "Interaktion"
|
||||
"Interactions" = "Interaktioner"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Gränssnittsdesign och prototyper. Ett fritt clean-room-designverktyg med öppen källkod, skrivet från grunden i Rust och en del av ArtCraft Crafting Apps."
|
||||
@@ -1649,6 +1651,7 @@ name = "Svenska"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} har glömts på den här datorn; filen finns kvar på {server}"
|
||||
"{name} is on the server." = "{name} finns på servern."
|
||||
"{name} is spotlighting" = "{name} har spotlight"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "{name} är för stor för att öppnas i webbläsaren (över 512 MB)."
|
||||
"{name}\nClick to apply to the selection" = "{name}\nKlicka för att använda på markeringen"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nDra till arbetsytan, eller klicka för att placera i mitten av vyn.\nHåll ned Alt (Option) när du släpper på en instans för att byta ut den."
|
||||
"{name} (from {library})" = "{name} (från {library})"
|
||||
|
||||
@@ -314,6 +314,7 @@ name = "Türkçe"
|
||||
"Corner radius" = "Köşe yarıçapı"
|
||||
"Corner smoothing" = "Köşe yumuşatma"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "{server} sunucusuna ulaşılamadı: bu bilgisayarın hatırladıkları gösteriliyor."
|
||||
"Couldn't read {name}." = "{name} okunamadı."
|
||||
"Count" = "Sayı"
|
||||
"Create" = "Oluştur"
|
||||
"Create a variable collection first" = "Önce bir değişken koleksiyonu oluşturun"
|
||||
@@ -638,6 +639,7 @@ name = "Türkçe"
|
||||
"Instance swap (no other component)" = "Örnek değiştirme (başka bileşen yok)"
|
||||
"Instances" = "Örnekler"
|
||||
"Instant" = "Anında"
|
||||
"Intensity" = "Yoğunluk"
|
||||
"Interaction" = "Etkileşim"
|
||||
"Interactions" = "Etkileşimler"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Arayüz tasarımı ve prototipleme. Rust ile sıfırdan yazılmış, ücretsiz, açık kaynaklı, temiz oda yöntemiyle geliştirilmiş bir tasarım aracı; ArtCraft Crafting Apps ailesinin bir parçası."
|
||||
@@ -1632,6 +1634,7 @@ name = "Türkçe"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "{name} bu bilgisayarda unutuldu; dosya {server} üzerinde kalır"
|
||||
"{name} is on the server." = "{name} sunucuda."
|
||||
"{name} is spotlighting" = "{name} öne çıkarıyor"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "{name}, tarayıcıda açılamayacak kadar büyük (512 MB'tan fazla)."
|
||||
"{name}\nClick to apply to the selection" = "{name}\nSeçime uygulamak için tıklayın"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nTuvale sürükleyin veya görünümün ortasına yerleştirmek için tıklayın.\nBir örneği değiştirmek için üzerine bırakırken Alt (Option) tuşunu basılı tutun."
|
||||
"{name} (from {library})" = "{name} ({library} kitaplığından)"
|
||||
|
||||
@@ -306,6 +306,7 @@ status = "draft — needs native review"
|
||||
"Corner radius" = "圆角半径"
|
||||
"Corner smoothing" = "圆角平滑"
|
||||
"Couldn't reach {server}: showing what this computer remembers." = "无法连接 {server}:显示的是这台电脑记录的内容。"
|
||||
"Couldn't read {name}." = "无法读取 {name}。"
|
||||
"Couldn't save automatically: {error}" = "无法自动保存:{error}"
|
||||
"Count" = "数量"
|
||||
"Create" = "创建"
|
||||
@@ -616,6 +617,7 @@ status = "draft — needs native review"
|
||||
"Instance swap (no other component)" = "实例替换(无其他组件)"
|
||||
"Instances" = "实例"
|
||||
"Instant" = "即时"
|
||||
"Intensity" = "强度"
|
||||
"Interaction" = "交互"
|
||||
"Interactions" = "交互"
|
||||
"Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "界面设计与原型制作。一款以 Rust 从零编写的免费、开源、净室设计工具,属于 ArtCraft Crafting Apps 系列。"
|
||||
@@ -1567,6 +1569,7 @@ status = "draft — needs native review"
|
||||
"{name} is forgotten on this computer; the file stays on {server}" = "已在这台电脑上忘记 {name};文件仍保留在 {server} 上"
|
||||
"{name} is on the server." = "{name} 已在服务器上。"
|
||||
"{name} is spotlighting" = "{name} 正在聚焦"
|
||||
"{name} is too large to open in the browser (over 512 MB)." = "{name} 太大,无法在浏览器中打开(超过 512 MB)。"
|
||||
"{name}\nClick to apply to the selection" = "{name}\n点击应用于所选项"
|
||||
"{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\n拖到画布上,或点击放置在视图中央。\n拖放到实例上时按住 Alt(Option)可替换该实例。"
|
||||
"{name} (from {library})" = "{name}(来自 {library})"
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::path::PathBuf;
|
||||
|
||||
use figcraft_engine::Session;
|
||||
|
||||
use crate::{DialogFuture, FigApp, Services};
|
||||
use crate::{ContentsFuture, DialogFuture, FigApp, Services};
|
||||
|
||||
struct OpenFile(PathBuf);
|
||||
|
||||
@@ -79,6 +79,74 @@ fn save_as_saves_the_file_it_was_opened_for_after_another_tab_comes_to_the_front
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
/// The browser: the picker hands over a file's name and contents, never a path.
|
||||
struct Contents(&'static str, Vec<u8>);
|
||||
|
||||
impl Services for Contents {
|
||||
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {
|
||||
panic!("a platform that picks contents is never asked for a path")
|
||||
}
|
||||
fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option<ContentsFuture> {
|
||||
Some(Box::pin(std::future::ready(Some(Ok((self.0.to_string(), self.1.clone()))))))
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser, with a picked file it won't read.
|
||||
struct Unreadable(String);
|
||||
|
||||
impl Services for Unreadable {
|
||||
fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option<ContentsFuture> {
|
||||
Some(Box::pin(std::future::ready(Some(Err(self.0.clone())))))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picked_file_that_cannot_be_read_says_why_and_opens_nothing() {
|
||||
let why = crate::browser_can_read("Huge.fig", 600.0 * 1024.0 * 1024.0).unwrap_err();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(Unreadable(why.clone())));
|
||||
app.open_dialog();
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert!(app.session.docs.is_empty());
|
||||
assert_eq!(app.ui.last_error.as_deref(), Some(why.as_str()));
|
||||
assert!(why.contains("Huge.fig"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_reads_files_up_to_512_mib() {
|
||||
let max = (512u64 << 20) as f64;
|
||||
assert!(crate::browser_can_read("a", 0.0).is_ok());
|
||||
assert!(crate::browser_can_read("a", max).is_ok());
|
||||
assert!(crate::browser_can_read("a", max + 1.0).is_err());
|
||||
assert!(crate::browser_can_read("a", f64::NAN).is_err());
|
||||
assert!(crate::browser_can_read("a", f64::INFINITY).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_dialog_opens_picked_contents_as_a_new_unsaved_document() {
|
||||
let bytes = figcraft_format::save_bytes(&figcraft_doc::Document::new("Picked"), None).unwrap();
|
||||
let mut app = FigApp::new(Session::empty(), Box::new(Contents("Picked.figcraft", bytes)));
|
||||
app.ui.home = true;
|
||||
app.open_dialog();
|
||||
assert!(app.session.docs.is_empty(), "opening waits for the picker result");
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(app.session.docs.len(), 1);
|
||||
assert_eq!(app.session.docs[0].path, None, "no path to save over");
|
||||
assert_eq!(app.session.docs[0].doc.name, "Picked");
|
||||
assert!(!app.ui.home, "and the editor shows it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_dialog_imports_picked_contents_into_the_file_in_front() {
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="red"/></svg>"#.to_vec();
|
||||
let mut app = FigApp::new(Session::new(), Box::new(Contents("Logo.svg", svg)));
|
||||
let rectangles = |app: &FigApp| app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)).count();
|
||||
assert_eq!(rectangles(&app), 0);
|
||||
app.import_dialog(false);
|
||||
app.poll_dialog(&egui::Context::default());
|
||||
assert_eq!(app.session.docs.len(), 1, "imported, not opened");
|
||||
assert_eq!(rectangles(&app), 1, "the SVG's content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialog_whose_file_closed_meanwhile_does_nothing() {
|
||||
let (mut app, dir, path) = two_files("closed");
|
||||
|
||||
@@ -507,21 +507,17 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
|
||||
let path = f.path().to_path_buf();
|
||||
let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default();
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into());
|
||||
let params = if path.exists() {
|
||||
json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y})
|
||||
} else if let Some(bytes) = crate::dropped_bytes(&f) {
|
||||
use base64::Engine as _;
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
json!({"data": b64, "name": name, "x": pt.x, "y": pt.y})
|
||||
let (params, read) = if path.exists() {
|
||||
(json!({"path": path.to_string_lossy(), "x": pt.x, "y": pt.y}), None)
|
||||
} else {
|
||||
continue;
|
||||
(json!({"name": name, "x": pt.x, "y": pt.y}), Some(crate::dropped_bytes(&f, &name)))
|
||||
};
|
||||
let Some(target) =
|
||||
app.session.ds().ok().map(|doc| crate::pending_open::ImportTarget { uid: doc.uid, page: doc.page, name: doc.doc.name.clone() })
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target) });
|
||||
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read });
|
||||
}
|
||||
let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else {
|
||||
// A drag released off the canvas, or off the window, ends where the pointer was last seen.
|
||||
@@ -1129,7 +1125,9 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [
|
||||
if !ov.pen.is_empty() {
|
||||
let pts: Vec<Pos2> = ov.pen.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
|
||||
if app.session.tool.tool == ToolId::Pencil {
|
||||
painter.add(Shape::line(pts, stroke(t.text, 2.0)));
|
||||
// The stroke it will make, in its colour and weight (at least a point when zoomed out).
|
||||
let (color, weight) = figcraft_engine::tools::PENCIL_STROKE;
|
||||
painter.add(Shape::line(pts, stroke(doc_c32(painter.ctx(), color), ((weight * zoom) as f32).max(1.0))));
|
||||
} else {
|
||||
for [x0, y0, x1, y1] in &ov.pen_handles {
|
||||
bezier_handle(painter, app.to_screen(*x0, *y0), app.to_screen(*x1, *y1), 3.5, Color32::WHITE);
|
||||
|
||||
@@ -128,3 +128,36 @@ fn the_pen_draws_the_handles_of_the_point_being_dragged_and_a_curved_band() {
|
||||
let band = shapes.iter().any(|s| matches!(s, Shape::CubicBezier(c) if c.points[0].distance(a) < 0.5 && c.points[1].distance(at(&u, 140.0, 100.0)) < 0.5 && c.stroke.color == accent));
|
||||
assert!(band, "a curve leaving along the out handle");
|
||||
}
|
||||
|
||||
/// While drawing, the Pencil shows the stroke it will make, in its colour and weight (#256): in a
|
||||
/// dark theme the theme's text colour was white, so over a white shape nothing showed until the
|
||||
/// stroke was let go.
|
||||
#[test]
|
||||
fn the_pencil_previews_its_stroke_in_its_colour_and_weight() {
|
||||
let mut u = Ui::new(Session::new());
|
||||
u.app.run("tool.set", json!({"tool": "pencil"}));
|
||||
let (color, weight) = figcraft_engine::tools::PENCIL_STROKE;
|
||||
let preview = |shapes: &[Shape]| {
|
||||
shapes.iter().find_map(|s| match s {
|
||||
Shape::Path(p) if !p.closed && p.points.len() >= 3 && p.stroke.color == egui::epaint::ColorMode::Solid(crate::theme::c32(color)) => {
|
||||
Some(p.stroke.width)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
};
|
||||
let at = |u: &Ui, x: f64, y: f64| u.app.to_screen(x, y);
|
||||
let start = at(&u, 10.0, 10.0);
|
||||
u.button(start, true);
|
||||
for i in 1..=6 {
|
||||
let p = at(&u, 10.0 + 12.0 * f64::from(i), 10.0 + 6.0 * f64::from(i % 2));
|
||||
u.frame(vec![egui::Event::PointerMoved(p)]);
|
||||
}
|
||||
let zoom = u.app.session.ds().unwrap().view.zoom;
|
||||
let w = preview(&flat(&u.frame_output(vec![]).shapes)).expect("a preview while drawing");
|
||||
assert!((w - (weight * zoom) as f32).abs() < 0.01, "{w} for {weight} at {zoom}");
|
||||
// Zoomed far out it stays visible.
|
||||
u.app.session.ds_mut().unwrap().view.zoom = 0.05;
|
||||
let p = at(&u, 120.0, 40.0);
|
||||
let w = preview(&flat(&u.frame_output(vec![egui::Event::PointerMoved(p)]).shapes)).expect("still previewing");
|
||||
assert!((w - 1.0).abs() < 0.01, "at least a point: {w}");
|
||||
}
|
||||
@@ -120,8 +120,7 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option<Paint> {
|
||||
/// Write `paint` to the target. `live` edits are part of an ongoing drag.
|
||||
fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
|
||||
if let PickerTarget::PluginField { plugin, control, .. } = target {
|
||||
let c = paint.solid_color().unwrap_or(Color::BLACK);
|
||||
let hex = c.with_alpha(c.a * paint.opacity).to_css_hex();
|
||||
let hex = paint.solid_color().unwrap_or(Color::BLACK).to_css_hex();
|
||||
let (plugin, control) = (plugin.clone(), control.clone());
|
||||
if !live {
|
||||
crate::plugins_ui::set_field(app, &plugin, &control, &hex);
|
||||
@@ -153,7 +152,7 @@ fn write(app: &mut FigApp, target: &PickerTarget, paint: &Paint, live: bool) {
|
||||
}
|
||||
PickerTarget::Variable { id, mode } => {
|
||||
let c = paint.solid_color().unwrap_or(Color::BLACK);
|
||||
app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.with_alpha(c.a * paint.opacity).to_css_hex()}))
|
||||
app.session.execute("variable.set", &json!({"variable": id, "mode": mode, "value": c.to_css_hex()}))
|
||||
}
|
||||
PickerTarget::PluginField { .. } => return,
|
||||
PickerTarget::Style { id, index } => {
|
||||
@@ -1181,6 +1180,22 @@ mod selection_color_tests {
|
||||
assert_eq!(app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "FF0000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_a_variable_keeps_its_alpha() {
|
||||
// The picker holds a colour variable's alpha as the paint opacity; writing it back applies it once.
|
||||
let (mut app, _, _) = setup();
|
||||
app.session.execute("variable.createCollection", &json!({"name":"T"})).unwrap();
|
||||
let v = app.session.execute("variable.create", &json!({"collection":"T","name":"tint","type":"color","value":"#0D6EFD80"})).unwrap();
|
||||
let var = app.session.doc().unwrap().variables.get(v["id"].as_str().unwrap()).unwrap().clone();
|
||||
let target = PickerTarget::Variable { id: var.id.clone(), mode: var.values.keys().next().unwrap().clone() };
|
||||
for _ in 0..2 {
|
||||
let paint = current_paint(&app, &target).unwrap();
|
||||
write(&mut app, &target, &paint, false);
|
||||
}
|
||||
let c = current_paint(&app, &target).unwrap().solid_color().unwrap();
|
||||
assert_eq!(c.to_css_hex().to_uppercase(), "#0D6EFD80");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_does_not_follow_selection_or_document_switches() {
|
||||
let (mut app, _, _) = setup();
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
//! for the frames that follow (all false: released); `gamepad` presses a controller button in the
|
||||
//! prototype playing (presenter or preview pane)
|
||||
//! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog)
|
||||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}`
|
||||
//! - `ui.screenshot {path?}` — PNG of the window (needs a visible window), written to `path` or
|
||||
//! returned as base64 `data`
|
||||
//! - `ui.render {path?, scale?, ids?}` — PNG of the document (no UI chrome; works without a
|
||||
@@ -429,6 +429,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
|
||||
if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) {
|
||||
app.ui.home_view.section = section;
|
||||
}
|
||||
if let Some(query) = p.get("homeSearch").and_then(Value::as_str) {
|
||||
app.ui.home_view.file_query = query.chars().take(256).collect();
|
||||
}
|
||||
if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) {
|
||||
view.store(&mut app.session.prefs);
|
||||
}
|
||||
|
||||
@@ -57,6 +57,24 @@ fn a_confined_app_writes_only_inside_its_write_root() {
|
||||
assert!(app.session.execute("file.export", &json!({"format": "svg", "path": p("outside/own.svg")})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_confined_app_saves_only_inside_its_write_root() {
|
||||
// #836: saving over the control channel, both as a new file and to the current one.
|
||||
let (mut app, d) = confined_app("save");
|
||||
let p = |rel: &str| d.0.join(rel).to_string_lossy().into_owned();
|
||||
for cmd in ["file.saveAs", "file.save"] {
|
||||
let e = handle(&mut app, "engine.execute", json!({"command": cmd, "params": {"path": p("outside/a.figcraft")}})).unwrap_err();
|
||||
assert!(e.contains("automation path rejected"), "{cmd}: {e}");
|
||||
let e = handle(&mut app, "engine.execute", json!({"command": cmd, "params": {"path": p("out/../outside/b.figcraft")}})).unwrap_err();
|
||||
assert!(e.contains("automation path rejected"), "{cmd}: {e}");
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(d.0.join("outside")).unwrap().count(), 0, "nothing written outside the root");
|
||||
handle(&mut app, "engine.execute", json!({"command": "file.saveAs", "params": {"path": p("out/in.figcraft")}})).unwrap();
|
||||
assert!(d.0.join("out/in.figcraft").is_file());
|
||||
// Saving again goes to the same file, inside the root.
|
||||
handle(&mut app, "engine.execute", json!({"command": "file.save", "params": {}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_captures_are_written_through_the_roots_or_returned() {
|
||||
let (app, d) = confined_app("capture");
|
||||
|
||||
@@ -473,3 +473,79 @@ fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() {
|
||||
let (_, _, w, h) = geom(&u, a);
|
||||
assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60");
|
||||
}
|
||||
|
||||
/// The texts painted in the last frame: what they say, whether they end in an ellipsis, and where
|
||||
/// they show (within their clip).
|
||||
fn painted_texts(shapes: &[egui::epaint::ClippedShape]) -> Vec<(String, bool, egui::Rect)> {
|
||||
fn walk(shape: &egui::Shape, clip: egui::Rect, out: &mut Vec<(String, bool, egui::Rect)>) {
|
||||
match shape {
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, clip, out)),
|
||||
egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.galley.elided, t.visual_bounding_rect().intersect(clip))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
shapes.iter().for_each(|c| walk(&c.shape, c.clip_rect, &mut out));
|
||||
out
|
||||
}
|
||||
|
||||
/// The glass effect's light intensity field is labelled (#256): drawn in the Design panel with the
|
||||
/// effect's settings open.
|
||||
#[test]
|
||||
fn the_glass_light_intensity_field_has_a_label() {
|
||||
let mut s = Session::new();
|
||||
let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()["id"].as_u64().unwrap());
|
||||
s.execute("select.set", &json!({"ids": [r]})).unwrap();
|
||||
s.execute("effect.add", &json!({"type": "glass"})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 0usize)), true));
|
||||
u.frame(vec![]);
|
||||
let shapes = u.frame_output(vec![]).shapes;
|
||||
let lead = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect;
|
||||
let labels: Vec<(String, bool)> =
|
||||
painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, cut, _)| (text, cut)).collect();
|
||||
assert_eq!(labels, [("Intensity".to_string(), false)], "labelled, and not cut short");
|
||||
let field = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("field")).expect("its value").rect;
|
||||
assert_eq!(field.max.x - lead.min.x, GLASS_FIELD_W, "the width the next test measures against");
|
||||
}
|
||||
|
||||
/// A glass settings field's width in the Design panel at its default width.
|
||||
const GLASS_FIELD_W: f32 = 98.5;
|
||||
|
||||
/// The light intensity label this fix adds fits whole beside its value in every language (#256).
|
||||
/// Other glass labels may end in "…" in some languages (es "Profundidad"); a longer
|
||||
/// "Intensity" translation fails here.
|
||||
#[test]
|
||||
fn the_intensity_label_fits_whole_in_every_language() {
|
||||
use crate::i18n::{self, Text};
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let width = |s: &str| ui.painter().layout_no_wrap(s.to_string(), crate::theme::regular(11.0), egui::Color32::WHITE).size().x;
|
||||
for lang in i18n::languages() {
|
||||
let label = lang.ui.iter().find(|(k, _)| *k == "Intensity").map_or("Intensity", |(_, t)| match t {
|
||||
Text::Plain(s) | Text::Plural { other: s, .. } => s,
|
||||
});
|
||||
let lead = crate::widgets::text_lead_width(ui, label, GLASS_FIELD_W);
|
||||
assert!(lead >= 12.0 + width(label), "{}: {label:?} would end in \"…\"", lang.code);
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut field = egui::Rect::NOTHING;
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| {
|
||||
let lead = crate::widgets::Lead::Text("A label much wider than its field");
|
||||
let _ = crate::widgets::number_field(ui, Id::new("long"), lead, Some(80.0), "%", 60.0, 1.0);
|
||||
field = ui.min_rect();
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let texts = painted_texts(&out.shapes);
|
||||
let [(_, label_cut, l), (value, value_cut, v)] = texts.as_slice() else { panic!("the label and the value: {texts:?}") };
|
||||
assert!(*label_cut, "the label ends in an ellipsis");
|
||||
assert_eq!((value.as_str(), *value_cut), ("80%", false), "the value shows whole");
|
||||
assert!(field.contains_rect(*l) && field.contains_rect(*v), "both inside the field {field:?}: {l:?} {v:?}");
|
||||
assert!(l.max.x < v.min.x, "the label stops before the value: {l:?} {v:?}");
|
||||
}
|
||||
+107
-30
@@ -51,6 +51,8 @@ impl Section {
|
||||
#[derive(Default)]
|
||||
pub struct Home {
|
||||
pub section: Section,
|
||||
/// Searches known files and starter templates together from the home sidebar.
|
||||
pub file_query: String,
|
||||
/// The Templates search text and kind filter.
|
||||
pub query: String,
|
||||
pub kind: Option<Kind>,
|
||||
@@ -143,7 +145,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
let left = Rect::from_min_size(rect.min, vec2(SIDEBAR, rect.height()));
|
||||
ui.painter().line_segment([left.right_top(), left.right_bottom()], Stroke::new(1.0, t.border));
|
||||
sidebar(app, ui, &t, left);
|
||||
if app.ui.home_view.section == Section::Online {
|
||||
if app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == Section::Online {
|
||||
// Your online files lay themselves out in the area (and scroll their own list).
|
||||
let area = Rect::from_min_max(pos2(left.max.x + 40.0, rect.min.y + 24.0), rect.max - vec2(40.0, 24.0));
|
||||
crate::online::section(app, ui, area, &t);
|
||||
@@ -155,9 +157,14 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) {
|
||||
egui::ScrollArea::vertical().id_salt("home").auto_shrink([false, false]).show(ui, |ui| {
|
||||
egui::Frame::NONE.inner_margin(egui::Margin::symmetric(40, 24)).show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(GAP, 0.0);
|
||||
match app.ui.home_view.section {
|
||||
Section::Templates => templates(app, ui, &t),
|
||||
_ => recents(app, ui, &t),
|
||||
if app.ui.home_view.file_query.trim().is_empty() {
|
||||
match app.ui.home_view.section {
|
||||
Section::Templates => templates(app, ui, &t),
|
||||
_ => recents(app, ui, &t),
|
||||
}
|
||||
} else {
|
||||
search_results(app, ui, &t);
|
||||
opened_join(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -180,16 +187,12 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
// `opens` saw an extension, so there is a file name to tell the format by.
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||
let params = if path.exists() {
|
||||
json!({"path": path.to_string_lossy()})
|
||||
} else if let Some(bytes) = crate::dropped_bytes(&f) {
|
||||
use base64::Engine as _;
|
||||
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
|
||||
json!({"data": data, "name": name})
|
||||
let (params, read) = if path.exists() {
|
||||
(json!({"path": path.to_string_lossy()}), None)
|
||||
} else {
|
||||
continue;
|
||||
(json!({"name": name}), Some(crate::dropped_bytes(&f, &name)))
|
||||
};
|
||||
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None });
|
||||
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.open", target: None, read });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,10 +201,20 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
icons::paint(ui.painter(), Rect::from_min_size(pos2(left.min.x + 16.0, y), vec2(20.0, 20.0)), Icon::Logo, ACCENT);
|
||||
ui.painter().text(pos2(left.min.x + 44.0, y + 10.0), Align2::LEFT_CENTER, "FigCraft", semibold(14.0), t.text);
|
||||
y += 48.0;
|
||||
let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| {
|
||||
let response =
|
||||
widgets::text_input(ui, Id::new("home_file_search"), &mut app.ui.home_view.file_query, search.width(), tr("Search files and templates"));
|
||||
a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query);
|
||||
if (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.home_view.file_query.clear();
|
||||
}
|
||||
});
|
||||
y += widgets::FIELD_H + 12.0;
|
||||
for (section, icon, label) in Section::ALL {
|
||||
let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0));
|
||||
let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click());
|
||||
let current = app.ui.home_view.section == section;
|
||||
let current = app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == section;
|
||||
a11y::tab(&resp, tr(label), current);
|
||||
if current || resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), if current { t.selected_row } else { t.hover });
|
||||
@@ -211,11 +224,65 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) {
|
||||
ui.painter().text(pos2(r.min.x + 36.0, r.center().y), Align2::LEFT_CENTER, tr(label), regular(12.0), t.text);
|
||||
if resp.clicked() {
|
||||
app.ui.home_view.section = section;
|
||||
app.ui.home_view.file_query.clear();
|
||||
}
|
||||
y += 34.0;
|
||||
}
|
||||
}
|
||||
|
||||
fn file_matches(name: &str, location: &str, query: &str) -> bool {
|
||||
name.to_lowercase().contains(query) || location.to_lowercase().contains(query)
|
||||
}
|
||||
|
||||
/// One search over known files and starter templates. It never sends a query or owner token to a
|
||||
/// server; online files are opened through their existing owner-token command only on click.
|
||||
fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
heading(ui, t, tr("Search results"));
|
||||
let query = app.ui.home_view.file_query.trim().to_lowercase();
|
||||
let mut count = open_files(app, ui, t, &query);
|
||||
count += crate::home_recents::section(app, ui, t, &query);
|
||||
|
||||
let online: Vec<_> = app.session.prefs.online_files.iter().filter(|f| file_matches(&f.name, &f.server, &query)).cloned().collect();
|
||||
let mut open_online = None;
|
||||
if !online.is_empty() {
|
||||
subheading(ui, t, tr("Online files"));
|
||||
for f in &online {
|
||||
count += 1;
|
||||
let host = figcraft_engine::online::server_host(&f.server);
|
||||
let icon = crate::online::mark(f.shared.is_some());
|
||||
let row = FileRow {
|
||||
mark: Some((icon, if f.shared.is_some() { t.success } else { t.icon_secondary })),
|
||||
name: &f.name,
|
||||
ink: t.text,
|
||||
detail: host,
|
||||
detail_size: 11.0,
|
||||
detail_ink: t.text_secondary,
|
||||
};
|
||||
if listed(ui, t, ui.id().with(("search_online", &f.server, &f.file)), 36.0, &row) {
|
||||
open_online = Some(f.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
let templates: Vec<&Template> = TEMPLATES.iter().filter(|template| matches(template, &query, None)).collect();
|
||||
if !templates.is_empty() {
|
||||
subheading(ui, t, tr("Templates"));
|
||||
template_grid(app, ui, t, &templates);
|
||||
}
|
||||
if count == 0 && templates.is_empty() {
|
||||
ui.label(egui::RichText::new(tr("No files or templates match your search")).font(semibold(13.0)).color(t.text));
|
||||
ui.add_space(6.0);
|
||||
widgets::label(ui, tr("Try another search, or browse templates."), t.text_secondary);
|
||||
ui.add_space(16.0);
|
||||
if widgets::button(ui, tr("Show all templates"), false, true).clicked() {
|
||||
app.ui.home_view.file_query.clear();
|
||||
app.ui.home_view.section = Section::Templates;
|
||||
}
|
||||
}
|
||||
if let Some(file) = open_online {
|
||||
crate::online::open(app, &file);
|
||||
}
|
||||
}
|
||||
|
||||
/// A ring around a card or row that has the keyboard.
|
||||
fn focus_ring(ui: &Ui, t: &Tokens, rect: Rect, resp: &Response, radius: u8) {
|
||||
if resp.has_focus() {
|
||||
@@ -290,22 +357,7 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
ui.add_space(28.0);
|
||||
getting_started(app, ui, t);
|
||||
}
|
||||
if !app.session.docs.is_empty() {
|
||||
subheading(ui, t, tr("Open files"));
|
||||
let mut switch = None;
|
||||
for (i, d) in app.session.docs.iter().enumerate() {
|
||||
let detail = d.path.as_deref().unwrap_or_default();
|
||||
let row =
|
||||
FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary };
|
||||
if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) {
|
||||
switch = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = switch {
|
||||
app.run("doc.switch", json!({"index": i}));
|
||||
app.ui.home = false;
|
||||
}
|
||||
}
|
||||
open_files(app, ui, t, "");
|
||||
if crate::home_recents::listed(&app.session.prefs.recent_files).next().is_none() {
|
||||
// A first run: nothing to reopen yet, so offer something to start from.
|
||||
subheading(ui, t, tr("Start from a template"));
|
||||
@@ -317,7 +369,32 @@ fn recents(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
crate::home_recents::section(app, ui, t);
|
||||
crate::home_recents::section(app, ui, t, "");
|
||||
}
|
||||
|
||||
/// The Open files list: the documents whose name or path has `query` (lower case; all for ""), a
|
||||
/// row each; choosing one shows its tab. How many are listed.
|
||||
fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize {
|
||||
let hit = |d: &&figcraft_engine::DocState| file_matches(&d.doc.name, d.path.as_deref().unwrap_or_default(), query);
|
||||
let count = app.session.docs.iter().filter(hit).count();
|
||||
if count == 0 {
|
||||
return 0;
|
||||
}
|
||||
subheading(ui, t, tr("Open files"));
|
||||
let mut switch = None;
|
||||
for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| hit(d)) {
|
||||
let detail = d.path.as_deref().unwrap_or_default();
|
||||
let row =
|
||||
FileRow { mark: Some((Icon::Logo, ACCENT)), name: &d.doc.name, ink: t.text, detail, detail_size: 11.0, detail_ink: t.text_secondary };
|
||||
if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) {
|
||||
switch = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = switch {
|
||||
app.run("doc.switch", json!({"index": i}));
|
||||
app.ui.home = false;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// The file joined from the home screen (a code, or one of your online files) opened in a tab of
|
||||
|
||||
@@ -554,16 +554,27 @@ enum Action {
|
||||
View(View),
|
||||
}
|
||||
|
||||
/// The Recent files heading, its toolbar and the files as cards or a table.
|
||||
pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
let paths: Vec<String> = listed(&app.session.prefs.recent_files).take(SHOWN).cloned().collect();
|
||||
/// The Recent files heading, its toolbar and the files as cards or a table. With a `query` (lower
|
||||
/// case, from the Home search) only the files not open whose path has it, from every recent file;
|
||||
/// nothing at all when none has it. How many files are shown.
|
||||
pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize {
|
||||
let searching = !query.is_empty();
|
||||
// A search reaches every recent file; the files shown without one stay known through it.
|
||||
let paths: Vec<String> = listed(&app.session.prefs.recent_files).take(if searching { usize::MAX } else { SHOWN }).cloned().collect();
|
||||
let time = ui.input(|i| i.time);
|
||||
let now = figcraft_engine::cmd::comment::now_secs();
|
||||
let reduce_motion = app.reduce_motion();
|
||||
let (view, sort) = (View::of(&app.session.prefs), Sort::of(&app.session.prefs));
|
||||
let state = &mut app.ui.home_view.recents;
|
||||
state.refresh(ui.ctx(), &paths, time);
|
||||
let files = sorted(state.shown(&paths), sort);
|
||||
let mut files = sorted(state.shown(&paths), sort);
|
||||
if searching {
|
||||
let docs = &app.session.docs;
|
||||
files.retain(|f| f.path.to_lowercase().contains(query) && !docs.iter().any(|d| d.path.as_deref() == Some(f.path.as_str())));
|
||||
if files.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
let with_created = files.iter().any(|f| f.stat.is_some_and(|s| s.created.is_some()));
|
||||
let mut action = toolbar(ui, t, view, sort, with_created);
|
||||
ui.add_space(16.0);
|
||||
@@ -593,6 +604,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, t: &Tokens) {
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
files.len()
|
||||
}
|
||||
|
||||
/// "Recent files", and at the right the sort menu and the grid and list buttons.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use egui::accesskit::Role;
|
||||
use egui::{Event, Key, Modifiers, PointerButton, Pos2, Rect, Shape};
|
||||
use figcraft_engine::Session;
|
||||
use figcraft_engine::online::OnlineFile;
|
||||
use figcraft_engine::templates::TEMPLATES;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -184,6 +185,161 @@ fn typing_in_the_search_field_filters() {
|
||||
assert!(u.app.session.docs.is_empty(), "typing opens nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_search_finds_known_files_and_opens_an_existing_tab() {
|
||||
let mut session = Session::new();
|
||||
session.execute("file.rename", &json!({"name": "North Trail"})).unwrap();
|
||||
session.docs[0].path = Some("C:/designs/North Trail.figcraft".into());
|
||||
session.prefs.recent_files =
|
||||
vec!["C:/designs/North Trail.figcraft".into(), "C:/designs/South Trail.figcraft".into(), "C:/designs/Other.figcraft".into()];
|
||||
session.prefs.online_files.push(OnlineFile {
|
||||
server: "wss://share.example/".into(),
|
||||
file: "trail".into(),
|
||||
name: "Trail Live".into(),
|
||||
owner: "test-only-secret".into(),
|
||||
..OnlineFile::default()
|
||||
});
|
||||
let mut u = Ui::new(session);
|
||||
u.app.ui.home = true;
|
||||
u.app.ui.home_view.file_query = " TRAIL ".into();
|
||||
let s = settle(&mut u);
|
||||
assert!(has_text(&s, "Search results"));
|
||||
assert!(has_text(&s, "North Trail") && has_text(&s, "South Trail") && has_text(&s, "Trail Live"));
|
||||
assert!(!has_text(&s, "Other") && !has_text(&s, "test-only-secret"));
|
||||
assert_eq!(texts(&s).iter().filter(|(name, pos, _)| name == "North Trail" && pos.y > 90.0).count(), 1, "an open file is not repeated in recents");
|
||||
assert_eq!(u.app.inspect()["homeSearch"], " TRAIL ");
|
||||
|
||||
u.app.ui.home_view.file_query = "nothing matches".into();
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "No files or templates match your search"));
|
||||
assert!(!has_text(&s, "Trail Live") && !has_text(&s, "South Trail"));
|
||||
assert!(!texts(&s).iter().any(|(name, pos, _)| name == "North Trail" && pos.y > 90.0));
|
||||
|
||||
u.app.ui.home_view.file_query = "north".into();
|
||||
let s = shapes(&mut u);
|
||||
click(&mut u, text_center(&s, "North Trail"));
|
||||
assert!(!u.app.ui.home, "choosing an open result shows its editor tab");
|
||||
assert_eq!(u.app.session.docs.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_search_shows_a_saved_files_preview() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-home-search-preview-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("Search preview.figcraft");
|
||||
let image = image::RgbaImage::from_pixel(64, 32, image::Rgba([30, 140, 210, 255]));
|
||||
let mut png = Vec::new();
|
||||
image.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let doc = figcraft_doc::Document::new("Search preview");
|
||||
std::fs::write(&path, figcraft_format::save_bytes(&doc, Some(&png)).unwrap()).unwrap();
|
||||
|
||||
let mut session = Session::empty();
|
||||
session.prefs.recent_files.push(path.to_string_lossy().into_owned());
|
||||
let mut u = Ui::new(session);
|
||||
u.app.ui.home_view.file_query = "Search preview".into();
|
||||
for _ in 0..400 {
|
||||
u.frame(vec![]);
|
||||
if u.app.inspect()["homeRecents"]["files"][0]["picture"] == "picture" {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "Search preview"));
|
||||
assert!(s.iter().any(|s| matches!(s, Shape::Mesh(m) if m.texture_id != egui::TextureId::default())), "the saved preview is drawn");
|
||||
let _ = std::fs::remove_file(path);
|
||||
let _ = std::fs::remove_dir(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_search_field_filters_as_typed_and_a_section_click_clears_it() {
|
||||
let mut u = first_run();
|
||||
u.app.session.prefs.recent_files = vec!["C:/designs/Glacier.figcraft".into(), "C:/designs/Forest.figcraft".into()];
|
||||
let s = shapes(&mut u);
|
||||
click(&mut u, text_center(&s, "Search files and templates"));
|
||||
u.frame(vec![Event::Text("glacier".into())]);
|
||||
let s = settle(&mut u);
|
||||
assert_eq!(u.app.ui.home_view.file_query, "glacier");
|
||||
assert!(has_text(&s, "Glacier") && !has_text(&s, "Forest"));
|
||||
click(&mut u, text_center(&s, "Recents"));
|
||||
assert!(u.app.ui.home_view.file_query.is_empty());
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "Forest"));
|
||||
// Escape in the field clears the search too.
|
||||
click(&mut u, text_center(&s, "Search files and templates"));
|
||||
u.frame(vec![Event::Text("forest".into())]);
|
||||
assert_eq!(u.app.ui.home_view.file_query, "forest");
|
||||
u.frame(vec![Event::Key { key: Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]);
|
||||
assert!(u.app.ui.home_view.file_query.is_empty(), "Escape clears the search");
|
||||
}
|
||||
|
||||
/// A search keeps what the recent files it doesn't match know: back from it, their pictures and
|
||||
/// the selection are still there (nothing is read again).
|
||||
#[test]
|
||||
fn a_search_keeps_the_other_recent_files_pictures_and_the_selection() {
|
||||
let dir = std::env::temp_dir().join(format!("figcraft-home-search-cache-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let mut png = Vec::new();
|
||||
image::RgbaImage::from_pixel(8, 8, image::Rgba([30, 140, 210, 255]))
|
||||
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
|
||||
.unwrap();
|
||||
let paths: Vec<String> = ["Alpine", "Basin"]
|
||||
.into_iter()
|
||||
.map(|name| {
|
||||
let path = dir.join(format!("{name}.figcraft"));
|
||||
std::fs::write(&path, figcraft_format::save_bytes(&figcraft_doc::Document::new(name), Some(&png)).unwrap()).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
})
|
||||
.collect();
|
||||
let mut session = Session::empty();
|
||||
session.prefs.recent_files = paths.clone();
|
||||
let mut u = Ui::new(session);
|
||||
let pictures = |u: &mut Ui| -> Vec<serde_json::Value> {
|
||||
u.app.inspect()["homeRecents"]["files"].as_array().unwrap().iter().map(|f| f["picture"].clone()).collect()
|
||||
};
|
||||
for _ in 0..400 {
|
||||
u.frame(vec![]);
|
||||
if pictures(&mut u).iter().all(|p| p == "picture") {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(pictures(&mut u).iter().all(|p| p == "picture"), "both pictures are in");
|
||||
u.app.ui.home_view.recents.selected = Some(paths[1].clone());
|
||||
u.app.ui.home_view.file_query = "alpine".into();
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "Alpine") && !has_text(&s, "Basin"));
|
||||
u.app.ui.home_view.file_query.clear();
|
||||
u.frame(vec![]);
|
||||
assert!(pictures(&mut u).iter().all(|p| p == "picture"), "no picture is read again");
|
||||
assert_eq!(u.app.ui.home_view.recents.selected.as_deref(), Some(paths[1].as_str()), "the selection stays");
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_search_includes_templates_and_opens_a_matching_card() {
|
||||
let mut u = first_run();
|
||||
u.app.ui.home_view.file_query = "MOBILE".into();
|
||||
let s = settle(&mut u);
|
||||
assert!(has_text(&s, "Templates") && has_text(&s, "Mobile app"));
|
||||
assert!(!has_text(&s, "Dashboard"));
|
||||
click(&mut u, text_center(&s, "Mobile app"));
|
||||
assert_eq!(u.app.session.doc().unwrap().name, "Mobile app");
|
||||
assert!(!u.app.ui.home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_home_search_can_browse_all_templates() {
|
||||
let mut u = first_run();
|
||||
u.app.ui.home_view.file_query = "no such design".into();
|
||||
let s = shapes(&mut u);
|
||||
assert!(has_text(&s, "No files or templates match your search"));
|
||||
click(&mut u, text_center(&s, "Show all templates"));
|
||||
assert!(u.app.ui.home_view.file_query.is_empty());
|
||||
assert_eq!(u.app.ui.home_view.section, Section::Templates);
|
||||
assert!(has_text(&settle(&mut u), "Dashboard"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnails_are_cached_per_theme() {
|
||||
let mut u = first_run();
|
||||
@@ -417,6 +573,71 @@ fn dropping_a_file_without_one_on_disk_opens_it_as_a_new_unsaved_document() {
|
||||
assert!(!path.exists(), "nothing is written");
|
||||
}
|
||||
|
||||
/// What a drop's read gives, once it has arrived.
|
||||
type Arrival = std::rc::Rc<std::cell::RefCell<Option<Result<Vec<u8>, String>>>>;
|
||||
|
||||
/// A drop whose contents arrive later, as every drop in the browser does: pending until `arrived`
|
||||
/// holds them.
|
||||
fn later(arrived: &Arrival) -> crate::ReadFuture {
|
||||
let arrived = arrived.clone();
|
||||
Box::pin(std::future::poll_fn(move |_| match arrived.borrow_mut().take() {
|
||||
Some(bytes) => std::task::Poll::Ready(bytes),
|
||||
None => std::task::Poll::Pending,
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_file_whose_contents_arrive_later_opens_once_they_do() {
|
||||
let svg = br#"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><rect width="40" height="20" fill="red"/></svg>"#.to_vec();
|
||||
let arrived = std::rc::Rc::new(std::cell::RefCell::new(None));
|
||||
let mut u = first_run();
|
||||
u.app.ui.home = true;
|
||||
u.app.ui.pending_opens.push(crate::pending_open::PendingOpen {
|
||||
label: "Browser drop.svg".into(),
|
||||
params: json!({"name": "Browser drop.svg"}),
|
||||
command: "file.open",
|
||||
target: None,
|
||||
read: Some(later(&arrived)),
|
||||
});
|
||||
// egui lays a new card out invisibly on its first frame.
|
||||
u.frame(vec![]);
|
||||
for _ in 0..5 {
|
||||
let s = flat(&u.frame_output(vec![]).shapes);
|
||||
assert!(has_text(&s, "Browser drop.svg"), "the card shows while the contents are read");
|
||||
}
|
||||
assert!(u.app.session.docs.is_empty(), "nothing opens before the contents arrive");
|
||||
*arrived.borrow_mut() = Some(Ok(svg));
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert!(u.app.ui.pending_opens.is_empty());
|
||||
assert_eq!(u.app.ui.last_error, None);
|
||||
assert_eq!(u.app.session.docs.len(), 1, "the contents open");
|
||||
assert_eq!(u.app.session.docs[0].path, None);
|
||||
assert!(u.app.session.docs[0].doc.nodes().any(|n| matches!(n.kind, figcraft_doc::NodeKind::Rectangle)), "the SVG's content");
|
||||
assert!(!u.app.ui.home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_file_that_cannot_be_read_says_so_and_opens_nothing() {
|
||||
let arrived = std::rc::Rc::new(std::cell::RefCell::new(Some(Err(crate::unreadable("Unreadable.figcraft")))));
|
||||
let mut u = first_run();
|
||||
u.app.ui.home = true;
|
||||
u.app.ui.pending_opens.push(crate::pending_open::PendingOpen {
|
||||
label: "Unreadable.figcraft".into(),
|
||||
params: json!({"name": "Unreadable.figcraft"}),
|
||||
command: "file.open",
|
||||
target: None,
|
||||
read: Some(later(&arrived)),
|
||||
});
|
||||
for _ in 0..3 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert!(u.app.ui.pending_opens.is_empty());
|
||||
assert!(u.app.session.docs.is_empty());
|
||||
assert_eq!(u.app.ui.last_error.as_deref(), Some("Couldn't read Unreadable.figcraft."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_a_native_file_on_the_home_screen_opens_it() {
|
||||
let dir = Temp::new();
|
||||
|
||||
@@ -343,11 +343,12 @@ fn system_languages_pick_the_first_one_with_a_catalog() {
|
||||
// Portugal's Portuguese and a bare "pt" read the Brazilian catalog.
|
||||
assert_eq!(code("system", Some("pt-PT")), "pt-BR");
|
||||
assert_eq!(code("pt", None), "pt-BR");
|
||||
// An OS list: the first language there is a catalog for (English counts).
|
||||
assert_eq!(code("system", Some("nl-NL,de-DE,en-US")), "de");
|
||||
assert_eq!(code("system", Some("nl-NL, en-US,fr-FR")), "en");
|
||||
assert_eq!(code("system", Some("nl,da")), "en");
|
||||
assert_eq!(code("system", Some("nl,sv")), "sv");
|
||||
// An OS list: the first language there is a catalog for (English counts). Hindi has no
|
||||
// catalog (Dutch gained one; `hi` keeps exercising the no-catalog path).
|
||||
assert_eq!(code("system", Some("hi-IN,de-DE,en-US")), "de");
|
||||
assert_eq!(code("system", Some("hi-IN, en-US,fr-FR")), "en");
|
||||
assert_eq!(code("system", Some("hi,da")), "en");
|
||||
assert_eq!(code("system", Some("hi,sv")), "sv");
|
||||
assert_eq!(code("fr", Some("de-DE")), "fr", "a chosen language beats the system's");
|
||||
}
|
||||
|
||||
@@ -472,7 +473,7 @@ fn russian_language_tags_and_integer_plurals() {
|
||||
assert_eq!(resolve(tag, None), ru, "{tag}");
|
||||
assert_eq!(resolve("system", Some(tag)), ru, "{tag}");
|
||||
}
|
||||
assert_eq!(resolve("system", Some("nl,ru-RU,en-US")), ru);
|
||||
assert_eq!(resolve("system", Some("hi,ru-RU,en-US")), ru);
|
||||
for n in [1, 21, 31, 101, 1_001] {
|
||||
assert_eq!(plural("ru", n), Plural::One, "{n}");
|
||||
assert_eq!(fill(counted(ru, "{n} layers", n), &[("n", &n)]), format!("{n} слой"));
|
||||
|
||||
@@ -177,6 +177,7 @@ fn a_dropped_file_shows_its_report_once_it_opens() {
|
||||
params: json!({"path": path.to_string_lossy()}),
|
||||
command: "file.open",
|
||||
target: None,
|
||||
read: None,
|
||||
});
|
||||
for _ in 0..6 {
|
||||
u.frame(vec![]);
|
||||
|
||||
+128
-31
@@ -236,6 +236,11 @@ pub trait Services {
|
||||
fn pick_open(&mut self, _title: &str, _exts: &[&str]) -> DialogFuture {
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
/// Pick a file and read it, where the platform hands over a file's name and contents rather
|
||||
/// than a path to it (the browser). `None`: the platform picks paths ([`Services::pick_open`]).
|
||||
fn pick_open_contents(&mut self, _title: &str, _exts: &[&str]) -> Option<ContentsFuture> {
|
||||
None
|
||||
}
|
||||
fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> DialogFuture {
|
||||
Box::pin(std::future::ready(None))
|
||||
}
|
||||
@@ -290,14 +295,61 @@ pub trait Services {
|
||||
}
|
||||
|
||||
pub type DialogFuture = Pin<Box<dyn Future<Output = Option<PathBuf>>>>;
|
||||
type DialogDone = Box<dyn FnOnce(&mut FigApp, PathBuf)>;
|
||||
/// A picked file's name and contents ([`Services::pick_open_contents`]); `None` when the picker
|
||||
/// was cancelled, `Err` with what to tell the user when the file couldn't be read.
|
||||
pub type ContentsFuture = Pin<Box<dyn Future<Output = Option<Result<(String, Vec<u8>), String>>>>>;
|
||||
/// A dropped file's contents, read once they arrive (`Err`: what to tell the user when they
|
||||
/// couldn't be read).
|
||||
pub type ReadFuture = Pin<Box<dyn Future<Output = Result<Vec<u8>, String>>>>;
|
||||
type PickFuture = Pin<Box<dyn Future<Output = Option<Picked>>>>;
|
||||
type DialogDone = Box<dyn FnOnce(&mut FigApp, Picked)>;
|
||||
|
||||
/// What a dialog picked: a path, or a file's name and contents where there are no paths, or why
|
||||
/// the picked file couldn't be read.
|
||||
enum Picked {
|
||||
Path(PathBuf),
|
||||
Contents(String, Vec<u8>),
|
||||
Unreadable(String),
|
||||
}
|
||||
|
||||
impl Picked {
|
||||
/// The file as `file.open` and `file.import` take it (`Err`: what to tell the user instead).
|
||||
fn file_params(self) -> Result<Value, String> {
|
||||
match self {
|
||||
Picked::Path(p) => Ok(json!({"path": p.to_string_lossy()})),
|
||||
Picked::Contents(name, bytes) => {
|
||||
use base64::Engine as _;
|
||||
Ok(json!({"data": base64::engine::general_purpose::STANDARD.encode(&bytes), "name": name}))
|
||||
}
|
||||
Picked::Unreadable(why) => Err(why),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the user is told about a file whose contents couldn't be read.
|
||||
pub fn unreadable(name: &str) -> String {
|
||||
i18n::trf("Couldn't read {name}.", &[("name", &name)])
|
||||
}
|
||||
|
||||
/// The largest file read in the browser: wasm has at most 4 GiB of memory, and a file's contents
|
||||
/// are held about three times on their way to `file.open` (the bytes, base64, decoded again).
|
||||
/// As large as one member of a `.figcraft` file may be.
|
||||
const MAX_BROWSER_FILE: f64 = (512u64 << 20) as f64;
|
||||
|
||||
/// Checks a file of `size` bytes before the browser reads it (`Err`: what to tell the user).
|
||||
pub fn browser_can_read(name: &str, size: f64) -> Result<(), String> {
|
||||
if size.is_nan() || size > MAX_BROWSER_FILE {
|
||||
return Err(i18n::trf("{name} is too large to open in the browser (over 512 MB).", &[("name", &name)]));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A native dialog in flight. The app keeps working while it is open, so its result goes to the
|
||||
/// file and window it was opened for, not to whichever is in front when it closes.
|
||||
struct PendingDialog {
|
||||
future: DialogFuture,
|
||||
/// The path picked, kept until the window that holds `doc` draws.
|
||||
path: Option<PathBuf>,
|
||||
future: PickFuture,
|
||||
/// What was picked, kept until the window that holds `doc` draws.
|
||||
picked: Option<Picked>,
|
||||
done: DialogDone,
|
||||
/// The file it acts on (`None`: none, such as Open).
|
||||
doc: Option<u64>,
|
||||
@@ -780,6 +832,11 @@ impl FigApp {
|
||||
fn failed(&mut self, id: &str, e: &figcraft_engine::EngineError) {
|
||||
let msg = e.to_string();
|
||||
log::warn!("{id}: {msg}");
|
||||
self.show_error(msg);
|
||||
}
|
||||
|
||||
/// Show an error as a toast, kept as the last error.
|
||||
pub(crate) fn show_error(&mut self, msg: String) {
|
||||
self.toast(msg.clone());
|
||||
self.ui.last_error = Some(msg);
|
||||
}
|
||||
@@ -1099,16 +1156,11 @@ impl FigApp {
|
||||
|
||||
pub fn open_dialog(&mut self) {
|
||||
// Opening needs no file in front: it goes ahead even if that file closed meanwhile.
|
||||
self.start_dialog(
|
||||
|services| services.pick_open(i18n::tr("Open"), OPEN_EXTENSIONS),
|
||||
false,
|
||||
|app, p| {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
if app.run_user("file.open", json!({"path": ps})).is_some() {
|
||||
app.ui.home = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
self.pick_file(i18n::tr("Open"), OPEN_EXTENSIONS, false, |app, file| {
|
||||
if app.run_user("file.open", file).is_some() {
|
||||
app.ui.home = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn import_dialog(&mut self, place: bool) {
|
||||
@@ -1118,12 +1170,35 @@ impl FigApp {
|
||||
&["svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff", "figcraft", "fig"]
|
||||
};
|
||||
let title = if place { i18n::tr("Place image") } else { i18n::tr("Import") };
|
||||
self.pick_open(title, exts, move |app, p| {
|
||||
let ps = p.to_string_lossy().to_string();
|
||||
app.run_user("file.import", json!({"path": ps}));
|
||||
self.pick_file(title, exts, true, |app, file| {
|
||||
app.run_user("file.import", file);
|
||||
});
|
||||
}
|
||||
|
||||
/// Pick a file to open or import; `done` gets it as `file.open` and `file.import` take it (a
|
||||
/// path, or the name and contents where the platform has no paths). `pin`: as
|
||||
/// [`FigApp::pick_open`]'s file in front.
|
||||
fn pick_file(&mut self, title: &str, exts: &[&str], pin: bool, done: impl FnOnce(&mut FigApp, Value) + 'static) {
|
||||
self.start_pick(
|
||||
|services| -> PickFuture {
|
||||
match services.pick_open_contents(title, exts) {
|
||||
Some(read) => {
|
||||
Box::pin(async move { read.await.map(|r| r.map_or_else(Picked::Unreadable, |(name, bytes)| Picked::Contents(name, bytes))) })
|
||||
}
|
||||
None => {
|
||||
let pick = services.pick_open(title, exts);
|
||||
Box::pin(async move { pick.await.map(Picked::Path) })
|
||||
}
|
||||
}
|
||||
},
|
||||
pin,
|
||||
move |app, picked| match picked.file_params() {
|
||||
Ok(file) => done(app, file),
|
||||
Err(why) => app.show_error(why),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Pick a file to open. `done` runs with the file in front now back in front (and not at all
|
||||
/// if that file closed meanwhile).
|
||||
pub fn pick_open(&mut self, title: &str, exts: &[&str], done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
|
||||
@@ -1142,12 +1217,28 @@ impl FigApp {
|
||||
|
||||
/// Open a native dialog from the window drawing now, for the file in front (`pin`).
|
||||
fn start_dialog(&mut self, start: impl FnOnce(&mut dyn Services) -> DialogFuture, pin: bool, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) {
|
||||
self.start_pick(
|
||||
|services| -> PickFuture {
|
||||
let pick = start(services);
|
||||
Box::pin(async move { pick.await.map(Picked::Path) })
|
||||
},
|
||||
pin,
|
||||
move |app, picked| {
|
||||
if let Picked::Path(p) = picked {
|
||||
done(app, p);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// [`FigApp::start_dialog`] for any kind of pick.
|
||||
fn start_pick(&mut self, start: impl FnOnce(&mut dyn Services) -> PickFuture, pin: bool, done: impl FnOnce(&mut FigApp, Picked) + 'static) {
|
||||
if self.pending_dialog.is_some() {
|
||||
return;
|
||||
}
|
||||
let doc = if pin { self.session.ds().ok().filter(|d| self.shows(d.uid)).map(|d| d.uid) } else { None };
|
||||
let future = start(self.services.as_mut());
|
||||
self.pending_dialog = Some(PendingDialog { future, path: None, done: Box::new(done), doc, window: self.windows.current });
|
||||
self.pending_dialog = Some(PendingDialog { future, picked: None, done: Box::new(done), doc, window: self.windows.current });
|
||||
if let Some(ctx) = &self.ui_context {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
@@ -1157,12 +1248,12 @@ impl FigApp {
|
||||
/// that file in front; then the tab in front before comes back (unless the dialog opened a file).
|
||||
fn poll_dialog(&mut self, ctx: &egui::Context) {
|
||||
let Some(d) = &mut self.pending_dialog else { return };
|
||||
if d.path.is_none() {
|
||||
if d.picked.is_none() {
|
||||
let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone())));
|
||||
let Poll::Ready(path) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return };
|
||||
d.path = path;
|
||||
let Poll::Ready(picked) = d.future.as_mut().poll(&mut TaskContext::from_waker(&waker)) else { return };
|
||||
d.picked = picked;
|
||||
}
|
||||
let (doc, origin, picked) = (d.doc, d.window, d.path.is_some());
|
||||
let (doc, origin, picked) = (d.doc, d.window, d.picked.is_some());
|
||||
let index = doc.map(|u| self.session.docs.iter().position(|ds| ds.uid == u));
|
||||
// Cancelled, or its file closed meanwhile.
|
||||
if !picked || index == Some(None) {
|
||||
@@ -1178,12 +1269,12 @@ impl FigApp {
|
||||
ctx.request_repaint_of(windows::viewport(window));
|
||||
return;
|
||||
}
|
||||
let Some(PendingDialog { path: Some(path), done, .. }) = self.pending_dialog.take() else { return };
|
||||
let Some(PendingDialog { picked: Some(picked), done, .. }) = self.pending_dialog.take() else { return };
|
||||
let before = self.session.ds().ok().map(|d| d.uid);
|
||||
if let Some(Some(i)) = index {
|
||||
self.session.active = i;
|
||||
}
|
||||
done(self, path);
|
||||
done(self, picked);
|
||||
if doc.is_some()
|
||||
&& self.session.ds().ok().map(|d| d.uid) == doc
|
||||
&& let Some(i) = before.and_then(|u| self.session.docs.iter().position(|d| d.uid == u))
|
||||
@@ -1678,6 +1769,7 @@ impl FigApp {
|
||||
"lastError": self.ui.last_error,
|
||||
"importReport": self.ui.import_report.as_ref().map(import_report::Report::inspect),
|
||||
});
|
||||
state["homeSearch"] = json!(self.ui.home_view.file_query);
|
||||
state["pendingOpens"] = json!(self.ui.pending_opens.len());
|
||||
state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect));
|
||||
state
|
||||
@@ -1692,17 +1784,22 @@ pub(crate) fn opens(path: &Path) -> bool {
|
||||
path.extension().and_then(|e| e.to_str()).is_some_and(|e| OPEN_EXTENSIONS.iter().any(|x| x.eq_ignore_ascii_case(e)))
|
||||
}
|
||||
|
||||
/// The contents of a dropped file that has no file on disk to read by path.
|
||||
/// Reads the contents of dropped file `name` that has no file on disk to read by path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle) -> Option<Vec<u8>> {
|
||||
f.bytes().ok()
|
||||
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture {
|
||||
Box::pin(std::future::ready(f.bytes().map_err(|_| unreadable(name))))
|
||||
}
|
||||
|
||||
/// The contents of a dropped file that has no file on disk to read by path.
|
||||
/// Reads the contents of dropped file `name`; the browser hands them over asynchronously.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn dropped_bytes(_f: &egui::DroppedFileHandle) -> Option<Vec<u8>> {
|
||||
// Browser drops arrive asynchronously; not supported yet.
|
||||
None
|
||||
pub(crate) fn dropped_bytes(f: &egui::DroppedFileHandle, name: &str) -> ReadFuture {
|
||||
let (f, name) = (f.clone(), name.to_string());
|
||||
Box::pin(async move {
|
||||
if let Some(file) = f.web_file() {
|
||||
browser_can_read(&name, file.size())?;
|
||||
}
|
||||
f.bytes_async().await.map_err(|_| unreadable(&name))
|
||||
})
|
||||
}
|
||||
|
||||
/// While files are dragged over the window: a tinted, outlined `rect` with `label` in its middle,
|
||||
|
||||
@@ -112,8 +112,13 @@ pub fn record_recent(app: &mut FigApp, key: &str) {
|
||||
r.truncate(figcraft_engine::MAX_RECENT_ACTIONS);
|
||||
}
|
||||
|
||||
/// Run a menu, shortcut or palette action by id: actions that need the UI (dialogs, panels, the
|
||||
/// clipboard) are handled here, the rest go to the engine.
|
||||
pub fn invoke(app: &mut FigApp, id: &str) {
|
||||
match id {
|
||||
// Edit ▸ Delete (and the Delete binding) with an interaction open in the Prototype tab
|
||||
// removes that interaction, not the layer (#767).
|
||||
"edit.delete" if app.ui_context.clone().is_some_and(|ctx| crate::proto_panel::delete_selected_interaction(app, &ctx)) => {}
|
||||
"file.open" => app.open_dialog(),
|
||||
"plugin.import" => crate::plugins_ui::import_dialog(app),
|
||||
"file.import" => app.import_dialog(false),
|
||||
@@ -203,12 +208,13 @@ pub fn invoke(app: &mut FigApp, id: &str) {
|
||||
"collab.publish" => crate::live::open_publish(app),
|
||||
"edit.copy" | "edit.cut" => {
|
||||
let ids = app.session.selection();
|
||||
// SVG for other apps, taken before a cut removes the layers.
|
||||
let svg = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids));
|
||||
// For other apps: text layers' text, other layers as SVG; taken before a cut removes them.
|
||||
let doc = app.session.doc().ok();
|
||||
let mirror = doc.and_then(|d| text_layers_text(d, &ids).or_else(|| figcraft_svg_export(d, &ids)));
|
||||
app.run(id, json!({}));
|
||||
// Mirror to the system clipboard (SVG text, else a marker) and remember it, so a later
|
||||
// paste knows whether the clipboard still holds our layers.
|
||||
let text = svg.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len()));
|
||||
// Mirror to the system clipboard (text or SVG, else a marker) and remember it, so a
|
||||
// later paste knows whether the clipboard still holds our layers.
|
||||
let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len()));
|
||||
app.services.clipboard_set_text(&text);
|
||||
app.ui.last_copy = Some(text);
|
||||
}
|
||||
@@ -236,6 +242,14 @@ fn export_frames_pdf(app: &mut FigApp) {
|
||||
});
|
||||
}
|
||||
|
||||
/// The text of a selection made only of text layers, one layer per line (None for any other
|
||||
/// selection, or when there is no text).
|
||||
fn text_layers_text(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option<String> {
|
||||
let texts: Option<Vec<&str>> = ids.iter().map(|i| doc.node(*i).and_then(|n| n.kind.text()).map(|t| t.characters.as_str())).collect();
|
||||
texts.map(|t| t.join("\n")).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// The layers as SVG text for other apps (None when the export fails).
|
||||
fn figcraft_svg_export(doc: &figcraft_doc::Document, ids: &[figcraft_doc::NodeId]) -> Option<String> {
|
||||
let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?;
|
||||
String::from_utf8(bytes).ok()
|
||||
|
||||
@@ -72,7 +72,7 @@ fn manage(app: &mut FigApp, ctx: &egui::Context, f: (&str, &str), action: &str,
|
||||
}
|
||||
|
||||
/// Open recorded file `f` as its owner; its tab shows once the file arrives.
|
||||
fn open(app: &mut FigApp, f: &OnlineFile) {
|
||||
pub(crate) fn open(app: &mut FigApp, f: &OnlineFile) {
|
||||
let Some(r) = app.run("collab.openFile", json!({"server": f.server, "file": f.file})) else { return };
|
||||
if r["alreadyOpen"] == true && !r["document"].is_null() {
|
||||
app.ui.home = false;
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
//! Files dropped on the home screen or the canvas open a frame later, behind a card that says what
|
||||
//! is happening: opening a large `.fig` blocks the UI thread for a while, and the card is on screen
|
||||
//! before it does (rather than a window that freezes without a word).
|
||||
//! before it does (rather than a window that freezes without a word). A file with no path to read
|
||||
//! (every drop in the browser) waits behind the card until its contents arrive.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context as TaskContext, Poll, Waker};
|
||||
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::Value;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::FigApp;
|
||||
use crate::i18n::{tr, trf, trn};
|
||||
use crate::theme::{Tokens, semibold};
|
||||
use crate::{FigApp, ReadFuture, RepaintWaker};
|
||||
|
||||
/// A dropped file waiting to be opened (`file.open`, a document of its own) or imported
|
||||
/// (`file.import`, into the current document).
|
||||
@@ -18,6 +22,8 @@ pub struct PendingOpen {
|
||||
pub command: &'static str,
|
||||
/// Canvas imports stay with the document and page that received the drop.
|
||||
pub target: Option<ImportTarget>,
|
||||
/// The file's contents still on their way; they go into `params` as `data` once read.
|
||||
pub read: Option<ReadFuture>,
|
||||
}
|
||||
|
||||
pub struct ImportTarget {
|
||||
@@ -66,23 +72,55 @@ fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option<Value> {
|
||||
|
||||
/// An import that has no destination left: nothing is imported, and the user is told why.
|
||||
fn cancel(app: &mut FigApp, message: String) {
|
||||
app.ui.last_error = Some(message.clone());
|
||||
app.toast(message);
|
||||
app.show_error(message);
|
||||
}
|
||||
|
||||
/// Frames the card is drawn before the files open: egui lays a new card out invisibly on its first
|
||||
/// frame, so the second one is the first the window shows.
|
||||
const FRAMES_BEFORE_OPENING: u8 = 2;
|
||||
|
||||
/// The card while files wait, then (once it has been on screen) open them. Called before the import
|
||||
/// report, so a report for these files shows after them.
|
||||
/// Polls the contents still being read: those that arrived go into their file's parameters, files
|
||||
/// that couldn't be read are dropped with a message. Whether any are still on their way.
|
||||
fn read_contents(app: &mut FigApp, ctx: &egui::Context) -> bool {
|
||||
let waker = Waker::from(Arc::new(RepaintWaker(ctx.clone())));
|
||||
let mut cx = TaskContext::from_waker(&waker);
|
||||
let mut reading = false;
|
||||
let mut unread = Vec::new();
|
||||
app.ui.pending_opens.retain_mut(|pending| {
|
||||
let Some(read) = pending.read.as_mut() else { return true };
|
||||
match read.as_mut().poll(&mut cx) {
|
||||
Poll::Pending => {
|
||||
reading = true;
|
||||
true
|
||||
}
|
||||
Poll::Ready(Ok(bytes)) => {
|
||||
use base64::Engine as _;
|
||||
pending.params["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes));
|
||||
pending.read = None;
|
||||
true
|
||||
}
|
||||
Poll::Ready(Err(why)) => {
|
||||
unread.push(why);
|
||||
false
|
||||
}
|
||||
}
|
||||
});
|
||||
for why in unread {
|
||||
app.show_error(why);
|
||||
}
|
||||
reading
|
||||
}
|
||||
|
||||
/// The card while files wait, then (once it has been on screen and their contents are read) open
|
||||
/// them. Called before the import report, so a report for these files shows after them.
|
||||
pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) {
|
||||
let reading = read_contents(app, ctx);
|
||||
if app.ui.pending_opens.is_empty() {
|
||||
app.ui.pending_open_frames = 0;
|
||||
return;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING {
|
||||
if app.ui.pending_open_frames >= FRAMES_BEFORE_OPENING && !reading {
|
||||
app.ui.pending_open_frames = 0;
|
||||
for pending in std::mem::take(&mut app.ui.pending_opens) {
|
||||
let command = pending.command;
|
||||
@@ -92,7 +130,7 @@ pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
app.ui.pending_open_frames += 1;
|
||||
app.ui.pending_open_frames = app.ui.pending_open_frames.saturating_add(1);
|
||||
let title = match app.ui.pending_opens.first().map(|p| p.command) {
|
||||
Some("file.import") => {
|
||||
let name = app
|
||||
|
||||
@@ -1788,8 +1788,7 @@ fn navigation_bar(p: &mut Presenter, ctx: &egui::Context, now: f64) -> Option<St
|
||||
let scale_menu = ui.menu_button(format!("{} ", tr(p.view.scale.label(settings.device().is_some()))), |ui| {
|
||||
navigation_menu(ui, |ui| {
|
||||
for scale in PresentScale::options(settings.device().is_some()) {
|
||||
let label = egui::RichText::new(tr(scale.label(settings.device().is_some()))).color(t.menu_text);
|
||||
if ui.selectable_label(p.view.scale == *scale, label).clicked() {
|
||||
if ui.selectable_label(p.view.scale == *scale, tr(scale.label(settings.device().is_some()))).clicked() {
|
||||
p.set_scale(*scale);
|
||||
ui.close();
|
||||
}
|
||||
@@ -1812,15 +1811,8 @@ fn paint_menu_chevron(painter: &egui::Painter, button: Rect, color: Color32) {
|
||||
}
|
||||
|
||||
fn navigation_menu(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.visuals_mut().override_text_color = Some(t.menu_text);
|
||||
egui::Frame::NONE.fill(t.menu).stroke(Stroke::new(1.0, t.border)).corner_radius(CornerRadius::same(7)).inner_margin(egui::Margin::same(6)).show(
|
||||
ui,
|
||||
|ui| {
|
||||
ui.set_min_width(212.0);
|
||||
add(ui);
|
||||
},
|
||||
);
|
||||
ui.set_min_width(212.0);
|
||||
add(ui);
|
||||
}
|
||||
|
||||
/// How often the player polls the controllers while its prototype has gamepad triggers (seconds).
|
||||
|
||||
@@ -272,6 +272,9 @@ pub fn input(app: &mut FigApp, ui: &egui::Ui, resp: &egui::Response) -> bool {
|
||||
crate::proto_panel::open_editor(&ctx, Some((n.source, n.index)));
|
||||
return true;
|
||||
}
|
||||
if pressed && crate::proto_panel::editor(&ctx).is_some() {
|
||||
crate::proto_panel::open_editor(&ctx, None);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
@@ -226,3 +226,81 @@ fn the_presenter_inspects_the_layer_under_the_pointer() {
|
||||
assert_eq!(presenter(&u)["inspected"], json!(a));
|
||||
assert_eq!(u.app.inspect()["presentOptions"]["inspect"], json!(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_prototype_interaction_removes_the_interaction_not_the_component() {
|
||||
let (s, _a, b, btn) = setup();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.right_tab = crate::RightTab::Prototype;
|
||||
u.run("select.set", json!({"ids": [btn]}));
|
||||
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
|
||||
u.frames(1, Vec::new);
|
||||
|
||||
assert!(u.app.session.doc().unwrap().node(btn).is_some());
|
||||
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 1);
|
||||
assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0)));
|
||||
|
||||
// Pressing Delete when an interaction is selected should delete the interaction, NOT the component.
|
||||
u.key(pos2(100.0, 100.0), egui::Key::Delete);
|
||||
|
||||
assert!(u.app.session.doc().unwrap().node(btn).is_some(), "Bug #767 regression: component was deleted when deleting prototype interaction!");
|
||||
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0, "the interaction should be removed");
|
||||
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
|
||||
|
||||
// Re-add interaction and test Backspace as well.
|
||||
u.run("proto.interaction.add", json!({"id": btn, "dest": b}));
|
||||
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 1);
|
||||
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
|
||||
u.frames(1, Vec::new);
|
||||
u.key(pos2(100.0, 100.0), egui::Key::Backspace);
|
||||
assert!(u.app.session.doc().unwrap().node(btn).is_some());
|
||||
assert_eq!(u.app.session.doc().unwrap().node(btn).unwrap().interactions.len(), 0);
|
||||
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
|
||||
|
||||
// When NO interaction is active, Delete deletes the component as normal.
|
||||
u.key(pos2(100.0, 100.0), egui::Key::Delete);
|
||||
assert!(u.app.session.doc().unwrap().node(btn).is_none(), "when no interaction is selected, Delete removes the layer");
|
||||
}
|
||||
|
||||
/// Edit ▸ Delete from the menu bar does the same as the Delete key (#767).
|
||||
#[test]
|
||||
fn edit_delete_from_the_menu_removes_the_open_interaction_not_the_layer() {
|
||||
let (s, _a, _b, btn) = setup();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.right_tab = crate::RightTab::Prototype;
|
||||
u.run("select.set", json!({"ids": [btn]}));
|
||||
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
|
||||
u.frames(1, Vec::new);
|
||||
crate::menus::invoke(&mut u.app, "edit.delete");
|
||||
let doc = u.app.session.doc().unwrap();
|
||||
assert!(doc.node(btn).is_some_and(|n| n.interactions.is_empty()), "the interaction goes, the layer stays");
|
||||
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
|
||||
crate::menus::invoke(&mut u.app, "edit.delete");
|
||||
assert!(u.app.session.doc().unwrap().node(btn).is_none(), "with no interaction open, Edit ▸ Delete deletes the layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_closes_prototype_editor_without_deselecting_layer() {
|
||||
let (s, a, _b, btn) = setup();
|
||||
let mut u = Ui::new(s);
|
||||
u.app.ui.right_tab = crate::RightTab::Prototype;
|
||||
u.run("select.set", json!({"ids": [btn]}));
|
||||
crate::proto_panel::open_editor(&u.ctx, Some((btn, 0)));
|
||||
u.frames(1, Vec::new);
|
||||
|
||||
assert_eq!(crate::proto_panel::editor(&u.ctx), Some((btn, 0)));
|
||||
assert_eq!(u.app.session.selection(), vec![btn]);
|
||||
|
||||
// First Escape closes the interaction editor; layer remains selected.
|
||||
u.key(pos2(100.0, 100.0), egui::Key::Escape);
|
||||
assert_eq!(crate::proto_panel::editor(&u.ctx), None);
|
||||
assert_eq!(u.app.session.selection(), vec![btn], "layer remains selected");
|
||||
|
||||
// Second Escape steps out to parent frame.
|
||||
u.key(pos2(100.0, 100.0), egui::Key::Escape);
|
||||
assert_eq!(u.app.session.selection(), vec![a], "steps out to parent frame");
|
||||
|
||||
// Third Escape deselects all.
|
||||
u.key(pos2(100.0, 100.0), egui::Key::Escape);
|
||||
assert!(u.app.session.selection().is_empty(), "selection cleared on third escape");
|
||||
}
|
||||
@@ -40,6 +40,37 @@ pub fn editor(ctx: &egui::Context) -> Option<(NodeId, usize)> {
|
||||
ctx.data(|d| d.get_temp(editor_id()))
|
||||
}
|
||||
|
||||
/// Delete the selected interaction (when one is active in the Prototype tab).
|
||||
/// Returns true if an interaction was deleted.
|
||||
pub fn delete_selected_interaction(app: &mut FigApp, ctx: &egui::Context) -> bool {
|
||||
if app.ui.right_tab != crate::RightTab::Prototype {
|
||||
return false;
|
||||
}
|
||||
let Some((node, index)) = editor(ctx) else { return false };
|
||||
let Ok(ds) = app.session.ds() else { return false };
|
||||
if ds.doc.node(node).and_then(|n| n.interactions.get(index)).is_none() {
|
||||
open_editor(ctx, None);
|
||||
return false;
|
||||
}
|
||||
open_editor(ctx, None);
|
||||
let ids = targets_for(app, node);
|
||||
app.run("proto.interaction.remove", json!({"ids": ids, "index": index}));
|
||||
true
|
||||
}
|
||||
|
||||
/// Close the interaction editor if open. Returns true if it was open.
|
||||
pub fn close_editor(ctx: &egui::Context) -> bool {
|
||||
if capturing(ctx) || crate::variable_ui::picker_open(ctx) {
|
||||
return false;
|
||||
}
|
||||
if editor(ctx).is_some() {
|
||||
open_editor(ctx, None);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
const TRIGGERS: [&str; 10] = [
|
||||
tr_noop("On click"),
|
||||
tr_noop("On drag"),
|
||||
@@ -607,8 +638,6 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: NodeId, node: NodeId, k: usize, it: &Interaction, at: Pos2) {
|
||||
let t = Tokens::get(ctx);
|
||||
// Escape in a field (it cancels the edit) doesn't close the editor.
|
||||
let typing = ctx.egui_wants_keyboard_input();
|
||||
let width = 280.0;
|
||||
let screen = ctx.content_rect();
|
||||
let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0));
|
||||
@@ -652,8 +681,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N
|
||||
});
|
||||
});
|
||||
});
|
||||
let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape)) && !typing && !capturing(ctx) && !crate::variable_ui::picker_open(ctx);
|
||||
if close || escape {
|
||||
if close {
|
||||
open_editor(ctx, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
ui.painter().galley(pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, if active { t.text } else { t.text_secondary });
|
||||
if resp.clicked() {
|
||||
if app.ui.right_tab != tab {
|
||||
crate::proto_panel::open_editor(ui.ctx(), None);
|
||||
}
|
||||
app.ui.right_tab = tab;
|
||||
}
|
||||
x = r.max.x + 2.0;
|
||||
@@ -1887,7 +1890,7 @@ fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &
|
||||
row(ui, 32.0, |ui| {
|
||||
let ed = widgets::number_field(ui, Id::new(("fx_gl", i)), Lead::Text(tr("Light|glass")), Some(g.light_angle.round()), "°", half, 1.0);
|
||||
edit(app, ed, &|v| with(&|gl| gl.light_angle = v.clamp(-180.0, 180.0)));
|
||||
let ed = widgets::number_field(ui, Id::new(("fx_gi", i)), Lead::None, pct(g.light_intensity), "%", half, 1.0);
|
||||
let ed = widgets::number_field(ui, Id::new(("fx_gi", i)), Lead::Text(tr("Intensity")), pct(g.light_intensity), "%", half, 1.0);
|
||||
edit(app, ed, &|v| with(&|gl| gl.light_intensity = unit(v)));
|
||||
});
|
||||
row(ui, 32.0, |ui| {
|
||||
|
||||
@@ -212,6 +212,11 @@ pub fn swallowed_paste(app: &mut FigApp, ctx: &Context) -> Swallowed {
|
||||
pub fn run_binding(app: &mut FigApp, id: &str) {
|
||||
match id {
|
||||
"select.none" => {
|
||||
if let Some(ctx) = &app.ui_context
|
||||
&& crate::proto_panel::close_editor(ctx)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Err(e) = app.session.escape() {
|
||||
app.toast(e.to_string());
|
||||
}
|
||||
@@ -230,6 +235,9 @@ pub fn run_binding(app: &mut FigApp, id: &str) {
|
||||
}
|
||||
|
||||
fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, repeat: bool, now: f64, ctx: &Context) -> bool {
|
||||
if key == Key::Escape && crate::proto_panel::close_editor(ctx) {
|
||||
return true;
|
||||
}
|
||||
if key == Key::Escape && crate::selection_spacing::cancel(app, ctx) {
|
||||
return true;
|
||||
}
|
||||
@@ -240,6 +248,10 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option<Key>, m: Modifiers, re
|
||||
if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && app.ui.picker.is_some() && crate::handles::remove_selected_stop(app, ctx) {
|
||||
return true;
|
||||
}
|
||||
// An interaction selected in the Prototype tab: Delete removes that interaction, not the component.
|
||||
if matches!(key, Key::Backspace | Key::Delete) && m.is_none() && crate::proto_panel::delete_selected_interaction(app, ctx) {
|
||||
return true;
|
||||
}
|
||||
// Vector edit mode and the shape builder have their own keys (tools, nudging points, delete,
|
||||
// join, Enter to finish); the rest fall through.
|
||||
match app.session.tool_key(key.name(), m.shift, m.alt, m.command) {
|
||||
|
||||
@@ -141,6 +141,24 @@ fn text_copied_elsewhere_after_our_copy_still_pastes_as_text() {
|
||||
assert_eq!(p.count(text), 1, "newer text from elsewhere becomes a text layer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copying_text_layers_puts_their_text_on_the_clipboard() {
|
||||
// #808: other apps get a text layer's text, not its SVG; FigCraft still pastes the layers.
|
||||
let mut p = Page::new();
|
||||
let a = p.app.session.execute("text.create", &json!({"text": "Hello", "x": 0, "y": 200})).unwrap();
|
||||
let b = p.app.session.execute("text.create", &json!({"text": "World", "x": 0, "y": 300})).unwrap();
|
||||
p.app.session.execute("select.set", &json!({"ids": [a["id"], b["id"]]})).unwrap();
|
||||
p.frame(vec![]);
|
||||
p.copy();
|
||||
assert_eq!(p.clipboard, "Hello\nWorld", "one line per text layer");
|
||||
p.paste();
|
||||
assert_eq!(p.count(text), 4, "the copied text layers were pasted, not one layer of their text");
|
||||
let r = p.app.session.doc().unwrap().nodes().find(|n| rect(&n.kind)).map(|n| n.id.0).unwrap();
|
||||
p.app.session.execute("select.set", &json!({"ids": [r, a["id"]]})).unwrap();
|
||||
p.copy();
|
||||
assert!(p.clipboard.contains("<svg"), "a selection with other layers stays SVG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_cmd_v_pastes_over_once_and_no_text() {
|
||||
for foreign in [false, true] {
|
||||
|
||||
@@ -202,7 +202,7 @@ fn number_field_impl(
|
||||
let mut out: Option<Edit> = None;
|
||||
let lead_w = match lead {
|
||||
Lead::None | Lead::Named(_) => 6.0,
|
||||
Lead::Text(s) => (12.0 + s.chars().count() as f32 * 7.0).max(24.0),
|
||||
Lead::Text(s) => text_lead_width(ui, s, width),
|
||||
Lead::Icon(..) => 24.0,
|
||||
};
|
||||
let lead_rect = Rect::from_min_size(rect.min, vec2(lead_w, FIELD_H));
|
||||
@@ -223,7 +223,12 @@ fn number_field_impl(
|
||||
}
|
||||
match lead {
|
||||
Lead::Text(s) => {
|
||||
ui.painter().text(pos2(lead_rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary);
|
||||
let galley = crate::bidi::name_galley(ui.painter(), s, regular(11.0), t.text_secondary, lead_w - 12.0);
|
||||
// A label cut short shows whole on hover.
|
||||
if galley.elided {
|
||||
lead_resp.clone().on_hover_text(s);
|
||||
}
|
||||
ui.painter().galley(pos2(lead_rect.min.x + 8.0, rect.center().y - galley.size().y / 2.0), galley, t.text_secondary);
|
||||
}
|
||||
Lead::Icon(i, _) => {
|
||||
icons::paint(ui.painter(), Rect::from_center_size(lead_rect.center() + vec2(1.0, 0.0), vec2(14.0, 14.0)), i, t.icon_secondary)
|
||||
@@ -347,7 +352,8 @@ fn number_field_impl(
|
||||
None => empty.to_string(),
|
||||
};
|
||||
let col = if value.is_some() { t.text } else { t.text_secondary };
|
||||
ui.painter().text(pos2(edit_rect.min.x, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), col);
|
||||
// A value too long for its room ends in "…", so a cut number never reads as another.
|
||||
crate::bidi::paint_name(ui.painter(), pos2(edit_rect.min.x, rect.center().y), &s, regular(11.0), col, edit_rect.width());
|
||||
}
|
||||
}
|
||||
// The variable button shows while the pointer is over a bindable field.
|
||||
@@ -369,6 +375,13 @@ fn number_field_impl(
|
||||
(out, bind)
|
||||
}
|
||||
|
||||
/// The width a text label takes at the start of a field `field_w` wide: the label as drawn plus
|
||||
/// its padding, leaving the value room for a few characters (a longer label ends in an ellipsis).
|
||||
pub(crate) fn text_lead_width(ui: &Ui, label: &str, field_w: f32) -> f32 {
|
||||
let text = ui.painter().layout_no_wrap(label.to_string(), regular(11.0), Color32::PLACEHOLDER).size().x;
|
||||
(12.0 + text).min(field_w - 32.0).max(24.0)
|
||||
}
|
||||
|
||||
/// A field bound to a variable: the lead, then the variable's name in a chip; the chip opens the
|
||||
/// picker and the detach button unbinds.
|
||||
/// A field showing the variable `name` bound to it in `rect`: click to change, detach to unbind.
|
||||
@@ -383,8 +396,9 @@ pub fn bound_field(ui: &mut Ui, id: Id, lead: Lead, rect: Rect, name: &str) -> O
|
||||
let lead_w = match lead {
|
||||
Lead::None | Lead::Named(_) => 4.0,
|
||||
Lead::Text(s) => {
|
||||
ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, s, regular(11.0), t.text_secondary);
|
||||
(12.0 + s.chars().count() as f32 * 7.0).max(24.0)
|
||||
let w = text_lead_width(ui, s, rect.width());
|
||||
crate::bidi::paint_name(ui.painter(), pos2(rect.min.x + 8.0, rect.center().y), s, regular(11.0), t.text_secondary, w - 12.0);
|
||||
w
|
||||
}
|
||||
Lead::Icon(i, _) => {
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(rect.min.x + 13.0, rect.center().y), vec2(14.0, 14.0)), i, t.icon_secondary);
|
||||
|
||||
+22
@@ -153,6 +153,28 @@ figcraft-cli run start.json --out dashboard.figcraft
|
||||
figcraft-cli export dashboard.figcraft -o dashboard.png
|
||||
```
|
||||
|
||||
### Confining a run: `--automation-read-root DIR` / `--automation-write-root DIR`
|
||||
|
||||
`info`, `export`, `convert`, `render` and `run` take these two flags, with the same meaning they
|
||||
have on [`mcp`](#mcp---attach-port---automation-read-root-dir---automation-write-root-dir): each is
|
||||
repeatable, each folder must exist, and together they confine every file that subcommand touches.
|
||||
Reads are allowed inside a read or write root, writes only inside a write root, and everything else
|
||||
is refused — so a run given only a read root cannot write at all. Without either flag a subcommand
|
||||
reads and writes as the user who started it.
|
||||
|
||||
```sh
|
||||
# Renders in/doc.figcraft to out/ok.png and can touch nothing else.
|
||||
figcraft-cli render in/doc.figcraft -o out/ok.png \
|
||||
--automation-read-root in --automation-write-root out
|
||||
|
||||
figcraft-cli render in/doc.figcraft -o ./escaped.png \
|
||||
--automation-read-root in --automation-write-root out
|
||||
# error: automation path rejected: . is outside the folders that may be written: …/out
|
||||
```
|
||||
|
||||
This is for driving the CLI from a script or an agent that should not reach the rest of the disk;
|
||||
the flags leave the output identical when nothing strays outside the roots.
|
||||
|
||||
### `commands [--json] [--filter TEXT]`
|
||||
|
||||
Every command with its label, shortcut, undoable flag (`*`) and parameter docs; `--json` for
|
||||
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user