Merge main into #872

This commit is contained in:
bflatastic
2026-10-20 00:02:45 +07:00
42 changed files with 907 additions and 165 deletions
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
name: test (ubuntu-latest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Install Linux system dependencies (egui/winit/rfd)
run: |
@@ -74,7 +74,7 @@ jobs:
name: wasm32 check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
+15 -3
View File
@@ -29,9 +29,20 @@ fn image_format(e: &str) -> Option<ExportFormat> {
}
}
/// Writes through [`figcraft_engine::file_access`] when automation roots are in force, so a
/// confined run can only write inside a write root (#833).
///
/// Unconfined it writes with `std::fs::write`, as it always did. `file_access::write` stages the
/// bytes and renames them into place, which **replaces** a symbolic link at `path` instead of
/// writing through it to its target -- a change of behaviour for anyone whose output path is a
/// link, and nothing to do with confinement. Inside a root the staged write is the right one:
/// `Roots::check` has already refused a link that leads out of the root.
fn write(path: &str, bytes: &[u8]) -> CliResult<()> {
if let Some(dir) = Path::new(path).parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(dir).map_err(|e| CliError::Failed(format!("{}: {e}", dir.display())))?;
figcraft_engine::file_access::create_dir_all(dir).map_err(|e| CliError::Failed(e.to_string()))?;
}
if figcraft_engine::file_access::current().is_some() {
return figcraft_engine::file_access::write(path, bytes).map_err(|e| CliError::Failed(e.to_string()));
}
std::fs::write(path, bytes).map_err(|e| CliError::Failed(format!("{path}: {e}")))
}
@@ -244,7 +255,7 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult<ExitCode> {
println!("{out}");
return Ok(ExitCode::SUCCESS);
}
std::fs::create_dir_all(out).map_err(|e| CliError::Failed(format!("{out}: {e}")))?;
figcraft_engine::file_access::create_dir_all(out).map_err(|e| CliError::Failed(e.to_string()))?;
let ids = match target {
Some(t) => vec![t],
None => {
@@ -329,7 +340,8 @@ pub fn parse_script(text: &str) -> Result<Vec<(String, Value)>, String> {
}
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
let text = std::fs::read_to_string(script).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let mut s = match input {
Some(path) => {
+50 -28
View File
@@ -22,6 +22,7 @@ USAGE:
figcraft-cli convert <in> <out>
figcraft-cli render <file> -o <out.png> [--scale N] [--page NAME]
figcraft-cli run <script.json|script.jsonl> [--in <file>] [--out <file>]
(info, export, convert, render and run also take --automation-read-root DIR / --automation-write-root DIR)
figcraft-cli commands [--json] [--filter TEXT]
figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]...
figcraft-cli plugin publish <folder> --server ws://host:port/[?key=KEY]
@@ -42,12 +43,33 @@ crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines)
`plugin publish` sends a plugin folder (figcraft-plugin.json, its module, an optional assets/ folder)
to a figcraft-server's plugin directory as a new version; the server's key comes from the address or
the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell history).
`mcp --automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files the agent's commands touch: reads only inside a read or write root,
writes only inside a write root, everything else refused. A running app confines itself: start it
with `figcraft --control PORT` and the same flags (`--attach` refuses them).
`--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders
must exist) confine the files a subcommand touches: reads only inside a read or write root, writes
only inside a write root, everything else refused -- so giving no write root denies writing
altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`,
`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes
as the user who started it, as before. A running app confines itself: start it with
`figcraft --control PORT` and the same flags (`--attach` refuses them).
See docs/cli.md and docs/mcp.md.";
/// The automation roots a file subcommand was given, if any. Omitting both leaves the run
/// unconfined, as before; giving either confines every file the subcommand touches (#833).
const ROOT_FLAGS: [&str; 2] = ["automation-read-root", "automation-write-root"];
fn roots_of(a: &Args) -> CliResult<Option<figcraft_engine::file_access::Roots>> {
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
if read.is_empty() && write.is_empty() {
return Ok(None);
}
Ok(Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?))
}
/// Runs `f` confined to the roots `a` carries.
fn confined<R>(a: &Args, f: impl FnOnce() -> CliResult<R>) -> CliResult<R> {
let roots = roots_of(a)?.map(std::sync::Arc::new);
figcraft_engine::file_access::confined(roots.as_ref(), f)
}
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
match run(&argv) {
@@ -98,35 +120,38 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"info" => {
let a = Args::parse(rest, &[], &["json"])?;
let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?;
let file = a.one_positional("info <file>")?;
files::info(&file, a.flag("json"))
confined(&a, || files::info(&file, a.flag("json")))
}
"export" => {
let a = Args::parse(rest, &["format", "scale", "page", "node", "o", "out", "bleed"], &["crop-marks", "bleed-marks", "cmyk"])?;
let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?;
let file = a.one_positional("export <file> -o <out>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o <out>".into()))?;
let target =
files::Target { format: a.value("format"), scale: a.scale()?, page: a.value("page"), node: a.value("node"), print: a.print()? };
files::export(&file, &out, &target)
confined(&a, || files::export(&file, &out, &target))
}
"convert" => {
let a = Args::parse(rest, &[], &[])?;
let a = Args::parse(rest, &ROOT_FLAGS, &[])?;
match a.positional.as_slice() {
[i, o] => files::convert(i, o),
[i, o] => confined(&a, || files::convert(i, o)),
_ => Err(CliError::Usage("convert <in> <out>".into())),
}
}
"render" => {
let a = Args::parse(rest, &["o", "out", "scale", "page"], &[])?;
let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let file = a.one_positional("render <file> -o <out.png>")?;
let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o <out.png>".into()))?;
files::render(&file, &out, a.scale()?, a.value("page").as_deref())
confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref()))
}
"run" => {
let a = Args::parse(rest, &["in", "out"], &[])?;
let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
let script = a.one_positional("run <script.json|.jsonl>")?;
files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())
confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref()))
}
"commands" => {
let a = Args::parse(rest, &["filter"], &["json"])?;
@@ -137,23 +162,20 @@ fn run(argv: &[String]) -> CliResult<ExitCode> {
Ok(ExitCode::SUCCESS)
}
"mcp" => {
let a = Args::parse(rest, &["attach", "automation-read-root", "automation-write-root"], &[])?;
let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat();
let a = Args::parse(rest, &valued, &[])?;
if !a.positional.is_empty() {
return Err(CliError::Usage("mcp takes no positional arguments".into()));
}
let (read, write) = (a.values("automation-read-root"), a.values("automation-write-root"));
let roots = match (read.is_empty() && write.is_empty(), a.value("attach")) {
(true, _) => None,
// The app runs the commands: only its own roots can confine them.
(false, Some(_)) => {
return Err(CliError::Usage(
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
.into(),
));
}
(false, None) => Some(figcraft_engine::file_access::Roots::new(read, write).map_err(CliError::Failed)?),
};
mcp(a.value("attach").as_deref(), roots)
let attach = a.value("attach");
// The app runs the commands: only its own roots can confine them.
if attach.is_some() && ROOT_FLAGS.iter().any(|f| !a.values(f).is_empty()) {
return Err(CliError::Usage(
"--attach can't take automation roots: start the app with them instead (`figcraft --control PORT --automation-read-root DIR ...`)"
.into(),
));
}
mcp(attach.as_deref(), roots_of(&a)?)
}
"plugin" => {
let a = Args::parse(rest, &["server"], &[])?;
+95
View File
@@ -172,3 +172,98 @@ fn commands_json_lists_everything() {
let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert!(v.as_array().unwrap().len() > 200);
}
/// The automation roots confine the file subcommands too, not just `mcp` (#833): a read outside a
/// root, and a write outside a write root, are refused and leave no file behind. Without the flags
/// the same commands behave as they always did.
#[test]
fn file_subcommands_are_confined_to_automation_roots() {
let d = tmp("file-roots");
let _ = std::fs::remove_dir_all(&d);
for sub in ["in", "out"] {
std::fs::create_dir_all(d.join(sub)).unwrap();
}
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
let out_of = |o: &std::process::Output| String::from_utf8_lossy(&o.stderr).into_owned();
// A document to work on, made unconfined.
std::fs::write(d.join("in/make.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
let made = bin().args(["run", &p("in/make.json"), "--out", &p("in/doc.figcraft")]).output().unwrap();
assert!(made.status.success(), "{}", out_of(&made));
// Inside the roots: renders as usual.
let ok = bin()
.args(["render", &p("in/doc.figcraft"), "-o", &p("out/ok.png"), "--automation-read-root", &p("in"), "--automation-write-root", &p("out")])
.output()
.unwrap();
assert!(ok.status.success(), "{}", out_of(&ok));
assert!(d.join("out/ok.png").is_file());
// A write outside the write root is refused, and nothing is written.
let escaped = d.join("escaped.png");
let refused = bin()
.args([
"render",
&p("in/doc.figcraft"),
"-o",
&escaped.to_string_lossy(),
"--automation-read-root",
&p("in"),
"--automation-write-root",
&p("out"),
])
.output()
.unwrap();
assert!(!refused.status.success());
assert!(out_of(&refused).contains("may be written"), "{}", out_of(&refused));
assert!(!escaped.exists(), "the refused render wrote a file anyway");
// No write root at all: writing is refused outright, reading still works.
let no_write = bin().args(["render", &p("in/doc.figcraft"), "-o", &p("out/no.png"), "--automation-read-root", &p("in")]).output().unwrap();
assert!(!no_write.status.success());
assert!(out_of(&no_write).contains("no folder may be written"), "{}", out_of(&no_write));
// A read outside every root is refused: `info` on the document with only `out` readable.
let read_refused = bin().args(["info", &p("in/doc.figcraft"), "--automation-read-root", &p("out")]).output().unwrap();
assert!(!read_refused.status.success());
assert!(out_of(&read_refused).contains("may be read"), "{}", out_of(&read_refused));
// `convert` and `run` take them as well, and a root that is not a folder is refused.
let missing = bin().args(["convert", &p("in/doc.figcraft"), &p("out/c.svg"), "--automation-read-root", &p("missing")]).output().unwrap();
assert!(!missing.status.success());
assert!(out_of(&missing).contains("automation-read-root"), "{}", out_of(&missing));
// Unconfined, the same render still writes wherever it is told.
let plain = bin().args(["render", &p("in/doc.figcraft"), "-o", &escaped.to_string_lossy()]).output().unwrap();
assert!(plain.status.success(), "{}", out_of(&plain));
assert!(escaped.is_file());
let _ = std::fs::remove_dir_all(&d);
}
/// An output path that is a symbolic link is written through, to its target, as `std::fs::write`
/// always did. Routing the CLI's writes through `file_access` for #833 would otherwise have
/// replaced the link with a regular file, because that write stages the bytes and renames them
/// into place.
#[cfg(unix)]
#[test]
fn an_unconfined_write_follows_a_symbolic_link() {
let d = tmp("symlink-out");
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("real")).unwrap();
let p = |rel: &str| d.join(rel).to_string_lossy().into_owned();
std::fs::write(d.join("in.json"), br#"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":40,"height":20}}]"#).unwrap();
let made = bin().args(["run", &p("in.json"), "--out", &p("doc.figcraft")]).output().unwrap();
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
let target = d.join("real/target.png");
std::fs::write(&target, b"placeholder").unwrap();
let link = d.join("link.png");
std::os::unix::fs::symlink(&target, &link).unwrap();
let out = bin().args(["render", &p("doc.figcraft"), "-o", &link.to_string_lossy()]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink(), "the link is still a link");
let written = std::fs::read(&target).unwrap();
assert!(written.starts_with(b"\x89PNG"), "the target holds the render, not the placeholder");
let _ = std::fs::remove_dir_all(&d);
}
+1 -1
View File
@@ -26,4 +26,4 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
js-sys = "0.3"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "HtmlElement", "HtmlAnchorElement", "Node", "Blob", "BlobPropertyBag", "File", "FileList", "HtmlInputElement", "EventTarget", "Url", "Location", "MediaQueryList", "Navigator", "Gamepad", "GamepadButton", "GamepadMappingType"] }
+99 -4
View File
@@ -1,7 +1,7 @@
//! The browser shell: web `Services` and the eframe web runner.
use figcraft_engine::Session;
use figcraft_ui_egui::{FigApp, Services};
use figcraft_ui_egui::{ContentsFuture, FigApp, Services};
use wasm_bindgen::JsCast as _;
const CANVAS_ID: &str = "figcraft_canvas";
@@ -75,9 +75,10 @@ impl eframe::App for WebShell {
}
}
/// Browser services. Open dialogs return nothing for now (the trait's defaults); files are saved
/// as downloads. Copies go to the system clipboard through egui's output; pastes arrive as paste
/// events with their text (a page can't read the clipboard when it likes).
/// Browser services. Files are opened and imported through the browser's file picker, which hands
/// over their contents (pickers that need a path return nothing, the trait's defaults); files are
/// saved as downloads. Copies go to the system clipboard through egui's output; pastes arrive as
/// paste events with their text (a page can't read the clipboard when it likes).
struct WebServices {
/// `prefers-reduced-motion: reduce` (it follows the setting while the page is open).
reduce_motion: Option<web_sys::MediaQueryList>,
@@ -85,6 +86,16 @@ struct WebServices {
}
impl Services for WebServices {
/// An `<input type="file">` taking `exts`, clicked; its file is read once picked.
fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option<ContentsFuture> {
let accept = exts.iter().map(|e| format!(".{e}")).collect::<Vec<_>>().join(",");
Some(Box::pin(async move {
pick_file(&accept).await.unwrap_or_else(|e| {
log::error!("couldn't open a file: {e:?}");
None
})
}))
}
fn downloads(&self) -> bool {
true
}
@@ -136,6 +147,90 @@ impl Services for WebServices {
}
}
/// The file picked in the browser's file picker (`accept`: the extensions it offers), with its
/// name; `None` when the picker is cancelled, `Some(Err)` with what to tell the user when the file
/// can't be read.
async fn pick_file(accept: &str) -> Result<Option<Result<(String, Vec<u8>), String>>, wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let body = document.body().ok_or("no body")?;
let input: web_sys::HtmlInputElement = document.create_element("input")?.dyn_into()?;
input.set_type("file");
input.set_accept(accept);
input.set_hidden(true);
// Some browsers (older Safari) open the picker only for an input in the page.
body.append_child(&input)?;
let (closed, picker) = PickerClosed::new(&window, input.clone());
input.click();
let result = wasm_bindgen_futures::JsFuture::from(closed).await;
let file = input.files().and_then(|files| files.get(0));
drop(picker);
result?;
let Some(file) = file else { return Ok(None) };
let name = file.name();
if let Err(why) = figcraft_ui_egui::browser_can_read(&name, file.size()) {
return Ok(Some(Err(why)));
}
Ok(Some(match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
Ok(buffer) => Ok((name, js_sys::Uint8Array::new(&buffer).to_vec())),
Err(e) => {
log::error!("couldn't read {name}: {e:?}");
Err(figcraft_ui_egui::unreadable(&name))
}
}))
}
/// How long after the page gets focus back the file picker counts as closed (milliseconds): a
/// picked file's `change` can come just after the focus, and must win. A `change` later than this
/// (a slow cloud-backed file in a browser that never sends `cancel`) counts as a cancel.
const PICKER_FOCUS_SETTLE_MS: i32 = 1_000;
/// Settles a file picker's promise once: on `change` (a file was picked), on `cancel` (closed
/// without one) or shortly after the page gets focus back, for browsers that never send `cancel`
/// (Safari before 16.4), where the pick would otherwise stay pending and block every later one.
/// Dropping it (also when the pick is abandoned) removes the input, the focus listener and its timer.
struct PickerClosed {
window: web_sys::Window,
input: web_sys::HtmlInputElement,
_settle: wasm_bindgen::closure::Closure<dyn FnMut()>,
on_focus: wasm_bindgen::closure::Closure<dyn FnMut()>,
timer: std::rc::Rc<std::cell::Cell<Option<i32>>>,
}
impl PickerClosed {
fn new(window: &web_sys::Window, input: web_sys::HtmlInputElement) -> (js_sys::Promise, Self) {
let resolve = std::rc::Rc::new(std::cell::RefCell::new(None::<js_sys::Function>));
let slot = resolve.clone();
let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res));
let settle = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
if let Some(res) = resolve.borrow_mut().take() {
let _ = res.call0(&wasm_bindgen::JsValue::NULL);
}
});
let _ = input.add_event_listener_with_callback("change", settle.as_ref().unchecked_ref());
let _ = input.add_event_listener_with_callback("cancel", settle.as_ref().unchecked_ref());
let timer = std::rc::Rc::new(std::cell::Cell::new(None));
let (w, t, f) = (window.clone(), timer.clone(), settle.as_ref().unchecked_ref::<js_sys::Function>().clone());
let on_focus = wasm_bindgen::closure::Closure::<dyn FnMut()>::new(move || {
if t.get().is_none() {
t.set(w.set_timeout_with_callback_and_timeout_and_arguments_0(&f, PICKER_FOCUS_SETTLE_MS).ok());
}
});
let _ = window.add_event_listener_with_callback("focus", on_focus.as_ref().unchecked_ref());
(closed, Self { window: window.clone(), input, _settle: settle, on_focus, timer })
}
}
impl Drop for PickerClosed {
fn drop(&mut self) {
if let Some(t) = self.timer.take() {
self.window.clear_timeout_with_handle(t);
}
let _ = self.window.remove_event_listener_with_callback("focus", self.on_focus.as_ref().unchecked_ref());
self.input.remove();
}
}
fn download(name: &str, bytes: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
+3
View File
@@ -56,3 +56,6 @@ sync_github_name = true
[people.pechnikov2026-star]
sync_github_name = true
[people.MortenHusted]
sync_github_name = true
+74
View File
@@ -97,3 +97,77 @@ fn effect_moves_and_removals_reject_junk() {
ex(&mut s, "effect.remove", json!({"ids": [r], "index": 7}));
assert_eq!(s.doc().unwrap().node(r).unwrap().effects.len(), 1);
}
fn fill_hex(s: &Session, r: NodeId) -> Vec<String> {
s.doc().unwrap().node(r).unwrap().fills.iter().filter_map(|p| p.solid_color()).map(|c| c.to_hex()).collect()
}
fn brand_id(s: &Session) -> String {
s.doc().unwrap().variables.get("brand").unwrap().id.clone()
}
#[test]
fn explicit_paint_edits_detach_the_colour_binding() {
// An edit that reported success keeps its colour: the binding it overwrote lets go (as
// selection colours already do), instead of putting the variable's colour back.
let edits = [
("fill.set", json!({"color": "#00FF00"})),
("node.set", json!({"props": {"fill": "#00FF00"}})),
("node.set", json!({"props": {"fills": ["#00FF00"]}})),
];
for (cmd, mut p) in edits {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
p["ids"] = json!([r]);
ex(&mut s, cmd, p.clone());
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}");
assert!(bound(&s, r).is_empty(), "{cmd} {p}");
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
assert_eq!(fill_hex(&s, r), ["00FF00"], "{cmd} {p}: no longer follows the variable");
}
for (cmd, mut p) in [("stroke.set", json!({"color": "#00FF00"})), ("node.set", json!({"props": {"stroke": "#00FF00"}}))] {
let (mut s, r) = setup();
ex(&mut s, "node.set", json!({"ids": [r], "props": {"strokes": ["#000000"]}}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "stroke", "variable": "brand"}));
p["ids"] = json!([r]);
ex(&mut s, cmd, p);
let n = s.doc().unwrap().node(r).unwrap();
assert_eq!(n.strokes[0].solid_color().map(|c| c.to_hex()).as_deref(), Some("00FF00"), "{cmd}");
assert!(n.bound.is_empty(), "{cmd}");
}
}
#[test]
fn clearing_bound_fills_adds_no_paint_back() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": []}}));
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty());
assert!(bound(&s, r).is_empty());
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#123456"}));
assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty(), "the variable doesn't bring the fill back");
}
#[test]
fn paint_opacity_edits_keep_the_colour_binding() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
ex(&mut s, "fill.set", json!({"ids": [r], "opacity": 0.5}));
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000FF"}));
assert_eq!(fill_hex(&s, r), ["0000FF"], "the colour still follows the variable");
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.5);
// The same colour with another opacity, through node.set, keeps it too.
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [{"type": "solid", "color": "#0000FF", "opacity": 0.25}]}}));
assert_eq!(bound(&s, r).len(), 1);
assert_eq!(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.25);
}
#[test]
fn paints_set_with_their_binding_in_the_same_call_keep_it() {
let (mut s, r) = setup();
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fill", "variable": "brand"}));
let brand = brand_id(&s);
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": ["#00FF00"], "boundVariables": {"fill": brand}}}));
assert_eq!(bound(&s, r), [("fill".to_string(), brand)]);
assert_eq!(fill_hex(&s, r), ["FF0000"], "the binding given with the paints applies");
}
+1 -1
View File
@@ -124,7 +124,7 @@ pub(crate) fn apply(s: &mut Session, cmd: &str, p: &Value, op: BoolOp) -> Result
}
return Ok(json!({"ids": ids, "operation": op}));
}
let out = crate::cmd::structure::wrap_boolean(s, p, op)?;
let out = crate::cmd::structure::wrap_boolean(s, p, cmd, op)?;
// Operands of a new group follow its operation.
if let Some(g) = crate::params::id_of(&out["id"]) {
let doc = s.doc_mut()?;
+1 -1
View File
@@ -1342,7 +1342,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 {
+40 -3
View File
@@ -112,7 +112,8 @@ pub fn stop_binding(field: &str) -> Option<usize> {
/// Keep the gradient stop bindings of paint `index` of `n`'s fills (or strokes) on their stops after
/// the paint changed from `old`: one stop removed shifts the later ones down (the removed one's
/// binding goes); a paint that is no longer a gradient drops them. Other edits keep stop indices.
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
/// Returns the removed stop's index.
pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) -> Option<usize> {
use figcraft_color::PaintKind;
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) };
@@ -122,11 +123,11 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
// The first stop that differs is the removed one when the rest moved down unchanged.
let k = a.stops.iter().zip(&b.stops).position(|(x, y)| x != y).unwrap_or(b.stops.len());
if a.stops.get(k + 1..) != b.stops.get(k..) {
return;
return None;
}
Some(k)
}
(_, Some(PaintKind::Gradient(_))) => return,
(_, Some(PaintKind::Gradient(_))) => return None,
_ => None,
};
let keys: Vec<(String, Option<String>)> = n
@@ -150,6 +151,42 @@ pub(crate) fn remap_stop_bindings(n: &mut Node, fills: bool, index: usize, old:
}
}
n.bound.extend(kept);
removed
}
/// After an explicit edit changed paint `index` of `n`'s fills (or strokes) from `old`: its stop
/// bindings follow a removed stop, then each binding whose value the edit changed (the colour, the
/// opacity, a stop's colour) lets go, so the edit stays instead of being put back from the
/// variable. Bindings whose value the edit kept stay; a paint that is gone takes its bindings.
pub(crate) fn rebind_edited_paint(n: &mut Node, fills: bool, index: usize, old: &figcraft_color::Paint) {
let removed = remap_stop_bindings(n, fills, index, old);
let mut old = old.clone();
if let (Some(k), figcraft_color::PaintKind::Gradient(g)) = (removed, &mut old.kind)
&& k < g.stops.len()
{
g.stops.remove(k);
}
let list = if fills { BoundList::Fills } else { BoundList::Strokes };
let new = if fills { n.fills.get(index) } else { n.strokes.get(index) }.cloned();
n.bound.retain(|key, _| match list_binding(key) {
Some((l, i, field)) if l == list && i == index => new.as_ref().is_some_and(|new| paint_field_kept(&old, new, field)),
_ => true,
});
}
/// Whether a paint's bound `field` (`color`, `opacity`, `stops/<j>/color`, …) has the same value in
/// `new` as in `old`.
fn paint_field_kept(old: &figcraft_color::Paint, new: &figcraft_color::Paint, field: &str) -> bool {
use figcraft_color::PaintKind;
match field {
"color" => matches!((&old.kind, &new.kind), (PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) if a == b),
"opacity" => old.opacity == new.opacity,
_ => match (stop_binding(field), &old.kind, &new.kind) {
(Some(j), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(j).map(|s| s.color) == b.stops.get(j).map(|s| s.color),
(Some(_), _, _) => false,
_ => old == new,
},
}
}
/// Where item `k` lands when the item at `from` moves to `to`.
+25 -11
View File
@@ -315,18 +315,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 +412,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"))?,
+16 -15
View File
@@ -12,9 +12,9 @@ use crate::{EngineError, Result, Session, bad, spec};
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 +42,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",
@@ -128,26 +128,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();
@@ -338,7 +339,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 +473,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 +520,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();
@@ -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}");
}
+17 -5
View File
@@ -62,7 +62,7 @@ impl Roots {
Ok(resolved) if roots.iter().any(|r| resolved.starts_with(r)) => return Ok(resolved),
Ok(_) => {}
// Why a path can't be resolved says what exists there: only told for paths inside a root.
Err(why) if lexically_inside(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(why) if inside_as_far_as_it_exists(path, &roots) => return Err(format!("automation path rejected: {asked}: {why}")),
Err(_) => {}
}
if roots.is_empty() {
@@ -79,9 +79,10 @@ impl Roots {
}
}
/// Whether `path`, made absolute with its `.` and `..` taken by name (no file system), lies in one
/// of `roots` (as resolved, or as shown without Windows' `\\?\` prefix).
fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
/// Whether `path` lies in one of `roots` once made absolute with its `.` and `..` taken by name and
/// its longest leading part that exists resolved, so links on the way are followed as the roots'
/// were (`/tmp/work/x` is inside root `/private/tmp/work`, a link out of a root leads out of it).
fn inside_as_far_as_it_exists(path: &Path, roots: &[PathBuf]) -> bool {
let Ok(abs) = std::path::absolute(path) else { return false };
let mut lexical = PathBuf::new();
for c in abs.components() {
@@ -93,7 +94,18 @@ fn lexically_inside(path: &Path, roots: &[PathBuf]) -> bool {
c => lexical.push(c),
}
}
roots.iter().any(|r| lexical.starts_with(r) || lexical.starts_with(shown(r)))
let mut rest: Vec<&OsStr> = Vec::new();
let mut at = lexical.as_path();
let resolved = loop {
if let Ok(mut base) = std::fs::canonicalize(at) {
base.extend(rest.iter().rev());
break base;
}
let (Some(name), Some(parent)) = (at.file_name(), at.parent()) else { return false };
rest.push(name);
at = parent;
};
roots.iter().any(|r| resolved.starts_with(r))
}
/// Whether `path` lies in the app's own folder `folder`, both resolved (links and `..` followed);
+17
View File
@@ -348,6 +348,10 @@ fn outside_paths_are_refused_alike_whatever_exists_there() {
let mut outside = vec![f.p("outside/secret.txt"), f.p("outside/none.txt"), f.p("outside/missing/../x.txt"), f.p("nowhere/deeper/x.txt")];
if symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("outside/dangling.txt")) {
outside.push(f.p("outside/dangling.txt"));
// Reached through a link in the root, it is outside all the same.
if symlink_dir(&f.0.join("outside"), &f.0.join("root/escape")) {
outside.push(f.p("root/escape/dangling.txt"));
}
}
for p in &outside {
let e = refusal(p);
@@ -359,6 +363,19 @@ fn outside_paths_are_refused_alike_whatever_exists_there() {
}
}
#[test]
fn a_root_given_through_a_link_tells_why_paths_in_it_are_refused() {
let f = Fixture::new("linked-root");
if !symlink_dir(&f.0.join("root"), &f.0.join("via")) || !symlink_file(&f.0.join("outside/not-yet.txt"), &f.0.join("root/dangling.txt")) {
eprintln!("skipped: this system doesn't let tests make symbolic links");
return;
}
// Like `/tmp/work` on macOS, where `/tmp` leads to `/private/tmp`.
let r = Roots::new(&[], &[f.p("via")]).unwrap();
let e = r.check(Path::new(&f.p("via/dangling.txt")), Access::Write).unwrap_err();
assert!(e.contains("leads nowhere"), "{e}");
}
#[test]
fn only_resolved_paths_count_as_the_apps_own_folder() {
let f = Fixture::new("own");
+7 -34
View File
@@ -9,7 +9,7 @@ use figcraft_doc::{Document, NodeId, NodeKind, StyleKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::cmd::lists::{BoundList, list_binding};
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
use crate::params::*;
use crate::{CommandSpec, Result, Session, bad, spec};
@@ -153,37 +153,6 @@ fn checked_targets(s: &Session, p: &Value, cmd: &str) -> Result<Vec<PaintTarget>
Ok(ts)
}
/// Detach variables only where the explicit edit changed their value. In particular, an
/// opacity edit keeps the color binding and a range edit keeps other characters' node bindings.
fn unbind_changed(n: &mut figcraft_doc::Node, t: &PaintTarget, old: &Paint, new: &Paint) {
// Range fills override the node's paints; the node binding still belongs to other chars.
if t.range.is_some() {
return;
}
let list = if t.fills { BoundList::Fills } else { BoundList::Strokes };
n.bound.retain(|key, _| {
let Some((l, index, field)) = list_binding(key) else { return true };
if l != list || index != t.index {
return true;
}
match field {
"color" => match (&old.kind, &new.kind) {
(PaintKind::Solid { color: a }, PaintKind::Solid { color: b }) => a == b,
_ => false,
},
"opacity" => old.opacity == new.opacity,
_ => {
let stop = field.strip_prefix("stops/").and_then(|s| s.strip_suffix("/color")).and_then(|s| s.parse::<usize>().ok());
match (stop, &old.kind, &new.kind) {
(Some(i), PaintKind::Gradient(a), PaintKind::Gradient(b)) => a.stops.get(i).map(|s| s.color) == b.stops.get(i).map(|s| s.color),
(Some(_), _, _) => false,
_ => old == new,
}
}
}
});
}
fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "selection.setPaint";
let targets = checked_targets(s, p, CMD)?;
@@ -197,18 +166,22 @@ fn set_paint(s: &mut Session, p: &Value) -> Result<Value> {
let old = slot.clone();
*slot = paint.clone();
let n = s.doc_mut()?.get_mut(target.id)?;
unbind_changed(n, target, &old, &paint);
// Variables detach only where the edit changed their value; range fills override the node's
// paints, whose bindings still belong to the other characters.
if let Some((start, end)) = target.range {
if let NodeKind::Text(t) = &mut n.kind {
t.restyle(start, end, |style| style.fills = Some(paints.clone()));
}
} else if target.fills {
continue;
}
if target.fills {
n.fills = paints;
n.styles.fill = None;
} else {
n.strokes = paints;
n.styles.stroke = None;
}
rebind_edited_paint(n, target.fills, target.index, &old);
}
Ok(json!({"ok": true}))
}
+26
View File
@@ -371,3 +371,29 @@ fn variant_properties_follow_string_variables() {
assert_eq!(main(&s), variants[1]);
healthy(&s);
}
#[test]
fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() {
let mut s = Session::new();
let r = gradient_rect(&mut s);
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/2/color", "variable": "brand"}));
ex(&mut s, "variable.bind", json!({"ids": [r], "field": "fills/0/stops/1/color", "variable": "ink"}));
let cur = |s: &Session| s.doc().unwrap().node(r).unwrap().fills[0].clone();
// Removing the first stop moves both bindings down with their stops.
let mut p = cur(&s);
if let PaintKind::Gradient(g) = &mut p.kind {
g.stops.remove(0);
}
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
assert_eq!(bound_keys(&s, r), ["fills/0/stops/0/color", "fills/0/stops/1/color"]);
// Recolouring one bound stop detaches that stop only.
let mut p = cur(&s);
if let PaintKind::Gradient(g) = &mut p.kind {
g.stops[0].color = Color::WHITE;
}
ex(&mut s, "node.set", json!({"ids": [r], "props": {"fills": [p]}}));
assert_eq!(bound_keys(&s, r), ["fills/0/stops/1/color"]);
ex(&mut s, "variable.set", json!({"variable": "brand", "value": "#0000ff"}));
assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]);
healthy(&s);
}
+2
View File
@@ -310,6 +310,7 @@ status = "draft — needs native review"
"Corner radius" = "Poloměr rohu"
"Corner smoothing" = "Vyhlazení rohů"
"Couldn't reach {server}: showing what this computer remembers." = "Server {server} není dostupný: zobrazuje se stav uložený v tomto počítači."
"Couldn't read {name}." = "Soubor {name} se nepodařilo přečíst."
"Count" = "Počet"
"Create" = "Vytvořit"
"Create a variable collection first" = "Nejprve vytvořte kolekci proměnných"
@@ -1629,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})"
+2
View File
@@ -306,6 +306,7 @@ status = "draft — needs native review"
"Corner smoothing" = "Eckenglättung"
"Couldn't download the variables" = "Die Variablen konnten nicht heruntergeladen werden"
"Couldn't reach {server}: showing what this computer remembers." = "{server} war nicht erreichbar: Es wird angezeigt, was dieser Computer gespeichert hat."
"Couldn't read {name}." = "{name} konnte nicht gelesen werden."
"Couldn't save automatically: {error}" = "Automatisches Speichern fehlgeschlagen: {error}"
"Count" = "Anzahl"
"Create" = "Erstellen"
@@ -1583,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"
+2
View File
@@ -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"
@@ -1667,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})"
+2
View File
@@ -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"
@@ -1668,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})"
+2
View File
@@ -321,6 +321,7 @@ status = "draft — needs native review"
"Corner smoothing" = "Kulman pehmennys"
"Couldn't download the variables" = "Muuttujia ei voitu ladata"
"Couldn't reach {server}: showing what this computer remembers." = "Palvelimeen {server} ei saatu yhteyttä. Näytetään tämän tietokoneen muistamat tiedot."
"Couldn't read {name}." = "Tiedostoa {name} ei voitu lukea."
"Couldn't save automatically: {error}" = "Automaattinen tallennus epäonnistui: {error}"
"Count" = "Määrä"
"Create" = "Luo"
@@ -1676,6 +1677,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} on unohdettu tällä tietokoneella. Tiedosto jää palvelimelle {server}."
"{name} is on the server." = "{name} on palvelimella."
"{name} is spotlighting" = "{name} nostaa näkymäänsä esiin"
"{name} is too large to open in the browser (over 512 MB)." = "{name} on liian suuri avattavaksi selaimessa (yli 512 Mt)."
"{name} was removed from the server" = "{name} poistettiin palvelimelta"
"{name} · deleted on main" = "{name} · poistettu päähaarassa"
"{name} · deleted on the branch" = "{name} · poistettu haarassa"
+2
View File
@@ -318,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"
@@ -1665,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"
+2
View File
@@ -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"
@@ -1633,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})"
+2
View File
@@ -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" = "作成"
@@ -1650,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} · ブランチで削除"
+2
View File
@@ -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" = "만들기"
@@ -1574,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} · 브랜치에서 삭제됨"
+2
View File
@@ -321,6 +321,7 @@ status = "draft — needs native review"
"Corner smoothing" = "Hoekafvlakking"
"Couldn't download the variables" = "De variabelen konden niet worden gedownload"
"Couldn't reach {server}: showing what this computer remembers." = "Kon {server} niet bereiken: toont wat deze computer onthoudt."
"Couldn't read {name}." = "Kon {name} niet lezen."
"Couldn't save automatically: {error}" = "Kan niet automatisch opslaan: {error}"
"Count" = "Aantal"
"Create" = "Maken"
@@ -1673,6 +1674,7 @@ status = "draft — needs native review"
"{name} is forgotten on this computer; the file stays on {server}" = "{name} is op deze computer vergeten; het bestand blijft op {server}"
"{name} is on the server." = "{name} staat op de server."
"{name} is spotlighting" = "{name} staat in de spotlight"
"{name} is too large to open in the browser (over 512 MB)." = "{name} is te groot om in de browser te openen (meer dan 512 MB)."
"{name} was removed from the server" = "{name} is van de server verwijderd"
"{name} · deleted on main" = "{name} · verwijderd op main"
"{name} · deleted on the branch" = "{name} · verwijderd op de branch"
+2
View File
@@ -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"
@@ -1573,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"
+2
View File
@@ -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" = "Сначала создайте коллекцию переменных"
@@ -1635,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})"
+2
View File
@@ -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"
@@ -1650,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})"
+2
View File
@@ -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"
@@ -1633,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)"
+2
View File
@@ -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" = "创建"
@@ -1568,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})"
+69 -1
View File
@@ -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");
+4 -8
View File
@@ -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.
+4 -8
View File
@@ -187,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 });
}
}
+65
View File
@@ -573,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();
@@ -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![]);
+127 -31
View File
@@ -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))
@@ -1693,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,
+47 -9
View File
@@ -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
+22
View File
@@ -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