diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e613617a..9700a8e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 65b19ee4..46215599 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -29,9 +29,20 @@ fn image_format(e: &str) -> Option { } } +/// 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 { 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, String> { } pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult { - 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) => { diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 281d79dd..badc5bc3 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -22,6 +22,7 @@ USAGE: figcraft-cli convert figcraft-cli render -o [--scale N] [--page NAME] figcraft-cli run [--in ] [--out ] + (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 --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> { + 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(a: &Args, f: impl FnOnce() -> CliResult) -> CliResult { + 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 = std::env::args().skip(1).collect(); match run(&argv) { @@ -98,35 +120,38 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "info" => { - let a = Args::parse(rest, &[], &["json"])?; + let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?; let file = a.one_positional("info ")?; - 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 -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".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 ".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 -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o ".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 ")?; - 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 { 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"], &[])?; diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 7eaf051f..eb42b8d8 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -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); +} diff --git a/apps/figcraft-web/Cargo.toml b/apps/figcraft-web/Cargo.toml index 2f1a8190..ffd44f3b 100644 --- a/apps/figcraft-web/Cargo.toml +++ b/apps/figcraft-web/Cargo.toml @@ -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"] } diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 29213621..f08ca90f 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -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, @@ -85,6 +86,16 @@ struct WebServices { } impl Services for WebServices { + /// An `` taking `exts`, clicked; its file is read once picked. + fn pick_open_contents(&mut self, _title: &str, exts: &[&str]) -> Option { + let accept = exts.iter().map(|e| format!(".{e}")).collect::>().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), 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, + on_focus: wasm_bindgen::closure::Closure, + timer: std::rc::Rc>>, +} + +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::)); + let slot = resolve.clone(); + let closed = js_sys::Promise::new(&mut |res, _rej| *slot.borrow_mut() = Some(res)); + let settle = wasm_bindgen::closure::Closure::::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::().clone()); + let on_focus = wasm_bindgen::closure::Closure::::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")?; diff --git a/apps/figcraft/src/args.rs b/apps/figcraft/src/args.rs index 11798f5b..436df61f 100644 --- a/apps/figcraft/src/args.rs +++ b/apps/figcraft/src/args.rs @@ -9,7 +9,7 @@ /// What the app was started with. #[derive(Debug, Default, PartialEq, Eq)] pub struct Args { - /// `--control `: the JSON-lines control server. + /// `--control ` (or `--control=`): the JSON-lines control server. pub control: Option, pub files: Vec, /// The last invite link given. @@ -71,8 +71,8 @@ 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) { diff --git a/apps/figcraft/src/args_tests.rs b/apps/figcraft/src/args_tests.rs index 9c23a3f4..b274beda 100644 --- a/apps/figcraft/src/args_tests.rs +++ b/apps/figcraft/src/args_tests.rs @@ -16,6 +16,14 @@ fn files_and_the_control_port() { 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"])); diff --git a/contributors/people.toml b/contributors/people.toml index 03b99aa1..6bf8af90 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -57,5 +57,8 @@ sync_github_name = true [people.pechnikov2026-star] sync_github_name = true +[people.MortenHusted] +sync_github_name = true + [people.AganzeFelicite] sync_github_name = true diff --git a/crates/collab-net/src/invite.rs b/crates/collab-net/src/invite.rs index 617ad518..e5d86bf2 100644 --- a/crates/collab-net/src/invite.rs +++ b/crates/collab-net/src/invite.rs @@ -220,7 +220,8 @@ fn valid_host(s: &str) -> bool { return true; } let Some((name, port)) = s.rsplit_once(':') else { return false }; - port.parse::().is_ok_and(|p| p != 0) + port.bytes().all(|b| b.is_ascii_digit()) + && port.parse::().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 { 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 { diff --git a/crates/collab-net/src/invite_tests.rs b/crates/collab-net/src/invite_tests.rs index 02017517..b7618dc2 100644 --- a/crates/collab-net/src/invite_tests.rs +++ b/crates/collab-net/src/invite_tests.rs @@ -123,3 +123,19 @@ proptest! { prop_assert_eq!(Invite::parse(&i.url()), Ok(i)); } } + +#[test] +fn plus_or_sign_inside_a_percent_escape_is_broken() { + assert!(Endpoint::parse("ws://example.org/a/?key=%41").is_ok()); + for bad in ["%+1", "%-1", "%1", "%+a"] { + assert!(Endpoint::parse(&format!("ws://example.org/a/?key={bad}")).is_err(), "{bad}"); + } +} + +#[test] +fn port_must_be_all_digits() { + assert!(Endpoint::parse("ws://localhost:80/").is_ok()); + for bad in ["ws://localhost:+80/", "ws://localhost:-80/", "ws://localhost:/"] { + assert!(Endpoint::parse(bad).is_err(), "accepted {bad}"); + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index d6859a51..8e9f98d9 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -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 { diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 68a838e0..4cf875bb 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -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)); +} diff --git a/crates/engine/src/binding_lists_tests.rs b/crates/engine/src/binding_lists_tests.rs index 805b0afe..9db9975a 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -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 { + 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"); +} diff --git a/crates/engine/src/booleans.rs b/crates/engine/src/booleans.rs index 87d90153..c3de51cb 100644 --- a/crates/engine/src/booleans.rs +++ b/crates/engine/src/booleans.rs @@ -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()?; diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index ad6c21d1..22c9c181 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -528,6 +528,7 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { 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 { } super::props::restore_worlds(doc, &kept)?; } + super::styles::detach_edited(doc, &before); ok() } diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index ff7a56df..42730a37 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -608,7 +608,7 @@ fn make_slot(s: &mut Session, p: &Value) -> Result { 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 { @@ -1039,7 +1039,12 @@ fn valid_prop_name(cmd: &str, name: &str) -> Result { } fn add_prop(s: &mut Session, p: &Value) -> Result { - 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 { 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")?; diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a4769a35..f1a4ada7 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -261,7 +261,7 @@ pub fn specs() -> Vec { }), 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 { fn opacity_keys(s: &mut Session, p: &Value) -> Result { 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 { } } 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 { 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 { } fn effect_add(s: &mut Session, p: &Value) -> Result { - 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}`")))? diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index bc13bc70..8e59b303 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -92,6 +92,20 @@ pub fn specs() -> Vec { ), spec!("file.placeImage", "Place image", "{path | data, x?, y?}", true, super::create::place_image), spec!("file.close", "Close tab", "{index?, force? (discard unsaved changes)}", false, close), + spec!("file.moveTab", "Move tab", "{from, to} (indices among open documents)", false, |s, p| { + let from = req_index("file.moveTab", p, "from")?; + let to = req_index("file.moveTab", p, "to")?; + if from >= s.docs.len() || to >= s.docs.len() { + return Err(bad("file.moveTab", "no such document")); + } + if from != to { + let active_uid = s.docs.get(s.active).map(|d| d.uid); + let doc = s.docs.remove(from); + s.docs.insert(to, doc); + s.active = active_uid.and_then(|uid| s.docs.iter().position(|d| d.uid == uid)).unwrap_or(0); + } + Ok(json!({"index": to})) + }), spec!( "file.recover", "Recover file", @@ -1374,14 +1388,36 @@ fn close(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled("file.close".into(), "the document has unsaved changes (pass force: true to discard)".into())); } let uid = ds.uid; + let active_uid = s.docs.get(s.active).map(|d| d.uid); s.docs.remove(i); - if s.active >= s.docs.len() { - s.active = s.docs.len().saturating_sub(1); - } + // Removing a tab before the active one shifts its index; keep the same document selected. + s.active = active_uid.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(i.min(s.docs.len().saturating_sub(1))); crate::branches::close_tabs(s, uid, None); ok() } +#[cfg(test)] +mod tab_order_tests { + use super::*; + + #[test] + fn moving_and_closing_inactive_tabs_preserves_the_active_document() { + let mut session = Session::new(); + for name in ["Two", "Three", "Four"] { + session.execute("file.new", &json!({"name": name})).unwrap(); + } + session.execute("doc.switch", &json!({"index": 2})).unwrap(); + let active_uid = session.docs[session.active].uid; + let moved_uid = session.docs[0].uid; + session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap(); + assert_eq!(session.docs[2].uid, moved_uid); + assert_eq!(session.docs[session.active].uid, active_uid); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(session.docs[session.active].uid, active_uid); + assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err()); + } +} + /// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around. fn cycle_doc(s: &mut Session, dir: i64) -> Result { let n = s.docs.len() as i64; diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs index 769d0a29..287c9ddd 100644 --- a/crates/engine/src/cmd/lists.rs +++ b/crates/engine/src/cmd/lists.rs @@ -112,7 +112,8 @@ pub fn stop_binding(field: &str) -> Option { /// 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 { 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)> = 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//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`. diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 8a1f4e1c..f180b53c 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -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> { + let effects: Vec = 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> { + 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) -> 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(v: &Value) -> Option { 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) -> 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) { + 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) -> 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 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 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 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, diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 75ddac3d..90e18611 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -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 { 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 { "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 { // 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 { - wrap(s, p, Wrap::Bool(op)) +pub(crate) fn wrap_boolean(s: &mut Session, p: &Value, cmd: &str, op: BoolOp) -> Result { + 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 { - 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 { + 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 { 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 { 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 { 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 { 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(); diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index adeb2250..af029e5f 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -110,16 +110,26 @@ pub fn specs() -> Vec { )) }), 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 = 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})) @@ -950,6 +960,104 @@ 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> { + 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]) { + for old in before { + let Some(n) = doc.node(old.id) else { continue }; + let edited: Vec = 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 { + 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::>().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. @@ -1007,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>, include: impl Fn(&str) -> bool) { use super::lists::{BoundList, list_binding}; if doc.variables.variables.is_empty() { @@ -1021,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, Color)> = Vec::new(); @@ -1030,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) { diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 3b861895..2e7d282a 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -18,6 +18,7 @@ fn style(s: &mut Session, p: &Value, f: impl Fn(&mut TextStyle)) -> Result Result = 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 { 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 { 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 { super::props::apply_text_style("text.style", t, a, b, k, v)?; } } + super::styles::detach_edited(doc, &before); ok() } ), diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 6dd80646..0871f886 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -259,6 +259,25 @@ fn stroke_color(n: &Node) -> Option { 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> { n.effects.iter().filter(|e| e.visible).find_map(|e| match &e.kind { EffectKind::DropShadow(s) => Some(s), @@ -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 = 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}"); + } + } + } } diff --git a/crates/engine/src/components_scale_tests.rs b/crates/engine/src/components_scale_tests.rs index c796dad8..68465932 100644 --- a/crates/engine/src/components_scale_tests.rs +++ b/crates/engine/src/components_scale_tests.rs @@ -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"); +} diff --git a/crates/engine/src/convert_container_tests.rs b/crates/engine/src/convert_container_tests.rs index 620388d2..be338a82 100644 --- a/crates/engine/src/convert_container_tests.rs +++ b/crates/engine/src/convert_container_tests.rs @@ -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}"); +} diff --git a/crates/engine/src/effects_tests.rs b/crates/engine/src/effects_tests.rs index be69fac2..607b7711 100644 --- a/crates/engine/src/effects_tests.rs +++ b/crates/engine/src/effects_tests.rs @@ -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(); diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 8abeaee5..709b34a7 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -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); diff --git a/crates/engine/src/file_access_tests.rs b/crates/engine/src/file_access_tests.rs index a05ad88c..ba55e134 100644 --- a/crates/engine/src/file_access_tests.rs +++ b/crates/engine/src/file_access_tests.rs @@ -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"); diff --git a/crates/engine/src/inspect_behaviour_tests.rs b/crates/engine/src/inspect_behaviour_tests.rs index a52cac86..c4bcc6be 100644 --- a/crates/engine/src/inspect_behaviour_tests.rs +++ b/crates/engine/src/inspect_behaviour_tests.rs @@ -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(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 312c2e87..29b40eaa 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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. diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 4001b156..ed13de5f 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -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)?)) } diff --git a/crates/engine/src/live_tests.rs b/crates/engine/src/live_tests.rs index cacc4f37..5cdccf69 100644 --- a/crates/engine/src/live_tests.rs +++ b/crates/engine/src/live_tests.rs @@ -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))); +} diff --git a/crates/engine/src/selection_colors.rs b/crates/engine/src/selection_colors.rs index e604c798..875c6416 100644 --- a/crates/engine/src/selection_colors.rs +++ b/crates/engine/src/selection_colors.rs @@ -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 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::().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 { 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 { 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})) } diff --git a/crates/engine/src/slot_tests.rs b/crates/engine/src/slot_tests.rs index 516cb9d6..cbf824d9 100644 --- a/crates/engine/src/slot_tests.rs +++ b/crates/engine/src/slot_tests.rs @@ -41,6 +41,17 @@ fn props_of(s: &mut Session, id: NodeId) -> Vec { 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(); diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 5a868a63..3fe156f8 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -279,6 +279,34 @@ fn bindings_by_field_and_type() { } } +#[test] +fn auto_layout_bindings_go_with_the_auto_layout() { + // Docs silent; pinned (#810): removing a frame's auto layout drops its gap and padding bindings, + // and a binding never brings an auto layout back. One made without auto layout waits for it. + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "C"})); + run(&mut s, "variable.create", json!({"collection": "C", "name": "g", "type": "number", "value": 12})); + let removals = + [("layout.remove", json!({})), ("node.set", json!({"props": {"layout": null}})), ("node.set", json!({"props": {"layout": "none"}}))]; + for (cmd, mut p) in removals { + let f = frame(&mut s, None); + run(&mut s, "layout.add", json!({"ids": [f]})); + for field in ["gap", "crossGap", "paddingTop"] { + run(&mut s, "variable.bind", json!({"ids": [f], "field": field, "variable": "g"})); + } + p["ids"] = json!([f]); + run(&mut s, cmd, p.clone()); + let n = node(&s, f); + assert!(n.layout.is_none(), "{cmd} {p}: auto layout stays removed"); + assert!(n.bound.is_empty(), "{cmd} {p}: layout bindings dropped"); + } + let f = frame(&mut s, None); + run(&mut s, "variable.bind", json!({"ids": [f], "field": "gap", "variable": "g"})); + assert!(node(&s, f).layout.is_none(), "binding gap adds no auto layout"); + run(&mut s, "layout.add", json!({"ids": [f]})); + assert_eq!(node(&s, f).layout.map(|l| l.gap), Some(12.0), "the waiting binding applies to the new auto layout"); +} + #[test] fn deleting_a_bound_variable_keeps_the_last_value() { // Documented: deleting a variable unbinds what used it, which keeps showing its last value. @@ -365,3 +393,90 @@ fn number_variables_are_pixels_and_edits_are_one_undo_step() { s.undo().unwrap(); assert_eq!(lh(&s), LineHeight::Pixels(20.0), "the value and the bound layer go back together"); } + +#[test] +fn create_collection_keeps_at_least_one_named_mode() { + // A collection keeps at least one mode (removeMode) and mode names can't be empty (renameMode), + // so createCollection refuses an empty mode list, blank mode names and a blank collection name. + let mut s = Session::new(); + for p in [ + json!({"name": "C", "modes": []}), + json!({"name": "C", "modes": [1]}), + json!({"name": "C", "modes": ["", " "]}), + json!({"name": "C", "modes": ["A", " "]}), + json!({"name": " "}), + json!({"name": ""}), + ] { + assert!(s.execute("variable.createCollection", &p).is_err(), "{p} should be refused"); + } + assert!(s.doc().unwrap().variables.collections.is_empty(), "a refused call creates nothing"); + run(&mut s, "variable.createCollection", json!({"name": "C", "modes": [" Light ", "Dark"]})); + let names: Vec = s.doc().unwrap().variables.collections[0].modes.iter().map(|m| m.name.clone()).collect(); + assert_eq!(names, ["Light", "Dark"]); +} + +#[test] +fn explicit_edits_of_bound_fields_stick_and_detach_that_binding() { + // #809: an edit of a bound field detaches its binding (as a paint edit does) instead of + // the variable's value coming back; bindings whose value the edit kept stay. + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "C"})); + let w = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "w", "type": "number", "value": 48}))); + let o = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "o", "type": "number", "value": 40}))); + let r = rect(&mut s, None); + for (field, v) in [("width", &w), ("height", &w), ("opacity", &o)] { + run(&mut s, "variable.bind", json!({"ids": [r], "field": field, "variable": v})); + } + assert_eq!((node(&s, r).width, node(&s, r).opacity), (48.0, 0.4)); + + run(&mut s, "node.set", json!({"ids": [r], "width": 100, "opacity": 0.4})); + let n = node(&s, r); + assert_eq!(n.width, 100.0); + assert!(!n.bound.contains_key("width")); + assert!(n.bound.contains_key("height") && n.bound.contains_key("opacity"), "unchanged values keep their bindings"); + + run(&mut s, "transform.resize", json!({"ids": [r], "height": 30})); + assert_eq!(node(&s, r).height, 30.0); + assert!(!node(&s, r).bound.contains_key("height")); + + run(&mut s, "object.opacityKeys", json!({"ids": [r], "digits": "9"})); + assert_eq!(node(&s, r).opacity, 0.9); + assert!(node(&s, r).bound.is_empty()); + + // A value set with its binding in the same call keeps the new binding. + run(&mut s, "node.set", json!({"ids": [r], "width": 5, "boundVariables": {"width": w}})); + assert_eq!((node(&s, r).width, node(&s, r).bound.get("width")), (48.0, Some(&w))); +} + +#[test] +fn a_gesture_or_a_text_style_on_a_bound_field_detaches_it() { + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "C"})); + let v = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "r", "type": "number", "value": 8}))); + let r = rect(&mut s, None); + run(&mut s, "variable.bind", json!({"ids": [r], "field": "cornerRadius", "variable": v})); + s.begin_interaction("Edit").unwrap(); + let props = serde_json::Map::from_iter([("cornerRadius".to_string(), json!(2))]); + s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap(); + s.commit_interaction().unwrap(); + assert_eq!(node(&s, r).corners.radii, [2.0; 4]); + assert!(node(&s, r).bound.is_empty()); + + let t = id(run(&mut s, "text.create", json!({"x": 0, "y": 0, "text": "Hi"}))); + run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v})); + run(&mut s, "text.style", json!({"id": t, "fontSize": 20})); + assert!(node(&s, t).bound.is_empty()); + assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 20.0); + + // The font size/weight/tracking shortcuts and text content edits detach too. + run(&mut s, "variable.bind", json!({"ids": [t], "field": "fontSize", "variable": v})); + assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 8.0); + run(&mut s, "text.sizeUp", json!({"ids": [t]})); + assert_eq!(node(&s, t).kind.text().unwrap().style.font_size, 9.0); + assert!(node(&s, t).bound.is_empty()); + let c = vid(run(&mut s, "variable.create", json!({"collection": "C", "name": "c", "type": "string", "value": "Hello"}))); + run(&mut s, "variable.bind", json!({"ids": [t], "field": "characters", "variable": c})); + run(&mut s, "text.set", json!({"id": t, "characters": "Bye"})); + assert_eq!(node(&s, t).kind.text().unwrap().characters, "Bye"); + assert!(node(&s, t).bound.is_empty()); +} diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 5c7305bb..3a57d9ea 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -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); +} diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index a7e8be38..eea0e8dc 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -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" @@ -1630,6 +1631,7 @@ status = "draft — needs native review" "{name} is forgotten on this computer; the file stays on {server}" = "Zapomenuto v tomto počítači: {name}; soubor zůstává na serveru {server}" "{name} is on the server." = "{name} je na serveru." "{name} is spotlighting" = "{name} sdílí pohled" +"{name} is too large to open in the browser (over 512 MB)." = "Soubor {name} je příliš velký na otevření v prohlížeči (více než 512 MB)." "{name}\nClick to apply to the selection" = "{name}\nKliknutím použijete na výběr" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nPřetáhněte na plátno nebo kliknutím umístěte doprostřed zobrazení.\nPři puštění na instanci podržte Alt (Option) a vyměňte ji." "{name} (from {library})" = "{name} (z {library})" @@ -1850,6 +1852,7 @@ status = "draft — needs native review" "file.exportLayers" = "Exportovat vrstvy" "file.exportSvg" = "Exportovat výběr jako SVG" "file.import" = "Importovat" +"file.moveTab" = "Přesunout kartu" "file.new" = "Nový soubor návrhu" "file.newFromTemplate" = "Nový ze šablony" "file.open" = "Otevřít" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index e910863b..4dc94a48 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -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" @@ -1584,6 +1585,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" @@ -1796,6 +1798,7 @@ status = "draft — needs native review" "file.exportLayers" = "Ebenen exportieren" "file.exportSvg" = "Auswahl als SVG exportieren" "file.import" = "Importieren" +"file.moveTab" = "Tab verschieben" "file.new" = "Neue Designdatei" "file.open" = "Öffnen" "file.openRecent" = "Zuletzt verwendete öffnen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 7220519d..6454cd39 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -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" @@ -1668,6 +1669,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})" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 63c73131..15e4e64d 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -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" @@ -1669,6 +1670,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})" @@ -1891,6 +1893,7 @@ status = "draft — needs native review" "file.exportLayers" = "Exportar capas" "file.exportSvg" = "Exportar selección como SVG" "file.import" = "Importar" +"file.moveTab" = "Mover pestaña" "file.new" = "Nuevo archivo de diseño" "file.newFromTemplate" = "Nuevo desde plantilla" "file.open" = "Abrir" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 6f12dd55..4858059f 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -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" @@ -1677,6 +1678,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" @@ -1892,6 +1894,7 @@ status = "draft — needs native review" "file.exportLayers" = "Vie tasot" "file.exportSvg" = "Vie valinta SVG-muodossa" "file.import" = "Tuo" +"file.moveTab" = "Siirrä välilehti" "file.new" = "Uusi suunnittelutiedosto" "file.newFromTemplate" = "Uusi mallista" "file.open" = "Avaa" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 4af37b1d..6facbb09 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -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" @@ -1666,6 +1667,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" @@ -1880,6 +1882,7 @@ status = "draft — needs native review" "file.exportLayers" = "Exporter les calques" "file.exportSvg" = "Exporter la sélection en SVG" "file.import" = "Importer" +"file.moveTab" = "Déplacer l’onglet" "file.new" = "Nouveau fichier de design" "file.newFromTemplate" = "Nouveau à partir d’un modèle" "file.open" = "Ouvrir" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 9c5aa4a6..f21e1ca3 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -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" @@ -1634,6 +1635,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})" @@ -1854,6 +1856,7 @@ status = "draft — needs native review" "file.exportLayers" = "Ekspor layer" "file.exportSvg" = "Ekspor pilihan sebagai SVG" "file.import" = "Impor" +"file.moveTab" = "Pindahkan tab" "file.new" = "File desain baru" "file.newFromTemplate" = "Baru dari templat" "file.open" = "Buka" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index b5dcaf27..46c242c4 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -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" = "作成" @@ -1651,6 +1652,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} · ブランチで削除" @@ -1865,6 +1867,7 @@ status = "draft — needs native review" "file.exportLayers" = "レイヤーを書き出し" "file.exportSvg" = "選択範囲を SVG で書き出し" "file.import" = "インポート" +"file.moveTab" = "タブを移動" "file.new" = "新規デザインファイル" "file.newFromTemplate" = "テンプレートから新規作成" "file.open" = "開く" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 70669778..6a185161 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -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" = "만들기" @@ -1575,6 +1576,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} · 브랜치에서 삭제됨" @@ -1787,6 +1789,7 @@ status = "draft — needs native review" "file.exportLayers" = "레이어 내보내기" "file.exportSvg" = "선택 항목을 SVG로 내보내기" "file.import" = "가져오기" +"file.moveTab" = "탭 이동" "file.new" = "새 디자인 파일" "file.open" = "열기" "file.openRecent" = "최근 파일 열기" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index d49e5f03..c744731e 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -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" @@ -1674,6 +1675,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" @@ -1891,6 +1893,7 @@ status = "draft — needs native review" "file.exportLayers" = "Lagen exporteren" "file.exportSvg" = "Selectie exporteren als SVG" "file.import" = "Importeren" +"file.moveTab" = "Tabblad verplaatsen" "file.new" = "Nieuw ontwerpbestand" "file.newFromTemplate" = "Nieuw vanuit sjabloon" "file.open" = "Openen" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 00ec38a9..0b235320 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -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" @@ -1574,6 +1575,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" @@ -1786,6 +1788,7 @@ status = "draft — needs native review" "file.exportLayers" = "Exportar camadas" "file.exportSvg" = "Exportar seleção como SVG" "file.import" = "Importar" +"file.moveTab" = "Mover aba" "file.new" = "Novo arquivo de design" "file.open" = "Abrir" "file.openRecent" = "Abrir recente" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index c72c3edb..6dd68a17 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -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" = "Сначала создайте коллекцию переменных" @@ -1636,6 +1637,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})" @@ -1856,6 +1858,7 @@ name = "Русский" "file.exportLayers" = "Экспортировать слои" "file.exportSvg" = "Экспортировать выделение в SVG" "file.import" = "Импорт" +"file.moveTab" = "Переместить вкладку" "file.new" = "Новый файл дизайна" "file.newFromTemplate" = "Создать из шаблона" "file.open" = "Открыть" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 7c317414..98327b2e 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -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" @@ -1651,6 +1652,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})" @@ -1873,6 +1875,7 @@ name = "Svenska" "file.exportLayers" = "Exportera lager" "file.exportSvg" = "Exportera som SVG" "file.import" = "Importera" +"file.moveTab" = "Flytta flik" "file.new" = "Nytt" "file.newFromTemplate" = "Nytt från mall" "file.open" = "Öppna" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index c9339e1f..bd8bb7c9 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -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" @@ -1634,6 +1635,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)" @@ -1854,6 +1856,7 @@ name = "Türkçe" "file.exportLayers" = "Katmanları dışa aktar" "file.exportSvg" = "Seçimi SVG olarak dışa aktar" "file.import" = "İçe aktar" +"file.moveTab" = "Sekmeyi taşı" "file.new" = "Yeni tasarım dosyası" "file.newFromTemplate" = "Şablondan yeni" "file.open" = "Aç" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index e726ca32..2fcaed5f 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -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" = "创建" @@ -1569,6 +1570,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})" @@ -1788,6 +1790,7 @@ status = "draft — needs native review" "file.exportLayers" = "导出图层" "file.exportSvg" = "将所选项导出为 SVG" "file.import" = "导入" +"file.moveTab" = "移动标签页" "file.new" = "新建设计文件" "file.open" = "打开" "file.openRecent" = "打开最近的文件" diff --git a/crates/ui-egui/src/async_dialog_tests.rs b/crates/ui-egui/src/async_dialog_tests.rs index 26808fe5..f38ac824 100644 --- a/crates/ui-egui/src/async_dialog_tests.rs +++ b/crates/ui-egui/src/async_dialog_tests.rs @@ -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); + +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 { + 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 { + 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#""#.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"); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9adaf8ab..cf9188c8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -509,21 +509,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. diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 236e2786..f945b27d 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -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 }); } } diff --git a/crates/ui-egui/src/home_tests.rs b/crates/ui-egui/src/home_tests.rs index c493a0a1..0606c2e5 100644 --- a/crates/ui-egui/src/home_tests.rs +++ b/crates/ui-egui/src/home_tests.rs @@ -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, 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#""#.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(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index fa4fb66e..3ef6b372 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -421,6 +421,7 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) { draw(&p, icon); } +/// Draw `icon` on the 16-unit grid through `p`, which maps grid units to the target box. fn draw(p: &P, icon: Icon) { use Icon::*; match icon { @@ -446,18 +447,23 @@ fn draw(p: &P, icon: Icon) { p.path(&h, false); } Frame => { - p.line(5.5, 2.0, 5.5, 14.0); - p.line(10.5, 2.0, 10.5, 14.0); - p.line(2.0, 5.5, 14.0, 5.5); - p.line(2.0, 10.5, 14.0, 10.5); + // Inset a unit from the box: four long hinted strokes read heavier than the + // anti-aliased outlines beside them at full size (#737). + p.line(5.5, 3.0, 5.5, 13.0); + p.line(10.5, 3.0, 10.5, 13.0); + p.line(3.0, 5.5, 13.0, 5.5); + p.line(3.0, 10.5, 13.0, 10.5); } Section | Section2 => { p.rect(2.5, 5.5, 13.5, 13.5, 1.0); p.fill_rect(2.0, 2.0, 9.0, 6.0, 1.0); } Slice => { - p.rect(2.5, 2.5, 13.5, 13.5, 0.0); - p.line(2.5, 13.5, 13.5, 2.5); + // A knife, one silhouette (#739): a slim blade to the top right, outlined like the + // rest of the row, and a solid handle whose end overlaps the blade's base so the two + // join at every size. + p.poly(&[(13.5, 2.5), (5.0, 8.0), (8.0, 11.0)], true); + p.fill(&capsule((2.8, 13.2), (5.9, 10.1), 1.3)); } Bend => { // A curve with a smooth point and its handle line. @@ -523,11 +529,12 @@ fn draw(p: &P, icon: Icon) { p.line(9.0, 4.5, 11.5, 7.0); } Text => { - p.line(2.0, 3.5, 13.0, 3.5); - p.line(7.5, 3.0, 7.5, 13.0); - p.line(2.5, 3.0, 2.5, 6.0); - p.line(12.5, 3.0, 12.5, 6.0); - p.line(5.0, 12.5, 10.0, 12.5); + // Inset like the Frame mark, so its long strokes don't outweigh the outlines (#737). + p.line(3.0, 3.5, 12.0, 3.5); + p.line(7.5, 3.0, 7.5, 12.5); + p.line(3.5, 3.0, 3.5, 5.5); + p.line(11.5, 3.0, 11.5, 5.5); + p.line(5.5, 12.5, 9.5, 12.5); } Comment => { let mut b: Vec<(f32, f32)> = arc_pts(8.5, 7.5, 5.0, 2.75, TAU + 1.95).collect(); @@ -1226,4 +1233,59 @@ mod tests { assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon"); assert_ne!(rotation, radius); } + + /// The shapes `icon` paints in a 32-point box at the origin: two points per grid unit. + fn shapes_at_32(icon: Icon) -> Vec { + let ctx = egui::Context::default(); + let mut out = + ctx.run_ui(egui::RawInput::default(), |ui| paint(ui.painter(), Rect::from_min_size(Pos2::ZERO, Vec2::splat(32.0)), icon, Color32::WHITE)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).collect() + } + + /// Frame and Text stay a grid unit inside the box, so their long hinted strokes don't + /// outweigh the outlines beside them in the toolbar (#737). + #[test] + fn frame_and_text_marks_are_inset() { + for icon in [Icon::Frame, Icon::Text] { + let pts: Vec = shapes_at_32(icon) + .iter() + .flat_map(|s| match s { + Shape::LineSegment { points, .. } => points.to_vec(), + other => panic!("{icon:?} draws straight strokes only: {other:?}"), + }) + .collect(); + assert!(!pts.is_empty()); + for p in pts { + // Grid units 3..13, two points each. + assert!((6.0..=26.0).contains(&p.x) && (6.0..=26.0).contains(&p.y), "{icon:?} reaches {p:?}"); + } + } + } + + /// Slice is one knife (#739): an outlined blade and a solid handle that covers the middle of + /// the blade's base, so the two read as one mark rather than separate pieces. + #[test] + fn slice_is_one_connected_knife() { + let shapes = shapes_at_32(Icon::Slice); + let paths: Vec<_> = shapes + .iter() + .map(|s| match s { + Shape::Path(p) => p, + other => panic!("Slice draws paths only: {other:?}"), + }) + .collect(); + let blade = paths.iter().find(|p| p.closed && p.fill == Color32::TRANSPARENT).expect("an outlined blade"); + let handle = paths.iter().find(|p| p.fill != Color32::TRANSPARENT).expect("a solid handle"); + assert_eq!((paths.len(), blade.points.len()), (2, 3), "a blade and a handle, nothing else"); + // The blade's base runs between its second and third points; the handle is convex. + let base = blade.points[1] + (blade.points[2] - blade.points[1]) / 2.0; + let h = &handle.points; + let side = |i: usize| { + let (a, b) = (h[i], h[(i + 1) % h.len()]); + (b - a).x * (base - a).y - (b - a).y * (base - a).x + }; + let inside = (0..h.len()).all(|i| side(i) >= 0.0) || (0..h.len()).all(|i| side(i) <= 0.0); + assert!(inside, "the handle covers the blade's base at {base:?}"); + } } diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index a2411582..8c10c7f3 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -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![]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 702a87d6..dfafccc6 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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 { + 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>>>; -type DialogDone = Box; +/// 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), 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, String>>>>; +type PickFuture = Pin>>>; +type DialogDone = Box; + +/// 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), + 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 { + 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, + future: PickFuture, + /// What was picked, kept until the window that holds `doc` draws. + picked: Option, done: DialogDone, /// The file it acts on (`None`: none, such as Open). doc: Option, @@ -785,6 +837,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); } @@ -1104,16 +1161,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) { @@ -1123,12 +1175,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) { @@ -1147,12 +1222,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(); } @@ -1162,12 +1253,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) { @@ -1183,12 +1274,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)) @@ -1698,17 +1789,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> { - 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> { - // 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, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 0d83024a..e81a7c49 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -112,6 +112,8 @@ 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 @@ -219,12 +221,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); } @@ -252,6 +255,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 { + let texts: Option> = 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 { let bytes = figcraft_engine::cmd::file::export_bytes(doc, ids, figcraft_doc::ExportFormat::Svg, 1.0).ok()?; String::from_utf8(bytes).ok() diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index 9d19b942..9575a1ea 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -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, + /// The file's contents still on their way; they go into `params` as `data` once read. + pub read: Option, } pub struct ImportTarget { @@ -66,23 +72,55 @@ fn run_pending(app: &mut FigApp, pending: PendingOpen) -> Option { /// 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 diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 381b09dd..c10edcfd 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -12,22 +12,93 @@ use crate::theme::{Tokens, regular}; use crate::{FigApp, TitleBar}; pub const HEIGHT: f32 = 40.0; +const MIN_TAB_WIDTH: f32 = 96.0; +const OVERFLOW_WIDTH: f32 = 32.0; +const OPEN_TIME: f64 = 0.18; + +/// Which tabs were open last frame and when the newly opened ones appeared. It is updated in place +/// in egui's memory; it only allocates when the set of tabs changes. +#[derive(Clone, Default)] +struct TabMotion { + initialized: bool, + known: Vec, + opening: Vec<(u64, f64)>, +} + +impl TabMotion { + /// Track tabs added since the previous frame and expire their animations. + fn begin_frame(&mut self, current: impl Iterator + Clone, now: f64, reduce_motion: bool) { + if !self.known.iter().copied().eq(current.clone()) { + if self.initialized && !reduce_motion { + for uid in current.clone().filter(|uid| !self.known.contains(uid)) { + self.opening.push((uid, now)); + } + } + self.known = current.collect(); + } + self.initialized = true; + if reduce_motion { + self.opening.clear(); + } else { + let known = &self.known; + self.opening.retain(|(uid, started)| known.contains(uid) && now - *started < OPEN_TIME); + } + } +} + +/// The entering tab's progress among `opening` (from [`TabMotion`]), or 1 when it is not animating. +fn open_progress(opening: &[(u64, f64)], uid: u64, now: f64) -> f32 { + opening.iter().find(|(u, _)| *u == uid).map_or(1.0, |(_, started)| ((now - started) / OPEN_TIME).clamp(0.0, 1.0) as f32) +} + +struct TabLayout { + first: usize, + widths: Vec, + overflow: bool, +} + +/// Shrink open tabs before moving the excess into the overflow menu. +fn tab_layout(preferred: &[f32], available: f32, active: Option) -> TabLayout { + let count = preferred.len(); + let overflow = count as f32 * MIN_TAB_WIDTH > available; + let visible = if overflow { ((available - OVERFLOW_WIDTH).max(0.0) / MIN_TAB_WIDTH).floor() as usize } else { count }.min(count); + let first = active.unwrap_or(0).saturating_sub(visible / 2).min(count - visible); + let mut widths = preferred[first..first + visible].to_vec(); + let budget = (available - if overflow { OVERFLOW_WIDTH } else { 0.0 }).max(0.0); + let mut excess = (widths.iter().sum::() - budget).max(0.0); + while excess > 0.01 { + let flexible = widths.iter().filter(|w| **w > MIN_TAB_WIDTH + 0.01).count(); + if flexible == 0 { + break; + } + let share = excess / flexible as f32; + let mut removed = 0.0; + for width in &mut widths { + let shrink = (*width - MIN_TAB_WIDTH).max(0.0).min(share); + *width -= shrink; + removed += shrink; + } + excess -= removed; + } + TabLayout { first, widths, overflow } +} + +/// Final document index and insertion marker for a tab dropped among the visible tabs. +fn tab_drop_position(visible: &[(usize, Rect)], from: usize, pointer_x: f32) -> Option<(usize, f32)> { + let others: Vec<_> = visible.iter().copied().filter(|(index, _)| *index != from).collect(); + let slot = others.partition_point(|(_, rect)| rect.center().x < pointer_x); + if let Some(&(index, rect)) = others.get(slot) { + Some((index - usize::from(from < index), rect.min.x)) + } else { + let &(index, rect) = others.last()?; + Some((index + usize::from(from > index), rect.max.x)) + } +} pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { let rect = ui.max_rect(); - // Empty space moves the window (first, so the tabs and buttons on top take their clicks). - if app.ui.title_bar != TitleBar::Native { - let drag = ui.interact(rect, ui.id().with("titlebar_drag"), Sense::click_and_drag()); - if drag.drag_started() { - ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); - } - if drag.double_clicked() { - let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); - ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); - } - } let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(app, ui, rect) } else { 0.0 }; let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 }; // Home. @@ -49,28 +120,70 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } x += 44.0; ui.painter().line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, t.border)); + let document_tabs: Vec = app.session.docs.iter().enumerate().filter_map(|(i, d)| app.shows(d.uid).then_some(i)).collect(); + let now = ui.input(|input| input.time); + let motion_id = ui.id().with("tab_motion"); + let current_uids = document_tabs.iter().filter_map(|&i| app.session.docs.get(i).map(|d| d.uid)); + let reduce_motion = app.reduce_motion(); + // Cloning `opening` is free while nothing animates (an empty Vec). + let opening = ui.ctx().data_mut(|data| { + let motion = data.get_temp_mut_or_default::(motion_id); + motion.begin_frame(current_uids, now, reduce_motion); + motion.opening.clone() + }); + let preferred: Vec = document_tabs + .iter() + .map(|&i| { + let d = &app.session.docs[i]; + let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 }; + let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 }; + (regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0) + }) + .collect(); + // Leave the new-tab button, theme toggle, window controls and a draggable gap free. + let present_w = if app.ui.present.is_some() || app.ui.paused_present.is_some() { 128.0 } else { 0.0 }; + let available = (rect.max.x - buttons_w - 70.0 - x - 34.0 - present_w).max(0.0); + let active_tab = document_tabs.iter().position(|&i| i == app.session.active); + let layout = tab_layout(&preferred, available, active_tab); + let mut tab_rects = Vec::with_capacity(layout.widths.len()); + let mut tab_x = x; + for &width in &layout.widths { + tab_rects.push(Rect::from_min_size(pos2(tab_x, rect.min.y), vec2(width, HEIGHT))); + tab_x += width; + } let mut switch = None; let mut close = None; // A tab dragged off the strip, or "Open in new window", moves to a window of its own. let mut detach = None; + let mut dragging = None; + let mut dropped = None; let several = !ui.ctx().embed_viewports(); - for (i, d) in app.session.docs.iter().enumerate().filter(|(_, d)| app.shows(d.uid)) { + for (slot, &w) in layout.widths.iter().enumerate() { + let i = document_tabs[layout.first + slot]; + let d = &app.session.docs[i]; let active = i == app.session.active && !app.ui.home && app.ui.present.is_none(); - let name = d.doc.name.clone(); + let name = &d.doc.name; // In a live session: a mark after the name says whether others get in. let live = live_mark(app, d.uid); let mark_w = if live.is_some() { 18.0 } else { 0.0 }; - // (A file in a session is rarely saved here: its unsaved dot gets room beside the mark.) let dirty = d.is_dirty(); - let dot_w = if dirty && live.is_some() { 12.0 } else { 0.0 }; - let w = (regular(12.0).size * 0.6 * name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0); - let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); - let resp = ui.interact(r, ui.id().with(("tab", d.uid)), if several { Sense::click_and_drag() } else { Sense::click() }); - if resp.drag_stopped() + let r = tab_rects[slot]; + let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); + let close_r = Rect::from_min_max(body_r.right_top(), r.max); + let resp = ui.interact(body_r, ui.id().with(("tab", d.uid)), Sense::click_and_drag()); + let cresp = ui.interact(close_r, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab")); + if (resp.dragged() || resp.drag_stopped()) && let Some(p) = ui.input(|inp| inp.pointer.latest_pos()) - && (p.y - rect.center().y).abs() > HEIGHT * 2.0 { - detach = Some((i, Some(p))); + if resp.drag_stopped() { + if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 { + detach = Some((i, Some(p))); + } else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) { + dropped = Some((i, p.x)); + } + } else { + dragging = Some((i, p)); + } } if several { resp.context_menu(|ui| { @@ -82,49 +195,52 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); } let label = match live { - Some(true) => trf("{name}, in a live session", &[("name", &name)]), - Some(false) => trf("{name}, online, open only to you", &[("name", &name)]), + Some(true) => trf("{name}, in a live session", &[("name", name)]), + Some(false) => trf("{name}, online, open only to you", &[("name", name)]), None => name.clone(), }; let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp }; crate::a11y::tab(&resp, &label, active); + let progress = open_progress(&opening, d.uid, now); + let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0)); + let painter = ui.painter().with_clip_rect(r); if active { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress)); + } else if resp.hovered() || cresp.hovered() { + painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress)); } icons::paint( - ui.painter(), - Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0)), + &painter, + Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)), Icon::Logo, - crate::theme::ACCENT, + crate::theme::ACCENT.linear_multiply(progress), ); // The unsaved-changes dot follows the name on screen, whichever way the name reads. let shown = crate::bidi::paint_name( - ui.painter(), - pos2(r.min.x + 32.0, r.center().y), - &name, + &painter, + pos2(paint_r.min.x + 32.0, paint_r.center().y), + name, regular(12.0), - t.text, - w - mark_w - if dirty { 72.0 } else { 60.0 }, + t.text.linear_multiply(progress), + (w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0), ); if let Some(shared) = live { - let at = Rect::from_center_size(pos2(r.max.x - 38.0, r.center().y), vec2(14.0, 14.0)); - icons::paint(ui.painter(), at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }); + let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0)); + icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress)); } if dirty { - ui.painter().text(pos2(shown.max.x + 4.0, r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text); + painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress)); } - let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); - if resp.hovered() || active { - let cresp = ui.interact(cr, ui.id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab")); + let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0)); + // The close target never overlaps the draggable body, including on inactive tabs. + if resp.hovered() || cresp.hovered() || active { if cresp.hovered() { - ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); - } - icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); - if cresp.clicked() { - close = Some(i); + painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress)); } + icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress)); + } + if cresp.clicked() { + close = Some(i); } if resp.clicked() && close.is_none() { switch = Some(i); @@ -135,35 +251,66 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { x += w; ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); } + let visible: Vec<(usize, Rect)> = tab_rects.iter().copied().enumerate().map(|(slot, r)| (document_tabs[layout.first + slot], r)).collect(); + if let Some((from, p)) = dragging + && (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) + && let Some((_, at)) = tab_drop_position(&visible, from, p.x) + { + ui.painter().line_segment([pos2(at, rect.min.y + 5.0), pos2(at, rect.max.y - 5.0)], Stroke::new(3.0, crate::theme::ACCENT)); + } + let reorder = dropped.and_then(|(from, at)| tab_drop_position(&visible, from, at).map(|(to, _)| (from, to))); + if layout.overflow { + let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT)); + let resp = ui.interact(r, ui.id().with("tab_overflow"), Sense::click()).tip(tr("Open files")); + crate::a11y::menu_button(&resp, tr("Open files"), crate::a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon); + crate::right::menu_popup(&resp).show(|ui| { + egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { + for &i in &document_tabs { + let Some(d) = app.session.docs.get(i) else { continue }; + let on = i == app.session.active && !app.ui.home && app.ui.present.is_none(); + let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() }; + if crate::right::menu_check(ui, on, &label, "") { + switch = Some(i); + ui.close(); + } + } + }); + }); + x += OVERFLOW_WIDTH; + } // Presentations live in their own tab. Switching back to the design keeps the player // state (current screen, navigation history and scroll position) for the return trip. if app.ui.present.is_some() || app.ui.paused_present.is_some() { let w = 128.0; let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); - let resp = ui.interact(r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); + let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); + let close_r = Rect::from_min_max(body_r.right_top(), r.max); + let resp = ui.interact(body_r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); + let close_resp = ui.interact(close_r, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); let active = app.ui.present.is_some(); crate::a11y::tab(&resp, tr("Present"), active); if active { ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() { + } else if resp.hovered() || close_resp.hovered() { ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); ui.painter().text(pos2(x + 34.0, r.center().y), Align2::LEFT_CENTER, tr("Present"), regular(12.0), t.text); - let cr = Rect::from_center_size(pos2(r.max.x - 16.0, r.center().y), vec2(18.0, 18.0)); - if resp.hovered() || active { - let close_resp = ui.interact(cr, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); + let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0)); + if resp.hovered() || close_resp.hovered() || active { if close_resp.hovered() { ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); } icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); - if close_resp.clicked() { - app.ui.present = None; - app.ui.paused_present = None; - app.ui.present_uid = None; - } else if resp.clicked() && !active { - resume_presentation(app); - } + } + if close_resp.clicked() { + app.ui.present = None; + app.ui.paused_present = None; + app.ui.present_uid = None; } else if resp.clicked() && !active { resume_presentation(app); } @@ -195,6 +342,18 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" }; app.run(cmd, json!({})); } + // Only the free space drags the window. A full-bar drag target steals tab drags. + if app.ui.title_bar != TitleBar::Native && x + 34.0 < theme_r.min.x { + let free = Rect::from_min_max(pos2(x + 34.0, rect.min.y), pos2(theme_r.min.x, rect.max.y)); + let drag = ui.interact(free, ui.id().with("titlebar_drag"), Sense::click_and_drag()); + if drag.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + } + if drag.double_clicked() { + let max = ui.input(|i| i.viewport().maximized.unwrap_or(false)); + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max)); + } + } if let Some(i) = switch { if let Some(p) = app.ui.present.take() { app.ui.paused_present = Some(p); @@ -209,6 +368,11 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { app.toast(e); } } + if let Some((from, to)) = reorder + && from != to + { + app.run("file.moveTab", json!({"from": from, "to": to})); + } if let Some(i) = close { // A branch tab's edits live on in its main file: closing it loses nothing. let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none()); @@ -230,8 +394,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { app.ui.present_uid = None; } } - let _ = Color32::TRANSPARENT; - let _ = Align2::LEFT_TOP; + if !opening.is_empty() { + ui.ctx().request_repaint(); + } }); } @@ -325,3 +490,53 @@ pub fn resize_edges(ctx: &egui::Context) { } }); } + +#[cfg(test)] +mod layout_tests { + use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, tab_drop_position, tab_layout}; + use egui::{Rect, pos2}; + + #[test] + fn tabs_shrink_to_fit_before_overflowing() { + let preferred = vec![120.0; 10]; + let layout = tab_layout(&preferred, 1100.0, Some(5)); + assert!(!layout.overflow); + assert_eq!(layout.first, 0); + assert_eq!(layout.widths.len(), preferred.len()); + assert!(layout.widths.iter().all(|&width| (width - 110.0).abs() < 0.01)); + } + + #[test] + fn excess_tabs_leave_room_for_overflow_and_keep_the_active_tab_visible() { + let preferred = vec![120.0; 24]; + let available = 1100.0; + let layout = tab_layout(&preferred, available, Some(23)); + assert!(layout.overflow); + assert!(layout.first <= 23 && 23 < layout.first + layout.widths.len()); + assert!(layout.widths.iter().all(|&width| width >= MIN_TAB_WIDTH)); + assert!(layout.widths.iter().sum::() + OVERFLOW_WIDTH <= available + 0.01); + } + + #[test] + fn drop_position_places_a_tab_between_its_neighbors() { + let visible = (0..4) + .map(|index| (index, Rect::from_min_max(pos2(index as f32 * 120.0, 0.0), pos2((index + 1) as f32 * 120.0, 40.0)))) + .collect::>(); + assert_eq!(tab_drop_position(&visible, 0, 330.0).map(|(to, _)| to), Some(2)); + assert_eq!(tab_drop_position(&visible, 3, 30.0).map(|(to, _)| to), Some(0)); + } + + #[test] + fn new_tabs_animate_unless_motion_is_reduced() { + let mut motion = TabMotion::default(); + motion.begin_frame([1].into_iter(), 0.0, false); + assert!(motion.opening.is_empty(), "the tabs open at start-up don't animate"); + motion.begin_frame([1, 2].into_iter(), 1.0, false); + assert_eq!(open_progress(&motion.opening, 2, 1.0), 0.0); + assert_eq!(open_progress(&motion.opening, 1, 1.0), 1.0); + motion.begin_frame([1, 2].into_iter(), 1.0 + 2.0 * OPEN_TIME, false); + assert!(motion.opening.is_empty(), "the animation ends"); + motion.begin_frame([1, 2, 3].into_iter(), 2.0, true); + assert!(motion.opening.is_empty(), "Reduce motion opens tabs at once"); + } +} diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs index e9224404..9f1916c1 100644 --- a/crates/ui-egui/src/titlebar_tests.rs +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -158,6 +158,81 @@ fn tabs_and_new_tab_keep_their_clicks() { assert!(opened, "the new-tab button took no click"); } +#[test] +fn many_tabs_do_not_cover_the_window_buttons() { + let mut u = Ui::new(TitleBar::Custom); + for _ in 0..24 { + u.app.session.execute("file.new", &json!({})).unwrap(); + } + u.frame(vec![]); + let before = u.app.session.docs.len(); + u.click(pos2(SIZE.x - 222.0, 20.0)); + assert_eq!(u.app.session.docs.len(), before + 1, "the new-tab button must stay reachable"); + assert!(u.click(window_button(0)).contains(&ViewportCommand::Minimized(true))); + assert!(u.click(window_button(1)).contains(&ViewportCommand::Maximized(true))); + u.click(window_button(2)); + assert!(u.frame(vec![]).contains(&ViewportCommand::Close)); +} + +#[test] +fn close_button_on_inactive_tab_closes_it_without_switching() { + let mut u = Ui::new(TitleBar::Custom); + u.app.session.execute("file.new", &json!({})).unwrap(); + u.app.session.execute("file.new", &json!({})).unwrap(); + let active_uid = u.app.session.docs[u.app.session.active].uid; + let inactive_uid = u.app.session.docs[1].uid; + let close_button = pos2(270.0, 20.0); + u.frame(vec![Event::PointerMoved(close_button)]); + u.click(close_button); + assert!( + !u.app.session.docs.iter().any(|doc| doc.uid == inactive_uid), + "docs={}, active={}, last_error={:?}, target_dirty={}", + u.app.session.docs.len(), + u.app.session.active, + u.app.ui.last_error, + u.app.session.docs.iter().find(|doc| doc.uid == inactive_uid).is_some_and(|doc| doc.is_dirty()) + ); + assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid); +} + +#[test] +fn closing_a_tab_before_the_active_one_keeps_the_active_file() { + let mut u = Ui::new(TitleBar::Custom); + for _ in 0..3 { + u.app.session.execute("file.new", &json!({})).unwrap(); + } + u.app.session.execute("doc.switch", &json!({"index": 2})).unwrap(); + let active_uid = u.app.session.docs[u.app.session.active].uid; + let closed_uid = u.app.session.docs[0].uid; + let close_button = pos2(156.0, 20.0); + u.frame(vec![Event::PointerMoved(close_button)]); + u.click(close_button); + assert!(!u.app.session.docs.iter().any(|doc| doc.uid == closed_uid)); + assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid); +} + +#[test] +fn dragging_a_tab_along_the_bar_reorders_it_without_detaching() { + let mut u = Ui::new(TitleBar::Custom); + for name in ["Two", "Three", "Four"] { + u.app.session.execute("file.new", &json!({"name": name})).unwrap(); + } + let moved_uid = u.app.session.docs[0].uid; + let active_uid = u.app.session.docs[u.app.session.active].uid; + u.frame(vec![]); + let commands = u.drag(pos2(100.0, 20.0), vec2(310.0, 0.0)); + assert!(!commands.contains(&ViewportCommand::StartDrag), "{commands:?}"); + assert_eq!( + u.app.session.docs[2].uid, + moved_uid, + "order={:?}, active={}, error={:?}", + u.app.session.docs.iter().map(|d| d.uid).collect::>(), + u.app.session.active, + u.app.ui.last_error + ); + assert_eq!(u.app.session.docs[u.app.session.active].uid, active_uid); +} + #[test] fn a_system_title_bar_leaves_the_window_alone() { let mut u = Ui::new(TitleBar::Native); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6da9badb..2f8c1972 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -11,6 +11,18 @@ use crate::i18n::{tr, tr_noop, trf}; use crate::icons::{self, Icon}; use crate::theme::{ACCENT, Tokens, regular}; +/// Every mark in the toolbar row is painted in a box this size, so they share one optical size +/// and one stroke width (#737, #738). +const GLYPH: f32 = 18.0; +/// The flyout carets: smaller than the marks, but on a box whose stroke rounds to the same width +/// as theirs at 1×, 1.5× and 2× (a 10-point box drew them one physical pixel thinner). +const CARET: f32 = 14.0; + +/// `GLYPH`'s box centred in `r`. +fn glyph_rect(r: Rect) -> Rect { + Rect::from_center_size(r.center(), vec2(GLYPH, GLYPH)) +} + struct Group { key: &'static str, /// The flyout button's name for screen readers. @@ -82,6 +94,8 @@ pub(crate) fn main_width() -> f32 { GROUPS.len() as f32 * (cell + gap) + gap * 10.0 + 2.0 * (cell + gap) + 16.0 + 4.0 * 34.0 + 24.0 } +/// The main toolbar pill: the tool groups with their flyouts, Resources and Actions, then the +/// Draw · Design · Prototype · Inspect mode switcher. fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { let (cell, gap) = (32.0, 4.0); let width = main_width(); @@ -121,7 +135,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { if cresp.hovered() { ui.painter().rect_filled(cr, CornerRadius::same(4), t.hover); } - icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(10.0, 10.0)), Icon::Chevron, t.icon_secondary); + icons::paint(ui.painter(), Rect::from_center_size(cr.center(), vec2(CARET, CARET)), Icon::Chevron, t.icon_secondary); if cresp.clicked() { app.ui.tool_flyout = if app.ui.tool_flyout == Some(g.key) { None } else { Some(g.key) }; } @@ -149,7 +163,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), r.shrink(7.0), icon, if actions_open { Color32::WHITE } else { t.icon }); + icons::paint(ui.painter(), glyph_rect(r), icon, if actions_open { Color32::WHITE } else { t.icon }); if resp.clicked() { if id == "actions" { crate::menus::toggle_palette(&mut app.ui); @@ -181,7 +195,7 @@ fn main_toolbar(app: &mut FigApp, ui: &mut Ui, canvas: Rect, t: &Tokens) { } else if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(6), t.hover); } - icons::paint(ui.painter(), r.shrink(9.0), *icon, if i == active { t.accent_text } else { t.icon_secondary }); + icons::paint(ui.painter(), glyph_rect(r), *icon, if i == active { t.accent_text } else { t.icon_secondary }); if resp.clicked() { match i { 0 => { @@ -379,7 +393,7 @@ fn tool_cell(ui: &mut Ui, t: &Tokens, r: Rect, id: impl egui::AsIdSalt, icon: Ic Color32::TRANSPARENT }; ui.painter().rect_filled(r, CornerRadius::same(5), bg); - icons::paint(ui.painter(), r.shrink(7.0), icon, if active { Color32::WHITE } else { t.icon }); + icons::paint(ui.painter(), glyph_rect(r), icon, if active { Color32::WHITE } else { t.icon }); resp } @@ -452,3 +466,52 @@ fn flyout_id(key: &str) -> egui::Id { pub fn pretty_shortcut(s: &str) -> String { figcraft_engine::keymap::display(s, crate::shortcuts::platform()) } + +#[cfg(test)] +mod tests { + use super::*; + use egui::Shape; + + /// The stroke widths `icon` paints with in a `size` box at `ppp` pixels per point. + fn widths(ppp: f32, icon: Icon, size: f32) -> Vec { + let ctx = egui::Context::default(); + let input = || { + let mut i = egui::RawInput::default(); + i.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(ppp); + i + }; + ctx.run_ui(input(), |_| {}).textures_delta.clear(); + let mut out = + ctx.run_ui(input(), |ui| icons::paint(ui.painter(), Rect::from_center_size(pos2(50.0, 50.0), vec2(size, size)), icon, Color32::WHITE)); + out.textures_delta.clear(); + assert_eq!(ctx.pixels_per_point(), ppp); + let mut w: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + Shape::LineSegment { stroke, .. } => Some(stroke.width), + Shape::Path(p) => Some(p.stroke.width), + Shape::Rect(r) => Some(r.stroke.width), + _ => None, + }) + .filter(|w| *w > 0.0) + .collect(); + w.dedup(); + w + } + + /// The flyout carets and the marks beside them draw with one stroke width (#737), and the + /// mode switcher's marks are the tool marks' size (#738). + #[test] + fn toolbar_marks_share_one_stroke_width() { + for ppp in [1.0, 1.5, 2.0] { + let mark = widths(ppp, Icon::Rectangle, GLYPH); + assert_eq!(mark.len(), 1, "one width at {ppp}×: {mark:?}"); + assert_eq!(widths(ppp, Icon::Chevron, CARET), mark, "caret at {ppp}×"); + assert_eq!(widths(ppp, Icon::Code, GLYPH), mark, "mode mark at {ppp}×"); + for icon in [Icon::Frame, Icon::Text] { + assert_eq!(widths(ppp, icon, GLYPH), mark, "{icon:?} at {ppp}×"); + } + } + } +} diff --git a/crates/ui-egui/src/web_clipboard_tests.rs b/crates/ui-egui/src/web_clipboard_tests.rs index 26389ff4..bda57373 100644 --- a/crates/ui-egui/src/web_clipboard_tests.rs +++ b/crates/ui-egui/src/web_clipboard_tests.rs @@ -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(" copy" right below, `style.renameFolder {folder, name}`. - **Variables**: `variable.bind` refuses a variable of a type the field doesn't take (a colour on `width`); scopes only filter pickers. `variable.delete` / `variable.deleteCollection` unbind the - layers using them (they keep their last value) and give aliases to them their values. + layers using them (they keep their last value) and give aliases to them their values. An + explicit edit that changes a bound field's value (`node.set`, `transform.resize`, + `object.opacityKeys`, `text.style`, `text.set`, the text size, weight and + tracking shortcuts, a canvas drag) detaches that field's variable; fields the edit + left as they were stay bound. `variable.addMode {from}` duplicates a mode, `variable.moveMode {collection, mode, to}` reorders (index 0 is the default mode, which layers on Auto follow). `variable.update {codeSyntax: {WEB?, iOS?, ANDROID?}}` sets code names (`""` clears); generated CSS uses the Web one.