diff --git a/Cargo.lock b/Cargo.lock index 24536ff9..413011ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1113,6 +1113,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + [[package]] name = "digest" version = "0.10.7" @@ -1645,6 +1651,7 @@ version = "0.11.2" dependencies = [ "base64 0.22.1", "craft-docpath", + "deunicode", "figcraft-collab", "figcraft-color", "figcraft-doc", @@ -1657,6 +1664,7 @@ dependencies = [ "figcraft-render", "figcraft-svg", "figcraft-text", + "figcraft-vectordrawable", "image", "log", "regex", @@ -1680,6 +1688,7 @@ dependencies = [ "ruzstd", "serde", "serde_json", + "sha1", "sha2", "zip 2.6.1", ] @@ -1856,10 +1865,25 @@ dependencies = [ "web-time", ] +[[package]] +name = "figcraft-vectordrawable" +version = "0.11.2" +dependencies = [ + "figcraft-color", + "figcraft-doc", + "figcraft-geom", + "figcraft-pathops", + "figcraft-render", + "figcraft-text", + "roxmltree 0.21.2", + "thiserror 2.0.21", +] + [[package]] name = "figcraft-web" version = "0.11.2" dependencies = [ + "base64 0.22.1", "eframe", "egui", "figcraft-collab-net", @@ -1867,6 +1891,7 @@ dependencies = [ "figcraft-ui-egui", "js-sys", "log", + "serde_json", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", diff --git a/Cargo.toml b/Cargo.toml index 3e1101e1..f6fca285 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ figcraft-pathops = { path = "crates/pathops" } figcraft-render = { path = "crates/render" } figcraft-svg = { path = "crates/svg" } figcraft-pdf = { path = "crates/pdf" } +figcraft-vectordrawable = { path = "crates/vectordrawable" } figcraft-format = { path = "crates/format" } figcraft-tools = { path = "crates/tools" } figcraft-collab = { path = "crates/collab" } @@ -68,11 +69,15 @@ skrifa = "0.47" harfrust = "0.13" unicode-bidi = "0.3" unicode-segmentation = "1.13" +# Code export: layer names transliterated into ASCII identifiers (pure Rust data tables, zero +# dependencies, BSD-3-Clause, no_std so the web build keeps it too). +deunicode = "1.6" linesweeper = "0.4" zip = { version = "2", default-features = false, features = ["deflate"] } # `.fig` import: raw deflate chunks (already built for zip) and zstd chunks (pure Rust, MIT, wasm-safe). flate2 = { version = "1", default-features = false, features = ["rust_backend"] } ruzstd = { version = "0.9.1", default-features = false, features = ["std"] } +sha1 = "0.11" sha2 = "0.10" base64 = "0.22" crc32fast = "1" diff --git a/README.md b/README.md index 2e91506a..e17cd74c 100644 --- a/README.md +++ b/README.md @@ -208,7 +208,7 @@ exposes to agents, and exported at 2x by FigCraft's own renderer. The source fil

Prototype and hand off

- Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. + Connections, triggers, actions and Smart Animate-style transitions, variables with conditionals (if / else if / else built in the interaction editor, expressions like count + 1), flows, a presentation view with device frames, hotspot hints and an Inspect panel, and a live preview pane. Alt to measure distances and padding on the canvas, Inspect mode with measurements, annotations in colour-coded categories, a ready-for-dev list with a focus view and Compare changes (before / after with property-level diffs since a frame was marked ready), and code (CSS, HTML, React, Vue, Leptos, SwiftUI, Compose) where components take their properties as parameters, comment threads pinned to frames (replies, @mentions, reactions, read state) and named version history. Export any layer at any scale to PNG, JPG, WebP, SVG or PDF. diff --git a/ROADMAP.md b/ROADMAP.md index afe0c6c8..74a617f3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (colour picker LCH and OKLCH models; branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-26** · Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` runs the name through `deunicode` before its ASCII filter, so 로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber` in the HTML/CSS/React/Leptos class names, component roots and `data-*` variant attributes (SwiftUI/Compose type names unchanged); a name left with nothing (symbols only) takes the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. New dependency: deunicode (pure Rust tables, zero deps, BSD-3-Clause, the `slug` crate's base). `html_codegen_tests`. Numbers unchanged. - **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. - **2026-10-24** · Boards (M12): board pages get a dot grid under their layers (drawn by the canvas render after the page colour, thinned out when zoomed out); a selected connector shows its ends as handles, and dragging one onto another object re-attaches it there, onto empty canvas leaves it free (`board.reattach`, one undo step, also over MCP); connectors can be curved, and elbows go around the objects they join when the short way would cross them. Checked in the app, through MCP and in the web build ([boards-parity](docs/boards-parity.md)). @@ -347,6 +348,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-26 | minor | Non-ASCII layer names transliterate in code export (M6, #1235): `class_name` transliterates through the new `deunicode` dependency (pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`) in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes; symbols-only names take the node's kind (`.text`) instead of `layer-`, and the 40-character cut lands on a word boundary. `html_codegen_tests` updated and extended. Numbers unchanged | | 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | Server memory (#1134): when the last person leaves a file nobody edited, figcraft-server keeps the client ids it issued in the log instead of rewriting the snapshot (compaction still follows `compact_after`/`compact_mb` and edits), and a snapshot is encoded straight into its file buffer, without a second copy of the body; the file format is unchanged. Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a dot grid on board pages under the layers, connector ends dragged onto other objects or empty canvas (`board.reattach`), curved connectors, elbows that go around what they join. Boards row ~35% → ~37%, ~20% → ~21%, 30–60 h → 28–56 h; headline numbers unchanged (boards are outside the weighted sum) | diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 0c04b732..740e885b 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -91,6 +91,15 @@ impl Args { Ok(figcraft_doc::PrintSettings { bleed, crop_marks: self.flag("crop-marks"), bleed_marks: self.flag("bleed-marks"), cmyk: self.flag("cmyk") }) } + /// `--vd-compat bestEffort|exact|ignore` (Android Vector Drawable exports; default bestEffort). + pub fn vd_compat(&self) -> CliResult { + match self.value("vd-compat") { + None => Ok(figcraft_doc::VdCompat::default()), + Some(s) => figcraft_doc::VdCompat::from_key(&s) + .ok_or_else(|| CliError::Usage(format!("--vd-compat takes bestEffort, exact or ignore, got `{s}`"))), + } + } + pub fn scale(&self) -> CliResult { match self.value("scale") { None => Ok(1.0), diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index f746becd..1ba9d60a 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -4,7 +4,7 @@ use std::ffi::OsStr; use std::path::Path; use std::process::ExitCode; -use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings}; +use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings, VdCompat}; use figcraft_engine::{BatchScope, BatchStep, Session}; use serde_json::{Value, json}; @@ -25,7 +25,7 @@ fn ext(path: &str) -> String { fn image_format(e: &str) -> Option { match e { - "png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" => ExportFormat::from_ext(e), + "png" | "jpg" | "jpeg" | "svg" | "webp" | "pdf" | "xml" => ExportFormat::from_ext(e), _ => None, } } @@ -225,6 +225,8 @@ pub struct Target { pub node: Option, /// PDF only. pub print: PrintSettings, + /// Android Vector Drawable (XML) only. + pub vd_compat: VdCompat, } pub fn export(file: &str, out: &str, t: &Target) -> CliResult { @@ -239,15 +241,20 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { let (scale, node, print) = (t.scale, t.node.as_deref(), &t.print); let fmt = match t.format.as_deref() { Some(f) => { - image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|fig)")))? + image_format(&f.to_ascii_lowercase()).ok_or_else(|| CliError::Usage(format!("unknown --format `{f}` (png|jpg|svg|webp|pdf|xml|fig)")))? } None => image_format(&ext(out)).unwrap_or(ExportFormat::Png), }; let target = node.map(|q| find_node(&doc, page, q)).transpose()?; let single = image_format(&ext(out)).is_some(); let bytes_of = |ids: &[NodeId]| { - let setting = ExportSetting { format: fmt, print: *print, ..Default::default() }; - figcraft_engine::cmd::file::export_bytes_with(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string())) + let setting = ExportSetting { format: fmt, print: *print, vd_compat: t.vd_compat, ..Default::default() }; + let (bytes, warnings) = figcraft_engine::cmd::file::export_file(&doc, ids, &setting, scale).map_err(|e| CliError::Failed(e.to_string()))?; + // What an Android Vector Drawable changed or left out, like the .fig notes. + for w in warnings { + eprintln!("note: {w}"); + } + Ok::<_, CliError>(bytes) }; if single { let bytes = match target { diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 8c7881c6..971b3905 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -17,8 +17,8 @@ pub const USAGE: &str = "figcraft-cli — FigCraft without a window USAGE: figcraft-cli info [--json] - figcraft-cli export -o [--format png|jpg|svg|webp|pdf|fig] [--scale N] [--page NAME] [--node ID|NAME] - [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] + figcraft-cli export -o [--format png|jpg|svg|webp|pdf|xml|fig] [--scale N] [--page NAME] [--node ID|NAME] + [--bleed LENGTH] [--crop-marks] [--bleed-marks] [--cmyk] [--vd-compat bestEffort|exact|ignore] figcraft-cli convert figcraft-cli render -o [--scale N] [--page NAME] figcraft-cli run [--in ] [--out ] @@ -144,12 +144,18 @@ fn run(argv: &[String]) -> CliResult { confined(&a, || files::info(&file, a.flag("json"))) } "export" => { - let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat(); + let valued = [&["format", "scale", "page", "node", "o", "out", "bleed", "vd-compat"][..], &ROOT_FLAGS[..]].concat(); let a = parse_args(&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()? }; + let target = files::Target { + format: a.value("format"), + scale: a.scale()?, + page: a.value("page"), + node: a.value("node"), + print: a.print()?, + vd_compat: a.vd_compat()?, + }; confined(&a, || files::export(&file, &out, &target)) } "convert" => { diff --git a/apps/figcraft-cli/tests/it/main.rs b/apps/figcraft-cli/tests/it/main.rs index ae4b0488..2e628773 100644 --- a/apps/figcraft-cli/tests/it/main.rs +++ b/apps/figcraft-cli/tests/it/main.rs @@ -10,3 +10,5 @@ mod fonts; mod pdf; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod pdf_print; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod vector_drawable; diff --git a/apps/figcraft-cli/tests/it/vector_drawable.rs b/apps/figcraft-cli/tests/it/vector_drawable.rs new file mode 100644 index 00000000..b99c32cd --- /dev/null +++ b/apps/figcraft-cli/tests/it/vector_drawable.rs @@ -0,0 +1,41 @@ +//! `figcraft-cli export … -o out.xml` (Android Vector Drawable) end to end. + +use std::process::Command; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_figcraft-cli")) +} + +#[test] +fn xml_export_writes_a_drawable_and_prints_its_notes() { + let d = std::env::temp_dir().join(format!("figcraft-cli-vd-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + let script = d.join("s.jsonl"); + std::fs::write( + &script, + r##"{"command":"shape.rectangle","params":{"x":0,"y":0,"width":24,"height":24,"name":"Dot","props":{"fill":"#ff0000","effects":[{"type":"dropShadow"}]}}}"##, + ) + .unwrap(); + let file = d.join("doc.figcraft"); + let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + + let xml = d.join("icon.xml"); + let out = bin().arg("export").arg(&file).arg("-o").arg(&xml).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let text = std::fs::read_to_string(&xml).unwrap(); + assert!(text.contains("` in another app) uses the same commands as +//! the CLI and the MCP server: +//! +//! ```js +//! await figcraft.execute("node.create", {kind: "frame", name: "Home"}) // engine command → its result +//! await figcraft.request("document.inspect") // control method → its result +//! await figcraft.commands() // command list +//! const {base64} = await figcraft.screenshot() // the canvas as PNG (base64) +//! await figcraft.openFile("home.fig", bytes) // `file.open` with the bytes +//! figcraft.captureDownloads(true) // saves stay in the page… +//! figcraft.files(); await figcraft.readFile("home.figcraft") // …to be read back +//! ``` +//! +//! Results resolve with the method's `result`; errors reject with an `Error`. + +use std::cell::{Cell, RefCell}; +use std::sync::mpsc::{Receiver, TryRecvError}; + +use base64::Engine as _; +use figcraft_ui_egui::{ControlReply, ControlRequest}; +use serde_json::{Value, json}; +use wasm_bindgen::prelude::*; + +use crate::saved_files::SavedFiles; + +type Waiting = (Receiver, js_sys::Function, js_sys::Function); + +thread_local! { + static QUEUE: RefCell> = const { RefCell::new(Vec::new()) }; + static WAITING: RefCell> = const { RefCell::new(Vec::new()) }; + static NEXT_ID: Cell = const { Cell::new(0) }; + static CTX: RefCell> = const { RefCell::new(None) }; + static CAPTURE: Cell = const { Cell::new(false) }; + static SAVED: RefCell = RefCell::new(SavedFiles::default()); +} + +pub fn connect(ctx: egui::Context) { + CTX.with(|c| *c.borrow_mut() = Some(ctx)); +} + +/// The page's requests since the last frame, for the app's control queue. +pub fn take_requests() -> Vec { + QUEUE.with(|q| std::mem::take(&mut *q.borrow_mut())) +} + +/// Keep a save instead of downloading it, when the page asked for that. True when kept. +pub fn capture(name: &str, bytes: &[u8]) -> bool { + if !CAPTURE.with(Cell::get) { + return false; + } + SAVED.with(|s| s.borrow_mut().keep(name, bytes)); + true +} + +fn to_js(v: &Value) -> JsValue { + js_sys::JSON::parse(&v.to_string()).unwrap_or(JsValue::NULL) +} + +fn from_js(v: &JsValue) -> Value { + if v.is_undefined() || v.is_null() { + return json!({}); + } + js_sys::JSON::stringify(v).ok().and_then(|s| s.as_string()).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(json!({})) +} + +/// Settle the promises of answered requests (called every frame). +pub fn poll_replies() { + WAITING.with(|w| { + w.borrow_mut().retain(|(rx, resolve, reject)| match rx.try_recv() { + Ok(ControlReply { result: Ok(v), .. }) => { + let _ = resolve.call1(&JsValue::NULL, &to_js(&v)); + false + } + Ok(ControlReply { result: Err(e), .. }) => { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new(&e)); + false + } + Err(TryRecvError::Empty) => true, + Err(TryRecvError::Disconnected) => { + let _ = reject.call1(&JsValue::NULL, &js_sys::Error::new("request dropped")); + false + } + }); + }); +} + +fn request(method: &str, params: Value) -> js_sys::Promise { + let method = method.to_string(); + let p = js_sys::Promise::new(&mut |resolve, reject| { + let (reply, rx) = std::sync::mpsc::channel(); + let id = NEXT_ID.with(|n| { + n.set(n.get() + 1); + n.get() + }); + let req = ControlRequest { id: json!(id), method: method.clone(), params: params.clone(), reply, state: Default::default() }; + QUEUE.with(|q| q.borrow_mut().push(req)); + WAITING.with(|w| w.borrow_mut().push((rx, resolve, reject))); + }); + CTX.with(|c| { + if let Some(ctx) = c.borrow().as_ref() { + ctx.request_repaint(); + } + }); + p +} + +fn set_fn(obj: &js_sys::Object, name: &str, f: JsValue) -> Result<(), JsValue> { + js_sys::Reflect::set(obj, &name.into(), &f).map(|_| ()) +} + +/// Install `window.figcraft`. +pub fn install() -> Result<(), JsValue> { + let obj = js_sys::Object::new(); + set_fn( + &obj, + "request", + Closure:: js_sys::Promise>::new(|method: String, params: JsValue| request(&method, from_js(¶ms))) + .into_js_value(), + )?; + set_fn( + &obj, + "execute", + Closure:: js_sys::Promise>::new(|command: String, params: JsValue| { + request("engine.execute", json!({"command": command, "params": from_js(¶ms)})) + }) + .into_js_value(), + )?; + set_fn(&obj, "commands", Closure:: js_sys::Promise>::new(|| request("engine.commands", json!({}))).into_js_value())?; + set_fn(&obj, "inspect", Closure:: js_sys::Promise>::new(|| request("document.inspect", json!({}))).into_js_value())?; + set_fn( + &obj, + "screenshot", + Closure:: js_sys::Promise>::new(|params: JsValue| request("ui.screenshot", from_js(¶ms))).into_js_value(), + )?; + set_fn( + &obj, + "openFile", + Closure:: js_sys::Promise>::new(|name: String, bytes: js_sys::Uint8Array| { + let data = base64::engine::general_purpose::STANDARD.encode(bytes.to_vec()); + request("engine.execute", json!({"command": "file.open", "params": {"name": name, "data": data}})) + }) + .into_js_value(), + )?; + set_fn(&obj, "captureDownloads", Closure::::new(|on: bool| CAPTURE.with(|c| c.set(on))).into_js_value())?; + set_fn( + &obj, + "files", + Closure:: JsValue>::new(|| { + SAVED.with(|s| to_js(&Value::Array(s.borrow().list().map(|(n, size)| json!({"path": n, "size": size})).collect()))) + }) + .into_js_value(), + )?; + set_fn( + &obj, + "readFile", + Closure:: Result>::new(|name: String| { + SAVED.with(|s| { + s.borrow() + .get(&name) + .map(|b| js_sys::Uint8Array::from(b).into()) + .ok_or_else(|| js_sys::Error::new(&format!("{name}: not found")).into()) + }) + }) + .into_js_value(), + )?; + let w = web_sys::window().ok_or("no window")?; + js_sys::Reflect::set(&w, &"figcraft".into(), &obj)?; + Ok(()) +} diff --git a/apps/figcraft-web/src/main.rs b/apps/figcraft-web/src/main.rs index 7eed2ffb..063a501e 100644 --- a/apps/figcraft-web/src/main.rs +++ b/apps/figcraft-web/src/main.rs @@ -13,17 +13,22 @@ //! (browsers block `ws://` from secure pages); serve it over `http://` on a trusted network to //! join from other machines. //! -//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU. +//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?control` installs +//! `window.figcraft`, the page's control channel (`api.rs`), which is off without it. //! //! URL fragment: `#join=` joins that live session once the page has //! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last. //! The link carries the session's secret; a fragment is never sent to the web host, but the address //! bar and the browser history keep it, so the URL is as private as the link itself. +#[cfg(target_arch = "wasm32")] +mod api; #[cfg(any(target_arch = "wasm32", test))] mod query; #[cfg(test)] mod query_tests; +#[cfg(any(target_arch = "wasm32", test))] +mod saved_files; #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/figcraft-web/src/query.rs b/apps/figcraft-web/src/query.rs index befeb36b..88a03b2b 100644 --- a/apps/figcraft-web/src/query.rs +++ b/apps/figcraft-web/src/query.rs @@ -1,4 +1,4 @@ -//! The page's URL: the invite link its fragment names. +//! The page's URL: the invite link its fragment names, and the flags its query sets. /// The invite link in `#join=` (`fragment` is `location.hash`, with or without its `#`), still /// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out @@ -13,3 +13,10 @@ pub fn join_link(fragment: &str) -> Option<&str> { let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 }; fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX) } + +/// Whether `search` (`location.search`, with or without its `?`) has a `control` entry (`?control`, +/// or `?control=…` with any value): the page's control channel, `window.figcraft`, is installed only +/// when it is asked for. +pub fn control(search: &str) -> bool { + search.strip_prefix('?').unwrap_or(search).split('&').any(|entry| entry.split('=').next() == Some("control")) +} diff --git a/apps/figcraft-web/src/query_tests.rs b/apps/figcraft-web/src/query_tests.rs index 261ebaeb..6113c0ca 100644 --- a/apps/figcraft-web/src/query_tests.rs +++ b/apps/figcraft-web/src/query_tests.rs @@ -1,6 +1,6 @@ -//! `#join=` in the page's URL. +//! `#join=` and `?control` in the page's URL. -use crate::query::join_link; +use crate::query::{control, join_link}; #[test] fn join_takes_the_rest_of_the_fragment() { @@ -27,3 +27,14 @@ fn an_overlong_link_is_ignored() { assert_eq!(join_link(&long), None); assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes"); } + +#[test] +fn control_is_opt_in() { + assert!(control("?control")); + assert!(control("?webgl&control=1"), "after other entries, with a value"); + assert!(control("control"), "no leading ?"); + assert!(!control("")); + assert!(!control("?")); + assert!(!control("?webgl")); + assert!(!control("?nocontrol&controls"), "only the whole entry name"); +} diff --git a/apps/figcraft-web/src/saved_files.rs b/apps/figcraft-web/src/saved_files.rs new file mode 100644 index 00000000..7cd88637 --- /dev/null +++ b/apps/figcraft-web/src/saved_files.rs @@ -0,0 +1,70 @@ +//! What the page keeps of the app's saves (`api.rs`): `readFile` reads them back. + +/// Saved files kept (the oldest go first). +pub const MAX_SAVED: usize = 16; + +/// The last component of a `/` or `\` path. +fn file_name(path: &str) -> &str { + path.rsplit(['/', '\\']).next().filter(|n| !n.is_empty()).unwrap_or(path) +} + +/// Files by name, newest last: saving a name again replaces it. +#[derive(Default)] +pub struct SavedFiles(Vec<(String, Vec)>); + +impl SavedFiles { + /// Keep `bytes` under the last component of `path`. + pub fn keep(&mut self, path: &str, bytes: &[u8]) { + let name = file_name(path).to_string(); + self.0.retain(|(n, _)| *n != name); + self.0.push((name, bytes.to_vec())); + let extra = self.0.len().saturating_sub(MAX_SAVED); + self.0.drain(..extra); + } + + /// A kept file by its name, or by a path ending in it. + pub fn get(&self, name: &str) -> Option<&[u8]> { + let wanted = file_name(name); + self.0.iter().find(|(n, _)| n == wanted).map(|(_, b)| b.as_slice()) + } + + /// `(name, size)` of each kept file, oldest first. + pub fn list(&self) -> impl Iterator { + self.0.iter().map(|(n, b)| (n.as_str(), b.len())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keeps_by_name_replaces_and_reads_back_by_path() { + let mut s = SavedFiles::default(); + s.keep("exports/poster.png", b"one"); + s.keep(r"C:\out\poster.png", b"two"); + s.keep("notes.txt", b"three"); + assert_eq!(s.list().collect::>(), vec![("poster.png", 3), ("notes.txt", 5)]); + assert_eq!(s.get("poster.png"), Some(&b"two"[..])); + assert_eq!(s.get("/exports/poster.png"), Some(&b"two"[..])); + assert_eq!(s.get("missing.png"), None); + } + + #[test] + fn drops_the_oldest_past_the_cap() { + let mut s = SavedFiles::default(); + for i in 0..MAX_SAVED + 3 { + s.keep(&format!("f{i}.png"), &[0]); + } + assert_eq!(s.list().count(), MAX_SAVED); + assert_eq!(s.get("f0.png"), None); + assert!(s.get(&format!("f{}.png", MAX_SAVED + 2)).is_some()); + } + + #[test] + fn a_trailing_separator_keeps_the_whole_name() { + let mut s = SavedFiles::default(); + s.keep("odd/", b"x"); + assert_eq!(s.get("odd/"), Some(&b"x"[..])); + } +} diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 40451d43..de17cc36 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -47,6 +47,13 @@ pub fn start() { let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string()); figcraft_ui_egui::live::open_invite(&mut fig, &link); } + // `?control`: `window.figcraft`, the page's control channel (crate::api), opt-in. + if crate::query::control(&query()) { + crate::api::connect(cc.egui_ctx.clone()); + if let Err(e) = crate::api::install() { + log::error!("couldn't install window.figcraft: {e:?}"); + } + } Ok(Box::new(WebShell { fig })) }), ) @@ -79,7 +86,9 @@ impl eframe::App for WebShell { } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + self.fig.control_queue.extend(crate::api::take_requests()); self.fig.ui(ui); + crate::api::poll_replies(); } } @@ -109,6 +118,9 @@ impl Services for WebServices { } /// A Blob behind a temporary `` link, clicked and removed. fn download(&mut self, name: &str, bytes: &[u8]) -> bool { + if crate::api::capture(name, bytes) { + return true; + } match download(name, bytes) { Ok(()) => true, Err(e) => { diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index c2202919..1471c179 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -956,11 +956,18 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit // Sessions and the operator may use the last connections; one-shot requests // (hosting, libraries, browsing plugins) leave them free. let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_)); + // A publish uploads a whole file; a large one on a slow link takes the server a + // while to receive and answer. The manage/hosting connections that carry uploads + // wait the longer request-reply timeout, not the short request idle. + let idle = match first { + ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout, + ClientMsg::Hello(_) => limits.idle_timeout, + _ => limits.request_idle, + }; let max = match &first { ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request, _ => limits.max_client_message, }; - let idle = if session { limits.idle_timeout } else { limits.request_idle }; let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions); // (In flight is only taken when the connection goes on: the hub gives it back.) if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { diff --git a/crates/doc/src/colors_tests.rs b/crates/doc/src/colors_tests.rs index 5cadfbbe..ebf65959 100644 --- a/crates/doc/src/colors_tests.rs +++ b/crates/doc/src/colors_tests.rs @@ -28,6 +28,7 @@ fn colourful() -> (Document, NodeId) { close_on_outside: false, background: Some(RED), offset: None, + anchor: Default::default(), }], transition: Default::default(), duration_ms: 300, diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 80eada04..caf0c861 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1270,6 +1270,8 @@ mod proto_expr_parse_tests; #[cfg(test)] mod proto_expr_tests; #[cfg(test)] +mod proto_overlay_anchor_tests; +#[cfg(test)] mod tests; #[cfg(test)] mod vector_regions_tests; diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index f6187589..0d4361b2 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1233,11 +1233,14 @@ pub enum ExportFormat { Svg, Pdf, Webp, + /// Android Vector Drawable (an `.xml` drawable resource). + VectorDrawable, } impl ExportFormat { /// Every format, in the order the export menus list them. - pub const ALL: [ExportFormat; 5] = [ExportFormat::Png, ExportFormat::Jpg, ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Webp]; + pub const ALL: [ExportFormat; 6] = + [ExportFormat::Png, ExportFormat::Jpg, ExportFormat::Svg, ExportFormat::Pdf, ExportFormat::Webp, ExportFormat::VectorDrawable]; /// Pixel formats (their size depends on the export scale). pub fn is_raster(self) -> bool { @@ -1252,9 +1255,15 @@ impl ExportFormat { ExportFormat::Svg => "SVG", ExportFormat::Pdf => "PDF", ExportFormat::Webp => "WebP", + ExportFormat::VectorDrawable => "XML", } } + /// Formats whose output is vector (one file whatever the scale). + pub fn is_vector(self) -> bool { + matches!(self, ExportFormat::Svg | ExportFormat::Pdf | ExportFormat::VectorDrawable) + } + pub fn ext(self) -> &'static str { match self { ExportFormat::Png => "png", @@ -1262,6 +1271,7 @@ impl ExportFormat { ExportFormat::Svg => "svg", ExportFormat::Pdf => "pdf", ExportFormat::Webp => "webp", + ExportFormat::VectorDrawable => "xml", } } pub fn from_ext(s: &str) -> Option { @@ -1271,6 +1281,7 @@ impl ExportFormat { "svg" => Some(ExportFormat::Svg), "pdf" => Some(ExportFormat::Pdf), "webp" => Some(ExportFormat::Webp), + "xml" | "vd" | "vectordrawable" => Some(ExportFormat::VectorDrawable), _ => None, } } @@ -1439,6 +1450,44 @@ pub struct ExportSetting { /// SVG: strokes as `stroke` attributes where that's exact (on), else as outlines. #[serde(skip_serializing_if = "is_true")] pub svg_simplify_stroke: bool, + /// Android Vector Drawable: what to do with what a drawable can't hold exactly. + #[serde(skip_serializing_if = "VdCompat::is_default")] + pub vd_compat: VdCompat, +} + +/// How an Android Vector Drawable export handles what the format can't draw exactly (layer +/// opacity over overlapping shapes, even-odd fills, diamond gradients, alpha masks). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum VdCompat { + /// The closest drawing, with a warning for each approximation. + #[default] + BestEffort, + /// Never approximate: what can't be drawn exactly is left out, with a warning. + Exact, + /// Write what maps, leave out the rest, no warnings. + Ignore, +} + +impl VdCompat { + pub const ALL: [VdCompat; 3] = [VdCompat::BestEffort, VdCompat::Exact, VdCompat::Ignore]; + + fn is_default(&self) -> bool { + *self == VdCompat::BestEffort + } + + /// The name commands, files and the CLI use ("bestEffort"). + pub fn key(self) -> &'static str { + match self { + VdCompat::BestEffort => "bestEffort", + VdCompat::Exact => "exact", + VdCompat::Ignore => "ignore", + } + } + + pub fn from_key(s: &str) -> Option { + Self::ALL.into_iter().find(|c| c.key() == s) + } } impl Default for ExportSetting { @@ -1453,6 +1502,7 @@ impl Default for ExportSetting { svg_outline_text: true, svg_id_attribute: false, svg_simplify_stroke: true, + vd_compat: VdCompat::BestEffort, } } } diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index 61b3628e..eaa1ccef 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -507,6 +507,47 @@ impl OverlayPosition { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum OverlayAnchor { + #[default] + Screen, + TriggerInside, + TriggerOutside, +} + +impl OverlayAnchor { + /// The default (the screen places the overlay), which files and JSON leave out. + pub fn is_screen(&self) -> bool { + *self == OverlayAnchor::Screen + } + + /// Gives None for Screen and for Manual. + pub fn offset(self, position: OverlayPosition, trigger: [f64; 2], overlay: [f64; 2]) -> Option<[f64; 2]> { + use OverlayPosition as P; + let ([tw, th], [ow, oh]) = (trigger, overlay); + let outside = match self { + OverlayAnchor::Screen => return None, + OverlayAnchor::TriggerInside => false, + OverlayAnchor::TriggerOutside => true, + }; + let x = match position { + P::TopLeft | P::BottomLeft => 0.0, + P::Center | P::TopCenter | P::BottomCenter => (tw - ow) / 2.0, + P::TopRight | P::BottomRight => tw - ow, + P::Manual => return None, + }; + let y = match position { + P::TopLeft | P::TopCenter | P::TopRight if outside => -oh, + P::TopLeft | P::TopCenter | P::TopRight => 0.0, + P::BottomLeft | P::BottomCenter | P::BottomRight if outside => th, + P::BottomLeft | P::BottomCenter | P::BottomRight => th - oh, + P::Center | P::Manual => (th - oh) / 2.0, + }; + Some([x, y]) + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "camelCase")] pub enum Action { @@ -538,6 +579,9 @@ pub enum Action { /// opened it (x, y). None = right below that layer. #[serde(default, skip_serializing_if = "Option::is_none")] offset: Option<[f64; 2]>, + /// The incumbent has no such option. The .fig export writes it as a manual offset. + #[serde(default, skip_serializing_if = "OverlayAnchor::is_screen")] + anchor: OverlayAnchor, }, SwapOverlay { dest: NodeId, diff --git a/crates/doc/src/proto_overlay_anchor_tests.rs b/crates/doc/src/proto_overlay_anchor_tests.rs new file mode 100644 index 00000000..d902d1c2 --- /dev/null +++ b/crates/doc/src/proto_overlay_anchor_tests.rs @@ -0,0 +1,61 @@ +use serde_json::json; + +use crate::NodeId; +use crate::proto::*; + +const TRIGGER: [f64; 2] = [100.0, 40.0]; +const OVERLAY: [f64; 2] = [60.0, 200.0]; + +fn at(anchor: OverlayAnchor, p: OverlayPosition) -> Option<[f64; 2]> { + anchor.offset(p, TRIGGER, OVERLAY) +} + +#[test] +fn outside_sits_above_or_below_with_edges_lined_up() { + use OverlayPosition as P; + let out = OverlayAnchor::TriggerOutside; + assert_eq!(at(out, P::BottomLeft), Some([0.0, 40.0])); + assert_eq!(at(out, P::BottomCenter), Some([20.0, 40.0])); + assert_eq!(at(out, P::BottomRight), Some([40.0, 40.0])); + assert_eq!(at(out, P::TopLeft), Some([0.0, -200.0])); + assert_eq!(at(out, P::TopCenter), Some([20.0, -200.0])); + assert_eq!(at(out, P::TopRight), Some([40.0, -200.0])); + assert_eq!(at(out, P::Center), Some([20.0, -80.0])); +} + +#[test] +fn inside_uses_the_presets_on_the_trigger() { + use OverlayPosition as P; + let ins = OverlayAnchor::TriggerInside; + assert_eq!(at(ins, P::TopLeft), Some([0.0, 0.0])); + assert_eq!(at(ins, P::TopRight), Some([40.0, 0.0])); + assert_eq!(at(ins, P::BottomLeft), Some([0.0, -160.0])); + assert_eq!(at(ins, P::BottomCenter), Some([20.0, -160.0])); + assert_eq!(at(ins, P::Center), Some([20.0, -80.0])); +} + +#[test] +fn the_screen_and_manual_positions_have_no_preset_offset() { + assert_eq!(at(OverlayAnchor::Screen, OverlayPosition::BottomLeft), None); + assert_eq!(at(OverlayAnchor::TriggerOutside, OverlayPosition::Manual), None); + assert_eq!(at(OverlayAnchor::TriggerInside, OverlayPosition::Manual), None); +} + +#[test] +fn json_keeps_the_anchor_and_old_actions_stay_on_the_screen() { + let a = Action::OpenOverlay { + dest: NodeId(7), + position: OverlayPosition::BottomLeft, + close_on_outside: true, + background: None, + offset: None, + anchor: OverlayAnchor::TriggerOutside, + }; + let v = serde_json::to_value(&a).unwrap(); + assert_eq!(v["anchor"], "triggerOutside"); + assert_eq!(serde_json::from_value::(v).unwrap(), a); + + let old: Action = serde_json::from_value(json!({"type": "openOverlay", "dest": 7, "position": "bottomLeft"})).unwrap(); + assert!(matches!(old, Action::OpenOverlay { anchor: OverlayAnchor::Screen, .. })); + assert!(serde_json::to_value(&old).unwrap().get("anchor").is_none()); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 24d7e088..a000447f 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -120,23 +120,40 @@ impl TextCase { } /// Apply the case to `s`, which follows `prev` in the text (so title case only capitalises - /// the first letter of each word even when a word spans several styled runs). + /// the first letter of each word even when a word spans several styled runs). An apostrophe + /// as `prev` is taken to continue a word. pub fn apply_after(self, s: &str, prev: Option) -> String { + self.apply_impl(s, prev, prev.is_some_and(is_word_char)) + } + + /// Like [`apply_after`](Self::apply_after), also given the char `before` `prev`: an apostrophe + /// only continues a word when a letter or digit precedes it. + pub fn apply_between(self, s: &str, before: Option, prev: Option) -> String { + let continues = prev.is_some_and(|c| is_word_char(c) && (c.is_alphanumeric() || before.is_some_and(char::is_alphanumeric))); + self.apply_impl(s, prev, continues) + } + + /// `in_word` says whether `prev` continues a word (rather than a new word starting here). + fn apply_impl(self, s: &str, prev: Option, in_word: bool) -> String { match self { TextCase::Original | TextCase::SmallCaps => s.to_string(), TextCase::Upper => s.to_uppercase(), TextCase::Lower => s.to_lowercase(), TextCase::Title => { let mut out = String::with_capacity(s.len()); - let mut start = prev.is_none_or(|c| !is_word_char(c)); + let mut start = !in_word; + let mut last = prev; for ch in s.chars() { if start && ch.is_alphanumeric() { out.extend(ch.to_uppercase()); start = false; } else { out.push(ch); - start = !is_word_char(ch); + // An apostrophe only continues a word right after a letter or digit. + let opening_apostrophe = matches!(ch, '\'' | '\u{2019}') && !last.is_some_and(char::is_alphanumeric); + start = !is_word_char(ch) || opening_apostrophe; } + last = Some(ch); } out } @@ -417,7 +434,8 @@ pub struct TextData { /// line's baseline). #[serde(skip_serializing_if = "crate::node::is_default")] pub leading_trim: LeadingTrim, - /// Max lines when truncating (0 = as many as fit). + /// Max lines when truncating, or before an auto-height text ends in an ellipsis (0 = as many + /// as fit). #[serde(skip_serializing_if = "crate::node::is_default")] pub max_lines: u32, /// OpenType feature toggles (e.g. "liga": false). @@ -510,6 +528,28 @@ impl TextData { self.characters.chars().count() } + /// Whether text that runs out of room ends in an ellipsis: a truncating box, or auto height + /// with a line limit (the box hugs up to `max_lines` lines). + pub fn truncates(&self) -> bool { + match self.auto_resize { + TextAutoResize::Truncate => true, + TextAutoResize::Height => self.max_lines > 0, + TextAutoResize::WidthAndHeight | TextAutoResize::None => false, + } + } + + /// The resize mode once the box is given an explicit `width` and/or `height`: an explicit + /// height fixes the box, an explicit width turns auto width into auto height, and truncating + /// text keeps truncating. + pub fn fix_size(&mut self, width: bool, height: bool) { + self.auto_resize = match self.auto_resize { + _ if height && self.truncates() => TextAutoResize::Truncate, + _ if height => TextAutoResize::None, + TextAutoResize::WidthAndHeight if width => TextAutoResize::Height, + mode => mode, + }; + } + /// The style at char index `i`. pub fn style_at(&self, i: usize) -> &TextStyle { self.runs.iter().find(|r| r.start <= i && i < r.end).map(|r| &r.style).unwrap_or(&self.style) @@ -857,3 +897,24 @@ mod case_tests { assert_eq!(t.para_list(0), ParaList::default()); } } + +#[cfg(test)] +mod title_case_tests { + use super::TextCase; + + #[test] + fn title_case_after_opening_apostrophe() { + assert_eq!(TextCase::Title.apply("'hello world'"), "'Hello World'"); + assert_eq!(TextCase::Title.apply("say 'hi'"), "Say 'Hi'"); + assert_eq!(TextCase::Title.apply("don't"), "Don't"); + assert_eq!(TextCase::Title.apply("\u{2019}hello"), "\u{2019}Hello"); + } + + #[test] + fn title_case_between_runs_sees_char_before_apostrophe() { + assert_eq!(TextCase::Title.apply_after("t", Some('\'')), "t"); + assert_eq!(TextCase::Title.apply_between("hello", None, Some('\'')), "Hello"); + assert_eq!(TextCase::Title.apply_between("t", Some('n'), Some('\'')), "t"); + assert_eq!(TextCase::Title.apply_between("t", Some(' '), Some('\u{2019}')), "T"); + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index ebd07a82..beedc173 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -20,6 +20,7 @@ figcraft-render = { workspace = true } figcraft-format = { workspace = true } figcraft-svg = { workspace = true } figcraft-pdf = { workspace = true } +figcraft-vectordrawable = { workspace = true } craft-docpath = { workspace = true } figcraft-plugin = { workspace = true } serde = { workspace = true } @@ -28,6 +29,7 @@ thiserror = { workspace = true } log = { workspace = true } base64 = { workspace = true } regex = { workspace = true } +deunicode = { workspace = true } same-file = { workspace = true } web-time = "1.1.0" diff --git a/crates/engine/src/batch_rollback_tests.rs b/crates/engine/src/batch_rollback_tests.rs index 12d958cd..298ca097 100644 --- a/crates/engine/src/batch_rollback_tests.rs +++ b/crates/engine/src/batch_rollback_tests.rs @@ -34,6 +34,31 @@ fn failed_group_closes_documents_opened_inside_it() { assert_eq!(s.journal, before_journal); } +#[test] +fn failed_group_reopens_documents_closed_inside_it() { + let mut s = Session::new(); + s.execute("file.new", &json!({"name": "Second"})).unwrap(); + let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect(); + assert_eq!((s.docs.len(), s.active), (2, 1)); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "file.close", "params": {"index": 1, "force": true}}, + {"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.docs.len(), 2); + assert_eq!(s.active, 1); + assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::>(), uids); + assert!(s.ds().is_ok()); + assert!(s.closed_in_group.is_empty()); +} + #[test] fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() { let mut s = Session::new(); diff --git a/crates/engine/src/branches.rs b/crates/engine/src/branches.rs index a4589180..4c9faa4a 100644 --- a/crates/engine/src/branches.rs +++ b/crates/engine/src/branches.rs @@ -194,7 +194,7 @@ pub fn tab_of(s: &Session, main: u64, id: &str) -> Option { /// branch's). Their edits are already in the branches ([`sync`]). pub(crate) fn close_tabs(s: &mut Session, main: u64, id: Option<&str>) { let active = s.docs.get(s.active).map(|d| d.uid); - s.docs.retain(|d| !d.branch_of.as_ref().is_some_and(|(m, b)| *m == main && id.is_none_or(|i| i == b))); + s.remove_tabs(|d| d.branch_of.as_ref().is_some_and(|(m, b)| *m == main && id.is_none_or(|i| i == b))); s.active = active.and_then(|a| s.docs.iter().position(|d| d.uid == a)).unwrap_or(s.active).min(s.docs.len().saturating_sub(1)); } diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 0180504c..8f8e40d2 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -562,7 +562,11 @@ pub(crate) fn resize_cmd(s: &mut Session, p: &Value) -> Result { n.child_layout.sizing_v = Sizing::Fixed; } if let NodeKind::Text(t) = &mut n.kind { - t.auto_resize = if num(p, "height").is_some() { TextAutoResize::None } else { TextAutoResize::Height }; + if num(p, "height").is_some() { + t.fix_size(num(p, "width").is_some(), true); + } else if t.auto_resize != TextAutoResize::Truncate { + t.auto_resize = TextAutoResize::Height; + } } super::props::restore_worlds(doc, &kept)?; } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index bb7e3501..60077efd 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -317,7 +317,7 @@ pub fn specs() -> Vec { spec!( "export.add", "Add export setting", - "{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp, bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)", + "{ids?, setting? {size: 2x|512w|512h|{by: scale|width|height, value}, suffix?, format?: png|jpg|svg|pdf|webp|xml (Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}} (without a setting: 1x, then one scale step up with an \"@2x\"-style suffix)", true, |s, p| { let cmd = "export.add"; @@ -335,7 +335,7 @@ pub fn specs() -> Vec { spec!( "export.update", "Edit export setting", - "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}", + "{ids?, index, size? (2x|512w|512h|{by, value}), suffix?, format? (png|jpg|svg|pdf|webp|xml: Android Vector Drawable), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), vdCompat? (XML: bestEffort|exact|ignore)}", true, |s, p| { let cmd = "export.update"; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 2379026b..13eb21d6 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -56,7 +56,7 @@ pub fn specs() -> Vec { spec!( "file.save", "Save", - "{path?} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + "{path?, overwrite? (replace what another app wrote to the file since it was opened or saved: without it that save is refused)} (programmatic saves need a path the first time); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", false, save ), @@ -77,14 +77,14 @@ pub fn specs() -> Vec { spec!( "file.export", "Export", - "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp, or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options)}", + "{ids? (default selection, else the page), format? (png|jpg|svg|pdf|webp|xml (an Android Vector Drawable, with `warnings` for what it changed or left out), or fig: the whole file as .fig, experimental, with `warnings` for what was left out; other apps may not open every file), scale? (default 1) | size? (2x|512w|512h or {by: scale|width|height, value}: a scale, or a width or height in pixels), path? (else returns base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options), svgIdAttribute? (SVG ids, XML android:name), vdCompat? (XML: bestEffort (default: approximate with a warning)|exact (leave out what can't be exact, with a warning)|ignore (no warnings))}", false, export ), spec!( "file.exportLayers", "Export layers", - "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}", + "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own, vdCompat? (bestEffort|exact|ignore: for every XML instead of each setting's own)} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data, warnings (XML only)}], skipped (layers without settings), data? (zip)}", false, export_layers ), @@ -159,6 +159,8 @@ pub fn specs() -> Vec { }), spec!("file.revert", "Revert to saved", "{} leave the live session first, if any", false, |s, _| { let path = s.ds()?.path.clone().ok_or_else(|| EngineError::Disabled("file.revert".into(), "the file was never saved".into()))?; + // Stamped before reading: a change made during the read makes the next save refuse. + let on_disk = crate::library::stat(&path).ok(); let (loaded, _) = load_path(&path, None)?; let i = s.active; let uid = s.ds()?.uid; @@ -167,6 +169,7 @@ pub fn specs() -> Vec { .with_versions(loaded.versions) .with_branches(loaded.branches) .with_images_left_in_file(&path, loaded.images_past_budget); + slot.on_disk = on_disk; } // Edits on its branch tabs are reverted too. crate::branches::close_tabs(s, uid, None); @@ -945,6 +948,8 @@ fn open(s: &mut Session, p: &Value) -> Result { remember(s, &path); return Ok(json!({"index": i})); } + // Stamped before reading: a change made during the read makes the next save refuse. + let on_disk = crate::library::stat(&path).ok(); let (loaded, rest) = load_path(&path, variables.as_ref())?; remember(s, &path); let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json"); @@ -958,12 +963,12 @@ fn open(s: &mut Session, p: &Value) -> Result { } let summary = import_summary(&loaded.doc); let images_left_out = if native { loaded.images_past_budget } else { 0 }; - s.docs.push( - DocState::new(loaded.doc, native.then(|| path.clone())) - .with_versions(loaded.versions) - .with_branches(loaded.branches) - .with_images_left_in_file(&path, images_left_out), - ); + let mut ds = DocState::new(loaded.doc, native.then(|| path.clone())) + .with_versions(loaded.versions) + .with_branches(loaded.branches) + .with_images_left_in_file(&path, images_left_out); + ds.on_disk = on_disk.filter(|_| native); + s.docs.push(ds); s.active = s.docs.len() - 1; let mut out = json!({"index": s.active, "summary": summary}); out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); @@ -1053,7 +1058,9 @@ impl FileSnapshot { }; let path = self.path.as_deref().ok_or(EngineError::NoDocument)?; refuse_dropping_images(ds, path)?; + refuse_overwriting_changes(ds, path)?; write(path, bytes)?; + ds.note_on_disk(); // The history or branches may have changed since: then they still count as unsaved. if ds.revision == self.revision { ds.versions_changed = false; @@ -1075,6 +1082,17 @@ fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> { } } +/// Saving over the tab's own file would lose what another app wrote to it since (#1330). +fn refuse_overwriting_changes(ds: &DocState, path: &str) -> Result<()> { + match (&ds.on_disk, crate::library::stat(path)) { + (Some(was), Ok(now)) if now != *was => Err(EngineError::Other(format!( + "{} wasn't saved: the file changed on disk since it was opened or saved here (another app or figcraft-cli wrote to it), and saving would replace those changes. Revert to saved to load them, or Save as to keep this version as well.", + file_stem(path) + ))), + _ => Ok(()), + } +} + /// The branch tabs of the file in tab `uid` were saved with it. fn branch_tabs_saved(s: &mut Session, uid: u64) { for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { @@ -1137,6 +1155,7 @@ fn save_to(s: &mut Session, path: Option) -> Result { write(&path, &bytes)?; ds.doc = snapshot.doc; ds.path = Some(path.clone()); + ds.note_on_disk(); mark_saved(s, &json!({}))?; remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) @@ -1157,13 +1176,19 @@ fn save(s: &mut Session, p: &Value) -> Result { if let Some(path) = string(p, "path") { return save_to(s, Some(path)); } - match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { + let ds = s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?; + match ds.path.clone() { // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. Some(path) if ext(&path) == "fig" => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) } - Some(path) => save_to(s, Some(path)), + Some(path) => { + if p.get("overwrite").and_then(Value::as_bool) != Some(true) { + refuse_overwriting_changes(ds, &path)?; + } + save_to(s, Some(path)) + } None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) @@ -1246,14 +1271,20 @@ fn slice_container(doc: &Document, slice: NodeId) -> Option { } /// [`export_bytes`] with an export setting's format and options (its size is `scale`: see -/// [`export_scale`]): contents only, include bounding box, the SVG options and PDF print settings. +/// [`export_scale`]): contents only, include bounding box, the SVG, PDF and Vector Drawable options. pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result> { + export_file(doc, ids, setting, scale).map(|(bytes, _)| bytes) +} + +/// [`export_bytes_with`] and what the export changed or left out (Android Vector Drawable only; +/// empty for the other formats). +pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result<(Vec, Vec)> { let nothing = || EngineError::Disabled("file.export".into(), "nothing to export".into()); if ids.is_empty() { return Err(nothing()); } let drawn = drawn_layers(doc, ids, setting); - if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) { + if setting.format.is_vector() { figcraft_render::check_export_depth(doc, &drawn).map_err(|e| EngineError::Other(e.to_string()))?; } let area = export_area(doc, ids, setting).ok_or_else(nothing)?; @@ -1270,22 +1301,37 @@ pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting simplify_stroke: setting.svg_simplify_stroke, bounds: Some(area), }; - figcraft_svg::try_export_svg(&doc.in_srgb(), &drawn, &opts).map(String::into_bytes).map_err(|e| match e { + let svg = figcraft_svg::try_export_svg(&doc.in_srgb(), &drawn, &opts).map_err(|e| match e { figcraft_svg::SvgError::Empty => nothing(), other => EngineError::Other(other.to_string()), - }) + })?; + Ok((svg.into_bytes(), Vec::new())) + } + // Android colours are sRGB too. + ExportFormat::VectorDrawable => { + let compat = match setting.vd_compat { + VdCompat::BestEffort => figcraft_vectordrawable::Compat::BestEffort, + VdCompat::Exact => figcraft_vectordrawable::Compat::Exact, + VdCompat::Ignore => figcraft_vectordrawable::Compat::Ignore, + }; + let opts = figcraft_vectordrawable::VdOptions { bounds: Some(area), include_names: setting.svg_id_attribute, compat }; + let out = figcraft_vectordrawable::try_export(&doc.in_srgb(), &drawn, &opts).map_err(|e| match e { + figcraft_vectordrawable::VdError::Empty => nothing(), + other => EngineError::Other(other.to_string()), + })?; + Ok((out.xml.into_bytes(), out.warnings)) } ExportFormat::Pdf => { let doc = &*doc.in_srgb(); // Frames export one page each; anything else shares one page. let all_frames = own_area && ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())); let groups: Vec> = if all_frames { ids.iter().map(|i| vec![*i]).collect() } else { vec![drawn] }; - pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area)) + Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new())) } _ => { let pix = figcraft_render::render_area(doc, &drawn, area, scale) .ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?; - figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other) + Ok((figcraft_render::encode_export(&pix, setting.format, doc.color_profile).map_err(EngineError::Other)?, Vec::new())) } } } @@ -1322,14 +1368,19 @@ fn export(s: &mut Session, p: &Value) -> Result { None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE), }; let format = setting.format; - let bytes = export_bytes_with(doc, &ids, &setting, scale)?; - match string(p, "path") { + let (bytes, warnings) = export_file(doc, &ids, &setting, scale)?; + let mut out = match string(p, "path") { Some(path) => { write(&path, &bytes)?; - Ok(json!({"path": path, "bytes": bytes.len(), "format": format.ext()})) + json!({"path": path, "bytes": bytes.len(), "format": format.ext()}) } - None => Ok(json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})), + None => json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}), + }; + // Android Vector Drawables say what they changed or left out (an empty list when nothing). + if format == ExportFormat::VectorDrawable { + out["warnings"] = json!(warnings); } + Ok(out) } /// The whole document as a `.fig` file (experimental: only round trips through our own importer @@ -1355,7 +1406,7 @@ pub fn fig_bytes(doc: &Document) -> Result<(Vec, Vec)> { pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 { // Size-based exports ask for bounds before encoding. Let encoding report the depth error // without entering a recursive bounds walk here; SVG/PDF do not use this raster scale. - if matches!(setting.format, ExportFormat::Svg | ExportFormat::Pdf) && figcraft_render::check_export_depth(doc, ids).is_err() { + if setting.format.is_vector() && figcraft_render::check_export_depth(doc, ids).is_err() { return 1.0; } export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height())) @@ -1370,7 +1421,10 @@ pub(super) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Resu e.suffix = s.chars().filter(|c| !c.is_control()).take(200).collect(); } if let Some(f) = string(p, "format") { - e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp"))?; + e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp|xml"))?; + } + if let Some(c) = vd_compat_param(cmd, p)? { + e.vd_compat = c; } e.print = print_param(cmd, p, e.print)?; for (key, slot) in [ @@ -1388,6 +1442,14 @@ pub(super) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Resu Ok(e) } +/// The `vdCompat` `p` passes: bestEffort, exact or ignore (anything else is an error). +fn vd_compat_param(cmd: &str, p: &Value) -> Result> { + match get(p, "vdCompat") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_str().and_then(VdCompat::from_key).map(Some).ok_or_else(|| bad(cmd, "vdCompat: bestEffort|exact|ignore")), + } +} + /// Whether `p` sets any PDF print option. fn has_print_param(p: &Value) -> bool { ["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null())) @@ -1523,12 +1585,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { let mut members = Vec::new(); // Print options passed here stand in for every PDF setting's own. let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; + // And a compatibility mode for every Android Vector Drawable. + let compat = vd_compat_param(cmd, p)?; let mut files = Vec::with_capacity(jobs.len()); for job in &jobs { - let setting = ExportSetting { print: print.unwrap_or(job.setting.print), ..job.setting.clone() }; + let setting = + ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() }; let scale = export_scale(doc, &[job.id], &setting); - let bytes = export_bytes_with(doc, &[job.id], &setting, scale)?; + let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?; let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()}); + if setting.format == ExportFormat::VectorDrawable { + f["warnings"] = json!(warnings); + } match &dir { Some(d) => { let path = std::path::Path::new(d).join(&job.file); @@ -1731,7 +1799,7 @@ fn close(s: &mut Session, p: &Value) -> Result { } let uid = ds.uid; let active_uid = s.docs.get(s.active).map(|d| d.uid); - s.docs.remove(i); + s.remove_tabs(|d| d.uid == uid); // 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); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 9ee499fc..30a10393 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -461,11 +461,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map TextAutoResize::WidthAndHeight, (_, Sizing::Hug) => TextAutoResize::Height, + _ if t.truncates() => TextAutoResize::Truncate, _ => TextAutoResize::None, }; } diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index 23e24aac..ce7219f4 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { spec!( "proto.interaction.add", "Add interaction", - "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}", + "{id?, trigger? {type: onClick|onHover|onPress|onDrag|mouseEnter|mouseLeave|mouseDown|mouseUp|afterDelay {ms}|keyPress {key (a key or a combination: Shift+K, Ctrl+Alt+Delete; or a controller button: Gamepad A|B|X|Y|LB|RB|LT|RT|View|Menu|LS|RS|Up|Down|Left|Right|Home)}}, action {type: navigate {preserveScroll?}|back|scrollTo {offset? [x, y] (where the layer ends up from the top-left)}|openOverlay {position?, anchor? screen|triggerInside|triggerOutside (default screen; the trigger ones measure the position preset on the layer that opens the overlay: outside puts top presets above it and bottom presets below it, edges lined up), offset? [x, y] (position manual: the overlay's top-left from the top-left of the layer that opens it; without it, right below that layer), close_on_outside? (default true), background?}|swapOverlay|closeOverlay|openUrl {url}|changeTo|setVariable {variable, value | expression (text, e.g. \"count + 1\")}|setVariableMode {collection, mode}|conditional {blocks [{condition? | expression? (text, e.g. \"count >= 3\"), actions}]}, dest?}, actions? [..], transition? {type: instant|dissolve|smartAnimate|moveIn|moveOut|push|slideIn|slideOut, direction?: left|right|top|bottom}, durationMs?, easing? linear|easeIn|easeOut|easeInOut|easeInBack|easeOutBack|easeInOutBack|gentle|quick|bouncy|slow|{customBezier {x1, y1, x2, y2}}|{customSpring {mass, stiffness, damping}}}, delayMs? (mouse enter, leave, down and up: how long after the gesture the actions run, up to 600000)} — or {id, dest} for a click-navigate. Returns {index}", true, add ), diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 813a6036..3822c5c5 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -291,15 +291,21 @@ pub fn specs() -> Vec { let v = finite(req_num("text.paragraphSpacing", p, "spacing")?).clamp(0.0, 10_000.0); para(s, p, |t| t.paragraph_spacing = v) }), - spec!("text.maxLines", "Max lines", "{lines (0 = no limit; truncates), ids?}", true, |s, p| { - let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32; - para(s, p, |t| { - t.max_lines = v; - if v > 0 { - t.auto_resize = TextAutoResize::Truncate; - } - }) - }), + spec!( + "text.maxLines", + "Max lines", + "{lines (0 = no limit; truncates: auto-height text hugs up to that many lines, other text becomes a truncating box), ids?}", + true, + |s, p| { + let v = finite(req_num("text.maxLines", p, "lines")?).clamp(0.0, 10_000.0) as u32; + para(s, p, |t| { + t.max_lines = v; + if v > 0 && t.auto_resize != TextAutoResize::Height { + t.auto_resize = TextAutoResize::Truncate; + } + }) + } + ), spec!( "text.setFeatures", "OpenType features", diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index eee312c2..6bd5bb40 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -1,6 +1,6 @@ //! Variables import and export: a file's collections as the local variables JSON (the shape the -//! REST importer reads, so exports round-trip) or as CSS custom properties, and such JSON read -//! back into a file. +//! REST importer reads, so exports round-trip), as CSS custom properties or as Dart constants for +//! Flutter, and such JSON read back into a file. use std::collections::{BTreeMap, BTreeSet}; @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "variable.export", "Export variables", - "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written", + "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file) | dart: a Dart file for Flutter, a class of constants per collection and mode (Color, double, String, bool), aliases resolved), path? (write it there)} → {text}, or {path} when written", false, export ), @@ -48,7 +48,8 @@ fn export(s: &mut Session, p: &Value) -> Result { .map_err(|e| EngineError::Other(e.to_string()))?, "css" => css(doc, &colls, false), "tailwind" => css(doc, &colls, true), - _ => return Err(bad(cmd, "format: json|css|tailwind")), + "dart" => dart(&doc.variables, &colls), + _ => return Err(bad(cmd, "format: json|css|tailwind|dart")), }; match string(p, "path") { Some(path) => { @@ -108,6 +109,143 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { out } +/// Words Dart never takes as a name. +const DART_RESERVED: &[&str] = &[ + "assert", "await", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", "enum", "extends", "false", "final", + "finally", "for", "if", "in", "is", "new", "null", "rethrow", "return", "super", "switch", "this", "throw", "true", "try", "var", "void", + "while", "with", "yield", +]; + +/// Dart's built-in identifiers (no type may take them), and the types the constants use or name, +/// which a class or constant of that name would shadow. +const DART_BUILT_IN: &[&str] = &[ + "abstract", + "as", + "bool", + "Color", + "covariant", + "deferred", + "double", + "dynamic", + "export", + "extension", + "external", + "factory", + "Function", + "get", + "implements", + "import", + "int", + "interface", + "late", + "library", + "List", + "mixin", + "num", + "Object", + "operator", + "part", + "required", + "set", + "static", + "String", + "typedef", +]; + +/// `name` as a Dart identifier: its words (anything but ASCII letters and digits separates them) +/// in camelCase, or PascalCase for a type; never empty, never starting with a digit or reserved. +fn dart_ident(name: &str, pascal: bool) -> String { + let mut out = String::new(); + for w in name.split(|c: char| !c.is_ascii_alphanumeric()).filter(|w| !w.is_empty()) { + let mut cs = w.chars(); + let Some(first) = cs.next() else { continue }; + if out.is_empty() && !pascal { + out.push(first.to_ascii_lowercase()); + } else { + out.push(first.to_ascii_uppercase()); + } + out.extend(cs); + } + if out.is_empty() || out.starts_with(|c: char| c.is_ascii_digit()) { + out.insert(0, if pascal { 'V' } else { 'v' }); + } + if DART_RESERVED.contains(&out.as_str()) || DART_BUILT_IN.contains(&out.as_str()) { + out.push('_'); + } + out +} + +/// `base`, or `base2`, `base3`… when `taken` has it already; the result is taken. +fn dart_unique(base: String, taken: &mut BTreeSet) -> String { + let mut name = base.clone(); + let mut n = 2; + while !taken.insert(name.clone()) { + name = format!("{base}{n}"); + n += 1; + } + name +} + +/// `text` inside a Dart single-quoted string. +fn dart_string(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '\\' => out.push_str("\\\\"), + '\'' => out.push_str("\\'"), + '$' => out.push_str("\\$"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + c if c.is_control() => out.push_str(&format!("\\u{{{:x}}}", u32::from(c))), + c => out.push(c), + } + } + out +} + +/// `x` at full precision as a Dart literal: whole numbers as integers (`4`), the rest in Rust's +/// shortest round-trip form (`0.125`, `1e21`), which Dart reads as the same double. +fn dart_number(x: f64) -> String { + if x.fract() == 0.0 && x.abs() < 1e15 { format!("{x}") } else { format!("{x:?}") } +} + +/// `text` on one line of a Dart comment. +fn dart_comment(text: &str) -> String { + text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect() +} + +/// Each collection's variables as Dart constants for Flutter: a class per mode (the default mode's +/// named after the collection, the others after the collection and the mode) with a `static const` +/// per variable. Aliases are resolved to their value in that mode (a variable of another +/// collection in that collection's default mode), so each class stands alone. +fn dart(vars: &Variables, colls: &[&Collection]) -> String { + let mut out = String::from("// Variables exported from FigCraft.\n\nimport 'dart:ui';\n"); + let mut classes = BTreeSet::new(); + for c in colls { + let base = dart_ident(&c.name, true); + let mut members = BTreeSet::new(); + let names: Vec<(&Variable, String)> = + vars.variables.iter().filter(|v| v.collection == c.id).map(|v| (v, dart_unique(dart_ident(&v.name, false), &mut members))).collect(); + for (i, m) in c.modes.iter().enumerate() { + let class = dart_unique(if i == 0 { base.clone() } else { format!("{base}{}", dart_ident(&m.name, true)) }, &mut classes); + out.push_str(&format!("\n/// {} · {}\nabstract final class {class} {{\n", dart_comment(&c.name), dart_comment(&m.name))); + let modes = BTreeMap::from([(c.id.clone(), m.id.clone())]); + for (v, name) in &names { + let (ty, value) = match vars.resolve(&v.id, &modes) { + Some(VariableValue::Color(col)) => ("Color", crate::codegen::kotlin_color(col)), + Some(VariableValue::Number(x)) if x.is_finite() => ("double", dart_number(x)), + Some(VariableValue::String(t)) => ("String", format!("'{}'", dart_string(&t))), + Some(VariableValue::Boolean(b)) => ("bool", b.to_string()), + _ => continue, + }; + out.push_str(&format!(" static const {ty} {name} = {value};\n")); + } + out.push_str("}\n"); + } + } + out +} + fn import(s: &mut Session, p: &Value) -> Result { let cmd = "variable.import"; let v = diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs index d1c03fe3..271193e0 100644 --- a/crates/engine/src/code_connect.rs +++ b/crates/engine/src/code_connect.rs @@ -37,7 +37,8 @@ const MAX_WARNINGS: usize = 50; /// The languages a component can be connected in. pub fn language(s: &str) -> Option { - CodeLang::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi)) + CodeLang::from_id(s) + .filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::Vue | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi)) } /// The layer `id`'s links are stored on: its component set, else its component (an instance's @@ -66,6 +67,8 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String { _ if path.is_empty() => String::new(), CodeLang::React | CodeLang::ArkUi => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), CodeLang::Leptos => format!("use {path}::{};", link.name.split('.').next().unwrap_or_default()), + // A single-file component is a default export. + CodeLang::Vue => format!("import {} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), _ => format!("import {path}"), } } @@ -266,6 +269,8 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) -> match (swapped, lang) { (Some(l), CodeLang::React) => format!("<{} />", l.name), (Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name), + // A Vue binding takes the component itself. + (Some(l), CodeLang::Vue) => l.name.clone(), (Some(l), CodeLang::SwiftUi) => format!("{}()", l.name), (Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name), (Some(l), _) => format!("{{ {}() }}", l.name), @@ -280,22 +285,155 @@ fn pieces(t: &str) -> Vec> { checkable.then(|| parse(t).ok()).flatten().unwrap_or_else(|| vec![Piece::Text(t)]) } +/// Whether `s` ends inside an unclosed opening tag (no `>` outside quotes since the last `<`). +fn inside_open_tag(s: &str) -> bool { + let mut in_tag = false; + let mut quote = None; + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + match quote { + Some(q) => { + if c == q { + quote = None; + } + } + None => match c { + '<' if !in_tag && chars.peek().is_some_and(|ch| ch.is_ascii_alphabetic() || matches!(*ch, '/' | '!' | '?')) => { + in_tag = true; + } + '>' if in_tag => in_tag = false, + '"' | '\'' if in_tag => quote = Some(c), + _ => {} + }, + } + } + in_tag +} + +/// Where a placeholder sits in a Vue template: whether the attribute is bound, and the quote the +/// template already opened (if any). `None` when it isn't in an attribute at all. +fn vue_attribute(out: &str) -> Option<(bool, Option)> { + let end = out.trim_end(); + if !inside_open_tag(end) { + return None; + } + let (after, quote) = match end.chars().last() { + Some(q @ ('"' | '\'')) => (&end[..end.len() - q.len_utf8()], Some(q)), + _ => (end, None), + }; + let after = after.trim_end(); + let eq = after.rfind('=')?; + // The placeholder must follow the `=` (or its opening quote) directly; text after a closed + // attribute (``) is template content, not an attribute value. + if !after[eq + 1..].trim().is_empty() { + return None; + } + let before = after[..eq].trim_end(); + let name: String = before + .chars() + .rev() + .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | ':' | '@' | '.' | '[' | ']')) + .collect::>() + .into_iter() + .rev() + .collect(); + if name.is_empty() { + return None; + } + let bound = name.starts_with(':') || name.starts_with("v-bind:") || name.starts_with('@') || name.starts_with("v-on:") || name.starts_with("v-"); + Some((bound, quote)) +} + +/// An expression written into an HTML attribute opened with `quote`: its own delimiter and markup +/// become entities, so Vue's parser hands the expression its string literal intact. +fn html_attr_expr(e: &str, quote: Option) -> String { + let e = e.replace('&', "&").replace('<', "<"); + match quote { + Some('\'') => e.replace('\'', "'"), + _ => e.replace('"', """), + } +} + +/// Literal text written into an HTML attribute opened with `quote`. +fn html_attr_text(s: &str, quote: Option) -> String { + let s = s.replace('&', "&").replace('<', "<"); + match quote { + Some('\'') => s.replace('\'', "'"), + _ => s.replace('"', """), + } +} + +/// Literal text in Vue template content: braces would start an interpolation, so they are escaped. +fn html_text(s: &str) -> String { + s.replace('&', "&").replace('<', "<").replace('>', ">").replace('{', "{").replace('}', "}") +} + /// `link`'s template with `values` (one per parameter of `c`), every line prefixed by `pad`. pub(crate) fn render(doc: &Document, c: &Component, values: &[(String, Value)], link: &CodeLink, lang: CodeLang, pad: &str) -> String { let mut out = String::new(); + // A quoted attribute stays open across substitutions: `title="{a} {b}"` must treat both + // placeholders as attribute values, not text after the first one. + let mut vue_attr: Option<(bool, Option)> = None; for piece in pieces(&link.template) { match piece { - Piece::Text(t) => out.push_str(t), + Piece::Text(t) => { + out.push_str(t); + if let Some((_, Some(q))) = vue_attr + && t.contains(q) + { + vue_attr = None; + } + } Piece::Value { name, inner } => match find(c, values, name) { Some((p, v)) => { let e = expr(doc, c, p, v, lang); // JSX and `view!`: a plain string reads best as an attribute value, anything // else is an expression in braces (JSX attribute strings have no escapes). - let markup = matches!(lang, CodeLang::React | CodeLang::Leptos); - if !markup || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) { - out.push_str(&e); + // Vue's bound attributes take an expression, so a string keeps its JS quotes + // inside the attribute (as entities); a static attribute takes plain text. + if lang == CodeLang::Vue { + match vue_attr.or_else(|| vue_attribute(&out)) { + Some((bound, quote)) => { + if bound { + let expr = html_attr_expr(&e, quote); + if quote.is_some() { + out.push_str(&expr); + } else { + out.push('"'); + out.push_str(&expr); + out.push('"'); + } + } else { + let text = match &p.kind { + ParamKind::Text | ParamKind::Variant(_) => html_attr_text(v.as_str().unwrap_or(""), quote), + _ => e.clone(), + }; + if quote.is_some() { + out.push_str(&text); + } else { + out.push('"'); + out.push_str(&text); + out.push('"'); + } + } + vue_attr = quote.map(|q| (bound, Some(q))); + } + None => { + let text = match &p.kind { + ParamKind::Text | ParamKind::Variant(_) => html_text(v.as_str().unwrap_or("")), + _ => e.clone(), + }; + out.push_str(&text); + vue_attr = None; + } + } } else { - out.push_str(&format!("{{{e}}}")); + let markup = matches!(lang, CodeLang::React | CodeLang::Leptos); + if !markup || (out.ends_with('=') && e.starts_with('"') && !e.contains('\\')) { + out.push_str(&e); + } else { + out.push_str(&format!("{{{e}}}")); + } } } None => out.push_str(&format!("{{{inner}}}")), @@ -317,6 +455,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String { CodeLang::Leptos => { format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(p))).collect::()) } + CodeLang::Vue => format!("<{name}{} />", params.iter().map(|p| format!(" :{p}={{{p}}}")).collect::()), CodeLang::SwiftUi => format!("{name}({})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), CodeLang::ArkUi => format!("{name}({{ {} }})", params.iter().map(|p| format!("{p}: {{{p}}}")).collect::>().join(", ")), _ => format!("{name}({})", params.iter().map(|p| format!("{p} = {{{p}}}")).collect::>().join(", ")), @@ -338,6 +477,7 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res let path_ok = match lang { CodeLang::React | CodeLang::ArkUi => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'), CodeLang::Leptos => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == ':'), + CodeLang::Vue => path.chars().all(|ch| ch.is_ascii_alphanumeric() || "_./@-".contains(ch)), _ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'), }; if path.chars().count() > MAX_IMPORT || !path_ok { @@ -345,6 +485,7 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res CodeLang::React => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"), CodeLang::ArkUi => format!("importPath: a module path without quotes, up to {MAX_IMPORT} characters"), CodeLang::Leptos => format!("importPath: a Rust module path (letters, digits, _ and ::), up to {MAX_IMPORT} characters"), + CodeLang::Vue => format!("importPath: a module path (letters, digits, _ . / @ and -), up to {MAX_IMPORT} characters"), _ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"), }); } @@ -380,7 +521,7 @@ fn text(cmd: &str, p: &Value, key: &str) -> Result> { /// The `language` parameter (default React). fn lang_param(cmd: &str, p: &Value) -> Result { match text(cmd, p, "language")? { - Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose|arkui")), + Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|vue|swiftui|compose|arkui")), None => Ok(CodeLang::React), } } @@ -410,7 +551,7 @@ pub fn specs() -> Vec { spec!( "codeConnect.set", "Connect to code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}", + "{id? (component, set or instance; default the selection), language?: react|leptos|vue|swiftui|compose|arkui (default react), importPath?, name? (default the generated name), template? (default a call passing every property)} links the component (its set, for a variant) to its code component; fields left out keep their current value. Placeholders: {property} (its name or parameter name) and {property ? \"a\" : \"b\"}; other {…} stay as written → {component, language, code (the snippet for the layer), unknown (placeholder names that aren't properties)}", true, |s, p| { let cmd = "codeConnect.set"; @@ -525,7 +666,7 @@ fn import(s: &mut Session, p: &Value) -> Result { let (Some(component), Some(lang), Some(name), Some(template)) = (field("component"), field("language").and_then(language), field("name"), e.get("template").and_then(Value::as_str)) else { - warnings.push(format!("link {}: needs component, language (react|leptos|swiftui|compose|arkui), name and template strings", i + 1)); + warnings.push(format!("link {}: needs component, language (react|leptos|vue|swiftui|compose|arkui), name and template strings", i + 1)); continue; }; let link = CodeLink { diff --git a/crates/engine/src/code_connect_tests.rs b/crates/engine/src/code_connect_tests.rs index 6cdd8e67..1ee785e6 100644 --- a/crates/engine/src/code_connect_tests.rs +++ b/crates/engine/src/code_connect_tests.rs @@ -193,3 +193,79 @@ fn code_connect_commands_survive_junk() { } } } + +#[test] +fn vue_links_are_a_default_import_with_quoted_bindings() { + let mut s = Session::new(); + let c = card(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false, "Title": "Buy"}})); + // A single-file component is a default import. A bound attribute takes an expression, so a + // string keeps its JS quotes inside the attribute (as entities): `Buy` alone would be a + // variable, not the string. + let v = ex( + &mut s, + "codeConnect.set", + json!({"id": i, "language": "vue", "importPath": "@/components/Card.vue", + "template": ""}), + ); + let want = "import Card from \"@/components/Card.vue\";\n\n\n"; + assert_eq!(v["code"], want); + assert_eq!(code(&mut s, i, "vue"), want); + assert_eq!(ex(&mut s, "inspect.code", json!({"id": i, "language": "vue"}))["connected"], true); + // No template given: the generated one binds a quoted value for every property. + ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "vue"})); + ex(&mut s, "codeConnect.set", json!({"id": c, "language": "vue", "importPath": "./Card.vue"})); + assert_eq!(code(&mut s, c, "vue"), "import Card from \"./Card.vue\";\n\n\n"); + // A plain string attribute (no `:`) takes the text itself, and a variant binding is a string + // expression like any other. + ex(&mut s, "codeConnect.set", json!({"id": i, "language": "vue", "template": ""})); + assert_eq!(code(&mut s, i, "vue"), "import Card from \"./Card.vue\";\n\n\n"); + let (set, hover) = button(&mut s); + let b = idv(&ex(&mut s, "instance.create", json!({"component": hover, "x": 0, "y": 700}))); + ex(&mut s, "codeConnect.set", json!({"id": b, "language": "vue", "template": "