diff --git a/Cargo.lock b/Cargo.lock index 5a77cda3..40a16fe7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1625,11 +1625,13 @@ dependencies = [ "image", "log", "regex", + "same-file", "serde", "serde_json", "thiserror 2.0.21", "wat", "web-time", + "zip 2.6.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2be79ed1..3e1101e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ serde = { version = "1", features = ["derive", "rc"] } serde_json = { version = "1", features = ["float_roundtrip"] } regex = "1.13" log = "0.4" +same-file = "1.0.6" # All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md). chrono = { version = "0.4.45", default-features = false, features = ["std"] } proptest = "1" diff --git a/README.md b/README.md index ebbf7fa0..778f558f 100644 --- a/README.md +++ b/README.md @@ -234,8 +234,8 @@ The desktop app also offers a loopback-only control channel (`figcraft --control - **Its own renderer:** tiny-skia on every core for the canvas and exports, with layer blending, effects and masks; booleans from linesweeper, geometry from kurbo, text from skrifa and HarfRust. - **Runs in the browser:** the whole engine and UI compile to WebAssembly. - **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes. -- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian and Georgian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). -- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). +- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech, Indonesian, Georgian and Vietnamese drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)). +- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and four example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md). - **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md). - **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md). - **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session. diff --git a/ROADMAP.md b/ROADMAP.md index 0fa5def9..cf505389 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 (boards: a shared timer and voting sessions; 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-25 · **Change:** minor (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 @@ -93,7 +93,7 @@ native speakers' reviews of the nine language drafts need people. | Team libraries | 70% | 40% | 4–8 | [components-parity](docs/components-parity.md) | | Figma Draw extras | 88% | 60% | 1–3 | [gaps](docs/gaps.md) | | AI features | 0% | 0% | 30–60 | [gaps](docs/gaps.md) | -| Boards (whiteboarding; not in the weighted numbers) | ~40% | ~24% | 26–54 | [boards-parity](docs/boards-parity.md) | +| Boards (whiteboarding; not in the weighted numbers) | ~42% | ~25% | 24–50 | [boards-parity](docs/boards-parity.md) | ## Languages @@ -110,11 +110,11 @@ native speakers' reviews of the nine language drafts need people. | Japanese | ja | 99% | draft (needs native review) | | German | de | 96% | draft (needs native review) | | Korean | ko | 95% | draft (needs native review) | -| Vietnamese | vi | 0% | none | +| Vietnamese | vi | 100% | draft (needs native review) | Percentages measured 2026-10-17 over 2,272 entries (strings added since a draft show English). Also shipped: Russian (`ru`, 99%, reviewed by VanTan), Swedish (`sv`, 99.6%, command labels since 2026-10-17; native review welcome), Turkish (`tr`, 99%) and Czech (`cs`, 99%, draft). Figma ships English, Japanese, Korean, French, German, Spanish and Portuguese (Brazil); FigCraft has the localization layer, English and Spanish (Spain and Latin America), -French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese and Indonesian drafts (Preferences ▸ +French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Vietnamese drafts (Preferences ▸ Language; Traditional Chinese has no catalog; CJK UI text uses an installed CJK font, loaded only when needed; adding a language: [`docs/translating.md`](docs/translating.md)). Document text in any script (bidi, Arabic shaping, @@ -134,9 +134,12 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **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)). +- **2026-10-24** · Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged. - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Board facilitation (M12): a timer everyone on a board sees (presets or any minutes, pause, resume, a minute more, stop; synced as its end time, so nothing ticks over the network) and voting sessions (votes per person, on everything, on sticky notes or on the selection; a click votes, your badge takes it back; your votes show while it runs, everyone's with a ranked list once it ends; clear results), in a pill at the top right of the board. `board.timer`, `board.timerState`, `board.vote`, `board.voteResults`; ballots sync per voter. Checked in the app, with two app instances in one live session through a local `figcraft-server`, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). +- **2026-10-22** · CJK line breaking completes JIS X 4051's kinsoku (M4): the line-start set now also has the halfwidth small kana ァ–ッ and small tsu, the halfwidth (semi-)voiced sound marks ゙゚, the spacing (semi-)voiced sound marks ゛゜, the kana iteration marks 〱–〵 and the halfwidth closing parenthesis ⦆; the line-end set has the halfwidth opening parenthesis ⦅. Text layers, auto layout and the canvas otherwise wrap Japanese as before: ideographs break anywhere and the behaviour suite pins every category plus that a manual newline may still start a line with a prohibited character. Numbers unchanged. - **2026-10-15** · Auto-save (M1, #305): Preferences ▸ Save files automatically (`pref.autoSave`, off by default, desktop only) saves each open file that has a path 3 s after its last change, never during a gesture, a live text preview or while a version is shown; the file is encoded off the UI thread, a failed save says why and is tried again a minute later. Numbers unchanged. - **2026-10-15** · A text box by dragging (M1, #363): a drag with the Text tool makes a fixed-size text box the size of the drag; a click still makes auto-width text. Numbers unchanged. - **2026-10-15** · Batch step references (M0.9, #400): a `command.batch` step can use the id an earlier step made (`{"$step": n}`, a reserved key), so a nested tree takes one call and one undo step. Numbers unchanged. @@ -339,9 +342,13 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). |---|---|---| | 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) | +| 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | | 2026-10-24 | minor | Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 97% → 98% breadth, 76% → 78% ready, 2–4 h → 1–2 h; headline numbers unchanged at this rounding | | 2026-10-24 | minor | Board facilitation (M12): a shared timer and voting sessions (`board.timer`, `board.vote` and their queries; timer and vote sync as document state, ballots per voter; neither is an undo step). Boards row ~35%/~20% → ~40%/~24%, 30–60 h → 26–54 h; headline numbers unchanged (boards are outside the weighted sum) | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Boards row added outside the weighted numbers; headline numbers unchanged at this rounding | +| 2026-10-21 | minor | Best-effort `.sketch` import (Sketch 48 and newer): open, import, drop, CLI and MCP; symbols become live components and instances, shared styles and Color Variables come across, Sketch 2025 frames, stacks (as auto layout) and tints too, text placed as Sketch places it; checked on three real files against Sketch's own renders ([file-format-parity](docs/file-format-parity.md)). Headline numbers unchanged | +| 2026-10-22 | minor | CJK line breaking: JIS X 4051 line-start/line-end sets completed (halfwidth small kana, halfwidth and spacing voiced sound marks, iteration marks 〱–〵, halfwidth parentheses) with behaviour cases; numbers unchanged | | 2026-10-15 | minor | Auto-save (#305, #296): Preferences ▸ Save files automatically (`pref.autoSave`, off by default; desktop only, the browser has no files to save to) saves each open file that has a path 3 s after its last change, so steady editing doesn't save every few seconds, and never during a gesture, a live text preview or while a version is shown. The file is encoded on a thread of its own and written as of that moment (edits made meanwhile stay unsaved); a failed save says why in a toast and is tried again a minute later, and version history checkpoints keep coming with it on. Numbers unchanged | | 2026-10-15 | minor | A text box by dragging (#363, #360): a drag with the Text tool makes a fixed-size text box the size of the drag (at least one line tall); a click, or a drag shorter than the drag threshold, still makes auto-width text. Numbers unchanged | | 2026-10-15 | minor | Batch step references (#400): inside `command.batch` (MCP `batch`), `{"$step": n}` anywhere in a step's params stands for the `id` step n returned (`{"$step": n, "field": …}` for another field; `$step` is a reserved key), so a frame with its children takes one batch and one undo step; a malformed reference fails the batch, which rolls back, and a nested batch keeps references to its own steps. Numbers unchanged | diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index a03d5b26..f746becd 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -292,8 +292,29 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { Ok(ExitCode::SUCCESS) } +fn refuse_dropping_images(input: &str, out: &str, images_left_out: usize) -> CliResult<()> { + if images_left_out > 0 && figcraft_engine::file_access::same_file(input, out) { + return Err(CliError::Failed(format!( + "{out} wasn't written: {images_left_out} of its images weren't loaded (together they pass what FigCraft reads from one file), and writing over it would delete them. Write to another path; {input} keeps them." + ))); + } + Ok(()) +} + +fn refuse_dropping_images_from_script(input: Option<(&str, usize)>, active: &figcraft_engine::DocState, out: &str) -> CliResult<()> { + if let Some((input, images_left_out)) = input { + refuse_dropping_images(input, out, images_left_out)?; + } + if let Some(images_left_out) = active.images_lost_saving_to(out) { + refuse_dropping_images(out, out, images_left_out)?; + } + Ok(()) +} + pub fn convert(input: &str, out: &str) -> CliResult { - let doc = load(input)?; + let (loaded, _) = figcraft_engine::cmd::file::load_path(input, None).map_err(|e| CliError::Failed(format!("can't open {input}: {e}")))?; + refuse_dropping_images(input, out, loaded.images_past_budget)?; + let doc = loaded.doc; let page = doc .current_page .filter(|p| doc.pages.contains(p)) @@ -353,13 +374,14 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe 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 { + let (mut s, input_images_left_out) = match input { Some(path) => { let mut s = Session::empty(); s.execute("file.open", &json!({"path": path})).map_err(|e| CliError::Failed(format!("can't open {path}: {e}")))?; - s + let left_out = s.ds().map_err(|e| CliError::Failed(e.to_string()))?.images_lost_saving_to(path).unwrap_or(0); + (s, left_out) } - None => Session::new(), + None => (Session::new(), 0), }; // One undo step and all-or-nothing, the way the same commands behave through `command.batch` // (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the @@ -389,6 +411,7 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; + refuse_dropping_images_from_script(input.map(|input| (input, input_images_left_out)), ds, out)?; if ext(out) == "figcraft" { // Through the engine, so the version history is kept. write(out, &figcraft_engine::cmd::file::file_bytes(ds).map_err(|e| CliError::Failed(e.to_string()))?)?; @@ -567,4 +590,24 @@ mod tests { assert!(!out.exists(), "a script that failed writes nothing"); std::fs::remove_dir_all(dir).unwrap(); } + + #[test] + fn a_script_output_over_its_input_is_refused_whichever_tab_the_script_left_active() { + let dir = temp_dir("script-images-left-out"); + let input = dir.join("big.figcraft"); + std::fs::write(&input, b"kept").unwrap(); + let link = dir.join("link.figcraft"); + std::fs::hard_link(&input, &link).unwrap(); + let (input, link, other) = (input.to_str().unwrap(), link.to_str().unwrap(), dir.join("other.figcraft")); + let duplicate = Session::new(); + let active = duplicate.ds().unwrap(); + + for out in [input, link] { + let refused = refuse_dropping_images_from_script(Some((input, 3)), active, out); + assert!(matches!(&refused, Err(CliError::Failed(m)) if m.contains("3 of its images")), "{out}"); + } + assert!(refuse_dropping_images_from_script(Some((input, 3)), active, other.to_str().unwrap()).is_ok()); + assert!(refuse_dropping_images_from_script(Some((input, 0)), active, input).is_ok()); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index ba516a83..8c7881c6 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -34,9 +34,9 @@ fonts built into FigCraft, for renders that match on every machine. Otherwise in indexed the first time text is laid out, as in the app. Files: .figcraft (native), .json (FigCraft or REST API file JSON), .fig (best effort; `info` lists what -wasn't imported), .svg, .png/.jpg/.webp/.gif/.bmp (open as an image layer). `export` writes one file -per top-level frame into the directory, or one file when has an image extension or --node -is given (`fig`, or an ending in .fig: the whole file as .fig, experimental; other apps may +wasn't imported), .sketch (Sketch 48 or newer, best effort), .svg, .png/.jpg/.webp/.gif/.bmp (open as an +image layer). `export` writes one file per top-level frame into the directory, or one file when + has an image extension or --node is given (`fig`, or an ending in .fig: the whole file as .fig, experimental; other apps may not open every file); PDFs take print options (a bleed like 3mm or 0.125in, crop and bleed marks, CMYK colour). `run` executes a JSON array (or JSON lines) of {\"command\": id, \"params\": {...}} in a headless session and prints one JSON result line per step. @@ -115,6 +115,9 @@ fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult = String::from_utf8_lossy(&out.stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect(); + assert_eq!(lines[0]["ok"], json!(true), "{lines:?}"); + let fonts: Vec = serde_json::from_value(lines[1]["result"].clone()).unwrap(); + assert_eq!(fonts, families(&["--no-system-fonts"], false), "the import didn't scan the system fonts"); +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index de999375..a3cd21bf 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -269,14 +269,15 @@ fn main() -> eframe::Result { let result = eframe::run_native( "FigCraft", options, - Box::new(move |_cc| { + Box::new(move |cc| { + with_app(|fig| fig.services.set_egui_context(&cc.egui_ctx)); Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery, #[cfg(target_os = "macos")] - apple_events: apple_events.connect(&_cc.egui_ctx), + apple_events: apple_events.connect(&cc.egui_ctx), })) }), ); diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 0818c048..b83a2664 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -11,6 +11,7 @@ use serde_json::{Value, json}; pub struct Desktop { clipboard: Option, + egui_ctx: Option, /// Where the preferences live ([`config_dir`]). dir: Option, pub prefs: Option, @@ -61,6 +62,7 @@ impl Desktop { let dark = Arc::new(AtomicU8::new(DARK_UNKNOWN)); Self { clipboard: arboard::Clipboard::new().ok(), + egui_ctx: None, dir, prefs, reduce_motion, @@ -409,10 +411,13 @@ impl Services for Desktop { remembering(self.last_folder.clone(), pick, true) } fn clipboard_set_text(&mut self, s: &str) { - if let Some(c) = self.clipboard.as_mut() { - let _ = c.set_text(s.to_string()); + if let Some(ctx) = &self.egui_ctx { + ctx.copy_text(s.to_string()); } } + fn set_egui_context(&mut self, ctx: &egui::Context) { + self.egui_ctx = Some(ctx.clone()); + } fn clipboard_get_text(&mut self) -> Option { read_clipboard(self.clipboard.as_mut()?, |c| c.get_text().ok()) } diff --git a/contributors/people.toml b/contributors/people.toml index 1bc75c24..9788a27e 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -72,6 +72,9 @@ sync_github_name = true [people.AganzeFelicite] sync_github_name = true +[people.mojtabajafarzadeh] +real_name = "Mojtaba Jafarzadeh" + [people.switch87] real_name = "Gert Pellin" display_name = "Gert Pellin" diff --git a/crates/collab-net/src/limits.rs b/crates/collab-net/src/limits.rs index a0647fa6..0321b36c 100644 --- a/crates/collab-net/src/limits.rs +++ b/crates/collab-net/src/limits.rs @@ -24,6 +24,12 @@ pub struct NetLimits { pub hello_timeout: Duration, /// A connection with no traffic for this long is dead. pub idle_timeout: Duration, + /// How long a one-shot request connection (hosting, a library, a plugin directory — and, most + /// importantly, publishing a file) waits for the server's reply before giving up. Publishing + /// uploads a whole file and a large one on a slow link takes the server a while to receive and + /// answer, so this is longer than `idle_timeout` (a large project on a slow connection can + /// easily need several minutes). + pub request_reply_timeout: Duration, /// A quiet client pings this often (keeps it under `idle_timeout`). pub keepalive: Duration, /// Messages queued for one client before it counts as stuck and is disconnected. @@ -68,6 +74,7 @@ impl Default for NetLimits { burst: 400.0, hello_timeout: Duration::from_secs(10), idle_timeout: Duration::from_secs(45), + request_reply_timeout: Duration::from_secs(300), keepalive: Duration::from_secs(15), queue: 1024, write_timeout: Duration::from_secs(10), @@ -85,3 +92,16 @@ impl Default for NetLimits { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_reply_wait_is_longer_than_idle() { + let d = NetLimits::default(); + // Publishing uploads a whole file; a slow link must not cut it off at the session idle + // timeout. Keep the reply wait comfortably above it. + assert!(d.request_reply_timeout > d.idle_timeout); + } +} diff --git a/crates/collab-net/src/manage.rs b/crates/collab-net/src/manage.rs index 2f0683c7..cfa3dceb 100644 --- a/crates/collab-net/src/manage.rs +++ b/crates/collab-net/src/manage.rs @@ -196,13 +196,15 @@ pub fn plugins(server: &Endpoint, key: &str, req: PluginReq, limits: &NetLimits, Ok(r) } -/// A WebSocket to `server` that waits for answers up to the idle timeout (a large upload takes the -/// server a while to keep). +/// A WebSocket to `server` that waits for answers up to the request-reply timeout (a large upload +/// takes the server a while to keep). fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result { let (stream, _, _) = open(server, timeout, limits)?; let (ws, _) = tungstenite::client::client_with_config(server.url(), stream, Some(config(limits.max_server_message))) .map_err(|e| format!("{} doesn't answer as a FigCraft server: {e}", server.display()))?; - ws.get_ref().set_read_timeout(Some(limits.idle_timeout)).map_err(|e| e.to_string())?; + // A publish uploads a whole file; a large project on a slow link can take several minutes to + // receive and answer. The wait uses `request_reply_timeout`, not the session `idle_timeout`. + ws.get_ref().set_read_timeout(Some(limits.request_reply_timeout)).map_err(|e| e.to_string())?; Ok(ws) } diff --git a/crates/doc/src/board.rs b/crates/doc/src/board.rs index 28ed0c1d..ab943d2a 100644 --- a/crates/doc/src/board.rs +++ b/crates/doc/src/board.rs @@ -7,7 +7,7 @@ use figcraft_color::Color; use figcraft_geom::kurbo::{Ellipse, RoundedRect}; -use figcraft_geom::{BezPath, Point, Rect, Shape}; +use figcraft_geom::{BezPath, Point, Rect, Shape, Vec2}; use serde::{Deserialize, Serialize}; use crate::NodeId; @@ -341,8 +341,11 @@ impl Stamp { pub enum Routing { #[default] Straight, - /// Right angles only: out of each end along its side's normal, turning once or twice. + /// Right angles only: out of each end along its side's normal, turning once or twice, around + /// the objects it joins when the short way would cross them. Elbow, + /// One smooth curve, leaving and arriving along the sides' normals. + Curved, } impl Routing { @@ -350,10 +353,11 @@ impl Routing { match self { Routing::Straight => "straight", Routing::Elbow => "elbow", + Routing::Curved => "curved", } } pub fn from_id(s: &str) -> Option { - [Routing::Straight, Routing::Elbow].into_iter().find(|r| r.id().eq_ignore_ascii_case(s)) + [Routing::Straight, Routing::Elbow, Routing::Curved].into_iter().find(|r| r.id().eq_ignore_ascii_case(s)) } } @@ -397,6 +401,17 @@ impl Side { pub fn horizontal(self) -> bool { matches!(self, Side::Left | Side::Right) } + + /// The way out of the side (y down; none for [`Side::Auto`]). + pub fn normal(self) -> Vec2 { + match self { + Side::Top => Vec2::new(0.0, -1.0), + Side::Right => Vec2::new(1.0, 0.0), + Side::Bottom => Vec2::new(0.0, 1.0), + Side::Left => Vec2::new(-1.0, 0.0), + Side::Auto => Vec2::ZERO, + } + } } /// One end of a connector: attached to a layer, or a free point on the page. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index a4784ce1..80eada04 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -46,6 +46,10 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::sync::Arc; +/// Deepest descendant [`Document::world_render_bounds`] follows: as deep as the renderer draws +/// (`figcraft_render::MAX_EXPORT_DEPTH`), so a crafted chain can't overflow the stack (#1062). +const MAX_RENDER_DEPTH: usize = 256; + /// Stable node identifier, unique within a document. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(transparent)] @@ -717,12 +721,16 @@ impl Document { /// a clipping frame's own area, else that and what its visible children show (masked /// children only within their mask). A group is what its children show, grown by its effects. pub fn world_render_bounds(&self, id: NodeId) -> Option { + self.render_bounds_at(id, 0) + } + + fn render_bounds_at(&self, id: NodeId, depth: usize) -> Option { let n = self.node(id)?; let own = n.own_paint_bounds(self.world_transform(id)); - if n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children() { + if (n.clip && n.kind.is_frame_like() && !n.kind.bounds_follow_children()) || depth >= MAX_RENDER_DEPTH { return Some(own); } - Some(match (self.children_render_bounds(n), &n.kind) { + Some(match (self.children_render_bounds(n, depth + 1), &n.kind) { (Some(kids), NodeKind::Group) => n.grow_by_effects(kids), (Some(kids), _) => own.union(kids), (None, _) => own, @@ -730,15 +738,15 @@ impl Document { } /// What `n`'s visible children show, together: each mask's run within the mask's bounds. - fn children_render_bounds(&self, n: &Node) -> Option { + fn children_render_bounds(&self, n: &Node, depth: usize) -> Option { let shown = |i: &NodeId| self.node(*i).is_some_and(|c| c.visible && !c.is_mask); let mut out: Option = None; for run in self.paint_runs(n) { let ids = n.children.get(run).unwrap_or(&[]); let mask = ids.first().copied().filter(|m| self.node(*m).is_some_and(|c| c.is_mask && c.visible)); - let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.world_render_bounds(*i)).reduce(|a, b| a.union(b)); + let r = ids.iter().filter(|i| shown(i)).filter_map(|i| self.render_bounds_at(*i, depth)).reduce(|a, b| a.union(b)); let r = match mask { - Some(m) => r.zip(self.world_render_bounds(m)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0), + Some(m) => r.zip(self.render_bounds_at(m, depth)).map(|(a, b)| a.intersect(b)).filter(|x| x.width() > 0.0 && x.height() > 0.0), None => r, }; out = match (out, r) { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 41e233c4..ebec2383 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -560,6 +560,24 @@ fn is_ancestor_holds_for_very_deep_chains() { assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..)))); } +#[test] +fn render_bounds_stop_where_drawing_stops_on_very_deep_chains() { + // A crafted file nests groups far deeper than anything draws (#1062). The leaf counts while + // it's within the renderer's depth and is left out past it, as the renderer leaves it out. + let chain = |depth: usize| { + let mut d = Document::new("x"); + let mut parent = page(&d); + for _ in 0..depth { + parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); + } + d.create(parent, NodeKind::Rectangle, "R", 100.0, 100.0, 10.0, 10.0).unwrap(); + let top = d.node(page(&d)).unwrap().children[0]; + d.world_render_bounds(top).unwrap() + }; + assert_eq!(chain(MAX_RENDER_DEPTH).x1, 110.0); + assert_eq!(chain(20_000).x1, 10.0); +} + #[test] fn text_insert_at_run_boundary_keeps_runs_disjoint() { let mut t = TextData::new("0123456789"); diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 545d872c..ebd07a82 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -28,8 +28,10 @@ thiserror = { workspace = true } log = { workspace = true } base64 = { workspace = true } regex = { workspace = true } +same-file = { workspace = true } web-time = "1.1.0" [dev-dependencies] +zip = { workspace = true } wat = { workspace = true } image = { workspace = true } diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs index 451a6300..47d6e876 100644 --- a/crates/engine/src/batch_step_ref_tests.rs +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -258,3 +258,25 @@ fn a_nested_batch_has_its_own_names() { ); assert!(msg.contains("`$ref` \"frame\" names no step"), "the outer names aren't visible inside: {msg}"); } + +#[test] +fn a_failing_command_names_its_step_and_rolls_back() { + let mut s = Session::new(); + let frame = id(&s.execute("node.create", &json!({"type": "frame", "width": 50, "height": 50})).unwrap()); + let text = id(&s.execute("node.create", &json!({"type": "text", "text": "orig"})).unwrap()); + let undo = s.ds().unwrap().history.undo.len(); + let name = s.doc().unwrap().node(s.page().unwrap()).unwrap().name.clone(); + let msg = batch_error( + &mut s, + json!([ + {"command": "text.set", "params": {"ids": [text.0], "text": "novo"}}, + {"command": "text.set", "params": {"ids": [frame.0], "text": "erro"}}, + {"command": "page.rename", "params": {"name": "Depois do erro"}} + ]), + ); + // Still the command's own kind of error, its message saying which step. + assert!(msg.starts_with("step 1: ") && msg.contains("is not text"), "{msg}"); + assert!(matches!(&s.doc().unwrap().node(text).unwrap().kind, figcraft_doc::NodeKind::Text(t) if t.characters == "orig")); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().name, name); + assert_eq!(s.ds().unwrap().history.undo.len(), undo); +} diff --git a/crates/engine/src/board.rs b/crates/engine/src/board.rs index 778cfaa5..c56d52a1 100644 --- a/crates/engine/src/board.rs +++ b/crates/engine/src/board.rs @@ -12,7 +12,8 @@ use figcraft_doc::{ HConstraint, LayoutMode, MainAlign, Node, NodeId, NodeKind, Routing, STAMP_SIZE, Shadow, Side, Sizing, Stamp, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color, }; -use figcraft_geom::{Affine, Point, Rect}; +use figcraft_geom::kurbo::PathEl; +use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; @@ -98,10 +99,17 @@ pub fn specs() -> Vec { spec!( "board.connector", "Connector", - "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", + "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", true, connector_cmd ), + spec!( + "board.reattach", + "Reattach connector", + "{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left)} → {id} moves one end of a connector", + true, + reattach + ), spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd), spec!( "board.stamp", @@ -127,7 +135,7 @@ pub fn specs() -> Vec { spec!( "board.setStyle", "Board style", - "{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow, startCap?, endCap? (connectors)}", + "{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow|curved, startCap?, endCap? (connectors)}", true, set_style ), @@ -143,7 +151,7 @@ pub fn specs() -> Vec { } board_tool(s, ToolId::BoardShape) }), - spec!("tool.connector", "Connector", "{style?: straight|elbow} (drag from one object to another)", false, |s, p| { + spec!("tool.connector", "Connector", "{style?: straight|elbow|curved} (drag from one object to another)", false, |s, p| { if let Some(v) = string(p, "style") { s.tool.board.routing = routing_of("tool.connector", &v)?; } @@ -205,7 +213,7 @@ fn stamp_of(cmd: &str, v: &str) -> Result { } fn routing_of(cmd: &str, v: &str) -> Result { - Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight or elbow, not `{v}`"))) + Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight, elbow or curved, not `{v}`"))) } fn cap_of(cmd: &str, p: &Value, key: &str) -> Result> { @@ -606,7 +614,7 @@ fn connector_cmd(s: &mut Session, p: &Value) -> Result { n.stroke.cap_end = end; n.board = Some(BoardItem::Connector(c)); })?; - place_route(doc, id, &pts); + place_route(doc, id, &pts, routing); let mut out = json!({"id": id}); if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) { out["label"] = json!(set_label(doc, id, &text)?); @@ -615,6 +623,80 @@ fn connector_cmd(s: &mut Session, p: &Value) -> Result { Ok(out) } +/// The connector `id` names: a connector, or the label on one. +fn connector_of(cmd: &str, doc: &Document, id: NodeId) -> Result<(NodeId, Connector)> { + match doc.get(id)?.board.clone() { + Some(BoardItem::Connector(c)) => Ok((id, c)), + Some(BoardItem::Label { connector }) => match doc.get(connector)?.board.clone() { + Some(BoardItem::Connector(c)) => Ok((connector, c)), + _ => Err(bad(cmd, "that isn't a connector")), + }, + _ => Err(bad(cmd, "that isn't a connector")), + } +} + +fn reattach(s: &mut Session, p: &Value) -> Result { + let cmd = "board.reattach"; + require(s, cmd)?; + let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; + let (id, mut c) = connector_of(cmd, s.doc()?, id)?; + let from = match string(p, "end").as_deref() { + Some("from") => true, + Some("to") => false, + _ => return Err(bad(cmd, "`end` is from or to")), + }; + let end = end_param(cmd, s, p, "target", "side")?; + let other = if from { c.to } else { c.from }; + if end.node().is_some() && end.node() == other.node() { + return Err(bad(cmd, "connect two different layers")); + } + *(if from { &mut c.from } else { &mut c.to }) = end; + let doc = s.doc_mut()?; + let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; + if pts.windows(2).all(|w| w.first() == w.get(1)) { + return Err(bad(cmd, "the ends are in the same place")); + } + doc.get_mut(id)?.board = Some(BoardItem::Connector(c)); + place_route(doc, id, &pts, c.routing); + Ok(json!({"id": id})) +} + +/// A connector end as `board.connector` and `board.reattach` take it. +fn end_json(e: End) -> Value { + match e { + End::Node { node, .. } => json!(node), + End::Point { x, y } => json!({"x": x, "y": y}), + } +} + +/// The end of connector `id` that stays while its other one (`from`: its start) is dragged. +fn other_end(doc: &Document, id: NodeId, from: bool) -> Option { + match &doc.node(id)?.board { + Some(BoardItem::Connector(c)) => Some(if from { c.to } else { c.from }), + _ => None, + } +} + +/// The selected connector (one, alone and unlocked) and its two ends on the page as drawn. +pub fn selected_connector_ends(doc: &Document, sel: &[NodeId]) -> Option<(NodeId, [Point; 2])> { + let [id] = sel else { return None }; + if !matches!(doc.node(*id)?.board, Some(BoardItem::Connector(_))) || crate::tools::locked_in(doc, *id) { + return None; + } + let (a, b) = ends_now(doc, *id)?; + Some((*id, [a, b])) +} + +/// How near (screen pixels) a press must be to a selected connector's end to drag it. +pub const END_HANDLE_PX: f64 = 8.0; + +/// The selected connector's end under `p` at `zoom`, nearest first (`true`: its start). +pub(crate) fn end_handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<(NodeId, bool)> { + let (id, [a, b]) = selected_connector_ends(doc, sel)?; + let (da, db) = ((a - p).hypot(), (b - p).hypot()); + (da.min(db) * zoom.max(1e-6) <= END_HANDLE_PX).then_some((id, da <= db)) +} + /// The label of connector `id`, if it has one. pub fn label_of(doc: &Document, id: NodeId) -> Option { let parent = doc.node(id)?.parent?; @@ -664,12 +746,7 @@ fn label_cmd(s: &mut Session, p: &Value) -> Result { let cmd = "board.label"; require(s, cmd)?; let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; - let doc = s.doc()?; - let id = match doc.node(id).and_then(|n| n.board.clone()) { - Some(BoardItem::Connector(_)) => id, - Some(BoardItem::Label { connector }) => connector, - _ => return Err(bad(cmd, "that isn't a connector")), - }; + let (id, _) = connector_of(cmd, s.doc()?, id)?; let text = req_string(cmd, p, "text")?; let label = set_label(s.doc_mut()?, id, &text)?; Ok(json!({"id": id, "label": label})) @@ -941,7 +1018,7 @@ pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTr n.board = Some(BoardItem::Connector(c)); } if let Some(pts) = route(doc, &c) { - place_route(doc, id, &pts); + place_route(doc, id, &pts, c.routing); } } for (label, connector) in labels { @@ -974,7 +1051,7 @@ fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet if !touched.contains(&id) || b.transform == n.transform || b.board != n.board { return false; } - let Some(expected) = route(doc, c).and_then(|pts| figcraft_geom::points_bounds(pts.iter().copied())).map(|b| b.origin()) else { return false }; + let Some(expected) = route(doc, c).and_then(|pts| finite_box(&route_path(&pts, c.routing))).map(|b| b.origin()) else { return false }; let d = (doc.world_transform(id) * Point::ZERO) - expected; if d.hypot() <= 1e-9 { return false; @@ -1017,7 +1094,12 @@ fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> { fn route_mid(doc: &Document, id: NodeId) -> Option { let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; let w = doc.world_transform(id); - let pts: Vec = network.vertices.iter().map(|v| w * v.point()).collect(); + let mut pts: Vec = Vec::new(); + figcraft_geom::kurbo::flatten(network.stroke_path().iter(), 0.25, |el| { + if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { + pts.push(w * p); + } + }); let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum(); let mut left = total / 2.0; for s in pts.windows(2) { @@ -1031,19 +1113,42 @@ fn route_mid(doc: &Document, id: NodeId) -> Option { pts.first().copied() } -/// Write the route `pts` (page points) into connector `id`: its box, place and path. Untouched -/// when it already runs there, so a connector that didn't move isn't an edit. -fn place_route(doc: &mut Document, id: NodeId, pts: &[Point]) { +/// The path through a route's page points `pts` (see [`route`]): one curve for a curved +/// connector, straight runs otherwise. +fn route_path(pts: &[Point], routing: Routing) -> BezPath { + let mut path = BezPath::new(); + match (routing, pts) { + (Routing::Curved, [p0, c0, c1, p1]) => { + path.move_to(*p0); + path.curve_to(*c0, *c1, *p1); + } + _ => { + for (i, p) in pts.iter().enumerate() { + if i == 0 { path.move_to(*p) } else { path.line_to(*p) } + } + } + } + path +} + +/// The box a path is drawn in, when it is finite. +fn finite_box(path: &BezPath) -> Option { + let b = path.bounding_box(); + (b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()).then_some(b) +} + +/// Write the route `pts` (page points, see [`route`]) into connector `id`: its box, place and +/// path. Untouched when it already runs there, so a connector that didn't move isn't an edit. +fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing: Routing) { let Some(n) = doc.node(id) else { return }; let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); - let inv = pw.inverse(); - let local: Vec = pts.iter().map(|p| inv * *p).collect(); - let Some(b) = figcraft_geom::points_bounds(local.iter().copied()) else { return }; - if !(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()) { - return; - } - let rel: Vec = local.iter().map(|p| Point::new(p.x - b.x0, p.y - b.y0)).collect(); - let network = VectorNetwork::from_points(&rel, false); + let local = pw.inverse() * route_path(pts, routing); + let Some(b) = finite_box(&local) else { return }; + let rel = Affine::translate((-b.x0, -b.y0)) * local; + let network = match routing { + Routing::Curved => VectorNetwork::from_path(&rel), + _ => VectorNetwork::from_points(&rel.elements().iter().filter_map(PathEl::end_point).collect::>(), false), + }; let t = Affine::translate((b.x0, b.y0)); let same = n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network); @@ -1057,8 +1162,12 @@ fn place_route(doc: &mut Document, id: NodeId, pts: &[Point]) { } } +/// How far an elbow connector going around the objects it joins keeps from them. +const ROOM: f64 = 24.0; + /// The page points connector `c` runs through: from the middle of the side of each attached layer -/// facing the other end (or the side it names), straight or in right angles. +/// facing the other end (or the side it names), straight, in right angles, or (curved) its two +/// ends with the curve's two control points between them. pub fn route(doc: &Document, c: &Connector) -> Option> { let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n)); let centre = |e: &End, b: Option| match e { @@ -1067,25 +1176,36 @@ pub fn route(doc: &Document, c: &Connector) -> Option> { }; let (fb, tb) = (bounds(&c.from), bounds(&c.to)); let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?); - let (p0, h0) = attach(&c.from, fb, tc)?; - let (p1, h1) = attach(&c.to, tb, fc)?; - let mut pts = match c.routing { - Routing::Straight => vec![p0, p1], - Routing::Elbow => elbow(p0, h0, p1, h1), + let (p0, s0) = attach(&c.from, fb, tc)?; + let (p1, s1) = attach(&c.to, tb, fc)?; + let pts = match c.routing { + Routing::Straight => tidy(vec![p0, p1]), + Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::>()), + Routing::Curved => curve(p0, s0, p1, s1), }; - pts.dedup(); - // A turn that doesn't turn (the ends in line) is no point of the route. - let in_line = |i: usize, b: &Point| match (i.checked_sub(1).and_then(|j| pts.get(j)), pts.get(i + 1)) { - (Some(a), Some(c)) => (a.x == b.x && b.x == c.x) || (a.y == b.y && b.y == c.y), - _ => false, - }; - let pts: Vec = pts.iter().enumerate().filter(|(i, b)| !in_line(*i, b)).map(|(_, b)| *b).collect(); pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts) } -/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and -/// whether that side faces sideways (`None`: a free point). -fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { +/// `pts` without repeats or turns that don't turn (three in a line across or down). +fn tidy(pts: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(pts.len()); + for p in pts { + if out.last() == Some(&p) { + continue; + } + if let [.., a, b] = out.as_slice() + && ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y)) + { + out.pop(); + } + out.push(p); + } + out +} + +/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that +/// side (`None`: a free point). +fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { match e { End::Point { x, y } => Some((Point::new(*x, *y), None)), End::Node { side, .. } => { @@ -1104,14 +1224,55 @@ fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option } s => *s, }; - Some((side.point(b), Some(side.horizontal()))) + Some((side.point(b), Some(side))) } } } -/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals -/// (`h`: sideways; a free end follows the other). -fn elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { +/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no +/// way): the ends and the two control points between them. +fn curve(p0: Point, s0: Option, p1: Point, s1: Option) -> Vec { + let reach = (p1 - p0).hypot() / 2.0; + let pull = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach); + vec![p0, pull(p0, s0), pull(p1, s1), p1] +} + +/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a +/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`, +/// else the cheapest of a few ways around them (by length, and turns). +fn elbow(p0: Point, s0: Option, p1: Point, s1: Option, boxes: &[Rect]) -> Vec { + let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal))); + let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct }; + if !crosses(&direct, boxes) { + return direct; + } + // Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects. + let out = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM); + let (a, b) = (out(p0, s0), out(p1, s1)); + let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]]; + for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] { + ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]); + } + for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] { + ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]); + } + let cost = |r: &Vec| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::() + ROOM * r.len() as f64; + ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct) +} + +/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge, +/// or out of one, is outside). +fn crosses(pts: &[Point], boxes: &[Rect]) -> bool { + const EDGE: f64 = 0.5; + pts.windows(2).any(|s| { + let (a, b) = (s[0], s[1]); + boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE) + }) +} + +/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end +/// follows the other): halfway across between two sides facing the same way, one turn otherwise. +fn short_elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs()); let h1 = h1.unwrap_or(h0); match (h0, h1) { @@ -1229,16 +1390,42 @@ impl Session { if to.node().is_some() && to.node() == from.node() { return Ok(()); } - let json_end = |e: End| match e { - End::Node { node, .. } => json!(node), - End::Point { x, y } => json!({"x": x, "y": y}), - }; let style = self.tool.board.routing.id(); - self.execute("board.connector", &json!({"from": json_end(from), "to": json_end(to), "style": style}))?; + self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?; self.board_placed(); Ok(()) } + /// Dragging the selected connector `id`'s end (`from`: its start): a line from its other end + /// to the pointer, and the object it would attach to outlined. + pub(crate) fn reattach_drag(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) { + self.tool.gesture = Gesture::Reattach { id, from, start }; + let Ok(doc) = self.doc() else { return }; + let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a }); + let other = other_end(doc, id, from).and_then(|e| e.node()); + self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]); + self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other); + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + } + + /// The end let go: attached to the object under the pointer, or free there (`board.reattach`). + /// A click, or a drop on the object the other end is on, changes nothing. + pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> { + self.tool.overlay.rubber = None; + if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { + return Ok(()); + } + let target = self.end_at(ev); + let other = other_end(self.doc()?, id, from).and_then(|e| e.node()); + if target.node().is_some() && target.node() == other { + return Ok(()); + } + self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?; + Ok(()) + } + /// The eraser at the pointer: the marker and highlighter strokes it touches go (until the /// gesture ends, which is one undo step). pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> { diff --git a/crates/engine/src/board_connector_tests.rs b/crates/engine/src/board_connector_tests.rs new file mode 100644 index 00000000..cbb2070e --- /dev/null +++ b/crates/engine/src/board_connector_tests.rs @@ -0,0 +1,179 @@ +//! Board connectors, continued: moving an end onto another object or off into the canvas (the +//! command and a drag of the selected connector's end), curved connectors, and elbows that go +//! around the objects they join. + +use figcraft_doc::{BoardItem, End, NodeKind, Routing, Side}; +use figcraft_geom::{Point, Rect}; +use serde_json::json; + +use crate::Session; +use crate::board::route; +use crate::board_tests::{board, connector, drawn, id, near, node}; +use crate::tools::{PointerEvent, PointerKind, handle_at}; + +/// A board with three 100 × 100 shapes in a row 300 apart, and an elbow connector from the +/// first to the second: (a, b, c, connector). +fn three() -> (Session, [figcraft_doc::NodeId; 4]) { + let mut s = board(); + let mut shape = |x: f64| id(&s.execute("board.shape", &json!({"x": x, "y": 0, "width": 100, "height": 100})).unwrap(), "id"); + let (a, b, c) = (shape(0.0), shape(300.0), shape(600.0)); + let k = id(&s.execute("board.connector", &json!({"from": a, "to": b, "style": "elbow"})).unwrap(), "id"); + (s, [a, b, c, k]) +} + +#[test] +fn reattach_moves_one_end_and_undoes() { + let (mut s, [a, b, c, k]) = three(); + s.execute("board.reattach", &json!({"id": k, "end": "to", "target": c, "side": "top"})).unwrap(); + assert_eq!(connector(&s, k).to, End::Node { node: c, side: Side::Top }); + assert_eq!(connector(&s, k).from.node(), Some(a)); + assert!(near(*drawn(&s, k).last().unwrap(), Point::new(650.0, 0.0)), "arrives on c's top: {:?}", drawn(&s, k)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(connector(&s, k).to.node(), Some(b), "one undo step"); + // Off into the canvas: the end stays there, free. The selection is the default connector. + s.execute("select.set", &json!({"ids": [k]})).unwrap(); + s.execute("board.reattach", &json!({"end": "from", "target": {"x": -200, "y": 400}})).unwrap(); + assert_eq!(connector(&s, k).from, End::Point { x: -200.0, y: 400.0 }); + assert!(near(drawn(&s, k)[0], Point::new(-200.0, 400.0))); + // A label stands for its connector. + let label = id(&s.execute("board.label", &json!({"id": k, "text": "next"})).unwrap(), "label"); + s.execute("board.reattach", &json!({"id": label, "end": "from", "target": c})).unwrap(); + assert_eq!(connector(&s, k).from.node(), Some(c)); +} + +#[test] +fn reattach_refuses_what_it_cant_join() { + let (mut s, [a, b, _, k]) = three(); + let refused = |s: &mut Session, p| s.execute("board.reattach", &p).is_err(); + assert!(refused(&mut s, json!({"id": k, "end": "to", "target": a})), "both ends on one layer"); + assert!(refused(&mut s, json!({"id": k, "end": "middle", "target": b}))); + assert!(refused(&mut s, json!({"id": k, "end": "to"})), "no target"); + assert!(refused(&mut s, json!({"id": a, "end": "to", "target": b})), "not a connector"); + assert!(refused(&mut s, json!({"id": k, "end": "to", "target": k})), "a connector isn't an end"); + assert!(refused(&mut s, json!({"id": k, "end": "to", "target": {"x": "a", "y": 1}}))); + assert!(refused(&mut s, json!({"id": k, "end": "to", "target": b, "side": "inside"}))); + let mut d = Session::new(); + d.execute("file.new", &json!({})).unwrap(); + assert!(d.execute("board.reattach", &json!({"end": "to", "target": {"x": 0, "y": 0}})).is_err(), "boards only"); + assert_eq!(connector(&s, k).to.node(), Some(b), "nothing changed"); +} + +#[test] +fn dragging_a_selected_connectors_end_reattaches_it() { + let (mut s, [a, b, c, k]) = three(); + s.execute("select.set", &json!({"ids": [k]})).unwrap(); + let doc = s.doc().unwrap(); + assert_eq!(handle_at(doc, &[k], Point::new(100.0, 50.0), 1.0), None, "no box handles on a connector"); + let end = *drawn(&s, k).last().unwrap(); + let ev = |k, p: Point| PointerEvent::at(k, p.x, p.y, 1.0); + // Onto c. + s.pointer(&ev(PointerKind::Down, end)).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(500.0, 50.0))).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(650.0, 50.0))).unwrap(); + assert_eq!(s.tool.overlay.drop_target, Some(c), "the object it would attach to shows"); + assert!(s.tool.overlay.rubber.is_some()); + s.pointer(&ev(PointerKind::Up, Point::new(650.0, 50.0))).unwrap(); + assert_eq!(connector(&s, k).to.node(), Some(c)); + assert_eq!(connector(&s, k).from.node(), Some(a)); + assert_eq!((s.tool.overlay.drop_target, s.tool.overlay.rubber), (None, None)); + assert_eq!(s.selection(), vec![k], "still selected"); + // The start into empty canvas: free where it was let go. + let start = drawn(&s, k)[0]; + s.pointer(&ev(PointerKind::Down, start + figcraft_geom::Vec2::new(3.0, 0.0))).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(100.0, 400.0))).unwrap(); + s.pointer(&ev(PointerKind::Up, Point::new(100.0, 400.0))).unwrap(); + assert_eq!(connector(&s, k).from, End::Point { x: 100.0, y: 400.0 }); + // A click on an end, or a drop on the other end's object, changes nothing. + let before = connector(&s, k); + let end = *drawn(&s, k).last().unwrap(); + s.pointer(&ev(PointerKind::Down, end)).unwrap(); + s.pointer(&ev(PointerKind::Up, end)).unwrap(); + assert_eq!(connector(&s, k), before); + s.execute("board.reattach", &json!({"id": k, "end": "from", "target": b})).unwrap(); + let before = connector(&s, k); + s.pointer(&ev(PointerKind::Down, *drawn(&s, k).last().unwrap())).unwrap(); + s.pointer(&ev(PointerKind::Move, Point::new(350.0, 50.0))).unwrap(); + assert_eq!(s.tool.overlay.drop_target, None, "not onto the other end's object"); + s.pointer(&ev(PointerKind::Up, Point::new(350.0, 50.0))).unwrap(); + assert_eq!(connector(&s, k), before); +} + +#[test] +fn curved_connectors_leave_and_arrive_along_the_sides() { + let (mut s, [a, _, c, k]) = three(); + s.execute("board.setStyle", &json!({"ids": [k], "style": "curved"})).unwrap(); + assert_eq!(connector(&s, k).routing, Routing::Curved); + let n = node(&s, k); + let NodeKind::Vector { network } = &n.kind else { panic!("a vector") }; + assert_eq!((network.vertices.len(), network.segments.len()), (2, 1)); + assert!(!network.segments[0].is_straight(), "one curve"); + assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]); + // Side to side in a line: the curve's control points lie on that line, so its box is flat. + assert!(n.height.abs() < 1e-9 && (n.width - 200.0).abs() < 1e-9, "{} × {}", n.width, n.height); + // From a's bottom to c's top: out downwards and in from above; the box holds the whole curve. + let k2 = id( + &s.execute("board.connector", &json!({"from": a, "to": c, "fromSide": "bottom", "toSide": "top", "style": "curved", "label": "x"})).unwrap(), + "id", + ); + let pts = route(s.doc().unwrap(), &connector(&s, k2)).unwrap(); + assert_eq!(pts.len(), 4); + assert!(pts[1].y > pts[0].y && pts[2].y < pts[3].y, "{pts:?}"); + let b = s.doc().unwrap().world_bounds(k2).unwrap(); + assert!(b.y0 <= 0.0 + 1e-9 && b.y1 >= 100.0 - 1e-9, "{b:?}"); + // The label sits on the curve's middle (by length): here, by symmetry, the middle of the box. + let label = crate::board::label_of(s.doc().unwrap(), k2).unwrap(); + let lc = s.doc().unwrap().world_bounds(label).unwrap().center(); + assert!((lc - b.center()).hypot() < 0.5, "{lc:?} vs {:?}", b.center()); + assert!(s.execute("board.setStyle", &json!({"ids": [k], "style": "wavy"})).is_err()); + s.execute("tool.connector", &json!({"style": "curved"})).unwrap(); + assert_eq!(s.tool.board.routing, Routing::Curved); +} + +/// Whether `pts` (a right-angled route) runs through the inside of `r`. +fn through(pts: &[Point], r: Rect) -> bool { + pts.windows(2).any(|w| { + let (a, b) = (w[0], w[1]); + a.x.min(b.x) < r.x1 - 0.5 && a.x.max(b.x) > r.x0 + 0.5 && a.y.min(b.y) < r.y1 - 0.5 && a.y.max(b.y) > r.y0 + 0.5 + }) +} + +#[test] +fn elbows_go_around_the_objects_they_join() { + let (mut s, [a, b, _, k]) = three(); + // Out of a's left and b's right: the short way runs through both, so it goes over them. + s.execute("board.connector", &json!({"from": a, "to": b, "fromSide": "left", "toSide": "right", "style": "elbow"})).unwrap(); + let k2 = s.selection()[0]; + let pts = drawn(&s, k2); + let (ra, rb) = (Rect::new(0.0, 0.0, 100.0, 100.0), Rect::new(300.0, 0.0, 400.0, 100.0)); + assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}"); + assert_eq!(pts.first().copied(), Some(Point::new(0.0, 50.0))); + assert_eq!(pts.last().copied(), Some(Point::new(400.0, 50.0))); + assert_eq!(pts.len(), 6, "out, over, down and in: {pts:?}"); + assert!(pts.iter().all(|p| p.y <= 50.0), "over the top, the nearer way round: {pts:?}"); + assert!(pts.windows(2).all(|w| w[0].x == w[1].x || w[0].y == w[1].y), "right angles only"); + // Facing each other, the short way stays. + assert_eq!(drawn(&s, k), vec![Point::new(100.0, 50.0), Point::new(300.0, 50.0)]); + // An end at the bottom of b leaving towards a above it: around b's side and up. + s.execute("board.reattach", &json!({"id": k2, "end": "to", "target": b, "side": "bottom"})).unwrap(); + let pts = drawn(&s, k2); + assert!(!through(&pts, ra) && !through(&pts, rb), "{pts:?}"); + assert_eq!(pts.last().copied(), Some(Point::new(350.0, 100.0))); +} + +#[test] +fn reattach_survives_junk() { + let (mut s, [_, _, _, k]) = three(); + for p in [ + json!({"id": k, "end": "to", "target": {"x": 1e308, "y": -1e308}}), + json!({"id": k, "end": "from", "target": {"x": f64::MAX, "y": 0}, "side": 5}), + json!({"id": [k], "end": ["to"], "target": [1]}), + json!({"ids": "x", "end": null, "target": null}), + json!({"id": k, "end": "to", "target": 99_999}), + ] { + let _ = s.execute("board.reattach", &p); + let _ = s.execute("board.setStyle", &json!({"ids": [k], "style": "curved"})); + let _ = s.execute("board.reattach", &p); + } + assert!(matches!(node(&s, k).board, Some(BoardItem::Connector(_)))); + assert!(drawn(&s, k).iter().all(|p| p.x.is_finite() && p.y.is_finite())); +} diff --git a/crates/engine/src/board_tests.rs b/crates/engine/src/board_tests.rs index 5b8cbbf9..56b24b84 100644 --- a/crates/engine/src/board_tests.rs +++ b/crates/engine/src/board_tests.rs @@ -277,6 +277,8 @@ fn board_commands_survive_junk() { json!({"ids": ["x", -1, 99_999], "color": "#GGG", "fontSize": -1, "side": "up", "edit": "yes"}), json!({"id": 2, "text": "", "path": "M0 0 L", "points": [[1]], "highlighter": "yes"}), json!({"path": "M0 0 L 1e308 1e308", "points": [[0, 0], [1e308, 1]]}), + json!({"end": "from", "target": {"x": 1e308, "y": -1e308}, "side": "inside", "style": "curved"}), + json!({"id": 99_999, "end": 7, "target": [1], "style": "wavy"}), ]; let ids: Vec<&str> = crate::cmd::all_commands() .iter() diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index c3514ac5..bb7e3501 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -235,6 +235,22 @@ pub fn specs() -> Vec { spec!("stroke.remove", "Remove stroke", "{ids?, index}", true, |s, p| paint_list(s, p, false, false)), spec!("fill.set", "Set fill", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, true)), spec!("stroke.set", "Set stroke", "{ids?, index?, paint | color, opacity?, visible?, blend?}", true, |s, p| paint_set(s, p, false)), + spec!("fill.copy", "Copy fill", "{id?, index? (default the top fill)}", false, |s, p| paint_copy(s, p, true)), + spec!("fill.paste", "Paste fill", "{ids?, index? (replace the fill there; without: add one)}", true, |s, p| paint_paste(s, p, true)), + spec!( + "stroke.copy", + "Copy stroke", + "{id?, index? (default the top stroke)} its paint and the stroke's weight, alignment, sides, caps, joins and dashes", + false, + |s, p| paint_copy(s, p, false) + ), + spec!( + "stroke.paste", + "Paste stroke", + "{ids?, index? (replace the stroke there; without: add one)} the copied stroke's settings come with its paint", + true, + |s, p| paint_paste(s, p, false) + ), spec!( "fill.pattern", "Pattern fill", @@ -282,6 +298,8 @@ pub fn specs() -> Vec { } ok() }), + spec!("effect.copy", "Copy effect", "{id?, index? (default the top effect)}", false, effect_copy), + spec!("effect.paste", "Paste effect", "{ids?, index? (replace the effect there; without: add one)}", true, effect_paste), spec!("grid.add", "Add layout grid", "{ids?, grid? {kind: grid|columns|rows, ..}}", true, |s, p| { let g: LayoutGrid = parse("grid.add", p, "grid")?.unwrap_or_default(); for id in req_targets("grid.add", s, p)? { @@ -620,13 +638,21 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); } let (nodes, images, bounds) = copied(&doc, &tops); + // A design-app paste replaces the copied layers, not the copied rows: a `fill.copy` before it + // still pastes afterwards. let props = s.clipboard.props.take(); + let paint = s.clipboard.paint.take(); + let stroke = s.clipboard.stroke.take(); + let effect = s.clipboard.effect.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. let layers_generation = s.clipboard.layers_generation.wrapping_add(1); s.clipboard = crate::Clipboard { nodes, images, props, + paint, + stroke, + effect, bounds, source_doc: 0, source: Some(doc), @@ -1434,6 +1460,61 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { ok() } +/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke +/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along. +fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.copy" } else { "stroke.copy" }; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; + let at = opt_index(cmd, p, "index")?; + let (paint, stroke) = { + let doc = s.doc()?; + let n = doc.get(id)?; + let list = if fills { &n.fills } else { &n.strokes }; + let at = at.unwrap_or(list.len().saturating_sub(1)); + let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?; + (paint, (!fills).then(|| n.stroke.clone())) + }; + s.clipboard.paint = Some((fills, paint.clone())); + s.clipboard.stroke = stroke; + serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string())) +} + +/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A +/// stroke's copied settings go onto each target too. +fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.paste" } else { "stroke.paste" }; + let Some((copied_fills, paint)) = s.clipboard.paint.clone() else { + return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into())); + }; + if copied_fills != fills { + let what = if fills { "stroke" } else { "fill" }; + return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}"))); + } + let ids = req_targets(cmd, s, p)?; + // A copied pattern pointing at a target or a layer inside it would make the layer pattern + // itself; `fill.pattern` refuses the same, and the renderer would drop the pattern. + if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind { + let doc = s.doc()?; + if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) { + return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it")); + } + } + let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?}); + let out = match opt_index(cmd, p, "index")? { + Some(at) => { + q["index"] = json!(at); + paint_set(s, &q, fills)? + } + None => paint_list(s, &q, fills, true)?, + }; + if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) { + for id in ids { + s.doc_mut()?.get_mut(id)?.stroke = stroke.clone(); + } + } + Ok(out) +} + fn pattern_fill(s: &mut Session, p: &Value) -> Result { const CMD: &str = "fill.pattern"; let src = req_id(CMD, p, "source")?; @@ -1470,3 +1551,39 @@ fn effect_add(s: &mut Session, p: &Value) -> Result { } ok() } + +/// Copy effect `index` (the top one by default) of the first target into the clipboard. +fn effect_copy(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.copy"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?; + let at = opt_index(CMD, p, "index")?; + let e = { + let doc = s.doc()?; + let list = &doc.get(id)?.effects; + let at = at.unwrap_or(list.len().saturating_sub(1)); + list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))? + }; + s.clipboard.effect = Some(e.clone()); + serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string())) +} + +/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one. +fn effect_paste(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.paste"; + let Some(e) = s.clipboard.effect.clone() else { + return Err(EngineError::Disabled(CMD.into(), "no copied effect".into())); + }; + let at = opt_index(CMD, p, "index")?; + for id in req_targets(CMD, s, p)? { + let n = s.doc_mut()?.get_mut(id)?; + match at { + Some(i) => { + if let Some(slot) = n.effects.get_mut(i) { + *slot = e.clone(); + } + } + None => n.effects.push(e.clone()), + } + } + ok() +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 97419c60..2379026b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -42,7 +42,7 @@ pub fn specs() -> Vec { spec!( "file.open", "Open", - "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .svg, .png, .jpg …); REST and .fig imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", + "{path | data (base64) + name (tells the format; opens as a new unsaved document), variables? (REST: the local variables JSON as an object, JSON text or a path; default `.variables.json` or `variables.json` next to the file)} (.figcraft, .json: FigCraft or REST API file JSON, .fig (best effort), .sketch (Sketch 48 or newer, best effort), .svg, .png, .jpg …); REST, .fig and .sketch imports return `warnings`; a file opened anew returns `summary` {name, pages, layers, images} (one that was already open just returns its `index`)", false, open ), @@ -105,7 +105,7 @@ pub fn specs() -> Vec { spec!( "file.import", "Import", - "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON and .fig pages are appended with their variables and flows (REST and .fig: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)", + "{path | data (base64) + name, x?, y?, center? (SVG: centre it on x, y), variables? (REST: the local variables JSON as an object, JSON text or a path)} SVG becomes an editable frame (shapes, text, gradients, masks, effects; with `warnings` for what it left out), images become image layers, .figcraft, REST API file JSON, .fig and .sketch pages are appended with their variables and flows (REST, .fig and .sketch: with `warnings`; those return `summary` {name, pages, layers, images} of the file read)", true, import ), @@ -163,7 +163,10 @@ pub fn specs() -> Vec { let i = s.active; let uid = s.ds()?.uid; if let Some(slot) = s.docs.get_mut(i) { - *slot = DocState::new(loaded.doc, Some(path)).with_versions(loaded.versions).with_branches(loaded.branches); + *slot = DocState::new(loaded.doc, Some(path.clone())) + .with_versions(loaded.versions) + .with_branches(loaded.branches) + .with_images_left_in_file(&path, loaded.images_past_budget); } // Edits on its branch tabs are reverted too. crate::branches::close_tabs(s, uid, None); @@ -555,6 +558,9 @@ fn load_with( if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { return Ok((r?, true)); } + if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); + } if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; link_instances(&mut doc); @@ -570,6 +576,219 @@ fn load_with( } } +/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try. +fn load_sketch(bytes: &[u8], name: &str) -> Option> { + if !figcraft_format::sketch::is_sketch(bytes) { + return None; + } + // The import and what follows read the installed fonts: the system's must be known first (the + // desktop app scans them in the background; this waits for that scan). + figcraft_text::fontdb::scan_system_fonts(); + let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face); + let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + match_font_families(&mut doc); + platform_tracking(&mut doc); + sketch_line_heights(&mut doc); + link_instances(&mut doc); + reflow_overridden_text(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + +/// Instances of a stack sized apart from their main are laid out at their size, and texts in +/// instances whose string an override changed are measured again with the auto layout around them +/// re-flowing (Sketch saves layouts measured with the master's string). The rest keeps the layout +/// Sketch saved. +fn reflow_overridden_text(doc: &mut Document) { + let changed: Vec = doc + .nodes() + .filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) { + (NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters, + _ => false, + }) + .map(|n| n.id) + .collect(); + let m = crate::measure::TextMeasure; + // Instances of a stack at another size than their main: the stack at their size, innermost first. + let mut resized: Vec<(usize, NodeId)> = doc + .nodes() + .filter(|n| n.layout.is_some()) + .filter(|n| match &n.kind { + NodeKind::Instance { component, .. } => { + doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6) + } + _ => false, + }) + .map(|n| (doc.ancestors(n.id).len(), n.id)) + .collect(); + resized.sort_by_key(|r| std::cmp::Reverse(r.0)); + for (_, id) in resized { + figcraft_layout::layout_frame(doc, id, &m); + } + for id in changed { + if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { + figcraft_layout::set_size(doc, id, w, h); + } + figcraft_layout::layout_up(doc, id, &m); + } +} + +/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from +/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and +/// case. +fn match_font_families(doc: &mut Document) { + let installed = figcraft_text::fontdb::family_names(); + let mut cache: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut fix = |style: &mut figcraft_doc::TextStyle| { + let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed)); + if let Some(f) = found { + style.font_family = f.clone(); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + fix(&mut td.style); + for r in &mut td.runs { + fix(&mut r.style); + } + } + } + // Shared text styles too, so they stay like the layers linked to them. + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + fix(style); + } + } +} + +/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine +/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so +/// lines measure and break as they did there. A named style's optical size follows the text size +/// there too (FigCraft keeps the instance's), so the imported text carries it as an `opsz` value. +fn platform_tracking(doc: &mut Document) { + let mut cache: std::collections::BTreeMap<(String, String), Option>> = + std::collections::BTreeMap::new(); + let mut face = |style: &figcraft_doc::TextStyle| { + cache + .entry((style.font_family.clone(), style.font_style.clone())) + .or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + }) + .clone() + }; + let mut track = |style: &mut figcraft_doc::TextStyle| { + let Some(f) = face(style) else { return }; + let t = f.tracking(style.font_size); + if t != 0.0 && t.is_finite() { + let px = style.letter_spacing.resolve(style.font_size); + style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t); + } + if f.axis("opsz").is_some() && f.instance(&style.font_style).is_some() && style.font_size.is_finite() { + style.variations.entry("opsz".into()).or_insert(style.font_size); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + track(&mut td.style); + for r in &mut td.runs { + track(&mut r.style); + } + } + } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + track(style); + } + } +} + +/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their +/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this. +const TALL_LINE_FAMILIES: [&str; 11] = + ["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"]; + +/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its +/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch +/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth +/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres +/// the font's own ascent and descent in the line. `(0, 0)` without metrics. +pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) { + let k = size / upem; + let (a, d) = (ascent * k, descent * k); + if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 { + return (0.0, 0.0); + } + let natural = a.round() + d.round(); + let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural }; + let sketch_baseline = line - d.round(); + let figcraft_baseline = (line - (a + d)) / 2.0 + a; + (line, sketch_baseline - figcraft_baseline) +} + +/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line, +/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a +/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text +/// styles take the line too. +fn sketch_line_heights(doc: &mut Document) { + // Per family and style: the face's ascent, descent and units per em. + type Metrics = Option<(f64, f64, f64)>; + let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new(); + let mut set = |style: &mut TextStyle| -> f64 { + if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 { + return 0.0; + } + let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + .map(|f| (f.ascent, f.descent, f.upem)) + }); + let Some((a, d, upem)) = m else { return 0.0 }; + let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size); + if line > 0.0 { + style.line_height = LineHeight::Percent(line / style.font_size * 100.0); + } + down + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + // An auto layout places its texts itself (its next pass would take a move back). + let laid_out = + doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let Some(n) = doc.node_mut(id) else { continue }; + let NodeKind::Text(td) = &mut n.kind else { continue }; + let down = set(&mut td.style); + for r in &mut td.runs { + set(&mut r.style); + } + // Down in the layer's own frame (a rotated text moves along its own axis). + if !laid_out && down.is_finite() && down != 0.0 { + n.transform *= figcraft_doc::geom::Affine::translate((0.0, down)); + } + } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + set(style); + } + } +} + +/// The installed family `family` means when it isn't installed under that exact name: the one +/// whose name is the same without spaces, ignoring case. +pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { + let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::(); + if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) { + return None; + } + let want = key(family); + installed.iter().find(|f| key(f) == want).cloned() +} + /// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { let r = figcraft_format::fig::try_import(bytes, name)?; @@ -738,7 +957,13 @@ fn open(s: &mut Session, p: &Value) -> Result { }); } let summary = import_summary(&loaded.doc); - s.docs.push(DocState::new(loaded.doc, native.then_some(path)).with_versions(loaded.versions).with_branches(loaded.branches)); + 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), + ); s.active = s.docs.len() - 1; let mut out = json!({"index": s.active, "summary": summary}); out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); @@ -826,7 +1051,9 @@ impl FileSnapshot { let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else { return Ok(false); }; - write(self.path.as_deref().ok_or(EngineError::NoDocument)?, bytes)?; + let path = self.path.as_deref().ok_or(EngineError::NoDocument)?; + refuse_dropping_images(ds, path)?; + write(path, bytes)?; // The history or branches may have changed since: then they still count as unsaved. if ds.revision == self.revision { ds.versions_changed = false; @@ -838,6 +1065,16 @@ impl FileSnapshot { } } +fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> { + match ds.images_lost_saving_to(path) { + Some(count) => Err(EngineError::Other(format!( + "{} wasn't saved: {count} of its images weren't loaded (together they pass what FigCraft reads from one file), and saving over it would delete them. Save it under another name; this file keeps them.", + file_stem(path) + ))), + None => 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)) { @@ -879,6 +1116,10 @@ fn save_to(s: &mut Session, path: Option) -> Result { } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + // A download (no path) writes over no file. + if let Some(path) = path.as_deref() { + refuse_dropping_images(ds, path)?; + } let mut snapshot = FileSnapshot::of(ds); let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); if let Some(path) = path.as_deref() @@ -1248,12 +1489,15 @@ fn export_path(name: &str) -> String { if parts.is_empty() { "Untitled".into() } else { parts.join("/") } } +/// Characters no file name may contain (path separators and ones file systems reject). +pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|"; + /// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and /// control characters become `_`); everything else is kept, up to the first 200 characters. /// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before /// the number and extension it adds). pub fn safe_file_name(name: &str) -> String { - name.chars().take(200).map(|c| if c.is_control() || "/\\:*?\"<>|".contains(c) { '_' } else { c }).collect() + name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect() } /// Layers on `page` that have export settings (document order). @@ -1384,7 +1628,7 @@ fn import(s: &mut Session, p: &Value) -> Result { } Ok(out) } - "figcraft" | "json" | "fig" => { + "figcraft" | "json" | "fig" | "sketch" => { let resolver = path.as_deref().map(rest_images); let images: figcraft_format::rest::ImageResolver = match &resolver { Some(r) => r, diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index a81a222c..7364557f 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -427,7 +427,7 @@ pub fn specs() -> Vec { spec!( "pref.language", "Interface language", - "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|zh-Hans|…; else English}", + "{language: system|en|cs|ru|es|es-419|fr|de|pt-BR|ja|ka|ko|vi|zh-Hans|…; else English}", false, |s, p| { let lang = req_string("pref.language", p, "language")?; diff --git a/crates/engine/src/code_connect.rs b/crates/engine/src/code_connect.rs index 08b3af02..d1c03fe3 100644 --- a/crates/engine/src/code_connect.rs +++ b/crates/engine/src/code_connect.rs @@ -37,7 +37,7 @@ 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::from_id(s).filter(|l| matches!(l, CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi)) } /// The layer `id`'s links are stored on: its component set, else its component (an instance's @@ -64,7 +64,7 @@ pub fn import_line(link: &CodeLink, lang: CodeLang) -> String { let path = &link.import_path; match lang { _ if path.is_empty() => String::new(), - CodeLang::React => format!("import {{ {} }} from {};", link.name.split('.').next().unwrap_or_default(), js_string(path)), + 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()), _ => format!("import {path}"), } @@ -267,6 +267,7 @@ fn expr(doc: &Document, c: &Component, p: &Param, v: &Value, lang: CodeLang) -> (Some(l), CodeLang::React) => format!("<{} />", l.name), (Some(l), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", l.name), (Some(l), CodeLang::SwiftUi) => format!("{}()", l.name), + (Some(l), CodeLang::ArkUi) => format!("() => {{ {}() }}", l.name), (Some(l), _) => format!("{{ {}() }}", l.name), (None, _) => value_expr(doc, c, p, v, lang), } @@ -317,6 +318,7 @@ fn default_template(c: &Component, lang: CodeLang, name: &str) -> String { format!("<{name}{} />", params.iter().map(|p| format!(" {}={{{p}}}", crate::component_codegen::rust_ident(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(", ")), } } @@ -334,13 +336,14 @@ pub fn check(c: &Component, link: &CodeLink, lang: CodeLang) -> std::result::Res } let path = &link.import_path; let path_ok = match lang { - CodeLang::React => !path.chars().any(|ch| ch.is_control() || ch == '"' || ch == '\\'), + 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 == ':'), _ => path.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '.'), }; if path.chars().count() > MAX_IMPORT || !path_ok { return Err(match lang { 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"), _ => format!("importPath: a module or package name (letters, digits, _ and .), up to {MAX_IMPORT} characters"), }); @@ -377,7 +380,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")), + Some(l) => language(&l).ok_or_else(|| bad(cmd, "language: react|leptos|swiftui|compose|arkui")), None => Ok(CodeLang::React), } } @@ -407,7 +410,7 @@ pub fn specs() -> Vec { spec!( "codeConnect.set", "Connect to code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (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|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"; @@ -522,7 +525,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), name and template strings", i + 1)); + warnings.push(format!("link {}: needs component, language (react|leptos|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 9d657d5f..6cdd8e67 100644 --- a/crates/engine/src/code_connect_tests.rs +++ b/crates/engine/src/code_connect_tests.rs @@ -72,6 +72,16 @@ fn connected_instances_show_the_real_snippet() { json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": ""}), ); assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n\n"); + // ArkUI: an ArkTS import and an object-literal call. + ex( + &mut s, + "codeConnect.set", + json!({"id": c, "language": "arkui", "importPath": "@acme/arkui", "template": "Card({ title: {title}, dotted: {Show dot} })"}), + ); + assert_eq!(code(&mut s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); + let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "arkui"})); + assert_eq!(v["usage"], "Card({ title: \"Say \\\"hi\\\"\", dotted: false })\n"); + assert_eq!(v["connected"]["import"], "import { Card } from \"@acme/arkui\";"); assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path"); ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"})); // inspect.componentCode: the snippet as the usage, and the link. @@ -105,7 +115,7 @@ fn connected_instances_show_the_real_snippet() { assert!(code(&mut s, i, "swiftui").starts_with("import AcmeUI")); ex(&mut s, "edit.undo", json!({})); assert!(code(&mut s, i, "react").starts_with("import { Card }")); - assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 2); + assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 3); assert!(s.execute("codeConnect.remove", &json!({"id": c})).is_err(), "nothing left"); } diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index c26084f5..520728bc 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -1,9 +1,9 @@ //! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the -//! layer itself, HTML or React for its subtree, and SwiftUI or Jetpack Compose (stacks for auto -//! layout, absolute offsets otherwise). Components become components whose properties are -//! parameters and instances become calls passing their values (see [`crate::component_codegen`]); -//! variables bound to a layer become CSS custom properties. The output is a starting point, not a -//! pixel-exact translation. +//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS; +//! stacks for auto layout, absolute offsets otherwise). Components become components whose +//! properties are parameters and instances become calls passing their values (see +//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The +//! output is a starting point, not a pixel-exact translation. use figcraft_color::{Color, PaintKind}; use figcraft_doc::*; @@ -18,13 +18,13 @@ pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", - "{id?, language?: css|html|react|leptos|tailwind|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", + "{id?, language?: css|html|react|leptos|tailwind|swiftui|compose|arkui (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", false, |s, p| { let cmd = "inspect.code"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; let lang = match string(p, "language") { - Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|swiftui|compose"))?, + Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|tailwind|swiftui|compose|arkui"))?, None => CodeLang::Css, }; let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { @@ -72,11 +72,13 @@ pub enum CodeLang { Tailwind, SwiftUi, Compose, + /// HarmonyOS ArkUI: an ArkTS struct (`@Component`, `build`). + ArkUi, } impl CodeLang { - pub const ALL: [CodeLang; 7] = - [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 8] = + [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", @@ -86,6 +88,7 @@ impl CodeLang { CodeLang::Tailwind => "tailwind", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", + CodeLang::ArkUi => "arkui", } } pub fn label(self) -> &'static str { @@ -97,6 +100,7 @@ impl CodeLang { CodeLang::Tailwind => "React + Tailwind", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", + CodeLang::ArkUi => "ArkUI", } } /// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths @@ -108,6 +112,9 @@ impl CodeLang { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { return Some(CodeLang::React); } + if s.eq_ignore_ascii_case("arkts") { + return Some(CodeLang::ArkUi); + } CodeLang::ALL.into_iter().find(|l| l.id().eq_ignore_ascii_case(s) || l.label().eq_ignore_ascii_case(s)) } } @@ -213,7 +220,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), CodeLang::Tailwind => crate::tailwind::code(doc, id, None), - CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), + CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi => crate::component_codegen::code(doc, id, lang), } } @@ -1202,6 +1209,232 @@ fn compose_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String) { } } +// ------------------------------------------------------------------ ArkUI (ArkTS) + +/// A colour as ArkUI writes it: `'#RRGGBB'`, or `'#AARRGGBB'` with transparency. +fn ark_color(c: Color) -> String { + let [r, g, b, a] = c.to_rgba8(); + if a == 255 { format!("'#{r:02X}{g:02X}{b:02X}'") } else { format!("'#{a:02X}{r:02X}{g:02X}{b:02X}'") } +} + +/// A layer's corner radii as `.borderRadius(...)` (uniform or per corner). +fn ark_radius(n: &Node, mods: &mut Vec) { + if let Some(r) = n.corners.uniform() { + if r > 0.0 { + mods.push(format!(".borderRadius({})", num(r))); + } + } else if n.corners.radii.iter().any(|r| *r > 0.0) { + let [tl, tr, br, bl] = n.corners.radii; + mods.push(format!(".borderRadius({{ topLeft: {}, topRight: {}, bottomRight: {}, bottomLeft: {} }})", num(tl), num(tr), num(br), num(bl))); + } +} + +/// ArkUI for a layer and its subtree at `depth`; `extra` attributes go last (a free child's +/// position). A layer a boolean parameter shows sits in an `if`. +pub(crate) fn arkui(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra: &[String]) { + let Some(n) = ctx.doc.node(id) else { return }; + if id != ctx.root + && let Some(p) = ctx.param(n, "visible") + { + let pad = indent(depth); + out.push_str(&format!("{pad}if (this.{}) {{\n", p.ident)); + arkui_layer(ctx, n, depth + 1, out, extra); + out.push_str(&format!("{pad}}}\n")); + return; + } + arkui_layer(ctx, n, depth, out, extra); +} + +fn arkui_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", one_line(&n.name))); + return; + } + // Nested instances are calls; an instance swap parameter is a builder. + let swap = ctx.param(n, "mainComponent").filter(|_| n.id != ctx.root); + let call = match swap { + Some(p) => Some(format!("{pad}this.{}()\n", p.ident)), + None => ctx.call(n, CodeLang::ArkUi, depth), + }; + if let Some(call) = call { + if let Some(p) = swap.filter(|_| !extra.is_empty()) { + // The builder returns nothing, so a position goes on a container around the call. + out.push_str(&format!("{pad}Stack() {{\n{pad} this.{}()\n{pad}}}\n", p.ident)); + for m in extra { + out.push_str(&format!("{pad}{m}\n")); + } + return; + } + out.push_str(&call); + for m in extra { + out.push_str(&format!("{pad} {m}\n")); + } + return; + } + let mut mods: Vec = Vec::new(); + match &n.kind { + NodeKind::Text(t) => { + let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident)); + out.push_str(&format!("{pad}Text({text})\n")); + let s = &t.style; + mods.push(format!(".fontSize({})", num(s.font_size))); + mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900))); + mods.push(format!(".fontFamily({})", literal(&s.font_family, false))); + if s.italic() { + mods.push(".fontStyle(FontStyle.Italic)".into()); + } + if let Some(c) = first_fill(n) { + mods.push(format!(".fontColor({})", ark_color(c))); + } + match s.line_height { + LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))), + LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))), + LineHeight::Auto => {} + } + match s.letter_spacing { + LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))), + LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))), + _ => {} + } + match t.align_h { + TextAlignH::Left => {} + TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()), + TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()), + TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()), + } + // Aligning needs a box to align in: the text layer's own width. + if !matches!(t.align_h, TextAlignH::Left) { + mods.push(format!(".width({})", num(n.width))); + } + match s.case { + TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()), + TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()), + _ => {} + } + match s.decoration { + TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()), + TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()), + TextDecoration::None => {} + } + } + NodeKind::Ellipse { .. } => { + out.push_str(&format!("{pad}Ellipse({{ width: {}, height: {} }})\n", num(n.width), num(n.height))); + if let Some(c) = first_fill(n) { + mods.push(format!(".fill({})", ark_color(c))); + } + if let Some(c) = stroke_color(n) { + mods.push(format!(".stroke({})", ark_color(c))); + mods.push(format!(".strokeWidth({})", num(n.stroke.weight))); + } + } + NodeKind::Rectangle => { + out.push_str(&format!("{pad}Column()\n")); + if let Some(c) = first_fill(n) { + mods.push(format!(".backgroundColor({})", ark_color(c))); + } + ark_radius(n, &mut mods); + mods.push(format!(".width({})", num(n.width))); + mods.push(format!(".height({})", num(n.height))); + } + k if k.is_frame_like() || matches!(k, NodeKind::Group) => { + let open = match n.layout.as_ref().map(|l| (l.mode, l)) { + Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)), + Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)), + None => "Stack({ alignContent: Alignment.TopStart })".to_string(), + }; + out.push_str(&format!("{pad}{open} {{\n")); + match ctx.param(n, "slot") { + // A slot shows the instance's content, or else the main's (its default builder). + Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)), + None => { + for c in ctx.kids(n) { + // Free children sit at their position inside a Stack, and so does a child + // that ignores its auto layout parent. + let pos: Vec = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) { + Some(cn) => { + let [_, _, _, _, x, y] = cn.transform.as_coeffs(); + vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))] + } + None => Vec::new(), + }; + arkui(ctx, c, depth + 1, out, &pos); + } + } + } + out.push_str(&format!("{pad}}}\n")); + if let Some(l) = &n.layout { + let [t, r, b, lft] = l.padding; + if [t, r, b, lft].iter().any(|v| *v > 0.0) { + mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft))); + } + let justify = match l.main_align { + MainAlign::Min => None, + MainAlign::Center => Some("FlexAlign.Center"), + MainAlign::Max => Some("FlexAlign.End"), + MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"), + MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"), + MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"), + }; + if let Some(j) = justify { + mods.push(format!(".justifyContent({j})")); + } + let cross = match l.cross_align { + CrossAlign::Min => None, + CrossAlign::Center => Some("Center"), + CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }), + CrossAlign::Baseline => None, + }; + if let Some(c) = cross { + let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" }; + mods.push(format!(".alignItems({ty}.{c})")); + } + } + mods.push(format!(".width({})", num(n.width))); + mods.push(format!(".height({})", num(n.height))); + if let Some(c) = first_fill(n) { + mods.push(format!(".backgroundColor({})", ark_color(c))); + } + if radius(n) > 0.0 { + mods.push(".clip(true)".into()); + } + ark_radius(n, &mut mods); + } + _ => { + out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image($r('app.media.asset'))\n", n.kind.label())); + mods.push(format!(".width({})", num(n.width))); + mods.push(format!(".height({})", num(n.height))); + } + } + // Ellipses take their stroke as a shape attribute (above). + if !matches!(n.kind, NodeKind::Ellipse { .. }) + && let Some(c) = stroke_color(n) + { + mods.push(format!(".border({{ width: {}, color: {} }})", num(n.stroke.weight), ark_color(c))); + } + if let Some(s) = drop_shadow(n) { + mods.push(format!( + ".shadow({{ radius: {}, color: {}, offsetX: {}, offsetY: {} }})", + num(s.radius / 2.0), + ark_color(s.color), + num(s.offset.0), + num(s.offset.1) + )); + } + if n.opacity < 1.0 { + mods.push(format!(".opacity({})", num(f64::from(n.opacity)))); + } + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 0.01 && rot.is_finite() { + mods.push(format!(".rotate({{ angle: {} }})", num(-rot))); + } + mods.extend(extra.iter().cloned()); + for m in mods { + out.push_str(&format!("{pad} {m}\n")); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1249,6 +1482,33 @@ mod tests { assert!(s.execute("inspect.code", &json!({"id": f, "language": "cobol"})).is_err()); } + #[test] + fn arkui_for_an_auto_layout_card() { + let mut s = Session::new(); + let f = card(&mut s); + let doc = s.doc().unwrap(); + let ark = code_for(doc, f, CodeLang::ArkUi); + for want in [ + "Row({ space: ", + "Text(\"Say \\\"hi\\\" $5\\nnow\")", + ".fontSize(", + ".fontWeight(", + "Ellipse({ width: 24, height: 24 })", + ".backgroundColor('#FFFFFF')", + ".borderRadius(12)", + ".width(", + ".height(100)", + ".clip(true)", + ] { + assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}"); + } + // Through the command, and "arkts" reads as ArkUI too. + let v = s.execute("inspect.code", &json!({"id": f, "language": "arkui"})).unwrap(); + assert_eq!(v["code"].as_str().unwrap(), ark); + assert_eq!(v["language"], "arkui"); + assert_eq!(s.execute("inspect.code", &json!({"id": f, "language": "arkts"})).unwrap()["language"], "arkui"); + } + #[test] fn free_children_get_offsets_and_deep_trees_are_capped() { let mut s = Session::new(); @@ -1264,9 +1524,59 @@ mod tests { assert!(sw.contains("…"), "depth capped"); let kt = code_for(doc, root, CodeLang::Compose); assert!(kt.contains("Modifier.offset(7.dp, 9.dp)")); + let ark = code_for(doc, root, CodeLang::ArkUi); + assert!(ark.contains(".position({ x: 7, y: 9 })"), "{ark}"); + assert!(ark.contains("…"), "depth capped"); assert!(code_for(doc, NodeId(999_999), CodeLang::Css).is_empty()); } + #[test] + fn arkui_aligned_text_gets_the_layers_width() { + let mut s = Session::new(); + let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap(); + let t = id_of(&s.execute("text.create", &json!({"text": "Hi", "x": 0, "y": 0, "parent": f})).unwrap()["id"]).unwrap(); + s.execute("node.set", &json!({"id": t, "props": {"textAlign": "center", "width": 120.0}})).unwrap(); + let doc = s.doc().unwrap(); + let ark = code_for(doc, f, CodeLang::ArkUi); + assert!(ark.contains(".textAlign(TextAlign.Center)"), "{ark}"); + let width = doc.node(t).map(|n| n.width).unwrap_or_default(); + assert!(ark.contains(&format!(".width({})", num(width))), "the alignment has a box: {ark}"); + } + + #[test] + fn arkui_places_a_child_that_ignores_its_auto_layout_parent() { + let mut s = Session::new(); + let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap(); + let c = id_of(&s.execute("shape.rectangle", &json!({"x": 7, "y": 9, "width": 20, "height": 20, "parent": f})).unwrap()["id"]).unwrap(); + s.execute("node.set", &json!({"id": c, "props": {"absolute": true}})).unwrap(); + s.execute("layout.add", &json!({"ids": [f]})).unwrap(); + let doc = s.doc().unwrap(); + let [_, _, _, _, x, y] = doc.node(c).unwrap().transform.as_coeffs(); + let ark = code_for(doc, f, CodeLang::ArkUi); + assert!(ark.contains("Row(") || ark.contains("Column("), "the parent is still auto layout: {ark}"); + assert!(ark.contains(&format!(".position({{ x: {}, y: {} }})", num(x), num(y))), "the absolute child keeps its place: {ark}"); + } + + #[test] + fn arkui_wraps_a_positioned_instance_swap_so_the_position_lands_on_a_container() { + let mut s = Session::new(); + let icon = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 400, "width": 24, "height": 24, "name": "Icon"})).unwrap()["id"]).unwrap(); + s.execute("shape.star", &json!({"x": 0, "y": 0, "width": 24, "height": 24, "parent": icon})).unwrap(); + let heart = id_of(&s.execute("component.create", &json!({"ids": [icon]})).unwrap()["id"]).unwrap(); + let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 120, "height": 40, "name": "Chip"})).unwrap()["id"]).unwrap(); + let nested = id_of(&s.execute("instance.create", &json!({"component": heart, "x": 8, "y": 8, "parent": f})).unwrap()["id"]).unwrap(); + let chip = id_of(&s.execute("component.create", &json!({"ids": [f]})).unwrap()["id"]).unwrap(); + s.execute("component.addProperty", &json!({"id": chip, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested})).unwrap(); + let doc = s.doc().unwrap(); + let ark = code_for(doc, chip, CodeLang::ArkUi); + assert!(ark.contains("Stack() {\n"), "the builder is wrapped: {ark}"); + assert!(ark.contains("this.icon()"), "{ark}"); + // The position follows the wrapper's closing brace, not the void builder call. + let lines: Vec<&str> = ark.lines().collect(); + let at = lines.iter().position(|l| l.contains(".position({ x: 8, y: 8 })")).unwrap_or_else(|| panic!("no position: {ark}")); + assert_eq!(lines[at - 1].trim(), "}", "the position is on a container: {ark}"); + } + #[test] fn too_deep_placeholder_keeps_a_multiline_layer_name_in_the_comment() { let mut s = Session::new(); @@ -1279,7 +1589,7 @@ mod tests { .unwrap(); } let doc = s.doc().unwrap(); - for lang in [CodeLang::SwiftUi, CodeLang::Compose] { + for lang in [CodeLang::SwiftUi, CodeLang::Compose, CodeLang::ArkUi] { let code = code_for(doc, root, lang); assert!(code.contains("// deep evil() more() …\n"), "{code}"); for line in code.lines() { diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index 11f5e7da..9a513454 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -1,16 +1,17 @@ -//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI or Jetpack -//! Compose component whose properties are its parameters (a variant property → a union or enum, boolean → -//! a flag showing the layers bound to it, text → a string, instance swap → a view parameter, slot → -//! optional content), and an instance becomes a call passing the values it sets. Variants of a set -//! share one definition (the set's default variant); in React and Leptos their differences become CSS -//! rules on `data-*` attributes. Returned by `inspect.componentCode` and used by `inspect.code`. A component -//! connected to code (see [`crate::code_connect`]) shows its real snippet instead. +//! Component code: a component (or component set) becomes a React, Leptos, SwiftUI, Jetpack +//! Compose or ArkUI component whose properties are its parameters (a variant property → a union or +//! enum, boolean → a flag showing the layers bound to it, text → a string, instance swap → a view +//! parameter, slot → optional content), and an instance becomes a call passing the values it sets. +//! Variants of a set share one definition (the set's default variant); in React and Leptos their +//! differences become CSS rules on `data-*` attributes. Returned by `inspect.componentCode` and +//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its +//! real snippet instead. use figcraft_doc::*; use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::codegen::{CodeLang, Ctx, Markup, class_name, compose, indent, literal, swift}; +use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift}; use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set}; use crate::params::*; use crate::{bad, spec}; @@ -67,14 +68,14 @@ pub fn specs() -> Vec { vec![spec!( "inspect.componentCode", "Component code", - "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}", + "{id? (component, set or instance; default the selection), language?: react|leptos|swiftui|compose|arkui (default react)} → {component, name, set, properties: [{name, param, type, default, value? (instances)}], definition, usage? (instances; the connected snippet when the component is connected to code), connected: {importPath, name, template, import, code} | null}", false, |s, p| { let cmd = "inspect.componentCode"; let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component or instance"))?; let lang = match string(p, "language").map(|l| CodeLang::from_id(&l)) { - Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose))) => l, - Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose")), + Some(Some(l @ (CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose | CodeLang::ArkUi))) => l, + Some(_) => return Err(bad(cmd, "language: react|leptos|swiftui|compose|arkui")), None => CodeLang::React, }; let doc = s.doc()?; @@ -107,9 +108,10 @@ pub fn specs() -> Vec { )] } -/// Code for any layer in React, Leptos, SwiftUI or Compose: an instance's call and its component's -/// definition, a component's (or set's) definition, or the layer tree (React and Leptos wrap it -/// in a function component), with the imports of the connected components it calls at the top. +/// Code for any layer in React, Leptos, SwiftUI, Compose or ArkUI: an instance's call and its +/// component's definition, a component's (or set's) definition, or the layer tree (React and +/// Leptos wrap it in a function component), with the imports of the connected components it calls +/// at the top. pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { let Some(n) = doc.node(id) else { return String::new() }; if let Some(code) = crate::code_connect::link(doc, id, lang).and_then(|l| crate::code_connect::connected_code(doc, id, lang, l)) { @@ -129,6 +131,11 @@ pub fn code(doc: &Document, id: NodeId, lang: CodeLang) -> String { swift(&Ctx::new(doc, id), id, 0, &mut out, &[]); out } + CodeLang::ArkUi => { + let mut out = String::new(); + arkui(&Ctx::new(doc, id), id, 0, &mut out, &[]); + out + } _ => { let mut out = String::new(); compose(&Ctx::new(doc, id), id, 0, &mut out); @@ -295,7 +302,7 @@ const RESERVED: &[&str] = &[ "yield", ]; -/// Types the generated SwiftUI and Compose code names: a variant enum may not shadow them. +/// Types the generated SwiftUI, Compose and ArkUI code names: a variant enum may not shadow them. const TYPES: &[&str] = &[ "Alignment", "AnyView", @@ -304,6 +311,7 @@ const TYPES: &[&str] = &[ "Boolean", "Box", "CaseIterable", + "Circle", "CircleShape", "Color", "Column", @@ -312,21 +320,30 @@ const TYPES: &[&str] = &[ "EdgeInsets", "Ellipse", "EmptyView", + "FlexAlign", "Font", + "FontStyle", "FontWeight", "Group", "HStack", + "HorizontalAlign", "Image", "Modifier", + "Rect", "RectangleShape", "RoundedCornerShape", "RoundedRectangle", "Row", "Shape", + "Stack", "String", "Text", + "TextAlign", + "TextCase", + "TextDecorationType", "Unit", "VStack", + "VerticalAlign", "View", "ZStack", ]; @@ -488,6 +505,9 @@ pub(crate) fn value_expr(doc: &Document, c: &Component, p: &Param, value: &Value (Some(t), CodeLang::Leptos) => format!("|| view! {{ <{} /> }}", rust_type(&t)), (None, CodeLang::Leptos) => "|| ()".into(), (Some(t), CodeLang::SwiftUi) => format!("AnyView({t}())"), + // ArkUI takes a builder: `@BuilderParam` is a `() => void`. + (Some(t), CodeLang::ArkUi) => format!("() => {{ {t}() }}"), + (None, CodeLang::ArkUi) => "() => {}".into(), (Some(t), _) => format!("{{ {t}() }}"), (None, CodeLang::React) => "null".into(), (None, CodeLang::SwiftUi) => "AnyView(EmptyView())".into(), @@ -566,6 +586,11 @@ pub fn usage(doc: &Document, id: NodeId, lang: CodeLang, depth: usize) -> Option format!("{pad}<{t}{attrs} />\n") } CodeLang::SwiftUi => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", ")), + CodeLang::ArkUi => { + let list = args.iter().map(|(n, e, _)| format!("{n}: {e}")).collect::>().join(", "); + // ArkTS calls take an object literal, or nothing at all. + if list.is_empty() { format!("{pad}{t}()\n") } else { format!("{pad}{t}({{ {list} }})\n") } + } _ => format!("{pad}{t}({})\n", args.iter().map(|(n, e, _)| format!("{n} = {e}")).collect::>().join(", ")), }) } @@ -598,6 +623,13 @@ fn slot_content(doc: &Document, slot: NodeId, lang: CodeLang, depth: usize) -> S } format!("AnyView(Group {{\n{out}{}}})", indent(depth + 1)) } + CodeLang::ArkUi => { + let mut out = String::new(); + for k in kids { + arkui(&ctx, k, depth + 2, &mut out, &[]); + } + format!("() => {{\n{out}{}}}", indent(depth + 1)) + } _ => { let mut out = String::new(); for k in kids { @@ -616,6 +648,7 @@ pub fn definition(doc: &Document, c: &Component, lang: CodeLang) -> String { CodeLang::SwiftUi => swift_definition(doc, c), CodeLang::Compose => compose_definition(doc, c), CodeLang::Leptos => leptos(doc, c), + CodeLang::ArkUi => arkui_definition(doc, c), _ => react(doc, c), } } @@ -812,3 +845,67 @@ fn compose_definition(doc: &Document, c: &Component) -> String { out.push_str("}\n"); out } + +/// An ArkUI (`@Component`) struct: variant enums, `@Prop` parameters, a default builder for each +/// instance swap and slot, and the `build` body. +fn arkui_definition(doc: &Document, c: &Component) -> String { + let ctx = Ctx { doc, root: c.comp, comp: Some(c), base: 1 }; + let mut out = String::new(); + for p in &c.params { + if let ParamKind::Variant(opts) = &p.kind { + out.push_str(&format!("export enum {} {{ {} }}\n\n", enum_name(c, p, false), cases(opts, false).join(", "))); + } + } + // A default builder per instance swap or slot, named apart from every parameter. + let mut used: Vec = c.params.iter().map(|p| p.ident.clone()).collect(); + let mut builders: Vec<(String, String)> = Vec::new(); + for p in &c.params { + if !matches!(p.kind, ParamKind::InstanceSwap | ParamKind::Slot) { + continue; + } + let base = format!("default{}Builder", capitalized(&p.ident)); + let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}{k}") }).find(|n| !used.contains(n)).unwrap_or(base); + used.push(name.clone()); + builders.push((p.ident.clone(), name)); + } + out.push_str("@Component\nexport struct "); + out.push_str(&c.type_name); + out.push_str(" {\n"); + for p in &c.params { + match &p.kind { + ParamKind::Variant(_) => { + out.push_str(&format!(" @Prop {}: {} = {}\n", p.ident, enum_name(c, p, false), value_expr(doc, c, p, &p.default, CodeLang::ArkUi))) + } + ParamKind::Boolean => out.push_str(&format!(" @Prop {}: boolean = {}\n", p.ident, p.default.as_bool().unwrap_or(false))), + ParamKind::Text => out.push_str(&format!(" @Prop {}: string = {}\n", p.ident, value_expr(doc, c, p, &p.default, CodeLang::ArkUi))), + ParamKind::InstanceSwap | ParamKind::Slot => { + let name = builders.iter().find(|(ident, _)| *ident == p.ident).map_or("", |(_, name)| name.as_str()); + out.push_str(&format!(" @BuilderParam {}: () => void = this.{name}\n", p.ident)); + } + } + } + for (ident, name) in &builders { + let Some(p) = c.params.iter().find(|p| p.ident == *ident) else { continue }; + out.push_str(&format!("\n @Builder\n {name}() {{\n")); + match &p.kind { + ParamKind::InstanceSwap => { + if let Some(t) = id_of(&p.default).and_then(|i| component_type(doc, i)) { + out.push_str(&format!(" {t}()\n")); + } + } + ParamKind::Slot => { + let layer = slots_of(doc, c.comp).into_iter().find(|(_, slot)| slot == &p.prop).map(|(layer, _)| layer); + let kids = layer.and_then(|l| doc.node(l)).map(|n| ctx.kids(n)).unwrap_or_default(); + for k in kids { + arkui(&ctx, k, 2, &mut out, &[]); + } + } + _ => {} + } + out.push_str(" }\n"); + } + out.push_str("\n build() {\n"); + arkui(&ctx, c.comp, 2, &mut out, &[]); + out.push_str(" }\n}\n"); + out +} diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index 54ca80c1..dc14bcd0 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -83,6 +83,17 @@ fn components_take_their_properties_as_parameters() { for want in ["@Composable\nfun Card(", " showDot: Boolean = true,", " title: String = \"Hello\",", "if (showDot) {", "text = title,"] { assert!(kt.contains(want), "Compose has {want:?}:\n{kt}"); } + let ark = code(&mut s, c, "arkui"); + for want in [ + "@Component\nexport struct Card {", + " @Prop showDot: boolean = true", + " @Prop title: string = \"Hello\"", + " build() {", + " if (this.showDot) {", + "Text(this.title)", + ] { + assert!(ark.contains(want), "ArkUI has {want:?}:\n{ark}"); + } let leptos = code(&mut s, c, "leptos"); for want in [ "use leptos::prelude::*;\n\n#[component]\npub fn Card(\n", @@ -129,12 +140,14 @@ fn instances_are_calls_passing_the_values_they_set() { assert!(react.contains("export function Card({")); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Buy\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Buy\")\n"); + assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Buy\" })\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); // Text that needs escaping is an expression in JSX and escaped per language. ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Title": "Say \"hi\" $5\nnow"}})); assert_eq!(component_code(&mut s, i, "react")["usage"], "\n"); assert_eq!(component_code(&mut s, i, "swiftui")["usage"], "Card(showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\")\n"); assert_eq!(component_code(&mut s, i, "compose")["usage"], "Card(showDot = false, title = \"Say \\\"hi\\\" \\$5\\nnow\")\n"); + assert_eq!(component_code(&mut s, i, "arkui")["usage"], "Card({ showDot: false, title: \"Say \\\"hi\\\" $5\\nnow\" })\n"); assert_eq!(component_code(&mut s, i, "leptos")["usage"], "\n"); } @@ -185,6 +198,9 @@ fn variant_sets_share_one_definition() { let kt = code(&mut s, set, "compose"); assert!(kt.contains("enum class ButtonState { Default, Hover }"), "{kt}"); assert!(kt.contains(" state: ButtonState = ButtonState.Default,"), "{kt}"); + let ark = code(&mut s, set, "arkui"); + assert!(ark.contains("export enum ButtonState { Default, Hover }"), "{ark}"); + assert!(ark.contains(" @Prop state: ButtonState = ButtonState.Default"), "{ark}"); let leptos = code(&mut s, set, "leptos"); for want in [ "#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]\npub enum ButtonState {\n #[default]\n Default,\n Hover,\n}", @@ -201,6 +217,7 @@ fn variant_sets_share_one_definition() { assert_eq!(component_code(&mut s, i, "react")["usage"], "