diff --git a/ROADMAP.md b/ROADMAP.md index b4dcba4b..4fc95c96 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-24 · **Change:** minor (boards: the dot grid, connector ends dragged to re-attach, curved connectors; 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-24 · **Change:** minor (boards: the dot grid, connector ends dragged to re-attach, curved connectors; Dev Mode measurements kept on the canvas and a read-only layers panel; 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 @@ -86,7 +86,7 @@ native speakers' reviews of the nine language drafts need people. | Styles & variables | 95% | 76% | 2–4 | [components-parity](docs/components-parity.md) | | Prototyping | 92% | 74% | 2–5 | [prototyping-parity](docs/prototyping-parity.md) | | Import/export | 86% | 58% | 5–10 | [file-format-parity](docs/file-format-parity.md) | -| Dev Mode / inspect | 97% | 76% | 2–4 | [dev-mode-parity](docs/dev-mode-parity.md) | +| Dev Mode / inspect | 98% | 78% | 1–2 | [dev-mode-parity](docs/dev-mode-parity.md) | | Comments, versions, misc | 87% | 63% | 1–3 | [collaboration-parity](docs/collaboration-parity.md) | | Multiplayer & sharing | 17% | 5% | 42–84 | [collaboration-parity](docs/collaboration-parity.md) | | Plugins & widgets | 60% | 35% | 8–26 | [plugins](docs/plugins.md) | @@ -133,6 +133,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log - **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** · 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** · 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-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. @@ -142,6 +143,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **2026-10-15** · Zoom previews (M8, #312): when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it. Numbers unchanged. - **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged. - **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged. +- **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged. - **2026-10-17** · Control channel while the window is minimised (M8, #720): a minimised or covered window answers every request that acts on the app's state (edits, `ui.inspect`, `ui.pointer`, `ui.key`, `ui.type`, `ui.set`, `app.quit`…) without drawing, and what needs a drawn frame (`ui.screenshot`, `ui.input`, `window.new`) is an error at once; before, those waited up to 60 s and held up every later request on the connection (an attached agent's `inspect_ui` or window screenshot stalled until the window came back). Measured on Windows 11, minimised: requests ~100 ms each (eframe wakes a minimised window at most ten times a second; ~2 ms on screen), a window screenshot fails in ~100 ms instead of 60 s, idle CPU 0. Numbers unchanged. - **2026-10-17** · Number variables on text content (M3, #629): the Typography section's variable picker also lists number variables for a text layer's content, as the engine already accepted (the text shows the number, e.g. 42.5, and follows the variable). Other text fields keep their own types. Numbers unchanged. - **2026-10-17** · Frames and sections convert into each other (M1, #719): `object.convertToSection` turns a frame on the page or in a section into a section in place (same box, name, fill and layers, which stay where they are; what only a frame has goes: auto layout, clipping, layout grids, guides, scrolling), and `object.convertToFrame` now also turns a section into a frame that keeps its fill and doesn't clip, so the look stays. Nested frames, instances, main components and sections holding sections are refused with the reason; one undo step each. Both are in the canvas context menu (Convert to section / Convert to frame) and in Spanish. Numbers unchanged. @@ -329,6 +331,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| | 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 | 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 | 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-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 | @@ -379,6 +382,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | 2026-10-15 | minor | Linux packaging (#346, #328): the window id matches the desktop entry (FreeBSD keeps its class); the RPM requires glibc 2.35. Numbers unchanged | | 2026-10-15 | minor | Tests (#472, #471, #339): the rename shortcut test, the hanging punctuation test and the font-scanning tests no longer depend on the platform, font or test order. Numbers unchanged | | 2026-10-15 | minor | Languages (#257, #263, #469, #477, #288): Russian (with one/few/many plural forms) and Turkish catalogs, reviewed by native speakers; Czech and Indonesian drafts; the Japanese draft completed. 2,177 entries now (1,587 UI strings, 559 command labels, 31 menu wordings); strings added since a catalog was written read English there. Localization 39% and 29–51 h unchanged at this rounding (Indonesian is now a draft, while the five older drafts lack 88 newer entries) | +| 2026-10-22 | minor | Type settings keeps its width (#1029): in languages whose labels run past the 110 px label column (French "Retrait de paragraphe", "Espacement de liste"), the List spacing, Paragraph indent, Max lines and decoration rows were wider than the popover, which then widened every frame while open. Those fields now take the width left after their label; English is unchanged. Regression test in `type_settings_tests`. Numbers unchanged. | +| 2026-10-22 | minor | `fill`/`stroke` props accept `"none"` (either case) and `null` as no paint (M1, #1036): mapped to an empty fills/strokes list, as `fills: []` already did (undocumented), so an outline-only shape is one call; `KEYS` and `docs/mcp.md` document it, the error names the accepted forms, `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged | | 2026-10-20 | minor | A document dropped on the canvas opens (#916): a `.figcraft`, `.fig` or file JSON dropped on the canvas opens as a file of its own, as on the home screen, in the browser too; it used to add its pages to the file in front. Images and SVG still import at the drop point, File ▸ Import still adds pages, and dragging only documents over the canvas says "Drop to open" (not in the browser, which names no file until the drop). Numbers unchanged | | 2026-10-20 | minor | Duplicating a main component makes an instance (#892): Duplicate and Alt-drag copied a main component as a second main; within its file the copy is now an instance of it, as Copy then Paste already was (a variant duplicated in its set stays a variant). Numbers unchanged. | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 4c162dbb..3fe17e80 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -1,6 +1,6 @@ //! Minimal std-only argument parsing: positionals, `--key value` / `--key=value` / `-o value` //! options (given again: the last counts, or all with [`Args::values`]), and boolean `--flag`s. -//! Unknown options are usage errors. +//! Unknown options are usage errors; `--help` or `-h` is [`CliError::Help`]. use std::collections::BTreeMap; @@ -16,6 +16,8 @@ pub struct Args { impl Args { pub fn parse(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult { let mut a = Args::default(); + // The first bad option, reported once the rest of the line is checked for `--help`. + let mut bad: Option = None; let mut it = argv.iter(); while let Some(arg) = it.next() { let name = if let Some(n) = arg.strip_prefix("--") { @@ -30,19 +32,27 @@ impl Args { Some((n, v)) => (n, Some(v.to_string())), None => (name, None), }; + // `--help` / `-h` anywhere among the options (not as an option's value) asks for help. + if inline.is_none() && matches!(name, "help" | "h") { + return Err(CliError::Help); + } if valued.contains(&name) { - let v = match inline { - Some(v) => v, - None => it.next().cloned().ok_or_else(|| CliError::Usage(format!("`{arg}` needs a value")))?, - }; - a.values.entry(name.to_string()).or_default().push(v); + match inline.or_else(|| it.next().cloned()) { + Some(v) => a.values.entry(name.to_string()).or_default().push(v), + None => { + bad.get_or_insert(CliError::Usage(format!("`{arg}` needs a value"))); + } + } } else if flags.contains(&name) && inline.is_none() { a.flags.push(name.to_string()); } else { - return Err(CliError::Usage(format!("unknown option `{arg}`"))); + bad.get_or_insert(CliError::Usage(format!("unknown option `{arg}`"))); } } - Ok(a) + match bad { + Some(e) => Err(e), + None => Ok(a), + } } pub fn value(&self, k: &str) -> Option { @@ -135,4 +145,18 @@ mod tests { let a = Args::parse(&v(&["-5"]), &[], &[]).unwrap(); assert_eq!(a.positional, ["-5"]); } + + #[test] + fn help_is_asked_for_anywhere_but_as_a_value() { + // Also after a bad option: help wins over the usage error (#1161 review). + for argv in + [&["--help"][..], &["-h"], &["in.figcraft", "-o", "out", "--help"], &["--json", "-h"], &["--bogus", "--help"], &["--json=1", "-h"]] + { + assert!(matches!(Args::parse(&v(argv), &["o"], &["json"]), Err(CliError::Help)), "{argv:?}"); + } + let a = Args::parse(&v(&["--filter", "--help", "-o", "-h"]), &["filter", "o"], &[]).unwrap(); + assert_eq!((a.value("filter").as_deref(), a.value("o").as_deref()), (Some("--help"), Some("-h"))); + // `--help=x` is no request for help, just an unknown option. + assert!(matches!(Args::parse(&v(&["--help=x"]), &[], &[]), Err(CliError::Usage(_)))); + } } diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 37a6aae4..b87aa431 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -208,10 +208,13 @@ pub fn info(path: &str, as_json: bool) -> CliResult { Ok(ExitCode::SUCCESS) } +/// The file name a layer exports to: the app's rule ([`figcraft_engine::cmd::file::safe_file_name`], +/// so `/` becomes `_` too), without leading or trailing spaces and dots, at most 100 characters +/// and 200 bytes (file systems allow 255, and a number and the extension still fit). fn sanitize(name: &str) -> String { - let s: String = name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '-' | '_' | ' ' | '.') { c } else { '_' }).collect(); - let s = s.trim().trim_matches('.').to_string(); - if s.is_empty() { "layer".into() } else { s.chars().take(100).collect() } + let s = figcraft_engine::cmd::file::safe_file_name(name); + let s = s.trim_matches(|c: char| c == '.' || c.is_whitespace()); + if s.is_empty() { "layer".into() } else { s.char_indices().take(100).take_while(|(i, c)| i + c.len_utf8() <= 200).map(|(_, c)| c).collect() } } /// What `export` exports and how. @@ -412,6 +415,14 @@ mod tests { assert_eq!(sanitize("Home / Hero"), "Home _ Hero"); assert_eq!(sanitize("../.."), "_"); assert_eq!(sanitize(""), "layer"); + // The same characters the app's export keeps (#1154): only what file systems refuse goes. + assert_eq!(sanitize("Homepage - desktop (live + verbeteringen)"), "Homepage - desktop (live + verbeteringen)"); + assert_eq!(sanitize("Q&A, 50% #1 [v2] {x} ~é"), "Q&A, 50% #1 [v2] {x} ~é"); + assert_eq!(sanitize("a\\b:c*d?e\"fh|i\tj"), "a_b_c_d_e_f_g_h_i_j"); + assert_eq!(sanitize(" .hidden. "), "hidden"); + assert_eq!(sanitize(" . .name. . "), "name", "spaces and dots at the edges, mixed"); + assert_eq!(sanitize(&"x".repeat(300)).chars().count(), 100); + assert_eq!(sanitize(&"\u{1F600}".repeat(100)), "\u{1F600}".repeat(50), "at most 200 bytes, whole characters"); } #[test] diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index badc5bc3..66627673 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -27,7 +27,7 @@ USAGE: figcraft-cli mcp [--attach PORT] [--automation-read-root DIR]... [--automation-write-root DIR]... figcraft-cli plugin publish --server ws://host:port/[?key=KEY] figcraft-cli perf [--layers N[,N...]] [--instances N[,N...]] - figcraft-cli help | --help | --version + figcraft-cli help | --help | --version (a subcommand followed by --help or -h prints this too) Every subcommand takes --no-system-fonts (or set FIGCRAFT_NO_SYSTEM_FONTS=1): text uses only the fonts built into FigCraft, for renders that match on every machine. Otherwise installed fonts are @@ -46,10 +46,11 @@ the FIGCRAFT_SERVER_KEY environment variable (which keeps it out of the shell hi `--automation-read-root DIR` / `--automation-write-root DIR` (each may be repeated; the folders must exist) confine the files a subcommand touches: reads only inside a read or write root, writes only inside a write root, everything else refused -- so giving no write root denies writing -altogether. They are taken by `mcp`, to confine the agent's commands, and by `info`, `export`, -`convert`, `render` and `run`, to confine that one run; without them a subcommand reads and writes -as the user who started it, as before. A running app confines itself: start it with -`figcraft --control PORT` and the same flags (`--attach` refuses them). +altogether. They reach the headless side only: `mcp` takes them to confine the agent's commands, +and `info`, `export`, `convert`, `render` and `run` to confine that one run; without them a +subcommand reads and writes as the user who started it, as before. A `mcp --attach` server is +confined by the app it attaches to, not by flags passed to the CLI: start that app with +`figcraft --control PORT` and the same roots (`--attach` refuses them). See docs/cli.md and docs/mcp.md."; /// The automation roots a file subcommand was given, if any. Omitting both leaves the run @@ -74,6 +75,10 @@ fn main() -> ExitCode { let argv: Vec = std::env::args().skip(1).collect(); match run(&argv) { Ok(code) => code, + Err(CliError::Help) => { + println!("{USAGE}"); + ExitCode::SUCCESS + } Err(CliError::Usage(msg)) => { eprintln!("error: {msg}\n\n{USAGE}"); ExitCode::from(2) @@ -87,6 +92,8 @@ fn main() -> ExitCode { #[derive(Debug)] pub enum CliError { + /// `--help` or `-h` after a subcommand: usage on stdout, exit code 0. + Help, /// Bad arguments: usage is printed, exit code 2. Usage(String), /// The operation failed: exit code 1. diff --git a/apps/figcraft-cli/tests/it/cli.rs b/apps/figcraft-cli/tests/it/cli.rs index 4af73d69..b1aeb54a 100644 --- a/apps/figcraft-cli/tests/it/cli.rs +++ b/apps/figcraft-cli/tests/it/cli.rs @@ -39,6 +39,40 @@ fn bad_arguments_exit_2_without_panicking() { assert_eq!(out.status.code(), Some(1)); } +/// `--help` / `-h` after a subcommand prints the usage on stdout and exits 0, as it does first +/// (#1155), whatever else is on the line; nothing runs. +#[test] +fn help_after_a_subcommand_prints_the_usage() { + for args in [ + &["--help"][..], + &["-h"], + &["help"], + &["mcp", "--help"], + &["mcp", "-h"], + &["info", "--help"], + &["export", "--help"], + &["export", "x.figcraft", "-o", "out", "--help"], + &["convert", "-h"], + &["render", "--help"], + &["run", "--help"], + &["commands", "--help"], + &["plugin", "publish", "--help"], + &["perf", "--help"], + &["mcp", "--attach", "notaport", "--help"], + &["mcp", "--bogus", "--help"], + ] { + let out = bin().args(args).stdin(Stdio::null()).output().unwrap(); + assert_eq!(out.status.code(), Some(0), "{args:?}: {}", String::from_utf8_lossy(&out.stderr)); + let text = String::from_utf8_lossy(&out.stdout); + assert!(text.contains("USAGE") && text.contains("figcraft-cli mcp"), "{args:?}: {text}"); + assert!(out.stderr.is_empty(), "{args:?}: nothing on stderr, got {}", String::from_utf8_lossy(&out.stderr)); + } + // A value that happens to be `-h` is still a value, not a request for help. + let out = bin().args(["commands", "--filter", "--help"]).output().unwrap(); + assert_eq!(out.status.code(), Some(0)); + assert!(!String::from_utf8_lossy(&out.stdout).contains("USAGE"), "--filter took `--help` as its text"); +} + #[test] fn run_export_info_convert() { let d = tmp("run"); @@ -80,6 +114,30 @@ fn run_export_info_convert() { std::fs::remove_dir_all(&d).ok(); } +/// `export` into a folder names the files as the app's export does (#1154): brackets, `+`, `&`, +/// `%`, `#` and commas stay, what file systems refuse becomes `_`. +#[test] +fn export_file_names_keep_what_the_app_keeps() { + let d = tmp("names"); + let script = d.join("s.json"); + let names = ["Homepage - desktop (live + verbeteringen)", "Q&A, 50% #1 [v2]", "a:b*c?d|e"]; + let steps: Vec = names + .iter() + .enumerate() + .map(|(i, n)| serde_json::json!({"command": "frame.create", "params": {"x": i * 200, "y": 0, "width": 20, "height": 20, "name": n}})) + .collect(); + std::fs::write(&script, serde_json::to_string(&steps).unwrap()).unwrap(); + let file = d.join("doc.figcraft"); + assert!(bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap().status.success()); + let dir = d.join("out"); + let out = bin().arg("export").arg(&file).arg("-o").arg(&dir).output().unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + for want in ["Homepage - desktop (live + verbeteringen).png", "Q&A, 50% #1 [v2].png", "a_b_c_d_e.png"] { + assert!(dir.join(want).is_file(), "{want} in {:?}", std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).collect::>()); + } + std::fs::remove_dir_all(&d).ok(); +} + #[test] fn run_takes_the_documented_gradient_shorthand() { // #252: only MCP used to accept it; `run` said "`fills` must be paints or colours". diff --git a/apps/figcraft-server/src/hardening_tests.rs b/apps/figcraft-server/src/hardening_tests.rs index 086539ca..92b17710 100644 --- a/apps/figcraft-server/src/hardening_tests.rs +++ b/apps/figcraft-server/src/hardening_tests.rs @@ -838,3 +838,25 @@ fn a_library_publish_that_fails_keeps_serving_the_old_version() { assert_eq!(served(fetch(&mut store)), (2, v2)); assert!(!files.join(crate::store::library_name(1)).exists(), "the old version is deleted"); } + +#[test] +fn a_library_publish_cannot_claim_the_highest_version() { + let dir = TempDir::new("libmax"); + let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap(); + let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap(); + let mut s = Session::new(); + let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap(); + s.execute("component.create", &json!({"ids": [f]})).unwrap(); + s.execute("library.publish", &json!({})).unwrap(); + let mut lib = s.doc().unwrap().published.clone().unwrap().as_ref().clone(); + let mut archive = |version: u64| { + lib.manifest.version = version; + figcraft_engine::library_server::archive(&lib).unwrap() + }; + let high = 1u64 << 32; + assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a first publish can't claim the highest version"); + assert_eq!(store.publish_library(&file.id, &archive(high)).unwrap(), high, "the highest accepted first version goes in"); + assert_eq!(store.publish_library(&file.id, &archive(high + 1)).unwrap(), high + 1, "and a newer one still follows it"); + assert!(store.publish_library(&file.id, &archive(u64::MAX)).is_err(), "a jump to the highest version is refused"); + assert_eq!(store.publish_library(&file.id, &archive(high + 2)).unwrap(), high + 2, "later versions still go in"); +} diff --git a/apps/figcraft-server/src/public_tests.rs b/apps/figcraft-server/src/public_tests.rs index 978d9e8e..dc86d123 100644 --- a/apps/figcraft-server/src/public_tests.rs +++ b/apps/figcraft-server/src/public_tests.rs @@ -164,6 +164,64 @@ fn caps_per_address_and_for_everyone() { assert_eq!(store.publish_open(ip("198.51.100.8"), "No", &snapshot("No")).unwrap_err(), KEY_ONLY); } +#[test] +fn caps_per_address_count_the_wider_ipv6_blocks() { + let dir = TempDir::new("block-caps"); + let mut c = open(dir.path()); + c.max_files_per_ip = 1; + c.publishes_per_hour = 1000; + let mut store = Store::open(dir.path(), c.store()).unwrap(); + // A /48 keeps 4 times one address's files: /64s in different /56s of it. + for i in 0..4 { + store.publish_open(ip(&format!("2001:db8:1:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap(); + } + let e = store.publish_open(ip("2001:db8:1:400::1"), "Five", &snapshot("Five")).unwrap_err(); + assert!(e.contains("files on this server already"), "{e}"); + // A /56 keeps twice one address's files. + store.publish_open(ip("2001:db8:2:1::1"), "One", &snapshot("One")).unwrap(); + store.publish_open(ip("2001:db8:2:2::1"), "Two", &snapshot("Two")).unwrap(); + let e = store.publish_open(ip("2001:db8:2:3::1"), "Three", &snapshot("Three")).unwrap_err(); + assert!(e.contains("files on this server already"), "{e}"); + drop(store); + // Bytes: one address keeps one file's worth, so a /48 keeps 4 files. + let probe = TempDir::new("block-caps-probe"); + let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap(); + let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes; + let dir = TempDir::new("block-caps-bytes"); + let mut s = open(dir.path()).store(); + s.hosting.max_files_per_ip = 100; + s.hosting.max_bytes_per_ip = size; + let mut store = Store::open(dir.path(), s).unwrap(); + for i in 0..4 { + store.publish_open(ip(&format!("2001:db8:3:{:x}::1", i << 8)), "One", &snapshot("One")).unwrap(); + } + let e = store.publish_open(ip("2001:db8:3:400::1"), "One", &snapshot("One")).unwrap_err(); + assert!(e.contains("MiB it may keep"), "{e}"); +} + +#[test] +fn edits_count_the_wider_ipv6_blocks_too() { + use figcraft_collab_net::hub::Backend; + let probe = TempDir::new("block-room-probe"); + let mut p = Store::open(probe.path(), open(probe.path()).store()).unwrap(); + let size = p.publish_open(ip("203.0.113.1"), "One", &snapshot("One")).unwrap().0.bytes; + let dir = TempDir::new("block-room"); + let mut s = open(dir.path()).store(); + s.hosting.max_files_per_ip = 100; + // One address keeps 2.25 files' worth, so its /56 keeps 4.5. + s.hosting.max_bytes_per_ip = size * 2 + size / 4; + let mut store = Store::open(dir.path(), s).unwrap(); + let mut ids = Vec::new(); + for i in 0..4 { + ids.push(store.publish_open(ip(&format!("2001:db8:4:{i:x}::1")), "One", &snapshot("One")).unwrap().0.id); + } + let server = Backend::open(&mut store, &ids[0]).unwrap(); + // Its own /64 has room for this, but its /56 doesn't. + let e = Backend::room(&mut store, &ids[0], &server, (size * 3 / 4) as usize).unwrap_err(); + assert!(e.contains("have reached their"), "{e}"); + Backend::room(&mut store, &ids[0], &server, (size / 4) as usize).unwrap(); +} + #[test] fn idle_files_published_without_the_key_expire() { let dir = TempDir::new("expiry"); @@ -196,6 +254,22 @@ fn idle_files_published_without_the_key_expire() { assert!(store.listing(&|_| None).iter().all(|r| r.info.id != fresh.id)); } +#[test] +fn offline_commands_do_not_sweep_idle_files() { + let dir = TempDir::new("offline-sweep"); + let mut c = open(dir.path()); + c.idle_days = 30.0; + let mut store = Store::open(dir.path(), c.store()).unwrap(); + let (public, ..) = store.publish_open(ip("203.0.113.9"), "Public", &snapshot("Public")).unwrap(); + store.age(&public.id, 31 * 86_400); + drop(store); + let store = Store::try_open(dir.path(), c.store()).unwrap().unwrap(); + drop(store); + assert!(dir.path().join("files").join(&public.id).exists(), "a command deleted an idle file"); + let _serving = Store::open(dir.path(), c.store()).unwrap(); + assert!(!dir.path().join("files").join(&public.id).exists()); +} + #[test] fn rate_limits_and_blocks_refuse_with_a_reason() { let dir = TempDir::new("limits"); diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..ab65c2d8 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -42,7 +42,7 @@ use figcraft_collab::message::{ PluginReply, PluginReq, ReportInfo, Usage, }; use figcraft_collab::{Change, Replica, Server, same_secret}; -use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_key}; +use figcraft_collab_net::hub::{Admission, Backend, Closing, Handled, OpenFile, ip_blocks, ip_key}; use figcraft_collab_net::token; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -291,7 +291,18 @@ fn valid_id(s: &str) -> bool { /// A name others may see: trimmed, no control characters, at most 256 characters. fn clean_name(s: &str) -> String { - s.trim().chars().filter(|c| !c.is_control()).take(256).collect() + s.trim().chars().filter(|c| !c.is_control() && !is_bidi_control(*c)).take(256).collect() +} + +/// A report's reason: trimmed, control and bidi characters turned into spaces, at most `MAX_REASON` characters. +fn clean_reason(s: &str) -> String { + let s: String = s.chars().map(|c| if c.is_control() || is_bidi_control(c) { ' ' } else { c }).collect(); + s.trim().chars().take(MAX_REASON).collect() +} + +/// Bidirectional format characters, which could reorder what a terminal shows. +fn is_bidi_control(c: char) -> bool { + matches!(c, '\u{61C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') } /// Take the data directory's lock: `None` when someone else holds it. @@ -302,6 +313,10 @@ pub fn lock(root: &Path) -> Result, String> { #[cfg(unix)] std::os::unix::fs::DirBuilderExt::mode(&mut dirs, 0o700); dirs.create(root).map_err(|e| format!("can't create the data directory {}: {e}", root.display()))?; + // `DirBuilder` only applies the mode to directories it creates; tighten an existing one too. + #[cfg(unix)] + fs::set_permissions(root, std::os::unix::fs::PermissionsExt::from_mode(0o700)) + .map_err(|e| format!("can't restrict the data directory {}: {e}", root.display()))?; let f = OpenOptions::new() .create(true) .truncate(false) @@ -334,16 +349,27 @@ fn write_key(root: &Path) -> Result { Ok(key) } +/// The highest version a file's first library publish may claim. Publishing counts up by one (a +/// library carried over from another server keeps its number), so no real history gets near it. +/// Later publishes may go one past the version on the server, so there is always a next one. +const MAX_FIRST_LIBRARY_VERSION: u64 = 1 << 32; + impl Store { /// Use the data directory `root` (made if missing). Fails when another server or command holds /// it. Files that can't be read are skipped and listed in [`problems`](Self::problems). pub fn open(root: &Path, cfg: StoreConfig) -> Result { - Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display())) + let mut store = Self::try_open(root, cfg)?.ok_or_else(|| format!("another figcraft-server (or a command) is using {}", root.display()))?; + store.sweep(); + Ok(store) } - /// [`open`](Self::open), or `None` when someone else holds the directory. + /// [`open`](Self::open) without the expiry sweep (for commands that only look or edit), or + /// `None` when someone else holds the directory. pub fn try_open(root: &Path, cfg: StoreConfig) -> Result, String> { let Some(lock) = lock(root)? else { return Ok(None) }; + for name in [KEY, BLOCKED, REPORTS] { + let _ = fs::remove_file(disk::temp_of(&root.join(name))); + } let key = load_key(root)?; let dir = root.join(FILES); fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; @@ -374,7 +400,6 @@ impl Store { store.scan(&dir); store.blocked = store.read_list::(BLOCKED).iter().filter_map(|s| s.parse().ok()).map(ip_key).collect(); store.reports = store.read_list(REPORTS); - store.sweep(); Ok(Some(store)) } @@ -425,12 +450,20 @@ impl Store { /// A file folder's description and links, after checking its snapshot and log. Leftover /// temporary files (writes cut short) are removed. fn check(&self, path: &Path) -> Result { - for name in [SNAPSHOT, LOG, META, LINKS] { + for name in [SNAPSHOT, LOG, META, LINKS, USAGE] { let tmp = disk::temp_of(&path.join(name)); if tmp.exists() { let _ = fs::remove_file(&tmp); } } + if let Ok(entries) = fs::read_dir(path) { + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().into_owned(); + if name.starts_with("library-") && name.ends_with(".bin.tmp") { + let _ = fs::remove_file(e.path()); + } + } + } let meta: Meta = serde_json::from_slice(&disk::read_capped(&path.join(META), 1 << 20)?).map_err(|e| format!("{META} is damaged ({e})"))?; let links: Vec = serde_json::from_slice(&disk::read_capped(&path.join(LINKS), 16 << 20)?).map_err(|e| format!("{LINKS} is damaged ({e})"))?; @@ -514,6 +547,34 @@ impl Store { self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view)) } + /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's + /// caps it gets, and the files and bytes kept by the files published from addresses in it. An + /// address that doesn't parse counts as itself only, ×1, by its exact text. + fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> { + let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default(); + let kept: Vec<(&str, Vec<_>, u64)> = self + .files + .iter() + .filter_map(|(id, e)| { + let from = e.meta.ip.as_deref()?; + Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id))) + }) + .collect(); + let sum = |mine: &mut dyn Iterator, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n))); + let blocks = blocks_of(ip); + if blocks.is_empty() { + let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip)); + return vec![(1, files, bytes)]; + } + blocks + .into_iter() + .map(|(block, times)| { + let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block))); + (times, files, bytes) + }) + .collect() + } + /// Keep a document published without the key from `ip`, within the caps on what one address /// and everyone may keep: the file, its links and its owner token. pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> { @@ -525,19 +586,21 @@ impl Store { if !self.abuse.allow(ip, Action::Publish, Instant::now()) { return Err("too many files published from your address lately: try again in an hour".into()); } - let ip_text = ip.to_string(); - let mine: Vec<&String> = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip_text.as_str())).map(|(id, _)| id).collect(); - if mine.len() >= max_files { - return Err(format!( - "your address keeps {} files on this server already, the most it may: remove one (with its owner token) or wait until one expires", - mine.len() - )); + let blocks = self.per_block(&ip.to_string()); + for &(times, files, _) in &blocks { + if files >= max_files.saturating_mul(times as usize) { + return Err(format!( + "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires" + )); + } } - let used_ip: u64 = mine.iter().map(|id| self.bytes(id)).sum(); let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); let fits = |n: u64| { - if used_ip.saturating_add(n) > max_ip { - return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", max_ip >> 20)); + for &(times, _, used) in &blocks { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20)); + } } if used_all.saturating_add(n) > max_all { return Err("this server is full: it takes no more files for now (try again later, or another server)".into()); @@ -748,7 +811,7 @@ impl Store { /// Keep a report about `file` (from someone who joined it with `link`, at `ip`). pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - let reason: String = reason.trim().chars().map(|c| if c.is_control() { ' ' } else { c }).take(MAX_REASON).collect(); + let reason = clean_reason(reason); if reason.is_empty() { return Err("say what is wrong with the file".into()); } @@ -973,6 +1036,12 @@ impl Store { if m.version <= old.version { return Err(format!("version {} is on the server already: publish again to follow it", old.version)); } + // A jump would let a client use up the numbers and leave no newer version to publish. + if m.version > old.version.saturating_add(1) { + return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1))); + } + } else if m.version > MAX_FIRST_LIBRARY_VERSION { + return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version)); } // Each version has a file of its own, written before the description names it: a failed // description write or a crash in between leaves the previous version served whole, never @@ -1090,9 +1159,11 @@ impl Store { let h = &self.cfg.hosting; let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) }; let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes); - let used_ip: u64 = self.files.iter().filter(|(_, e)| e.meta.ip.as_deref() == Some(ip.as_str())).map(|(id, _)| self.bytes(id)).sum(); - if used_ip.saturating_add(n) > max_ip { - return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", max_ip >> 20)); + for (times, _, used) in self.per_block(&ip) { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20)); + } } let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); if used_all.saturating_add(n) > max_all { @@ -1110,7 +1181,24 @@ impl Store { let mut issues = Vec::new(); let mut fresh_log = true; let mut kept = 0; - match disk::read_capped(&dir.join(LOG), self.cfg.max_file_bytes).and_then(|b| disk::read_log(&b)) { + let log_path = dir.join(LOG); + // A log that's there but can't be read (an IO error, or over a lowered cap) may hold + // confirmed edits: the file isn't opened, and the log is left as it is for another try. + let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) { + Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")), + r => r, + }; + // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened). + let read = match read.map(|b| disk::read_log(&b)) { + Ok(Err(why)) => { + let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now())); + fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?; + Err(format!("{why}; it was moved aside to {}", aside.display())) + } + Ok(Ok(log)) => Ok(log), + Err(why) => Err(why), + }; + match read { Ok(log) if log.generation == generation => { fresh_log = false; for entry in log.entries { @@ -1167,7 +1255,10 @@ impl Store { Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } } // A new snapshot and log of the next generation, so appends start clean. - _ => self.rewrite(file, generation + 1, &server).map_err(|e| e.why)?, + _ => { + let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?; + self.rewrite(file, next, &server).map_err(|e| e.why)? + } }; self.entry(file)?.log = Some(log); Ok(server) @@ -1243,7 +1334,11 @@ impl Store { fn compact(&mut self, file: &str, server: &Server) { self.compactions += 1; let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return }; - match self.rewrite(file, generation + 1, server) { + let Some(next) = generation.checked_add(1) else { + log::error!("file {file}: compaction failed: the file's generation can't go any higher"); + return; + }; + match self.rewrite(file, next, server) { Ok(log) => { if let Ok(e) = self.entry(file) { e.log = Some(log); diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index f380568c..d8c5b915 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -229,6 +229,17 @@ fn one_server_per_data_directory() { assert_eq!(again.key(), key, "the key is kept"); } +#[cfg(unix)] +#[test] +fn an_existing_data_directory_is_made_private() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new("perms"); + fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(crate::store::lock(dir.path()).unwrap().is_some()); + let mode = fs::metadata(dir.path()).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); +} + #[test] fn temporary_directories_are_removed() { let path = { @@ -313,3 +324,129 @@ fn edits_confirmed_after_a_compaction_cut_short_survive_a_restart() { let name = page_name(&restored); assert!(at(&name) >= at(&confirmed), "restored {name:?}, confirmed {confirmed:?}"); } + +#[test] +fn a_log_that_cant_be_read_is_never_overwritten() { + let dir = TempDir::new("unreadlog"); + let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) }; + let log_path = dir.path().join(FILES); + let last = "Confirmed page name number 39 with some padding"; + let id = { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Busy"); + let mut server = store.open(&id).unwrap(); + let mut a = join(&mut server, Access::Edit); + store.changed(&id, &mut server); + for i in 0..40 { + rename(&mut server, &mut a, &format!("Confirmed page name number {i} with some padding")); + store.changed(&id, &mut server); + } + assert!(store.flush().is_empty()); + id + }; + let log_path = log_path.join(&id).join(LOG); + let before = fs::read(&log_path).unwrap(); + // The cap lowered just under the log: it can't be read now, but it must still be there after. + let lowered = StoreConfig { max_file_bytes: before.len() as u64 - 1, ..c.clone() }; + { + let mut store = Store::open(dir.path(), lowered).unwrap(); + let opened = store.open(&id); + assert!(opened.is_err(), "the file isn't opened without its log"); + assert!(!store.problems().is_empty(), "the problem is reported"); + assert_eq!(fs::read(&log_path).unwrap(), before, "the log is left as it was"); + } + let mut store = Store::open(dir.path(), c).unwrap(); + let server = store.open(&id).unwrap(); + assert_eq!(page_name(&server), last, "every confirmed edit comes back"); +} + +#[test] +fn a_log_that_isnt_one_is_moved_aside() { + let dir = TempDir::new("badlog"); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + let (id, _, _) = publish(&mut store, "Busy"); + let folder = dir.path().join(FILES).join(&id); + let junk = b"not a log at all, but maybe worth keeping".to_vec(); + fs::write(folder.join(LOG), &junk).unwrap(); + drop(store); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + store.open(&id).unwrap(); + assert!(store.problems().iter().any(|p| p.contains("moved aside")), "{:?}", store.problems()); + let kept = fs::read_dir(&folder).unwrap().filter_map(|e| e.ok()).any(|e| fs::read(e.path()).is_ok_and(|b| b == junk)); + assert!(kept, "the old log's bytes are kept beside the file"); +} + +const BIDI: [char; 12] = + ['\u{61C}', '\u{200E}', '\u{200F}', '\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}', '\u{2066}', '\u{2067}', '\u{2068}', '\u{2069}']; + +#[test] +fn names_and_report_reasons_lose_bidi_controls() { + let dir = TempDir::new("bidi"); + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + for (n, m) in BIDI.into_iter().enumerate() { + let ip: std::net::IpAddr = format!("192.0.2.{}", n + 1).parse().unwrap(); + let (id, _, _) = publish(&mut store, &format!("a{m}b")); + assert_eq!(store.list(&|_| None)[0].name, "ab", "name U+{:04X}", m as u32); + store.keep_report(&id, None, ip, &format!("a{m}b")).unwrap(); + assert_eq!(store.reports().last().unwrap().reason, "a b", "reason U+{:04X}", m as u32); + store.remove(&id).unwrap(); + } + // A reason that is only control characters says nothing. + let (id, _, _) = publish(&mut store, "blank"); + for blank in ["\0", "\u{202E}", " \0\t\u{2066} "] { + assert!(store.keep_report(&id, None, "192.0.2.99".parse().unwrap(), blank).is_err(), "{blank:?}"); + } +} + +#[test] +fn a_maxed_out_generation_does_not_wrap() { + let dir = TempDir::new("maxgen"); + let c = StoreConfig { compact_after: 3, ..cfg(dir.path()) }; + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Max"); + let path = dir.path().join(FILES).join(&id); + let (_, state) = disk::decode_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap(); + // Loading: no log goes with the snapshot, so the next generation would overflow. + fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX, &state).unwrap()).unwrap(); + fs::remove_file(path.join(LOG)).unwrap(); + assert!(store.open(&id).is_err()); + // Compacting: the open log is at the largest generation. + fs::write(path.join(SNAPSHOT), disk::encode_snapshot(u64::MAX - 1, &state).unwrap()).unwrap(); + let _ = fs::remove_file(path.join(LOG)); + let mut server = store.open(&id).unwrap(); + let mut a = join(&mut server, Access::Edit); + store.changed(&id, &mut server); + for i in 0..4 { + rename(&mut server, &mut a, &format!("P{i}")); + store.changed(&id, &mut server); + store.flush(); + } + let snap = disk::check_snapshot(&fs::read(path.join(SNAPSHOT)).unwrap()).unwrap(); + assert_eq!(snap, u64::MAX); +} + +#[test] +fn every_leftover_temporary_file_is_removed_at_startup() { + let dir = TempDir::new("leftovers"); + let id = { + let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + let id = publish(&mut store, "A").0; + store.flush(); + id + }; + let folder = dir.path().join(FILES).join(&id); + let leftovers = [ + dir.path().join("blocked.json.tmp"), + dir.path().join("reports.json.tmp"), + dir.path().join("server.key.tmp"), + folder.join("usage.json.tmp"), + folder.join("library-1.bin.tmp"), + ]; + for path in &leftovers { + fs::write(path, b"half").unwrap(); + } + let _store = Store::open(dir.path(), cfg(dir.path())).unwrap(); + for path in &leftovers { + assert!(!path.exists(), "leftover not cleaned at startup: {}", path.display()); + } +} diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 1c91840f..ff9c2fbe 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -16,6 +16,10 @@ const MAX_LINE: usize = 32 * 1024 * 1024; const MAX_CONNECTIONS: usize = 8; const MAX_QUEUED_REQUESTS: usize = 8; const TIMEOUT: Duration = Duration::from_secs(60); +/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for +/// example while the process has no file descriptors left, and retrying at once would keep a +/// core busy until it recovers. +const ACCEPT_RETRY: Duration = Duration::from_millis(100); /// Deferred requests for a hidden window must not make the UI's queue grow without a bound. pub fn drain_queue(queue: &Queue, target: &mut Vec) { @@ -38,7 +42,7 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { eprintln!("FigCraft control server listening on 127.0.0.1:{port}"); let spawned = std::thread::Builder::new().name("figcraft-control".into()).spawn(move || { let mut workers = Vec::new(); - for stream in listener.incoming().flatten() { + for stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) { spawn_connection(stream, queue.clone(), ctx.clone(), &mut workers, TIMEOUT); } }); @@ -47,6 +51,11 @@ pub fn start(port: u16, queue: Queue, ctx: CtxSlot) { } } +/// The connections `incoming` accepts, calling `wait` after each failed accept. +fn accepted(incoming: impl Iterator>, mut wait: impl FnMut()) -> impl Iterator { + incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok()) +} + fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut Vec>, timeout: Duration) { workers.retain(|worker| !worker.is_finished()); if workers.len() >= MAX_CONNECTIONS { diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 008170fb..011b0613 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -131,6 +131,18 @@ fn idle_and_partial_frame_connections_expire() { } } +/// A failed accept is followed by a wait before the next one, so a listener that keeps failing +/// does not spin, and the connections around the failures still come through. +#[test] +fn a_failed_accept_waits_before_the_next() { + let failed = || Err(std::io::Error::other("no file descriptors left")); + let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)]; + let mut waits = 0; + let connections: Vec = accepted(incoming.into_iter(), || waits += 1).collect(); + assert_eq!(connections, [1, 2]); + assert_eq!(waits, 3, "one wait per failed accept"); +} + #[test] fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() { let pending = queue(); diff --git a/contributors/people.toml b/contributors/people.toml index 27307935..6a92a2a9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -71,3 +71,8 @@ sync_github_name = true [people.AganzeFelicite] sync_github_name = true + +[people.switch87] +real_name = "Gert Pellin" +display_name = "Gert Pellin" +sync_github_name = false diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/barred_tests.rs index c9355a5f..7942e766 100644 --- a/crates/collab-net/src/barred_tests.rs +++ b/crates/collab-net/src/barred_tests.rs @@ -51,3 +51,47 @@ fn a_closed_file_forgets_whom_it_barred() { assert!(!hub.barred.contains_key("f"), "and its barred addresses went with it"); assert!(hub.barred.contains_key("other"), "another file's stay"); } + +#[test] +fn a_kicked_connection_with_a_full_queue_is_shut() { + use std::io::Read; + use std::net::TcpListener; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::mpsc::sync_channel; + use std::time::Duration; + + use super::{Conn, Kind, Line, Out}; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let (server_side, _) = listener.accept().unwrap(); + client.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + // Stands in for the writer thread's handle: it keeps the socket open after the Conn is gone. + let _writer = server_side.try_clone().unwrap(); + let (tx, _rx) = sync_channel(2); + while tx.try_send(Out::Bytes(Default::default(), None)).is_ok() {} + let out = Line { + tx, + queued: Arc::default(), + block: Arc::new(AtomicUsize::new(0)), + all: Arc::new(AtomicUsize::new(0)), + max: 1 << 20, + max_block: usize::MAX, + max_all: usize::MAX, + }; + let ip = IpAddr::V4(Ipv4Addr::LOCALHOST); + let mut hub = Hub { + in_flight: InFlight(Default::default()), + backend: Closes, + rooms: HashMap::new(), + conns: HashMap::from([(1, Conn { out, sock: server_side, kind: Kind::Managing, ip })]), + local: None, + outbox: Vec::new(), + barred: HashMap::new(), + }; + hub.kick(&[1], "kicked"); + assert!(hub.conns.is_empty()); + let mut buf = [0u8; 1]; + assert_eq!(client.read(&mut buf).ok(), Some(0), "the kicked socket must be shut (client sees EOF)"); +} diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 5cf3e519..c2202919 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -402,6 +402,8 @@ enum HubIn { sock: TcpStream, /// Its size (in flight until handled). bytes: usize, + /// Told when the hub takes the connection in (dropped unsent when it doesn't). + admitted: SyncSender<()>, }, Msg { conn: u64, @@ -811,17 +813,22 @@ fn encode(msg: &ServerMsg) -> Option { /// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps). fn refuse(out: &Line, why: &str) { + refused(out, why); +} + +/// [`refuse`], and whether the close was queued (it isn't when the queue is full). +fn refused(out: &Line, why: &str) -> bool { if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) { let _ = out.tx.try_send(Out::Bytes(b, None)); } - let _ = out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))); + out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok() } /// What the server tells a client when it is too busy to take more from it. const BUSY: &str = "the server is busy: try again in a moment"; -/// Read the next binary message (skipping pings) and its size, or why the connection should end. -fn next_message(ws: &mut WebSocket, max: usize) -> Result, String> { +/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end. +fn next_message(ws: &mut WebSocket, max: usize, deadline: Option) -> Result, String> { loop { return match ws.read() { Ok(Message::Binary(b)) => { @@ -830,6 +837,13 @@ fn next_message(ws: &mut WebSocket, max: usize) -> Result(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string()) } Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()), + // Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and + // a message trickled in is cut off by it (`Duplex::read_until`). + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_)) + if deadline.is_some_and(|d| Instant::now() >= d) => + { + Err("no hello in time".into()) + } Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, Ok(Message::Close(_)) => Ok(None), Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)), @@ -851,6 +865,7 @@ fn too_large(max: usize) -> String { /// One connection's reader (the writer runs on its own thread). fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, open: Open, shared: &Shared) { let _ = stream.set_nodelay(true); + let hello_deadline = Instant::now() + limits.hello_timeout; if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { return; } @@ -859,7 +874,9 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit } let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); - let Ok((rd, wr, sock)) = Duplex::split(stream, None) else { return }; + let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return }; + // The handshake and the hello must be in by the deadline, however slowly they trickle in. + rd.read_until(Some(hello_deadline)); let cfg = config(limits.max_client_message); let mut forwarded = None; // The handshake callback's type is tungstenite's (its error is an HTTP response). @@ -923,7 +940,10 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit log::info!("event=refused ip={ip} reason=connections_per_address"); end(Some("too many connections from your address: close some and try again")); } else { - match next_message(&mut ws, limits.max_hello) { + let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline)); + // In time or not, the hello's deadline is over: what's read next has timeouts of its own. + ws.get_mut().read_until(None); + match hello { Ok(Some(( first @ (ClientMsg::Hello(_) | ClientMsg::Manage(_) @@ -946,9 +966,13 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { end(Some(BUSY)); } else { - ws.set_config(|c| *c = config(max)); - let opened = sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes }).is_ok()); - if opened && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { + let (admitted, verdict) = sync_channel(1); + let opened = + sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok()); + // Nothing more is read until the hub took the connection in (its token checked): + // only then may it send messages of the full size. + if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { + ws.set_config(|c| *c = config(max)); read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end); let _ = hub.send(HubIn::Gone { conn }); } @@ -962,6 +986,7 @@ fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimit drop(counted); // The block's counters go with the last handle on them (the table forgets it then). drop(block); + ws.get_mut().read_until(None); linger(&mut ws); drop(out); // The writer closes the socket once it has sent what's queued (a refusal, a close). @@ -981,7 +1006,7 @@ fn read_loop( let mut bucket = Bucket::new(limits.rate, limits.burst); let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST); loop { - match next_message(ws, max) { + match next_message(ws, max, None) { Ok(Some((msg, bytes))) => { let now = Instant::now(); if !bucket.take(now) { @@ -1074,6 +1099,8 @@ struct Pending { out: Line, sock: TcpStream, ip: IpAddr, + /// Told when it is taken in. + admitted: SyncSender<()>, } /// A connection the hub took in. @@ -1195,7 +1222,7 @@ impl Hub { fn handle(&mut self, m: HubIn) { match m { - HubIn::Open { conn, ip, first, out, sock, bytes } => { + HubIn::Open { conn, ip, first, out, sock, bytes, admitted } => { self.in_flight.give(bytes); // The operator (with the server key) gets in from anywhere; everyone else is // checked (blocked addresses, connection rates). @@ -1204,7 +1231,7 @@ impl Hub { log::info!("event=refused ip={ip} reason={why:?}"); return refuse(&out, &why); } - let c = Pending { out, sock, ip }; + let c = Pending { out, sock, ip, admitted }; match first { ClientMsg::Hello(hello) => self.join(conn, hello, c), ClientMsg::Manage(m) => self.manage(conn, &m, c), @@ -1399,6 +1426,7 @@ impl Hub { if let Some(c) = old.and_then(|old| self.conns.remove(&old)) { shutdown(&c.sock); } + let _ = c.admitted.try_send(()); self.conns .insert(conn, Conn { out: c.out, sock: c.sock, kind: Kind::Joined { file: file.clone(), id, link: admission.link }, ip: c.ip }); self.queue(&file, more); @@ -1429,7 +1457,9 @@ impl Hub { barred.insert(c.ip); } log::info!("event=removed file={file} client={id} ip={}", c.ip); - let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())); + if c.out.tx.try_send(Out::Close(CloseCode::Away, REMOVED.into())).is_err() { + shutdown(&c.sock); + } self.gone(conn); Ok(()) } @@ -1444,6 +1474,7 @@ impl Hub { /// Take in connection `c` as `kind` and send it `first`. fn take_in(&mut self, conn: u64, c: Pending, kind: Kind, first: &ServerMsg) { + let _ = c.admitted.try_send(()); self.conns.insert(conn, Conn { out: c.out, sock: c.sock, kind, ip: c.ip }); self.answer(conn, first); } @@ -1543,8 +1574,10 @@ impl Hub { // People in the file are told the session is over for them, as when a host stops (they // keep their copy; nothing retries); a library request is refused. for conn in &people { - if let Some(c) = self.conns.get(conn) { - let _ = c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into())); + if let Some(c) = self.conns.get(conn) + && c.out.tx.try_send(Out::Close(CloseCode::Away, STOPPED.into())).is_err() + { + shutdown(&c.sock); } self.gone(*conn); } @@ -1593,8 +1626,10 @@ impl Hub { /// Refuse connections with `why` and drop them at once (nothing more they send counts). fn kick(&mut self, conns: &[u64], why: &str) { for conn in conns { - if let Some(c) = self.conns.get(conn) { - refuse(&c.out, why); + if let Some(c) = self.conns.get(conn) + && !refused(&c.out, why) + { + shutdown(&c.sock); } self.gone(*conn); } @@ -1747,3 +1782,7 @@ mod queue_tests; #[cfg(test)] #[path = "barred_tests.rs"] mod barred_tests; + +#[cfg(test)] +#[path = "preauth_tests.rs"] +mod preauth_tests; diff --git a/crates/collab-net/src/inbox.rs b/crates/collab-net/src/inbox.rs index fb505a0a..1ec00e67 100644 --- a/crates/collab-net/src/inbox.rs +++ b/crates/collab-net/src/inbox.rs @@ -11,11 +11,18 @@ use figcraft_engine::live::{Event, Presence}; pub type Waker = Arc; /// Most discrete events kept between two drains (joins, leaves, status); the oldest go first. +/// Document sync events don't count and are never dropped: the sync client treats a missing +/// batch as a fatal sequence gap, so losing one would end the session. const MAX_EVENTS: usize = 10_000; #[derive(Default)] struct State { - events: VecDeque, + /// Arrival number of the next event, so [`Inbox::drain`] can merge the two queues in order. + next: u64, + /// [`Event::Sync`] events, never dropped. + syncs: VecDeque<(u64, Event)>, + /// Every other event, capped at [`MAX_EVENTS`]. + others: VecDeque<(u64, Event)>, presence: BTreeMap, } @@ -43,11 +50,17 @@ impl Inbox { if let Event::PeerLeft(id) = &e { s.presence.remove(id); } - if s.events.len() >= MAX_EVENTS { - log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest"); - s.events.pop_front(); + let n = s.next; + s.next += 1; + if matches!(e, Event::Sync(_)) { + s.syncs.push_back((n, e)); + return; } - s.events.push_back(e); + if s.others.len() >= MAX_EVENTS { + log::warn!("live session: {MAX_EVENTS} events not taken; dropping the oldest"); + s.others.pop_front(); + } + s.others.push_back((n, e)); }); } @@ -62,8 +75,70 @@ impl Inbox { /// peer. pub fn drain(&self) -> Vec { let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner); - let mut out: Vec = s.events.drain(..).collect(); + let mut syncs = std::mem::take(&mut s.syncs).into_iter().peekable(); + let mut others = std::mem::take(&mut s.others).into_iter().peekable(); + let mut out = Vec::with_capacity(syncs.len() + others.len() + s.presence.len()); + loop { + let sync_first = match (syncs.peek(), others.peek()) { + (Some((a, _)), Some((b, _))) => a < b, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => break, + }; + let next = if sync_first { syncs.next() } else { others.next() }; + out.extend(next.map(|(_, e)| e)); + } out.extend(std::mem::take(&mut s.presence).into_iter().map(|(id, p)| Event::Presence(id, p))); out } } + +#[cfg(test)] +mod tests { + use super::*; + use figcraft_collab::message::ServerMsg; + + fn reject(batch_id: u64) -> Event { + Event::Sync(ServerMsg::Reject { batch_id, reason: String::new(), fixes: Vec::new() }) + } + + fn sync_ids(events: &[Event]) -> Vec { + events + .iter() + .filter_map(|e| match e { + Event::Sync(ServerMsg::Reject { batch_id, .. }) => Some(*batch_id), + _ => None, + }) + .collect() + } + + #[test] + fn a_full_inbox_never_drops_sync_events() { + let inbox = Inbox::new(Arc::new(|| {})); + inbox.push(reject(1)); + for _ in 0..MAX_EVENTS { + inbox.push(Event::PeerLeft(7)); + } + inbox.push(reject(2)); + for _ in 0..MAX_EVENTS { + inbox.push(Event::PeerLeft(7)); + } + let out = inbox.drain(); + assert_eq!(sync_ids(&out), vec![1, 2]); + assert!(out.len() <= MAX_EVENTS + 2, "{} events kept", out.len()); + } + + #[test] + fn sync_events_past_the_cap_are_all_kept_in_order() { + let inbox = Inbox::new(Arc::new(|| {})); + let n = MAX_EVENTS as u64 + 5; + for id in 0..n { + inbox.push(reject(id)); + } + inbox.push(Event::PeerLeft(7)); + let out = inbox.drain(); + let ids = sync_ids(&out); + assert_eq!(ids.len() as u64, n, "Sync events dropped"); + assert!(ids.iter().copied().eq(0..n), "Sync events out of order"); + } +} diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index a043d8c7..d5a38d9d 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -2,7 +2,7 @@ //! refusals, presence relay and throttling, leaving and dropping, limits, malformed frames, and //! reconnecting. Every test stops its host and closes its sockets. -use std::io::Read; +use std::io::{Read, Write}; use std::net::{SocketAddr, TcpStream}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -670,3 +670,49 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { assert!(!welcomed(&gp.events)); host.stop(); } + +/// How long a connection that never says hello stays open while `poke` sends it something every +/// 300 ms (told whether it's the first time); `None`: still open after 6 s. +fn pre_hello_lifetime(mut poke: impl FnMut(&mut Raw, bool) -> bool) -> Option { + let (mut host, _hp, invite) = start_with(NetLimits { hello_timeout: Duration::from_secs(1), ..fast_limits() }, Limits::default()); + let addr: SocketAddr = invite.server.host.parse().unwrap(); + let mut r = Raw::connect(addr); + let t0 = Instant::now(); + let (mut closed, mut first) = (None, true); + while t0.elapsed() < Duration::from_secs(6) { + if !poke(&mut r, std::mem::take(&mut first)) { + closed = Some(t0.elapsed()); + break; + } + std::thread::sleep(Duration::from_millis(300)); + // Drain what the host sent back (pongs, the close); an error means it hung up. + r.0.get_mut().set_read_timeout(Some(Duration::from_millis(10))).unwrap(); + match r.0.read() { + Ok(Message::Close(_)) | Err(tungstenite::Error::ConnectionClosed | tungstenite::Error::AlreadyClosed) => { + closed = Some(t0.elapsed()); + break; + } + Err(tungstenite::Error::Io(e)) if !matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) => { + closed = Some(t0.elapsed()); + break; + } + _ => {} + } + } + host.stop(); + closed +} + +#[test] +fn pings_do_not_keep_a_connection_without_a_hello_alive() { + let closed = pre_hello_lifetime(|r, _| r.0.send(Message::Ping(Bytes::new())).is_ok() && r.0.flush().is_ok()); + assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "pinged pre-hello connection survived: {closed:?}"); +} + +#[test] +fn a_hello_trickled_in_fragments_does_not_outlast_the_deadline() { + // A binary message begun and never finished: one masked byte per frame, each in time for the + // read timeout. + let closed = pre_hello_lifetime(|r, first| r.0.get_mut().write_all(&[if first { 0x02 } else { 0x00 }, 0x81, 0, 0, 0, 0, b'x']).is_ok()); + assert!(closed.is_some_and(|t| t < Duration::from_secs(3)), "trickled pre-hello connection survived: {closed:?}"); +} diff --git a/crates/collab-net/src/manage.rs b/crates/collab-net/src/manage.rs index 6e995bdb..2f0683c7 100644 --- a/crates/collab-net/src/manage.rs +++ b/crates/collab-net/src/manage.rs @@ -206,9 +206,15 @@ fn connect(server: &Endpoint, limits: &NetLimits, timeout: Duration) -> Result(ws: &mut Ws, limits: &NetLimits, pick: impl Fn(ServerMsg) -> Option< }; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Whether `close` returns within 3 s against a server that never answers the close and does + /// `serve` instead, with reads waiting up to `idle`. + fn closes_in_time(idle: Duration, serve: impl FnOnce(WebSocket) + Send + 'static) -> bool { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + std::thread::spawn(move || { + let (s, _) = listener.accept().unwrap(); + if let Ok(ws) = tungstenite::accept(s) { + serve(ws); + } + }); + let limits = NetLimits { idle_timeout: idle, ..NetLimits::default() }; + let server = Endpoint { host: addr.to_string(), path: "/".into(), secure: false }; + let ws = connect(&server, &limits, Duration::from_secs(2)).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + close(ws); + let _ = tx.send(()); + }); + rx.recv_timeout(Duration::from_secs(3)).is_ok() + } + + /// A server that keeps pinging and never answers the close must not pin `close` forever. + #[test] + fn close_returns_against_a_server_that_keeps_pinging() { + let pinging = closes_in_time(Duration::from_millis(300), |mut ws| { + while ws.send(Message::Ping(Bytes::new())).is_ok() { + std::thread::sleep(Duration::from_millis(10)); + } + }); + assert!(pinging, "close() kept draining a pinging server"); + } + + /// Nor one that goes quiet, however long the idle timeout. + #[test] + fn close_returns_against_a_silent_server() { + let silent = closes_in_time(Duration::from_secs(10), |ws| { + std::thread::sleep(Duration::from_secs(5)); + drop(ws); + }); + assert!(silent, "close() waited out the idle timeout"); + } +} diff --git a/crates/collab-net/src/net.rs b/crates/collab-net/src/net.rs index 8c841e9c..c6628662 100644 --- a/crates/collab-net/src/net.rs +++ b/crates/collab-net/src/net.rs @@ -33,14 +33,16 @@ pub struct Duplex { tls: Option, /// What the bytes read count against (a server's reader), if anything. meter: Option, + /// When reading must be over, however slowly the bytes come ([`Duplex::read_until`]). + deadline: Option, } impl Duplex { /// Two handles on `stream` (for the reader and the writer) and one to shut it down with. pub fn split(stream: TcpStream, tls: Option) -> io::Result<(Duplex, Duplex, TcpStream)> { let write = Arc::new(Mutex::new(stream.try_clone()?)); - let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None }; - let b = Duplex { read: stream.try_clone()?, write, tls, meter: None }; + let a = Duplex { read: stream.try_clone()?, write: write.clone(), tls: tls.clone(), meter: None, deadline: None }; + let b = Duplex { read: stream.try_clone()?, write, tls, meter: None, deadline: None }; Ok((a, b, stream)) } @@ -49,6 +51,12 @@ impl Duplex { self.meter = Some(meter); } + /// Reads on this handle fail once `deadline` passes, even when each one gets a few bytes in + /// time (a message trickled in can't outlast it). `None`: only the read timeout applies. + pub fn read_until(&mut self, deadline: Option) { + self.deadline = deadline; + } + /// What was read so far became a message (handed on): it no longer counts as being read. pub fn read_done(&mut self) { if let Some(m) = self.meter.as_mut() { @@ -187,6 +195,13 @@ impl Duplex { impl Read for Duplex { fn read(&mut self, buf: &mut [u8]) -> io::Result { + if let Some(d) = self.deadline { + let left = d.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(io::ErrorKind::TimedOut.into()); + } + self.read.set_read_timeout(Some(left))?; + } let n = match &self.tls { Some(t) => tls::read(t, &mut self.read, &self.write, buf), None => self.read.read(buf), diff --git a/crates/collab-net/src/preauth_tests.rs b/crates/collab-net/src/preauth_tests.rs new file mode 100644 index 00000000..5ae91665 --- /dev/null +++ b/crates/collab-net/src/preauth_tests.rs @@ -0,0 +1,72 @@ +//! A connection whose hello the backend hasn't admitted yet is held to the hello's size limit: it +//! can't have a message of the full size read while its token is unchecked. + +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::mpsc::{Receiver, channel}; +use std::time::{Duration, Instant}; + +use figcraft_collab::message::{self, ClientMsg, ServerMsg, Signal}; +use figcraft_collab::{Client, Server}; +use tungstenite::{Bytes, Message}; + +use super::{Admission, Backend, Closing, Hosting}; +use crate::net::NetLimits; + +/// A backend that refuses every hello, once it is told to. +struct Slow(Receiver<()>); + +impl Backend for Slow { + fn admit(&mut self, _file: &str, _token: &str) -> Result { + let _ = self.0.recv(); + Err("wrong token".into()) + } + + fn open(&mut self, _file: &str) -> Result { + Err("no".into()) + } + + fn closing(&self) -> Closing { + Closing::Ended + } +} + +fn connect(addr: SocketAddr) -> tungstenite::WebSocket { + let s = TcpStream::connect(addr).unwrap(); + s.set_read_timeout(Some(Duration::from_secs(10))).unwrap(); + tungstenite::client::client(format!("ws://{addr}/"), s).unwrap().0 +} + +#[test] +fn an_unadmitted_connection_cant_send_a_large_message() { + let limits = NetLimits { hello_timeout: Duration::from_secs(5), ..NetLimits::default() }; + let (release, wait) = channel(); + let mut hosting = Hosting::serve(TcpListener::bind("127.0.0.1:0").unwrap(), Slow(wait), limits).unwrap(); + let addr = hosting.addr(); + let sender = std::thread::spawn(move || { + let mut ws = connect(addr); + let hello = message::encode(&Client::hello("0123456789abcdef", "bad", "x", "")).unwrap(); + ws.send(Message::Binary(Bytes::from(hello))).unwrap(); + let big = ClientMsg::Signal(Signal { kind: Signal::CHAT.into(), text: "x".repeat(1 << 20) }); + let _ = ws.send(Message::Binary(Bytes::from(message::encode(&big).unwrap()))); + let mut refused = None; + while let Ok(m) = ws.read() { + if let Message::Binary(b) = m + && let Ok(ServerMsg::Refused { reason }) = message::decode(&b, 64 << 20) + { + refused = Some(reason); + } + } + refused + }); + let mut peak = 0; + let until = Instant::now() + Duration::from_secs(2); + while Instant::now() < until { + peak = peak.max(hosting.in_flight_bytes()); + std::thread::sleep(Duration::from_millis(2)); + } + release.send(()).unwrap(); + let refused = sender.join().unwrap(); + hosting.stop(); + assert!(peak <= limits.max_hello, "a message of {peak} bytes was read before the token was checked"); + assert_eq!(refused.as_deref(), Some("wrong token")); +} diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index 57bb9cf2..b273ba34 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -181,6 +181,7 @@ node_props! { Annotations => annotations, CodeLinks => code_links, DevResources => dev_resources, + Measurements => measurements, Board => board, } diff --git a/crates/collab/src/merge.rs b/crates/collab/src/merge.rs index 5132ce75..acd40397 100644 --- a/crates/collab/src/merge.rs +++ b/crates/collab/src/merge.rs @@ -6,11 +6,12 @@ //! values, or a layer deleted on one side while the other side edited it or something inside it //! (including adding layers to it or moving layers into or out of it). The layer lists whose items //! stand on their own (fills, strokes, effects, layout grids, export settings, guides, prototype -//! interactions, accessibility annotations and Dev Mode resources) merge item by item: both sides -//! changing different items of the list (or one adding items at its end) is no conflict, only the -//! same item changed both ways or the list shortened on a side while the other changed it. Nothing -//! that conflicts is merged until it is resolved: each conflict is resolved for one side, and the -//! other side's changes to it are left out. Everything else from both sides is kept. +//! interactions, accessibility annotations, Dev Mode resources and measurements) merge item by +//! item: both sides changing different items of the list (or one adding items at its end) is no +//! conflict, only the same item changed both ways or the list shortened on a side while the other +//! changed it. Nothing that conflicts is merged until it is resolved: each conflict is resolved for +//! one side, and the other side's changes to it are left out. Everything else from both sides is +//! kept. use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -176,6 +177,7 @@ const PER_ITEM: &[Prop] = &[ // Not `CodeLinks`: there is one per language, and lining them up by position could leave two // for one language (the second hidden), so they merge whole. Prop::DevResources, + Prop::Measurements, ]; /// Both sides' writes `b` and `m` of list property `key` merged item by item against the base diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index fdad0fa7..1fff7667 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -549,7 +549,7 @@ pub enum PaintKind { } /// One fill or stroke paint. -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct Paint { #[serde(flatten)] pub kind: PaintKind, @@ -559,19 +559,41 @@ pub struct Paint { pub visible: bool, #[serde(default)] pub blend: BlendMode, + /// The part of the opacity that is the alpha of a typed solid colour (see [`Paint::solid`]), + /// the rest being an opacity set on its own: a colour variable bound to the paint replaces + /// this part only. 1: all of it is the paint's own. + #[serde(default = "one", rename = "colorAlpha", skip_serializing_if = "is_one")] + pub color_alpha: f32, +} + +/// Paints compare by what they paint; where the opacity came from doesn't matter. +impl PartialEq for Paint { + fn eq(&self, other: &Self) -> bool { + self.kind == other.kind && self.opacity == other.opacity && self.visible == other.visible && self.blend == other.blend + } } fn yes() -> bool { true } +fn is_one(x: &f32) -> bool { + *x == 1.0 +} + impl Paint { pub fn solid(color: Color) -> Self { - Self { kind: PaintKind::Solid { color: color.with_alpha(1.0) }, opacity: color.a, visible: true, blend: BlendMode::Normal } + Self { + kind: PaintKind::Solid { color: color.with_alpha(1.0) }, + opacity: color.a, + visible: true, + blend: BlendMode::Normal, + color_alpha: color.a, + } } pub fn gradient(g: Gradient) -> Self { - Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal } + Self { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 } } pub fn image(hash: impl Into, mode: ImageScaleMode) -> Self { @@ -587,12 +609,13 @@ impl Paint { opacity: 1.0, visible: true, blend: BlendMode::Normal, + color_alpha: 1.0, } } /// Layer `source` (a node id) tiled at its own size. pub fn pattern(source: u64) -> Self { - Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal } + Self { kind: PaintKind::Pattern(PatternPaint::new(source)), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 } } /// The solid colour (with the paint opacity folded into alpha), if solid. diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index d0ed4f2b..b4f89dbc 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -72,7 +72,16 @@ fn hits_node_itself(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64, o NodeKind::Frame | NodeKind::Component { .. } | NodeKind::ComponentSet { .. } | NodeKind::Instance { .. } | NodeKind::Section { .. } => { n.local_rect().inflate(tol_l, tol_l).contains(lp) } - _ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && path.winding(lp) != 0)), + _ => shape_path(n).is_some_and(|path| hits_path(n, &path, lp, tol_l, !outline && is_closed(n) && fills_point(n, &path, lp))), + } +} + +/// Whether the fill of `n`, drawn as `path`, covers `lp` (vectors follow the rule of +/// [`VectorNetwork::fill_contains`](crate::VectorNetwork::fill_contains): even-odd when any region asks for it). +fn fills_point(n: &Node, path: &BezPath, lp: Point) -> bool { + match &n.kind { + NodeKind::Vector { network } => network.fill_contains(lp), + _ => path.winding(lp) != 0, } } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3097181c..70797dd8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -275,7 +275,16 @@ impl Document { /// A fresh unused id (in this document's client space during a collaboration session; see /// [`set_client_id`](Self::set_client_id)). pub fn alloc_id(&mut self) -> NodeId { - self.next_id.alloc() + // A file's saved `next_id` can be stale (behind ids already in use): skip taken ids. + let mut id = self.next_id.alloc(); + while self.nodes.contains_key(&id) { + let next = self.next_id.alloc(); + if next == id { + break; + } + id = next; + } + id } /// The id [`alloc_id`](Self::alloc_id) returns next. @@ -459,8 +468,9 @@ impl Document { return true; } guard += 1; - if guard > 10_000 { - return false; + // More hops than nodes means the parent chain loops; fail closed so callers reject the move. + if guard > self.nodes.len() { + return true; } cur = self.node(c).and_then(|n| n.parent); } @@ -827,6 +837,11 @@ impl Document { return Err(DocError::NotContainer(parent)); } let new_root = *map.get(&root).ok_or(DocError::NoNode(root))?; + // Never overwrite an existing node, or another copy, with a copy. + let mut copied = std::collections::BTreeSet::new(); + if let Some(taken) = nodes.iter().filter_map(|n| map.get(&n.id)).find(|i| self.nodes.contains_key(*i) || !copied.insert(**i)) { + return Err(DocError::Invalid(format!("node {taken} already exists"))); + } for n in nodes { let Some(new_id) = map.get(&n.id) else { continue }; let mut c = n.clone(); @@ -841,6 +856,12 @@ impl Document { { *component = *m; } + // A measurement copied with the layer it measures to measures to the copy; one to a + // layer left behind isn't copied. + c.measurements.retain_mut(|m| match m.to { + Some(t) => map.get(&t).map(|n| m.to = Some(*n)).is_some(), + None => true, + }); // A connector (or label) copied with what it joins joins the copies. if let Some(b) = c.board.as_mut() { b.remap(map); @@ -994,39 +1015,46 @@ impl Document { /// Check structural invariants (used by tests and after loading untrusted files). pub fn validate(&self) -> Vec { + // Hostile files can break an invariant per child; keep only the first few reports. + const MAX_ISSUES: usize = 100; + fn note(issues: &mut Vec, msg: impl FnOnce() -> String) { + if issues.len() < MAX_ISSUES { + issues.push(msg()); + } + } let mut issues = Vec::new(); for (id, n) in self.nodes.iter() { if n.id != *id { - issues.push(format!("node key {id} has id {}", n.id)); + note(&mut issues, || format!("node key {id} has id {}", n.id)); } let mut seen = std::collections::BTreeSet::new(); for c in &n.children { if !seen.insert(*c) { - issues.push(format!("child {c} listed twice in {id}")); + note(&mut issues, || format!("child {c} listed twice in {id}")); } match self.node(*c) { Some(cn) if cn.parent == Some(*id) => {} - Some(_) => issues.push(format!("child {c} of {id} has another parent")), - None => issues.push(format!("child {c} of {id} missing")), + Some(_) => note(&mut issues, || format!("child {c} of {id} has another parent")), + None => note(&mut issues, || format!("child {c} of {id} missing")), } } if let Some(p) = n.parent && !self.node(p).is_some_and(|pn| pn.children.contains(id)) { - issues.push(format!("{id} not listed in parent {p}")); + note(&mut issues, || format!("{id} not listed in parent {p}")); } // The library store's root is a page that is never listed. let listed = self.pages.contains(id) || (n.is_page() && self.libraries.store == Some(*id)); if n.is_page() != listed { - issues.push(format!("page list mismatch for {id}")); + note(&mut issues, || format!("page list mismatch for {id}")); } if n.is_page() && n.parent.is_some() { - issues.push(format!("page {id} has a parent")); + note(&mut issues, || format!("page {id} has a parent")); } } let reachable = self.reachable(); for id in self.nodes.keys().filter(|k| !reachable.contains(*k)) { - issues.push(format!("{id} is not under a page (cycle or detached)")); + note(&mut issues, || format!("{id} is not under a page (cycle or detached)")); } issues } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index 649d5e56..ca06fe53 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -237,6 +237,17 @@ pub struct DevResource { pub name: String, } +/// A measurement kept on the canvas for developers, stored on the layer it starts from: to another +/// layer (`to`), or across one of the layer's own sides (`side`). Dev Mode draws it from the layers' +/// current bounds, so it follows them. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Measurement { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub to: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub side: Option, +} + /// A kind of annotation (Development, Accessibility…): its stable id, name and the colour its /// callouts are drawn in. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -518,6 +529,9 @@ pub struct Node { /// Dev resources: links for developers (docs, code, tickets…), in the order they were added. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub dev_resources: Vec, + /// Dev Mode measurements that start from this layer, in the order they were added. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub measurements: Vec, /// On a board: what this object is (a sticky note, a connector, a stamp…). #[serde(default, skip_serializing_if = "Option::is_none")] pub board: Option, @@ -631,6 +645,7 @@ impl Node { annotations: Vec::new(), code_links: Vec::new(), dev_resources: Vec::new(), + measurements: Vec::new(), board: None, } } diff --git a/crates/doc/src/proto_expr.rs b/crates/doc/src/proto_expr.rs index f4404dc7..d5441b28 100644 --- a/crates/doc/src/proto_expr.rs +++ b/crates/doc/src/proto_expr.rs @@ -169,7 +169,12 @@ impl Expr { }) .collect(); match (op, parts.as_slice()) { - (ExprOp::Not, [x]) => format!("not {x}"), + // `not done` reads as a variable named "not done" if there is one: then + // `not (done)`. + (ExprOp::Not, [x]) => { + let bare = format!("not {x}"); + if x.starts_with('(') || Expr::parse(&bare, vars).is_ok_and(|e| e == *self) { bare } else { format!("not ({x})") } + } (ExprOp::Negate, [x]) => format!("-{x}"), _ => parts.join(&format!(" {} ", op.symbol())), } diff --git a/crates/doc/src/proto_expr_parse.rs b/crates/doc/src/proto_expr_parse.rs index 5214a2d6..54615ee0 100644 --- a/crates/doc/src/proto_expr_parse.rs +++ b/crates/doc/src/proto_expr_parse.rs @@ -459,16 +459,19 @@ impl Parser<'_> { if self.at_keyword("mode") && self.next_after(4) == Some('(') { return self.mode_of(); } - if let Some((id, n)) = self.variable_here() { - self.i += n; - return self.in_mode(id); - } + let var = self.variable_here(); + // A bare `true`/`false` is the literal, also when a variable has that name (it reads + // as `{true}`); longer names that start with it ("true value") stay variables. for (word, v) in [("true", true), ("false", false)] { - if self.at_keyword(word) { + if self.at_keyword(word) && var.as_ref().is_none_or(|(_, n)| *n <= word.len()) { self.i += word.len(); return Ok(Expr::Value(VariableValue::Boolean(v))); } } + if let Some((id, n)) = var { + self.i += n; + return self.in_mode(id); + } let word: String = self.s.iter().skip(self.i).take_while(|c| ident(**c)).collect(); if word.is_empty() { Err(self.err(format!("Unexpected {c}"))) } else { Err(self.err(format!("No variable `{word}`"))) } } diff --git a/crates/doc/src/proto_expr_parse_tests.rs b/crates/doc/src/proto_expr_parse_tests.rs index e106121d..1cac9f53 100644 --- a/crates/doc/src/proto_expr_parse_tests.rs +++ b/crates/doc/src/proto_expr_parse_tests.rs @@ -195,3 +195,49 @@ fn names_that_look_like_literals_read_back_as_variables() { assert_eq!(parse("1"), n(1.0)); assert_eq!(parse("\"q\""), Expr::Value(VariableValue::String("q".into()))); } + +#[test] +fn not_of_a_variable_reads_back_when_a_name_starts_with_not() { + use ExprOp::*; + let mut v = vars(); + let b = v.variables.iter().find(|x| x.id == "b").unwrap().clone(); + for (id, name) in [("done", "done"), ("nd", "not done")] { + let mut x = b.clone(); + x.id = id.into(); + x.name = name.into(); + v.variables.push(x); + } + let not_done = Expr::op(Not, vec![Expr::var("done")]); + for e in [ + not_done.clone(), + Expr::op(And, vec![Expr::var("b"), not_done.clone()]), + Expr::op(Equals, vec![not_done.clone(), Expr::Value(VariableValue::Boolean(true))]), + Expr::op(Not, vec![not_done.clone()]), + ] { + let shown = e.display(&v); + assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "shown as {shown:?}"); + } + assert_eq!(Expr::op(Not, vec![Expr::var("b")]).display(&vars()), "not b-name", "plain names stay bare"); +} + +#[test] +fn literal_true_false_survive_variables_named_true_false() { + use ExprOp::*; + let lit = |x: bool| Expr::Value(VariableValue::Boolean(x)); + for name in ["true", "false", "TRUE"] { + let mut v = vars(); + if let Some(var) = v.variables.iter_mut().find(|x| x.id == "b") { + var.name = name.into(); + } + for e in [ + Expr::op(Equals, vec![Expr::var("b"), lit(true)]), + Expr::op(Equals, vec![Expr::var("b"), lit(false)]), + Expr::op(Not, vec![lit(false)]), + Expr::var("b"), + ] { + let shown = e.display(&v); + assert_eq!(Expr::parse(&shown, &v), Ok(e.clone()), "{name:?} shown as {shown:?}"); + } + assert_eq!(Expr::parse(&format!("{{{name}}}"), &v), Ok(Expr::var("b"))); + } +} diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index a5ead3b7..1a006c26 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -326,6 +326,20 @@ fn repair_drops_cycles_and_repeated_children() { assert_eq!(bad.subtree(p), vec![p, r]); } +#[test] +fn validate_caps_the_issue_list() { + let mut d = Document::new("x"); + let p = page(&d); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let g = d.create(f, NodeKind::Frame, "G", 0.0, 0.0, 10.0, 10.0).unwrap(); + let mut v = serde_json::to_value(&d).unwrap(); + v["nodes"][f.to_string()]["children"] = serde_json::json!(vec![g; 5000]); + let bad: Document = serde_json::from_value(v).unwrap(); + let issues = bad.validate(); + assert!(!issues.is_empty()); + assert!(issues.len() <= 100, "{}", issues.len()); +} + #[test] fn auto_layout_loads_files_without_grid_tracks() { // Saved before grid tracks, cells and spans existed. @@ -464,3 +478,84 @@ fn text_style_empty_range_links_and_unlinks_all() { e.unlink_text_style(0, 0); assert!(e.style.style_id.is_none()); } + +#[test] +fn stale_next_id_never_overwrites_existing_nodes() { + let mut d = Document::new("x"); + let p = page(&d); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let a = d.create(f, NodeKind::Rectangle, "A", 0.0, 0.0, 10.0, 10.0).unwrap(); + let mut v = serde_json::to_value(&d).unwrap(); + v["next_id"] = serde_json::json!(1); + let mut bad: Document = serde_json::from_value(v).unwrap(); + let before: Vec<(NodeId, String, Option)> = + [p, f, a].iter().map(|i| (*i, bad.node(*i).unwrap().name.clone(), bad.node(*i).unwrap().parent)).collect(); + let n = bad.node_count(); + let copy = bad.duplicate_into(f, p, None).unwrap(); + assert!(![p, f, a].contains(©)); + assert_eq!(bad.node_count(), n + 2); + let copies = bad.duplicate_all_into(&[f], p).unwrap(); + assert_eq!(bad.node_count(), n + 4); + assert!(copies.iter().all(|c| ![p, f, a, copy].contains(c))); + for (i, name, parent) in &before { + let node = bad.node(*i).unwrap(); + assert_eq!((&node.name, node.parent), (name, *parent)); + } + assert!(bad.validate().is_empty(), "{:?}", bad.validate()); + + // A paste whose id map points at an existing node fails and changes nothing. + let nodes: Vec = bad.subtree(f).into_iter().filter_map(|i| bad.node(i).cloned()).collect(); + let mut map: BTreeMap = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect(); + map.insert(a, p); + let snapshot = bad.clone(); + assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err()); + assert_eq!(bad, snapshot); + assert_eq!(bad.node(p).unwrap().name, snapshot.node(p).unwrap().name); + assert!(bad.validate().is_empty(), "{:?}", bad.validate()); + + // So does one that maps two nodes to the same new id. + let mut map: BTreeMap = nodes.iter().map(|n| (n.id, bad.alloc_id())).collect(); + map.insert(a, map[&f]); + assert!(bad.paste_nodes(&nodes, &map, f, p, None).is_err()); + assert_eq!(bad, snapshot); +} + +#[test] +fn hit_test_even_odd_vector_hole_misses() { + let ring = |rule| { + let sq = |o: f64, s: f64, base: usize| { + ( + [(o, o), (o + s, o), (o + s, o + s), (o, o + s)].map(|(x, y)| Vertex::new(x, y)), + [Segment::line(base, base + 1), Segment::line(base + 1, base + 2), Segment::line(base + 2, base + 3), Segment::line(base + 3, base)], + ) + }; + let (v1, s1) = sq(0.0, 200.0, 0); + let (v2, s2) = sq(60.0, 80.0, 4); + VectorNetwork { + vertices: v1.into_iter().chain(v2).collect(), + segments: s1.into_iter().chain(s2).collect(), + regions: vec![Region { loops: vec![vec![0, 1, 2, 3], vec![4, 5, 6, 7]], winding: rule }], + } + }; + let mut d = Document::new("x"); + let p = page(&d); + let eo = d.create(p, NodeKind::Vector { network: ring(WindingRule::EvenOdd) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap(); + assert_eq!(d.hit_test(p, Point::new(20.0, 100.0), 0.0, None), Some(eo)); + assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), None, "the hole is not filled"); + let mut d = Document::new("x"); + let p = page(&d); + let nz = d.create(p, NodeKind::Vector { network: ring(WindingRule::NonZero) }, "V", 0.0, 0.0, 200.0, 200.0).unwrap(); + assert_eq!(d.hit_test(p, Point::new(100.0, 100.0), 0.0, None), Some(nz)); +} + +#[test] +fn is_ancestor_holds_for_very_deep_chains() { + let mut d = Document::new("x"); + let root = d.create(page(&d), NodeKind::Frame, "root", 0.0, 0.0, 1.0, 1.0).unwrap(); + let mut leaf = root; + for _ in 0..10_005 { + leaf = d.create(leaf, NodeKind::Frame, "n", 0.0, 0.0, 1.0, 1.0).unwrap(); + } + assert!(d.is_ancestor(root, leaf)); + assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..)))); +} diff --git a/crates/engine/src/binding_lists_tests.rs b/crates/engine/src/binding_lists_tests.rs index 9db9975a..8b221efd 100644 --- a/crates/engine/src/binding_lists_tests.rs +++ b/crates/engine/src/binding_lists_tests.rs @@ -46,7 +46,7 @@ fn a_bound_fill_colour_keeps_the_other_fills() { let fills = s.doc().unwrap().node(r).unwrap().fills.clone(); assert_eq!(fills.len(), 2, "the second fill stays"); assert_eq!(fills[0].kind, PaintKind::Solid { color: Color::from_rgb8_const(0xFF, 0, 0) }); - assert_eq!(fills[0].opacity, 1.0, "the variable brings the whole colour, alpha included"); + assert_eq!(fills[0].opacity, 0.5, "the bound paint keeps its opacity"); assert_eq!(fills[1].kind, PaintKind::Solid { color: Color::from_rgb8_const(0, 0xFF, 0) }); // Removing the bound (first) fill takes the binding with it. ex(&mut s, "fill.remove", json!({"ids": [r], "index": 0})); diff --git a/crates/engine/src/bound_alpha_tests.rs b/crates/engine/src/bound_alpha_tests.rs index b7210f0f..fb15425d 100644 --- a/crates/engine/src/bound_alpha_tests.rs +++ b/crates/engine/src/bound_alpha_tests.rs @@ -129,3 +129,36 @@ fn code_gives_a_translucent_stroke_its_alpha_once() { let code = s.execute("inspect.code", &json!({"id": r.0})).unwrap()["code"].as_str().unwrap_or_default().to_string(); assert!(code.contains("solid rgba(255, 0, 0, 0.50)"), "{code}"); } + +#[test] +fn binding_keeps_an_opacity_set_on_the_paint() { + // A 15% tint whose colour comes from a variable (#923): the bind replaces the colour only. + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Light"]})); + ex(&mut s, "variable.create", json!({"collection": "Tokens", "name": "red", "type": "color", "value": "#FF0000"})); + let props = json!({"fills": [{"type": "solid", "color": "#000000", "opacity": 0.15}]}); + let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, "props": props})).unwrap(); + let r = NodeId(r["id"].as_u64().unwrap()); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "red"})); + let p = s.doc().unwrap().node(r).unwrap().fills[0].clone(); + assert_eq!(p.kind, figcraft_color::PaintKind::Solid { color: figcraft_color::Color::from_rgb8_const(0xFF, 0, 0) }); + assert!(near(p.opacity, 0.15), "{}", p.opacity); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": null})); + assert!(near(s.doc().unwrap().node(r).unwrap().fills[0].opacity, 0.15)); + // An opacity set on a `#RRGGBBAA` fill is the user's too, and stays. + let (mut s, r) = setup("#FF0000", "#FF0000"); + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.25})); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": "tint"})); + assert!(near(alpha(&s, r), 0.25), "{}", alpha(&s, r)); +} + +#[test] +fn an_unbind_and_rebind_keeps_the_opacity_set_on_the_paint() { + // A paint at 50% bound to a half-transparent colour shows a quarter, unbound or bound again. + let (mut s, r) = setup("#FF000080", "#FF000080"); + ex(&mut s, "fill.set", json!({"ids": [r.0], "index": 0, "opacity": 0.5})); + for variable in [json!("tint"), json!(null), json!("tint"), json!(null)] { + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": variable})); + assert!(near(alpha(&s, r), HALF / 2.0), "{variable}: {}", alpha(&s, r)); + } +} diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 6da64855..78609771 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -165,7 +165,7 @@ pub fn specs() -> Vec { spec!( "component.resetOverride", "Reset override", - "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|component|props|slot; all when omitted)}", + "{id?, group? (fills|strokes|effects|opacity|visible|name|corners|characters|textStyle|padding|gap|alignment|grids|exports|interactions|size|position|modes|component|props|slot; all when omitted)}", true, reset_override ), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 2225133b..5ed92aec 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1375,10 +1375,12 @@ fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) }; if c.a < 1.0 { slot.opacity = c.a; + slot.color_alpha = c.a; } } if let Some(o) = num(p, "opacity") { slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32; + slot.color_alpha = 1.0; } if let Some(v) = boolean(p, "visible") { slot.visible = v; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index e0a3e0d8..ec579d51 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1181,8 +1181,11 @@ fn export_path(name: &str) -> String { if parts.is_empty() { "Untitled".into() } else { parts.join("/") } } -/// `name` without path separators and characters file systems reject. -fn safe_file_name(name: &str) -> String { +/// `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() } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 60d08963..049c0d6f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -99,6 +99,7 @@ fn registry() -> &'static Registry { list.extend(crate::code_connect::specs()); list.extend(crate::annotations::specs()); list.extend(crate::dev_resources::specs()); + list.extend(crate::dev_measurements::specs()); list.extend(crate::distance::specs()); list.extend(crate::collab::specs()); list.extend(crate::branches::specs()); diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 3ed0f9b0..2eedab45 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st /// Keys `node.set` understands (documented for agents). pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \ -fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ +fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour, or \"none\"/null: no fill), strokes, stroke (colour, or \"none\"/null: no stroke), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ @@ -372,14 +372,21 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec) { } } -/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, variable -/// bindings last so paints set in the same call don't detach them. +/// `"none"` (either case) and null ask for no paint, as an empty `fills`/`strokes` list does. +fn paint_none(v: &Value) -> bool { + v.is_null() || v.as_str().is_some_and(|s| s.eq_ignore_ascii_case("none")) +} + +/// The order [`apply`] takes keys in: geometry first so rotation applies to the final size, style +/// links after the values so a link wins over values its style sets, variable bindings last so +/// paints set in the same call don't detach them. fn apply_order(k: &str) -> u8 { match k { "width" | "height" | "w" | "h" => 0, "rotation" => 2, "x" | "y" => 3, - "boundVariables" => 4, + "fillStyle" | "strokeStyle" | "effectStyle" | "textStyle" | "gridStyle" => 4, + "boundVariables" => 5, _ => 1, } } @@ -462,13 +469,21 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?), "fill" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour"))?; - set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + if paint_none(v) { + set_paints(doc.get_mut(id)?, true, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]); + } } "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?), "stroke" => { - let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour"))?; - set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + if paint_none(v) { + set_paints(doc.get_mut(id)?, false, vec![]); + } else { + let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?; + set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]); + } } "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0), "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?, diff --git a/crates/engine/src/cmd/structure.rs b/crates/engine/src/cmd/structure.rs index 90e18611..28a242af 100644 --- a/crates/engine/src/cmd/structure.rs +++ b/crates/engine/src/cmd/structure.rs @@ -107,6 +107,20 @@ pub fn specs() -> Vec { } ok() }), + spec!( + "vector.fillRule", + "Set fill rule", + "{ids? | id?, region? (a filled area of a vector; every area when left out), rule: nonZero|evenOdd}", + true, + fill_rule + ), + spec!( + "vector.reverseLoop", + "Reverse loop", + "{ids? | id?, region, loop} (a loop of a filled area; its direction flips, which changes how the area fills)", + true, + reverse_loop + ), spec!("vector.simplify", "Simplify vector", "{ids?, tolerance?}", true, simplify), spec!("vector.offset", "Offset path", "{ids?, offset}", true, offset_path), spec!("vector.join", "Join selection", "{ids?} (in vector edit mode: joins the selected points)", true, |s, p| { @@ -729,3 +743,146 @@ fn resize_to_fit(s: &mut Session, p: &Value) -> Result { } Ok(json!({"ids": fitted})) } + +/// The vector network of `id`, when it is a vector layer. +fn network_mut(doc: &mut Document, id: NodeId) -> Option<&mut VectorNetwork> { + match &mut doc.get_mut(id).ok()?.kind { + NodeKind::Vector { network } => Some(network), + _ => None, + } +} + +/// The id a fill rule is written as. +fn rule_id(rule: WindingRule) -> &'static str { + match rule { + WindingRule::NonZero => "nonZero", + WindingRule::EvenOdd => "evenOdd", + } +} + +/// `region` as an index: absent (or null) means every area. +fn area_index(cmd: &str, p: &Value) -> Result> { + match get(p, "region") { + None | Some(Value::Null) => Ok(None), + Some(v) => { + let k = f64_of(v).ok_or_else(|| bad(cmd, "`region` must be a number"))?; + if !(0.0..1e9).contains(&k) || k.fract() != 0.0 { + return Err(bad(cmd, "`region` out of range")); + } + Ok(Some(k as usize)) + } + } +} + +/// Segments another one- or two-segment loop lists: a short loop takes its direction from the ends +/// its segments are stored with, so re-storing one of those would turn that loop round too. A loop +/// of three or more segments is left out: its ring is walked in list order, which the stored ends +/// don't decide. +fn short_loop_segments(net: &VectorNetwork, skip: (usize, usize)) -> std::collections::HashSet { + let mut out = std::collections::HashSet::new(); + for (r, region) in net.regions.iter().enumerate() { + for (l, lp) in region.loops.iter().enumerate() { + if (r, l) != skip && lp.len() <= 2 { + out.extend(lp.iter().copied()); + } + } + } + out +} + +fn fill_rule(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("vector.fillRule", s, p)?; + let rule = match string(p, "rule").as_deref().map(str::trim) { + Some("evenOdd" | "evenodd" | "EVENODD") => WindingRule::EvenOdd, + Some("nonZero" | "nonzero" | "NONZERO") => WindingRule::NonZero, + _ => return Err(bad("vector.fillRule", "`rule` is nonZero or evenOdd")), + }; + let only = area_index("vector.fillRule", p)?; + let doc = s.doc_mut()?; + let mut count = 0usize; + for id in ids { + let Some(net) = network_mut(doc, id) else { continue }; + match only { + Some(r) => { + if let Some(region) = net.regions.get_mut(r) { + region.winding = rule; + count += 1; + } + } + None => { + for region in &mut net.regions { + region.winding = rule; + count += 1; + } + } + } + } + if count == 0 { + return Err(EngineError::Disabled("vector.fillRule".into(), "no filled area to set (select a vector)".into())); + } + Ok(json!({"regions": count, "rule": rule_id(rule)})) +} + +/// One loop of a vector, worked out but not yet written back: its segments in the new order and, +/// for a loop of one or two segments, the one to store the other way. +struct LoopFlip { + id: NodeId, + turned: Vec, + store: Option, +} + +/// Turn one loop of a filled area round: its segments are walked the other way, so the area it +/// bounds counts the other way in the winding (which turns a hole into a filled part and back). +/// +/// How a loop is walked depends on how many segments it has: +/// - three or more: the order they are listed in, each segment turned to continue from the point +/// reached. Reversing the list is therefore enough, and the stored ends are left alone (a shared +/// segment's ends belong to its neighbour too); +/// - two: the same ring either way round, so one of the two has to be stored the other way instead; +/// - one: only its stored ends say which way it runs, so they are swapped. +/// +/// A short loop can only turn round by re-storing a segment, which is refused when another short +/// loop lists that segment too: it would turn that area round as well. +fn reverse_loop(s: &mut Session, p: &Value) -> Result { + let cmd = "vector.reverseLoop"; + let ids = req_targets(cmd, s, p)?; + let region = req_index(cmd, p, "region")?; + let index = req_index(cmd, p, "loop")?; + let doc = s.doc_mut()?; + // Work every loop out before touching the document: one that can't turn round refuses the call + // and the others stay as they were. + let mut flips: Vec = Vec::new(); + for id in ids { + let Some(NodeKind::Vector { network }) = doc.node(id).map(|n| &n.kind) else { continue }; + let Some(loop_) = network.regions.get(region).and_then(|r| r.loops.get(index)) else { continue }; + if loop_.is_empty() { + continue; + } + let mut store = None; + if loop_.len() <= 2 { + let busy = short_loop_segments(network, (region, index)); + let Some(pick) = loop_.iter().copied().filter(|s| !busy.contains(s)).min() else { + return Err(EngineError::Disabled( + cmd.into(), + "the loop shares its segments with another one- or two-segment loop, so turning it round would turn that one round too".into(), + )); + }; + store = Some(pick); + } + flips.push(LoopFlip { id, turned: loop_.iter().rev().copied().collect(), store }); + } + if flips.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "no such loop (select a vector with filled areas)".into())); + } + for LoopFlip { id, turned, store } in flips { + let Some(net) = network_mut(doc, id) else { continue }; + if let Some(seg) = store.and_then(|i| net.segments.get_mut(i)) { + std::mem::swap(&mut seg.a, &mut seg.b); + std::mem::swap(&mut seg.ta, &mut seg.tb); + } + if let Some(l) = net.regions.get_mut(region).and_then(|r| r.loops.get_mut(index)) { + *l = turned; + } + } + Ok(json!({"reversed": true})) +} diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index af029e5f..e5ac145e 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -804,10 +804,10 @@ pub fn bind(s: &mut Session, p: &Value) -> Result { ok() } -/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding restarts -/// the opacity (which holds the alpha a typed colour had, see `Paint::solid`) at 1, so the -/// variable's alpha applies once; unbinding moves the colour's alpha back into the opacity, where a -/// typed colour keeps it. An opacity set while bound multiplies on top and stays. +/// A colour variable bound to a solid paint owns its whole colour, alpha included. Binding takes the +/// alpha a typed colour had (see `Paint::solid`) out of the opacity, so the variable's alpha applies +/// once; an opacity set on its own, before or while bound, multiplies on top and stays. Unbinding +/// moves the colour's alpha back into the opacity, where a typed colour keeps it. pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], field: &str, bind: bool) { use super::lists::{BoundList, list_binding}; let Some((list, i, "color")) = list_binding(field) else { return }; @@ -819,7 +819,14 @@ pub(crate) fn rebind_paint_alpha(fills: &mut [Paint], strokes: &mut [Paint], fie if let Some(p) = paints.get_mut(i) && let PaintKind::Solid { color } = &mut p.kind { - p.opacity = if bind { 1.0 } else { p.opacity * color.a }; + if bind { + // What's left is the opacity set on its own (unknown under a colour alpha of 0: 1). + p.opacity = if p.color_alpha > 0.0 { (p.opacity / p.color_alpha).clamp(0.0, 1.0) } else { 1.0 }; + p.color_alpha = 1.0; + } else { + p.opacity *= color.a; + p.color_alpha *= color.a; + } color.a = 1.0; } } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 0905284c..17f71b49 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -537,7 +537,14 @@ pub fn specs() -> Vec { spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)), spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)), spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)), - spec!("edit.pickColor", "Pick color", "{}", false, |s, _| tool(s, ToolId::Eyedropper)), + // `apply: false` is for the UI colour picker: the eyedropper only samples (into + // `ToolState::picked`) and the picker writes the colour to the paint it edits. + spec!("edit.pickColor", "Pick color", "{apply?}", false, |s, p| { + let r = tool(s, ToolId::Eyedropper)?; + s.tool.pick_only = !boolean(p, "apply").unwrap_or(true); + s.tool.picked = None; + Ok(r) + }), ] } diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 67259d03..87496c37 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -12,9 +12,10 @@ //! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment` //! (of auto layout: its direction and other settings are the main's), `grids`, `exports`, //! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers -//! inside an instance, `component` (a swapped nested instance), `props` (property values of a -//! nested instance) and `slot` (a slot holding the instance's own content). An instance's own -//! export settings, interactions and resizing are its own, like its place. `position` +//! inside an instance, `modes` (the variable modes a layer inside an instance chooses), +//! `component` (a swapped nested instance), `props` (property values of a nested instance) and +//! `slot` (a slot holding the instance's own content). An instance's own export settings, +//! interactions, resizing and variable modes are its own, like its place. `position` //! overrides of layers inside an instance are still read and kept, but new ones are refused //! ([`check_override_edit`]): those layers take their place and constraints from the main. //! @@ -60,6 +61,7 @@ pub const OVERRIDE_GROUPS: &[&str] = &[ "size", "resizing", "position", + "modes", "component", "props", SLOT, @@ -114,6 +116,23 @@ pub fn inside_instance(doc: &Document, id: NodeId) -> bool { enclosing_instance(doc, id).is_some() } +/// Mark the `modes` override on layers inside instances whose variable modes differ from their +/// main layer's, for files from before `modes` was an override group. Copies follow their main +/// layer's modes, so a layer on other modes chose them itself. +pub fn mark_layer_modes(doc: &mut Document) { + let chose: Vec = doc + .nodes() + .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes)) + .map(|n| n.id) + .filter(|id| inside_instance(doc, *id)) + .collect(); + for id in chose { + if let Some(n) = doc.node_mut(id) { + n.overrides.insert("modes".into()); + } + } +} + /// The slot property frame `id` stands for (its name and preferred components): a frame bound to /// a slot property of its component, or an instance's copy of one (followed through its sources). pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> { @@ -283,6 +302,8 @@ pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, root.code_links.clear(); // The main's dev resources show on its instances without being copied. root.dev_resources.clear(); + // Measurements belong to the layer they were placed on. + root.measurements.clear(); // The main's interactions play on its instances in prototypes; the instance's own start empty. root.interactions.clear(); // So do the variable modes the main sets (see `Document::inherited_modes`). @@ -335,6 +356,27 @@ fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { } } +/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis) +/// that is still `was`'s follows `new`; what the instance set itself stays in `own`. +fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) { + if own.sizing_h == was.sizing_h { + own.sizing_h = new.sizing_h; + } + if own.sizing_v == was.sizing_v { + own.sizing_v = new.sizing_v; + } + for (o, w, n) in [ + (&mut own.min_w, was.min_w, new.min_w), + (&mut own.max_w, was.max_w, new.max_w), + (&mut own.min_h, was.min_h, new.min_h), + (&mut own.max_h, was.max_h, new.max_h), + ] { + if *o == w { + *o = n; + } + } +} + /// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them. pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) { @@ -415,6 +457,7 @@ pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); } "position" => to.transform = from.transform, + "modes" => to.var_modes = from.var_modes.clone(), "component" => { if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { *b = *a; @@ -679,6 +722,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT let prev = old.get(&nid); // Annotations belong to the layer they were added to, not to its copies. n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default(); + n.measurements = prev.map(|o| o.measurements.clone()).unwrap_or_default(); if let Some(o) = prev { carry_overrides(o, &mut n, Carry::Values); keep_laid_out(doc, orig, o, &mut n); @@ -756,6 +800,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT fresh.code_links.clear(); // An instance's own dev resources (its main's are listed with them, not copied). fresh.dev_resources = root.dev_resources.clone(); + fresh.measurements = root.measurements.clone(); fresh.locked = root.locked; fresh.visible = root.visible; fresh.var_modes = root.var_modes.clone(); @@ -779,6 +824,7 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) { let differs = diff_groups(prev, &comp); root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str())); + follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout); } carry_overrides(&root, &mut fresh, Carry::Root); if let Some(r) = doc.node_mut(inst) { @@ -830,9 +876,10 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT } // Nested instances showing another main than the main's copy (swapped): rebuild from it, // with the old layers below them this rebuild didn't reuse. - let unused: BTreeMap> = old.into_iter().filter(|(i, _)| !used.contains(i) && !doc.contains(*i)).collect(); + let unused: BTreeMap> = + old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect(); for n in nested { - let below: BTreeMap> = unused + let mut below: BTreeMap> = unused .iter() .filter(|(_, x)| { let mut cur = x.parent; @@ -847,10 +894,77 @@ fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BT }) .map(|(i, x)| (*i, x.clone())) .collect(); + take_placing_overrides(doc, n, &old, &mut below); sync_instance_depth(doc, n, depth + 1, below); } } +/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the +/// copies just made of the outer main's, and `below`) takes the overrides held along the source +/// chain it had before this rebuild (`before`), through layers inside instances in the mains that +/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a +/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main. +/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value. +fn take_placing_overrides( + doc: &mut Document, + n: NodeId, + before: &BTreeMap>, + below: &mut BTreeMap>, +) { + let placing = placing_mains(doc, n); + for id in doc.subtree(n).into_iter().filter(|i| *i != n) { + let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default(); + if let Some(layer) = doc.node_mut(id) { + take(layer, &held); + } + } + for layer in below.values_mut() { + let held = held_along(doc, layer, &placing); + if !held.is_empty() { + take(std::sync::Arc::make_mut(layer), &held); + } + } +} + +/// The mains placing nested instance `n`: those of the layers it mirrors, outward. +fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet { + let mut out = BTreeSet::new(); + let mut cur = doc.node(n).and_then(|x| x.source); + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break }; + out.extend(owning_component(doc, s.id)); + cur = s.source; + } + out +} + +/// The overrides but geometry and slot content held along `layer`'s source chain while it runs +/// through layers inside instances in `placing` mains, each with the nearest layer holding it. +fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet) -> BTreeMap> { + let mut out = BTreeMap::new(); + let mut cur = layer.source; + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break }; + if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) { + break; + } + for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) { + out.entry(g.clone()).or_insert_with(|| s.clone()); + } + cur = s.source; + } + out +} + +/// Give `layer` the `held` groups it has no override of its own for. +fn take(layer: &mut Node, held: &BTreeMap>) { + for (g, from) in held { + if layer.overrides.insert(g.clone()) { + copy_group(from, layer, g); + } + } +} + /// Whether main components `a` and `b` are two variants of one set. fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool { a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s)) @@ -1049,6 +1163,9 @@ pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { if a.interactions != b.interactions { out.push("interactions"); } + if a.var_modes != b.var_modes { + out.push("modes"); + } if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) { if x != y { out.push("component"); @@ -1146,10 +1263,10 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId] } let raw = diff_groups(b, n); let mut groups = raw.clone(); - // An instance's own place, resizing, export settings and interactions are its own, not - // overrides. + // An instance's own place, resizing, export settings, interactions and variable modes are + // its own, not overrides. if top_root { - groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions")); + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes")); } if groups.iter().any(|g| matches!(*g, "size" | "position")) { groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); @@ -1478,6 +1595,7 @@ fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option = value_groups(m, n) .into_iter() .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) - .filter(|g| placed_source.is_some() || g != "resizing") + .filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) .collect(); let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; if named { @@ -1993,8 +2111,11 @@ pub fn rebase_instance(doc: &mut Document, inst: NodeId) { } if let Some(n) = doc.node_mut(inst) { n.source = root_base; - // A top-level instance's resizing is its own (a nested one's overrides its main layer's). - n.overrides.retain(|g| !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || g != "resizing")); + // A top-level instance's resizing and modes are its own (a nested one's override its main + // layer's). + n.overrides.retain(|g| { + !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + }); n.overrides.extend(root_groups); } } diff --git a/crates/engine/src/components_behaviour_tests.rs b/crates/engine/src/components_behaviour_tests.rs index 7a5c1f90..a95e1268 100644 --- a/crates/engine/src/components_behaviour_tests.rs +++ b/crates/engine/src/components_behaviour_tests.rs @@ -657,6 +657,64 @@ fn switching_variants_keeps_overrides_where_the_variants_agreed() { healthy(&s); } +/// A "Layout" set: Row (horizontal, hugging, a 40 wide "A") and Stacked (vertical, fixed 300 wide, +/// "A" filling it). Returns (set, row, stacked). +fn layout_set(s: &mut Session) -> (NodeId, NodeId, NodeId) { + let mut variants = Vec::new(); + for (k, (name, props, child)) in [ + ("Layout=Row", json!({"layout": "horizontal", "gap": 8, "sizingH": "hug", "sizingV": "hug", "fills": []}), json!({})), + ( + "Layout=Stacked", + json!({"layout": "vertical", "gap": 8, "sizingH": "fixed", "width": 300, "sizingV": "hug", "fills": []}), + json!({"sizingH": "fill"}), + ), + ] + .into_iter() + .enumerate() + { + let c = idv(&ex(s, "node.create", json!({"type": "component", "name": name, "x": k as f64 * 400.0, "props": props}))); + ex(s, "node.create", json!({"type": "rectangle", "parent": c, "name": "A", "width": 40, "height": 20, "props": child})); + variants.push(c); + } + let set = idv(&ex(s, "component.combineVariants", json!({"ids": variants.clone()}))); + (set, variants[0], variants[1]) +} + +#[test] +fn switching_variants_takes_the_new_variants_resizing_unless_the_instance_set_its_own() { + let mut s = Session::new(); + let (set, row, stacked) = layout_set(&mut s); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200}))); + assert_eq!(component_of(&s, i), row); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Layout": "Stacked"}})); + assert_eq!(component_of(&s, i), stacked); + let direct = idv(&ex(&mut s, "instance.create", json!({"component": stacked, "x": 0, "y": 400}))); + let (n, d) = (node(&s, i), node(&s, direct)); + assert_eq!(n.child_layout.sizing_h, Sizing::Fixed, "Row's Hug was never the instance's own: it takes Stacked's Fixed"); + assert_eq!((n.width, n.child_layout.sizing_h), (d.width, d.child_layout.sizing_h), "the same as an instance made from Stacked"); + let a = layer(&s, i, "A").unwrap(); + assert_eq!((node(&s, a).child_layout.sizing_h, node(&s, a).width), (Sizing::Fill, 300.0), "A fills the 300 wide instance"); + // Filling an auto layout parent afterwards leaves A filling, with nothing turned into an override. + let col = idv(&ex( + &mut s, + "node.create", + json!({"type": "frame", "name": "Column", "x": 600, "y": 0, "width": 500, "props": {"layout": "vertical", "sizingH": "fixed", "sizingV": "hug", "fills": []}}), + )); + ex(&mut s, "node.reparent", json!({"ids": [i], "parent": col})); + ex(&mut s, "node.set", json!({"ids": [i], "props": {"sizingH": "fill"}})); + let a = layer(&s, i, "A").unwrap(); + assert_eq!(node(&s, a).child_layout.sizing_h, Sizing::Fill, "A still fills"); + assert!(!node(&s, a).overrides.contains("resizing"), "A's resizing is still the main's"); + assert_eq!(node(&s, a).width, node(&s, i).width); + // Resizing the instance set itself stays across a switch. + let own = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 600}))); + ex(&mut s, "node.set", json!({"ids": [own], "props": {"minWidth": 120}})); + ex(&mut s, "instance.setProperties", json!({"ids": [own], "properties": {"Layout": "Stacked"}})); + assert_eq!(node(&s, own).child_layout.min_w, Some(120.0), "its own min width stays"); + assert_eq!(node(&s, own).child_layout.sizing_h, Sizing::Fixed, "its sizing, still Row's, follows Stacked"); + healthy(&s); +} + #[test] fn variant_combinations_order_default_and_conflicts() { let mut s = Session::new(); diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/components_rebase_source_tests.rs index b272b82f..94eae71f 100644 --- a/crates/engine/src/components_rebase_source_tests.rs +++ b/crates/engine/src/components_rebase_source_tests.rs @@ -201,6 +201,20 @@ fn rebase_detached_nested_uses_own_main_and_keeps_inherited_geometry() { assert_eq!(s.doc.node(s.base).unwrap().transform.translation().x, 2.0); } +#[test] +fn rebase_marks_a_nested_roots_own_mode_but_not_a_top_level_instances() { + let mut s = scene(); + let modes = BTreeMap::from([("Tone".to_string(), "B".to_string())]); + s.doc.node_mut(s.nested).unwrap().var_modes = modes.clone(); + both(&mut s); + assert!(s.doc.node(s.nested).unwrap().overrides.contains("modes"), "its slot in Outer sets no mode"); + let page = s.doc.first_page().unwrap(); + let detached = mirror(&mut s.doc, s.nested, page); + rebase_instance(&mut s.doc, detached); + let n = s.doc.node(detached).unwrap(); + assert_eq!((&n.var_modes, n.overrides.contains("modes")), (&modes, false), "a top-level instance's modes are its own"); +} + #[test] fn rebase_does_not_preserve_existing_foreign_or_stale_sources() { let mut s = scene(); diff --git a/crates/engine/src/dev_measurements.rs b/crates/engine/src/dev_measurements.rs new file mode 100644 index 00000000..c5df56ea --- /dev/null +++ b/crates/engine/src/dev_measurements.rs @@ -0,0 +1,222 @@ +//! Measurements kept on the canvas (Dev Mode): a developer places one between two layers, or +//! across one side of a layer, and everyone who opens the file sees it. Each is stored on the layer +//! it starts from (`Node::measurements`: saved, synced, undoable) and drawn from the layers' +//! current bounds, so it follows them when they move or resize. One whose other layer was deleted +//! or moved to another page is neither drawn nor listed (undoing the deletion brings it back). + +use figcraft_doc::{Document, Measurement, Node, NodeId, Side}; +use figcraft_geom::{Point, Rect}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::distance::{between, measured}; +use crate::params::{get, opt_id, opt_index}; +use crate::snap::Guide; +use crate::{EngineError, Result, Session, bad, spec}; + +/// Most measurements starting from one layer. +pub const MAX_PER_LAYER: usize = 64; +/// Most measurements listed or drawn for one page. +pub const MAX_LISTED: usize = 2_000; + +/// A measurement on a page: the layer it starts from, its index there, what it measures and the +/// lines it draws (page space). +#[derive(Clone, Debug, PartialEq)] +pub struct Placed { + pub from: NodeId, + pub index: usize, + pub measurement: Measurement, + pub lines: Vec, +} + +impl Placed { + /// Whether it starts or ends at layer `id`. + pub fn touches(&self, id: NodeId) -> bool { + self.from == id || self.measurement.to == Some(id) + } + + /// The distances it shows, as on its labels. + pub fn labels(&self) -> Vec<&str> { + self.lines.iter().filter_map(|g| g.label.as_deref()).collect() + } +} + +/// The line along side `side` of box `b`, labelled with that side's length. +fn side_line(b: Rect, side: Side) -> Vec { + let (p, q) = match side { + Side::Top => (Point::new(b.x0, b.y0), Point::new(b.x1, b.y0)), + Side::Bottom => (Point::new(b.x0, b.y1), Point::new(b.x1, b.y1)), + Side::Left => (Point::new(b.x0, b.y0), Point::new(b.x0, b.y1)), + Side::Right => (Point::new(b.x1, b.y0), Point::new(b.x1, b.y1)), + Side::Auto => return Vec::new(), + }; + let mut out = Vec::new(); + measured(&mut out, p, q); + out +} + +/// The lines measurement `m` from layer `from` draws, in page space (none when a layer is gone). +pub fn lines(doc: &Document, from: NodeId, m: Measurement) -> Vec { + let Some(a) = doc.world_bounds(from) else { return Vec::new() }; + match (m.to, m.side) { + (Some(to), _) => doc.world_bounds(to).map(|b| between(a, b)).unwrap_or_default(), + (None, Some(side)) => side_line(a, side), + (None, None) => Vec::new(), + } +} + +/// The measurements on `page` whose layers are both there, in layer order (bounded). +pub fn on_page(doc: &Document, page: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![page]; + let mut visited = 0usize; + while let Some(id) = stack.pop() { + visited += 1; + if out.len() >= MAX_LISTED || visited > 200_000 { + break; + } + let Some(n) = doc.node(id) else { continue }; + for (index, m) in n.measurements.iter().enumerate() { + if m.to.is_some_and(|t| t == id || doc.page_of(t) != Some(page)) { + continue; + } + out.push(Placed { from: id, index, measurement: *m, lines: lines(doc, id, *m) }); + } + stack.extend(n.children.iter().rev().copied()); + } + out +} + +fn placed_json(doc: &Document, p: &Placed) -> Value { + let name = |id: NodeId| doc.node(id).map(|n| n.name.clone()).unwrap_or_default(); + let mut j = json!({"from": p.from, "fromName": name(p.from), "index": p.index, "labels": p.labels(), "lines": p.lines}); + if let Some(t) = p.measurement.to { + j["to"] = json!(t); + j["toName"] = json!(name(t)); + } + if let Some(s) = p.measurement.side { + j["side"] = json!(s); + } + j +} + +/// The layer a command starts from: `from`, else the first selected layer; never a page. +fn from_layer(cmd: &str, s: &Session, p: &Value) -> Result { + let id = match opt_id(cmd, p, "from")? { + Some(id) => id, + None => s.selection().first().copied().ok_or_else(|| bad(cmd, "select a layer or pass `from`"))?, + }; + let n = s.doc()?.node(id).ok_or(EngineError::NoNode(id))?; + if n.is_page() { + return Err(bad(cmd, "measure from a layer, not a page")); + } + Ok(id) +} + +/// The `side` parameter: top, right, bottom or left. +fn side_param(cmd: &str, p: &Value) -> Result> { + match get(p, "side") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(v)) => Side::from_id(v).filter(|s| *s != Side::Auto).map(Some).ok_or_else(|| bad(cmd, "side: top, right, bottom or left")), + Some(_) => Err(bad(cmd, "side: top, right, bottom or left")), + } +} + +fn add(s: &mut Session, p: &Value) -> Result { + let cmd = "devMode.addMeasurement"; + let from = from_layer(cmd, s, p)?; + let (to, side) = (opt_id(cmd, p, "to")?, side_param(cmd, p)?); + let doc = s.doc()?; + let n = doc.node(from).ok_or(EngineError::NoNode(from))?; + let to = match (to, side) { + (Some(_), Some(_)) => return Err(bad(cmd, "pass `to` or `side`, not both")), + (Some(t), None) => Some(t), + (None, Some(_)) => None, + // Neither: to the layer around it. + (None, None) => Some( + n.parent + .filter(|q| doc.node(*q).is_some_and(|q| !q.is_page())) + .ok_or_else(|| bad(cmd, "pass `to` or `side` (the layer is at the top of its page)"))?, + ), + }; + if let Some(t) = to { + let target = doc.node(t).ok_or(EngineError::NoNode(t))?; + if t == from { + return Err(bad(cmd, "measure to another layer, or across a `side` of this one")); + } + if target.is_page() || doc.page_of(t) != doc.page_of(from) { + return Err(bad(cmd, "measure to a layer on the same page")); + } + } + let m = Measurement { to, side }; + // The same two layers measured from the other end are the same measurement. + let back = to.and_then(|t| doc.node(t)).is_some_and(|t| t.measurements.contains(&Measurement { to: Some(from), side: None })); + if n.measurements.contains(&m) || back { + return Err(bad(cmd, "that measurement is already there")); + } + if n.measurements.len() >= MAX_PER_LAYER { + return Err(bad(cmd, format!("a layer starts at most {MAX_PER_LAYER} measurements"))); + } + let drawn = lines(doc, from, m); + let n = s.doc_mut()?.get_mut(from)?; + n.measurements.push(m); + Ok(json!({"from": from, "index": n.measurements.len() - 1, "lines": drawn})) +} + +fn remove(s: &mut Session, p: &Value) -> Result { + let cmd = "devMode.removeMeasurement"; + let from = from_layer(cmd, s, p)?; + let index = opt_index(cmd, p, "index")?; + let n = s.doc_mut()?.get_mut(from)?; + let count = n.measurements.len(); + if count == 0 { + return Err(bad(cmd, "no measurement starts from this layer")); + } + let removed = match index { + Some(i) if i < count => { + n.measurements.remove(i); + 1 + } + Some(i) => return Err(bad(cmd, format!("no measurement {i} (the layer starts {count})"))), + None => std::mem::take(&mut n.measurements).len(), + }; + Ok(json!({"removed": removed})) +} + +fn list(s: &mut Session, p: &Value) -> Result { + let cmd = "devMode.measurements"; + let doc = s.doc()?; + let page = match opt_id(cmd, p, "page")? { + Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg, + Some(_) => return Err(bad(cmd, "`page` must be a page id")), + None => s.page()?, + }; + let only = opt_id(cmd, p, "id")?; + Ok(Value::Array(on_page(doc, page).iter().filter(|m| only.is_none_or(|id| m.touches(id))).map(|m| placed_json(doc, m)).collect())) +} + +pub fn specs() -> Vec { + vec![ + spec!( + "devMode.addMeasurement", + "Add measurement", + "{from? (default the first selected layer), to? (a layer on the same page), side? (top|right|bottom|left)} keeps a measurement on the canvas: between `from` and `to`, across one side of `from`, or (neither) to the layer around it; Dev Mode draws it and it follows the layers → {from, index, lines}", + true, + add + ), + spec!( + "devMode.removeMeasurement", + "Remove measurement", + "{from? (default the first selected layer), index? (omit: every measurement starting from the layer)} → {removed}", + true, + remove + ), + spec!( + "devMode.measurements", + "List measurements", + "{page? (default the current page), id? (only those from or to this layer)} → [{from, fromName, index, to?, toName?, side?, labels, lines: [{x0, y0, x1, y1, label, dashed}]}] (page space)", + false, + list + ), + ] +} diff --git a/crates/engine/src/dev_measurements_tests.rs b/crates/engine/src/dev_measurements_tests.rs new file mode 100644 index 00000000..695b8a7e --- /dev/null +++ b/crates/engine/src/dev_measurements_tests.rs @@ -0,0 +1,204 @@ +//! Measurements kept on the canvas: placed between two layers, across a side or to the layer +//! around one, following the layers, removed, undoable, copied with what they measure, refused on +//! bad input, synced, saved (old files without them still open) and allowed in Dev Mode. + +use figcraft_collab::{Change, Prop, Replica}; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +fn ok(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn rect(s: &mut Session, x: f64, w: f64, parent: Option) -> NodeId { + let mut p = json!({"x": x, "y": 20, "width": w, "height": 40}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id_of(&ok(s, "shape.rectangle", p)["id"]).unwrap() +} + +/// A frame with two rectangles 30 apart: (frame, a, b). +fn setup() -> (Session, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let f = id_of(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 100}))["id"]).unwrap(); + let a = rect(&mut s, 20.0, 50.0, Some(f)); + let b = rect(&mut s, 100.0, 60.0, Some(f)); + (s, f, a, b) +} + +fn labels(s: &mut Session, id: Option) -> Vec> { + let p = id.map_or(json!({}), |i| json!({"id": i})); + ok(s, "devMode.measurements", p) + .as_array() + .unwrap() + .iter() + .map(|m| m["labels"].as_array().unwrap().iter().map(|l| l.as_str().unwrap().to_string()).collect()) + .collect() +} + +#[test] +fn measurements_are_placed_follow_their_layers_and_are_removed() { + let (mut s, f, a, b) = setup(); + let m = ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + assert_eq!((&m["from"], &m["index"]), (&json!(a), &json!(0))); + assert_eq!(m["lines"][0]["label"], "30", "the gap between them"); + // Across a side: the side's length; with neither, to the frame around it. + ok(&mut s, "devMode.addMeasurement", json!({"from": b, "side": "top"})); + ok(&mut s, "select.set", json!({"ids": [a]})); + ok(&mut s, "devMode.addMeasurement", json!({})); + assert_eq!(labels(&mut s, None), [vec!["30"], vec!["20", "230", "20", "40"], vec!["60"]]); + let listed = ok(&mut s, "devMode.measurements", json!({"id": b})); + assert_eq!(listed.as_array().unwrap().len(), 2, "from or to the layer"); + assert_eq!((&listed[1]["side"], &listed[1]["to"]), (&json!("top"), &Value::Null)); + assert_eq!(listed[0]["toName"], s.doc().unwrap().node(b).unwrap().name.as_str()); + // They follow the layers. + ok(&mut s, "node.set", json!({"id": b, "props": {"x": 110, "width": 80}})); + assert_eq!(labels(&mut s, Some(b)), [vec!["40"], vec!["80"]]); + // Undo takes back an edit, and a placement. + ok(&mut s, "edit.undo", json!({})); + assert_eq!(labels(&mut s, Some(b)), [vec!["30"], vec!["60"]]); + ok(&mut s, "edit.undo", json!({})); + assert_eq!(labels(&mut s, None).len(), 2); + ok(&mut s, "edit.redo", json!({})); + // A deleted layer takes its measurements off the canvas; undo brings them back. + ok(&mut s, "edit.delete", json!({"ids": [b]})); + assert_eq!(labels(&mut s, None), [vec!["20", "230", "20", "40"]]); + ok(&mut s, "edit.undo", json!({})); + assert_eq!(labels(&mut s, None).len(), 3); + // Removed one by one, or all of a layer's. + assert_eq!(ok(&mut s, "devMode.removeMeasurement", json!({"from": a, "index": 1}))["removed"], 1); + assert_eq!(labels(&mut s, None), [vec!["30"], vec!["60"]]); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "side": "left"})); + assert_eq!(ok(&mut s, "devMode.removeMeasurement", json!({"from": a}))["removed"], 2); + assert!(s.execute("devMode.removeMeasurement", &json!({"from": a})).is_err(), "nothing left"); + assert_eq!(labels(&mut s, None), [vec!["60"]]); + let _ = f; +} + +#[test] +fn bad_measurements_are_refused() { + let (mut s, f, a, b) = setup(); + let page = s.page().unwrap(); + let other = id_of(&ok(&mut s, "page.add", json!({}))["id"]).unwrap(); + let far = rect(&mut s, 0.0, 10.0, Some(other)); + ok(&mut s, "page.select", json!({"id": page})); + let top = rect(&mut s, 400.0, 10.0, None); + ok(&mut s, "select.none", json!({})); + for p in [ + json!({"from": a, "to": a}), + json!({"from": a, "to": page}), + json!({"from": a, "to": far}), + json!({"from": a, "to": 99_999}), + json!({"from": page, "side": "top"}), + json!({"from": a, "to": b, "side": "top"}), + json!({"from": a, "side": "middle"}), + json!({"from": a, "side": "auto"}), + json!({"from": a, "side": 3}), + json!({"from": "x", "side": "top"}), + json!({"from": top}), + json!({"side": "top"}), + ] { + assert!(s.execute("devMode.addMeasurement", &p).is_err(), "{p}"); + } + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + assert!(s.execute("devMode.addMeasurement", &json!({"from": a, "to": b})).is_err(), "already there"); + assert!(s.execute("devMode.addMeasurement", &json!({"from": b, "to": a})).is_err(), "the same two layers"); + assert!(s.execute("devMode.removeMeasurement", &json!({"from": a, "index": 5})).is_err()); + assert!(s.execute("devMode.measurements", &json!({"page": a})).is_err()); + assert_eq!(ok(&mut s, "devMode.measurements", json!({"page": other})), json!([])); + // A layer starts a bounded number. + for _ in 1..crate::dev_measurements::MAX_PER_LAYER { + let t = rect(&mut s, 200.0, 5.0, Some(f)); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": t})); + } + assert!(s.execute("devMode.addMeasurement", &json!({"from": a, "side": "top"})).is_err(), "capped"); + assert_eq!(s.doc().unwrap().node(a).unwrap().measurements.len(), crate::dev_measurements::MAX_PER_LAYER); +} + +#[test] +fn copies_measure_what_was_copied_with_them() { + let (mut s, f, a, b) = setup(); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "side": "bottom"})); + // The frame copied: the copy of `a` measures to the copy of `b`. + ok(&mut s, "edit.duplicate", json!({"ids": [f]})); + let copy = s.selection()[0]; + let doc = s.doc().unwrap(); + let kids = doc.node(copy).unwrap().children.clone(); + let ca = doc.node(kids[0]).unwrap(); + assert_eq!(ca.measurements.len(), 2); + assert_eq!(ca.measurements[0].to, Some(kids[1])); + // `a` alone copied: only its side measurement comes along. + ok(&mut s, "edit.duplicate", json!({"ids": [a]})); + let lone = s.selection()[0]; + let m = &s.doc().unwrap().node(lone).unwrap().measurements; + assert_eq!((m.len(), m[0].to, m[0].side), (1, None, Some(figcraft_doc::Side::Bottom))); +} + +#[test] +fn measurements_sync_save_and_old_files_open() { + let (mut s, _, a, b) = setup(); + let before = s.doc().unwrap().clone(); + ok(&mut s, "devMode.addMeasurement", json!({"from": a, "to": b})); + ok(&mut s, "devMode.addMeasurement", json!({"from": b, "side": "right"})); + // A change collaborators receive. + let d = figcraft_collab::diff(&Replica::new(before), s.doc().unwrap()).unwrap(); + assert!(d.changes.iter().any(|c| matches!(c, Change::Set { id, prop: Prop::Measurements, .. } if *id == a)), "{:?}", d.changes); + // What agents inspect. + assert_eq!(crate::inspect::node_json(s.doc().unwrap(), b, 0)["measurements"], json!([{"side": "right"}])); + // Saved and opened again. + let doc = s.doc().unwrap(); + let bytes = figcraft_format::save_bytes(doc, None).unwrap(); + let (back, _) = figcraft_format::load_bytes(&bytes).unwrap(); + assert_eq!(back.node(a).unwrap().measurements, doc.node(a).unwrap().measurements); + assert_eq!(back.node(b).unwrap().measurements, doc.node(b).unwrap().measurements); + // A file from before measurements (no such field) opens with none. + fn strip(v: &mut Value) { + match v { + Value::Object(o) => { + o.remove("measurements"); + o.values_mut().for_each(strip); + } + Value::Array(a) => a.iter_mut().for_each(strip), + _ => {} + } + } + let mut old = figcraft_format::to_json(doc).unwrap(); + assert!(old.to_string().contains("\"measurements\"")); + strip(&mut old); + let (back, _) = figcraft_format::from_json(&old.to_string()).unwrap(); + assert!(back.node(a).unwrap().measurements.is_empty() && back.node(b).unwrap().name == doc.node(b).unwrap().name); +} + +#[test] +fn dev_mode_places_measurements() { + use crate::devinspect::dev_mode_allows; + for id in ["devMode.addMeasurement", "devMode.removeMeasurement", "devMode.measurements"] { + assert!(dev_mode_allows(id), "{id}"); + } +} + +#[test] +fn instances_keep_their_own_measurements_across_main_edits() { + let mut s = Session::new(); + let f = id_of(&ok(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 40}))["id"]).unwrap(); + let r = rect(&mut s, 5.0, 10.0, Some(f)); + ok(&mut s, "devMode.addMeasurement", json!({"from": r, "side": "top"})); + let main = id_of(&ok(&mut s, "component.create", json!({"ids": [f]}))["id"]).unwrap(); + let inst = id_of(&ok(&mut s, "instance.create", json!({"component": main, "x": 200, "y": 0}))["id"]).unwrap(); + let inner = s.doc().unwrap().node(inst).unwrap().children[0]; + // The main's measurements aren't copied onto its instances. + assert!(s.doc().unwrap().node(inner).unwrap().measurements.is_empty()); + ok(&mut s, "devMode.addMeasurement", json!({"from": inner})); + ok(&mut s, "devMode.addMeasurement", json!({"from": inst, "side": "left"})); + ok(&mut s, "node.set", json!({"id": r, "props": {"opacity": 0.5}})); + ok(&mut s, "node.set", json!({"id": main, "props": {"width": 120}})); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(inner).unwrap().measurements[0].to, Some(inst), "kept across a main edit"); + assert_eq!(doc.node(inst).unwrap().measurements.len(), 1); + assert_eq!(doc.node(r).unwrap().measurements.len(), 1); +} diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index 16f4bbdc..7a15610f 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -219,11 +219,11 @@ pub struct DevItem { } /// Whether Dev Mode runs command `id` from the menus and shortcuts: commands that don't edit the -/// document, and Dev Mode's own edits (annotations, statuses, dev resources, code connections, -/// comments). Choosing a tool other than moving around and commenting is refused too, and so is -/// Revert to saved, which isn't an undo step but throws the design's unsaved edits away. +/// document, and Dev Mode's own edits (annotations, statuses, dev resources, measurements, code +/// connections, comments). Choosing a tool other than moving around and commenting is refused too, +/// and so is Revert to saved, which isn't an undo step but throws the design's unsaved edits away. pub fn dev_mode_allows(id: &str) -> bool { - const OWN: [&str; 5] = ["annotation.", "dev.", "devResource.", "codeConnect.", "comment."]; + const OWN: [&str; 6] = ["annotation.", "dev.", "devMode.", "devResource.", "codeConnect.", "comment."]; match id.strip_prefix("tool.") { Some(t) => matches!(t, "move" | "hand" | "comment"), None if id == "file.revert" => false, diff --git a/crates/engine/src/distance.rs b/crates/engine/src/distance.rs index c86aeaf0..62679e38 100644 --- a/crates/engine/src/distance.rs +++ b/crates/engine/src/distance.rs @@ -34,7 +34,7 @@ pub fn label(d: f64) -> String { } /// A labelled distance line, unless it has no length. -fn measured(out: &mut Vec, a: Point, b: Point) { +pub(crate) fn measured(out: &mut Vec, a: Point, b: Point) { let d = (b - a).hypot(); if d > EPS && d.is_finite() { out.push(Guide { label: Some(label(d)), ..Guide::new(a, b) }); diff --git a/crates/engine/src/fill_none_tests.rs b/crates/engine/src/fill_none_tests.rs new file mode 100644 index 00000000..74fe6231 --- /dev/null +++ b/crates/engine/src/fill_none_tests.rs @@ -0,0 +1,88 @@ +//! `props.fill` and `props.stroke` accept `"none"` (either case) and `null` as no paint, the +//! same as an empty `fills`/`strokes` list (#1036): an outline-only shape in one call. + +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; + +fn id(v: Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) +} + +fn create_rect(s: &mut Session, fill: Value) -> NodeId { + id(ex( + s, + "node.create", + json!({ + "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, + "props": {"fill": fill, "stroke": "#000000", "strokeWeight": 1} + }), + )) +} + +#[test] +fn fill_none_or_null_makes_an_outline_only_shape_in_one_call() { + for none in [json!("none"), json!("None"), json!("NONE"), json!(null)] { + let mut s = Session::new(); + let r = create_rect(&mut s, none.clone()); + let n = s.doc().unwrap().node(r).unwrap().clone(); + assert!(n.fills.is_empty(), "{none:?}: fills not empty"); + assert_eq!(n.strokes.len(), 1, "{none:?}: stroke kept"); + } +} + +#[test] +fn fill_none_clears_an_existing_fill_as_one_undo_step() { + let mut s = Session::new(); + let r = create_rect(&mut s, json!("#FF0000")); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1); + ex(&mut s, "node.set", json!({"id": r, "props": {"fill": "none"}})); + assert!(s.doc().unwrap().node(r).unwrap().fills.is_empty()); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1); +} + +#[test] +fn stroke_none_or_null_clears_the_strokes() { + for none in [json!("none"), json!(null)] { + let mut s = Session::new(); + let r = create_rect(&mut s, json!("#FF0000")); + ex(&mut s, "node.set", json!({"id": r, "props": {"stroke": none}})); + assert!(s.doc().unwrap().node(r).unwrap().strokes.is_empty(), "{none:?}"); + assert_eq!(s.doc().unwrap().node(r).unwrap().fills.len(), 1, "{none:?}"); + } +} + +#[test] +fn text_with_fill_none_gets_no_automatic_colour() { + let mut s = Session::new(); + let t = id(ex( + &mut s, + "node.create", + json!({ + "type": "text", "x": 0, "y": 0, "text": "Hi", "props": {"fill": "none"} + }), + )); + assert!(s.doc().unwrap().node(t).unwrap().fills.is_empty()); +} + +#[test] +fn an_unknown_string_still_names_the_key() { + let mut s = Session::new(); + let e = s + .execute( + "node.create", + &json!({ + "type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10, + "props": {"fill": "nope"} + }), + ) + .unwrap_err(); + let msg = e.to_string(); + assert!(msg.contains("`fill`") && msg.contains("none"), "{msg}"); +} diff --git a/crates/engine/src/fx_behaviour_tests.rs b/crates/engine/src/fx_behaviour_tests.rs index eb8da9b2..d9d93592 100644 --- a/crates/engine/src/fx_behaviour_tests.rs +++ b/crates/engine/src/fx_behaviour_tests.rs @@ -53,6 +53,29 @@ fn the_eyedropper_picks_the_colour_seen_and_fills_the_selection() { assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into())); } +#[test] +fn the_eyedropper_of_a_colour_picker_samples_without_filling() { + // Opened from a colour picker (`apply: false`), the eyedropper hands the colour to the picker, + // which writes it to its own paint (a stroke, an effect...): the fill stays as it was. + let mut s = Session::new(); + s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap(); + rect(&mut s, None, (0.0, 0.0, 100.0, 100.0), "#FF0000"); + let b = rect(&mut s, None, (200.0, 0.0, 50.0, 50.0), "#00FF00"); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let undo = s.undo_depth().0; + s.execute("edit.pickColor", &json!({"apply": false})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap(); + assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("00FF00".into())); + assert_eq!(s.undo_depth().0, undo, "nothing to undo"); + assert_eq!(s.tool.picked.take().map(|c| c.to_hex()), Some("FF0000".into())); + assert_eq!(s.tool.tool, ToolId::Move, "picking ends the eyedropper"); + // The I key's eyedropper fills the selection again. + s.execute("edit.pickColor", &json!({})).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Down, 50.0, 50.0, 1.0)).unwrap(); + assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().map(|c| c.to_hex()), Some("FF0000".into())); + assert_eq!(s.tool.picked, None); +} + #[test] fn blend_modes_and_effects_through_commands() { let mut s = Session::new(); diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index d30f5f51..33162314 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -144,6 +144,9 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { if !n.code_links.is_empty() { o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); } + if !n.measurements.is_empty() { + o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); + } let dev = crate::dev_resources::list_json(doc, n.id); if dev.as_array().is_some_and(|a| !a.is_empty()) { o.insert("devResources".into(), dev); diff --git a/crates/engine/src/instance_modes_tests.rs b/crates/engine/src/instance_modes_tests.rs index 79007622..43de3ca4 100644 --- a/crates/engine/src/instance_modes_tests.rs +++ b/crates/engine/src/instance_modes_tests.rs @@ -126,3 +126,71 @@ fn detaching_keeps_the_mode_it_took_from_its_main() { assert_eq!(fill(&s, i), "0000FF", "the detached frame keeps mode B"); assert_eq!(s.doc().unwrap().node(i).unwrap().var_modes.len(), 1); } + +/// A "Button" holding an instance of a swatch "Icon" on mode A, and a "Modal" holding a Button +/// instance whose icon copy was set to mode B: (the Button main, its icon layer, the copy in Modal). +fn modal(s: &mut Session) -> (NodeId, NodeId, NodeId) { + tone(s); + let icon = swatch(s, "Icon", None); + let frame = id(run(s, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); + let button_icon = id(run(s, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": frame}))); + set_mode(s, button_icon, json!("A")); + let button = id(run(s, "component.create", json!({"ids": [frame]}))); + let outer = id(run(s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); + let close = id(run(s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": outer}))); + let nested = s.doc().unwrap().node(close).unwrap().children[0]; + set_mode(s, nested, json!("B")); + run(s, "component.create", json!({"ids": [outer]})); + (button, button_icon, nested) +} + +#[test] +fn a_mode_set_on_a_layer_inside_an_instance_is_an_override() { + let mut s = Session::new(); + let (button, button_icon, nested) = modal(&mut s); + assert_eq!(fill(&s, nested), "0000FF"); + // An edit the copies can't take as they are rebuilds the Button instance. + run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"})); + assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps the mode set on the copy"); + // The main's layer choosing another mode doesn't reach it either. + set_mode(&mut s, button_icon, json!("B")); + set_mode(&mut s, button_icon, Value::Null); + assert_eq!(fill(&s, nested), "0000FF"); + run(&mut s, "component.resetOverride", json!({"id": nested, "group": "modes"})); + assert_eq!(fill(&s, nested), "FF0000", "reset: the main's layer is on Auto (mode A)"); + assert!(s.doc().unwrap().node(nested).unwrap().var_modes.is_empty()); +} + +#[test] +fn a_mode_set_on_an_icon_inside_a_placed_instance_survives_main_edits() { + let mut s = Session::new(); + let (button, _, _) = modal(&mut s); + // A Button placed on the page, its icon recoloured through the icon's mode. + let placed = instance(&mut s, button); + let icon = s.doc().unwrap().node(placed).unwrap().children[0]; + set_mode(&mut s, icon, json!("B")); + assert!(s.doc().unwrap().node(icon).unwrap().overrides.contains("modes"), "it shows as an override"); + run(&mut s, "node.set", json!({"ids": [button], "layout": "vertical"})); + assert_eq!(fill(&s, icon), "0000FF", "a rebuild of the Button keeps it"); + run(&mut s, "component.resetInstance", json!({"ids": [placed]})); + assert_eq!(fill(&s, icon), "FF0000", "reset all brings back the main's mode"); +} + +#[test] +fn a_file_from_before_modes_were_overrides_keeps_the_modes_chosen_inside_its_instances() { + let mut s = Session::new(); + let (button, _, nested) = modal(&mut s); + // Saved before `modes` was an override group: the copy has its mode but no marker. + s.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes"); + let path = std::env::temp_dir().join(format!("figcraft-instance-modes-{}.figcraft", std::process::id())); + let p = path.to_string_lossy().into_owned(); + run(&mut s, "file.saveAs", json!({"path": p})); + let mut s = Session::new(); + run(&mut s, "file.open", json!({"path": p})); + let _ = std::fs::remove_file(&path); + let doc = s.doc().unwrap(); + assert!(doc.node(nested).unwrap().overrides.contains("modes"), "opening it marks the mode the copy chose"); + assert!(doc.nodes().filter(|n| n.id != nested).all(|n| !n.overrides.contains("modes")), "layers on their main's modes stay unmarked"); + run(&mut s, "node.set", json!({"ids": [button], "layout": "horizontal"})); + assert_eq!(fill(&s, nested), "0000FF", "a rebuild keeps it"); +} diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index 1ffad5e2..6f0641db 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -229,8 +229,10 @@ pub static KEYS: &[Binding] = &[ k(View, "view.prevPage", &["PageUp"]), k(View, "view.nextPage", &["PageDown"]), // Zoom - k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+="]), - k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-"]), + // Each zoom-in key has its zoom-out twin on the key beside it: `-` alone zooms out, so `=` + // alone (the `+` key without Shift) zooms in (#973). + k(Zoom, "view.zoomIn", &["Cmd+=", "Shift+=", "Shift+Cmd+=", "="]), + k(Zoom, "view.zoomOut", &["Cmd+-", "-", "Shift+-", "Shift+Cmd+-"]), k(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"]), k(Zoom, "view.zoomFit", &["Shift+1"]), k(Zoom, "view.zoomSelection", &["Shift+2"]), diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs index 38775d3b..f624ecfb 100644 --- a/crates/engine/src/keymap_tests.rs +++ b/crates/engine/src/keymap_tests.rs @@ -168,6 +168,25 @@ fn display_per_platform() { assert_eq!(primary("polygon.none", mac), None); } +/// Each way of zooming in has its zoom-out twin on the key beside it, as the keys arrive from a US +/// keyboard (#973: `-` alone zoomed out but `=` alone did nothing, so zooming back in was stuck). +#[test] +fn zoom_keys_come_in_pairs() { + let none = Mods::default(); + let shift = mods(true, false, false, false); + let cmd = mods(false, false, true, false); + let shift_cmd = mods(true, false, true, false); + for p in PLATFORMS { + for (zoom_in, zoom_out, m) in [("Equals", "Minus", none), ("Equals", "Minus", cmd), ("Plus", "Minus", shift_cmd), ("Plus", "Minus", shift)] { + assert_eq!(id_for(p, Context::Canvas, zoom_in, m), Some("view.zoomIn"), "{zoom_in} {m:?} on {p:?}"); + assert_eq!(id_for(p, Context::Canvas, zoom_out, m), Some("view.zoomOut"), "{zoom_out} {m:?} on {p:?}"); + } + // The menus still show Cmd/Ctrl and `+`. + assert_eq!(primary("view.zoomIn", p), Some("Cmd+=")); + assert_eq!(primary("view.zoomOut", p), Some("Cmd+-")); + } +} + #[test] fn tool_keys_come_from_the_keymap() { assert_eq!(ToolId::Move.shortcut(), "V"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 21b56bad..0e1e7f9f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,7 @@ pub mod codegen; pub mod collab; pub mod component_codegen; pub mod components; +pub mod dev_measurements; pub mod dev_resources; pub mod devcompare; pub mod devinspect; @@ -414,6 +415,7 @@ impl DocState { } doc.link_layer_text_styles(); cmd::styles::apply_paint_bindings_on_load(&mut doc); + components::mark_layer_modes(&mut doc); // Initial import settlement belongs to the saved baseline, not the user's first edit. let page = doc.current_page.filter(|p| doc.pages.contains(p)).or_else(|| doc.first_page()).unwrap_or(NodeId(0)); let doc = Arc::new(doc); @@ -1658,6 +1660,8 @@ mod dark_page_text_tests; #[cfg(test)] mod design_paste_tests; #[cfg(test)] +mod dev_measurements_tests; +#[cfg(test)] mod dev_resources_tests; #[cfg(test)] mod devcompare_tests; @@ -1692,6 +1696,8 @@ mod fig_export_tests; #[cfg(test)] mod fig_import_tests; #[cfg(test)] +mod fill_none_tests; +#[cfg(test)] mod finite_geometry_tests; #[cfg(test)] mod frame_guides_tests; @@ -1904,6 +1910,8 @@ mod variants_discovery_tests; #[cfg(test)] mod vector_behaviour_tests; #[cfg(test)] +mod vector_fill_rule_tests; +#[cfg(test)] mod vector_multi_behaviour_tests; #[cfg(test)] mod vector_offset_tests; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 72f2a4ac..cc6c3856 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -491,6 +491,11 @@ impl Cache { None => Err(EngineError::Other(crate::library_server::NOT_FETCHED.into())), }; } + // The path comes from a document: a network share would be contacted (and on Windows get + // the opener's credentials) by the stat below. + if !craft_docpath::is_local(path) { + return Err(EngineError::Other(format!("{path}: a library is opened only from this computer"))); + } let stamp = stat(path); // Checked before the cache: the limit is the caller's. if let Ok(st) = &stamp @@ -553,11 +558,18 @@ fn stat(_path: &str) -> std::result::Result { Err(NO_FILES.into()) } +/// A library read from a file or a server, with the modes chosen inside its instances marked when +/// it was published before they were overrides ([`crate::components::mark_layer_modes`]). +pub(crate) fn settled(mut lib: PublishedLibrary) -> PublishedLibrary { + crate::components::mark_layer_modes(Arc::make_mut(&mut lib.doc)); + lib +} + #[cfg(not(target_arch = "wasm32"))] fn read_file(path: &str) -> std::result::Result { let f = crate::file_access::open(path).map_err(|e| e.to_string())?; match figcraft_format::read_library(std::io::BufReader::new(f)) { - Ok(Some(lib)) => Ok(lib), + Ok(Some(lib)) => Ok(settled(lib)), Ok(None) => Err(format!("{path} has no published library: open it and publish it first (Libraries ▸ Publish library…)")), Err(e) => Err(format!("{path}: {e}")), } diff --git a/crates/engine/src/library_path_tests.rs b/crates/engine/src/library_path_tests.rs index f15105dc..de40128a 100644 --- a/crates/engine/src/library_path_tests.rs +++ b/crates/engine/src/library_path_tests.rs @@ -28,3 +28,12 @@ fn a_local_library_path_is_still_followed() { let e = s.fetch_library(&missing.to_string_lossy(), false).expect_err("the file does not exist"); assert!(!e.to_string().contains("only from this computer"), "a local path must not be refused as remote: {e}"); } + +#[test] +fn the_library_cache_refuses_a_path_off_this_computer() { + let mut cache = crate::library::Cache::default(); + for path in [r"\\attacker.example\share\lib.figcraft", "//attacker.example/share/lib.figcraft"] { + let e = cache.load(path).expect_err(&format!("{path} must be refused")); + assert!(e.to_string().contains("only from this computer"), "{path}: {e}"); + } +} diff --git a/crates/engine/src/library_server.rs b/crates/engine/src/library_server.rs index 99d5f29a..10852b5f 100644 --- a/crates/engine/src/library_server.rs +++ b/crates/engine/src/library_server.rs @@ -53,7 +53,7 @@ pub fn archive(lib: &PublishedLibrary) -> Result> { /// The library in an archive from a server (untrusted: read with the library file's limits). pub fn read_archive(bytes: &[u8]) -> std::result::Result { match figcraft_format::read_library(Cursor::new(bytes)) { - Ok(Some(lib)) => Ok(lib), + Ok(Some(lib)) => Ok(crate::library::settled(lib)), Ok(None) => Err("the server sent a file without a library".into()), Err(e) => Err(format!("the server sent a library that can't be read ({e})")), } diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index 4fc778a7..b4a4171a 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -344,3 +344,67 @@ fn library_style_bindings_ignore_unrelated_local_variables() { healthy(&s); let _ = std::fs::remove_dir_all(&d); } + +/// A library "Modal" holding a Button instance whose icon copy is on mode Dark: (the Button main, +/// the icon copy in Modal). +fn nested_mode_kit(lib: &mut Session) -> (NodeId, NodeId) { + theme(lib); + let f = idv(&ex(lib, "frame.create", json!({"x": 0, "y": 0, "width": 20, "height": 20, "name": "Icon"}))); + ex(lib, "variable.bind", json!({"ids": [f], "field": "fill", "variable": "primary"})); + let icon = idv(&ex(lib, "component.create", json!({"ids": [f]}))); + let b = idv(&ex(lib, "frame.create", json!({"x": 100, "y": 0, "width": 60, "height": 40, "name": "Button"}))); + ex(lib, "instance.create", json!({"component": icon, "x": 10, "y": 10, "parent": b})); + let button = idv(&ex(lib, "component.create", json!({"ids": [b]}))); + let m = idv(&ex(lib, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 100, "name": "Modal"}))); + let close = idv(&ex(lib, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": m}))); + let nested = lib.doc().unwrap().node(close).unwrap().children[0]; + ex(lib, "variable.setMode", json!({"ids": [nested], "collection": "Theme", "mode": "Dark"})); + ex(lib, "component.create", json!({"ids": [m]})); + (button, nested) +} + +/// The icon inside the Button inside library Modal instance `inst`. +fn modal_icon(s: &Session, inst: NodeId) -> NodeId { + let doc = s.doc().unwrap(); + doc.node(doc.node(inst).unwrap().children[0]).unwrap().children[0] +} + +#[test] +fn modes_set_inside_nested_instances_come_along() { + let d = dir("nested-modes"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + let (button, _) = nested_mode_kit(&mut lib); + let lid = publish_to(&mut lib, &lib_path); + + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0}))); + let copy = |s: &Session| modal_icon(s, inst); + assert_eq!(fill(&s, copy(&s)), "FFFFFF", "the Dark mode set on the nested icon comes along"); + // An update that rebuilds the Button copies keeps it too. + ex(&mut lib, "node.set", json!({"ids": [button], "layout": "horizontal"})); + republish(&mut lib); + ex(&mut s, "library.acceptUpdates", json!({})); + assert_eq!(fill(&s, copy(&s)), "FFFFFF"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_nested_instances() { + let d = dir("nested-modes-old"); + let lib_path = path_str(&d.join("kit.figcraft")); + let mut lib = Session::new(); + let (_, nested) = nested_mode_kit(&mut lib); + // Published before `modes` was an override group: the copy has its mode but no marker. + lib.doc_mut().unwrap().node_mut(nested).unwrap().overrides.remove("modes"); + let lid = publish_to(&mut lib, &lib_path); + + let mut s = Session::new(); + ex(&mut s, "library.add", json!({"path": lib_path})); + let inst = idv(&ex(&mut s, "library.place", json!({"library": lid, "component": "Modal", "x": 0, "y": 0}))); + assert_eq!(fill(&s, modal_icon(&s, inst)), "FFFFFF", "the Dark mode comes along"); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/nested_override_tests.rs b/crates/engine/src/nested_override_tests.rs index f684a693..0d61edf7 100644 --- a/crates/engine/src/nested_override_tests.rs +++ b/crates/engine/src/nested_override_tests.rs @@ -198,3 +198,179 @@ fn copied_instances_keep_their_overrides() { assert!(node(&s, t).source.is_none() && node(&s, t).overrides.is_empty()); healthy(&s); } + +/// `button()` with the main's copy of the icon's Shape filled red, and an instance-swap property +/// `Icon` on the nested icon: (icon, shape, button, nested). +fn button_with_icon_override(s: &mut Session) -> (NodeId, NodeId, NodeId, NodeId) { + let (icon_main, shape, button, nested) = button(s); + let main_shape = copy_of(s, nested, shape); + ex(s, "node.set", json!({"id": main_shape, "props": {"fill": "#ff0000"}})); + ex(s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": icon_main, "layer": nested})); + (icon_main, shape, button, nested) +} + +/// Swap instance `i`'s icon to `to` by its instance-swap property `Icon`. +fn swap_icon(s: &mut Session, i: NodeId, to: NodeId) { + ex(s, "instance.setProperties", json!({"ids": [i], "properties": {"Icon": to}})); + healthy(s); +} + +#[test] +fn a_main_override_carries_onto_a_swapped_nested_instance() { + // Documented: overrides survive a swap when layer names match, and a swapped icon takes the + // colour the main component set (forum thread with staff replies). Rows: by the instance-swap + // property and by instance.swap, onto a matching and an unmatched name. The main's size + // override is geometry and stays out; the instance's own override wins over the main's. + let mut s = Session::new(); + let (_icon, shape, button, nested) = button_with_icon_override(&mut s); + let main_shape = copy_of(&s, nested, shape); + ex(&mut s, "node.set", json!({"id": main_shape, "props": {"width": 10}})); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let unmatched = icon(&mut s, 300.0, "Unmatched", "Glyph"); + for (by_property, to, want) in [(true, other, "FF0000"), (false, other, "FF0000"), (true, unmatched, "111111"), (false, unmatched, "111111")] { + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + if by_property { + swap_icon(&mut s, i, to); + } else { + let inested = copy_of(&s, i, nested); + ex(&mut s, "instance.swap", json!({"ids": [inested], "component": to})); + healthy(&s); + } + let glyph = kids(&s, copy_of(&s, i, nested))[0]; + assert_eq!(fill(&s, glyph), want, "by property {by_property}, to {to}"); + assert_eq!(node(&s, glyph).width, 20.0, "the main's size override stays out"); + } + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 600}))); + let glyph = kids(&s, copy_of(&s, i, nested))[0]; + ex(&mut s, "node.set", json!({"id": glyph, "props": {"fill": "#0000ff"}})); + swap_icon(&mut s, i, other); + assert_eq!(fill(&s, kids(&s, copy_of(&s, i, nested))[0]), "0000FF", "the instance's own override wins"); +} + +#[test] +fn a_main_override_carries_onto_a_swap_one_level_deeper() { + // As above for a Button instance inside a Toolbar main, swapped in a Toolbar instance. + let mut s = Session::new(); + let (_icon, _shape, button, nested) = button_with_icon_override(&mut s); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let bar = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 600, "width": 300, "height": 60, "name": "Toolbar"}))); + let inner = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 10, "y": 10, "parent": bar}))); + let toolbar = idv(&ex(&mut s, "component.create", json!({"ids": [bar]}))); + let t = idv(&ex(&mut s, "instance.create", json!({"component": toolbar, "x": 0, "y": 800}))); + let icon_in_t = copy_of(&s, t, copy_of(&s, inner, nested)); + ex(&mut s, "instance.swap", json!({"ids": [icon_in_t], "component": other})); + healthy(&s); + assert_eq!(fill(&s, kids(&s, icon_in_t)[0]), "FF0000"); +} + +#[test] +fn a_swapped_nested_instance_keeps_the_main_override_it_took() { + // Documented (staff reply): later edits of the main's icon colour don't reach instances that + // swapped, and do reach the rest. Undo and redo follow; resetting the override shows the icon + // main's own fill. + let mut s = Session::new(); + let (_icon, shape, button, nested) = button_with_icon_override(&mut s); + let other = icon(&mut s, 200.0, "Other", "Shape"); + let swapped = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + let kept = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 500}))); + swap_icon(&mut s, swapped, other); + let glyph = |s: &Session, i: NodeId| fill(s, kids(s, copy_of(s, i, nested))[0]); + let main_shape = copy_of(&s, nested, shape); + ex(&mut s, "node.set", json!({"id": main_shape, "props": {"fill": "#00ff00"}})); + healthy(&s); + assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into())); + s.undo().unwrap(); + assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "FF0000".into())); + s.redo().unwrap(); + assert_eq!((glyph(&s, swapped), glyph(&s, kept)), ("FF0000".into(), "00FF00".into())); + let swapped_glyph = kids(&s, copy_of(&s, swapped, nested))[0]; + ex(&mut s, "component.resetOverride", json!({"id": swapped_glyph, "group": "fills"})); + healthy(&s); + assert_eq!(glyph(&s, swapped), "111111"); +} + +#[test] +fn a_swapped_in_icon_keeps_its_own_overrides_to_itself() { + // Docs silent; pinned: what an icon main overrides inside itself stays that main's. After a + // swap to it, its edits still reach the instance; a second swap doesn't carry them on. + let mut s = Session::new(); + let (_icon, _shape, button, nested) = button_with_icon_override(&mut s); + let dot = icon(&mut s, 200.0, "Dot", "Shape"); + let with_dot = |s: &mut Session, x: f64, name: &str, fill: Option<&str>| { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 300, "width": 24, "height": 24, "name": name}))); + let d = idv(&ex(s, "instance.create", json!({"component": dot, "x": 0, "y": 0, "parent": f}))); + if let Some(c) = fill { + let shape = kids(s, d)[0]; + ex(s, "node.set", json!({"id": shape, "props": {"fill": c}})); + } + (idv(&ex(s, "component.create", json!({"ids": [f]}))), d) + }; + let (other2, dot2) = with_dot(&mut s, 300.0, "Other2", Some("#0000ff")); + let (other3, _) = with_dot(&mut s, 400.0, "Other3", None); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 300, "y": 400}))); + swap_icon(&mut s, i, other2); + let shape_in = |s: &Session| kids(s, kids(s, copy_of(s, i, nested))[0])[0]; + assert_eq!(fill(&s, shape_in(&s)), "0000FF"); + let shape2 = kids(&s, dot2)[0]; + ex(&mut s, "node.set", json!({"id": shape2, "props": {"fill": "#00ffff"}})); + healthy(&s); + assert_eq!(fill(&s, shape_in(&s)), "00FFFF", "Other2's own edit reaches it"); + swap_icon(&mut s, i, other3); + assert_eq!(fill(&s, shape_in(&s)), "111111", "Other2's override doesn't carry onto Other3"); +} + +#[test] +fn a_swapped_out_icons_own_overrides_stay_with_it() { + // Docs silent; pinned: what the Button's default icon main overrides inside itself isn't the + // Button main's to carry. Icon A colours its Base's Shape green; swapping to icon B, also built + // on Base, shows Base's own fill. + let mut s = Session::new(); + let base = icon(&mut s, 0.0, "Base", "Shape"); + let on_base = |s: &mut Session, x: f64, name: &str| { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 500, "width": 24, "height": 24, "name": name}))); + let b = idv(&ex(s, "instance.create", json!({"component": base, "x": 0, "y": 0, "parent": f}))); + (idv(&ex(s, "component.create", json!({"ids": [f]}))), b) + }; + let (a, a_base) = on_base(&mut s, 100.0, "A"); + let (b, _) = on_base(&mut s, 200.0, "B"); + let a_shape = kids(&s, a_base)[0]; + ex(&mut s, "node.set", json!({"id": a_shape, "props": {"fill": "#00ff00"}})); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 700, "width": 40, "height": 40, "name": "Button"}))); + let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 4, "y": 4, "parent": f}))); + let button = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + ex(&mut s, "component.addProperty", json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": a, "layer": nested})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": button, "x": 0, "y": 800}))); + swap_icon(&mut s, i, b); + let shape = kids(&s, kids(&s, copy_of(&s, i, nested))[0])[0]; + assert_eq!(fill(&s, shape), "111111"); + assert!(node(&s, shape).overrides.is_empty()); +} + +#[test] +fn a_slot_the_main_filled_shows_the_swapped_in_mains_content() { + // Docs silent on slot content across a swap; pinned: a Chip the Panel main put in its card's + // slot doesn't carry onto the swapped-in card, which shows its own default content. + let mut s = Session::new(); + let card = |s: &mut Session, x: f64, name: &str| { + let f = idv(&ex(s, "frame.create", json!({"x": x, "y": 0, "width": 100, "height": 60, "name": name}))); + let content = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 60, "parent": f, "name": "Content"}))); + ex(s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 20, "parent": content, "props": {"name": format!("{name} default")}})); + let c = idv(&ex(s, "component.create", json!({"ids": [f]}))); + ex(s, "component.makeSlot", json!({"id": content})); + c + }; + let (a, b) = (card(&mut s, 0.0, "A"), card(&mut s, 200.0, "B")); + let chip = icon(&mut s, 400.0, "Chip", "Dot"); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 400, "width": 200, "height": 100, "name": "Panel"}))); + let nested = idv(&ex(&mut s, "instance.create", json!({"component": a, "x": 0, "y": 0, "parent": f}))); + let panel = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + ex(&mut s, "instance.slotInsert", json!({"id": nested, "slot": "Content", "component": chip, "replace": true})); + ex(&mut s, "component.addProperty", json!({"id": panel, "name": "Card", "type": "instanceSwap", "default": a, "layer": nested})); + let i = idv(&ex(&mut s, "instance.create", json!({"component": panel, "x": 0, "y": 700}))); + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Card": b}})); + healthy(&s); + let content = kids(&s, copy_of(&s, i, nested))[0]; + let names: Vec = kids(&s, content).into_iter().map(|k| node(&s, k).name.clone()).collect(); + assert_eq!(names, ["B default"]); + assert!(!node(&s, content).overrides.contains(SLOT)); +} diff --git a/crates/engine/src/pen_overlay_tests.rs b/crates/engine/src/pen_overlay_tests.rs index 7e0064d2..7f6dba3c 100644 --- a/crates/engine/src/pen_overlay_tests.rs +++ b/crates/engine/src/pen_overlay_tests.rs @@ -63,3 +63,24 @@ fn finishing_the_path_clears_its_handles_and_band() { let ov = &s.tool.overlay; assert!(ov.pen.is_empty() && ov.pen_handles.is_empty() && ov.rubber.is_none() && ov.rubber_handle.is_none()); } + +#[test] +fn shift_keeps_the_band_and_the_next_point_at_45_degree_steps() { + // #970: with Shift the band shows where the point will land, at a 45° step from the last + // anchor (the same length as the pointer is away), not under the pointer. + let mut s = Session::new(); + s.set_tool(ToolId::Pen); + at(&mut s, PointerKind::Down, 100.0, 100.0); + at(&mut s, PointerKind::Up, 100.0, 100.0); + let shifted = |kind, x, y| PointerEvent { shift: true, ..PointerEvent::at(kind, x, y, 1.0) }; + s.pointer(&shifted(PointerKind::Hover, 300.0, 120.0)).unwrap(); + let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap(); + assert_eq!([x0, y0, y1], [100.0, 100.0, 100.0], "level with the last anchor"); + assert!((x1 - (100.0 + 200f64.hypot(20.0))).abs() < 1e-9, "{x1}"); + s.pointer(&shifted(PointerKind::Down, 300.0, 120.0)).unwrap(); + s.pointer(&shifted(PointerKind::Up, 300.0, 120.0)).unwrap(); + s.pointer(&shifted(PointerKind::Hover, 400.0, 230.0)).unwrap(); + let [x0, y0, x1, y1] = s.tool.overlay.rubber.unwrap(); + assert_eq!(y0, 100.0, "the point landed on the band"); + assert!((x1 - x0 - (y1 - y0)).abs() < 1e-9, "45° down to the right: {:?}", [x0, y0, x1, y1]); +} diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index be997079..b7c12227 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -107,6 +107,8 @@ const DENIED: &[&str] = &[ "plugins.", "libraries.", "library.add", + // Uploads the open document to a server. + "library.publishToServer", "edit.undo", "edit.redo", "edit.repeat", @@ -132,6 +134,9 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { if command == "image.place" && get(params, "path").is_some() { return Some("plugins can't read files: pass the image as `data`".into()); } + if command == "text.loadFont" && get(params, "path").is_some() { + return Some("plugins can't read files: pass the font as `data`".into()); + } // `path` here is a file on disk (elsewhere it is a vector path or an action's place). if matches!(command, "variable.export" | "codeConnect.export" | "component.playground") && get(params, "path").is_some() { return Some("plugins can't write files: leave out `path` to get the result back".into()); @@ -155,7 +160,8 @@ fn refusal(m: &Manifest, command: &str, params: &Value) -> Option { /// Whether `params` set layer data (`pluginData`, anywhere in them) under a widget's own keys. fn writes_widget_data(params: &Value, depth: usize) -> bool { let own = |data: &Value| data.as_object().is_some_and(|m| m.keys().any(|k| k.starts_with(crate::widgets::DATA_PREFIX))); - depth < 64 + // serde_json refuses nesting past 128, so nothing deeper can reach here. + depth < 128 && match params { Value::Object(m) => m.iter().any(|(k, v)| (k == "pluginData" && own(v)) || writes_widget_data(v, depth + 1)), Value::Array(a) => a.iter().any(|v| writes_widget_data(v, depth + 1)), @@ -592,3 +598,28 @@ pub fn specs() -> Vec { spec!("plugins.manage", "Manage plugins…", "{} opens the plugins window; returns the list like `plugin.list`", false, manage), ] } + +#[cfg(test)] +mod refusal_tests { + use super::*; + + #[test] + fn read_only_plugin_cannot_publish_to_server() { + let m = Manifest::parse(r#"{"id":"x.y","name":"X","main":"plugin.wasm"}"#).unwrap(); + let r = refusal(&m, "library.publishToServer", &json!({})); + assert!(r.is_some(), "library.publishToServer uploads the document and must be refused"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn widget_data_is_found_at_any_depth_serde_accepts() { + let depth = 100; + let text = format!("{}{{\"pluginData\":{{\"{}x\":1}}}}{}", "{\"a\":".repeat(depth), crate::widgets::DATA_PREFIX, "}".repeat(depth)); + let v: Value = serde_json::from_str(&text).unwrap(); + assert!(writes_widget_data(&v, 0)); + } +} diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 657b6825..7f6c3740 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -164,6 +164,7 @@ fn files_dialogs_history_and_plugins_are_off_limits() { ("file.saveAs", json!({"path": target})), ("file.open", json!({"path": "C:/Windows/win.ini"})), ("image.place", json!({"path": "secret.png"})), + ("text.loadFont", json!({"path": "/etc/hosts"})), ("library.add", json!({"path": "lib.figcraft"})), ("plugin.run", json!({"id": "org.test.deny"})), ("plugin.remove", json!({"id": "org.test.deny"})), diff --git a/crates/engine/src/save_format_tests.rs b/crates/engine/src/save_format_tests.rs index d635808e..fc4ca775 100644 --- a/crates/engine/src/save_format_tests.rs +++ b/crates/engine/src/save_format_tests.rs @@ -15,8 +15,12 @@ struct SaveFiles { impl SaveFiles { fn new() -> Self { + // Tests run in parallel and the clock can read the same for two of them (macOS counts + // microseconds), so a counter keeps their folders apart. + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); - let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}", std::process::id())); + let dir = std::env::temp_dir().join(format!("figcraft-save-formats-{}-{stamp}-{n}", std::process::id())); std::fs::create_dir(&dir).unwrap(); Self { dir, files: Vec::new() } } diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index bc961a4a..ef5869b2 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -324,6 +324,49 @@ fn shift_drag_moves_the_selection() { assert_eq!(s.selection(), vec![k.empty], "Shift let go before the button still takes the layer out"); } +#[test] +fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() { + let mut s = Session::new(); + let k = scene(&mut s); + for i in [k.r, k.c] { + ex(&mut s, "node.set", json!({"ids": [i], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}})); + } + let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin(); + // (selection before, drag from, selection after, the layers that move by (10, 10), derivation) + let rows: Vec<(Vec, (f64, f64), Vec, Vec, &str)> = vec![ + (vec![k.r], (520.0, 20.0), vec![k.r], vec![k.r], "a selected unfilled shape drags from its inside, not only its stroke"), + (vec![k.c], (40.0, 140.0), vec![k.c], vec![k.c], "the same in a top-level frame, whose background is under the inside"), + (vec![k.r, k.empty], (570.0, 20.0), vec![k.r, k.empty], vec![k.r, k.empty], "a gap inside a multiple selection's box drags them all"), + (vec![], (520.0, 20.0), vec![k.r], vec![], "an unselected unfilled shape is hit on its stroke only: the drag is a marquee, which touches it"), + ]; + for (before, from, after, moved, why) in rows { + let home: Vec = [k.r, k.c, k.empty].iter().map(|i| at(&s, *i)).collect(); + ex(&mut s, "select.set", json!({"ids": before})); + drag(&mut s, from, (from.0 + 10.0, from.1 + 10.0), NONE); + assert_eq!(sorted(s.selection()), sorted(after), "{why}"); + for (i, h) in [k.r, k.c, k.empty].iter().zip(&home) { + let d = if moved.contains(i) { 10.0 } else { 0.0 }; + assert_eq!(at(&s, *i), Point::new(h.x + d, h.y + d), "{why}"); + } + if !moved.is_empty() { + assert!(s.undo().unwrap(), "{why}: one undo step"); + } + } + // A turned layer's box is turned with it: the corners of its page-aligned bounds are outside. + ex(&mut s, "node.set", json!({"ids": [k.r], "props": {"rotation": 45}})); + let b = s.doc().unwrap().world_bounds(k.r).unwrap(); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + drag(&mut s, (b.x0 + 2.0, b.y0 + 2.0), (b.x0 + 12.0, b.y0 + 12.0), NONE); + assert_eq!(at(&s, k.r), b.origin(), "a press outside a turned layer's box is a marquee: it doesn't move the layer"); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + let c = b.center(); + drag(&mut s, (c.x, c.y), (c.x + 10.0, c.y + 10.0), NONE); + assert_eq!(s.selection(), vec![k.r], "inside the turned box it drags"); + // Pixel snapping rounds the turned box's origin, so the move is about the pointer's. + let d = at(&s, k.r) - b.origin(); + assert!((d.x - 10.0).abs() < 2.0 && (d.y - 10.0).abs() < 2.0, "it moves with the pointer: {d:?}"); +} + #[test] fn alt_shift_drag_on_a_selected_layer_duplicates_it() { let mut s = Session::new(); diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs index c0d7e65e..cb2faf13 100644 --- a/crates/engine/src/shortcut_parity_tests.rs +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -171,8 +171,8 @@ static ROWS: &[Row] = &[ doc(Zoom, "Zoom out", &["Cmd+-", "-"], &[], Bound("view.zoomOut"), ""), doc(Zoom, "Zoom to fit", &["Shift+1"], &[], Bound("view.zoomFit"), ""), doc(Zoom, "Zoom to the selection", &["Shift+2"], &[], Bound("view.zoomSelection"), ""), - pin(Zoom, "view.zoomIn", &["Shift+Cmd+="], &[]), - pin(Zoom, "view.zoomOut", &["Shift+-"], &[]), + pin(Zoom, "view.zoomIn", &["Shift+Cmd+=", "="], &[]), + pin(Zoom, "view.zoomOut", &["Shift+-", "Shift+Cmd+-"], &[]), pin(Zoom, "view.zoom100", &["Shift+0", "Cmd+0"], &[]), pin(Zoom, "view.prevFrame", &["Shift+N", "Home"], &[]), pin(Zoom, "view.nextFrame", &["N", "End"], &[]), diff --git a/crates/engine/src/styles_behaviour_tests.rs b/crates/engine/src/styles_behaviour_tests.rs index ff7af50b..de9da5a5 100644 --- a/crates/engine/src/styles_behaviour_tests.rs +++ b/crates/engine/src/styles_behaviour_tests.rs @@ -80,6 +80,20 @@ fn link(s: &Session, n: NodeId, kind: &str) -> Option { } } +/// The key linking a style of `kind` (a paint style links fills). +fn style_key(kind: &str) -> String { + if kind == "paint" { "fillStyle".into() } else { format!("{kind}Style") } +} + +/// `node.create` params for an unselected layer that takes `kind` styles, with `props`. +fn new_layer(kind: &str, props: Value) -> Value { + if kind == "text" { + json!({"type": "text", "text": "Hej", "select": false, "props": props}) + } else { + json!({"type": "frame", "select": false, "props": props}) + } +} + #[test] fn a_style_holds_the_whole_property_and_links_its_source() { // Documented: colour styles can hold several fills (gradients, images); effect styles combine @@ -234,6 +248,59 @@ fn editing_a_styled_property_detaches_that_style_only() { let _ = st; } +#[test] +fn a_style_link_wins_over_values_in_the_same_call() { + // Docs silent on one call setting both (a linked layer shows its style's values; editing one + // detaches it); pinned: the link wins whatever the keys are called, on node.create and + // node.set, and a deviation set in a later call unlinks as before. + let mut s = Session::new(); + let rows = styled(&mut s); + let pairs: [(&str, &str, Value); 12] = [ + ("paint", "fill", json!("#123456")), + ("paint", "fills", json!(["#123456"])), + ("stroke", "stroke", json!("#123456")), + ("stroke", "strokes", json!(["#123456"])), + ("effect", "effects", json!([])), + ("grid", "grids", json!([])), + ("grid", "layoutGrids", json!([])), + ("text", "fontStyle", json!("Bold")), + ("text", "lineHeight", json!(40)), + ("text", "textDecoration", json!("underline")), + ("text", "letterSpacing", json!(5)), + ("text", "variationSettings", json!({"wght": 700})), + ]; + for (kind, key, value) in pairs { + let (source, st, _) = rows.iter().find(|r| r.2 == kind).unwrap().clone(); + let link_key = style_key(kind); + let props = json!({key: value.clone(), link_key.as_str(): st.clone()}); + let made = id(run(&mut s, "node.create", new_layer(kind, props.clone()))); + let set = if kind == "text" { text(&mut s, 400.0) } else { rect(&mut s, 400.0) }; + run(&mut s, "node.set", json!({"id": set, "props": props})); + for (how, layer) in [("node.create", made), ("node.set", set)] { + assert_eq!(link(&s, layer, kind).as_deref(), Some(st.as_str()), "{how} `{key}` + `{link_key}`: linked"); + assert_eq!(shown(&s, layer, kind), shown(&s, source, kind), "{how} `{key}` + `{link_key}`: the style's values"); + } + run(&mut s, "node.set", json!({"id": set, "props": {key: value}})); + assert_eq!(link(&s, set, kind), None, "`{key}` in a later call unlinks"); + assert_ne!(shown(&s, set, kind), shown(&s, source, kind), "`{key}` in a later call applies"); + } +} + +#[test] +fn a_style_link_leaves_values_its_style_does_not_set() { + // Docs silent; pinned: alignment given beside a text style applies, on node.create and node.set. + let mut s = Session::new(); + let st = styled(&mut s).into_iter().find(|r| r.2 == "text").unwrap().1; + let props = json!({"textStyle": st, "textAlign": "center"}); + let made = id(run(&mut s, "node.create", new_layer("text", props.clone()))); + let set = text(&mut s, 400.0); + run(&mut s, "node.set", json!({"id": set, "props": props})); + for (how, layer) in [("node.create", made), ("node.set", set)] { + assert_eq!(link(&s, layer, "text").as_deref(), Some(st.as_str()), "{how}: linked"); + assert_eq!(node(&s, layer).kind.text().unwrap().align_h, figcraft_doc::TextAlignH::Center, "{how}: `textAlign` applies"); + } +} + #[test] fn applying_a_style_replaces_variables_bound_to_that_property() { // Documented: applying a style sets the property to the style's values. Docs silent on bound diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 646c7c4b..14a6a3bc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -395,6 +395,10 @@ fn every_command_survives_junk_params() { // Links (dev resources) and indexes as junk. json!({"id": 2, "url": "javascript:alert(1)", "index": 1e308, "name": {"x": 1}}), json!({"id": 2, "url": "https://\u{0}x", "index": -1, "name": ""}), + // Dev Mode measurements: layers, sides, pages and indexes as junk. + json!({"from": "x", "to": -1, "side": [1], "index": 1e308, "page": {"x": 1}}), + json!({"from": 2, "to": 2, "side": "middle", "index": -1, "page": 2, "id": "nope"}), + json!({"from": 2, "to": 1, "side": "auto"}), // Widgets: plugins, targets and fuel as junk. json!({"id": 1, "plugin": {"x": 1}, "target": [1], "fuel": -1e308, "x": 1e308}), // Plugin directories: ids, versions, servers and confirmations as junk. diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 935c9048..ac5d28d3 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -466,6 +466,13 @@ pub(crate) enum Gesture { }, } +/// Where Shift puts the Pen's next point: as far from `last` as `p` is, at the nearest 45° step. +fn at_45_degrees(last: Point, p: Point) -> Point { + let v = p - last; + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + last + Vec2::from_angle(ang) * v.hypot() +} + /// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in /// and out handles and the one before's out handle, those that aren't zero. fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> { @@ -519,6 +526,11 @@ pub struct ToolState { pub pencil_brush: Option, /// The board tools' colours and choices. pub board: crate::board::BoardTool, + /// The eyedropper samples for a colour picker (`edit.pickColor` with `apply: false`): the + /// colour goes to `picked`, for the picker to write to the paint it edits, not to the fill. + pub pick_only: bool, + /// The colour the eyedropper sampled for a colour picker, until the picker takes it. + pub picked: Option, } /// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most @@ -984,6 +996,7 @@ impl Session { } self.tool.tool = t; self.tool.gesture = Gesture::None; + self.tool.pick_only = false; // The tool's cursor shows before the pointer moves. self.tool.overlay = Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() }; @@ -1166,6 +1179,8 @@ impl Session { guides = g; self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + // With Shift the band ends where a click puts the point (#970). + let to = if ev.shift { at_45_degrees(*last, to) } else { to }; self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); } @@ -1279,11 +1294,21 @@ impl Session { n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom() }) }; - let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| { + let label = label_at(&doc, page, &self.tool.labels, ev.p()); + let target = label.or_else(|| { self.hit(&doc, page, ev.p(), ev.tol() * 2.0) .filter(|leaf| ev.cmd || !board_background(*leaf)) .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) }); + // A press inside the selection's box that reaches nothing above it (an unfilled shape's + // inside, or the frame or canvas behind it) drags the selection. + let behind_sel = |t: Option| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s))); + let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { + Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { + Some(first) + } + _ => target, + }; match target { Some(target) => { // Clicking inside the current selection's box keeps it (drag them all). @@ -2159,7 +2184,9 @@ impl Session { 255, ); let sel = ds.selection.clone(); - if !sel.is_empty() { + if self.tool.pick_only { + self.tool.picked = Some(c); + } else if !sel.is_empty() { self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; } self.recent_colors.retain(|x| *x != c); @@ -2221,9 +2248,7 @@ impl Session { if ev.shift && let Some((last, _, _)) = pen.points.last() { - let v = p - *last; - let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; - p = *last + Vec2::from_angle(ang) * v.hypot(); + p = at_45_degrees(*last, p); } pen.points.push((p, Vec2::ZERO, Vec2::ZERO)); self.tool.gesture = Gesture::PenDrag { anchor: p }; diff --git a/crates/engine/src/vector_fill_rule_tests.rs b/crates/engine/src/vector_fill_rule_tests.rs new file mode 100644 index 00000000..8a8fe850 --- /dev/null +++ b/crates/engine/src/vector_fill_rule_tests.rs @@ -0,0 +1,306 @@ +//! Fill rules on vectors: `vector.fillRule` sets the rule a filled area fills by and +//! `vector.reverseLoop` turns one of its loops round — which is how a hole is made and unmade. +//! The numbers come from the two rules' definitions: non-zero fills where the winding count is not +//! zero (so a hole is a loop running against its outer loop), even-odd where it is odd (so the +//! direction of a loop doesn't matter). + +use figcraft_doc::{NodeId, NodeKind, Region, Segment, VectorNetwork, Vertex, WindingRule}; +use figcraft_geom::{BezPath, Point}; +use serde_json::{Value, json}; + +use crate::Session; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn net(s: &Session, id: NodeId) -> VectorNetwork { + match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Vector { network } => network.clone(), + other => panic!("not a vector: {other:?}"), + } +} + +/// A 100 × 100 square with a 25…75 hole, both loops in one area: the outer one clockwise, the inner +/// one the same way round (so non-zero fills the hole too until it is turned round). +fn ring() -> VectorNetwork { + let mut path = BezPath::new(); + for (x0, y0, x1, y1) in [(0.0, 0.0, 100.0, 100.0), (25.0, 25.0, 75.0, 75.0)] { + path.move_to((x0, y0)); + path.line_to((x1, y0)); + path.line_to((x1, y1)); + path.line_to((x0, y1)); + path.close_path(); + } + let mut net = VectorNetwork::from_path(&path); + net.regions[0].winding = WindingRule::NonZero; + net +} + +/// A selected vector layer holding `network`. +fn vector_layer(s: &mut Session, network: VectorNetwork) -> NodeId { + let id = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()["id"].as_u64().unwrap(); + let id = NodeId(id); + ex(s, "vector.edit.begin", json!({"id": id})); + ex(s, "vector.edit.end", json!({})); + { + let doc = s.doc_mut().unwrap(); + match &mut doc.get_mut(id).unwrap().kind { + NodeKind::Vector { network: net } => *net = network, + other => panic!("not a vector: {other:?}"), + } + } + ex(s, "select.set", json!({"ids": [id]})); + id +} + +/// A selected vector layer holding `ring()`. +fn ringed(s: &mut Session) -> NodeId { + vector_layer(s, ring()) +} + +/// Two 50 × 100 squares side by side share the vertical edge between them: one segment listed by +/// both areas' single loops. A segment shared between areas is the case `vector.reverseLoop` must +/// not flip, or a neighbour's fill or stroke would turn round with it. +fn two_squares() -> VectorNetwork { + // 0:A(0,0) 1:B(50,0) 2:C(100,0) 3:D(100,100) 4:E(50,100) 5:F(0,100) + // s0 A→B (left bottom), s1 B→E (the shared vertical edge), s2 E→F (left top), + // s3 F→A (left left), s4 B→C (right bottom), s5 C→D (right right), s6 D→E (right top); + // the right area's left edge is s1 walked the other way, so the squares share one segment. + // region 0: left square A→B→E→F→A = [s0, s1, s2, s3]. + // region 1: right square B→C→D→E→B = [s4, s5, s6, s1] (s1 walked backwards). + VectorNetwork { + vertices: vec![ + Vertex::new(0.0, 0.0), + Vertex::new(50.0, 0.0), + Vertex::new(100.0, 0.0), + Vertex::new(100.0, 100.0), + Vertex::new(50.0, 100.0), + Vertex::new(0.0, 100.0), + ], + segments: vec![ + Segment::line(0, 1), + Segment::line(1, 4), + Segment::line(4, 5), + Segment::line(5, 0), + Segment::line(1, 2), + Segment::line(2, 3), + Segment::line(3, 4), + ], + regions: vec![ + Region { loops: vec![vec![0, 1, 2, 3]], winding: WindingRule::NonZero }, + Region { loops: vec![vec![4, 5, 6, 1]], winding: WindingRule::NonZero }, + ], + } +} + +/// A selected vector layer holding [`two_squares`]. +fn squared(s: &mut Session) -> NodeId { + vector_layer(s, two_squares()) +} + +/// Two areas of two segments each over the same line: one curve above it and one below. Both areas +/// list the line's segment — the first area first (`[line, above]`), the second area last +/// (`[below, line]`) — and a two-segment ring reads the same whichever way its list is turned, so +/// these only turn round when a segment is stored the other way. +fn two_lenses() -> VectorNetwork { + // 0:A(0,0) 1:B(100,0) + // s0 A→B the straight line both areas use; s1 B→A curving above it; s2 A→B curving below it. + VectorNetwork { + vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)], + segments: vec![ + Segment::line(0, 1), + Segment { a: 1, b: 0, ta: (0.0, -50.0), tb: (0.0, -50.0) }, + Segment { a: 0, b: 1, ta: (0.0, 50.0), tb: (0.0, 50.0) }, + ], + regions: vec![ + Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }, + Region { loops: vec![vec![2, 0]], winding: WindingRule::NonZero }, + ], + } +} + +/// A selected vector layer holding [`two_lenses`]. +fn lensed(s: &mut Session) -> NodeId { + vector_layer(s, two_lenses()) +} + +/// Two areas whose only loop is the same closed segment (a curve the path closes with a straight +/// line): a one-segment loop's direction is its segment's stored ends, and both areas read them. +fn one_shared_segment() -> VectorNetwork { + VectorNetwork { + vertices: vec![Vertex::new(0.0, 0.0), Vertex::new(100.0, 0.0)], + segments: vec![Segment { a: 0, b: 1, ta: (0.0, -60.0), tb: (0.0, -60.0) }], + regions: vec![Region { loops: vec![vec![0]], winding: WindingRule::NonZero }, Region { loops: vec![vec![0]], winding: WindingRule::NonZero }], + } +} + +/// The point in the middle of the hole, and one on the square's left band. +const HOLE: Point = Point::new(50.0, 50.0); +const BAND: Point = Point::new(10.0, 50.0); + +#[test] +fn a_loop_running_with_its_outer_loop_fills_the_hole_until_it_is_turned_round() { + let mut s = Session::new(); + let id = ringed(&mut s); + let n = net(&s, id); + assert_eq!(n.regions.len(), 1); + assert_eq!(n.regions[0].loops.len(), 2, "an outer loop and the hole's"); + assert!(n.region_contains(0, BAND), "the band fills"); + assert!(n.region_contains(0, HOLE), "the hole fills while its loop runs the same way"); + // Turning the inner loop round makes it a hole under non-zero. + let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 1})); + assert_eq!(r["reversed"], json!(true)); + let n = net(&s, id); + assert!(n.region_contains(0, BAND), "the band still fills"); + assert!(!n.region_contains(0, HOLE), "the hole is empty"); + assert_eq!(n.region_winding(0, HOLE), Some(0), "its loops cancel out there"); + // Undo turns it back. + s.undo().unwrap(); + assert!(net(&s, id).region_contains(0, HOLE), "one undo step restores the loop"); +} + +#[test] +fn the_rule_switches_every_area_or_only_the_one_asked_for() { + let mut s = Session::new(); + let id = ringed(&mut s); + // Even-odd leaves the hole empty whatever way its loop runs. + let r = ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"})); + assert_eq!((r["regions"].clone(), r["rule"].clone()), (json!(1), json!("evenOdd"))); + let n = net(&s, id); + assert_eq!(n.regions[0].winding, WindingRule::EvenOdd); + assert!(!n.region_contains(0, HOLE), "even-odd counts the hole's loop as a second crossing"); + // And back. + ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "nonZero"})); + assert!(net(&s, id).region_contains(0, HOLE), "non-zero fills it again"); + // One area of several, by index. + let n = net(&s, id); + let mut two = n.clone(); + two.regions.push(n.regions[0].clone()); + { + let doc = s.doc_mut().unwrap(); + match &mut doc.get_mut(id).unwrap().kind { + NodeKind::Vector { network } => *network = two, + other => panic!("not a vector: {other:?}"), + } + } + ex(&mut s, "vector.fillRule", json!({"id": id, "region": 1, "rule": "evenOdd"})); + let n = net(&s, id); + assert_eq!(n.regions[0].winding, WindingRule::NonZero, "the other area keeps its rule"); + assert_eq!(n.regions[1].winding, WindingRule::EvenOdd); + // Every area at once. + ex(&mut s, "vector.fillRule", json!({"id": id, "rule": "evenOdd"})); + assert!(net(&s, id).regions.iter().all(|r| r.winding == WindingRule::EvenOdd)); +} + +#[test] +fn junk_parameters_are_refused_and_change_nothing() { + let mut s = Session::new(); + let id = ringed(&mut s); + let before = net(&s, id); + for p in [ + json!({"id": id, "rule": "nope"}), + json!({"id": id}), + json!({"id": id, "rule": 7}), + json!({"id": id, "rule": "evenOdd", "region": -1}), + json!({"id": id, "rule": "evenOdd", "region": 1.5}), + json!({"id": id, "rule": "evenOdd", "region": "x"}), + json!({"id": id, "rule": "evenOdd", "region": 99}), + ] { + assert!(s.execute("vector.fillRule", &p).is_err(), "{p} is refused"); + } + for p in [ + json!({"id": id}), + json!({"id": id, "region": 0}), + json!({"id": id, "region": 0, "loop": 99}), + json!({"id": id, "region": 99, "loop": 0}), + json!({"id": id, "region": -2, "loop": 0}), + ] { + assert!(s.execute("vector.reverseLoop", &p).is_err(), "{p} is refused"); + } + assert_eq!(net(&s, id), before, "nothing changed"); + // A rectangle that was never a vector is refused too. + let rect = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + assert!(s.execute("vector.fillRule", &json!({"id": rect, "rule": "evenOdd"})).is_err()); + assert!(s.execute("vector.reverseLoop", &json!({"id": rect, "region": 0, "loop": 0})).is_err()); +} + +#[test] +fn reversing_a_loop_shared_with_a_neighbour_leaves_its_segment_and_fill_alone() { + let mut s = Session::new(); + let id = squared(&mut s); + let n = net(&s, id); + assert_eq!(n.regions.len(), 2, "two areas, side by side"); + // Both areas fill under non-zero: a point in each. + assert!(n.region_contains(0, Point::new(25.0, 50.0)), "the left area fills"); + assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the right area fills"); + // The vertical edge between them is one segment listed by both areas' loops. + const SHARED: usize = 1; + assert!(n.regions[0].loops[0].contains(&SHARED) && n.regions[1].loops[0].contains(&SHARED), "one segment is shared"); + let before = n.segments[SHARED]; + let left_winding = n.region_winding(0, Point::new(25.0, 50.0)).unwrap(); + assert_ne!(left_winding, 0, "the left area winds once"); + // Reverse the left area's only loop. + let r = ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0})); + assert_eq!(r["reversed"], json!(true)); + let n = net(&s, id); + // The shared segment's stored ends are untouched: reversing the loop must not flip a neighbour's + // segment (reversing the list already turns this loop's traversal of it the other way). + assert_eq!(n.segments[SHARED], before, "the shared segment is left as it was"); + // The neighbour's fill is unchanged: it still fills its interior. + assert!(n.region_contains(1, Point::new(75.0, 50.0)), "the neighbour still fills"); + // The reversed loop really turned round: its winding at a left-area point flipped sign. + assert_eq!(n.region_winding(0, Point::new(25.0, 50.0)), Some(-left_winding), "the left loop reversed"); + // Undo restores the shared segment and the loop. + s.undo().unwrap(); + assert_eq!(net(&s, id).segments[SHARED], before, "undo restores the shared segment too"); +} + +#[test] +fn a_two_segment_loop_turns_round_with_its_shared_segment_listed_first_or_last() { + let mut s = Session::new(); + let id = lensed(&mut s); + const SHARED: usize = 0; + const ABOVE: Point = Point::new(50.0, -25.0); + const BELOW: Point = Point::new(50.0, 25.0); + let n = net(&s, id); + assert_eq!(n.regions.len(), 2, "two areas over the same line"); + assert_eq!((n.regions[0].loops[0][0], n.regions[0].loops[0].len()), (SHARED, 2), "the shared segment is listed first"); + assert_eq!((n.regions[1].loops[0][1], n.regions[1].loops[0].len()), (SHARED, 2), "and last"); + assert!(n.region_contains(0, ABOVE), "the area above the line fills"); + assert!(n.region_contains(1, BELOW), "the area below it fills"); + let (above, below) = (n.region_winding(0, ABOVE).unwrap(), n.region_winding(1, BELOW).unwrap()); + assert_ne!(above, 0, "the area above winds once"); + assert_ne!(below, 0, "the area below winds once"); + let shared = n.segments[SHARED]; + // Turn the area that lists the shared segment first round: a two-segment ring reads the same + // either way, so this only works by storing one of its segments the other way. + assert_eq!(ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 0, "loop": 0}))["reversed"], json!(true)); + let n = net(&s, id); + assert_eq!(n.segments[SHARED], shared, "the shared segment keeps its ends"); + assert!(n.region_contains(0, ABOVE), "the area still fills"); + assert_eq!(n.region_winding(0, ABOVE), Some(-above), "it really turned round"); + assert_eq!(n.region_winding(1, BELOW), Some(below), "the neighbour did not"); + // And the area that lists it last. + ex(&mut s, "vector.reverseLoop", json!({"id": id, "region": 1, "loop": 0})); + let n = net(&s, id); + assert_eq!(n.segments[SHARED], shared, "still untouched"); + assert_eq!(n.region_winding(1, BELOW), Some(-below), "the second area really turned round"); + assert_eq!(n.region_winding(0, ABOVE), Some(-above), "the first stays as it was"); + // Undo turns each back. + s.undo().unwrap(); + assert_eq!(net(&s, id).region_winding(1, BELOW), Some(below), "undo turns the second back"); + s.undo().unwrap(); + assert_eq!(net(&s, id).region_winding(0, ABOVE), Some(above), "and the first"); +} + +#[test] +fn a_one_segment_loop_shared_with_another_is_refused_instead_of_claiming_a_turn() { + let mut s = Session::new(); + let id = vector_layer(&mut s, one_shared_segment()); + let before = net(&s, id); + let err = s.execute("vector.reverseLoop", &json!({"id": id, "region": 0, "loop": 0})).unwrap_err(); + let message = err.to_string(); + assert!(message.contains("shares its segments"), "the error says why: {message}"); + assert_eq!(net(&s, id), before, "nothing changed"); +} diff --git a/crates/engine/src/vector_multi_behaviour_tests.rs b/crates/engine/src/vector_multi_behaviour_tests.rs index 1d2075b1..b1e6a948 100644 --- a/crates/engine/src/vector_multi_behaviour_tests.rs +++ b/crates/engine/src/vector_multi_behaviour_tests.rs @@ -169,13 +169,19 @@ fn editing_several_vectors_at_once() { assert!(near(page_pos(&s, a, atl), (1.0, 0.0)) && near(page_pos(&s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer"); s.undo().unwrap(); assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "one undo step for every layer"); - // Dragging one selected point drags them all. - let active = editing(&s).unwrap().id; - let grab = if active == a { (0.0, 0.0) } else { (200.0, 0.0) }; - drag(&mut s, grab, (grab.0 + 10.0, grab.1 + 20.0), NONE); - assert!(near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), "dragging one selected point moves them all"); + // Dragging one selected point drags them all (one on the transform box's edge: its corners + // scale, as with points of one layer). + let atr = point_at(&s, a, (100.0, 0.0)); + ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true})); + drag(&mut s, (100.0, 0.0), (110.0, 20.0), NONE); + assert!( + near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, a, atr), (110.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)), + "dragging one selected point moves them all" + ); s.undo().unwrap(); assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "the drag is one undo step"); + ex(&mut s, "vector.edit.select", json!({"vertices": [atl]})); + ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true})); // Corner radius, mirroring and deleting reach every layer's selected points. ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 10})); assert!(net(&s, a).vertices[atl].radius == 10.0 && net(&s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer"); diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/vectoredit.rs index a3fbf803..3b79dd4b 100644 --- a/crates/engine/src/vectoredit.rs +++ b/crates/engine/src/vectoredit.rs @@ -4,7 +4,7 @@ //! Entering edit mode on a non-vector shape converts it to a vector first (like the incumbent). //! Vertex coordinates are node-local; after each edit the node is re-fitted so its box wraps the //! network again. The network edits themselves live in [`ops`]; pointer gestures in `pointer.rs`, -//! the transform box around several points in [`transform`], point snapping in `point_snap.rs` and +//! the transform box around several points (in one layer or several) in [`transform`], point snapping in `point_snap.rs` and //! hovering, hatching and selecting filled regions in [`regions`]. pub mod ops; diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/vectoredit/pointer.rs index 6667a209..4721d6bd 100644 --- a/crates/engine/src/vectoredit/pointer.rs +++ b/crates/engine/src/vectoredit/pointer.rs @@ -262,7 +262,7 @@ impl Session { } /// Every edited layer's selected points (segment ends included) other than the active one's. - fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec)> { + pub(super) fn others_moving(doc: &Document, ve: &VectorEdit) -> Vec<(NodeId, Vec)> { ve.others .iter() .filter_map(|o| { @@ -310,7 +310,7 @@ impl Session { let Some(b) = transform::shown_box(&doc, ve) else { return Ok(()) }; match bh { BoxHit::Inside => { - let g = self.points_gesture(ve.id, ev.p(), b.points, vec![], None, true)?; + let g = self.points_gesture(ve.id, ev.p(), b.points, b.others, None, true)?; self.begin_interaction("Move points")?; self.vgesture(g); } @@ -320,7 +320,7 @@ impl Session { self.vgesture(VGesture::BoxRotate { b, start_angle, angle: 0.0 }); } BoxHit::Handle(handle) => { - let targets = Arc::new(point_snap::targets(&doc, page, Some(ve.id), &b.points, &[])); + let targets = Arc::new(point_snap::targets_moving(&doc, page, Some(ve.id), &b.points, &[], &b.others)); self.begin_interaction("Scale points")?; self.vgesture(VGesture::BoxScale { b, handle, targets, pick }); } @@ -428,11 +428,11 @@ impl Session { } /// The region of the active layer a press at `ev` would take (Move tool, `h` the network's part - /// there). Inside the transform box around points selected in their own right, the box keeps - /// its drag unless the region is already selected. + /// there). Inside the transform box around points selected in their own right (in any edited + /// layer), the box keeps its drag unless the region is already selected. fn region_under(doc: &Document, ve: &VectorEdit, h: Option, ev: &PointerEvent) -> Option { let r = region_hit(doc, ve, ev.p(), ev.zoom)?; - let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty(); + let own = !ve.shown_vertices().is_empty() || !ve.segments.is_empty() || ve.others_selected(); let in_box = !ev.shift && own && transform::box_hit(doc, ve, h, ev.p(), ev.zoom) == Some(BoxHit::Inside); (!in_box || ve.regions.contains(&r)).then_some(r) } @@ -883,7 +883,7 @@ impl Session { let q = b.frame.inverse() * (ev.p() + c); let (x0, y0, x1, y1) = crate::tools::resized_box(handle, q, b.width, b.height, ev.shift, ev.alt); let m = b.onto(x0, y0, x1, y1); - self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?; + self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?; } VGesture::BoxRotate { b, start_angle, .. } => { let c = b.center(); @@ -892,7 +892,7 @@ impl Session { angle = (angle / 15.0).round() * 15.0; } let m = figcraft_geom::rotate_about(Affine::IDENTITY, c, -angle); - self.preview(|doc, _| transform::transform_points(doc, id, &b.points, m))?; + self.preview(|doc, _| transform::transform_box(doc, id, &b, m))?; self.tool.overlay.cursor = Cursor::Rotate((ev.p() - c).atan2().to_degrees()); self.vgesture(VGesture::BoxRotate { b, start_angle, angle }); } @@ -1007,10 +1007,7 @@ impl Session { } else { self.cancel_interaction()?; if click_clears { - set_state(self, |e| { - e.vertices.clear(); - e.segments.clear(); - })?; + self.clear_points()?; } } } @@ -1037,23 +1034,7 @@ impl Session { } })?, VGesture::Erase { .. } | VGesture::Paint { .. } => self.commit_interaction()?, - VGesture::BoxScale { pick, .. } => { - let ds = self.ds()?; - let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc)); - match pick.filter(|_| clicked) { - Some(v) => { - self.cancel_interaction()?; - set_state(self, |e| { - if let Some(i) = e.vertices.iter().position(|x| *x == v) { - e.vertices.remove(i); - } else { - e.vertices.push(v); - } - })?; - } - None => self.commit_interaction()?, - } - } + VGesture::BoxScale { pick, .. } => self.box_scale_up(pick)?, VGesture::Cut { start, hit } => self.cut_up(ve, start, hit, ev)?, VGesture::Width(g) => self.width_up(g)?, } @@ -1063,6 +1044,34 @@ impl Session { Ok(()) } + /// Clears the point selection of the active layer and of the other layers being edited. + fn clear_points(&mut self) -> Result<()> { + set_state(self, |e| { + e.vertices.clear(); + e.segments.clear(); + for o in &mut e.others { + o.vertices.clear(); + o.segments.clear(); + } + }) + } + + /// The transform box's corner or edge was let go: a Shift-click (no drag) on the corner's + /// point `pick` toggles it in the selection; anything else keeps the scale. + fn box_scale_up(&mut self, pick: Option) -> Result<()> { + let ds = self.ds()?; + let clicked = ds.interaction.as_ref().is_some_and(|it| Arc::ptr_eq(&it.doc, &ds.doc)); + let Some(v) = pick.filter(|_| clicked) else { return self.commit_interaction() }; + self.cancel_interaction()?; + set_state(self, |e| { + if let Some(i) = e.vertices.iter().position(|x| *x == v) { + e.vertices.remove(i); + } else { + e.vertices.push(v); + } + }) + } + /// Point `v` of vector `id`, dragged and dropped within `tol` (page units) of another of its /// points, merges into that point (in the same undo step as the move), which stays selected. fn merge_dropped_point(&mut self, id: NodeId, v: usize, tol: f64) -> Result<()> { diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/vectoredit/transform.rs index 1d5f27a6..ca6e59e2 100644 --- a/crates/engine/src/vectoredit/transform.rs +++ b/crates/engine/src/vectoredit/transform.rs @@ -1,11 +1,11 @@ //! The transform box of vector edit mode. With two or more points selected (Move tool) a box in the -//! vector's own axes wraps them: its corners and edges scale them (Shift keeps proportions, Alt -//! scales from the centre), dragging just outside a corner rotates them about its centre (Shift: -//! 15° steps) and dragging inside it moves them; each drag is one undo step (the gestures live in -//! `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP, +//! vector's own axes wraps them (in the page's axes when they are in several layers): its corners +//! and edges scale them (Shift keeps proportions, Alt scales from the centre), dragging just +//! outside a corner rotates them about its centre (Shift: 15° steps) and dragging inside it moves +//! them; each drag is one undo step for every layer (the gestures live in `pointer.rs`). Commands make the same edits for the Design panel, the control channel and MCP, //! and while points are selected the flip and 90°/180° rotation commands act on them. -use figcraft_doc::Document; +use figcraft_doc::{Document, NodeId}; use figcraft_geom::{Affine, Point}; use serde_json::{Value, json}; @@ -19,16 +19,23 @@ use crate::{Result, Session, bad, spec}; const EPS: f64 = 1e-9; /// The selected points' box: the page transform of its top-left corner (axes along the vector's -/// own), its size in those axes and the points in it. +/// own), its size in those axes and the points in it: the active layer's, and other edited +/// layers' in `others`. #[derive(Clone, Debug, PartialEq)] pub struct PointBox { pub frame: Affine, pub width: f64, pub height: f64, pub points: Vec, + pub others: Vec<(NodeId, Vec)>, } impl PointBox { + /// How many points the box wraps, in every layer. + pub fn point_count(&self) -> usize { + self.points.len() + self.others.iter().map(|(_, v)| v.len()).sum::() + } + /// Corners in page coordinates (tl, tr, br, bl). pub fn corners(&self) -> [Point; 4] { let (f, w, h) = (self.frame, self.width, self.height); @@ -62,17 +69,37 @@ pub fn point_box(doc: &Document, ve: &VectorEdit) -> Option { if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) { return None; } - Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points }) + Some(PointBox { frame: doc.world_transform(ve.id) * Affine::translate((x0, y0)), width, height, points, others: vec![] }) +} + +/// The box of the selected points of every edited layer, in the page's axes (the layers' own may +/// differ). +fn layers_box(doc: &Document, ve: &VectorEdit) -> Option { + let points = selected_points(network(doc, ve.id)?, ve); + let others = Session::others_moving(doc, ve); + let page = |id: NodeId, verts: &[usize]| -> Vec { + let w = doc.world_transform(id); + network(doc, id).map(|n| verts.iter().filter_map(|v| n.vertices.get(*v)).map(|v| w * v.point()).collect()).unwrap_or_default() + }; + let all = page(ve.id, &points).into_iter().chain(others.iter().flat_map(|(id, v)| page(*id, v))); + let (x0, y0, x1, y1) = all.fold((f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY), |(a, b, c, d), p| { + (a.min(p.x), b.min(p.y), c.max(p.x), d.max(p.y)) + }); + let (width, height) = (x1 - x0, y1 - y0); + if !(x0.is_finite() && y0.is_finite() && width.is_finite() && height.is_finite()) { + return None; + } + Some(PointBox { frame: Affine::translate((x0, y0)), width, height, points, others }) } /// The box drawn and dragged on the canvas: the Move tool, no open pen path, and two or more -/// points that aren't all in one place, all in the active layer. -/// shortcut: with points selected in several layers there is no box (drags, nudges and the Design panel still reach them all); wrap them all if asked for. +/// points that aren't all in one place, in any of the edited layers. pub fn shown_box(doc: &Document, ve: &VectorEdit) -> Option { - if ve.tool != VTool::Move || ve.anchor.is_some() || ve.others_selected() { + if ve.tool != VTool::Move || ve.anchor.is_some() { return None; } - point_box(doc, ve).filter(|b| b.points.len() >= 2 && (b.width > EPS || b.height > EPS)) + let b = if ve.others_selected() { layers_box(doc, ve) } else { point_box(doc, ve) }; + b.filter(|b| b.point_count() >= 2 && (b.width > EPS || b.height > EPS)) } /// What the transform box does under the pointer. @@ -115,12 +142,21 @@ pub fn box_hit(doc: &Document, ve: &VectorEdit, h: Option, p: Point, zoom: /// Map points `verts` of vector `id` (and the handles at them) through page transform `m`, then /// re-fit the vector. -pub fn transform_points(doc: &mut Document, id: figcraft_doc::NodeId, verts: &[usize], m: Affine) -> Result<()> { +pub fn transform_points(doc: &mut Document, id: NodeId, verts: &[usize], m: Affine) -> Result<()> { let w = doc.world_transform(id); let local = w.inverse() * m * w; edit(doc, id, |net| ops::transform_vertices(net, verts, local)) } +/// Map the points of box `b` (`id`: the active layer) in every layer through page transform `m`. +pub fn transform_box(doc: &mut Document, id: NodeId, b: &PointBox, m: Affine) -> Result<()> { + transform_points(doc, id, &b.points, m)?; + for (o, verts) in &b.others { + transform_points(doc, *o, verts, m)?; + } + Ok(()) +} + /// Transform the selected points by `f(box)` (page space) when editing with points selected and /// the command names no layers (`p` has no `ids` or `id`); `None` otherwise (it then acts on /// layers). diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/vectoredit/transform_tests.rs index d69baf52..f29ed2a5 100644 --- a/crates/engine/src/vectoredit/transform_tests.rs +++ b/crates/engine/src/vectoredit/transform_tests.rs @@ -307,3 +307,122 @@ fn edit_mode_transform_commands_survive_junk() { } } } + +/// Two edited layers turned differently: `a` a 100×100 square at the origin turned -20°, `b` one +/// at (200, 0) turned 30° (the active layer), with two of `a`'s corners and all of `b`'s points +/// selected. Neither layer's axes are the page's. Snapping (the pixel grid too) is off so drags +/// land exactly. +fn two_layers(s: &mut Session) -> (NodeId, NodeId) { + let mut square = |x: f64| crate::params::id_of(&ex(s, "shape.rectangle", json!({"x": x, "y": 0, "width": 100, "height": 100}))["id"]).unwrap(); + let (a, b) = (square(0.0), square(200.0)); + ex(s, "node.set", json!({"id": a, "props": {"rotation": -20}})); + ex(s, "node.set", json!({"id": b, "props": {"rotation": 30}})); + ex(s, "view.snapGeometry", json!({"on": false})); + ex(s, "view.snapObjects", json!({"on": false})); + ex(s, "view.snapPixel", json!({"on": false})); + ex(s, "vector.edit.begin", json!({"ids": [a, b]})); + let n = net(s, a); + ex(s, "vector.edit.select", json!({"vertices": [vertex_at(&n, 0.0, 0.0), vertex_at(&n, 100.0, 100.0)]})); + ex(s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3], "add": true})); + assert_eq!(ve(s).id, b, "the layer named last is the active one"); + (a, b) +} + +/// Every selected point's page position, layer by layer. +fn selected_page_points(s: &Session, a: NodeId, b: NodeId) -> Vec<(NodeId, usize, Point)> { + let e = ve(s); + let parts = e.parts(); + [a, b] + .iter() + .flat_map(|id| { + let part = parts.iter().find(|p| p.id == *id).unwrap(); + part.vertices.iter().map(|v| (*id, *v, page_pt(s, *id, *v))).collect::>() + }) + .collect() +} + +#[test] +fn the_box_wraps_points_selected_in_several_layers() { + let mut s = Session::new(); + let (a, b) = two_layers(&mut s); + let before = selected_page_points(&s, a, b); + assert_eq!(before.len(), 6); + // On the page's axes: `b` is turned, so neither layer's own axes fit both. + let (x0, y0) = before.iter().fold((f64::INFINITY, f64::INFINITY), |(x, y), (_, _, p)| (x.min(p.x), y.min(p.y))); + let (x1, y1) = before.iter().fold((f64::NEG_INFINITY, f64::NEG_INFINITY), |(x, y), (_, _, p)| (x.max(p.x), y.max(p.y))); + let c = s.vector_edit_box().expect("a box around the points of both layers"); + assert!(near(c[0], pt(x0, y0)) && near(c[2], pt(x1, y1)), "{c:?} vs ({x0}, {y0})–({x1}, {y1})"); + + // The bottom-right corner dragged to twice the width scales every point in both layers. + drag(&mut s, (x1, y1), (x0 + 2.0 * (x1 - x0), y1)); + for (id, v, p) in &before { + let q = page_pt(&s, *id, *v); + assert!(near(q, pt(x0 + 2.0 * (p.x - x0), p.y)), "point {v} of {id:?}: {p:?} → {q:?}"); + } + s.undo().unwrap(); + for (id, v, p) in &before { + assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}"); + } + + // From just outside the top-right corner, a quarter turn clockwise about the box's centre. + let ctr = pt((x0 + x1) / 2.0, (y0 + y1) / 2.0); + let from = pt(x1 + 10.0, y0 - 10.0); + let d = from - ctr; + drag(&mut s, (from.x, from.y), (ctr.x - d.y, ctr.y + d.x)); + for (id, v, p) in &before { + let r = *p - ctr; + let q = page_pt(&s, *id, *v); + assert!(near(q, pt(ctr.x - r.y, ctr.y + r.x)), "point {v} of {id:?}: {p:?} → {q:?}"); + } + s.undo().unwrap(); + for (id, v, p) in &before { + assert!(near(page_pt(&s, *id, *v), *p), "one undo step brings back point {v} of {id:?}"); + } +} + +#[test] +fn inside_the_box_of_several_layers_drags_and_a_click_clears_them_all() { + let mut s = Session::new(); + let (a, b) = two_layers(&mut s); + let before = selected_page_points(&s, a, b); + // Clear of both networks: between the two squares, inside the box. + let doc = s.doc().unwrap().clone(); + assert!(hit_any(&doc, &ve(&s), pt(150.0, 50.0), 6.0).is_none()); + assert_eq!(box_hit(&doc, &ve(&s), None, pt(150.0, 50.0), 1.0), Some(BoxHit::Inside)); + drag(&mut s, (150.0, 50.0), (170.0, 60.0)); + for (id, v, p) in &before { + assert!(near(page_pt(&s, *id, *v), *p + Vec2::new(20.0, 10.0)), "point {v} of {id:?} moves along"); + } + s.undo().unwrap(); + click(&mut s, 150.0, 50.0); + assert!(ve(&s).parts().iter().all(|p| p.vertices.is_empty()), "a click inside clears every layer's points"); +} + +#[test] +fn inside_the_box_of_another_layer_drags_over_a_region_of_the_active_one() { + // `b`'s points selected, `a` (a filled 300 square around `b`) active with nothing selected: + // the press at the box's centre lands on `a`'s region too. + let mut s = Session::new(); + let mut square = |x: f64, size: f64| { + crate::params::id_of(&ex(&mut s, "shape.rectangle", json!({"x": x, "y": x, "width": size, "height": size}))["id"]).unwrap() + }; + let (a, b) = (square(0.0, 300.0), square(100.0, 50.0)); + ex(&mut s, "view.snapGeometry", json!({"on": false})); + ex(&mut s, "view.snapObjects", json!({"on": false})); + ex(&mut s, "view.snapPixel", json!({"on": false})); + ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]})); + ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [0, 1, 2, 3]})); + ex(&mut s, "vector.edit.select", json!({"id": a, "add": true})); + let doc = s.doc().unwrap().clone(); + let e = ve(&s); + assert!(e.id == a && e.vertices.is_empty() && e.others_selected()); + assert!(hit_any(&doc, &e, pt(125.0, 125.0), 6.0).is_none()); + assert!(regions::region_hit(&doc, &e, pt(125.0, 125.0), 1.0).is_some(), "a region of the active layer under the press"); + assert_eq!(box_hit(&doc, &e, None, pt(125.0, 125.0), 1.0), Some(BoxHit::Inside)); + let before: Vec = (0..4).map(|v| page_pt(&s, b, v)).collect(); + drag(&mut s, (125.0, 125.0), (145.0, 135.0)); + assert!(ve(&s).regions.is_empty(), "the region isn't selected"); + for (v, p) in before.iter().enumerate() { + assert!(near(page_pt(&s, b, v), *p + Vec2::new(20.0, 10.0)), "point {v} of b moves along"); + } +} diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index 4777be18..a271a908 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -104,7 +104,10 @@ fn themed() -> Document { add(&mut doc, frame, NodeKind::Rectangle, "Swatch", (10.0, 10.0), (80.0, 60.0), &|n| { let mut g = Gradient::default_for(GradientKind::Linear, Color::BLACK); g.stops = vec![ColorStop { position: 0.0, color: Color::BLACK }, ColorStop { position: 1.0, color: Color::WHITE }]; - n.fills = vec![solid(0.5, 0.5, 0.5), Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal }]; + n.fills = vec![ + solid(0.5, 0.5, 0.5), + Paint { kind: PaintKind::Gradient(g), opacity: 1.0, visible: true, blend: BlendMode::Normal, color_alpha: 1.0 }, + ]; n.strokes = vec![solid(0.0, 0.0, 0.0), solid(1.0, 0.0, 0.0)]; n.effects = vec![Effect { kind: EffectKind::DropShadow(Shadow { diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 0cad2122..dad09d95 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -25,10 +25,10 @@ use figcraft_doc::proto::{ Action, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, }; use figcraft_doc::{ - AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, HConstraint, LayoutMode, LetterSpacing, - LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, StrokeJoin, StyleKind, - TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, VariableValue, VectorNetwork, - WindingRule, + AutoLayout, BoolOp, ContentAlign, CrossAlign, Document, EffectKind, ExportSize, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, + LetterSpacing, LineHeight, ListStyle, MainAlign, MaskType, Node, NodeId, NodeKind, NoiseMode, PropDef, Sizing, StrokeAlign, StrokeCap, + StrokeJoin, StyleKind, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, VConstraint, VariableType, + VariableValue, VectorNetwork, WindingRule, }; use serde_json::Value; @@ -168,7 +168,12 @@ message PrototypeStartingPoint string name=1, string position=3 message OverlayBackgroundAppearance OverlayBackgroundType backgroundType=1, Color backgroundColor=2 message GUIDPositionMapEntry GUID id=1, string position=2 message GUIDPositionMap GUIDPositionMapEntry[] entries=1 -message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 +enum GridTrackSizingType FLEX=0 FIXED=1 HUG=2 UNKNOWN=3 +message GridTrackSizeFunction GridTrackSizingType type=1, float value=2 +message GridTrackSize GridTrackSizeFunction minSizing=1, GridTrackSizeFunction maxSizing=2 +message GridTrackSizeEntry GUID id=1, GridTrackSize trackSize=2 +message GridTrackSizeMap GridTrackSizeEntry[] entries=1 +message NodeChange GUID guid=1, NodePhase phase=2, ParentIndex parentIndex=3, NodeType type=4, string name=5, bool visible=6, bool locked=7, float opacity=8, BlendMode blendMode=9, Vector size=11, Matrix transform=12, bool mask=16, MaskType maskType=317, float backgroundOpacity=19, bool backgroundEnabled=15, bool internalOnly=142, string description=318, StyleType styleType=163, string sortPosition=320, StyleId styleIdForFill=332, StyleId styleIdForStrokeFill=333, StyleId styleIdForText=334, StyleId styleIdForEffect=335, StyleId styleIdForGrid=336, VariableDataMap variableConsumptionMap=307, VariableModeBySetMap variableModeBySetMap=316, VariableSetMode[] variableSetModes=312, VariableSetID variableSetID=313, VariableResolvedDataType variableResolvedType=314, VariableDataValues variableDataValues=315, VariableScope[] variableScopes=353, DocumentColorProfile documentColorProfile=349, Paint[] fillPaints=38, Paint[] strokePaints=39, float strokeWeight=26, StrokeAlign strokeAlign=29, StrokeJoin strokeJoin=31, StrokeCap strokeCap=30, float[] dashPattern=13, float miterLimit=25, bool borderStrokeWeightsIndependent=299, float borderTopWeight=295, float borderRightWeight=298, float borderBottomWeight=296, float borderLeftWeight=297, Effect[] effects=43, float cornerRadius=20, float cornerSmoothing=160, bool rectangleCornerRadiiIndependent=149, float rectangleTopLeftCornerRadius=145, float rectangleTopRightCornerRadius=146, float rectangleBottomRightCornerRadius=148, float rectangleBottomLeftCornerRadius=147, ConstraintType horizontalConstraint=28, ConstraintType verticalConstraint=37, bool frameMaskDisabled=115, bool resizeToFit=117, bool isStateGroup=225, bool sectionContentsHidden=283, Color backgroundColor=50, StackMode stackMode=105, float stackSpacing=107, float stackCounterSpacing=324, StackWrap stackWrap=323, StackCounterAlignContent stackCounterAlignContent=343, float stackHorizontalPadding=209, float stackVerticalPadding=210, float stackPaddingRight=233, float stackPaddingBottom=234, StackJustify stackPrimaryAlignItems=230, StackAlign stackCounterAlignItems=231, StackSize stackPrimarySizing=229, StackSize stackCounterSizing=221, bool bordersTakeSpace=294, bool stackReverseZIndex=271, float gridColumnGap=438, float gridRowGap=437, GUIDPositionMap gridColumns=436, GUIDPositionMap gridRows=435, GridTrackSizeMap gridColumnsSizing=443, GridTrackSizeMap gridRowsSizing=444, float stackChildPrimaryGrow=232, StackCounterAlign stackChildAlignSelf=236, StackPositioning stackPositioning=269, OptionalVector minSize=325, OptionalVector maxSize=326, GUID gridColumnAnchor=440, GUID gridRowAnchor=439, uint gridColumnSpan=442, uint gridRowSpan=441, LayoutGrid[] layoutGrids=47, ExportSettings[] exportSettings=45, TextData textData=42, FontName fontName=41, float fontSize=21, Number lineHeight=40, Number letterSpacing=165, TextCase textCase=34, TextDecoration textDecoration=35, TextAlignHorizontal textAlignHorizontal=32, TextAlignVertical textAlignVertical=33, TextAutoResize textAutoResize=46, TextTruncation textTruncation=280, int maxLines=351, float paragraphSpacing=23, float paragraphIndent=22, Hyperlink hyperlink=223, bool autoRename=14, bool fontVariantCommonLigatures=127, bool fontVariantContextualLigatures=128, string fontVersion=202, uint textUserLayoutVersion=203, uint textExplicitLayoutVersion=396, uint textBidiVersion=279, float textTracking=27, bool textDecorationSkipInk=412, EmojiImageSet emojiImageSet=391, uint styleID=49, Path[] fillGeometry=51, Path[] strokeGeometry=52, VectorData vectorData=48, VectorMirror handleMirroring=44, uint count=10, float starInnerScale=24, ArcData arcData=195, BooleanOperation booleanOperation=36, string symbolDescription=144, VariableWidthPoint[] variableWidthPoints=447, PluginData[] pluginData=204, SymbolData symbolData=113, NodeChange[] derivedSymbolData=125, GUID overriddenSymbolID=143, ComponentPropDef[] componentPropDefs=266, ComponentPropAssignment[] componentPropAssignments=268, VariableDataMap parameterConsumptionMap=445, GUIDPath guidPath=111, PrototypeInteraction[] prototypeInteractions=226, PrototypeStartingPoint prototypeStartingPoint=249, ScrollDirection scrollDirection=159, ScrollBehavior scrollBehavior=186, OverlayPositionType overlayPositionType=198, OverlayBackgroundInteraction overlayBackgroundInteraction=200, OverlayBackgroundAppearance overlayBackgroundAppearance=201 struct Blob byte[] bytes message Message MessageType type=1, uint sessionID=2, NodeChange[] nodeChanges=4, Blob[] blobs=6 "; @@ -277,6 +282,37 @@ fn s(x: &str) -> Kv<'static> { fn b(x: bool) -> Kv<'static> { Kv::Bool(x) } + +/// A track's size as the format's enum name and value, when both sizing functions agree (the +/// importer keeps only those). `None`: the format has no name for it, so an equal track stands in. +fn grid_track_size(track: &GridTrack) -> Option<(&'static str, f64)> { + let value = track.value; + let fits = value.is_finite() && (0.0..=crate::fig::map::MAX_GRID_TRACK_VALUE).contains(&value); + match track.sizing { + Sizing::Fixed if fits => Some(("FIXED", value)), + Sizing::Fill if fits && value > 0.0 => Some(("FLEX", value)), + Sizing::Hug => Some(("HUG", 0.0)), + _ => None, + } +} + +/// A grid axis's track sizes, keyed by track id like the order map. A track the document doesn't +/// set is left out, and one the format can't hold keeps its id (the import equalises it) and sets +/// `dropped`. +fn grid_sizing(tracks: &[GridTrack], ids: &[u32], dropped: &mut bool) -> Option> { + let mut entries = Vec::new(); + for (track, id) in tracks.iter().zip(ids) { + match grid_track_size(track) { + Some((kind, value)) => { + let size = || o(vec![("type", e(kind)), ("value", fl(value))]); + entries.push(o(vec![("id", guid(AUX, *id)), ("trackSize", o(vec![("minSizing", size()), ("maxSizing", size())]))])); + } + None => *dropped = true, + } + } + (!entries.is_empty()).then(|| o(vec![("entries", arr(entries))])) +} + fn guid(session: u32, local: u32) -> Kv<'static> { o(vec![("sessionID", u(session)), ("localID", u(local))]) } @@ -1098,8 +1134,13 @@ impl<'d> Exporter<'d> { if !n.guides.is_empty() { self.warn("ruler guides were left out"); } - if !n.annotations.is_empty() || !n.dev_resources.is_empty() || !n.code_links.is_empty() || !n.dev_status.is_none() { - self.warn("Dev Mode annotations, statuses, resources and code links were left out"); + if !n.annotations.is_empty() + || !n.dev_resources.is_empty() + || !n.code_links.is_empty() + || !n.measurements.is_empty() + || !n.dev_status.is_none() + { + self.warn("Dev Mode annotations, statuses, resources, measurements and code links were left out"); } if !n.plugin_data.is_empty() { self.warn("plugin data was left out"); @@ -1400,25 +1441,7 @@ impl<'d> Exporter<'d> { ("stackReverseZIndex", b(l.reverse_z)), ]); if grid { - // Instance layers share their main layer's track ids, so unchanged tracks and the cells - // their layers sit in still match. - let key = self.main_layer(n.id); - let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize); - let (cols, rows) = match self.tracks.get(&key) { - Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()), - _ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()), - }; - let map = |ids: &[u32]| { - o(vec![( - "entries", - arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()), - )]) - }; - f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); - if l.column_tracks.iter().chain(&l.row_tracks).any(|t| t.sizing != Sizing::Fill) { - self.warn("grid tracks of fixed or content size were written as equal tracks"); - } - self.tracks.insert(key, (cols, rows)); + self.grid_layout(n, l, f); } else { let spread = l.content_align == ContentAlign::SpaceBetween; f.extend([ @@ -1429,6 +1452,34 @@ impl<'d> Exporter<'d> { } } + /// A grid layout's track gaps, order and sizes. + fn grid_layout(&mut self, n: &Node, l: &AutoLayout, f: &mut Fields) { + // Instance layers share their main layer's track ids, so unchanged tracks and the cells + // their layers sit in still match. + let key = self.main_layer(n.id); + let (want_c, want_r) = (l.grid_columns.min(1000) as usize, l.grid_rows.min(1000) as usize); + let (cols, rows) = match self.tracks.get(&key) { + Some((c, r)) if (c.len(), r.len()) == (want_c, want_r) => (c.clone(), r.clone()), + _ => ((0..want_c).map(|_| self.next_aux()).collect(), (0..want_r).map(|_| self.next_aux()).collect()), + }; + let map = |ids: &[u32]| { + o(vec![("entries", arr(ids.iter().enumerate().map(|(i, t)| o(vec![("id", guid(AUX, *t)), ("position", s(&position(i)))])).collect()))]) + }; + f.extend([("gridColumnGap", fl(l.gap)), ("gridRowGap", fl(l.cross_gap)), ("gridColumns", map(&cols)), ("gridRows", map(&rows))]); + let mut dropped = false; + let column_sizing = grid_sizing(&l.column_tracks, &cols, &mut dropped); + let row_sizing = grid_sizing(&l.row_tracks, &rows, &mut dropped); + if dropped { + self.warn("grid tracks whose size the format can't hold were written as equal tracks"); + } + for (name, sizing) in [("gridColumnsSizing", column_sizing), ("gridRowsSizing", row_sizing)] { + if let Some(v) = sizing { + f.push((name, v)); + } + } + self.tracks.insert(key, (cols, rows)); + } + /// How a layer sizes and sits in its auto layout parent. Min/max sizes are the layer's own /// and are written whatever its parent is. fn child_layout(&mut self, n: &Node, parent: Option<&Node>, f: &mut Fields) { @@ -2374,7 +2425,7 @@ fn unset(key: &str) -> Option> { "styleIdForFill" | "styleIdForStrokeFill" | "styleIdForEffect" | "styleIdForGrid" | "styleIdForText" => { o(vec![("guid", guid(u32::MAX, u32::MAX))]) } - "variableConsumptionMap" | "variableModeBySetMap" => o(vec![("entries", arr(Vec::new()))]), + "variableConsumptionMap" | "variableModeBySetMap" | "gridColumnsSizing" | "gridRowsSizing" => o(vec![("entries", arr(Vec::new()))]), "mask" | "rectangleCornerRadiiIndependent" | "borderStrokeWeightsIndependent" => b(false), "effects" | "dashPattern" | "layoutGrids" | "exportSettings" | "prototypeInteractions" | "fillGeometry" | "variableWidthPoints" => { arr(Vec::new()) diff --git a/crates/format/src/fig/export_tests.rs b/crates/format/src/fig/export_tests.rs index 0eac9571..1f0fe172 100644 --- a/crates/format/src/fig/export_tests.rs +++ b/crates/format/src/fig/export_tests.rs @@ -74,7 +74,7 @@ fn paint(r: &mut Lcg, image: &str) -> Paint { p.kind } }; - Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS) } + Paint { kind, opacity: r.pick(&[1.0, 0.5, 0.25]), visible: r.chance(85), blend: r.pick(&BLENDS), color_alpha: 1.0 } } fn effect(r: &mut Lcg) -> Effect { @@ -1022,6 +1022,81 @@ fn junk_documents_never_panic() { } } +/// Grid track sizes reach the file and come back: fixed px, fractional fill and hug tracks, and a +/// track the format has no name for leaves an equal track behind instead of failing the write. +#[test] +fn grid_track_sizes_round_trip() { + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let grid = doc.create(page, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap(); + { + let node = doc.node_mut(grid).unwrap(); + node.layout = Some(AutoLayout { + mode: LayoutMode::Grid, + grid_columns: 3, + grid_rows: 2, + column_tracks: vec![GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()], + row_tracks: vec![GridTrack::fill(1.0), GridTrack::fixed(50.0)], + ..AutoLayout::default() + }); + } + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.is_empty(), "{warnings:?}"); + let (back, iw) = import(&bytes, "File").unwrap(); + assert!(iw.is_empty(), "{iw:?}"); + let l = back.node(back.pages[0]).and_then(|p| p.children.first()).and_then(|c| back.node(*c)).and_then(|n| n.layout.clone()).unwrap(); + assert_eq!(l.column_tracks, vec![GridTrack::fixed(120.0), GridTrack::fill(2.0), GridTrack::hug()]); + assert_eq!(l.row_tracks, vec![GridTrack::fill(1.0), GridTrack::fixed(50.0)]); + + // A negative fixed track is no size the format can name: the write still succeeds, the track + // falls back to an equal one, and the warning says so. + let mut odd = doc.clone(); + if let Some(l) = odd.node_mut(grid).and_then(|n| n.layout.as_mut()) { + l.column_tracks[0] = GridTrack::fixed(-5.0); + } + let (bytes, warnings) = export(&odd, None).unwrap(); + assert_eq!(warnings, vec!["grid tracks whose size the format can't hold were written as equal tracks".to_string()]); + let (back, _) = import(&bytes, "File").unwrap(); + let l = back.node(back.pages[0]).and_then(|p| p.children.first()).and_then(|c| back.node(*c)).and_then(|n| n.layout.clone()).unwrap(); + assert_eq!(l.column_tracks[0], GridTrack::fill(1.0), "an unwritable track reads back equal"); +} + +/// An instance layer whose grid tracks are equal doesn't read back its main layer's track sizes. +#[test] +fn instance_grid_tracks_clear_the_mains_sizes() { + let mut doc = Document::new("File"); + let page = doc.first_page().unwrap(); + let main = doc.create(page, NodeKind::Component { description: String::new(), props: BTreeMap::new() }, "Main", 0.0, 0.0, 400.0, 200.0).unwrap(); + let grid = doc.create(main, NodeKind::Frame, "Grid", 0.0, 0.0, 400.0, 200.0).unwrap(); + let layout = AutoLayout { + mode: LayoutMode::Grid, + grid_columns: 2, + grid_rows: 1, + column_tracks: vec![GridTrack::fixed(120.0); 2], + ..AutoLayout::default() + }; + doc.node_mut(grid).unwrap().layout = Some(layout.clone()); + let inst = doc.alloc_id(); + let mut n = doc.node(main).unwrap().clone(); + (n.id, n.kind, n.parent, n.children) = (inst, NodeKind::Instance { component: main, props: BTreeMap::new() }, None, Vec::new()); + doc.insert(n, page, None).unwrap(); + let mut layer = doc.node(grid).unwrap().clone(); + (layer.id, layer.parent, layer.source) = (doc.alloc_id(), None, Some(grid)); + layer.layout = Some(AutoLayout { column_tracks: Vec::new(), ..layout }); + doc.insert(layer, inst, None).unwrap(); + let (bytes, _) = export(&doc, None).unwrap(); + let (back, _) = import(&bytes, "File").unwrap(); + let tracks = |inside: bool| { + let n = back.nodes().find(|n| { + n.name == "Grid" + && back.ancestors(n.id).iter().any(|a| matches!(back.node(*a).map(|p| &p.kind), Some(NodeKind::Instance { .. }))) == inside + }); + n.and_then(|n| n.layout.clone()).unwrap().column_tracks + }; + assert_eq!(tracks(false), vec![GridTrack::fixed(120.0); 2]); + assert!(tracks(true).iter().all(|t| *t == GridTrack::fill(1.0)), "{:?}", tracks(true)); +} + /// A frame's own min/max size survives whatever its parent is, not only under auto layout. #[test] fn frame_size_limits_survive_without_auto_layout_parent() { diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 8e467151..ec0dd8e4 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -24,7 +24,7 @@ const MAX_DEPTH: usize = 256; /// Most path commands read from one geometry blob. const MAX_COMMANDS: usize = 200_000; const MAX_GRID_TRACKS: usize = 1_000; -const MAX_GRID_TRACK_VALUE: f64 = 1e6; +pub(super) const MAX_GRID_TRACK_VALUE: f64 = 1e6; /// Id of "no style". const NO_STYLE: &str = "4294967295:4294967295"; /// Longest asset `key` or `version` kept for resolving references given as assets. diff --git a/crates/format/src/rest/props.rs b/crates/format/src/rest/props.rs index efb7710d..f5be3d27 100644 --- a/crates/format/src/rest/props.rs +++ b/crates/format/src/rest/props.rs @@ -123,10 +123,13 @@ pub(super) fn paints(cx: &mut Ctx, v: &Value, k: &str) -> Option> { fn paint(cx: &mut Ctx, p: &Value) -> Option { let ty = text(p, "type")?; let mut opacity = num(p, "opacity").unwrap_or(1.0).clamp(0.0, 1.0) as f32; + // The colour's alpha goes into the opacity as for a typed colour (`Paint::solid`). + let mut color_alpha = 1.0; let kind = match ty { "SOLID" => { let c = p.get("color").and_then(color).unwrap_or(Color::BLACK); opacity *= c.a; + color_alpha = c.a; PaintKind::Solid { color: c.with_alpha(1.0) } } "GRADIENT_LINEAR" => PaintKind::Gradient(gradient(GradientKind::Linear, p)), @@ -139,7 +142,7 @@ fn paint(cx: &mut Ctx, p: &Value) -> Option { return None; } }; - Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p) }) + Some(Paint { kind, opacity, visible: flag(p, "visible").unwrap_or(true), blend: paint_blend(p), color_alpha }) } /// Gradient handles are already in the node's unit box (start, end, width), like ours. diff --git a/crates/format/src/rest/tests.rs b/crates/format/src/rest/tests.rs index 1b3f85b6..f9acf2ea 100644 --- a/crates/format/src/rest/tests.rs +++ b/crates/format/src/rest/tests.rs @@ -162,6 +162,7 @@ fn paints_gradients_and_image_placeholders() { let s = &named(&doc, "Solid").fills[0]; assert!(matches!(s.kind, PaintKind::Solid { color } if color.r == 1.0 && color.a == 1.0)); assert!(close(f64::from(s.opacity), 0.25), "paint opacity × colour alpha"); + assert!(close(f64::from(s.color_alpha), 0.5), "the colour's part, which a bound variable replaces"); assert!(!s.visible && s.blend == BlendMode::Screen); let PaintKind::Gradient(g) = &named(&doc, "Linear").fills[0].kind else { panic!() }; assert_eq!((g.kind, g.from, g.to, g.width), (GradientKind::Linear, (0.0, 0.5), (1.0, 0.5), (0.0, 1.0))); diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index fb9cfcfe..324af4d4 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -13,6 +13,8 @@ mod brush_tests; mod ops_tests; mod points; #[cfg(test)] +mod rounded_side_strokes_tests; +#[cfg(test)] mod sides_tests; pub mod width; #[cfg(test)] @@ -451,6 +453,104 @@ pub fn rect_side_strokes(w: f64, h: f64, sides: [f64; 4], align: StrokeAlign) -> p } +/// Per-side stroke of a rectangle with corner radii `[tl, tr, br, bl]`, like CSS borders: the outer +/// edge is the rounded rectangle (center and outside grow its box by half or all of each side's +/// weight, and each non-zero radius with it); the inner edge is inset by each side's weight, with +/// elliptical corners `max(0, R − weight)` across and down (square where either is 0). Corners +/// that stay circular are smoothed like the shape's. An even-odd ring whose inner edge runs against +/// the outer one, so it is a ring under non-zero too. +pub fn rounded_side_strokes(w: f64, h: f64, radii: [f64; 4], smoothing: f64, sides: [f64; 4], align: StrokeAlign) -> BezPath { + let [t, r, b, l] = sides.map(non_negative); + if t + r + b + l <= 0.0 { + return BezPath::new(); + } + let f = match align { + StrokeAlign::Inside => 0.0, + StrokeAlign::Center => 0.5, + StrokeAlign::Outside => 1.0, + }; + // Clamped like the fill: at most half the shorter side. + let max_r = non_negative(w.min(h)) / 2.0; + let radii = radii.map(|v| non_negative(v).min(max_r)); + // Each corner's radius with the weights across and down it, clockwise from the top-left. + let corners = [(radii[0], l, t), (radii[1], r, t), (radii[2], r, b), (radii[3], l, b)]; + let outer = if align == StrokeAlign::Inside { + figcraft_geom::rounded_rect(w, h, radii, smoothing) + } else { + let grown = corners.map(|(rr, x, y)| if rr > 0.0 { (rr + x * f, rr + y * f) } else { (0.0, 0.0) }); + corner_rect(figcraft_geom::Rect::new(-l * f, -t * f, w + r * f, h + b * f), grown, smoothing) + }; + let k = 1.0 - f; + let inner = figcraft_geom::Rect::new(l * k, t * k, w - r * k, h - b * k); + if inner.width() <= 0.0 || inner.height() <= 0.0 { + return outer; + } + let (inset, fit) = fit_corners(inner, corners.map(|(rr, x, y)| ((rr - x * k).max(0.0), (rr - y * k).max(0.0)))); + // The hole's corners sit on the outer ones, inside them, unless they were shrunk to fit or + // smoothed: then it may leave the outer edge. + ring(outer, &corner_rect(inner, inset, smoothing), fit < 1.0 || smoothing > 0.0) +} + +/// Rectangle `r` with `(rx, ry)` corners, smoothed like `figcraft_geom::rounded_rect` where every +/// corner is circular (smoothing is defined for those only). +fn corner_rect(r: figcraft_geom::Rect, corners: [(f64, f64); 4], smoothing: f64) -> BezPath { + if smoothing > 0.0 && corners.iter().all(|(rx, ry)| rx == ry) { + return kurbo::Affine::translate((r.x0, r.y0)) * figcraft_geom::rounded_rect(r.width(), r.height(), corners.map(|c| c.0), smoothing); + } + elliptical_rect(r, corners) +} + +fn non_negative(v: f64) -> f64 { + if v.is_finite() { v.max(0.0) } else { 0.0 } +} + +/// `outer` with `hole` taken out: cut out of it when the hole may leave it, else as an even-odd +/// ring whose hole runs against it. +fn ring(outer: BezPath, hole: &BezPath, cut: bool) -> BezPath { + if cut && let Ok(p) = boolean_step(&outer, hole, BoolOp::Subtract) { + return p; + } + let mut p = outer; + p.extend(hole.reverse_subpaths().elements().iter().copied()); + p +} + +/// Corner radii `(rx, ry)` for the inner box `inner`, square where either is 0, all shrunk by one +/// factor (returned too) where two on an edge add up to more than it, as CSS shrinks radii. +fn fit_corners(inner: figcraft_geom::Rect, corners: [(f64, f64); 4]) -> ([(f64, f64); 4], f64) { + let c = corners.map(|(rx, ry)| if rx > 0.0 && ry > 0.0 { (rx, ry) } else { (0.0, 0.0) }); + let [tl, tr, br, bl] = c; + let edges = [(inner.width(), tl.0 + tr.0), (inner.width(), bl.0 + br.0), (inner.height(), tl.1 + bl.1), (inner.height(), tr.1 + br.1)]; + let fit = edges.iter().filter(|(_, sum)| *sum > 0.0).fold(1.0_f64, |m, (len, sum)| m.min(len / sum)); + (c.map(|(rx, ry)| (rx * fit, ry * fit)), fit) +} + +/// Rectangle `r` with an `(rx, ry)` quarter ellipse at each corner `[tl, tr, br, bl]`, drawn like +/// `figcraft_geom::rounded_rect` (circular corners come out the same). +fn elliptical_rect(r: figcraft_geom::Rect, [tl, tr, br, bl]: [(f64, f64); 4]) -> BezPath { + use std::f64::consts::{FRAC_PI_2, PI}; + // Each corner, the direction of its ellipse centre from it, and the angle its quarter starts at. + let quarters = [ + (Point::new(r.x1, r.y0), (-1.0, 1.0), tr, -FRAC_PI_2), + (Point::new(r.x1, r.y1), (-1.0, -1.0), br, 0.0), + (Point::new(r.x0, r.y1), (1.0, -1.0), bl, FRAC_PI_2), + (Point::new(r.x0, r.y0), (1.0, 1.0), tl, PI), + ]; + let mut p = BezPath::new(); + p.move_to((r.x0 + tl.0, r.y0)); + for (corner, (sx, sy), (rx, ry), start) in quarters { + if rx > 0.0 && ry > 0.0 { + let c = Point::new(corner.x + sx * rx, corner.y + sy * ry); + p.line_to((c.x + rx * start.cos(), c.y + ry * start.sin())); + kurbo::Arc::new(c, (rx, ry), start, FRAC_PI_2, 0.0).to_cubic_beziers(0.01, |a, b, e| p.curve_to(a, b, e)); + } else { + p.line_to(corner); + } + } + p.close_path(); + p +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pathops/src/rounded_side_strokes_tests.rs b/crates/pathops/src/rounded_side_strokes_tests.rs new file mode 100644 index 00000000..9c2f387e --- /dev/null +++ b/crates/pathops/src/rounded_side_strokes_tests.rs @@ -0,0 +1,124 @@ +//! Per-side stroke weights on rounded rectangles, drawn like CSS borders: square corners, a bar +//! along a rounded edge, tapers through corners, alignments, inner corners that no longer fit, +//! smoothing and degenerate weights. + +use figcraft_doc::StrokeAlign; +use figcraft_geom::kurbo::{BezPath, Point, Shape}; +use figcraft_geom::rounded_rect; + +use crate::{rect_side_strokes, rounded_side_strokes}; + +const W: f64 = 120.0; +const H: f64 = 60.0; + +/// The area of a ring whose hole runs against its outer edge. +fn area(p: &BezPath) -> f64 { + p.area().abs() +} + +fn painted(p: &BezPath, x: f64, y: f64) -> bool { + p.winding(Point::new(x, y)) % 2 != 0 +} + +#[test] +fn square_corners_paint_what_rect_side_strokes_paints() { + for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] { + let sides = [1.0, 2.0, 3.0, 4.0]; + let a = area(&rounded_side_strokes(W, H, [0.0; 4], 0.0, sides, align)); + assert!((a - area(&rect_side_strokes(W, H, sides, align))).abs() < 1e-6, "{align:?}: {a}"); + } +} + +/// `border-l-4 rounded-r-lg`. +#[test] +fn a_left_border_on_a_right_rounded_box_is_a_straight_bar() { + let bar = rounded_side_strokes(W, H, [0.0, 8.0, 8.0, 0.0], 0.0, [0.0, 0.0, 0.0, 4.0], StrokeAlign::Inside); + assert!((area(&bar) - 4.0 * H).abs() < 1e-6, "{}", area(&bar)); + assert!(painted(&bar, 2.0, 30.0)); + for (x, y) in [(119.9, 30.0), (117.0, 1.0), (60.0, 0.1), (60.0, 59.9), (4.1, 30.0)] { + assert!(!painted(&bar, x, y), "({x}, {y})"); + } +} + +#[test] +fn one_weight_on_every_side_is_the_uniform_ring() { + let ring = rounded_side_strokes(W, H, [8.0; 4], 0.0, [2.0; 4], StrokeAlign::Inside); + let want = area(&rounded_rect(W, H, [8.0; 4], 0.0)) - area(&rounded_rect(W - 4.0, H - 4.0, [6.0; 4], 0.0)); + assert!((area(&ring) - want).abs() < 1e-6, "{} vs {want}", area(&ring)); + assert_eq!(ring.winding(Point::new(60.0, 30.0)), 0, "a hole under non-zero too"); +} + +#[test] +fn center_and_outside_grow_the_box_and_its_corners() { + for (align, out, inset) in [(StrokeAlign::Center, 1.0, 1.0), (StrokeAlign::Outside, 2.0, 0.0)] { + let a = area(&rounded_side_strokes(W, H, [8.0; 4], 0.0, [2.0; 4], align)); + let outer = area(&rounded_rect(W + 2.0 * out, H + 2.0 * out, [8.0 + out; 4], 0.0)); + let hole = area(&rounded_rect(W - 2.0 * inset, H - 2.0 * inset, [8.0 - inset; 4], 0.0)); + assert!((a - (outer - hole)).abs() < 1e-6, "{align:?}: {a} vs {}", outer - hole); + } +} + +/// 8 × 7 inner ellipses at the bottom corners. +#[test] +fn a_bottom_border_tapers_into_the_bottom_corners() { + let bottom = rounded_side_strokes(W, H, [8.0; 4], 0.0, [0.0, 0.0, 1.0, 0.0], StrokeAlign::Inside); + assert!(painted(&bottom, 60.0, 59.5) && painted(&bottom, 8.0, 59.5) && painted(&bottom, 112.0, 59.5)); + for (x, y) in [(60.0, 0.5), (0.5, 30.0), (0.5, 52.0), (119.5, 52.0), (4.0, 4.0)] { + assert!(!painted(&bottom, x, y), "({x}, {y})"); + } +} + +#[test] +fn a_weight_past_the_radius_leaves_square_inner_corners() { + let top = rounded_side_strokes(W, H, [8.0; 4], 0.0, [10.0, 0.0, 0.0, 2.0], StrokeAlign::Inside); + assert!(!painted(&top, 2.2, 10.2) && !painted(&top, 119.8, 10.2)); + assert!(painted(&top, 60.0, 9.8) && painted(&top, 1.8, 30.0)); +} + +/// A weight past `w − R` leaves inner corners wider than the inner box: they shrink to fit, so the +/// hole stays inside the outer edge, across or down. +#[test] +fn inner_corners_shrink_to_fit_a_narrow_inner_box() { + let wide = rounded_side_strokes(20.0, 20.0, [10.0; 4], 0.0, [0.0, 0.0, 0.0, 12.0], StrokeAlign::Inside); + assert!(painted(&wide, 6.0, 10.0) && !painted(&wide, 16.0, 10.0)); + assert!(!painted(&wide, 17.5, 3.0) && !painted(&wide, 17.5, 17.0), "nothing past the outer edge"); + let tall = rounded_side_strokes(20.0, 20.0, [10.0; 4], 0.0, [12.0, 0.0, 0.0, 0.0], StrokeAlign::Inside); + assert!(painted(&tall, 10.0, 6.0) && !painted(&tall, 10.0, 16.0)); + assert!(!painted(&tall, 3.0, 17.5) && !painted(&tall, 17.0, 17.5), "nothing past the outer edge"); + let pill = rounded_side_strokes(30.0, 20.0, [10.0; 4], 0.0, [0.0, 0.0, 0.0, 25.0], StrokeAlign::Inside); + assert!(painted(&pill, 12.0, 10.0) && !painted(&pill, 27.0, 10.0)); + assert!(!painted(&pill, 24.0, 0.5) && !painted(&pill, 24.0, 19.5), "nothing past the outer edge"); +} + +#[test] +fn smoothed_corners_keep_the_stroke_inside_the_fill() { + let smooth = rounded_side_strokes(W, H, [16.0; 4], 0.6, [0.0, 0.0, 2.0, 0.0], StrokeAlign::Inside); + let fill = rounded_rect(W, H, [16.0; 4], 0.6); + let grid = (0..1200).flat_map(|i| (0..600).map(move |j| Point::new(f64::from(i) * 0.1 + 0.05, f64::from(j) * 0.1 + 0.05))); + let stray = grid.filter(|pt| painted(&smooth, pt.x, pt.y) && (pt.y < H / 2.0 || fill.winding(*pt) == 0)); + assert_eq!(stray.count(), 0, "painted outside the fill or along the top"); + assert!(painted(&smooth, 60.0, 59.5)); +} + +#[test] +fn center_and_outside_strokes_follow_smoothed_corners() { + for (align, out, inset) in [(StrokeAlign::Center, 1.0, 1.0), (StrokeAlign::Outside, 2.0, 0.0)] { + let a = area(&rounded_side_strokes(W, H, [16.0; 4], 0.6, [2.0; 4], align)); + let outer = area(&rounded_rect(W + 2.0 * out, H + 2.0 * out, [16.0 + out; 4], 0.6)); + let hole = area(&rounded_rect(W - 2.0 * inset, H - 2.0 * inset, [16.0 - inset; 4], 0.6)); + assert!((a - (outer - hole)).abs() < 1e-3, "{align:?}: {a} vs {}", outer - hole); + } +} + +#[test] +fn weights_wider_than_the_box_leave_the_outer_edge() { + let full = rounded_side_strokes(20.0, 10.0, [4.0; 4], 0.0, [6.0; 4], StrokeAlign::Inside); + assert!((area(&full) - area(&rounded_rect(20.0, 10.0, [4.0; 4], 0.0))).abs() < 1e-6); +} + +#[test] +fn no_weight_paints_nothing() { + assert!(rounded_side_strokes(W, H, [8.0; 4], 0.0, [0.0; 4], StrokeAlign::Inside).elements().is_empty()); + let junk = [f64::NAN, -4.0, f64::INFINITY, 0.0]; + assert!(rounded_side_strokes(W, H, [8.0; 4], 0.0, junk, StrokeAlign::Inside).elements().is_empty()); +} diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 48e44e88..4bd3590f 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -44,7 +44,7 @@ pub struct Manifest { pub main: String, /// The plugin API version the plugin was written for (1 when not given; panels and settings /// need 2, widgets 3). - #[serde(default = "api_version")] + #[serde(default = "api_version", deserialize_with = "api_from_number_or_string")] pub api: u32, #[serde(default)] pub permissions: Permissions, @@ -61,6 +61,25 @@ fn api_version() -> u32 { 1 } +/// `api` as a whole number or a version string (`"1.0.0"`, whose major part is the API version). +fn api_from_number_or_string<'de, D: serde::Deserializer<'de>>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Api { + Number(u32), + Text(String), + } + match Api::deserialize(d)? { + Api::Number(n) => Ok(n), + Api::Text(s) => s + .trim() + .split('.') + .next() + .and_then(|major| major.parse().ok()) + .ok_or_else(|| serde::de::Error::custom(format!("invalid api version `{s}`"))), + } +} + /// What a plugin may do. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Permissions { diff --git a/crates/plugin/src/tests.rs b/crates/plugin/src/tests.rs index 5d094ab4..a6e2a587 100644 --- a/crates/plugin/src/tests.rs +++ b/crates/plugin/src/tests.rs @@ -462,3 +462,12 @@ fn bad_manifests_are_refused() { assert!(Manifest::parse(junk).is_err(), "accepted {}", junk.chars().take(40).collect::()); } } + +#[test] +fn api_may_be_a_version_string() { + assert_eq!(manifest(json!({"api": "1.0.0"})).unwrap().api, 1); + assert_eq!(manifest(json!({"api": "2"})).unwrap().api, 2); + assert_eq!(manifest(json!({"api": 2})).unwrap().api, 2); + assert!(manifest(json!({"api": "4.0.0"})).is_err()); + assert!(manifest(json!({"api": "x"})).is_err()); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 63b38220..3718df26 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -162,9 +162,13 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { // Per-side strokes on rectangles/frames. if let Some(sides) = props.sides && matches!(n.kind, NodeKind::Rectangle | NodeKind::Frame | NodeKind::Component { .. } | NodeKind::Instance { .. }) - && n.corners.uniform() == Some(0.0) { - return Arc::new(figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align)); + let c = &n.corners; + return Arc::new(if c.uniform() == Some(0.0) { + figcraft_pathops::rect_side_strokes(n.width, n.height, sides, props.align) + } else { + figcraft_pathops::rounded_side_strokes(n.width, n.height, c.radii, c.smoothing, sides, props.align) + }); } // Rect/ellipse inside/outside strokes without booleans: an even-odd ring of two outlines. if props.dashes.is_empty() && props.profile().is_none() && props.brush.is_none() && closed && props.align != StrokeAlign::Center { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 55488153..a5a59831 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -129,6 +129,27 @@ fn strokes_align() { assert_eq!(px(&pix, 30, 8)[0], 0xF5); } +#[test] +fn side_strokes_follow_rounded_corners() { + // border-l-4 rounded-r-lg: a straight 4 px bar on the left, nothing on the other edges. + let mut d = figcraft_doc::Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 10.0, 10.0, 120.0, 60.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.fills = vec![Paint::solid(Color::WHITE)]; + n.corners.radii = [0.0, 8.0, 8.0, 0.0]; + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.align = StrokeAlign::Inside; + n.stroke.sides = Some([0.0, 0.0, 0.0, 4.0]); + let pix = render_view(&d, p, &view(150, 80), &Options::default()).unwrap(); + for x in 10..14 { + assert_eq!(px(&pix, x, 40), [0, 0, 0, 255], "x {x}"); + } + for (x, y) in [(14, 40), (129, 40), (126, 12), (126, 67), (70, 10), (70, 69)] { + assert_eq!(px(&pix, x, y), [255, 255, 255, 255], "({x}, {y})"); + } +} + #[test] fn mask_clips_siblings() { let mut d = figcraft_doc::Document::new("t"); diff --git a/crates/svg/src/import/mod.rs b/crates/svg/src/import/mod.rs index fdae7156..3aed363a 100644 --- a/crates/svg/src/import/mod.rs +++ b/crates/svg/src/import/mod.rs @@ -377,15 +377,38 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { return; } path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); - let mut network = VectorNetwork::from_path(&path); let rule = if fill.is_some_and(|f| f.rule() == usvg::FillRule::EvenOdd) { WindingRule::EvenOdd } else { WindingRule::NonZero }; - for r in &mut network.regions { - r.winding = rule; + let vector = |path: &BezPath, filled: bool| { + let mut network = VectorNetwork::from_path(path); + for r in &mut network.regions { + r.winding = rule; + } + if !filled { + network.regions.clear(); + } + NodeKind::Vector { network } + }; + let at = Affine::translate((bb.x0, bb.y0)); + // Fill closes every subpath; a stroke only those that close themselves. A filled + // and stroked path with a really open subpath becomes a fill layer and a stroke layer. + let tol = CLOSE_TOLERANCE * bb.width().max(bb.height()); + let (snapped, closed) = (closed_for_fill(&path, tol, false), closed_for_fill(&path, tol, true)); + if let (Some(_), Some(_)) = (fill, stroke) + && snapped != closed + { + let fill_only = + Node::new(cx.doc.alloc_id(), vector(&closed, true), format!("{} fill", layer_name(p.id(), "Vector")), bb.width(), bb.height()); + let stroke_only = Node::new(cx.doc.alloc_id(), vector(&snapped, false), layer_name(p.id(), "Vector"), bb.width(), bb.height()); + let mut layers = [paint_path(cx, p, fill_only, at, world, true, false), paint_path(cx, p, stroke_only, at, world, false, true)]; + if p.paint_order() == usvg::PaintOrder::StrokeAndFill { + layers.reverse(); + } + for n in layers { + cx.add(n, parent); + } + return; } - if fill.is_none() { - network.regions.clear(); - } - (NodeKind::Vector { network }, Affine::translate((bb.x0, bb.y0)), bb.width(), bb.height(), 0.0) + (vector(if fill.is_some() { &closed } else { &path }, fill.is_some()), at, bb.width(), bb.height(), 0.0) } }; let fallback = match kind { @@ -395,12 +418,18 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { _ => "Vector", }; let mut n = Node::new(cx.doc.alloc_id(), kind, layer_name(p.id(), fallback), w, h); - n.transform = transform; n.corners.radii = [radius; 4]; - let target = Target { to_node: transform.inverse() * world, size: (w, h) }; - n.fills = fill.and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect(); + let n = paint_path(cx, p, n, transform, world, true, true); + cx.add(n, parent); +} + +/// Layer `n` of path `p` placed at `transform`, with the path's fill and / or stroke. +fn paint_path(cx: &mut Ctx, p: &usvg::Path, mut n: Node, transform: Affine, world: Affine, with_fill: bool, with_stroke: bool) -> Node { + n.transform = transform; + let target = Target { to_node: transform.inverse() * world, size: (n.width, n.height) }; + n.fills = p.fill().filter(|_| with_fill).and_then(|f| paint::paint(cx, f.paint(), f.opacity().get(), target)).into_iter().collect(); n.strokes.clear(); - if let Some(s) = stroke { + if let Some(s) = p.stroke().filter(|_| with_stroke) { n.strokes = paint::paint(cx, s.paint(), s.opacity().get(), target).into_iter().collect(); n.stroke = stroke_props(s, length_scale(world)); // A stroke painted under the fill shows only its outer half. @@ -409,7 +438,55 @@ fn path(cx: &mut Ctx, p: &usvg::Path, parent: NodeId, base: Affine) { n.stroke.weight /= 2.0; } } - cx.add(n, parent); + n +} + +/// How far (as a share of the path's larger side) a subpath may end from its start and still be +/// closed onto it: exporters that round coordinates leave closed outlines this far apart. +const CLOSE_TOLERANCE: f64 = 1e-3; + +/// `path` with the subpaths that end within `tol` of their start closed onto it and, with `all` +/// (SVG fills every subpath as if closed), every other subpath that can enclose an area (two or +/// more segments, or one curve) closed with a straight segment. +fn closed_for_fill(path: &BezPath, tol: f64, all: bool) -> BezPath { + use figcraft_geom::kurbo::PathEl; + let mut out = BezPath::new(); + let mut sub: Vec = Vec::new(); + let flush = |sub: &mut Vec, out: &mut BezPath| { + let start = match sub.first() { + Some(PathEl::MoveTo(p)) => *p, + _ => { + sub.clear(); + return; + } + }; + let open = !matches!(sub.last(), Some(PathEl::ClosePath)); + let segments = sub.len() - 1; + let curve = matches!(sub.last(), Some(PathEl::QuadTo(..) | PathEl::CurveTo(..))); + if open && (segments >= 2 || curve) { + let near = sub.last().and_then(|e| e.end_point()).is_some_and(|end| (end - start).hypot() <= tol); + if near && let Some(last) = sub.last_mut() { + match last { + PathEl::LineTo(p) | PathEl::QuadTo(_, p) | PathEl::CurveTo(_, _, p) => *p = start, + PathEl::MoveTo(_) | PathEl::ClosePath => {} + } + } + if near || all { + sub.push(PathEl::ClosePath); + } + } + for e in sub.drain(..) { + out.push(e); + } + }; + for e in path.elements() { + if matches!(e, PathEl::MoveTo(_)) { + flush(&mut sub, &mut out); + } + sub.push(*e); + } + flush(&mut sub, &mut out); + out } /// A recognised primitive placed by `world`: its kind, transform, size and corner radius. `None` diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 55176aaf..656307d9 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -79,6 +79,8 @@ struct W<'a> { clip: Option, /// `id` attributes written so far. used_ids: std::collections::BTreeSet, + /// The next `_k` suffix to try per base id, so repeated names don't rescan from `_2`. + next_suffix: std::collections::HashMap, } /// ` clip-path="url(#…)"` for a clip path id (else nothing). @@ -150,8 +152,15 @@ fn blend_style(b: figcraft_color::BlendMode) -> String { } } +/// `s` escaped for XML text and attribute values; characters XML 1.0 forbids (control characters +/// other than tab, newline and carriage return, U+FFFE and U+FFFF) are left out. fn esc(s: &str) -> String { - s.replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) + s.chars().filter(|&c| xml_char(c)).collect::().replace('&', "&").replace('<', "<").replace('>', ">").replace('"', """) +} + +/// Whether XML 1.0 allows `c` (see [`esc`]). +fn xml_char(c: char) -> bool { + !matches!(c, '\u{0}'..='\u{8}' | '\u{b}' | '\u{c}' | '\u{e}'..='\u{1f}' | '\u{fffe}' | '\u{ffff}') } fn hex(c: Color) -> String { @@ -160,8 +169,13 @@ fn hex(c: Color) -> String { impl W<'_> { fn id(&mut self, prefix: &str) -> String { - self.next += 1; - format!("{prefix}{}", self.next) + loop { + self.next += 1; + let id = format!("{prefix}{}", self.next); + if self.used_ids.insert(id.clone()) { + return id; + } + } } /// `fill="..." [fill-opacity]` for a paint on a w×h box; `None` when it paints nothing. @@ -547,9 +561,19 @@ impl W<'_> { /// `name` as an `id` not used yet in this file (spaces as underscores; repeats get `_2`…). fn unique_id(&mut self, name: &str) -> String { - let base: String = name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).collect(); + // As written: what `esc` leaves out isn't part of it. + let base: String = + name.chars().take(200).map(|c| if c.is_whitespace() || c.is_control() { '_' } else { c }).filter(|&c| xml_char(c)).collect(); let base = if base.is_empty() { "layer".to_string() } else { base }; - let id = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}_{k}") }).find(|i| !self.used_ids.contains(i)).unwrap_or(base); + let next = self.next_suffix.entry(base.clone()).or_insert(1); + let id = loop { + let k = *next; + *next += 1; + let candidate = if k == 1 { base.clone() } else { format!("{base}_{k}") }; + if !self.used_ids.contains(&candidate) { + break candidate; + } + }; self.used_ids.insert(id.clone()); id } @@ -1004,6 +1028,7 @@ pub fn try_export_svg(doc: &Document, ids: &[NodeId], opts: &SvgOptions) -> Resu mask: None, clip: None, used_ids: Default::default(), + next_suffix: Default::default(), }; for id in ids { let parent_xf = doc.node(*id).and_then(|n| n.parent).map(|p| doc.world_transform(p)).unwrap_or(Affine::IDENTITY); diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index 8dece77f..dedeca3b 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -135,6 +135,54 @@ fn title_case_does_not_restart_words_across_fill_runs_in_live_text() { assert!(svg.contains(">He") && svg.contains(">l") && svg.contains(">lo"), "{svg}"); } +/// Control characters in text (a pasted vertical-tab soft break, U+0001) are not allowed in XML: +/// live-text export must leave them out so the SVG still parses, for plain and per-run spans. +#[test] +fn live_text_export_drops_xml_forbidden_control_characters() { + use figcraft_doc::{StyleRun, TextData}; + figcraft_text::scan_system_fonts(); + for styled in [false, true] { + let mut d = Document::new("t"); + let page = d.first_page().unwrap(); + let mut text = TextData::new("line one\u{b}line two\u{1}"); + text.style.font_size = 24.0; + if styled { + let mut style = text.style.clone(); + style.fills = Some(vec![Paint::solid(Color::from_hex("00FF00").unwrap())]); + text.runs.push(StyleRun { start: 5, end: 12, style }); + text.normalize(); + } + let id = d.create(page, NodeKind::Text(text), "Text", 0.0, 0.0, 600.0, 40.0).unwrap(); + d.get_mut(id).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + let svg = export_svg(&d, &[id], &SvgOptions { outline_text: false, ..SvgOptions::default() }).unwrap(); + assert!(!svg.contains('\u{b}') && !svg.contains('\u{1}'), "{svg:?}"); + assert!(svg.contains("line") && svg.contains("two"), "{svg}"); + let mut d2 = Document::new("t2"); + let p2 = d2.first_page().unwrap(); + assert!(import_svg(&mut d2, svg.as_bytes(), p2, 0.0, 0.0, "x").is_ok(), "{svg:?}"); + } +} + +#[test] +fn side_strokes_on_rounded_corners_export_as_their_area() { + // border-l-4 rounded-r-lg: the stroke path is the 4 px bar (60 px tall), drawn even-odd. + use figcraft_geom::Shape; + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let f = d.create(p, NodeKind::Frame, "F", 0.0, 0.0, 120.0, 60.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.corners.radii = [0.0, 8.0, 8.0, 0.0]; + n.strokes = vec![Paint::solid(Color::from_hex("FF0000").unwrap())]; + n.stroke.align = figcraft_doc::StrokeAlign::Inside; + n.stroke.sides = Some([0.0, 0.0, 0.0, 4.0]); + let svg = export_svg(&d, &[f], &SvgOptions::default()).unwrap(); + let stroke = svg.split(""##; + import_svg(&mut d, svg.as_bytes(), p, 0.0, 0.0, "x").unwrap(); + let loops = |n: &figcraft_doc::Node| match &n.kind { + NodeKind::Vector { network } => network.regions.iter().map(|r| r.loops.len()).sum::(), + _ => 0, + }; + assert_eq!(d.nodes().map(loops).sum::(), 1); +} diff --git a/crates/svg/tests/corpus.rs b/crates/svg/tests/corpus.rs index 35f6f9f4..24803c40 100644 --- a/crates/svg/tests/corpus.rs +++ b/crates/svg/tests/corpus.rs @@ -243,6 +243,27 @@ fn groups_keep_their_ids_and_transforms_bake_into_shapes() { assert_eq!(layer(&d, f, "d").kind.type_name(), "VECTOR"); } +#[test] +fn fills_close_open_subpaths() { + let (d, f) = imported("open_subpaths"); + let loops = |n: &figcraft_doc::Node| match &n.kind { + NodeKind::Vector { network } => network.regions.iter().map(|r| r.loops.len()).sum::(), + _ => 0, + }; + // One filled layer: all four subpaths are fill loops, the 0.001 gap and the triangle included. + let filled = layer(&d, f, "filled"); + assert_eq!((filled.kind.type_name(), loops(filled), filled.strokes.len()), ("VECTOR", 4, 0)); + // Filled and stroked with an open triangle: a fill layer under the stroke, which stays open. + let (fill, stroke) = (layer(&d, f, "stroked fill"), layer(&d, f, "stroked")); + assert_eq!((loops(fill), fill.fills.len(), fill.strokes.len()), (2, 1, 0)); + assert_eq!((loops(stroke), stroke.fills.len(), stroke.strokes.len()), (0, 0, 1)); + // Only near-closed ends: one layer, closed, stroke and all. + let near = layer(&d, f, "near"); + assert_eq!((loops(near), near.fills.len(), near.strokes.len()), (1, 1, 1)); + let order: Vec<&str> = d.node(f).unwrap().children.iter().filter_map(|c| d.node(*c)).map(|n| n.name.as_str()).collect(); + assert_eq!(order, ["filled", "stroked fill", "stroked", "near", "under", "under fill"], "paint order: fill then stroke, or the stroke first"); +} + #[test] fn basic_shapes_stay_rectangles_ellipses_and_lines() { let (d, f) = imported("primitives"); diff --git a/crates/svg/tests/corpus/open_subpaths.svg b/crates/svg/tests/corpus/open_subpaths.svg new file mode 100644 index 00000000..daed50b7 --- /dev/null +++ b/crates/svg/tests/corpus/open_subpaths.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 3f5458ea..52284d0b 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -19,6 +19,8 @@ enum Class { Hyphen, /// Everything else (letters, digits, other symbols): no break inside a run of them. Other, + /// The figure space: glues what's on either side, ideographs included. + Glue, } /// Opening brackets and quotes (line-end prohibited). @@ -39,7 +41,8 @@ fn class(c: char) -> Class { return Class::Close; } match c { - ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{200B}' => Class::Space, + ' ' | '\t' | '\u{3000}' | '\u{2002}'..='\u{2006}' | '\u{2008}'..='\u{200B}' => Class::Space, + '\u{2007}' => Class::Glue, '-' | '/' | '\u{2010}' | '\u{2013}' | '\u{2014}' => Class::Hyphen, '\u{2E80}'..='\u{2FFF}' | '\u{3003}'..='\u{33FF}' @@ -60,8 +63,8 @@ pub(crate) fn break_between(a: char, b: char) -> bool { use Class::*; match (class(a), class(b)) { // Spaces hang at the end of the line before them; kinsoku: no closing mark starts a line, - // no opening mark ends one. - (_, Space | Close) | (Open, _) => false, + // no opening mark ends one; glue holds on both sides. + (_, Space | Close | Glue) | (Open | Glue, _) => false, (Space | Hyphen | Ideo, _) | (_, Ideo) => true, _ => false, } @@ -98,3 +101,16 @@ pub fn grapheme_step(text: &str, index: usize, forward: bool) -> usize { } at.min(index) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn figure_space_is_non_breaking() { + assert!(!break_between('\u{2007}', '0')); + assert!(!break_between('1', '\u{2007}')); + assert!(!break_between('日', '\u{2007}') && !break_between('\u{2007}', '日'), "nor beside ideographs"); + assert!(!is_space('\u{2007}')); + } +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index 26ae3d33..f844b571 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -1035,6 +1035,18 @@ fn is_emoji(c: char) -> bool { matches!(c, '\u{1F1E6}'..='\u{1F1FF}' | '\u{1F300}'..='\u{1FAFF}') } +/// The lower case of the capital sigma at `i`: the final form ς or σ, chosen from the letters +/// around it in the whole text (as lower-casing the whole string would), not from the lone char. +fn lower_sigma(chars: &[char], i: usize) -> char { + // Enough context to step over any realistic run of combining marks next to the sigma. + const CONTEXT: usize = 32; + let before: String = chars.get(i.saturating_sub(CONTEXT)..i).unwrap_or(&[]).iter().collect(); + let after: String = chars.iter().skip(i + 1).take(CONTEXT).collect(); + let lower = format!("{before}\u{3A3}{after}").to_lowercase(); + // Only the sigma's lower case depends on context, so the text before it keeps its length. + lower.get(before.to_lowercase().len()..).and_then(|s| s.chars().next()).unwrap_or('σ') +} + /// One run of chars shaped with a single face and style. struct Segment<'a> { start: usize, @@ -1065,6 +1077,8 @@ fn shape_segment(chars: &[char], seg: &Segment, face: &Arc, features: &[Fe for u in c.to_uppercase() { mapped.push((u, src, true)); } + } else if style.case == TextCase::Lower && *c == 'Σ' { + mapped.push((lower_sigma(chars, src), src, false)); } else { let mut buf = [0u8; 4]; for u in style.case.apply_after(c.encode_utf8(&mut buf), prev).chars() { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index ebf5889c..4475fbbc 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -497,3 +497,25 @@ fn the_caret_after_a_line_break_sits_on_the_next_line() { let l = layout(&TextData::new("ab\n"), None, None); assert_eq!(l.caret(3), (0.0, 1)); } + +#[test] +fn lower_case_writes_final_sigma() { + if !have_fonts() { + return; + } + let gids = |t: &TextData| layout(t, None, None).glyphs.iter().map(|g| (g.gid, g.char_start)).collect::>(); + let want = gids(&TextData::new("οδος ας")); + let Some(face) = layout(&TextData::new("ς"), None, None).glyphs.first().map(|g| g.face.clone()) else { return }; + if face.glyph_for('σ') == face.glyph_for('ς') { + eprintln!("face has no distinct final sigma; skipping"); + return; + } + let mut t = TextData::new("ΟΔΟΣ ΑΣ"); + t.style.case = figcraft_doc::TextCase::Lower; + assert_eq!(gids(&t), want, "one run"); + // The word split across two styled runs: the final form is still chosen from the whole text. + let mut other = t.style.clone(); + other.font_size += 4.0; + t.runs.push(figcraft_doc::StyleRun { start: 3, end: 4, style: other }); + assert_eq!(gids(&t).iter().map(|g| g.0).collect::>(), want.iter().map(|g| g.0).collect::>(), "split runs"); +} diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 6223c501..decd782f 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -55,6 +55,7 @@ status = "draft — needs native review" "Add library file…" = "লাইব্রেরি ফাইল যোগ করুন..." "Add max height" = "সর্বোচ্চ উচ্চতা যোগ করুন" "Add max width" = "সর্বোচ্চ প্রস্থ যোগ করুন" +"Add measurement" = "মাপ যোগ করুন" "Add min height" = "সর্বনিম্ন উচ্চতা যোগ করুন" "Add min width" = "সর্বনিম্ন প্রস্থ যোগ করুন" "Add mode" = "মোড যোগ করুন" @@ -94,6 +95,10 @@ status = "draft — needs native review" "All supported properties" = "সমর্থিত সব প্রপার্টি" "All the fonts this file uses are installed" = "এই ফাইলে ব্যবহৃত সব ফন্ট ইনস্টল করা আছে" "All variables" = "সব ভেরিয়েবল" +"Along the bottom" = "নিচের কিনারা বরাবর" +"Along the left" = "বাম কিনারা বরাবর" +"Along the right" = "ডান কিনারা বরাবর" +"Along the top" = "উপরের কিনারা বরাবর" "Alpha" = "আলফা" "Alphabetical" = "বর্ণানুক্রমিক" "Alt+drag" = "Alt+ড্র্যাগ" @@ -158,6 +163,7 @@ status = "draft — needs native review" "Basics" = "মৌলিক বিষয়" "Before" = "আগে" "Bend" = "বাঁক" +"Between the selected layers" = "নির্বাচিত লেয়ারগুলোর মাঝে" "Bevel" = "বেভেল" "Binary assets added" = "বাইনারি অ্যাসেট যোগ করা হয়েছে" "Binary assets removed" = "বাইনারি অ্যাসেট সরানো হয়েছে" @@ -253,7 +259,6 @@ status = "draft — needs native review" "Close tab" = "ট্যাব বন্ধ করুন" "Close version history" = "সংস্করণ ইতিহাস বন্ধ করুন" "Close when clicking outside" = "বাইরে ক্লিক করলে বন্ধ করুন" -"Close without saving" = "সেভ না করেই বন্ধ করুন" "Code" = "কোড" "Code syntax" = "কোড সিনট্যাক্স" "Collapse" = "সংকুচিত করুন" @@ -410,6 +415,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "অনুভূমিক ব্যবধান বিতরণ করুন" "Distribute vertical spacing" = "উল্লম্ব ব্যবধান বিতরণ করুন" "Divider" = "ডিভাইডার" +"Don't Save" = "সংরক্ষণ করবেন না" "Don't truncate" = "ট্রাঙ্কেট করবেন না" "Done" = "সম্পন্ন" "Done editing" = "সম্পাদনা শেষ" @@ -684,6 +690,7 @@ status = "draft — needs native review" "Jun" = "জুন" "Just now" = "এইমাত্র" "Justify" = "উভয়মুখী সারিবদ্ধকরণ (Justify)" +"Keep a measurement on the canvas for developers" = "ডেভেলপারদের জন্য ক্যানভাসে একটি মাপ রাখুন" "Keep appearance" = "চেহারা অপরিবর্তিত রাখুন" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "চেহারা অপরিবর্তিত রাখলে প্রতিটি রঙের মান {profile}-এ রূপান্তরিত হয় যাতে রঙগুলো একই দেখায়। মান অপরিবর্তিত রাখলে সংখ্যাগুলো যেমন আছে তেমনই থাকে, ফলে রঙ পরিবর্তিত হয়।" "Keep tool selected after use" = "ব্যবহারের পরেও টুল নির্বাচিত রাখুন" @@ -773,6 +780,7 @@ status = "draft — needs native review" "Maximize" = "বড় করুন" "May" = "মে" "Measure to the selection" = "নির্বাচন পর্যন্ত পরিমাপ করুন" +"Measurements" = "মাপ" "Merge into main" = "প্রধান ফাইলের সাথে মার্জ করুন" "Merge regions" = "অঞ্চলগুলো মার্জ করুন" "Merge the branch into the main file (one undo step) and archive it" = "ব্রাঞ্চটিকে প্রধান ফাইলের সাথে মার্জ করুন (একটি আনডু ধাপ) এবং আর্কাইভ করুন" @@ -1088,6 +1096,7 @@ status = "draft — needs native review" "Remove guide" = "গাইড সরান" "Remove interaction" = "ইন্টারঅ্যাকশন সরান" "Remove library from this file" = "এই ফাইল থেকে লাইব্রেরি সরান" +"Remove measurement" = "মাপ সরান" "Remove min and max" = "সর্বনিম্ন এবং সর্বোচ্চ সরান" "Remove min and max height" = "সর্বনিম্ন এবং সর্বোচ্চ উচ্চতা সরান" "Remove min and max width" = "সর্বনিম্ন এবং সর্বোচ্চ প্রস্থ সরান" @@ -1421,6 +1430,7 @@ status = "draft — needs native review" "Title" = "শিরোনাম" "Title case" = "টাইটেল কেস" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "আপনার টিমের সাথে এই লাইব্রেরি শেয়ার করতে, ফাইলটি একটি সার্ভারে প্রকাশ করুন (Share ▸ Publish to server…), এরপর সেখানে লাইব্রেরি প্রকাশ করুন।" +"To the layer around it" = "চারপাশের লেয়ার পর্যন্ত" "Toggle" = "টগল" "Toggle light/dark theme" = "লাইট/ডার্ক থিম টগল করুন" "Toggle visibility" = "দৃশ্যমানতা টগল করুন" @@ -1456,7 +1466,7 @@ status = "draft — needs native review" "Unread" = "অপঠিত" "Unresolve" = "অমীমাংসিত করুন" "Unsaved changes" = "সংরক্ষিত হয়নি এমন পরিবর্তন" -"Unsaved changes in {names}. Close the window and lose them?" = "{names}-এ সংরক্ষিত হয়নি এমন পরিবর্তন রয়েছে। উইন্ডোটি বন্ধ করলে কি সেগুলো হারিয়ে যাবে?" +"Unsaved changes in {names}. Save them before closing?" = "{names}-এ সংরক্ষিত হয়নি এমন পরিবর্তন রয়েছে। বন্ধ করার আগে সেগুলো সংরক্ষণ করবেন?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন ({save}), অথবা বাতিল করতে {alt}-ক্লিক করে ট্যাব বন্ধ করুন" "Unsaved changes: save first, or close again with {key} to discard" = "সংরক্ষিত হয়নি এমন পরিবর্তন: প্রথমে সেভ করুন, অথবা বাতিল করতে {key} দিয়ে আবার বন্ধ করুন" "Up" = "উপরে" @@ -1778,6 +1788,9 @@ status = "draft — needs native review" "board.shape" = "লেখাসহ আকৃতি" "board.stamp" = "স্ট্যাম্প" "board.sticky" = "স্টিকি নোট" +"devMode.addMeasurement" = "মাপ যোগ করুন" +"devMode.measurements" = "মাপের তালিকা" +"devMode.removeMeasurement" = "মাপ সরান" "file.newBoard" = "নতুন বোর্ড" "tool.connector" = "সংযোগকারী" "tool.eraser" = "ইরেজার" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 899b3fcf..670e5f7c 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -51,6 +51,7 @@ status = "draft — needs native review" "Add library file…" = "Přidat soubor knihovny…" "Add max height" = "Přidat maximální výšku" "Add max width" = "Přidat maximální šířku" +"Add measurement" = "Přidat měření" "Add min height" = "Přidat minimální výšku" "Add min width" = "Přidat minimální šířku" "Add mode" = "Přidat režim" @@ -88,6 +89,10 @@ status = "draft — needs native review" "All fonts" = "Všechna písma" "All supported properties" = "Všechny podporované vlastnosti" "All variables" = "Všechny proměnné" +"Along the bottom" = "Podél dolní hrany" +"Along the left" = "Podél levé hrany" +"Along the right" = "Podél pravé hrany" +"Along the top" = "Podél horní hrany" "Alpha" = "Alfa" "Alphabetical" = "Abecedně" "Alt+drag" = "Alt+tažení" @@ -152,6 +157,7 @@ status = "draft — needs native review" "Basics" = "Základy" "Before" = "Před" "Bend" = "Ohnout" +"Between the selected layers" = "Mezi vybranými vrstvami" "Bevel" = "Zkosený" "Binary assets added" = "Přidané binární soubory" "Binary assets removed" = "Odstraněné binární soubory" @@ -243,7 +249,6 @@ status = "draft — needs native review" "Close tab" = "Zavřít kartu" "Close version history" = "Zavřít historii verzí" "Close when clicking outside" = "Zavřít při kliknutí mimo" -"Close without saving" = "Zavřít bez uložení" "Code" = "Kód" "Code syntax" = "Syntaxe kódu" "Collapse" = "Sbalit" @@ -400,6 +405,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Rozmístit vodorovné rozestupy" "Distribute vertical spacing" = "Rozmístit svislé rozestupy" "Divider" = "Oddělovač" +"Don't Save" = "Neukládat" "Don't truncate" = "Nezkracovat" "Done" = "Hotovo" "Done editing" = "Dokončit úpravy" @@ -669,6 +675,7 @@ status = "draft — needs native review" "Jun" = "června" "Just now" = "Právě teď" "Justify" = "Do bloku" +"Keep a measurement on the canvas for developers" = "Ponechat měření na plátně pro vývojáře" "Keep appearance" = "Zachovat vzhled" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachovat vzhled převede všechny hodnoty barev do profilu {profile}, aby barvy vypadaly stejně. Zachovat hodnoty ponechá čísla beze změny, takže se barvy posunou." "Keep tool selected after use" = "Po použití ponechat nástroj vybraný" @@ -758,6 +765,7 @@ status = "draft — needs native review" "Maximize" = "Maximalizovat" "May" = "května" "Measure to the selection" = "Měřit k výběru" +"Measurements" = "Měření" "Merge into main" = "Sloučit do hlavního souboru" "Merge regions" = "Sloučit oblasti" "Merge the branch into the main file (one undo step) and archive it" = "Sloučit větev do hlavního souboru (jeden krok zpět) a archivovat ji" @@ -909,6 +917,7 @@ status = "draft — needs native review" "Open branch" = "Otevřít větev" "Open file" = "Otevřít soubor" "Open files" = "Otevřít soubory" +"Open presentations" = "Otevřené prezentace" "Open in focus view" = "Otevřít v zaměřeném pohledu" "Open in new window" = "Otevřít v novém okně" "Open invite links in FigCraft" = "Otevírat odkazy s pozvánkou v aplikaci FigCraft" @@ -1066,6 +1075,7 @@ status = "draft — needs native review" "Remove guide" = "Odebrat vodítko" "Remove interaction" = "Odebrat interakci" "Remove library from this file" = "Odebrat knihovnu z tohoto souboru" +"Remove measurement" = "Odebrat měření" "Remove min and max" = "Odebrat minimum a maximum" "Remove min and max height" = "Odebrat minimální a maximální výšku" "Remove min and max width" = "Odebrat minimální a maximální šířku" @@ -1391,6 +1401,7 @@ status = "draft — needs native review" "Title" = "Název" "Title case" = "Velká počáteční písmena slov" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Chcete-li sdílet tuto knihovnu s týmem, publikujte soubor na server (Sdílet ▸ Publikovat na server…) a pak tam publikujte knihovnu." +"To the layer around it" = "K vrstvě, která ji obsahuje" "Toggle" = "Přepnout" "Toggle light/dark theme" = "Přepnout světlý/tmavý motiv" "Toggle visibility" = "Přepnout viditelnost" @@ -1425,7 +1436,7 @@ status = "draft — needs native review" "Unread" = "Nepřečtené" "Unresolve" = "Znovu otevřít" "Unsaved changes" = "Neuložené změny" -"Unsaved changes in {names}. Close the window and lose them?" = "Neuložené změny v {names}. Zavřít okno a zahodit je?" +"Unsaved changes in {names}. Save them before closing?" = "Neuložené změny v {names}. Uložit je před zavřením?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Neuložené změny: nejprve uložte ({save}) nebo zavřete kartu kliknutím s {alt} a zahoďte je" "Unsaved changes: save first, or close again with {key} to discard" = "Neuložené změny: nejprve uložte nebo zavřete znovu s {key} a zahoďte je" "Up" = "Nahoru" @@ -1841,6 +1852,9 @@ status = "draft — needs native review" "dev.focus" = "Zaměřený pohled" "dev.resolveChange" = "Vyřešit změnu" "dev.unfocus" = "Opustit zaměřený pohled" +"devMode.addMeasurement" = "Přidat měření" +"devMode.measurements" = "Vypsat měření" +"devMode.removeMeasurement" = "Odebrat měření" "devResource.add" = "Přidat zdroj pro vývojáře" "devResource.edit" = "Upravit zdroj pro vývojáře" "devResource.list" = "Vypsat zdroje pro vývojáře" @@ -2243,11 +2257,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Nástroj úprav vektoru" "vector.edit.transform" = "Transformovat body" "vector.edit.widthPoint" = "Bod šířky" +"vector.fillRule" = "Nastavit pravidlo výplně" "vector.flatten" = "Sloučit do vektoru" "vector.join" = "Spojit výběr" "vector.offset" = "Odsadit cestu" "vector.outlineStroke" = "Převést tah na obrysy" "vector.reverse" = "Obrátit směr cesty" +"vector.reverseLoop" = "Obrátit smyčku" "vector.simplify" = "Zjednodušit vektor" "vector.smoothJoin" = "Hladce spojit výběr" "version.autosave" = "Automaticky uložit verzi" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index b8527ed4..eeaf902b 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -52,6 +52,7 @@ status = "draft — needs native review" "Add library file…" = "Bibliotheksdatei hinzufügen…" "Add max height" = "Max. Höhe hinzufügen" "Add max width" = "Max. Breite hinzufügen" +"Add measurement" = "Messung hinzufügen" "Add min height" = "Min. Höhe hinzufügen" "Add min width" = "Min. Breite hinzufügen" "Add mode" = "Modus hinzufügen" @@ -90,6 +91,10 @@ status = "draft — needs native review" "All fonts" = "Alle Schriften" "All supported properties" = "Alle unterstützten Eigenschaften" "All variables" = "Alle Variablen" +"Along the bottom" = "Entlang der Unterkante" +"Along the left" = "Entlang der linken Kante" +"Along the right" = "Entlang der rechten Kante" +"Along the top" = "Entlang der Oberkante" "Alpha" = "Alpha" "Alt+drag" = "Alt+Ziehen" "An invite link that opens FigCraft and joins" = "Ein Einladungslink, der FigCraft öffnet und beitritt" @@ -149,6 +154,7 @@ status = "draft — needs native review" "Basics" = "Grundlagen" "Before" = "Vorher" "Bend" = "Biegen" +"Between the selected layers" = "Zwischen den ausgewählten Ebenen" "Bevel" = "Abgeflacht" "Binary assets added" = "Binärdateien hinzugefügt" "Binary assets removed" = "Binärdateien entfernt" @@ -238,7 +244,6 @@ status = "draft — needs native review" "Close tab" = "Tab schließen" "Close version history" = "Versionsverlauf schließen" "Close when clicking outside" = "Bei Klick außerhalb schließen" -"Close without saving" = "Ohne Speichern schließen" "Code" = "Code" "Code syntax" = "Code-Syntax" "Collapse" = "Einklappen" @@ -394,6 +399,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Horizontale Abstände verteilen" "Distribute vertical spacing" = "Vertikale Abstände verteilen" "Divider" = "Trennlinie" +"Don't Save" = "Nicht speichern" "Don't truncate" = "Nicht kürzen" "Done" = "Fertig" "Done editing" = "Bearbeitung beenden" @@ -653,6 +659,7 @@ status = "draft — needs native review" "Join|stroke" = "Ecke" "Just now" = "Gerade eben" "Justify" = "Blocksatz" +"Keep a measurement on the canvas for developers" = "Eine Messung für Entwickler auf der Arbeitsfläche behalten" "Keep appearance" = "Aussehen beibehalten" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„Aussehen beibehalten“ wandelt jeden Farbwert in {profile} um, damit die Farben gleich aussehen. „Werte beibehalten“ lässt die Zahlen unverändert, sodass sich die Farben verschieben." "Keep tool selected after use" = "Werkzeug nach Verwendung ausgewählt lassen" @@ -735,6 +742,7 @@ status = "draft — needs native review" "Max lines" = "Max. Zeilen" "Maximize" = "Maximieren" "Measure to the selection" = "Bis zur Auswahl messen" +"Measurements" = "Messungen" "Merge into main" = "In Hauptdatei zusammenführen" "Merge regions" = "Bereiche vereinen" "Merge the branch into the main file (one undo step) and archive it" = "Führt den Branch in die Hauptdatei zusammen (ein Rückgängig-Schritt) und archiviert ihn" @@ -880,6 +888,7 @@ status = "draft — needs native review" "Open branch" = "Branch öffnen" "Open file" = "Datei öffnen" "Open files" = "Geöffnete Dateien" +"Open presentations" = "Geöffnete Präsentationen" "Open in focus view" = "In Fokusansicht öffnen" "Open in new window" = "In neuem Fenster öffnen" "Open invite links in FigCraft" = "Einladungslinks in FigCraft öffnen" @@ -1034,6 +1043,7 @@ status = "draft — needs native review" "Remove guide" = "Hilfslinie entfernen" "Remove interaction" = "Interaktion entfernen" "Remove library from this file" = "Bibliothek aus dieser Datei entfernen" +"Remove measurement" = "Messung entfernen" "Remove min and max" = "Min. und Max. entfernen" "Remove min and max height" = "Min. und max. Höhe entfernen" "Remove min and max width" = "Min. und max. Breite entfernen" @@ -1352,6 +1362,7 @@ status = "draft — needs native review" "Title" = "Titel" "Title case" = "Großschreibung am Wortanfang" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Um diese Bibliothek mit Ihrem Team zu teilen, veröffentlichen Sie die Datei auf einem Server (Teilen ▸ Auf Server veröffentlichen…) und veröffentlichen Sie dort dann die Bibliothek." +"To the layer around it" = "Bis zur umgebenden Ebene" "Toggle" = "Umschalten" "Toggle light/dark theme" = "Zwischen hellem und dunklem Design wechseln" "Toggle visibility" = "Sichtbarkeit umschalten" @@ -1384,7 +1395,7 @@ status = "draft — needs native review" "Unread" = "Ungelesen" "Unresolve" = "Wieder öffnen" "Unsaved changes" = "Nicht gespeicherte Änderungen" -"Unsaved changes in {names}. Close the window and lose them?" = "Nicht gespeicherte Änderungen in {names}. Fenster schließen und Änderungen verwerfen?" +"Unsaved changes in {names}. Save them before closing?" = "Nicht gespeicherte Änderungen in {names}. Vor dem Schließen speichern?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Nicht gespeicherte Änderungen: Speichern Sie zuerst ({save}) oder schließen Sie den Tab mit {alt}+Klick, um sie zu verwerfen" "Unsaved changes: save first, or close again with {key} to discard" = "Nicht gespeicherte Änderungen: Speichern Sie zuerst oder schließen Sie erneut mit {key}, um sie zu verwerfen" "Up" = "Nach oben" @@ -1796,6 +1807,9 @@ status = "draft — needs native review" "dev.focus" = "Fokusansicht" "dev.resolveChange" = "Änderung erledigen" "dev.unfocus" = "Fokusansicht verlassen" +"devMode.addMeasurement" = "Messung hinzufügen" +"devMode.measurements" = "Messungen auflisten" +"devMode.removeMeasurement" = "Messung entfernen" "devResource.add" = "Entwicklerressource hinzufügen" "devResource.edit" = "Entwicklerressource bearbeiten" "devResource.list" = "Entwicklerressourcen auflisten" @@ -2195,11 +2209,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Vektorbearbeitungswerkzeug" "vector.edit.transform" = "Punkte transformieren" "vector.edit.widthPoint" = "Breitenpunkt" +"vector.fillRule" = "Füllregel festlegen" "vector.flatten" = "Zusammenfügen" "vector.join" = "Auswahl verbinden" "vector.offset" = "Pfad versetzen" "vector.outlineStroke" = "Kontur in Pfad umwandeln" "vector.reverse" = "Pfadrichtung umkehren" +"vector.reverseLoop" = "Schleife umdrehen" "vector.simplify" = "Vektor vereinfachen" "vector.smoothJoin" = "Auswahl glatt verbinden" "version.autosave" = "Version automatisch speichern" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index c1fb2333..365fa129 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -54,6 +54,7 @@ name = "English" "Add library file…" = "Add library file…" "Add max height" = "Add max height" "Add max width" = "Add max width" +"Add measurement" = "Add measurement" "Add min height" = "Add min height" "Add min width" = "Add min width" "Add mode" = "Add mode" @@ -93,6 +94,10 @@ name = "English" "All supported properties" = "All supported properties" "All the fonts this file uses are installed" = "All the fonts this file uses are installed" "All variables" = "All variables" +"Along the bottom" = "Along the bottom" +"Along the left" = "Along the left" +"Along the right" = "Along the right" +"Along the top" = "Along the top" "Alpha" = "Alpha" "Alphabetical" = "Alphabetical" "Alt+drag" = "Alt+drag" @@ -157,6 +162,7 @@ name = "English" "Basics" = "Basics" "Before" = "Before" "Bend" = "Bend" +"Between the selected layers" = "Between the selected layers" "Bevel" = "Bevel" "Binary assets added" = "Binary assets added" "Binary assets removed" = "Binary assets removed" @@ -253,7 +259,6 @@ name = "English" "Close tab" = "Close tab" "Close version history" = "Close version history" "Close when clicking outside" = "Close when clicking outside" -"Close without saving" = "Close without saving" "Code" = "Code" "Code syntax" = "Code syntax" "Collapse" = "Collapse" @@ -413,6 +418,7 @@ name = "English" "Distribute horizontal spacing" = "Distribute horizontal spacing" "Distribute vertical spacing" = "Distribute vertical spacing" "Divider" = "Divider" +"Don't Save" = "Don't Save" "Don't truncate" = "Don't truncate" "Done" = "Done" "Done editing" = "Done editing" @@ -689,6 +695,7 @@ name = "English" "Jun" = "Jun" "Just now" = "Just now" "Justify" = "Justify" +"Keep a measurement on the canvas for developers" = "Keep a measurement on the canvas for developers" "Keep appearance" = "Keep appearance" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." "Keep tool selected after use" = "Keep tool selected after use" @@ -779,6 +786,7 @@ name = "English" "Maximize" = "Maximize" "May" = "May" "Measure to the selection" = "Measure to the selection" +"Measurements" = "Measurements" "Merge into main" = "Merge into main" "Merge regions" = "Merge regions" "Merge the branch into the main file (one undo step) and archive it" = "Merge the branch into the main file (one undo step) and archive it" @@ -934,6 +942,7 @@ name = "English" "Open branch" = "Open branch" "Open file" = "Open file" "Open files" = "Open files" +"Open presentations" = "Open presentations" "Open in focus view" = "Open in focus view" "Open in new window" = "Open in new window" "Open invite links in FigCraft" = "Open invite links in FigCraft" @@ -1097,6 +1106,7 @@ name = "English" "Remove guide" = "Remove guide" "Remove interaction" = "Remove interaction" "Remove library from this file" = "Remove library from this file" +"Remove measurement" = "Remove measurement" "Remove min and max" = "Remove min and max" "Remove min and max height" = "Remove min and max height" "Remove min and max width" = "Remove min and max width" @@ -1439,6 +1449,7 @@ name = "English" "Title" = "Title" "Title case" = "Title case" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." +"To the layer around it" = "To the layer around it" "Toggle" = "Toggle" "Toggle light/dark theme" = "Toggle light/dark theme" "Toggle visibility" = "Toggle visibility" @@ -1475,7 +1486,7 @@ name = "English" "Unread" = "Unread" "Unresolve" = "Unresolve" "Unsaved changes" = "Unsaved changes" -"Unsaved changes in {names}. Close the window and lose them?" = "Unsaved changes in {names}. Close the window and lose them?" +"Unsaved changes in {names}. Save them before closing?" = "Unsaved changes in {names}. Save them before closing?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" "Unsaved changes: save first, or close again with {key} to discard" = "Unsaved changes: save first, or close again with {key} to discard" "Up" = "Up" diff --git a/crates/ui-egui/locales/es-419.toml b/crates/ui-egui/locales/es-419.toml index 7756fba7..6ef81633 100644 --- a/crates/ui-egui/locales/es-419.toml +++ b/crates/ui-egui/locales/es-419.toml @@ -35,6 +35,7 @@ status = "draft — needs native review" "Add library file…" = "Agregar archivo de biblioteca…" "Add max height" = "Agregar alto máximo" "Add max width" = "Agregar ancho máximo" +"Add measurement" = "Agregar medición" "Add min height" = "Agregar alto mínimo" "Add min width" = "Agregar ancho mínimo" "Add mode" = "Agregar modo" @@ -95,6 +96,7 @@ status = "draft — needs native review" "comment.add" = "Agregar comentario" "component.addProperty" = "Agregar propiedad de componente" "component.addVariant" = "Agregar variante" +"devMode.addMeasurement" = "Agregar medición" "devResource.add" = "Agregar recurso de desarrollo" "effect.add" = "Agregar efecto" "export.add" = "Agregar configuración de exportación" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 9da68641..771d2b8e 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,7 +8,13 @@ name = "Español" status = "draft — needs native review" [ui] +"Add measurement" = "Añadir medición" +"Along the bottom" = "A lo largo del borde inferior" +"Along the left" = "A lo largo del borde izquierdo" +"Along the right" = "A lo largo del borde derecho" +"Along the top" = "A lo largo del borde superior" "Arrow at the end" = "Flecha al final" +"Between the selected layers" = "Entre las capas seleccionadas" "Check" = "Visto" "Chevron" = "Cheurón" "Compatibility notes" = "Notas de compatibilidad" @@ -254,7 +260,6 @@ status = "draft — needs native review" "Close tab" = "Cerrar pestaña" "Close version history" = "Cerrar historial de versiones" "Close when clicking outside" = "Cerrar al hacer clic fuera" -"Close without saving" = "Cerrar sin guardar" "Code" = "Código" "Code syntax" = "Sintaxis de código" "Collapse" = "Contraer" @@ -414,6 +419,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Distribuir espacio horizontal" "Distribute vertical spacing" = "Distribuir espacio vertical" "Divider" = "Separador" +"Don't Save" = "No guardar" "Don't truncate" = "No truncar" "Done" = "Listo" "Done editing" = "Terminar edición" @@ -690,6 +696,7 @@ status = "draft — needs native review" "Jun" = "jun" "Just now" = "Justo ahora" "Justify" = "Justificar" +"Keep a measurement on the canvas for developers" = "Mantén una medición en el lienzo para desarrolladores" "Keep appearance" = "Mantener apariencia" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Mantener apariencia convierte cada valor de color a {profile} para que los colores se vean igual. Mantener valores deja los números como están, así que los colores cambian." "Keep tool selected after use" = "Mantener la herramienta tras usarla" @@ -780,6 +787,7 @@ status = "draft — needs native review" "Maximize" = "Maximizar" "May" = "may" "Measure to the selection" = "Medir hasta la selección" +"Measurements" = "Mediciones" "Merge into main" = "Fusionar en la principal" "Merge regions" = "Fusionar regiones" "Merge the branch into the main file (one undo step) and archive it" = "Fusiona la rama en el archivo principal (un paso de deshacer) y la archiva" @@ -935,6 +943,7 @@ status = "draft — needs native review" "Open branch" = "Abrir rama" "Open file" = "Abrir archivo" "Open files" = "Archivos abiertos" +"Open presentations" = "Presentaciones abiertas" "Open in focus view" = "Abrir en vista de enfoque" "Open in new window" = "Abrir en una ventana nueva" "Open invite links in FigCraft" = "Abrir enlaces de invitación en FigCraft" @@ -1098,6 +1107,7 @@ status = "draft — needs native review" "Remove guide" = "Quitar guía" "Remove interaction" = "Quitar interacción" "Remove library from this file" = "Quitar biblioteca de este archivo" +"Remove measurement" = "Quitar medición" "Remove min and max" = "Quitar mín. y máx." "Remove min and max height" = "Quitar alto mín. y máx." "Remove min and max width" = "Quitar ancho mín. y máx." @@ -1440,6 +1450,7 @@ status = "draft — needs native review" "Title" = "Título" "Title case" = "Tipo título" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Para compartir esta biblioteca con tu equipo, publica el archivo en un servidor (Compartir ▸ Publicar en servidor…) y luego publica allí la biblioteca." +"To the layer around it" = "Hasta la capa que la contiene" "Toggle" = "Activar/desactivar" "Toggle light/dark theme" = "Cambiar tema claro/oscuro" "Toggle visibility" = "Mostrar/ocultar" @@ -1476,7 +1487,7 @@ status = "draft — needs native review" "Unread" = "No leídos" "Unresolve" = "Reabrir" "Unsaved changes" = "Cambios sin guardar" -"Unsaved changes in {names}. Close the window and lose them?" = "Hay cambios sin guardar en {names}. ¿Cerrar la ventana y perderlos?" +"Unsaved changes in {names}. Save them before closing?" = "Hay cambios sin guardar en {names}. ¿Guardarlos antes de cerrar?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Cambios sin guardar: guarda primero ({save}) o cierra la pestaña con {alt}+clic para descartarlos" "Unsaved changes: save first, or close again with {key} to discard" = "Cambios sin guardar: guarda primero o vuelve a cerrar con {key} para descartarlos" "Up" = "Arriba" @@ -1905,6 +1916,9 @@ status = "draft — needs native review" "dev.focus" = "Vista de enfoque" "dev.resolveChange" = "Resolver cambio" "dev.unfocus" = "Salir de la vista de enfoque" +"devMode.addMeasurement" = "Añadir medición" +"devMode.measurements" = "Listar mediciones" +"devMode.removeMeasurement" = "Quitar medición" "devResource.add" = "Añadir recurso de desarrollo" "devResource.edit" = "Editar recurso de desarrollo" "devResource.list" = "Listar recursos de desarrollo" @@ -2315,11 +2329,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Herramienta de edición vectorial" "vector.edit.transform" = "Transformar puntos" "vector.edit.widthPoint" = "Punto de ancho" +"vector.fillRule" = "Establecer regla de relleno" "vector.flatten" = "Acoplar" "vector.join" = "Unir selección" "vector.offset" = "Desplazar trazado" "vector.outlineStroke" = "Contornear trazo" "vector.reverse" = "Invertir dirección del trazado" +"vector.reverseLoop" = "Invertir bucle" "vector.simplify" = "Simplificar vector" "vector.smoothJoin" = "Unir selección con suavizado" "version.autosave" = "Guardar versión automáticamente" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 91beb2e8..a1d4d530 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -56,6 +56,7 @@ status = "draft — needs native review" "Add library file…" = "Lisää kirjastotiedosto…" "Add max height" = "Lisää enimmäiskorkeus" "Add max width" = "Lisää enimmäisleveys" +"Add measurement" = "Lisää mittaus" "Add min height" = "Lisää vähimmäiskorkeus" "Add min width" = "Lisää vähimmäisleveys" "Add mode" = "Lisää tila" @@ -95,6 +96,10 @@ status = "draft — needs native review" "All supported properties" = "Kaikki tuetut ominaisuudet" "All the fonts this file uses are installed" = "Kaikki tämän tiedoston käyttämät fontit on asennettu" "All variables" = "Kaikki muuttujat" +"Along the bottom" = "Alareunaa pitkin" +"Along the left" = "Vasenta reunaa pitkin" +"Along the right" = "Oikeaa reunaa pitkin" +"Along the top" = "Yläreunaa pitkin" "Alpha" = "Alfa" "Alphabetical" = "Aakkosjärjestys" "Alt+drag" = "Alt+drag" @@ -159,6 +164,7 @@ status = "draft — needs native review" "Basics" = "Perusteet" "Before" = "Ennen" "Bend" = "Taivutus" +"Between the selected layers" = "Valittujen tasojen välillä" "Bevel" = "Viiste" "Binary assets added" = "Lisätyt binääriresurssit" "Binary assets removed" = "Poistetut binääriresurssit" @@ -248,7 +254,6 @@ status = "draft — needs native review" "Close tab" = "Sulje välilehti" "Close version history" = "Sulje versiohistoria" "Close when clicking outside" = "Sulje napsautettaessa ulkopuolelle" -"Close without saving" = "Sulje tallentamatta" "Code" = "Koodi" "Code or invite link" = "Koodi tai kutsulinkki" "Code syntax" = "Koodin syntaksi" @@ -412,6 +417,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Jaa vaakavälit tasan" "Distribute vertical spacing" = "Jaa pystyvälit tasan" "Divider" = "Erotin" +"Don't Save" = "Älä tallenna" "Don't truncate" = "Älä lyhennä" "Done" = "Valmis" "Done editing" = "Lopeta muokkaus" @@ -686,6 +692,7 @@ status = "draft — needs native review" "Jun" = "kesäk." "Just now" = "Juuri nyt" "Justify" = "Tasaa molemmat reunat" +"Keep a measurement on the canvas for developers" = "Säilytä mittaus piirtoalueella kehittäjille" "Keep appearance" = "Säilytä ulkoasu" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Säilytä ulkoasu muuntaa kaikki väriarvot profiiliin {profile}, jotta värit näyttävät samoilta. Säilytä arvot jättää lukuarvot ennalleen, jolloin värit muuttuvat." "Keep tool selected after use" = "Pidä työkalu valittuna käytön jälkeen" @@ -775,6 +782,7 @@ status = "draft — needs native review" "Maximize" = "Suurenna" "May" = "toukok." "Measure to the selection" = "Mittaa valintaan" +"Measurements" = "Mittaukset" "Merge into main" = "Yhdistä päähaaraan" "Merge regions" = "Yhdistä alueet" "Merge the branch into the main file (one undo step) and archive it" = "Yhdistä haara päätiedostoon (yksi kumottava toiminto) ja arkistoi se" @@ -929,6 +937,7 @@ status = "draft — needs native review" "Open branch" = "Avaa haara" "Open file" = "Avaa tiedosto" "Open files" = "Avaa tiedostoja" +"Open presentations" = "Avoimet esitykset" "Open in focus view" = "Avaa kohdistusnäkymässä" "Open in new window" = "Avaa uudessa ikkunassa" "Open invite links in FigCraft" = "Avaa kutsulinkit FigCraftissa" @@ -1091,6 +1100,7 @@ status = "draft — needs native review" "Remove guide" = "Poista apuviiva" "Remove interaction" = "Poista vuorovaikutus" "Remove library from this file" = "Poista kirjasto tästä tiedostosta" +"Remove measurement" = "Poista mittaus" "Remove min and max" = "Poista vähimmäis- ja enimmäisrajat" "Remove min and max height" = "Poista korkeuden vähimmäis- ja enimmäisrajat" "Remove min and max width" = "Poista leveyden vähimmäis- ja enimmäisrajat" @@ -1425,6 +1435,7 @@ status = "draft — needs native review" "Title" = "Otsikko" "Title case" = "Sanojen isot alkukirjaimet" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Jaa tämä kirjasto tiimillesi julkaisemalla tiedosto palvelimelle (Jaa ▸ Julkaise palvelimelle…) ja julkaisemalla kirjasto siellä." +"To the layer around it" = "Ympäröivään tasoon" "Toggle" = "Vaihda tila" "Toggle light/dark theme" = "Vaihda vaalea/tumma teema" "Toggle visibility" = "Vaihda näkyvyys" @@ -1460,7 +1471,7 @@ status = "draft — needs native review" "Unread" = "Lukematon" "Unresolve" = "Merkitse ratkaisemattomaksi" "Unsaved changes" = "Tallentamattomat muutokset" -"Unsaved changes in {names}. Close the window and lose them?" = "Tallentamattomia muutoksia kohteissa {names}. Suljetaanko ikkuna ja hylätään ne?" +"Unsaved changes in {names}. Save them before closing?" = "Tallentamattomia muutoksia kohteissa {names}. Tallennetaanko ne ennen sulkemista?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Tallentamattomia muutoksia: tallenna ensin ({save}) tai hylkää sulkemalla välilehti {alt}-napsautuksella" "Unsaved changes: save first, or close again with {key} to discard" = "Tallentamattomia muutoksia: tallenna ensin tai hylkää sulkemalla uudelleen näppäimellä {key}" "Up" = "Ylös" @@ -1884,6 +1895,9 @@ status = "draft — needs native review" "dev.focus" = "Kohdistusnäkymä" "dev.resolveChange" = "Ratkaise muutos" "dev.unfocus" = "Poistu kohdistusnäkymästä" +"devMode.addMeasurement" = "Lisää mittaus" +"devMode.measurements" = "Luettele mittaukset" +"devMode.removeMeasurement" = "Poista mittaus" "devResource.add" = "Lisää kehittäjäresurssi" "devResource.edit" = "Muokkaa kehittäjäresurssia" "devResource.list" = "Luettele kehittäjäresurssit" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 3da1067d..e601c398 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -55,6 +55,7 @@ status = "draft — needs native review" "Add library file…" = "Ajouter un fichier de bibliothèque…" "Add max height" = "Ajouter une hauteur max." "Add max width" = "Ajouter une largeur max." +"Add measurement" = "Ajouter une mesure" "Add min height" = "Ajouter une hauteur min." "Add min width" = "Ajouter une largeur min." "Add mode" = "Ajouter un mode" @@ -93,6 +94,10 @@ status = "draft — needs native review" "All fonts" = "Toutes les polices" "All supported properties" = "Toutes les propriétés prises en charge" "All variables" = "Toutes les variables" +"Along the bottom" = "Le long du bord inférieur" +"Along the left" = "Le long du bord gauche" +"Along the right" = "Le long du bord droit" +"Along the top" = "Le long du bord supérieur" "Alpha" = "Alpha" "Alphabetical" = "Alphabétique" "Alt+drag" = "Alt+glisser" @@ -157,6 +162,7 @@ status = "draft — needs native review" "Basics" = "Essentiel" "Before" = "Avant" "Bend" = "Courber" +"Between the selected layers" = "Entre les calques sélectionnés" "Bevel" = "Biseau" "Binary assets added" = "Ressources binaires ajoutées" "Binary assets removed" = "Ressources binaires supprimées" @@ -247,7 +253,6 @@ status = "draft — needs native review" "Close tab" = "Fermer l’onglet" "Close version history" = "Fermer l’historique des versions" "Close when clicking outside" = "Fermer en cliquant à l’extérieur" -"Close without saving" = "Fermer sans enregistrer" "Code" = "Code" "Code syntax" = "Syntaxe du code" "Collapse" = "Réduire" @@ -411,6 +416,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Répartir l’espacement horizontal" "Distribute vertical spacing" = "Répartir l’espacement vertical" "Divider" = "Séparateur" +"Don't Save" = "Ne pas enregistrer" "Don't truncate" = "Ne pas tronquer" "Done" = "Terminé" "Done editing" = "Terminer la modification" @@ -686,6 +692,7 @@ status = "draft — needs native review" "Jun" = "juin" "Just now" = "À l’instant" "Justify" = "Justifier" +"Keep a measurement on the canvas for developers" = "Garder une mesure sur le canevas pour les développeurs" "Keep appearance" = "Conserver l’apparence" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Conserver l’apparence convertit chaque valeur de couleur en {profile} pour que les couleurs restent identiques. Conserver les valeurs laisse les nombres tels quels, si bien que les couleurs changent." "Keep tool selected after use" = "Garder l’outil sélectionné après usage" @@ -775,6 +782,7 @@ status = "draft — needs native review" "Maximize" = "Agrandir" "May" = "mai" "Measure to the selection" = "Mesurer jusqu’à la sélection" +"Measurements" = "Mesures" "Merge into main" = "Fusionner dans le principal" "Merge regions" = "Fusionner les régions" "Merge the branch into the main file (one undo step) and archive it" = "Fusionne la branche dans le fichier principal (une seule étape d’annulation) et l’archive" @@ -928,6 +936,7 @@ status = "draft — needs native review" "Open branch" = "Ouvrir la branche" "Open file" = "Ouvrir un fichier" "Open files" = "Fichiers ouverts" +"Open presentations" = "Présentations ouvertes" "Open in focus view" = "Ouvrir en vue ciblée" "Open in new window" = "Ouvrir dans une nouvelle fenêtre" "Open invite links in FigCraft" = "Ouvrir les liens d’invitation dans FigCraft" @@ -1091,6 +1100,7 @@ status = "draft — needs native review" "Remove guide" = "Retirer le repère" "Remove interaction" = "Retirer l’interaction" "Remove library from this file" = "Retirer la bibliothèque de ce fichier" +"Remove measurement" = "Retirer la mesure" "Remove min and max" = "Retirer min. et max." "Remove min and max height" = "Retirer la hauteur min. et max." "Remove min and max width" = "Retirer la largeur min. et max." @@ -1426,6 +1436,7 @@ status = "draft — needs native review" "Title" = "Titre" "Title case" = "Casse de titre" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Pour partager cette bibliothèque avec votre équipe, publiez le fichier sur un serveur (Partager ▸ Publier sur un serveur…), puis publiez-y la bibliothèque." +"To the layer around it" = "Jusqu’au calque qui le contient" "Toggle" = "Activer/désactiver" "Toggle light/dark theme" = "Basculer entre thème clair et sombre" "Toggle visibility" = "Afficher/masquer" @@ -1460,7 +1471,7 @@ status = "draft — needs native review" "Unread" = "Non lus" "Unresolve" = "Rouvrir" "Unsaved changes" = "Modifications non enregistrées" -"Unsaved changes in {names}. Close the window and lose them?" = "Modifications non enregistrées dans {names}. Fermer la fenêtre et les perdre ?" +"Unsaved changes in {names}. Save them before closing?" = "Modifications non enregistrées dans {names}. Les enregistrer avant de fermer ?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Modifications non enregistrées : enregistrez d’abord ({save}) ou fermez l’onglet avec {alt}+clic pour les abandonner" "Unsaved changes: save first, or close again with {key} to discard" = "Modifications non enregistrées : enregistrez d’abord ou fermez à nouveau avec {key} pour les abandonner" "Up" = "Haut" @@ -1885,6 +1896,9 @@ status = "draft — needs native review" "dev.focus" = "Vue ciblée" "dev.resolveChange" = "Résoudre la modification" "dev.unfocus" = "Quitter la vue ciblée" +"devMode.addMeasurement" = "Ajouter une mesure" +"devMode.measurements" = "Lister les mesures" +"devMode.removeMeasurement" = "Retirer la mesure" "devResource.add" = "Ajouter une ressource de développement" "devResource.edit" = "Modifier la ressource de développement" "devResource.list" = "Lister les ressources de développement" @@ -2292,11 +2306,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Outil de modification vectorielle" "vector.edit.transform" = "Transformer les points" "vector.edit.widthPoint" = "Point de largeur" +"vector.fillRule" = "Définir la règle de remplissage" "vector.flatten" = "Aplatir" "vector.join" = "Joindre la sélection" "vector.offset" = "Décaler le tracé" "vector.outlineStroke" = "Convertir le contour en tracé" "vector.reverse" = "Inverser le sens du tracé" +"vector.reverseLoop" = "Inverser la boucle" "vector.simplify" = "Simplifier le vecteur" "vector.smoothJoin" = "Joindre la sélection en lissant" "version.autosave" = "Enregistrer automatiquement une version" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index d588fa96..5c532888 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -55,6 +55,7 @@ status = "draft — needs native review" "Add library file…" = "Tambah file pustaka…" "Add max height" = "Tambah tinggi maks" "Add max width" = "Tambah lebar maks" +"Add measurement" = "Tambah pengukuran" "Add min height" = "Tambah tinggi min" "Add min width" = "Tambah lebar min" "Add mode" = "Tambah mode" @@ -92,6 +93,10 @@ status = "draft — needs native review" "All fonts" = "Semua font" "All supported properties" = "Semua properti yang didukung" "All variables" = "Semua variabel" +"Along the bottom" = "Sepanjang sisi bawah" +"Along the left" = "Sepanjang sisi kiri" +"Along the right" = "Sepanjang sisi kanan" +"Along the top" = "Sepanjang sisi atas" "Alpha" = "Alfa" "Alphabetical" = "Alfabetis" "Alt+drag" = "Alt+seret" @@ -156,6 +161,7 @@ status = "draft — needs native review" "Basics" = "Dasar" "Before" = "Sebelum" "Bend" = "Lengkungan" +"Between the selected layers" = "Di antara layer yang dipilih" "Bevel" = "Bevel" "Binary assets added" = "Aset biner ditambahkan" "Binary assets removed" = "Aset biner dihapus" @@ -247,7 +253,6 @@ status = "draft — needs native review" "Close tab" = "Tutup tab" "Close version history" = "Tutup riwayat versi" "Close when clicking outside" = "Tutup saat klik di luar" -"Close without saving" = "Tutup tanpa menyimpan" "Code" = "Kode" "Code syntax" = "Sintaks kode" "Collapse" = "Ciutkan" @@ -404,6 +409,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Sebarkan jarak horizontal" "Distribute vertical spacing" = "Sebarkan jarak vertikal" "Divider" = "Pembatas" +"Don't Save" = "Jangan simpan" "Don't truncate" = "Jangan potong" "Done" = "Selesai" "Done editing" = "Selesai mengedit" @@ -673,6 +679,7 @@ status = "draft — needs native review" "Jun" = "Jun" "Just now" = "Baru saja" "Justify" = "Rata kiri-kanan" +"Keep a measurement on the canvas for developers" = "Simpan pengukuran di kanvas untuk developer" "Keep appearance" = "Pertahankan tampilan" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Pertahankan tampilan mengonversi setiap nilai warna ke {profile} agar warna terlihat sama. Pertahankan nilai membiarkan angkanya apa adanya, sehingga warna bergeser." "Keep tool selected after use" = "Tetap pilih alat setelah dipakai" @@ -762,6 +769,7 @@ status = "draft — needs native review" "Maximize" = "Maksimalkan" "May" = "Mei" "Measure to the selection" = "Ukur ke pilihan" +"Measurements" = "Pengukuran" "Merge into main" = "Merge ke main" "Merge regions" = "Gabungkan area" "Merge the branch into the main file (one undo step) and archive it" = "Merge branch ke file utama (satu langkah urungkan) lalu arsipkan" @@ -913,6 +921,7 @@ status = "draft — needs native review" "Open branch" = "Buka branch" "Open file" = "Buka file" "Open files" = "Buka file" +"Open presentations" = "Presentasi yang terbuka" "Open in focus view" = "Buka di tampilan fokus" "Open in new window" = "Buka di jendela baru" "Open invite links in FigCraft" = "Buka link undangan di FigCraft" @@ -1070,6 +1079,7 @@ status = "draft — needs native review" "Remove guide" = "Hapus garis bantu" "Remove interaction" = "Hapus interaksi" "Remove library from this file" = "Hapus pustaka dari file ini" +"Remove measurement" = "Hapus pengukuran" "Remove min and max" = "Hapus min dan maks" "Remove min and max height" = "Hapus tinggi min dan maks" "Remove min and max width" = "Hapus lebar min dan maks" @@ -1395,6 +1405,7 @@ status = "draft — needs native review" "Title" = "Judul" "Title case" = "Huruf Awal Kapital" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Untuk membagikan pustaka ini kepada tim Anda, publikasikan file ke server (Bagikan ▸ Publikasikan ke server…), lalu publikasikan pustaka di sana." +"To the layer around it" = "Ke layer yang membungkusnya" "Toggle" = "Alihkan" "Toggle light/dark theme" = "Alihkan tema terang/gelap" "Toggle visibility" = "Alihkan visibilitas" @@ -1429,7 +1440,7 @@ status = "draft — needs native review" "Unread" = "Belum dibaca" "Unresolve" = "Buka kembali" "Unsaved changes" = "Perubahan belum disimpan" -"Unsaved changes in {names}. Close the window and lose them?" = "Perubahan belum disimpan di {names}. Tutup jendela dan buang perubahan?" +"Unsaved changes in {names}. Save them before closing?" = "Perubahan belum disimpan di {names}. Simpan sebelum menutup?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Perubahan belum disimpan: simpan dulu ({save}), atau tutup tab dengan {alt}-klik untuk membuangnya" "Unsaved changes: save first, or close again with {key} to discard" = "Perubahan belum disimpan: simpan dulu, atau tutup lagi dengan {key} untuk membuangnya" "Up" = "Atas" @@ -1845,6 +1856,9 @@ status = "draft — needs native review" "dev.focus" = "Tampilan fokus" "dev.resolveChange" = "Selesaikan perubahan" "dev.unfocus" = "Keluar dari tampilan fokus" +"devMode.addMeasurement" = "Tambah pengukuran" +"devMode.measurements" = "Daftar pengukuran" +"devMode.removeMeasurement" = "Hapus pengukuran" "devResource.add" = "Tambah sumber daya dev" "devResource.edit" = "Edit sumber daya dev" "devResource.list" = "Daftar sumber daya dev" @@ -2247,11 +2261,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Alat edit vector" "vector.edit.transform" = "Transformasi titik" "vector.edit.widthPoint" = "Titik lebar" +"vector.fillRule" = "Atur aturan isian" "vector.flatten" = "Satukan" "vector.join" = "Gabungkan pilihan" "vector.offset" = "Offset path" "vector.outlineStroke" = "Outline stroke" "vector.reverse" = "Balik arah path" +"vector.reverseLoop" = "Balik loop" "vector.simplify" = "Sederhanakan vector" "vector.smoothJoin" = "Gabungkan pilihan dengan halus" "version.autosave" = "Simpan versi otomatis" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 2a19c00d..897415eb 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -57,6 +57,7 @@ status = "draft — needs native review" "Add library file…" = "ライブラリファイルを追加…" "Add max height" = "最大の高さを追加" "Add max width" = "最大の幅を追加" +"Add measurement" = "測定を追加" "Add min height" = "最小の高さを追加" "Add min width" = "最小の幅を追加" "Add mode" = "モードを追加" @@ -94,6 +95,10 @@ status = "draft — needs native review" "All fonts" = "すべてのフォント" "All supported properties" = "サポートされているすべてのプロパティ" "All variables" = "すべての変数" +"Along the bottom" = "下辺に沿って" +"Along the left" = "左辺に沿って" +"Along the right" = "右辺に沿って" +"Along the top" = "上辺に沿って" "Alpha" = "アルファ" "Alphabetical" = "名前順" "Alt+drag" = "Alt+ドラッグ" @@ -158,6 +163,7 @@ status = "draft — needs native review" "Basics" = "基本" "Before" = "変更前" "Bend" = "曲げ" +"Between the selected layers" = "選択したレイヤーの間" "Bevel" = "ベベル" "Binary assets added" = "追加されたバイナリアセット" "Binary assets removed" = "削除されたバイナリアセット" @@ -248,7 +254,6 @@ status = "draft — needs native review" "Close tab" = "タブを閉じる" "Close version history" = "バージョン履歴を閉じる" "Close when clicking outside" = "外側をクリックしたら閉じる" -"Close without saving" = "保存せずに閉じる" "Code" = "コード" "Code syntax" = "コード構文" "Collapse" = "折りたたむ" @@ -409,6 +414,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "水平方向に等間隔に分布" "Distribute vertical spacing" = "垂直方向に等間隔に分布" "Divider" = "区切り線" +"Don't Save" = "保存しない" "Don't truncate" = "切り詰めない" "Done" = "完了" "Done editing" = "編集を完了" @@ -680,6 +686,7 @@ status = "draft — needs native review" "Jun" = "6月" "Just now" = "たった今" "Justify" = "両端揃え" +"Keep a measurement on the canvas for developers" = "開発者向けに測定をキャンバスに残す" "Keep appearance" = "見た目を保持" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "「見た目を保持」はすべてのカラー値を {profile} に変換し、色の見た目を保つ。「値を保持」は数値をそのまま残すため、色が変わる。" "Keep tool selected after use" = "使用後もツールを選択したままにする" @@ -769,6 +776,7 @@ status = "draft — needs native review" "Maximize" = "最大化" "May" = "5月" "Measure to the selection" = "選択範囲までの距離を測定" +"Measurements" = "測定" "Merge into main" = "メインにマージ" "Merge regions" = "領域を結合" "Merge the branch into the main file (one undo step) and archive it" = "ブランチをメインファイルにマージし(1 回の取り消し単位)、アーカイブする" @@ -921,6 +929,7 @@ status = "draft — needs native review" "Open branch" = "ブランチを開く" "Open file" = "ファイルを開く" "Open files" = "開いているファイル" +"Open presentations" = "開いているプレゼンテーション" "Open in focus view" = "フォーカスビューで開く" "Open in new window" = "新しいウィンドウで開く" "Open invite links in FigCraft" = "招待リンクを FigCraft で開く" @@ -1078,6 +1087,7 @@ status = "draft — needs native review" "Remove guide" = "ガイドを削除" "Remove interaction" = "インタラクションを削除" "Remove library from this file" = "このファイルからライブラリを削除" +"Remove measurement" = "測定を削除" "Remove min and max" = "最小と最大を削除" "Remove min and max height" = "最小と最大の高さを削除" "Remove min and max width" = "最小と最大の幅を削除" @@ -1409,6 +1419,7 @@ status = "draft — needs native review" "Title" = "タイトル" "Title case" = "タイトルケース" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "このライブラリをチームと共有するには、ファイルをサーバーに公開し(共有 ▸ サーバーに公開…)、そこでライブラリを公開する。" +"To the layer around it" = "親レイヤーまで" "Toggle" = "切り替え" "Toggle light/dark theme" = "ライト/ダークテーマを切り替え" "Toggle visibility" = "表示を切り替え" @@ -1443,7 +1454,7 @@ status = "draft — needs native review" "Unread" = "未読" "Unresolve" = "未解決に戻す" "Unsaved changes" = "未保存の変更" -"Unsaved changes in {names}. Close the window and lose them?" = "{names} に未保存の変更がある。ウィンドウを閉じて変更を破棄する?" +"Unsaved changes in {names}. Save them before closing?" = "{names} に未保存の変更がある。閉じる前に保存する?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "未保存の変更:先に保存する({save})か、{alt}-クリックでタブを閉じて破棄" "Unsaved changes: save first, or close again with {key} to discard" = "未保存の変更:先に保存するか、もう一度 {key} で閉じて破棄" "Up" = "上" @@ -1865,6 +1876,9 @@ status = "draft — needs native review" "dev.focus" = "フォーカスビュー" "dev.resolveChange" = "変更を解決" "dev.unfocus" = "フォーカスビューを終了" +"devMode.addMeasurement" = "測定を追加" +"devMode.measurements" = "測定を一覧表示" +"devMode.removeMeasurement" = "測定を削除" "devResource.add" = "開発リソースを追加" "devResource.edit" = "開発リソースを編集" "devResource.list" = "開発リソースを一覧表示" @@ -2270,11 +2284,13 @@ status = "draft — needs native review" "vector.edit.tool" = "ベクター編集ツール" "vector.edit.transform" = "ポイントを変形" "vector.edit.widthPoint" = "幅のポイント" +"vector.fillRule" = "塗りつぶし規則を設定" "vector.flatten" = "平坦化" "vector.join" = "選択範囲を連結" "vector.offset" = "パスのオフセット" "vector.outlineStroke" = "線のアウトライン化" "vector.reverse" = "パスの方向を反転" +"vector.reverseLoop" = "ループを反転" "vector.simplify" = "ベクターを単純化" "vector.smoothJoin" = "選択範囲を滑らかに連結" "version.autosave" = "自動保存バージョン" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 83a15ac3..d2b3fda0 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -57,6 +57,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Add library file…" = "ბიბლიოთეკის ფაილის დამატება…" "Add max height" = "მაქსიმალური სიმაღლის დამატება" "Add max width" = "მაქსიმალური სიგანის დამატება" +"Add measurement" = "გაზომვის დამატება" "Add min height" = "მინიმალური სიმაღლის დამატება" "Add min width" = "მინიმალური სიგანის დამატება" "Add mode" = "რეჟიმის დამატება" @@ -96,6 +97,10 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "All supported properties" = "ყველა მხარდაჭერილი თვისება" "All the fonts this file uses are installed" = "ამ ფაილის ყველა შრიფტი დაინსტალირებულია" "All variables" = "ყველა ცვლადი" +"Along the bottom" = "ქვედა კიდის გასწვრივ" +"Along the left" = "მარცხენა კიდის გასწვრივ" +"Along the right" = "მარჯვენა კიდის გასწვრივ" +"Along the top" = "ზედა კიდის გასწვრივ" "Alpha" = "ალფა" "Alphabetical" = "ანბანის მიხედვით" "Alt+drag" = "Alt+გადათრევა" @@ -160,6 +165,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Basics" = "საფუძვლები" "Before" = "მანამდე" "Bend" = "მოხრა" +"Between the selected layers" = "მონიშნულ შრეებს შორის" "Bevel" = "წაკვეთილი" "Binary assets added" = "დამატებული ბინარული ფაილები" "Binary assets removed" = "წაშლილი ბინარული ფაილები" @@ -255,7 +261,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Close tab" = "ჩანართის დახურვა" "Close version history" = "ვერსიების ისტორიის დახურვა" "Close when clicking outside" = "დახურვა გარეთ დაწკაპუნებისას" -"Close without saving" = "დახურვა შენახვის გარეშე" "Code" = "კოდი" "Code syntax" = "კოდის სინტაქსი" "Collapse" = "ჩაკეცვა" @@ -412,6 +417,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Distribute horizontal spacing" = "ჰორიზონტალური შუალედების განაწილება" "Distribute vertical spacing" = "ვერტიკალური შუალედების განაწილება" "Divider" = "გამყოფი" +"Don't Save" = "არ შეინახო" "Don't truncate" = "შეკვეცის გარეშე" "Done" = "მზადაა" "Done editing" = "რედაქტირების დასრულება" @@ -686,6 +692,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Jun" = "ივნ" "Just now" = "ახლახან" "Justify" = "ორმხრივი სწორება" +"Keep a measurement on the canvas for developers" = "გაზომვის შენახვა ტილოზე დეველოპერებისთვის" "Keep appearance" = "იერსახის შენარჩუნება" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "„იერსახის შენარჩუნება“ ყველა ფერის მნიშვნელობას {profile}-ში გარდაქმნის, რომ ფერები იგივენაირად გამოჩნდეს. „მნიშვნელობების შენარჩუნება“ რიცხვებს უცვლელად ტოვებს, ამიტომ ფერები იცვლება." "Keep tool selected after use" = "ხელსაწყოს არჩეულად დატოვება გამოყენების შემდეგ" @@ -775,6 +782,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Maximize" = "გაშლა" "May" = "მაი" "Measure to the selection" = "მონიშნულამდე გაზომვა" +"Measurements" = "გაზომვები" "Merge into main" = "მთავარში შერწყმა" "Merge regions" = "არეების შერწყმა" "Merge the branch into the main file (one undo step) and archive it" = "განშტოების მთავარ ფაილთან შერწყმა (დაბრუნების ერთი ნაბიჯი) და მისი დაარქივება" @@ -1090,6 +1098,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Remove guide" = "დამხმარე ხაზის მოცილება" "Remove interaction" = "ურთიერთქმედების წაშლა" "Remove library from this file" = "ბიბლიოთეკის ამ ფაილიდან ამოშლა" +"Remove measurement" = "გაზომვის წაშლა" "Remove min and max" = "მინ. და მაქს. მოხსნა" "Remove min and max height" = "მინ. და მაქს. სიმაღლის მოხსნა" "Remove min and max width" = "მინ. და მაქს. სიგანის მოხსნა" @@ -1423,6 +1432,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Title" = "დასახელება" "Title case" = "სათაურის რეგისტრი" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "ამ ბიბლიოთეკის გუნდთან გასაზიარებლად გამოაქვეყნეთ ფაილი სერვერზე (გაზიარება ▸ სერვერზე გამოქვეყნება…), შემდეგ იქ გამოაქვეყნეთ ბიბლიოთეკა." +"To the layer around it" = "გარშემო მყოფ შრემდე" "Toggle" = "გადართვა" "Toggle light/dark theme" = "ღია/ბნელი თემის გადართვა" "Toggle visibility" = "ხილვადობის ჩართვა/გამორთვა" @@ -1458,7 +1468,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Unread" = "წაუკითხავი" "Unresolve" = "ხელახლა გახსნა" "Unsaved changes" = "შეუნახავი ცვლილებები" -"Unsaved changes in {names}. Close the window and lose them?" = "შეუნახავი ცვლილებები: {names}. დაიხუროს ფანჯარა და დაიკარგოს ისინი?" +"Unsaved changes in {names}. Save them before closing?" = "შეუნახავი ცვლილებები: {names}. შევინახოთ დახურვამდე?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "შეუნახავი ცვლილებები: ჯერ შეინახეთ ({save}) ან გასაუქმებლად დახურეთ ჩანართი {alt}+დაწკაპუნებით" "Unsaved changes: save first, or close again with {key} to discard" = "შეუნახავი ცვლილებები: ჯერ შეინახეთ ან გასაუქმებლად ხელახლა დახურეთ {key}-ით" "Up" = "ზემოთ" @@ -1881,6 +1891,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "dev.focus" = "ფოკუსის ხედი" "dev.resolveChange" = "ცვლილების გადაწყვეტა" "dev.unfocus" = "ფოკუსის ხედიდან გასვლა" +"devMode.addMeasurement" = "გაზომვის დამატება" +"devMode.measurements" = "გაზომვების სია" +"devMode.removeMeasurement" = "გაზომვის წაშლა" "devResource.add" = "დეველოპერის რესურსის დამატება" "devResource.edit" = "დეველოპერის რესურსის რედაქტირება" "devResource.list" = "დეველოპერის რესურსების სია" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index d83aea66..8afca040 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -53,6 +53,7 @@ status = "draft — needs native review" "Add library file…" = "라이브러리 파일 추가…" "Add max height" = "최대 높이 추가" "Add max width" = "최대 너비 추가" +"Add measurement" = "측정 추가" "Add min height" = "최소 높이 추가" "Add min width" = "최소 너비 추가" "Add mode" = "모드 추가" @@ -90,6 +91,10 @@ status = "draft — needs native review" "All fonts" = "모든 글꼴" "All supported properties" = "지원되는 모든 속성" "All variables" = "모든 변수" +"Along the bottom" = "아래쪽 가장자리를 따라" +"Along the left" = "왼쪽 가장자리를 따라" +"Along the right" = "오른쪽 가장자리를 따라" +"Along the top" = "위쪽 가장자리를 따라" "Alpha" = "알파" "Alt+drag" = "Alt+드래그" "An invite link that opens FigCraft and joins" = "FigCraft를 열고 참여하는 초대 링크" @@ -149,6 +154,7 @@ status = "draft — needs native review" "Basics" = "기본" "Before" = "변경 전" "Bend" = "구부리기" +"Between the selected layers" = "선택한 레이어 사이" "Bevel" = "베벨" "Binary assets added" = "추가된 바이너리 에셋" "Binary assets removed" = "제거된 바이너리 에셋" @@ -238,7 +244,6 @@ status = "draft — needs native review" "Close tab" = "탭 닫기" "Close version history" = "버전 기록 닫기" "Close when clicking outside" = "바깥쪽을 클릭하면 닫기" -"Close without saving" = "저장하지 않고 닫기" "Code" = "코드" "Code syntax" = "코드 구문" "Collapse" = "접기" @@ -393,6 +398,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "수평 간격 균등 분포" "Distribute vertical spacing" = "수직 간격 균등 분포" "Divider" = "구분선" +"Don't Save" = "저장 안 함" "Don't truncate" = "자르지 않음" "Done" = "완료" "Done editing" = "편집 완료" @@ -649,6 +655,7 @@ status = "draft — needs native review" "Join|stroke" = "선 연결" "Just now" = "방금" "Justify" = "양쪽 정렬" +"Keep a measurement on the canvas for developers" = "개발자를 위해 캔버스에 측정 유지" "Keep appearance" = "모양 유지" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "\"모양 유지\"는 모든 색상 값을 {profile}(으)로 변환해 색이 똑같아 보이게 함. \"값 유지\"는 숫자를 그대로 두므로 색이 달라짐." "Keep tool selected after use" = "사용 후에도 도구 선택 유지" @@ -731,6 +738,7 @@ status = "draft — needs native review" "Max lines" = "최대 줄 수" "Maximize" = "최대화" "Measure to the selection" = "선택 항목까지 측정" +"Measurements" = "측정" "Merge into main" = "메인에 병합" "Merge regions" = "영역 병합" "Merge the branch into the main file (one undo step) and archive it" = "브랜치를 메인 파일에 병합(실행 취소 한 번)하고 보관" @@ -876,6 +884,7 @@ status = "draft — needs native review" "Open branch" = "브랜치 열기" "Open file" = "파일 열기" "Open files" = "열린 파일" +"Open presentations" = "열린 프레젠테이션" "Open in focus view" = "포커스 보기로 열기" "Open in new window" = "새 창에서 열기" "Open invite links in FigCraft" = "초대 링크를 FigCraft에서 열기" @@ -1028,6 +1037,7 @@ status = "draft — needs native review" "Remove guide" = "안내선 제거" "Remove interaction" = "인터랙션 제거" "Remove library from this file" = "이 파일에서 라이브러리 제거" +"Remove measurement" = "측정 제거" "Remove min and max" = "최소 및 최대 제거" "Remove min and max height" = "최소 및 최대 높이 제거" "Remove min and max width" = "최소 및 최대 너비 제거" @@ -1343,6 +1353,7 @@ status = "draft — needs native review" "Title" = "제목" "Title case" = "제목 대소문자" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "이 라이브러리를 팀과 공유하려면 파일을 서버에 게시(공유 ▸ 서버에 게시…)한 다음 그곳에서 라이브러리를 게시하세요." +"To the layer around it" = "감싸는 레이어까지" "Toggle" = "전환" "Toggle light/dark theme" = "라이트/다크 테마 전환" "Toggle visibility" = "표시 여부 전환" @@ -1375,7 +1386,7 @@ status = "draft — needs native review" "Unread" = "읽지 않음" "Unresolve" = "미해결로 되돌리기" "Unsaved changes" = "저장되지 않은 변경 사항" -"Unsaved changes in {names}. Close the window and lose them?" = "{names}에 저장되지 않은 변경 사항이 있음. 창을 닫고 변경 사항을 버릴까요?" +"Unsaved changes in {names}. Save them before closing?" = "{names}에 저장되지 않은 변경 사항이 있음. 닫기 전에 저장할까요?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "저장되지 않은 변경 사항: 먼저 저장({save})하거나 {alt}-클릭으로 탭을 닫아 버리세요" "Unsaved changes: save first, or close again with {key} to discard" = "저장되지 않은 변경 사항: 먼저 저장하거나 {key}로 다시 닫아 버리세요" "Up" = "위" @@ -1787,6 +1798,9 @@ status = "draft — needs native review" "dev.focus" = "포커스 보기" "dev.resolveChange" = "변경 사항 해결" "dev.unfocus" = "포커스 보기 종료" +"devMode.addMeasurement" = "측정 추가" +"devMode.measurements" = "측정 목록" +"devMode.removeMeasurement" = "측정 제거" "devResource.add" = "개발 리소스 추가" "devResource.edit" = "개발 리소스 편집" "devResource.list" = "개발 리소스 목록" @@ -2186,11 +2200,13 @@ status = "draft — needs native review" "vector.edit.tool" = "벡터 편집 도구" "vector.edit.transform" = "점 변형" "vector.edit.widthPoint" = "너비 점" +"vector.fillRule" = "칠 규칙 설정" "vector.flatten" = "평면화" "vector.join" = "선택 항목 연결" "vector.offset" = "패스 오프셋" "vector.outlineStroke" = "선 윤곽선 만들기" "vector.reverse" = "패스 방향 반전" +"vector.reverseLoop" = "루프 반전" "vector.simplify" = "벡터 단순화" "vector.smoothJoin" = "선택 항목 부드럽게 연결" "version.autosave" = "자동 저장 버전" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 9218262b..90038dac 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -56,6 +56,7 @@ status = "draft — needs native review" "Add library file…" = "Bibliotheekbestand toevoegen…" "Add max height" = "Maximale hoogte toevoegen" "Add max width" = "Maximale breedte toevoegen" +"Add measurement" = "Meting toevoegen" "Add min height" = "Minimale hoogte toevoegen" "Add min width" = "Minimale breedte toevoegen" "Add mode" = "Modus toevoegen" @@ -95,6 +96,10 @@ status = "draft — needs native review" "All supported properties" = "Alle ondersteunde eigenschappen" "All the fonts this file uses are installed" = "Alle lettertypen die dit bestand gebruikt zijn geïnstalleerd" "All variables" = "Alle variabelen" +"Along the bottom" = "Langs de onderkant" +"Along the left" = "Langs de linkerkant" +"Along the right" = "Langs de rechterkant" +"Along the top" = "Langs de bovenkant" "Alpha" = "Alpha" "Alphabetical" = "Alfabetisch" "Alt+drag" = "Alt+drag" @@ -159,6 +164,7 @@ status = "draft — needs native review" "Basics" = "Basis" "Before" = "Voor" "Bend" = "Buiging" +"Between the selected layers" = "Tussen de geselecteerde lagen" "Bevel" = "Afgeschuind" "Binary assets added" = "Binaire assets toegevoegd" "Binary assets removed" = "Verwijderde binaire assets" @@ -248,7 +254,6 @@ status = "draft — needs native review" "Close tab" = "Tabblad sluiten" "Close version history" = "Versiegeschiedenis sluiten" "Close when clicking outside" = "Sluiten bij klikken erbuiten" -"Close without saving" = "Sluiten zonder opslaan" "Code" = "Code" "Code or invite link" = "Code of uitnodigingslink" "Code syntax" = "Code-syntaxis" @@ -412,6 +417,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Horizontale afstand verdelen" "Distribute vertical spacing" = "Verticale afstand verdelen" "Divider" = "Scheidslijn" +"Don't Save" = "Niet opslaan" "Don't truncate" = "Niet afkappen" "Done" = "Klaar" "Done editing" = "Klaar met bewerken" @@ -686,6 +692,7 @@ status = "draft — needs native review" "Jun" = "jun." "Just now" = "Zojuist" "Justify" = "Uitvullen" +"Keep a measurement on the canvas for developers" = "Een meting voor ontwikkelaars op het canvas houden" "Keep appearance" = "Uiterlijk behouden" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Kleur behouden converteert elke kleurwaarde naar {profile}, zodat kleuren er hetzelfde uitzien. Waarden behouden laat de getallen staan, waardoor kleuren verschuiven." "Keep tool selected after use" = "Tool na gebruik geselecteerd houden" @@ -775,6 +782,7 @@ status = "draft — needs native review" "Maximize" = "Maximaliseren" "May" = "mei" "Measure to the selection" = "Meten tot de selectie" +"Measurements" = "Metingen" "Merge into main" = "Samenvoegen in main" "Merge regions" = "Gebieden samenvoegen" "Merge the branch into the main file (one undo step) and archive it" = "Voeg de branch samen met het hoofdbestand (één keer ongedaan maken) en archiveer hem" @@ -929,6 +937,7 @@ status = "draft — needs native review" "Open branch" = "Branch openen" "Open file" = "Bestand openen" "Open files" = "Geopende bestanden" +"Open presentations" = "Geopende presentaties" "Open in focus view" = "Openen in focusweergave" "Open in new window" = "Openen in een nieuw venster" "Open invite links in FigCraft" = "Uitnodigingslinks openen in FigCraft" @@ -1091,6 +1100,7 @@ status = "draft — needs native review" "Remove guide" = "Hulplijn verwijderen" "Remove interaction" = "Interactie verwijderen" "Remove library from this file" = "Bibliotheek uit dit bestand verwijderen" +"Remove measurement" = "Meting verwijderen" "Remove min and max" = "Min en max verwijderen" "Remove min and max height" = "Minimale en maximale hoogte verwijderen" "Remove min and max width" = "Minimale en maximale breedte verwijderen" @@ -1425,6 +1435,7 @@ status = "draft — needs native review" "Title" = "Titel" "Title case" = "Hoofdletter per woord" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Om deze bibliotheek met je team te delen, publiceer het bestand naar een server (Delen ▸ Publiceren naar server…), publiceer daarna de bibliotheek." +"To the layer around it" = "Tot de laag eromheen" "Toggle" = "Omschakelen" "Toggle light/dark theme" = "Wisselen tussen licht/donker thema" "Toggle visibility" = "Zichtbaarheid omschakelen" @@ -1460,7 +1471,7 @@ status = "draft — needs native review" "Unread" = "Ongelezen" "Unresolve" = "Oplossing ongedaan maken" "Unsaved changes" = "Niet-opgeslagen wijzigingen" -"Unsaved changes in {names}. Close the window and lose them?" = "Niet-opgeslagen wijzigingen in {names}. Het venster sluiten en ze verliezen?" +"Unsaved changes in {names}. Save them before closing?" = "Niet-opgeslagen wijzigingen in {names}. Opslaan voor het sluiten?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niet-opgeslagen wijzigingen: sla eerst op ({save}), of sluit het tabblad met {alt}-klik om ze te verwerpen" "Unsaved changes: save first, or close again with {key} to discard" = "Niet-opgeslagen wijzigingen: sla eerst op, of sluit nogmaals met {key} om te verwerpen" "Up" = "Omhoog" @@ -1883,6 +1894,9 @@ status = "draft — needs native review" "dev.focus" = "Focusweergave" "dev.resolveChange" = "Wijziging oplossen" "dev.unfocus" = "Focusweergave verlaten" +"devMode.addMeasurement" = "Meting toevoegen" +"devMode.measurements" = "Metingen weergeven" +"devMode.removeMeasurement" = "Meting verwijderen" "devResource.add" = "Dev-resource toevoegen" "devResource.edit" = "Dev-resource bewerken" "devResource.list" = "Dev-resources weergeven" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 1dc9513e..1d8d8a2b 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -53,6 +53,7 @@ status = "draft — needs native review" "Add library file…" = "Dodaj plik biblioteki…" "Add max height" = "Dodaj maksymalną wysokość" "Add max width" = "Dodaj maksymalną szerokość" +"Add measurement" = "Dodaj pomiar" "Add min height" = "Dodaj minimalną wysokość" "Add min width" = "Dodaj minimalną szerokość" "Add mode" = "Dodaj tryb" @@ -92,6 +93,10 @@ status = "draft — needs native review" "All supported properties" = "Wszystkie obsługiwane właściwości" "All the fonts this file uses are installed" = "Wszystkie czcionki używane w tym pliku są zainstalowane" "All variables" = "Wszystkie zmienne" +"Along the bottom" = "Wzdłuż dolnej krawędzi" +"Along the left" = "Wzdłuż lewej krawędzi" +"Along the right" = "Wzdłuż prawej krawędzi" +"Along the top" = "Wzdłuż górnej krawędzi" "Alpha" = "Alfa" "Alphabetical" = "Alfabetycznie" "Alt+drag" = "Alt+przeciągnięcie" @@ -156,6 +161,7 @@ status = "draft — needs native review" "Basics" = "Podstawy" "Before" = "Przed" "Bend" = "Wygnij" +"Between the selected layers" = "Między zaznaczonymi warstwami" "Bevel" = "Ścięte" "Binary assets added" = "Dodane pliki binarne" "Binary assets removed" = "Usunięte pliki binarne" @@ -251,7 +257,6 @@ status = "draft — needs native review" "Close tab" = "Zamknij kartę" "Close version history" = "Zamknij historię wersji" "Close when clicking outside" = "Zamknij przy kliknięciu poza obszarem" -"Close without saving" = "Zamknij bez zapisywania" "Code" = "Kod" "Code syntax" = "Składnia kodu" "Collapse" = "Zwiń" @@ -409,6 +414,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Rozmieść poziomo" "Distribute vertical spacing" = "Rozmieść pionowo" "Divider" = "Rozdzielacz" +"Don't Save" = "Nie zapisuj" "Don't truncate" = "Nie obcinaj tekstu" "Done" = "Gotowe" "Done editing" = "Zakończ edycję" @@ -683,6 +689,7 @@ status = "draft — needs native review" "Jun" = "Cze" "Just now" = "Przed chwilą" "Justify" = "Wyjustuj" +"Keep a measurement on the canvas for developers" = "Zachowaj pomiar na płótnie dla programistów" "Keep appearance" = "Zachowaj wygląd" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Zachowanie wyglądu konwertuje każdą wartość koloru na {profile}, aby kolory wyglądały tak samo. Zachowanie wartości pozostawia liczby bez zmian, co może zmienić odcienie barw." "Keep tool selected after use" = "Pozostaw narzędzie wybrane po użyciu" @@ -772,6 +779,7 @@ status = "draft — needs native review" "Maximize" = "Maksymalizuj" "May" = "maja" "Measure to the selection" = "Zmierz odległość do zaznaczenia" +"Measurements" = "Pomiary" "Merge into main" = "Scal z gałęzią główną" "Merge regions" = "Scal obszary" "Merge the branch into the main file (one undo step) and archive it" = "Scal gałąź z plikiem głównym (jeden krok cofnięcia) i zarchiwizuj ją" @@ -926,6 +934,7 @@ status = "draft — needs native review" "Open branch" = "Otwórz gałąź" "Open file" = "Otwórz plik" "Open files" = "Otwórz pliki" +"Open presentations" = "Otwarte prezentacje" "Open in focus view" = "Otwórz w widoku skupienia" "Open in new window" = "Otwórz w nowym oknie" "Open invite links in FigCraft" = "Otwieraj linki z zaproszeniem w FigCraft" @@ -1087,6 +1096,7 @@ status = "draft — needs native review" "Remove guide" = "Usuń linię pomocniczą" "Remove interaction" = "Usuń interakcję" "Remove library from this file" = "Usuń bibliotekę z tego pliku" +"Remove measurement" = "Usuń pomiar" "Remove min and max" = "Usuń min. i maks." "Remove min and max height" = "Usuń minimalną i maksymalną wysokość" "Remove min and max width" = "Usuń minimalną i maksymalną szerokość" @@ -1420,6 +1430,7 @@ status = "draft — needs native review" "Title" = "Tytuł" "Title case" = "Jak W Tytule" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Aby udostępnić tę bibliotekę zespołowi, opublikuj plik na serwerze (Udostępnij ▸ Publikuj na serwerze…), a następnie opublikuj tam bibliotekę." +"To the layer around it" = "Do warstwy nadrzędnej" "Toggle" = "Przełącz" "Toggle light/dark theme" = "Przełącz motyw jasny/ciemny" "Toggle visibility" = "Przełącz widoczność" @@ -1455,7 +1466,7 @@ status = "draft — needs native review" "Unread" = "Nieprzeczytane" "Unresolve" = "Cofnij rozwiązanie" "Unsaved changes" = "Niezapisane zmiany" -"Unsaved changes in {names}. Close the window and lose them?" = "Niezapisane zmiany w: {names}. Zamknąć okno i utracić je?" +"Unsaved changes in {names}. Save them before closing?" = "Niezapisane zmiany w: {names}. Zapisać je przed zamknięciem?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Niezapisane zmiany: najpierw zapisz ({save}) lub zamknij kartę z {alt}-kliknięciem, aby odrzucić" "Unsaved changes: save first, or close again with {key} to discard" = "Niezapisane zmiany: najpierw zapisz lub naciśnij ponownie {key}, aby odrzucić" "Up" = "W górę" @@ -1879,6 +1890,9 @@ status = "draft — needs native review" "dev.focus" = "Widok skupienia" "dev.resolveChange" = "Rozwiąż zmianę" "dev.unfocus" = "Wyjdź z widoku skupienia" +"devMode.addMeasurement" = "Dodaj pomiar" +"devMode.measurements" = "Lista pomiarów" +"devMode.removeMeasurement" = "Usuń pomiar" "devResource.add" = "Dodaj zasób programistyczny" "devResource.edit" = "Edytuj zasób programistyczny" "devResource.list" = "Lista zasobów programistycznych" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 8aa1ed5d..38cd8f67 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -51,6 +51,7 @@ status = "draft — needs native review" "Add library file…" = "Adicionar arquivo de biblioteca…" "Add max height" = "Adicionar altura máx." "Add max width" = "Adicionar largura máx." +"Add measurement" = "Adicionar medição" "Add min height" = "Adicionar altura mín." "Add min width" = "Adicionar largura mín." "Add mode" = "Adicionar modo" @@ -88,6 +89,10 @@ status = "draft — needs native review" "All fonts" = "Todas as fontes" "All supported properties" = "Todas as propriedades compatíveis" "All variables" = "Todas as variáveis" +"Along the bottom" = "Ao longo da borda inferior" +"Along the left" = "Ao longo da borda esquerda" +"Along the right" = "Ao longo da borda direita" +"Along the top" = "Ao longo da borda superior" "Alpha" = "Alfa" "Alt+drag" = "Alt+arrastar" "An invite link that opens FigCraft and joins" = "Um link de convite que abre o FigCraft e entra na sessão" @@ -147,6 +152,7 @@ status = "draft — needs native review" "Basics" = "Básico" "Before" = "Antes" "Bend" = "Curvar" +"Between the selected layers" = "Entre as camadas selecionadas" "Bevel" = "Chanfro" "Binary assets added" = "Arquivos binários adicionados" "Binary assets removed" = "Arquivos binários removidos" @@ -236,7 +242,6 @@ status = "draft — needs native review" "Close tab" = "Fechar aba" "Close version history" = "Fechar histórico de versões" "Close when clicking outside" = "Fechar ao clicar fora" -"Close without saving" = "Fechar sem salvar" "Code" = "Código" "Code syntax" = "Sintaxe de código" "Collapse" = "Recolher" @@ -392,6 +397,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "Distribuir espaçamento horizontal" "Distribute vertical spacing" = "Distribuir espaçamento vertical" "Divider" = "Divisor" +"Don't Save" = "Não salvar" "Don't truncate" = "Não truncar" "Done" = "Concluído" "Done editing" = "Concluir edição" @@ -648,6 +654,7 @@ status = "draft — needs native review" "Join|stroke" = "Junção" "Just now" = "Agora mesmo" "Justify" = "Justificar" +"Keep a measurement on the canvas for developers" = "Manter uma medição no canvas para desenvolvedores" "Keep appearance" = "Manter aparência" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Manter aparência converte cada valor de cor para {profile} para que as cores pareçam iguais. Manter valores deixa os números como estão, então as cores mudam." "Keep tool selected after use" = "Manter a ferramenta selecionada após o uso" @@ -730,6 +737,7 @@ status = "draft — needs native review" "Max lines" = "Máx. de linhas" "Maximize" = "Maximizar" "Measure to the selection" = "Medir até a seleção" +"Measurements" = "Medições" "Merge into main" = "Mesclar no principal" "Merge regions" = "Mesclar regiões" "Merge the branch into the main file (one undo step) and archive it" = "Mescla a branch no arquivo principal (uma etapa de desfazer) e a arquiva" @@ -875,6 +883,7 @@ status = "draft — needs native review" "Open branch" = "Abrir branch" "Open file" = "Abrir arquivo" "Open files" = "Arquivos abertos" +"Open presentations" = "Apresentações abertas" "Open in focus view" = "Abrir na visualização em foco" "Open in new window" = "Abrir em nova janela" "Open invite links in FigCraft" = "Abrir links de convite no FigCraft" @@ -1027,6 +1036,7 @@ status = "draft — needs native review" "Remove guide" = "Remover guia" "Remove interaction" = "Remover interação" "Remove library from this file" = "Remover biblioteca deste arquivo" +"Remove measurement" = "Remover medição" "Remove min and max" = "Remover mín. e máx." "Remove min and max height" = "Remover altura mín. e máx." "Remove min and max width" = "Remover largura mín. e máx." @@ -1342,6 +1352,7 @@ status = "draft — needs native review" "Title" = "Título" "Title case" = "Iniciais maiúsculas" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Para compartilhar esta biblioteca com sua equipe, publique o arquivo em um servidor (Compartilhar ▸ Publicar no servidor…) e depois publique a biblioteca lá." +"To the layer around it" = "Até a camada que a contém" "Toggle" = "Alternar" "Toggle light/dark theme" = "Alternar tema claro/escuro" "Toggle visibility" = "Alternar visibilidade" @@ -1374,7 +1385,7 @@ status = "draft — needs native review" "Unread" = "Não lidos" "Unresolve" = "Reabrir" "Unsaved changes" = "Alterações não salvas" -"Unsaved changes in {names}. Close the window and lose them?" = "Há alterações não salvas em {names}. Fechar a janela e perdê-las?" +"Unsaved changes in {names}. Save them before closing?" = "Há alterações não salvas em {names}. Salvá-las antes de fechar?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Alterações não salvas: salve primeiro ({save}) ou feche a aba com {alt}+clique para descartá-las" "Unsaved changes: save first, or close again with {key} to discard" = "Alterações não salvas: salve primeiro ou feche de novo com {key} para descartá-las" "Up" = "Para cima" @@ -1900,6 +1911,9 @@ status = "draft — needs native review" "dev.focus" = "Visualização em foco" "dev.resolveChange" = "Resolver alteração" "dev.unfocus" = "Sair da visualização em foco" +"devMode.addMeasurement" = "Adicionar medição" +"devMode.measurements" = "Listar medições" +"devMode.removeMeasurement" = "Remover medição" "devResource.add" = "Adicionar recurso de desenvolvimento" "devResource.edit" = "Editar recurso de desenvolvimento" "devResource.list" = "Listar recursos de desenvolvimento" @@ -2299,11 +2313,13 @@ status = "draft — needs native review" "vector.edit.tool" = "Ferramenta de edição vetorial" "vector.edit.transform" = "Transformar pontos" "vector.edit.widthPoint" = "Ponto de largura" +"vector.fillRule" = "Definir regra de preenchimento" "vector.flatten" = "Nivelar" "vector.join" = "Unir seleção" "vector.offset" = "Deslocar caminho" "vector.outlineStroke" = "Contornar traço" "vector.reverse" = "Inverter direção do caminho" +"vector.reverseLoop" = "Inverter laço" "vector.simplify" = "Simplificar vetor" "vector.smoothJoin" = "Unir seleção com suavização" "version.autosave" = "Salvar versão automaticamente" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index f8062989..61847fce 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -54,6 +54,7 @@ name = "Русский" "Add library file…" = "Добавить файл библиотеки…" "Add max height" = "Добавить максимальную высоту" "Add max width" = "Добавить максимальную ширину" +"Add measurement" = "Добавить измерение" "Add min height" = "Добавить минимальную высоту" "Add min width" = "Добавить минимальную ширину" "Add mode" = "Добавить режим" @@ -93,6 +94,10 @@ name = "Русский" "All supported properties" = "Все поддерживаемые свойства" "All the fonts this file uses are installed" = "Все шрифты, которые использует этот файл, установлены" "All variables" = "Все переменные" +"Along the bottom" = "Вдоль нижнего края" +"Along the left" = "Вдоль левого края" +"Along the right" = "Вдоль правого края" +"Along the top" = "Вдоль верхнего края" "Alpha" = "Альфа" "Alphabetical" = "По алфавиту" "Alt+drag" = "Alt+перетаскивание" @@ -157,6 +162,7 @@ name = "Русский" "Basics" = "Основы" "Before" = "До" "Bend" = "Изгиб" +"Between the selected layers" = "Между выбранными слоями" "Bevel" = "Скошенное" "Binary assets added" = "Добавлено бинарных ресурсов" "Binary assets removed" = "Удалено бинарных ресурсов" @@ -250,7 +256,6 @@ name = "Русский" "Close tab" = "Закрыть вкладку" "Close version history" = "Закрыть историю версий" "Close when clicking outside" = "Закрывать при нажатии снаружи" -"Close without saving" = "Закрыть без сохранения" "Code" = "Код" "Code syntax" = "Синтаксис кода" "Collapse" = "Свернуть" @@ -411,6 +416,7 @@ name = "Русский" "Distribute horizontal spacing" = "Распределить по горизонтали" "Distribute vertical spacing" = "Распределить по вертикали" "Divider" = "Разделитель" +"Don't Save" = "Не сохранять" "Don't truncate" = "Не обрезать" "Done" = "Готово" "Done editing" = "Завершить редактирование" @@ -687,6 +693,7 @@ name = "Русский" "Jun" = "июн." "Just now" = "Только что" "Justify" = "По ширине" +"Keep a measurement on the canvas for developers" = "Оставить измерение на холсте для разработчиков" "Keep appearance" = "Сохранить внешний вид" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "«Сохранить внешний вид» преобразует все значения цветов в {profile}, сохраняя их вид. «Сохранить значения» оставляет числа без изменений, поэтому цвета меняются." "Keep tool selected after use" = "Оставлять инструмент выбранным после использования" @@ -777,6 +784,7 @@ name = "Русский" "Maximize" = "Развернуть окно" "May" = "май" "Measure to the selection" = "Измерить до выделения" +"Measurements" = "Измерения" "Merge into main" = "Объединить с основной" "Merge regions" = "Объединить области" "Merge the branch into the main file (one undo step) and archive it" = "Объединить ветку с основным файлом (один шаг отмены) и архивировать её" @@ -931,6 +939,7 @@ name = "Русский" "Open branch" = "Открыть ветку" "Open file" = "Открыть файл" "Open files" = "Открыть файлы" +"Open presentations" = "Открытые презентации" "Open in focus view" = "Открыть в режиме фокуса" "Open in new window" = "Открыть в новом окне" "Open invite links in FigCraft" = "Открывать ссылки приглашения в FigCraft" @@ -1094,6 +1103,7 @@ name = "Русский" "Remove guide" = "Удалить направляющую" "Remove interaction" = "Удалить взаимодействие" "Remove library from this file" = "Удалить библиотеку из этого файла" +"Remove measurement" = "Удалить измерение" "Remove min and max" = "Убрать минимум и максимум" "Remove min and max height" = "Убрать минимальную и максимальную высоту" "Remove min and max width" = "Убрать минимальную и максимальную ширину" @@ -1433,6 +1443,7 @@ name = "Русский" "Title" = "Заголовок" "Title case" = "Каждое Слово С Прописной" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Чтобы поделиться библиотекой с командой, опубликуйте файл на сервере («Поделиться» ▸ «Опубликовать на сервере…»), затем опубликуйте там библиотеку." +"To the layer around it" = "До родительского слоя" "Toggle" = "Переключить" "Toggle light/dark theme" = "Переключить светлую/тёмную тему" "Toggle visibility" = "Переключить видимость" @@ -1469,7 +1480,7 @@ name = "Русский" "Unread" = "Непрочитанные" "Unresolve" = "Снова открыть" "Unsaved changes" = "Несохранённые изменения" -"Unsaved changes in {names}. Close the window and lose them?" = "Несохранённые изменения в {names}. Закрыть окно и потерять их?" +"Unsaved changes in {names}. Save them before closing?" = "Несохранённые изменения в {names}. Сохранить их перед закрытием?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Есть несохранённые изменения: сохраните ({save}) или закройте вкладку нажатием с {alt}, чтобы отбросить их" "Unsaved changes: save first, or close again with {key} to discard" = "Есть несохранённые изменения: сохраните или закройте ещё раз с {key}, чтобы отбросить их" "Up" = "Вверх" @@ -1896,6 +1907,9 @@ name = "Русский" "dev.focus" = "Режим фокуса" "dev.resolveChange" = "Разрешить изменение" "dev.unfocus" = "Выйти из режима фокуса" +"devMode.addMeasurement" = "Добавить измерение" +"devMode.measurements" = "Список измерений" +"devMode.removeMeasurement" = "Удалить измерение" "devResource.add" = "Добавить ресурс для разработки" "devResource.edit" = "Изменить ресурс для разработки" "devResource.list" = "Список ресурсов для разработки" @@ -2306,11 +2320,13 @@ name = "Русский" "vector.edit.tool" = "Инструмент редактирования вектора" "vector.edit.transform" = "Трансформировать точки" "vector.edit.widthPoint" = "Точка ширины" +"vector.fillRule" = "Задать правило заливки" "vector.flatten" = "Свести" "vector.join" = "Соединить выделение" "vector.offset" = "Сместить путь" "vector.outlineStroke" = "Преобразовать обводку в контур" "vector.reverse" = "Обратить направление пути" +"vector.reverseLoop" = "Развернуть контур" "vector.simplify" = "Упростить вектор" "vector.smoothJoin" = "Плавно соединить выделение" "version.autosave" = "Автосохранить версию" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 8f5e4b0c..627c82cc 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -54,6 +54,7 @@ name = "Svenska" "Add library file…" = "Lägg till biblioteksfil…" "Add max height" = "Lägg till maxhöjd" "Add max width" = "Lägg till maxbredd" +"Add measurement" = "Lägg till mått" "Add min height" = "Lägg till minhöjd" "Add min width" = "Lägg till minbredd" "Add mode" = "Lägg till läge" @@ -92,6 +93,10 @@ name = "Svenska" "All fonts" = "Alla typsnitt" "All supported properties" = "Alla egenskaper som stöds" "All variables" = "Alla variabler" +"Along the bottom" = "Längs nederkanten" +"Along the left" = "Längs vänsterkanten" +"Along the right" = "Längs högerkanten" +"Along the top" = "Längs överkanten" "Alpha" = "Alfa" "Alphabetical" = "I bokstavsordning" "Alt+drag" = "Alt+dra" @@ -156,6 +161,7 @@ name = "Svenska" "Basics" = "Grunder" "Before" = "Före" "Bend" = "Böj" +"Between the selected layers" = "Mellan de markerade lagren" "Bevel" = "Fasning" "Binary assets added" = "Binära resurser tillagda" "Binary assets removed" = "Binära resurser borttagna" @@ -247,7 +253,6 @@ name = "Svenska" "Close tab" = "Stäng flik" "Close version history" = "Stäng versionshistorik" "Close when clicking outside" = "Stäng vid klick utanför" -"Close without saving" = "Stäng utan att spara" "Code" = "Kod" "Code syntax" = "Kodsyntax" "Collapse" = "Komprimera" @@ -406,6 +411,7 @@ name = "Svenska" "Distribute horizontal spacing" = "Fördela horisontellt avstånd" "Distribute vertical spacing" = "Fördela vertikalt avstånd" "Divider" = "Avdelare" +"Don't Save" = "Spara inte" "Don't truncate" = "Korta inte av" "Done" = "Klart" "Done editing" = "Klar med redigeringen" @@ -680,6 +686,7 @@ name = "Svenska" "Jun" = "jun" "Just now" = "Nyss" "Justify" = "Marginaljustera" +"Keep a measurement on the canvas for developers" = "Behåll ett mått på arbetsytan för utvecklare" "Keep appearance" = "Behåll utseende" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Behåll utseende konverterar varje färgvärde till {profile} så att färgerna ser likadana ut. Behåll värden lämnar siffrorna som de är, så färgerna förskjuts." "Keep tool selected after use" = "Behåll verktyget markerat efter användning" @@ -769,6 +776,7 @@ name = "Svenska" "Maximize" = "Maximera" "May" = "Maj" "Measure to the selection" = "Mät avståndet till markeringen" +"Measurements" = "Mått" "Merge into main" = "Sammanfoga med main" "Merge regions" = "Sammanfoga regioner" "Merge the branch into the main file (one undo step) and archive it" = "Sammanfoga grenen med huvudfilen (ett ångrasteg) och arkivera den" @@ -920,6 +928,7 @@ name = "Svenska" "Open branch" = "Öppna gren" "Open file" = "Öppna fil" "Open files" = "Öppna filer" +"Open presentations" = "Öppna presentationer" "Open in focus view" = "Öppna i fokusvy" "Open in new window" = "Öppna i nytt fönster" "Open invite links in FigCraft" = "Öppna inbjudningslänkar i FigCraft" @@ -1079,6 +1088,7 @@ name = "Svenska" "Remove guide" = "Ta bort stödlinje" "Remove interaction" = "Ta bort interaktion" "Remove library from this file" = "Ta bort biblioteket från den här filen" +"Remove measurement" = "Ta bort mått" "Remove min and max" = "Ta bort min och max" "Remove min and max height" = "Ta bort min- och maxhöjd" "Remove min and max width" = "Ta bort min- och maxbredd" @@ -1410,6 +1420,7 @@ name = "Svenska" "Title" = "Titel" "Title case" = "Inledande versaler" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Om du vill dela biblioteket med ditt team publicerar du filen på en server (Dela ▸ Publicera på server…) och publicerar sedan biblioteket där." +"To the layer around it" = "Till lagret runt det" "Toggle" = "Visa/dölj" "Toggle light/dark theme" = "Växla mellan ljust och mörkt tema" "Toggle visibility" = "Visa/dölj" @@ -1444,7 +1455,7 @@ name = "Svenska" "Unread" = "Olästa" "Unresolve" = "Markera som olöst" "Unsaved changes" = "Osparade ändringar" -"Unsaved changes in {names}. Close the window and lose them?" = "Osparade ändringar i {names}. Vill du stänga fönstret och förlora dem?" +"Unsaved changes in {names}. Save them before closing?" = "Osparade ändringar i {names}. Vill du spara dem innan du stänger?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Osparade ändringar: spara först ({save}), eller stäng fliken med {alt}-klick för att slänga dem" "Unsaved changes: save first, or close again with {key} to discard" = "Osparade ändringar: spara först, eller stäng igen med {key} för att slänga dem" "Up" = "Upp" @@ -1865,6 +1876,9 @@ name = "Svenska" "dev.focus" = "Fokusvy" "dev.resolveChange" = "Lös ändring" "dev.unfocus" = "Lämna fokusvy" +"devMode.addMeasurement" = "Lägg till mått" +"devMode.measurements" = "Lista mått" +"devMode.removeMeasurement" = "Ta bort mått" "devResource.add" = "Lägg till utvecklarresurs" "devResource.edit" = "Redigera utvecklarresurs" "devResource.list" = "Lista utvecklarresurser" @@ -2270,11 +2284,13 @@ name = "Svenska" "vector.edit.tool" = "Vektorredigeringsverktyg" "vector.edit.transform" = "Transformera punkter" "vector.edit.widthPoint" = "Breddpunkt" +"vector.fillRule" = "Ange fyllnadsregel" "vector.flatten" = "Platta till" "vector.join" = "Sammanfoga markering" "vector.offset" = "Förskjut bana" "vector.outlineStroke" = "Konvertera kontur till form" "vector.reverse" = "Vänd banans riktning" +"vector.reverseLoop" = "Vänd slinga" "vector.simplify" = "Förenkla vektor" "vector.smoothJoin" = "Sammanfoga markering mjukt" "version.autosave" = "Autospara version" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index b3768592..5b06cab6 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -55,6 +55,7 @@ name = "Türkçe" "Add library file…" = "Kitaplık dosyası ekle…" "Add max height" = "En büyük yükseklik ekle" "Add max width" = "En büyük genişlik ekle" +"Add measurement" = "Ölçüm ekle" "Add min height" = "En küçük yükseklik ekle" "Add min width" = "En küçük genişlik ekle" "Add mode" = "Mod ekle" @@ -92,6 +93,10 @@ name = "Türkçe" "All fonts" = "Tüm yazı tipleri" "All supported properties" = "Desteklenen tüm özellikler" "All variables" = "Tüm değişkenler" +"Along the bottom" = "Alt kenar boyunca" +"Along the left" = "Sol kenar boyunca" +"Along the right" = "Sağ kenar boyunca" +"Along the top" = "Üst kenar boyunca" "Alpha" = "Alfa" "Alphabetical" = "Alfabetik" "Alt+drag" = "Alt+sürükle" @@ -156,6 +161,7 @@ name = "Türkçe" "Basics" = "Temel bilgiler" "Before" = "Önce" "Bend" = "Bük" +"Between the selected layers" = "Seçili katmanlar arasında" "Bevel" = "Eğimli" "Binary assets added" = "Eklenen ikili varlıklar" "Binary assets removed" = "Kaldırılan ikili varlıklar" @@ -247,7 +253,6 @@ name = "Türkçe" "Close tab" = "Sekmeyi kapat" "Close version history" = "Sürüm geçmişini kapat" "Close when clicking outside" = "Dışarı tıklanınca kapat" -"Close without saving" = "Kaydetmeden kapat" "Code" = "Kod" "Code syntax" = "Kod söz dizimi" "Collapse" = "Daralt" @@ -404,6 +409,7 @@ name = "Türkçe" "Distribute horizontal spacing" = "Yatay aralığı dağıt" "Distribute vertical spacing" = "Dikey aralığı dağıt" "Divider" = "Ayırıcı" +"Don't Save" = "Kaydetme" "Don't truncate" = "Kısaltma yapma" "Done" = "Bitti" "Done editing" = "Düzenlemeyi bitir" @@ -673,6 +679,7 @@ name = "Türkçe" "Jun" = "Haz" "Just now" = "Az önce" "Justify" = "İki yana yasla" +"Keep a measurement on the canvas for developers" = "Geliştiriciler için tuvalde bir ölçüm tut" "Keep appearance" = "Görünümü koru" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "Görünümü koru, renkler aynı görünsün diye her renk değerini {profile} profiline dönüştürür. Değerleri koru, sayıları olduğu gibi bırakır; bu yüzden renkler kayar." "Keep tool selected after use" = "Kullanımdan sonra aracı seçili tut" @@ -762,6 +769,7 @@ name = "Türkçe" "Maximize" = "Ekranı kapla" "May" = "May" "Measure to the selection" = "Seçime kadar ölç" +"Measurements" = "Ölçümler" "Merge into main" = "Ana dosyaya birleştir" "Merge regions" = "Bölgeleri birleştir" "Merge the branch into the main file (one undo step) and archive it" = "Dalı ana dosyaya birleştir (tek geri alma adımı) ve arşivle" @@ -913,6 +921,7 @@ name = "Türkçe" "Open branch" = "Dalı aç" "Open file" = "Dosya aç" "Open files" = "Dosyaları aç" +"Open presentations" = "Açık sunumlar" "Open in focus view" = "Odak görünümünde aç" "Open in new window" = "Yeni pencerede aç" "Open invite links in FigCraft" = "Davet bağlantılarını FigCraft'ta aç" @@ -1070,6 +1079,7 @@ name = "Türkçe" "Remove guide" = "Kılavuzu kaldır" "Remove interaction" = "Etkileşimi kaldır" "Remove library from this file" = "Kitaplığı bu dosyadan kaldır" +"Remove measurement" = "Ölçümü kaldır" "Remove min and max" = "En küçük ve en büyüğü kaldır" "Remove min and max height" = "En küçük ve en büyük yüksekliği kaldır" "Remove min and max width" = "En küçük ve en büyük genişliği kaldır" @@ -1395,6 +1405,7 @@ name = "Türkçe" "Title" = "Başlık" "Title case" = "Başlık biçimi" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "Bu kitaplığı ekibinizle paylaşmak için dosyayı bir sunucuya yayımlayın (Paylaş ▸ Sunucuya yayımla…), sonra kitaplığı orada yayımlayın." +"To the layer around it" = "Çevreleyen katmana kadar" "Toggle" = "Aç/Kapat" "Toggle light/dark theme" = "Açık/koyu temayı değiştir" "Toggle visibility" = "Görünürlüğü değiştir" @@ -1429,7 +1440,7 @@ name = "Türkçe" "Unread" = "Okunmamış" "Unresolve" = "Çözülmedi yap" "Unsaved changes" = "Kaydedilmemiş değişiklikler" -"Unsaved changes in {names}. Close the window and lose them?" = "{names} içinde kaydedilmemiş değişiklikler var. Pencere kapatılıp bunlar kaybedilsin mi?" +"Unsaved changes in {names}. Save them before closing?" = "{names} içinde kaydedilmemiş değişiklikler var. Kapatmadan önce kaydedilsin mi?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin ({save}) veya atmak için sekmeyi {alt}-tıklamayla kapatın" "Unsaved changes: save first, or close again with {key} to discard" = "Kaydedilmemiş değişiklikler: önce kaydedin veya atmak için {key} ile yeniden kapatın" "Up" = "Yukarı" @@ -1845,6 +1856,9 @@ name = "Türkçe" "dev.focus" = "Odak görünümü" "dev.resolveChange" = "Değişikliği çöz" "dev.unfocus" = "Odak görünümünden çık" +"devMode.addMeasurement" = "Ölçüm ekle" +"devMode.measurements" = "Ölçümleri listele" +"devMode.removeMeasurement" = "Ölçümü kaldır" "devResource.add" = "Geliştirici kaynağı ekle" "devResource.edit" = "Geliştirici kaynağını düzenle" "devResource.list" = "Geliştirici kaynaklarını listele" @@ -2247,11 +2261,13 @@ name = "Türkçe" "vector.edit.tool" = "Vektör düzenleme aracı" "vector.edit.transform" = "Noktaları dönüştür" "vector.edit.widthPoint" = "Genişlik noktası" +"vector.fillRule" = "Dolgu kuralını ayarla" "vector.flatten" = "Düzleştir" "vector.join" = "Seçimi birleştir" "vector.offset" = "Yolu ötele" "vector.outlineStroke" = "Konturu ana hatlara dönüştür" "vector.reverse" = "Yol yönünü ters çevir" +"vector.reverseLoop" = "Döngüyü çevir" "vector.simplify" = "Vektörü sadeleştir" "vector.smoothJoin" = "Seçimi yumuşak birleştir" "version.autosave" = "Otomatik kayıt sürümü" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 3136fef3..1d1bbd54 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -54,6 +54,7 @@ status = "draft — needs native review" "Add library file…" = "添加库文件…" "Add max height" = "添加最大高度" "Add max width" = "添加最大宽度" +"Add measurement" = "添加测量" "Add min height" = "添加最小高度" "Add min width" = "添加最小宽度" "Add mode" = "添加模式" @@ -91,6 +92,10 @@ status = "draft — needs native review" "All fonts" = "全部字体" "All supported properties" = "所有支持的属性" "All variables" = "全部变量" +"Along the bottom" = "沿底边" +"Along the left" = "沿左边" +"Along the right" = "沿右边" +"Along the top" = "沿顶边" "Alpha" = "Alpha" "Alt+drag" = "Alt+拖动" "An invite link that opens FigCraft and joins" = "可打开 FigCraft 并加入的邀请链接" @@ -150,6 +155,7 @@ status = "draft — needs native review" "Basics" = "基础" "Before" = "之前" "Bend" = "弯曲" +"Between the selected layers" = "所选图层之间" "Bevel" = "斜角" "Binary assets added" = "新增的二进制资源" "Binary assets removed" = "删除的二进制资源" @@ -239,7 +245,6 @@ status = "draft — needs native review" "Close tab" = "关闭标签页" "Close version history" = "关闭版本历史" "Close when clicking outside" = "点击外部时关闭" -"Close without saving" = "关闭但不保存" "Code" = "代码" "Code syntax" = "代码语法" "Collapse" = "折叠" @@ -394,6 +399,7 @@ status = "draft — needs native review" "Distribute horizontal spacing" = "水平间距分布" "Distribute vertical spacing" = "垂直间距分布" "Divider" = "分隔线" +"Don't Save" = "不保存" "Don't truncate" = "不截断" "Done" = "完成" "Done editing" = "完成编辑" @@ -650,6 +656,7 @@ status = "draft — needs native review" "Join|stroke" = "转角" "Just now" = "刚刚" "Justify" = "两端对齐" +"Keep a measurement on the canvas for developers" = "为开发者在画布上保留测量" "Keep appearance" = "保持外观" "Keep appearance converts every color value to {profile} so colors look the same. Keep values leaves the numbers as they are, so colors shift." = "“保持外观”会将每个颜色值转换为 {profile},使颜色看起来不变。“保持数值”则保留数值不变,颜色会有偏移。" "Keep tool selected after use" = "使用后保持工具选中" @@ -732,6 +739,7 @@ status = "draft — needs native review" "Max lines" = "最大行数" "Maximize" = "最大化" "Measure to the selection" = "测量到所选项的距离" +"Measurements" = "测量" "Merge into main" = "合并到主文件" "Merge regions" = "合并区域" "Merge the branch into the main file (one undo step) and archive it" = "将分支合并到主文件(一步撤销)并归档" @@ -877,6 +885,7 @@ status = "draft — needs native review" "Open branch" = "打开分支" "Open file" = "打开文件" "Open files" = "打开的文件" +"Open presentations" = "打开的演示" "Open in focus view" = "在聚焦视图中打开" "Open in new window" = "在新窗口中打开" "Open invite links in FigCraft" = "在 FigCraft 中打开邀请链接" @@ -1029,6 +1038,7 @@ status = "draft — needs native review" "Remove guide" = "移除参考线" "Remove interaction" = "移除交互" "Remove library from this file" = "从此文件中移除库" +"Remove measurement" = "移除测量" "Remove min and max" = "移除最小值和最大值" "Remove min and max height" = "移除最小和最大高度" "Remove min and max width" = "移除最小和最大宽度" @@ -1344,6 +1354,7 @@ status = "draft — needs native review" "Title" = "标题" "Title case" = "首字母大写" "To share this library with your team, publish the file to a server (Share ▸ Publish to server…), then publish the library there." = "要与团队共享此库,请先将文件发布到服务器(分享 ▸ 发布到服务器…),然后在那里发布库。" +"To the layer around it" = "到外层图层" "Toggle" = "切换显示" "Toggle light/dark theme" = "切换浅色/深色主题" "Toggle visibility" = "切换可见性" @@ -1376,7 +1387,7 @@ status = "draft — needs native review" "Unread" = "未读" "Unresolve" = "取消解决" "Unsaved changes" = "未保存的更改" -"Unsaved changes in {names}. Close the window and lose them?" = "{names} 中有未保存的更改。要关闭窗口并丢弃这些更改吗?" +"Unsaved changes in {names}. Save them before closing?" = "{names} 中有未保存的更改。关闭前要保存吗?" "Unsaved changes: save first ({save}), or close the tab with {alt}-click to discard" = "有未保存的更改:请先保存({save}),或按住 {alt} 点击关闭标签页以丢弃" "Unsaved changes: save first, or close again with {key} to discard" = "有未保存的更改:请先保存,或再次按 {key} 关闭以丢弃" "Up" = "向上" @@ -1788,6 +1799,9 @@ status = "draft — needs native review" "dev.focus" = "聚焦视图" "dev.resolveChange" = "解决更改" "dev.unfocus" = "退出聚焦视图" +"devMode.addMeasurement" = "添加测量" +"devMode.measurements" = "列出测量" +"devMode.removeMeasurement" = "移除测量" "devResource.add" = "添加开发资源" "devResource.edit" = "编辑开发资源" "devResource.list" = "列出开发资源" @@ -2187,11 +2201,13 @@ status = "draft — needs native review" "vector.edit.tool" = "矢量编辑工具" "vector.edit.transform" = "变换点" "vector.edit.widthPoint" = "宽度点" +"vector.fillRule" = "设置填充规则" "vector.flatten" = "拼合" "vector.join" = "连接所选项" "vector.offset" = "偏移路径" "vector.outlineStroke" = "轮廓化描边" "vector.reverse" = "反转路径方向" +"vector.reverseLoop" = "反转环" "vector.simplify" = "简化矢量" "vector.smoothJoin" = "平滑连接所选项" "version.autosave" = "自动保存版本" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 56623f54..08e675f4 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -246,6 +246,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let image_rect = presented_image_rect(tl, v, zoom, ppp, pixel_preview); painter.image(tex.id(), image_rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); } + crate::dev_mode::refresh_measurements(app); app.session.tool.labels = overlays(app, &painter, &t); crate::frame_labels::rename_field(app, ui); // The zoom tool's area. diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index d6aad31d..9d37477a 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -435,8 +435,21 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { slider_knob(ui, rect, color.a, Id::new("cp_alpha")) } +/// The colour picker window's area id. A fixed id (not `Window::new`'s, which egui keys by +/// `Id::new(Some(title))`) so the style picker can tell a click inside the picker from one outside. +pub fn window_id() -> Id { + Id::new("color_picker") +} + pub fn show(app: &mut FigApp, ctx: &egui::Context) { - let Some(target) = app.ui.picker.clone() else { return }; + let Some(target) = app.ui.picker.clone() else { + // A colour sampled for a picker that has closed has no paint to go to. + app.session.tool.picked = None; + if app.session.tool.pick_only { + app.run("tool.move", json!({})); + } + return; + }; // A layer's picker belongs to the selection it was opened for: once that changes, close it // rather than keep editing layers that are no longer selected. let stale = match &target { @@ -463,6 +476,9 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut open = true; let mut close_now = false; egui::Window::new("color_picker") + // A fixed area id so the style picker can tell a click in here from a click outside + // (`Window::new` alone would key the area by `Id::new(Some(title))`). + .id(window_id()) .title_bar(false) .fixed_pos(pos) .fixed_size(vec2(240.0, 0.0)) @@ -470,6 +486,10 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { .open(&mut open) .show(ctx, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 8.0); + // The eyedropper's colour goes to this picker's paint (a stroke, an effect, a gradient stop...). + if let Some(c) = app.session.tool.picked.take() { + set_color(app, &target, &mut paint, c, stop_id(), ui); + } // Header. let (hr, _) = ui.allocate_exact_size(vec2(240.0, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Custom"), semibold(11.0), t.text); @@ -772,8 +792,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { ) .clicked() { - app.ui.picker = None; - app.run("edit.pickColor", json!({})); + app.run("edit.pickColor", json!({"apply": false})); return; } let (c2, d2) = hue_slider(ui, hr, &mut hsb); diff --git a/crates/ui-egui/src/colorpicker_eyedropper_tests.rs b/crates/ui-egui/src/colorpicker_eyedropper_tests.rs new file mode 100644 index 00000000..51856b48 --- /dev/null +++ b/crates/ui-egui/src/colorpicker_eyedropper_tests.rs @@ -0,0 +1,90 @@ +//! The eyedropper of a colour picker, driven through egui headless: the colour it samples goes to +//! the paint the picker edits (a stroke, an effect), not to the layer's fill (vectorcraft#3342). + +use egui::accesskit::Role; +use egui::{Event, Pos2, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::{Session, ToolId}; +use serde_json::json; + +use crate::colorpicker::PickerTarget; +use crate::layers_drag_tests::Ui; + +fn rect(s: &mut Session, x: f64, props: serde_json::Value) -> NodeId { + let v = s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 60, "height": 60, "props": props})).unwrap(); + NodeId(v["id"].as_u64().unwrap()) +} + +/// The middle of the picker's eyedropper button. +fn eyedropper_button(u: &mut Ui) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some("Pick color (I)")).expect("the eyedropper button"); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![]); +} + +#[test] +fn the_pickers_eyedropper_applies_the_colour_to_the_paint_it_edits() { + for effect in [false, true] { + // A red layer to sample, and a green one with a blue stroke (and a shadow) to edit. + let mut s = Session::new(); + s.execute("page.setBackground", &json!({"color": "#FFFFFF"})).unwrap(); + let red = rect(&mut s, 0.0, json!({"fill": "#FF0000"})); + let b = rect(&mut s, 200.0, json!({"fill": "#00FF00", "stroke": "#0000FF", "strokeWeight": 2})); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let target = if effect { + s.execute("effect.add", &json!({"ids": [b], "type": "dropShadow"})).unwrap(); + PickerTarget::Effect { ids: vec![b], index: 0, secondary: false } + } else { + PickerTarget::Paint { fills: false, index: 0, ids: vec![b] } + }; + let mut u = Ui::new(s); + u.app.ui.picker = Some(target.clone()); + let at = eyedropper_button(&mut u); + click(&mut u, at); + assert_eq!(u.app.session.tool.tool, ToolId::Eyedropper, "the button picks up the eyedropper"); + let undo = u.app.session.undo_depth().0; + let on_red = u.app.to_screen(30.0, 30.0); + click(&mut u, on_red); + let doc = u.app.session.doc().unwrap(); + let n = doc.node(b).unwrap(); + let hex = |p: Option| p.map(|c| c.to_hex()).unwrap_or_default(); + let picked = if effect { hex(n.effects[0].color(false)) } else { hex(n.strokes[0].solid_color()) }; + assert_eq!(picked, "FF0000", "the picker's paint takes the colour (effect: {effect})"); + assert_eq!(hex(n.fills[0].solid_color()), "00FF00", "the fill is left alone (effect: {effect})"); + assert!(doc.node(red).is_some()); + assert_eq!(u.app.session.tool.tool, ToolId::Move, "picking ends the eyedropper"); + assert_eq!(u.app.ui.picker, Some(target), "the picker stays open on its paint"); + assert_eq!(u.app.session.undo_depth().0, undo + 1, "one undo step"); + } +} + +#[test] +fn closing_the_picker_drops_its_eyedropper() { + // The colour sampled after the picker closed has no paint to go to: it must not wait for the + // next picker, and the eyedropper gives way to the Move tool. + let mut s = Session::new(); + let b = rect(&mut s, 200.0, json!({"fill": "#00FF00", "stroke": "#0000FF"})); + s.execute("select.set", &json!({"ids": [b]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.picker = Some(PickerTarget::Paint { fills: false, index: 0, ids: vec![b] }); + let at = eyedropper_button(&mut u); + click(&mut u, at); + assert_eq!(u.app.session.tool.tool, ToolId::Eyedropper); + u.app.ui.picker = None; + u.app.session.tool.picked = figcraft_color::Color::from_hex("#FF0000"); + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.session.tool.tool, ToolId::Move); + assert_eq!(u.app.session.tool.picked, None, "a colour sampled for a closed picker is dropped"); +} diff --git a/crates/ui-egui/src/colorpicker_selection_tests.rs b/crates/ui-egui/src/colorpicker_selection_tests.rs index 57aae197..fff5536a 100644 --- a/crates/ui-egui/src/colorpicker_selection_tests.rs +++ b/crates/ui-egui/src/colorpicker_selection_tests.rs @@ -52,3 +52,54 @@ fn picker_closes_when_another_layer_is_selected() { u.frame(vec![]); assert!(u.app.ui.picker.is_none(), "selecting another layer closes the picker: {:?}", u.app.ui.picker); } + +// Regression test for #237: the "On this page" swatches wrapped onto rows that started at the picker's +// left edge (only the first row was indented) and ran past its right edge. +#[test] +fn wrapped_page_colour_swatches_stay_inset_inside_the_picker() { + use egui::Shape; + let mut s = Session::new(); + let mut first = None; + for i in 0..40 { + let v = s + .execute( + "shape.rectangle", + &json!({"x": i * 30, "y": 0, "width": 20, "height": 20, "props": {"fill": format!("#{:02X}{:02X}80", i * 6, 255 - i * 6)}}), + ) + .unwrap(); + first.get_or_insert(NodeId(v["id"].as_u64().unwrap())); + } + let a = first.unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.picker = Some(PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); + for _ in 0..3 { + u.frame(vec![]); + } + let out = u.frame_output(vec![]); + // The picker is the only floating window open. + let picker = u + .ctx + .memory(|m| m.areas().visible_layer_ids().into_iter().filter(|l| l.order == egui::Order::Middle).find_map(|l| m.area_rect(l.id))) + .expect("the picker is open"); + // A swatch is a 14 pt square with a 1 pt outline. + let swatches: Vec = out + .shapes + .iter() + .filter_map(|c| match &c.shape { + Shape::Rect(r) if r.stroke.width == 1.0 && (r.rect.width() - 14.0).abs() < 0.01 && (r.rect.height() - 14.0).abs() < 0.01 => Some(r.rect), + _ => None, + }) + .filter(|r| picker.contains_rect(*r)) + .collect(); + let mut rows: Vec = swatches.iter().map(|r| r.min.y).collect(); + rows.sort_by(f32::total_cmp); + rows.dedup_by(|a, b| (*a - *b).abs() < 1.0); + assert!(rows.len() >= 2, "expected the page colours to wrap onto several rows, got {} swatches in {} row(s)", swatches.len(), rows.len()); + for r in &swatches { + assert!( + r.min.x >= picker.min.x + 16.0 - 0.5 && r.max.x <= picker.max.x - 16.0 + 0.5, + "swatch {r:?} is not inset 16 pt from both sides of the picker {picker:?}" + ); + } +} diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 2b2573c7..6caab6d5 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -88,7 +88,7 @@ fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) /// Local components (and component sets, not their variants), for swap menus: (id, label), page /// by page. Listed once per document revision (a file with thousands of layers isn't walked every /// frame). -fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc> { +pub(crate) fn local_components(app: &FigApp, ui: &Ui, doc: &Document) -> std::sync::Arc> { type Cached = ((u64, u64), std::sync::Arc>); let key = app.session.ds().map(|d| (d.uid, d.revision)).unwrap_or_default(); let id = Id::new("component_panel_local_components"); @@ -521,6 +521,7 @@ fn group_label(g: &str) -> String { "grids" => tr("Layout grids"), "exports" => tr("Export settings"), "interactions" => tr("Interactions"), + "modes" => tr("Variable modes"), "size" => tr("Size"), "resizing" => tr("Resizing"), "position" => tr("Position"), @@ -708,35 +709,9 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps }); } PropDef::InstanceSwap { preferred, .. } => { - let cur = figcraft_engine::params::id_of(&v); - let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into()); - let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance)); - menu_popup(&resp).width(200.0).show(|ui| { - let t = Tokens::get(ui.ctx()); - let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); - let mut pick = |ui: &mut Ui, c: NodeId, name: &str| { - let current = cur == Some(c); - if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { - if !current { - app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); - } - ui.close(); - } - }; - let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect(); - if !preferred.is_empty() { - heading(ui, tr("Preferred")); - for (c, name) in &preferred { - pick(ui, *c, name); - } - heading(ui, tr("All components")); - } - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for (c, name) in comps { - pick(ui, *c, name); - } - }); - }); + if let Some(c) = swap_dropdown(ui, doc, figcraft_engine::params::id_of(&v), &preferred, comps, w, "").1 { + app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): c.0}})); + } } PropDef::Variant { .. } => {} PropDef::Slot { preferred } => { @@ -749,6 +724,54 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps } } +/// The dropdown of an instance-swap property showing component `cur`: its menu lists the +/// property's preferred components first (when it has any), then every component in the file. The +/// dropdown, and the component picked (picking the current one picks nothing). `name` labels it for +/// assistive technology. +pub(crate) fn swap_dropdown( + ui: &mut Ui, + doc: &Document, + cur: Option, + preferred: &[NodeId], + comps: &[(NodeId, String)], + w: f32, + name: &str, +) -> (egui::Response, Option) { + let label = cur.and_then(|c| doc.node(c)).map(|c| c.name.clone()).unwrap_or_else(|| tr("None").into()); + let resp = widgets::dropdown(ui, &label, w, Some(Icon::Instance)); + if !name.is_empty() { + crate::a11y::combo(&resp, name, &label, crate::a11y::menu_open(&resp)); + } + let mut picked = None; + menu_popup(&resp).width(200.0).show(|ui| { + let t = Tokens::get(ui.ctx()); + let heading = |ui: &mut Ui, text: &str| ui.label(egui::RichText::new(text).font(semibold(11.0)).color(t.text_secondary)); + let mut pick = |ui: &mut Ui, c: NodeId, name: &str| { + let current = cur == Some(c); + if menu_item(ui, &format!("{}{name}", if current { "✓ " } else { "" }), "") { + if !current { + picked = Some(c); + } + ui.close(); + } + }; + let preferred: Vec<(NodeId, String)> = preferred.iter().filter_map(|c| doc.node(*c).map(|n| (*c, n.name.clone()))).collect(); + if !preferred.is_empty() { + heading(ui, tr("Preferred")); + for (c, name) in &preferred { + pick(ui, *c, name); + } + heading(ui, tr("All components")); + } + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for (c, name) in comps { + pick(ui, *c, name); + } + }); + }); + (resp, picked) +} + fn prop_label(ui: &mut Ui, name: &str, icon: Icon) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(84.0, 20.0), Sense::hover()); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4daea00b..edd9fff7 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -336,7 +336,8 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul .ok_or("`events` required: [{move|down|up: [x, y]} | {key, shift?, alt?, cmd?} | {text} | {leave: true}]")?; let pos = |v: &Value| -> Option { let a = v.as_array()?; - Some(egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32)) + let p = egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32); + p.is_finite().then_some(p) }; let mut events = Vec::new(); let mut pads = Vec::new(); diff --git a/crates/ui-egui/src/control_roots_tests.rs b/crates/ui-egui/src/control_roots_tests.rs index 35454bb5..763f00e6 100644 --- a/crates/ui-egui/src/control_roots_tests.rs +++ b/crates/ui-egui/src/control_roots_tests.rs @@ -90,3 +90,13 @@ fn window_captures_are_written_through_the_roots_or_returned() { }); assert!(d.0.join("out/shot.png").is_file() && !d.0.join("outside/shot.png").exists()); } + +#[test] +fn ui_input_rejects_non_finite_coordinates() { + let (mut app, _d) = confined_app("input"); + for ev in [json!({"move": [1e300, 5]}), json!({"down": [5, 1e300]}), json!({"scroll": [1e300, 0]})] { + let r = handle(&mut app, "ui.input", json!({"events": [ev]})); + assert!(r.is_err(), "{ev}: {r:?}"); + } + assert!(app.control_input.iter().all(|e| !matches!(e, egui::Event::PointerMoved(p) if !p.is_finite()))); +} diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs new file mode 100644 index 00000000..5a36f073 --- /dev/null +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -0,0 +1,75 @@ +//! Individual corners in the Design panel, driven headless: the four corner fields sit where +//! their corners are (top left, top right / bottom left, bottom right) and each sets its own. + +use egui::accesskit::Role; +use egui::{Event, Id, Key, Modifiers, Pos2, Rect, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +/// A rectangle rounded only on its left corners (top left and bottom left 30), selected. +fn scene() -> (Ui, NodeId) { + let mut s = Session::new(); + let r = NodeId(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 120})).unwrap()["id"].as_u64().unwrap()); + s.execute("node.set", &json!({"ids": [r], "props": {"topLeftRadius": 30, "bottomLeftRadius": 30}})).unwrap(); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + (Ui::new(s), r) +} + +/// The rectangle's radii as `[top left, top right, bottom right, bottom left]`. +fn radii(u: &Ui, r: NodeId) -> [f64; 4] { + u.app.session.doc().unwrap().node(r).unwrap().corners.radii +} + +/// A full press-and-release at `p`, then settling one frame. +fn click(u: &mut Ui, p: Pos2) { + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +/// A press-and-release of Enter, committing the focused number field. +fn enter(u: &mut Ui) { + u.frame(vec![ + Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, + Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, + ]); +} + +/// The middle of the toggle button named `label`, found in the AccessKit tree. +fn named_button(u: &mut Ui, label: &str) -> Pos2 { + u.ctx.enable_accesskit(); + u.frame(vec![]); + let out = u.frame_output(vec![]); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let n = nodes.iter().map(|(_, n)| n).find(|n| n.role() == Role::Button && n.label() == Some(label)).expect(label); + let b = n.bounds().expect("bounds"); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// Corner field `k` (an index into the radii). +fn field(u: &Ui, k: usize) -> Rect { + u.ctx.read_response(Id::new(("corner", k)).with("field")).expect("corner field shows").rect +} + +/// The fields sit at their corners, and typing in the bottom right one sets only that corner in a +/// single undo step (#1002: the bottom row showed bottom right on the left). +#[test] +fn corner_fields_sit_at_their_corners_and_set_their_own() { + let (mut u, r) = scene(); + let t = named_button(&mut u, "Individual corners"); + click(&mut u, t); + let (tl, tr, br, bl) = (field(&u, 0).center(), field(&u, 1).center(), field(&u, 2).center(), field(&u, 3).center()); + assert!(tl.x < tr.x && (tl.y - tr.y).abs() < 1.0, "top row: top left, then top right"); + assert!(bl.x < br.x && (bl.y - br.y).abs() < 1.0, "bottom row: bottom left, then bottom right"); + assert!(bl.y > tl.y && (bl.x - tl.x).abs() < 1.0, "bottom left sits under top left"); + let history = u.app.session.undo_depth().0; + click(&mut u, br); + u.frame(vec![Event::Text("12".into())]); + enter(&mut u); + u.frame(vec![]); + assert_eq!(radii(&u, r), [30.0, 0.0, 12.0, 30.0]); + assert_eq!(u.app.session.undo_depth().0, history + 1); +} diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 1b21ded2..8783b0dd 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -3,22 +3,25 @@ //! categories and the filter of the callouts, the handoff status of the selected frames (with the //! focus view and Compare changes), the list of frames and sections that are ready for dev, the //! focus view's bar, Connect to code (a component's link to its code component), dev resources -//! (links for developers) and the plugins that only read the document. +//! (links for developers), measurements kept on the canvas and the plugins that only read the +//! document. use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use figcraft_doc::{Annotation, AnnotationCategory, CodeLink, DevStatus, Document, NodeId}; use figcraft_engine::annotations::{self, PINNABLE, Pinnable}; use figcraft_engine::code_connect; use figcraft_engine::codegen::{CodeLang, code_for}; +use figcraft_engine::dev_measurements::{self, Placed}; use figcraft_engine::devinspect::{self, DevItem, Prop}; use figcraft_engine::plugins::Access; +use figcraft_engine::snap::Guide; use figcraft_engine::{components, dev_resources}; use serde_json::json; use crate::a11y::Tip; use crate::i18n::{tr, tr_noop, trf, trn}; use crate::icons::{self, Icon}; -use crate::right::{menu_check, menu_item_with, menu_popup, prop_row, row}; +use crate::right::{menu_check, menu_item, menu_item_with, menu_popup, prop_row, row}; use crate::theme::{ACCENT, Tokens, doc_c32, regular, semibold}; use crate::widgets::{self, DropLead, PAD}; use crate::{FigApp, RightTab}; @@ -94,6 +97,9 @@ pub struct DevUi { pub compare: Option, annotated: Vec, annotated_key: Option<(u64, u64, NodeId)>, + /// The measurements kept on the current page, with their lines. + measured: Vec, + measured_key: Option<(u64, u64, NodeId)>, ready: Vec, ready_key: Option<(u64, u64, usize)>, /// Every ready-for-dev frame (the focus view's previous / next). @@ -190,6 +196,35 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter) { } } +/// Bring the current page's measurements up to date (when the document, its revision or the page +/// changed). +pub fn refresh_measurements(app: &mut FigApp) { + let Ok(ds) = app.session.ds() else { return }; + let key = (ds.uid, ds.revision, ds.page); + if app.ui.dev.measured_key != Some(key) { + app.ui.dev.measured = dev_measurements::on_page(&ds.doc, ds.page); + app.ui.dev.measured_key = Some(key); + } +} + +/// The lines of the measurements kept on the canvas, drawn with the hover measurements (Inspect +/// tab only; both layers shown; the focus view's frame only). +pub fn saved_lines(app: &FigApp) -> impl Iterator { + let ds = app.session.ds().ok().filter(|_| active(app)); + let focus = ds.and_then(figcraft_engine::DocState::focused); + app.ui + .dev + .measured + .iter() + .filter(move |m| { + ds.is_some_and(|ds| { + let shown = |id: NodeId| ds.doc.node(id).is_some_and(|n| n.visible); + shown(m.from) && m.measurement.to.is_none_or(shown) && focus.is_none_or(|f| ds.doc.is_ancestor(f, m.from)) + }) + }) + .flat_map(|m| &m.lines) +} + /// One annotation as a card: its category, its note (up to six lines) and its pinned properties /// with their values. struct Callout { @@ -285,13 +320,14 @@ pub fn code(app: &mut FigApp, ui: &Ui, id: NodeId, lang: CodeLang) -> String { } /// The component playground, for a selected instance, component or component set: a preview of the -/// component and a control for each variant, boolean and text property. The values tried here -/// change the preview and the code only (returned when any is set: the Code section shows it), -/// never the document. +/// component and a control for each variant, boolean, text and instance-swap property. The values +/// tried here change the preview and the code only (returned when any is set: the Code section +/// shows it), never the document. pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, lang: CodeLang) -> Option { figcraft_engine::playground::component_of(doc, id)?; let t = Tokens::get(ui.ctx()); let Ok((uid, revision)) = app.session.ds().map(|d| (d.uid, d.revision)) else { return None }; + let comps = crate::component_panel::local_components(app, ui, doc); let play = &mut app.ui.dev.playground; if play.target != Some(id) { *play = Playground { target: Some(id), ..Playground::default() }; @@ -361,7 +397,16 @@ pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: Nod set = Some((name.to_string(), json!(new.chars().take(10_000).collect::()))); } }), - // Instance swaps and slots show as the component has them. + "instanceSwap" => row(ui, 32.0, |ui| { + widgets::label(ui, name, t.text_secondary); + let preferred: Vec = p["preferred"].as_array().into_iter().flatten().filter_map(figcraft_engine::params::id_of).collect(); + let w = ui.available_width().max(60.0); + if let Some(c) = crate::component_panel::swap_dropdown(ui, doc, figcraft_engine::params::id_of(value), &preferred, &comps, w, name).1 + { + set = Some((name.to_string(), json!(c.0))); + } + }), + // Slots show as the component has them. _ => {} } } @@ -680,6 +725,97 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: ui.add_space(4.0); } +/// The menu label of measuring along `side`. +fn side_label(side: figcraft_doc::Side) -> &'static str { + use figcraft_doc::Side; + match side { + Side::Top => tr("Along the top"), + Side::Right => tr("Along the right"), + Side::Bottom => tr("Along the bottom"), + Side::Left | Side::Auto => tr("Along the left"), + } +} + +/// Measurements kept on the canvas: those from or to the selected layer (with nothing selected, +/// every one on the page), each with its distances; a click selects the layer it starts from and +/// − removes it. With a layer selected, a menu places a new one: between two selected layers, to +/// the layer around it or along one of its sides. +pub fn measurements_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { + use figcraft_doc::Side; + refresh_measurements(app); + let t = Tokens::get(ui.ctx()); + let first = sel.first().copied().filter(|id| doc.node(*id).is_some_and(|n| !n.is_page())); + let rows: Vec<&Placed> = app.ui.dev.measured.iter().filter(|m| first.is_none_or(|id| m.touches(id))).collect(); + if first.is_none() && rows.is_empty() { + return; + } + let mut action: Option<(&str, serde_json::Value)> = None; + widgets::separator(ui); + widgets::section_header(ui, tr("Measurements"), &[], rows.is_empty()); + for m in &rows { + if let Some(a) = measurement_row(ui, &t, doc, m) { + action = Some(a); + } + } + if let Some(id) = first { + let around = doc.node(id).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| !p.is_page()); + let w = ui.available_width() - PAD * 2.0; + row(ui, 32.0, |ui| { + let resp = widgets::dropdown(ui, tr("Add measurement"), w, Some(Icon::Measure)) + .on_hover_text(tr("Keep a measurement on the canvas for developers")); + menu_popup(&resp).show(|ui| { + if let [a, b, ..] = sel + && menu_item(ui, tr("Between the selected layers"), "") + { + action = Some(("devMode.addMeasurement", json!({"from": a, "to": b}))); + } + if around && menu_item(ui, tr("To the layer around it"), "") { + action = Some(("devMode.addMeasurement", json!({"from": id}))); + } + for side in [Side::Top, Side::Right, Side::Bottom, Side::Left] { + if menu_item(ui, side_label(side), "") { + action = Some(("devMode.addMeasurement", json!({"from": id, "side": side}))); + } + } + }); + }); + } + if let Some((cmd, params)) = action { + app.run(cmd, params); + } + ui.add_space(4.0); +} + +/// One measurement in the Inspect tab, on two lines: what it measures, and its distances below. A +/// click selects the layer it starts from, − removes it: the command to run. +fn measurement_row(ui: &mut Ui, t: &Tokens, doc: &Document, m: &Placed) -> Option<(&'static str, serde_json::Value)> { + let name = |id: NodeId| doc.node(id).map_or(String::new(), |n| n.name.clone()); + let mut action = None; + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 36.0), Sense::click()); + if resp.hovered() { + ui.painter().rect_filled(r.shrink2(vec2(PAD / 2.0, 1.0)), CornerRadius::same(4), t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + PAD + 7.0, r.center().y), vec2(14.0, 14.0)), Icon::Measure, t.icon_secondary); + let what = match (m.measurement.to, m.measurement.side) { + (Some(to), _) => format!("{} → {}", name(m.from), name(to)), + (None, side) => format!("{} · {}", name(m.from), side.map_or("", side_label)), + }; + let value = m.labels().join(" · "); + let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0)); + let x = r.min.x + PAD + 22.0; + let text_w = del.min.x - 6.0 - x; + ui.painter().text(pos2(x, r.center().y - 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &what, text_w), regular(11.0), t.text); + ui.painter().text(pos2(x, r.center().y + 7.0), Align2::LEFT_CENTER, widgets::elide(ui, &value, text_w), regular(10.0), t.text_secondary); + crate::a11y::button(&resp, &format!("{what}: {value}")); + if resp.clicked() { + action = Some(("select.set", json!({"ids": [m.from]}))); + } + if widgets::icon_button_at(ui, Id::new(("measure_del", m.from, m.index)), del, Icon::Minus, tr("Remove measurement")) { + action = Some(("devMode.removeMeasurement", json!({"from": m.from, "index": m.index}))); + } + action +} + /// The category of annotation `a` as a dropdown: the one picked (`Value::Null` for none). fn category_menu(ui: &mut Ui, doc: &Document, id: Id, a: &Annotation) -> Option { let current = annotations::category_of(doc, a); diff --git a/crates/ui-egui/src/dev_mode_tests.rs b/crates/ui-egui/src/dev_mode_tests.rs index 941f1b2c..3e2ea9ba 100644 --- a/crates/ui-egui/src/dev_mode_tests.rs +++ b/crates/ui-egui/src/dev_mode_tests.rs @@ -1,8 +1,10 @@ //! Dev Mode in the UI, driven headless (the harness is `present_tests`'): the compare view opens //! from `dev.compare {show}`, switches to the overlay and closes with Escape before the focus view -//! does; the focus view's Escape; the annotation filter. +//! does; the focus view's Escape; the annotation filter; the playground's instance-swap menu. -use egui::{Key, pos2}; +use egui::accesskit::{Node, Role}; +use egui::{Event, Key, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2}; +use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::json; @@ -80,3 +82,76 @@ fn annotation_filter_and_categories_panel() { assert_eq!(CategoryFilter::parse(&doc, "none"), Some(CategoryFilter::Uncategorized)); assert_eq!(CategoryFilter::parse(&doc, "nope"), None); } + +/// A "Button" component whose nested "Heart" instance is its instance-swap property "Icon", a +/// second icon "Star", and an instance of the button selected in the Inspect tab. Returns (ui, +/// instance, heart, star). +fn swap_setup() -> (crate::layers_drag_tests::Ui, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let icon = |s: &mut Session, name: &str, x: i32| { + let f = id(&s.execute("frame.create", &json!({"x": x, "y": 0, "width": 24, "height": 24, "name": name})).unwrap()); + id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()) + }; + let (heart, star) = (icon(&mut s, "Heart", 0), icon(&mut s, "Star", 40)); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 100, "width": 120, "height": 40, "name": "Button"})).unwrap()); + let nested = id(&s.execute("instance.create", &json!({"component": heart, "x": 8, "y": 108, "parent": f})).unwrap()); + let button = id(&s.execute("component.create", &json!({"ids": [f]})).unwrap()); + s.execute("component.addProperty", &json!({"id": button, "name": "Icon", "type": "instanceSwap", "default": heart, "layer": nested})).unwrap(); + let inst = id(&s.execute("instance.create", &json!({"component": button, "x": 0, "y": 300})).unwrap()); + s.execute("select.set", &json!({"ids": [inst]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.ctx.enable_accesskit(); + (u, inst, heart, star) +} + +fn tree(u: &mut crate::layers_drag_tests::Ui) -> Vec { + u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes.into_iter().map(|(_, n)| n).collect() +} + +fn centre(n: &Node) -> Pos2 { + let b = n.bounds().unwrap(); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click(u: &mut crate::layers_drag_tests::Ui, at: Pos2) { + u.button(at, true); + u.button(at, false); +} + +/// The playground's dropdown for property `name`, scrolled into view. +fn playground_combo(u: &mut crate::layers_drag_tests::Ui, name: &str) -> Option { + for _ in 0..20 { + let combo = tree(u).into_iter().find(|n| n.role() == Role::ComboBox && n.label() == Some(name))?; + if centre(&combo).y < 860.0 { + return Some(combo); + } + let wheel = + Event::MouseWheel { unit: MouseWheelUnit::Point, delta: vec2(0.0, -100.0), phase: TouchPhase::Move, modifiers: Default::default() }; + u.frame(vec![Event::PointerMoved(pos2(1300.0, 500.0)), wheel]); + for _ in 0..30 { + u.frame(vec![]); + } + } + None +} + +#[test] +fn the_playground_swaps_an_instance_in_the_preview_only() { + let (mut u, inst, heart, star) = swap_setup(); + let (doc, undo) = (u.app.session.ds().unwrap().doc.clone(), u.app.session.undo_depth()); + let combo = playground_combo(&mut u, "Icon").expect("the playground has a dropdown for the instance-swap property"); + assert_eq!(combo.value(), Some("Heart")); + // Open it and pick Star: the item that wasn't there before the menu opened. + let before: Vec = tree(&mut u).iter().filter(|n| n.label() == Some("Star")).map(centre).collect(); + click(&mut u, centre(&combo)); + let item = tree(&mut u).into_iter().find(|n| n.label() == Some("Star") && !before.contains(¢re(n))).expect("Star in the menu"); + click(&mut u, centre(&item)); + let combo = playground_combo(&mut u, "Icon").unwrap(); + assert_eq!(combo.value(), Some("Star"), "the playground shows the swapped icon"); + // The file isn't touched: the instance still has the heart, nothing to undo. + assert!(std::sync::Arc::ptr_eq(&doc, &u.app.session.ds().unwrap().doc)); + assert_eq!(u.app.session.undo_depth(), undo); + let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap(); + assert_eq!(props["properties"][0]["value"], json!(heart.0), "{star:?}"); +} diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs index 7008e9e5..b7901083 100644 --- a/crates/ui-egui/src/devmode_tail_tests.rs +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -108,3 +108,81 @@ fn dev_mode_pastes_only_a_comments_link() { crate::menus::paste(&mut app, None, None); assert_eq!(app.ui.comments.open, Some(c), "the link opens its thread"); } + +#[test] +fn dev_mode_layers_panel_selects_and_expands_but_edits_nothing() { + use egui::{Event, Pos2}; + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "Login"})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 120, "height": 40, "parent": f})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 90, "width": 120, "height": 40, "parent": f})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.app.ui.expanded.insert(f); + u.frame(vec![]); + let order = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(f).unwrap().children.clone(); + let before = order(&u); + let (ra, rb) = (u.row_at(a), u.row_at(b)); + let click = |u: &mut crate::layers_drag_tests::Ui, p: Pos2| { + u.button(p, true); + u.button(p, false); + }; + let rest = |u: &mut crate::layers_drag_tests::Ui| { + for _ in 0..30 { + u.frame(vec![]); + } + }; + // A click selects. + click(&mut u, ra); + assert_eq!(u.app.session.selection(), [a]); + // A double click doesn't rename, and says why. + click(&mut u, ra); + assert!(u.app.ui.rename.is_none(), "no rename"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + rest(&mut u); + // The eye and padlock spots don't hide or lock (the row is selected instead). + let (eye, padlock) = (272.0, 250.0); + for x in [eye, padlock] { + u.frame(vec![Event::PointerMoved(Pos2::new(x, rb.y))]); + click(&mut u, Pos2::new(x, rb.y)); + rest(&mut u); + } + let doc = u.app.session.doc().unwrap(); + assert!(doc.node(b).unwrap().visible && !doc.node(b).unwrap().locked, "not hidden or locked"); + assert_eq!(u.app.session.selection(), [b]); + // Dragging a row doesn't reorder. + u.drag_to(ra, rb + egui::vec2(0.0, 6.0)); + u.button(rb + egui::vec2(0.0, 6.0), false); + rest(&mut u); + assert_eq!(order(&u), before, "not reordered"); + // Back in the design, the same double click renames. + u.app.ui.right_tab = crate::RightTab::Design; + u.frame(vec![]); + click(&mut u, ra); + click(&mut u, ra); + assert!(u.app.ui.rename.is_some_and(|r| r.id == a)); +} + +#[test] +fn measurements_kept_on_the_canvas_draw_in_dev_mode_and_follow_their_layers() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 400})).unwrap()); + let a = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 30, "width": 120, "height": 40, "parent": f})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 20, "y": 90, "width": 120, "height": 40, "parent": f})).unwrap()); + let mut u = Ui::new(s); + u.run("devMode.addMeasurement", json!({"from": a, "to": b})); + let labels = |u: &Ui| crate::dev_mode::saved_lines(&u.app).filter_map(|g| g.label.clone()).collect::>(); + u.frame(vec![]); + assert!(labels(&u).is_empty(), "the design doesn't show them"); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + assert_eq!(labels(&u), ["20"]); + u.run("node.set", json!({"id": b, "props": {"y": 110}})); + u.frame(vec![]); + assert_eq!(labels(&u), ["40"], "it follows the layer"); + // Hidden layers' measurements aren't drawn. + u.run("node.set", json!({"id": b, "props": {"visible": false}})); + u.frame(vec![]); + assert!(labels(&u).is_empty()); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bf9f1b29..85d8ca15 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -498,12 +498,42 @@ fn interface_size(app: &mut FigApp, ui: &mut egui::Ui, chosen: f64) { let resp = crate::widgets::dropdown(ui, &shown, 120.0, None); crate::a11y::combo(&resp, tr("Interface size"), &shown, crate::a11y::menu_open(&resp)); crate::right::menu_popup(&resp).show(|ui| { + let focused = ui.memory(|memory| memory.focused()); + let step = ui.input(|input| { + if input.key_pressed(egui::Key::ArrowDown) || (input.key_pressed(egui::Key::Tab) && !input.modifiers.shift) { + 1 + } else if input.key_pressed(egui::Key::ArrowUp) || (input.key_pressed(egui::Key::Tab) && input.modifiers.shift) { + -1 + } else { + 0 + } + }); + let mut rows = Vec::with_capacity(crate::ui_scale::SIZES.len()); for size in crate::ui_scale::SIZES { - if crate::right::menu_check(ui, (chosen - size).abs() < 1e-6, &percent(size), "") { + let label = percent(size); + let selected = (chosen - size).abs() < 1e-6; + let row = crate::right::menu_row(ui, &format!("{}{label}", if selected { "✓ " } else { " " }), "", 0.0, |_, _, _| {}); + crate::a11y::menu_item(&row, &label, "", Some(selected)); + if row.has_focus() { + ui.memory_mut(|memory| memory.set_focus_lock_filter(row.id, egui::EventFilter { tab: true, ..Default::default() })); + } + rows.push(row.id); + if row.clicked() { app.run("pref.uiScale", json!({"scale": size})); ui.close(); } } + // Spatial focus can jump to a control behind the popup when the menu overlaps the + // Preferences window. Once an item is focused, walk the menu's own rows instead. + if step != 0 + && let Some(index) = rows.iter().position(|id| Some(*id) == focused) + { + let next = index.saturating_add_signed(step).min(rows.len() - 1); + ui.memory_mut(|memory| { + memory.request_focus(rows[next]); + memory.move_focus(egui::FocusDirection::None); + }); + } }); }); crate::widgets::label( diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index fea0be8e..dd63988a 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -370,6 +370,58 @@ fn arithmetic_commits_tab_moves_on_and_escape_restores() { assert_eq!(geom(&u, a).0, 78.0); } +/// Answers a Save as dialog with `path`, once, and counts the dialogs asked for. +struct SaveAsPicker { + path: Option, + asked: std::rc::Rc>, +} + +impl crate::Services for SaveAsPicker { + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> crate::DialogFuture { + self.asked.set(self.asked.get() + 1); + Box::pin(std::future::ready(self.path.take())) + } +} + +/// The x of layer `a` in the file at `path`, opened again. +fn saved_x(path: &std::path::Path, a: NodeId) -> f64 { + let mut back = Session::new(); + back.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + back.doc().unwrap().world_bounds(a).unwrap().x0 +} + +#[test] +fn the_save_shortcut_saves_from_a_field_with_what_was_typed() { + // Cmd/Ctrl+S with the keyboard in a panel field saved nothing (#1157). It commits the field + // first (as clicking away does), then saves; the field lets go of the keyboard. + // Shift+Cmd/Ctrl+S does the same through Save as. + let cmd = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-save-from-field-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, copy) = (dir.join("doc.figcraft"), dir.join("copy.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + for (shortcut, mods, x, file) in [("Cmd/Ctrl+S", cmd, 77.0, &path), ("Shift+Cmd/Ctrl+S", cmd.plus(Modifiers::SHIFT), 66.0, ©)] { + let (p, _) = field(&mut u, "X"); + click(&mut u, p); + assert!(crate::a11y::field_focused(&u.ctx), "{shortcut}: the field has the keyboard"); + u.frame(vec![Event::Text(x.to_string())]); + key(&mut u, Key::S, mods); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(geom(&u, a).0, x, "{shortcut}: what was typed is committed"); + assert!(!crate::a11y::field_focused(&u.ctx), "{shortcut}: the field let go"); + assert!(!u.app.session.ds().unwrap().is_dirty(), "{shortcut}: saved"); + assert_eq!(saved_x(file, a), x, "{shortcut}: the file on disk has the typed value"); + } + assert_eq!(asked.get(), 1, "only Save as asked where"); + assert_eq!(saved_x(&path, a), 77.0, "Save as left the first file alone"); + std::fs::remove_dir_all(&dir).ok(); +} + #[test] fn mixed_values_take_edits_relative_to_each_layer() { let (mut u, a, b) = setup(); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index f2b3b631..f46d8658 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -83,6 +83,11 @@ icons! { FlipH, FlipV, CornerRadius, + /// Individual corners: the rounded corner each field sets. + CornerTopLeft, + CornerTopRight, + CornerBottomRight, + CornerBottomLeft, Angle, IndependentCorners, /// Individual padding: a frame with one inward tick per side, each set alone. @@ -206,6 +211,8 @@ icons! { CurvedConnector, Shapes, Stamp, + /// A measurement: a dimension line with arrowheads between two end ticks. + Measure, } /// Points along a circular arc from angle `a0` to `a1` (radians, y down: increasing is clockwise). @@ -721,7 +728,10 @@ fn draw(p: &P, icon: Icon) { p.poly(&[(3.5, 5.5), (12.5, 5.5), (12.5, 1.5)], true); p.fill(&[(3.0, 9.0), (13.0, 9.0), (13.0, 14.0)]); } - CornerRadius => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0), + CornerRadius | CornerTopLeft => p.corner(2.5, 2.5, 1.0, 1.0, 11.5, 6.0), + CornerTopRight => p.corner(13.5, 2.5, -1.0, 1.0, 11.5, 6.0), + CornerBottomRight => p.corner(13.5, 13.5, -1.0, -1.0, 11.5, 6.0), + CornerBottomLeft => p.corner(2.5, 13.5, 1.0, -1.0, 11.5, 6.0), Angle => { // Two rays from a vertex, the arc between them marking the angle (all unhinted, so // the arc meets the rays at every size). @@ -1180,6 +1190,13 @@ fn draw(p: &P, icon: Icon) { p.circle(8.0, 8.0, 6.0); p.poly(&[(5.0, 8.3), (7.2, 10.5), (11.0, 6.0)], false); } + Measure => { + p.line(2.5, 4.0, 2.5, 12.0); + p.line(13.5, 4.0, 13.5, 12.0); + p.line(3.0, 8.0, 13.0, 8.0); + p.poly(&[(5.5, 5.5), (3.0, 8.0), (5.5, 10.5)], false); + p.poly(&[(10.5, 5.5), (13.0, 8.0), (10.5, 10.5)], false); + } Logo => { // FigCraft's own mark: the curled tail of the chameleon on the app icon. One bold // stroke: a shaft along the bottom, then three half turns, each tighter than the last. @@ -1425,4 +1442,18 @@ mod tests { let inside = (0..h.len()).all(|i| side(i) >= 0.0) || (0..h.len()).all(|i| side(i) <= 0.0); assert!(inside, "the handle covers the blade's base at {base:?}"); } + + /// Each corner's icon shows its own corner: no two of the four draw alike. + #[test] + fn corner_icons_differ() { + let drawn: Vec = [Icon::CornerTopLeft, Icon::CornerTopRight, Icon::CornerBottomRight, Icon::CornerBottomLeft] + .iter() + .map(|&icon| format!("{:?}", shapes_at_32(icon))) + .collect(); + for (i, a) in drawn.iter().enumerate() { + for b in &drawn[i + 1..] { + assert_ne!(a, b); + } + } + } } diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index f172b309..0973fd42 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -704,6 +704,11 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut fn layers(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // Dev Mode only inspects: rows select and expand, but don't rename, reorder, hide or lock. + let dev = app.ui.right_tab == crate::RightTab::Inspect; + if dev { + app.ui.rename = None; + } let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Layers"), semibold(11.0), t.text); @@ -825,7 +830,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let ui = &mut band_ui; for row in rows.iter().take(last).skip(first) { let Some(n) = doc.node(row.id) else { continue }; - let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag()); + let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), if dev { Sense::click() } else { Sense::click_and_drag() }); let selected = sel.contains(&row.id); a11y::layer_row(&mut app.ui.nav, &resp); if sel.last() == Some(&row.id) { @@ -923,14 +928,17 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Lock / eye on hover (or when set); a layer in a locked one shows a faint padlock. let hidden = swept(false).unwrap_or(!n.visible); let locked = swept(true).unwrap_or(n.locked); - let show_lock = over || locked || row.locked_above; - let show_eye = over || hidden; + // In Dev Mode they only show what is set. + let show_lock = (over && !dev) || locked || row.locked_above; + let show_eye = (over && !dev) || hidden; let er = Rect::from_center_size(pos2(rr.max.x - 14.0, r.center().y), vec2(20.0, 20.0)); let lr = Rect::from_center_size(pos2(rr.max.x - 36.0, r.center().y), vec2(20.0, 20.0)); if show_eye { + icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary); + } + if show_eye && !dev { let eresp = ui.interact(er, ui.id().with(("eye", row.id)), Sense::click_and_drag()); a11y::toggle(&eresp, tr("Hidden"), hidden); - icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary); if eresp.clicked() { vis = Some(row.id); } @@ -939,10 +947,12 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } } if show_lock { - let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click_and_drag()); - a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above); let col = if locked || !row.locked_above { t.icon_secondary } else { t.text_tertiary }; icons::paint(ui.painter(), lr.shrink(3.0), if locked || row.locked_above { Icon::Lock } else { Icon::Unlock }, col); + } + if show_lock && !dev { + let lresp = ui.interact(lr, ui.id().with(("lock", row.id)), Sense::click_and_drag()); + a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above); if lresp.clicked() { lock = Some(row.id); } @@ -953,7 +963,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if resp.clicked() { clicked = Some((row.id, ui.input(|i| i.modifiers))); } - if resp.double_clicked() { + if resp.double_clicked() && !crate::menus::dev_mode_refuses(app, "object.rename") { app.ui.rename = Some(Rename { id: row.id, start: true }); } if resp.secondary_clicked() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7ac70c39..da40bf0c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -90,12 +90,16 @@ mod canvas_fixes_tests; #[cfg(test)] mod canvas_image_rect_tests; #[cfg(test)] +mod colorpicker_eyedropper_tests; +#[cfg(test)] mod colorpicker_selection_tests; #[cfg(test)] mod comments_tests; #[cfg(test)] mod component_props_reorder_tests; #[cfg(test)] +mod corner_individual_tests; +#[cfg(test)] mod cursors_tests; #[cfg(test)] mod design_paste_tests; @@ -156,6 +160,8 @@ mod measure_nudge_tests; #[cfg(test)] mod menu_aim_tests; #[cfg(test)] +mod menu_contrast_tests; +#[cfg(test)] mod menu_input_tests; #[cfg(test)] mod missing_fonts_tests; @@ -188,6 +194,8 @@ mod popup_width_tests; #[cfg(test)] mod present_links_tests; #[cfg(test)] +mod present_tab_tests; +#[cfg(test)] mod present_tests; #[cfg(test)] mod present_view_tests; @@ -551,10 +559,10 @@ pub struct UiState { /// A message at the bottom of the window and how long it has been showing. pub toast: Option<(String, ToastClock)>, pub present: Option, - /// A presentation kept alive while its design tab or Home is in front. - pub paused_present: Option, - /// The file that owns the presentation tab. - pub present_uid: Option, + pub presents: Vec, + /// The tab shown last. It is on screen while `present` has a value. + pub present_active: Option, + pub present_next: u64, /// The full-window presenter's options (how it sizes the prototype), kept between /// presentations. pub present_view: present::PresentView, @@ -640,6 +648,9 @@ pub struct UiState { pub paste_event: bool, /// The window got the focus back since the last V release. pub refocused: bool, + /// A save shortcut pressed while a panel field had the keyboard: the field lets go (which + /// commits what was typed) and the command runs the next frame (`shortcuts::handle`). + pub after_field: Option<&'static str>, /// The modifiers held, as of the last modifier change in the raw events: a paste event doesn't /// carry them. pub held: egui::Modifiers, @@ -666,6 +677,12 @@ pub struct UiState { pub online: online::OnlineUi, /// The window was asked to close with unsaved changes: the question shows. pub confirm_close: bool, + /// The file a Save as started from the unsaved-changes question is saving: the window closes + /// once it is saved, and stays open if the dialog is cancelled or the save fails. + pub close_after_save_as: Option, + /// Save was answered: the files save next frame, once a panel field the click took the + /// keyboard from has committed what was typed (as [`UiState::after_field`]). + pub save_on_close: bool, } impl Default for UiState { @@ -708,8 +725,9 @@ impl Default for UiState { icon_gallery: false, toast: None, present: None, - paused_present: None, - present_uid: None, + presents: Vec::new(), + present_active: None, + present_next: 1, present_view: present::PresentView::default(), preview: None, picker: None, @@ -758,6 +776,7 @@ impl Default for UiState { v_pressed: false, paste_event: false, refocused: false, + after_field: None, held: egui::Modifiers::NONE, next_autosave: 0.0, auto_save_at: None, @@ -770,6 +789,8 @@ impl Default for UiState { live: live::LiveUi::default(), online: online::OnlineUi::default(), confirm_close: false, + close_after_save_as: None, + save_on_close: false, } } } @@ -1064,12 +1085,7 @@ impl FigApp { "dialog.colorProfile" => self.ui.color_profile = self.session.doc().ok().map(|d| d.color_profile), "present" => { if let Some(start) = r.data.get("start").and_then(figcraft_engine::params::id_of) { - let mut p = present::Presenter::new(start); - p.view = self.ui.present_view; - self.ui.present = Some(p); - self.ui.paused_present = None; - self.ui.present_uid = self.session.docs.get(self.session.active).map(|d| d.uid); - self.ui.home = false; + tabs::open_present(self, start); } } "present.options" => { @@ -1621,6 +1637,7 @@ impl FigApp { widgets::set_nudge(&ctx, self.session.prefs.nudge_small, self.session.prefs.nudge_big); self.sync_dev_mode(); self.end_pen_outside_design(); + tabs::relink_presents(self); tabs::tab_bar(self, ui); if self.ui.present.is_some() { // A close request can arrive while the presenter fills the window. Let its unsaved @@ -1808,7 +1825,7 @@ impl FigApp { "focus": {"region": self.ui.nav.region.id(), "keyboard": self.ui.nav.keyboard}, "reduceMotion": self.reduce_motion(), "presenting": self.ui.present.is_some(), - "presenter": self.ui.present.as_ref().or(self.ui.paused_present.as_ref()).map(present::Presenter::inspect), + "presenter": tabs::shown_or_paused(self).map(present::Presenter::inspect), "presentOptions": {"scale": self.ui.present_view.scale.id(), "deviceFrame": self.ui.present_view.device_frame, "comments": self.ui.present_view.comments, "hotspotHints": self.ui.present_view.hotspot_hints, "inspect": self.ui.present_view.inspect}, "preview": self.ui.preview.as_ref().map(|p| p.player.inspect()), "mainMenu": self.ui.main_menu, @@ -1862,6 +1879,7 @@ impl FigApp { state["uiScale"] = json!({"choice": self.session.prefs.ui_scale, "zoom": self.ui.ui_scale.zoom, "drawn": self.ui.ui_scale.drawn, "limited": self.ui.ui_scale.limited}); state["pendingOpens"] = json!(self.ui.pending_opens.len()); state["missingFonts"] = json!(self.ui.missing_fonts.as_ref().map(missing_fonts::Shown::inspect)); + state["presentTabs"] = tabs::present_tabs_inspect(self); state } } @@ -1921,5 +1939,8 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) { #[cfg(test)] mod selection_spacing_tests; +#[cfg(test)] +mod type_settings_tests; + #[cfg(test)] mod typography_scrub_tests; diff --git a/crates/ui-egui/src/measure_overlay.rs b/crates/ui-egui/src/measure_overlay.rs index 053ce861..ed8f019b 100644 --- a/crates/ui-egui/src/measure_overlay.rs +++ b/crates/ui-egui/src/measure_overlay.rs @@ -12,8 +12,9 @@ const GAP: f32 = 4.0; /// Padding inside a label's pill. const PAD: Vec2 = vec2(4.0, 2.0); -/// Draw the tool overlay's guides, measurement labels and measured box; labels keep clear of -/// `avoid` (the selection's size label). +/// Draw the tool overlay's guides, measurement labels and measured box, and in Dev Mode the +/// measurements kept on the canvas; labels keep clear of `avoid` (the selection's size label) and +/// of one another. pub fn draw(app: &FigApp, painter: &egui::Painter, t: &Tokens, avoid: Option) { let ov = &app.session.tool.overlay; let line = Stroke::new(1.0, t.guide); @@ -21,7 +22,7 @@ pub fn draw(app: &FigApp, painter: &egui::Painter, t: &Tokens, avoid: Option = Vec::new(); - for g in &ov.guides { + for g in ov.guides.iter().chain(crate::dev_mode::saved_lines(app)) { let (a, b) = (app.to_screen(g.x0, g.y0), app.to_screen(g.x1, g.y1)); if g.x0 == g.x1 && g.y0 == g.y1 { for d in [vec2(4.0, 4.0), vec2(4.0, -4.0)] { diff --git a/crates/ui-egui/src/menu_contrast_tests.rs b/crates/ui-egui/src/menu_contrast_tests.rs new file mode 100644 index 00000000..f3a9b1bd --- /dev/null +++ b/crates/ui-egui/src/menu_contrast_tests.rs @@ -0,0 +1,84 @@ +//! Panel dropdowns (the prototype device picker, Preferences, blend modes…) draw on the theme's +//! popup fill, so their rows' text must read on it in both themes (#972): drawn headless. + +use egui::{Color32, Event, Order, PointerButton, Pos2, RawInput, Rect, Shape, pos2, vec2}; + +use crate::right::{menu_check, menu_item, menu_popup}; +use crate::theme::{self, Tokens}; + +fn input(t: &mut f64, events: Vec) -> RawInput { + *t += 1.0 / 60.0; + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), time: Some(*t), events, ..Default::default() } +} + +fn press(p: Pos2, pressed: bool) -> Vec { + vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Default::default() }] +} + +fn color(x: Color32) -> figcraft_color::Color { + figcraft_color::Color::new(f32::from(x.r()) / 255.0, f32::from(x.g()) / 255.0, f32::from(x.b()) / 255.0, 1.0) +} + +/// The text painted on the foreground (menu) layers: (text, colour). +fn menu_texts(out: &egui::FullOutput) -> Vec<(String, Color32)> { + fn walk(s: &Shape, out: &mut Vec<(String, Color32)>) { + match s { + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + Shape::Text(t) => out.push((t.galley.text().to_string(), t.fallback_color)), + _ => {} + } + } + let mut texts = Vec::new(); + for c in &out.shapes { + walk(&c.shape, &mut texts); + } + texts +} + +/// Open a dropdown with `tokens` and return its rows' text colours, with the pointer away from them. +fn rows(tokens: Tokens) -> Vec<(String, Color32)> { + let ctx = egui::Context::default(); + theme::apply(&ctx, tokens); + let mut t = 0.0; + let mut frame = |events: Vec| { + let mut out = ctx.run_ui(input(&mut t, events), |ui| { + let (_, resp) = ui.allocate_exact_size(vec2(200.0, 24.0), egui::Sense::click()); + menu_popup(&resp).show(|ui| { + menu_check(ui, true, "iPhone 17 Pro Max", ""); + menu_check(ui, false, "Pixel 9", ""); + menu_item(ui, "Copy", "Cmd+C"); + }); + }); + out.textures_delta.clear(); + out + }; + let at = pos2(12.0, 12.0); + frame(vec![]); + frame(press(at, true)); + frame(press(at, false)); + // Past the popup's fade-in, so the colours are the ones it settles on. + for _ in 0..30 { + frame(vec![Event::PointerMoved(pos2(790.0, 590.0))]); + } + let out = frame(vec![]); + assert!(ctx.memory(|m| m.areas().visible_layer_ids().iter().any(|l| l.order == Order::Foreground)), "the menu opened"); + menu_texts(&out) +} + +#[test] +fn dropdown_rows_read_on_the_popup_in_both_themes() { + for tokens in [Tokens::light(), Tokens::dark()] { + // The popup's own fill (`window_fill`), what the rows are drawn on. + let fill = color(tokens.panel); + let texts = rows(tokens); + let keys = crate::toolbar::pretty_shortcut("Cmd+C"); + // Labels at WCAG AA for text; the dimmer shortcut at least 3:1. + for (label, min) in [("iPhone 17 Pro Max", 4.5), ("Pixel 9", 4.5), ("Copy", 4.5), (keys.as_str(), 3.0)] { + let Some((_, col)) = texts.iter().find(|(s, _)| s.trim() == label || s.ends_with(label)) else { + panic!("{label} painted (dark {}): {texts:?}", tokens.dark) + }; + let ratio = color(*col).contrast(fill); + assert!(ratio >= min, "{label} at {ratio:.2}:1 on the popup (dark {}): {texts:?}", tokens.dark); + } + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c48fa401..eeb25e3d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -109,8 +109,8 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, u32, u32)> { /// Dev Mode (the Inspect tab) refuses command `id` from the menus, shortcuts and clipboard: it /// edits the design ([`figcraft_engine::devinspect::dev_mode_allows`]). Says so when it does. -/// shortcut: the layers panel (rename, reorder, hide, lock) and files dropped on the canvas still -/// edit in Dev Mode; route them through here if people trip over it. +/// shortcut: files dropped on the canvas still edit in Dev Mode; route them through here if people +/// trip over it. pub fn dev_mode_refuses(app: &mut FigApp, id: &str) -> bool { let refused = app.ui.right_tab == crate::RightTab::Inspect && !figcraft_engine::devinspect::dev_mode_allows(id); if refused { diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 9c0f1608..fae1e1a7 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -4,8 +4,9 @@ //! The player works on its own copy of the document (interactive components, variables and scroll //! offsets change it without touching the user's file) and copies the editor's document again //! whenever it changes, so the preview pane follows edits live. On a device, the presented frame -//! is sized to the device's screen ([`fit_viewport`]): what doesn't fit is clipped, or scrolls when -//! the frame's overflow allows it. A responsive prototype's frame is instead resized to the screen +//! is sized to the device's screen ([`fit_viewport`]): a frame bigger than the screen scrolls +//! through its whole size (as in Figma, with no overflow setting), while content past the frame's +//! own edges scrolls only as its overflow allows and is otherwise clipped. A responsive prototype's frame is instead resized to the screen //! (the device's, else the player's whole area) and laid out again, its constraints and auto layout //! responding (`figcraft_engine::cmd::proto::respond`, on the player's copy, from the design each //! time). Device bodies are drawn in code in the device's colour. Every frame is rendered through @@ -330,6 +331,11 @@ impl Presenter { true } + /// The frame the presentation started from (Restart goes back there). + pub fn start(&self) -> NodeId { + self.start + } + pub fn new(start: NodeId) -> Self { Self { current: start, @@ -511,6 +517,16 @@ impl Presenter { self.rev += 1; } + /// After an action changed layers of `design` (since `before`): text resizes to what it shows, + /// instances follow and auto layout reflows, as after an edit on the canvas; then + /// [`relayout`](Self::relayout). + fn settle(&mut self, before: &Document) { + if let Some(d) = self.design.as_mut() { + figcraft_engine::normalize(d, before); + } + self.relayout(); + } + /// Responsive prototypes: resize the presented frame to a screen of `size` (None: frames /// keep their size). Lays out again only when it changes. fn respond_to(&mut self, size: Option<(f64, f64)>) { @@ -879,28 +895,31 @@ impl Presenter { } self.begin(tr, dur, easing, now); let Some(d) = self.design.as_mut() else { return false }; + let before = d.clone(); if let Some(n) = d.node_mut(inst) && let NodeKind::Instance { component, .. } = &mut n.kind { *component = *dest; } figcraft_engine::components::sync_instance(d, inst); - self.relayout(); + self.settle(&before); true } Action::SetVariable { variable, value, expr } => { let Some(d) = self.design.as_mut() else { return false }; + let before = d.clone(); let set = figcraft_engine::proto_run::set_variable(d, node, variable, value, expr.as_ref()); if set { - self.relayout(); + self.settle(&before); } set } Action::SetVariableMode { collection, mode } => { let Some(d) = self.design.as_mut() else { return false }; + let before = d.clone(); let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode); if set { - self.relayout(); + self.settle(&before); } set } @@ -1170,11 +1189,13 @@ pub fn scroll_range(doc: &Document, frame: NodeId) -> (f64, f64) { /// How far `frame`'s content can scroll when `viewport` (page units, from its top-left) of it /// shows, as a device's screen shows a top-level frame: the frame itself is content then, so a -/// frame taller than the screen scrolls through its whole height. Without a viewport the frame -/// shows at its own size and what overflows it scrolls. Only the axes its overflow allows scroll. +/// frame bigger than the screen scrolls through its whole size whatever its overflow (as in +/// Figma, a top-level frame needs no overflow setting for that). Without a viewport the frame +/// shows at its own size and what overflows it scrolls. Content past the frame's own edges +/// scrolls only on the axes its overflow allows. pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64)>) -> (f64, f64) { let Some(n) = doc.node(frame) else { return (0.0, 0.0) }; - if n.overflow == Overflow::None { + if n.overflow == Overflow::None && viewport.is_none() { return (0.0, 0.0); } let (mut x1, mut y1) = (0.0f64, 0.0f64); @@ -1191,6 +1212,13 @@ pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64 x1 += l.padding[1]; y1 += l.padding[2]; } + // Axes the overflow doesn't scroll clip what lies past the frame's edges. + if !matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { + x1 = 0.0; + } + if !matches!(n.overflow, Overflow::Vertical | Overflow::Both) { + y1 = 0.0; + } let (vw, vh) = match viewport { Some(v) => { x1 = x1.max(n.width); @@ -1199,8 +1227,7 @@ pub fn scroll_range_in(doc: &Document, frame: NodeId, viewport: Option<(f64, f64 } None => (n.width, n.height), }; - let rx = if matches!(n.overflow, Overflow::Horizontal | Overflow::Both) { (x1 - vw).max(0.0) } else { 0.0 }; - let ry = if matches!(n.overflow, Overflow::Vertical | Overflow::Both) { (y1 - vh).max(0.0) } else { 0.0 }; + let (rx, ry) = ((x1 - vw).max(0.0), (y1 - vh).max(0.0)); (if rx.is_finite() { rx } else { 0.0 }, if ry.is_finite() { ry } else { 0.0 }) } @@ -1642,12 +1669,26 @@ enum BarAction { Inspect, } +pub struct PresentTab { + pub id: u64, + /// Empty while the tab is on screen: then the player is in `app.ui.present`. + pub pres: Option, + pub doc_uid: u64, + pub path: Option, + pub page: NodeId, + pub file: String, + pub page_name: String, +} + /// The full-window presenter (while `app.ui.present` is set). Escape or Close ends it. pub fn show(app: &mut FigApp, ui: &mut Ui) { let ctx = ui.ctx().clone(); let now = ctx.input(|i| i.time); let Some(mut pres) = app.ui.present.take() else { return }; - if let Ok(ds) = app.session.ds() { + // A tab can play a file that is closed or not in front. Then it must not read or change + // the file in front. + let live = crate::tabs::present_owner_active(app); + if live && let Ok(ds) = app.session.ds() { pres.sync(&ds.doc, now); } poll_pads(app, &mut pres, &ctx); @@ -1684,7 +1725,8 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { Some(BarAction::Inspect) => pres.view.inspect = !pres.view.inspect, _ => {} } - if pres.view.comments + if live + && pres.view.comments && let Some((frame, place, clip)) = pres.shown { crate::comments::presenter(app, &ctx, frame, place, clip); @@ -1694,17 +1736,20 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { finish(app, &mut pres); // The options carry over to the next presentation. app.ui.present_view = pres.view; - if let Some(BarAction::Responsive(on)) = action { + if live && let Some(BarAction::Responsive(on)) = action { app.run("proto.settings", json!({"responsive": on})); } - if let Some(device) = picked_device { + if live && let Some(device) = picked_device { choose_device(app, &mut pres, &device); } - if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.present_uid = None; - return; - } app.ui.present = Some(pres); + if action == Some(BarAction::Close) || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + if let Some(id) = app.ui.present_active { + crate::tabs::close_present(app, id); + } else { + app.ui.present = None; + } + } } /// Device choices in the presenter use the screen's real dimensions and reflow a copy of the @@ -2356,7 +2401,7 @@ fn scrollers_at(p: &Presenter, base: &Document, disp: &Document, root: NodeId, p let mut out: Vec = disp .subtree(root) .into_iter() - .filter(|f| disp.node(*f).is_some_and(|n| n.visible && n.overflow != Overflow::None)) + .filter(|f| disp.node(*f).is_some_and(|n| n.visible && (n.overflow != Overflow::None || *f == root))) .filter(|f| disp.world_bounds(*f).is_some_and(|b| place.rect(b).contains(q))) .filter(|f| p.range(base, *f) != (0.0, 0.0)) .collect(); @@ -3144,9 +3189,11 @@ mod tests { assert_eq!(disp.world_bounds(list).unwrap().y0, 800.0); p.scroll_to(0.0, 99_999.0); assert_eq!(p.scroll[&a], (0.0, 1148.0), "clamped to the range"); - // Without scrolling the screen only clips; without a device the frame shows whole. + // A top-level frame taller than the screen scrolls without an overflow setting (its own + // range still clips nested content); without a device the frame shows whole. d.get_mut(a).unwrap().overflow = Overflow::None; - assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 0.0)); + assert_eq!(scroll_range_in(&d, a, viewport(&d, a)), (0.0, 1148.0)); + assert_eq!(scroll_range(&d, a), (0.0, 0.0)); if let Some(pg) = d.page_of(a) && let NodeKind::Page { prototype, .. } = &mut d.get_mut(pg).unwrap().kind { diff --git a/crates/ui-egui/src/present_behaviour_tests.rs b/crates/ui-egui/src/present_behaviour_tests.rs index 791e0d3f..e9a036f2 100644 --- a/crates/ui-egui/src/present_behaviour_tests.rs +++ b/crates/ui-egui/src/present_behaviour_tests.rs @@ -454,6 +454,99 @@ fn interactive_components_change_in_place() { } } +/// A hugging auto layout frame (in `parent`, else on the page) holding text `chars`: (frame, text). +fn hugging_label(s: &mut Session, parent: Option, name: &str, x: f64, chars: &str) -> (NodeId, NodeId) { + let f = id(&ex(s, "frame.create", json!({"x": x, "y": 600, "width": 60, "height": 30, "name": name, "parent": parent}))); + let t = id(&ex(s, "text.create", json!({"text": chars, "x": 0, "y": 0, "parent": f}))); + ex(s, "node.set", json!({"ids": [f], "props": {"layout": "horizontal", "padding": 8, "sizingH": "hug", "sizingV": "hug"}})); + (f, t) +} + +fn size(d: &Document, n: NodeId) -> (f64, f64) { + d.node(n).map(|n| (n.width, n.height)).unwrap() +} + +#[test] +fn change_to_lays_out_the_instance_around_its_own_content() { + // Change to shows the other variant in place: the instance keeps its overrides (a longer + // label), so it is laid out around them, as switching the variant on the canvas does. + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let variants: Vec = ["Default", "Hover"] + .iter() + .enumerate() + .map(|(k, state)| { + let (f, _) = hugging_label(&mut s, None, &format!("State={state}"), k as f64 * 150.0, "Button"); + id(&ex(&mut s, "component.create", json!({"ids": [f]}))) + }) + .collect(); + let set = id(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()}))); + on(&mut s, variants[0], json!({"type": "onClick"}), json!({"type": "changeTo", "dest": variants[1]}), json!({"type": "instant"}), 0); + let screen = frame(&mut s, "Screen", 0.0, 400.0, 300.0); + let button = id(&ex(&mut s, "instance.create", json!({"component": set, "x": 20, "y": 20, "parent": screen}))); + let label = s.doc().unwrap().node(button).unwrap().children[0]; + ex(&mut s, "text.set", json!({"id": label, "characters": "A much longer label"})); + let (main, canvas, canvas_label) = { + let d = s.doc().unwrap(); + (size(d, variants[1]), size(d, button), size(d, label)) + }; + // Without any font nothing is measured and both sizes are the main's. + if figcraft_text::layout_node(s.doc().unwrap().node(label).unwrap()).is_some_and(|(l, _)| !l.glyphs.is_empty()) { + assert!(canvas.0 > main.0 + 20.0, "on the canvas the instance hugs its longer label"); + } + let mut p = player(s.doc().unwrap().clone(), screen); + assert!(p.fire(button, 0, None, 1.0)); + let d = p.doc.as_ref().unwrap(); + assert!(matches!(d.node(button).unwrap().kind, NodeKind::Instance { component, .. } if component == variants[1])); + assert_eq!((size(d, button), size(d, label)), (canvas, canvas_label), "the Hover variant, laid out around the instance's label"); +} + +/// A screen holding a hugging chip whose text is bound to string variable `label` of collection +/// "Copy" (modes A "Short" and B "A much longer label"), the chip's click running `action`: +/// (session, screen, chip, text). +fn bound_chip(action: Value) -> (Session, NodeId, NodeId, NodeId) { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Copy", "modes": ["A", "B"]})); + ex( + &mut s, + "variable.create", + json!({"collection": "Copy", "name": "label", "type": "string", "values": {"A": "Short", "B": "A much longer label"}}), + ); + let screen = frame(&mut s, "Screen", 0.0, 400.0, 300.0); + let (chip, text) = hugging_label(&mut s, Some(screen), "Chip", 20.0, "Short"); + ex(&mut s, "variable.bind", json!({"ids": [text], "field": "characters", "variable": "label"})); + on(&mut s, chip, json!({"type": "onClick"}), action, json!({"type": "instant"}), 0); + (s, screen, chip, text) +} + +#[test] +fn set_variable_and_set_mode_lay_out_the_text_they_change() { + // A text bound to a string variable shows what an interaction sets, measured: the hugging + // chip around it grows with it, to the size the canvas gives the same text. + let cases = [ + ( + json!({"type": "setVariable", "variable": "label", "value": "A much longer label"}), + "variable.set", + json!({"variable": "label", "value": "A much longer label"}), + ), + (json!({"type": "setVariableMode", "collection": "Copy", "mode": "B"}), "variable.setMode", json!({"collection": "Copy", "mode": "B"})), + ]; + for (action, canvas_cmd, canvas_params) in cases { + let (mut s, screen, chip, text) = bound_chip(action.clone()); + let mut p = player(s.doc().unwrap().clone(), screen); + assert!(p.fire(chip, 0, None, 1.0), "{action}"); + let page = s.doc().unwrap().page_of(screen).unwrap(); + let mut params = canvas_params; + if canvas_cmd == "variable.setMode" { + params["ids"] = json!([page]); + } + ex(&mut s, canvas_cmd, params); + let (d, canvas) = (p.doc.as_ref().unwrap(), s.doc().unwrap()); + assert_eq!((size(d, chip), size(d, text)), (size(canvas, chip), size(canvas, text)), "{action}: the chip grows with its text"); + } +} + #[test] fn a_drag_started_transition_follows_the_pointer() { let (mut u, hot) = one_interaction(json!({"type": "onDrag"}), json!({"type": "push", "direction": "left"}), 400); diff --git a/crates/ui-egui/src/present_tab_tests.rs b/crates/ui-egui/src/present_tab_tests.rs new file mode 100644 index 00000000..1868babb --- /dev/null +++ b/crates/ui-egui/src/present_tab_tests.rs @@ -0,0 +1,193 @@ +use egui::accesskit::{Node, Role}; +use egui::{Key, Pos2, pos2}; +use figcraft_doc::proto::PrototypeSettings; +use serde_json::{Value, json}; + +use crate::present_tests::{Ui, id, setup}; + +const FILE_TAB: Pos2 = pos2(100.0, 20.0); + +fn click(u: &mut Ui, at: Pos2) { + u.button(at, true); + u.button(at, false); + u.frame(vec![]); +} + +fn tabs(u: &Ui) -> Value { + u.app.inspect()["presentTabs"].clone() +} + +fn presenting(u: &Ui) -> bool { + u.app.inspect()["presenting"] == json!(true) +} + +fn nodes(u: &mut Ui) -> Vec { + u.ctx.enable_accesskit(); + u.frame(vec![]); + u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect() +} + +fn centre(n: &Node) -> Pos2 { + let b = n.bounds().unwrap(); + pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +fn click_label(u: &mut Ui, label: &str) { + let all = nodes(u); + let n = all.iter().find(|n| n.label().is_some_and(|l| l.trim() == label)).unwrap_or_else(|| panic!("no {label:?} on screen")); + click(u, centre(n)); +} + +/// The presenter bar's Options button: it has no label, and sits right of Show comments. +fn open_options(u: &mut Ui) { + let all = nodes(u); + let comments = all.iter().find(|n| n.label() == Some("Show comments (Shift+C)")).unwrap().bounds().unwrap(); + let options = all + .iter() + .filter(|n| n.role() == Role::Unknown && n.label().is_none()) + .filter(|n| n.bounds().is_some_and(|b| b.y0 == comments.y0 && b.x0 > comments.x1)) + .min_by(|a, b| a.bounds().unwrap().x0.total_cmp(&b.bounds().unwrap().x0)) + .expect("the Options button"); + click(u, centre(options)); +} + +fn front_settings(u: &Ui) -> PrototypeSettings { + let page = u.app.session.page().unwrap(); + crate::present::page_settings(u.app.session.doc().unwrap(), page) +} + +/// The middle of presentation tab `k` and of its close button, after `files` file tabs named +/// "Untitled". +fn present_tab(u: &Ui, files: usize, k: usize) -> (Pos2, Pos2) { + let width = |chars: usize, lo: f32, hi: f32| (12.0 * 0.6 * chars as f32 + 64.0).clamp(lo, hi); + let mut x = 52.0 + files as f32 * width("Untitled".len(), 120.0, 220.0); + let names: Vec = tabs(u).as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap().to_string()).collect(); + for n in names.iter().take(k) { + x += width(n.chars().count(), 140.0, 260.0); + } + let w = width(names[k].chars().count(), 140.0, 260.0); + (pos2(x + w / 2.0, 20.0), pos2(x + w - 16.0, 20.0)) +} + +#[test] +fn the_tab_is_named_after_the_file_and_page_and_its_x_closes_it() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + assert!(presenting(&u)); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}])); + click(&mut u, FILE_TAB); + assert!(!presenting(&u)); + assert_eq!(tabs(&u)[0]["active"], false); + let (tab, close) = present_tab(&u, 1, 0); + click(&mut u, tab); + assert!(presenting(&u)); + click(&mut u, close); + assert_eq!((presenting(&u), tabs(&u)), (false, json!([]))); + assert!(!u.app.ui.home, "back to its file"); +} + +#[test] +fn play_again_shows_the_tab_as_it_was_and_another_page_opens_a_new_one() { + let (mut u, a, b, _) = setup(); + u.run("proto.present", json!({"start": a})); + u.key(pos2(720.0, 470.0), Key::ArrowRight); + click(&mut u, FILE_TAB); + u.run("proto.present", json!({"start": a})); + assert_eq!(tabs(&u).as_array().unwrap().len(), 1, "same page: the same tab"); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "it shows the prototype as it was"); + u.run("proto.present", json!({"start": b})); + assert_eq!(tabs(&u).as_array().unwrap().len(), 1); + assert_eq!(u.app.inspect()["presenter"]["history"], json!([]), "another start frame starts again"); + + click(&mut u, FILE_TAB); + let page = id(&u.run("page.add", json!({"name": "Second"}))); + u.run("page.select", json!({"id": page})); + let c = id(&u.run("node.create", json!({"type": "frame", "name": "C", "x": 0, "y": 0, "width": 200, "height": 200}))); + u.run("proto.present", json!({"start": c})); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": false}, {"name": "Untitled - Second", "active": true}])); + let (first, _) = present_tab(&u, 1, 0); + click(&mut u, first); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": true}, {"name": "Untitled - Second", "active": false}])); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b)); +} + +#[test] +fn the_presentation_plays_on_when_its_file_closes() { + let (mut u, a, b, _) = setup(); + let hot = id(&u.run("node.create", json!({"type": "rectangle", "parent": a, "x": 0, "y": 0, "width": 393, "height": 80}))); + u.run("proto.interaction.add", json!({"id": hot, "dest": b})); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.close", json!({"index": 0, "force": true})); + assert_eq!(tabs(&u), json!([{"name": "Untitled - Page 1", "active": false}])); + let (tab, _) = present_tab(&u, 0, 0); + click(&mut u, tab); + assert!(presenting(&u)); + let spot = u.app.inspect()["presenter"]["hotspots"][0]["rect"].clone(); + let r: Vec = spot.as_array().unwrap().iter().map(|v| v.as_f64().unwrap() as f32).collect(); + click(&mut u, pos2((r[0] + r[2]) / 2.0, (r[1] + r[3]) / 2.0)); + u.frames(60, Vec::new); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "the prototype still plays"); + u.key(pos2(700.0, 500.0), Key::Escape); + assert_eq!(tabs(&u), json!([])); + assert!(u.app.ui.home, "no file to go back to: Home"); +} + +#[test] +fn a_tab_whose_file_closed_leaves_the_file_in_front_alone() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.new", json!({})); + u.run("file.close", json!({"index": 0, "force": true})); + let (tab, _) = present_tab(&u, 1, 0); + click(&mut u, tab); + u.frames(5, Vec::new); + assert!(presenting(&u)); + assert!(!crate::tabs::present_owner_active(&u.app)); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "it keeps playing its own copy"); + let before = front_settings(&u); + click_label(&mut u, "iPhone 16"); + click_label(&mut u, "iPhone 17 402×874"); + assert!(presenting(&u)); + for size in ["Fixed size", "Responsive"] { + open_options(&mut u); + click_label(&mut u, size); + } + assert_eq!(front_settings(&u), before, "picking a device or Responsive leaves the file in front alone"); + assert!(!u.app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn a_saved_file_opened_again_gets_its_tab_back() { + let (mut u, a, ..) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-present-tab-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Proto.figcraft").to_string_lossy().to_string(); + u.run("file.saveAs", json!({"path": path})); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.close", json!({"index": 0})); + u.run("file.open", json!({"path": path})); + u.run("proto.present", json!({"start": a})); + // Saved as Proto.figcraft: the file's title follows its name. + assert_eq!(tabs(&u), json!([{"name": "Proto - Page 1", "active": true}])); + u.key(pos2(700.0, 500.0), Key::Escape); + assert!(!u.app.ui.home, "back to its file"); + assert_eq!(tabs(&u), json!([])); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn renaming_the_file_or_the_page_renames_the_tab() { + let (mut u, a, ..) = setup(); + u.run("proto.present", json!({"start": a})); + click(&mut u, FILE_TAB); + u.run("file.rename", json!({"name": "Playground"})); + u.run("page.rename", json!({"name": "Calendar"})); + u.frame(vec![]); + assert_eq!(tabs(&u), json!([{"name": "Playground - Calendar", "active": false}])); + u.run("file.close", json!({"index": 0, "force": true})); + u.frame(vec![]); + assert_eq!(tabs(&u)[0]["name"], "Playground - Calendar", "the names stay when the file closes"); +} diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index a1e4ebe2..73bccaed 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -23,11 +23,18 @@ impl Ui { } pub(crate) fn frame(&mut self, events: Vec) { + self.frame_output(events); + } + + /// [`Self::frame`], returning what egui output (the AccessKit tree when it's enabled). + pub(crate) fn frame_output(&mut self, events: Vec) -> egui::FullOutput { self.time += 1.0 / 60.0; let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; let app = &mut self.app; - self.ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear(); + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + out } pub(crate) fn frames(&mut self, n: usize, events: impl Fn() -> Vec) { @@ -175,6 +182,38 @@ fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { assert_eq!(u.scroll_y(), 0.0); } +/// A long top-level frame scrolls on a phone with no overflow setting, as in Figma: the landing +/// page template's Phone frame ("No scrolling", hugging its height) scrolls by the wheel. +#[test] +fn a_top_level_frame_taller_than_the_phone_scrolls_without_an_overflow_setting() { + let mut s = Session::new(); + s.execute("file.newFromTemplate", &json!({"template": "landing-page"})).unwrap(); + s.execute("proto.settings", &json!({"device": "iPhone 16", "responsive": true})).unwrap(); + let doc = s.doc().unwrap(); + let page = doc.first_page().unwrap(); + let phone = doc.top_frames(page).into_iter().find(|f| doc.node(*f).unwrap().name == "Phone").unwrap(); + assert_eq!(doc.node(phone).unwrap().overflow, figcraft_doc::proto::Overflow::None); + let mut u = Ui::new(s); + u.run("proto.preview", json!({"start": phone})); + let range = u.preview()["scrollRange"][1].as_f64().unwrap(); + assert!(range > 1000.0, "the page is far taller than the screen: {range}"); + let c = u.pane().center(); + let wheel = move || { + vec![ + Event::PointerMoved(c), + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, -60.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }, + ] + }; + u.frames(6, wheel); + u.frames(20, Vec::new); + assert!(u.scroll_y() > 0.0, "{}", u.scroll_y()); +} + #[test] fn full_window_presenter_shares_the_player() { let (mut u, a, ..) = setup(); diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 8fdd5eb0..42647dc0 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -106,14 +106,14 @@ fn presentation_tab_keeps_the_current_screen_when_returning_to_the_editor() { u.button(design_tab, true); u.button(design_tab, false); assert!(u.app.ui.present.is_none()); - assert_eq!(u.app.ui.paused_present.as_ref().map(|p| p.current), Some(b)); + assert_eq!(presenter(&u)["current"], json!(b)); let name_len = u.app.session.doc().unwrap().name.chars().count() as f32; let design_w = (12.0 * 0.6 * name_len + 64.0).clamp(120.0, 220.0); let preview_tab = pos2(8.0 + 44.0 + design_w + 40.0, 20.0); u.button(preview_tab, true); u.button(preview_tab, false); - assert!(u.app.ui.paused_present.is_none()); + assert!(u.app.ui.present.is_some()); assert_eq!(presenter(&u)["current"], json!(b)); } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index afa884a8..e3aba95b 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -286,11 +286,13 @@ pub fn menu_row(ui: &mut Ui, label: &str, shortcut: &str, art_w: f32, art: impl if hovered { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); } - let col = t.menu_text; + // These menus sit on the theme's popup fill (white in the light theme), not on the dark + // `t.menu` of the menu bar: the theme's text, white only on the green highlight (#972). + let col = if hovered { t.menu_text } else { t.text }; ui.painter().galley(pos2(r.min.x + MENU_PAD, r.center().y - text.size().y / 2.0), text, col); if let Some(k) = keys { let at = pos2(r.max.x - MENU_PAD - k.size().x, r.center().y - k.size().y / 2.0); - ui.painter().galley(at, k, if hovered { t.menu_text } else { t.menu_dim }); + ui.painter().galley(at, k, if hovered { t.menu_text } else { t.text_secondary }); } art(ui.painter(), r, col); resp @@ -879,44 +881,32 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }); if indep_ok && ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false) { let radii = common(doc, sel, |n| n.corners.radii); - let keys = [ - ("topLeftRadius", tr("Top left radius")), - ("topRightRadius", tr("Top right radius")), - ("bottomRightRadius", tr("Bottom right radius")), - ("bottomLeftRadius", tr("Bottom left radius")), + // `k` indexes `radii` ([top left, top right, bottom right, bottom left]); the fields sit + // where their corners are: top left, top right / bottom left, bottom right. + let corners = [ + ("topLeftRadius", tr("Top left radius"), Icon::CornerTopLeft, 0), + ("topRightRadius", tr("Top right radius"), Icon::CornerTopRight, 1), + ("bottomLeftRadius", tr("Bottom left radius"), Icon::CornerBottomLeft, 3), + ("bottomRightRadius", tr("Bottom right radius"), Icon::CornerBottomRight, 2), ]; - row(ui, 32.0, |ui| { - for (k, (key, name)) in keys.iter().enumerate().take(2) { - let e = crate::variable_ui::number_field( - app, - ui, - &ids, - key, - Id::new(("corner", k)), - Lead::Icon(Icon::CornerRadius, name), - radii.map(|r| r[k]), - "", - col, - ); - app.field(&ids, key, e, |v| json!(v.max(0.0))); - } - }); - row(ui, 32.0, |ui| { - for (k, (key, name)) in keys.iter().enumerate().skip(2) { - let e = crate::variable_ui::number_field( - app, - ui, - &ids, - key, - Id::new(("corner", k)), - Lead::Icon(Icon::CornerRadius, name), - radii.map(|r| r[k]), - "", - col, - ); - app.field(&ids, key, e, |v| json!(v.max(0.0))); - } - }); + for pair in corners.chunks(2) { + row(ui, 32.0, |ui| { + for (key, name, icon, k) in pair { + let e = crate::variable_ui::number_field( + app, + ui, + &ids, + key, + Id::new(("corner", *k)), + Lead::Icon(*icon, name), + radii.map(|r| r[*k]), + "", + col, + ); + app.field(&ids, key, e, |v| json!(v.max(0.0))); + } + }); + } let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); widgets::sublabels(ui, tr("Corner smoothing"), None); row(ui, 32.0, |ui| { @@ -2253,6 +2243,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) { widgets::note(ui, tr("Hover other layers to measure the distance"), t.text_secondary); crate::dev_mode::ready_list(app, ui); crate::dev_mode::categories_section(app, ui); + crate::dev_mode::measurements_section(app, ui, &doc, &sel); crate::dev_mode::plugins_section(app, ui); return; }; @@ -2261,6 +2252,7 @@ fn inspect(app: &mut FigApp, ui: &mut Ui) { crate::dev_mode::status(app, ui, &doc, &sel); crate::dev_mode::annotations_section(app, ui, &doc, first); crate::dev_mode::dev_resources_section(app, ui, &doc, first); + crate::dev_mode::measurements_section(app, ui, &doc, &sel); use figcraft_engine::devinspect::ColorFormat; let fmt_id = Id::new("inspect_color_format"); let fmt = ui.data(|d| d.get_temp::(fmt_id)).and_then(|i| ColorFormat::ALL.get(i).copied()).unwrap_or_default(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 38b8f0dc..4d44115a 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -97,8 +97,16 @@ fn cached_tip(label: &'static str, key: Option<&'static str>) -> &'static str { /// `swallowed`: what this frame's Cmd/Ctrl+V chords the shell swallowed ask for ([`swallowed_paste`]). pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowed) { note_platform(ctx); - // Text fields in panels own the keyboard (also the key that ends their editing). + // A save asked for from a field last frame: the field has committed by now. + if let Some(id) = app.ui.after_field.take() { + run_binding(app, id); + } + // Text fields in panels own the keyboard (also the key that ends their editing), except for + // the save shortcuts. if typing || crate::a11y::field_focused(ctx) || app.ui.palette.is_some() { + if app.ui.palette.is_none() { + save_from_field(app, ctx); + } return; } let events: Vec = ctx.input(|i| i.events.clone()); @@ -137,6 +145,33 @@ pub fn handle(app: &mut FigApp, ctx: &Context, typing: bool, swallowed: Swallowe } } +/// Commands whose shortcut works while a panel field has the keyboard: they don't edit text, and +/// a file that can't be saved from a field looks like a file that can't be saved (#1157). +const FROM_A_FIELD: [&str; 2] = ["file.save", "file.saveAs"]; + +/// Cmd/Ctrl+S (or Shift+Cmd/Ctrl+S) pressed in a panel field: the field lets go of the keyboard, +/// which commits what was typed as clicking away does, and the save runs next frame, after that +/// commit. +fn save_from_field(app: &mut FigApp, ctx: &Context) { + let id = ctx.input(|i| { + i.events.iter().find_map(|e| match e { + Event::Key { key, physical_key, pressed: true, repeat: false, modifiers } if modifiers.command => { + lookup(keymap::Context::Canvas, *key, *physical_key, *modifiers).map(|b| b.id).filter(|id| FROM_A_FIELD.contains(id)) + } + _ => None, + }) + }); + if let Some(id) = id { + ctx.memory_mut(|m| { + if let Some(f) = m.focused() { + m.surrender_focus(f); + } + }); + app.ui.after_field = Some(id); + ctx.request_repaint(); + } +} + /// The command Cmd/Ctrl+V runs with `m` held too: `edit.paste`, `edit.pasteOver` (Shift), /// `edit.pasteProperties` (Alt), or `None` for a chord nothing is bound to. The keymap decides, /// whether the key press arrived (browser) or only a paste event and the release (desktop). diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 29c9c5a9..22713000 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -909,7 +909,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { // Clicks in the colour picker editing this picker's style, or in a menu the editor opened, // keep it open. let picker_rect = - app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(Id::new("color_picker")))); + app.ui.picker.as_ref().filter(|t| matches!(t, PickerTarget::Style { .. })).and(ctx.memory(|m| m.area_rect(crate::colorpicker::window_id()))); // So do clicks in the variable picker binding one of its fields (Escape closes that first). let variables = crate::variable_ui::picker_rect(ctx); let in_popup = egui::Popup::is_any_open(ctx) || variables.is_some(); diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/style_picker_tests.rs index becc42ce..ad2c2971 100644 --- a/crates/ui-egui/src/style_picker_tests.rs +++ b/crates/ui-egui/src/style_picker_tests.rs @@ -164,6 +164,34 @@ fn chars_linked_to_several_styles_show_mixed() { assert_eq!(text_link(&app, &doc, &[t]), None); } +#[test] +fn a_click_in_the_colour_picker_keeps_the_style_editor_open() { + use crate::layers_drag_tests::Ui; + let mut s = Session::new(); + s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + let mut u = Ui::new(s); + open_for_selection(&mut u.app, StyleSlot::Fill, Some("Brand")); + for _ in 0..3 { + u.frame(vec![]); + } + let id = u.app.session.doc().unwrap().style("Brand").unwrap().id.clone(); + u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Style { id, index: 0 }); + for _ in 0..3 { + u.frame(vec![]); + } + let cp = u.ctx.memory(|m| m.area_rect(crate::colorpicker::window_id())).expect("colour picker shows"); + let p = cp.center(); + u.button(p, true); + u.button(p, false); + u.frame(vec![]); + assert!(u.app.ui.style_picker.is_some(), "the style editor stays open while the colour picker is used"); + assert!( + matches!(u.app.ui.picker, Some(crate::colorpicker::PickerTarget::Style { .. })), + "the colour picker stays open too: {:?}", + u.app.ui.picker + ); +} + #[test] fn local_style_drops_move_into_the_folder_above() { let st = |id: &str, name: &str| Style { diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 3a4e9ecb..521fd05c 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -3,11 +3,13 @@ //! window buttons; the window's edges resize it. use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2}; +use figcraft_doc::NodeId; use serde_json::json; use crate::a11y::Tip; use crate::i18n::{tr, trf}; use crate::icons::{self, Icon}; +use crate::present::{PresentTab, Presenter}; use crate::theme::{Tokens, regular}; use crate::{FigApp, TitleBar}; @@ -113,9 +115,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon); if hresp.clicked() { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); - } + pause_present(app); app.ui.home = true; } x += 44.0; @@ -141,8 +141,13 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }) .collect(); // Leave the new-tab button, theme toggle, window controls and a draggable gap free. - let present_w = if app.ui.present.is_some() || app.ui.paused_present.is_some() { 128.0 } else { 0.0 }; - let available = (rect.max.x - buttons_w - 70.0 - x - 34.0 - present_w).max(0.0); + let room = (rect.max.x - buttons_w - 70.0 - x - 34.0).max(0.0); + let present_pref: Vec = app.ui.presents.iter().map(present_tab_width).collect(); + let present_on = app.ui.present.as_ref().and(app.ui.present_active); + let active_present = present_on.and_then(|id| app.ui.presents.iter().position(|p| p.id == id)); + let present_layout = tab_layout(&present_pref, present_room(room, &present_pref, !document_tabs.is_empty()), active_present); + let present_used = present_layout.widths.iter().sum::() + if present_layout.overflow { OVERFLOW_WIDTH } else { 0.0 }; + let available = (room - present_used).max(0.0); let active_tab = document_tabs.iter().position(|&i| i == app.session.active); let layout = tab_layout(&preferred, available, active_tab); let mut tab_rects = Vec::with_capacity(layout.widths.len()); @@ -282,41 +287,45 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); x += OVERFLOW_WIDTH; } - // Presentations live in their own tab. Switching back to the design keeps the player + // Presentations live in their own tabs. Switching back to the design keeps the player // state (current screen, navigation history and scroll position) for the return trip. - if app.ui.present.is_some() || app.ui.paused_present.is_some() { - let w = 128.0; - let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)); - let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); - let close_r = Rect::from_min_max(body_r.right_top(), r.max); - let resp = ui.interact(body_r, ui.id().with("presentation_tab"), Sense::click()).tip(tr("Present")); - let close_resp = ui.interact(close_r, ui.id().with("presentation_close"), Sense::click()).tip(tr("Close preview")); - let active = app.ui.present.is_some(); - crate::a11y::tab(&resp, tr("Present"), active); - if active { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); - } else if resp.hovered() || close_resp.hovered() { - ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + let mut to_present = None; + let mut close_present_tab = None; + for (tab, &w) in app.ui.presents.iter().skip(present_layout.first).zip(&present_layout.widths) { + let active = present_on == Some(tab.id); + let (clicked, closed) = present_tab(ui, &t, tab, Rect::from_min_size(pos2(x, rect.min.y), vec2(w, HEIGHT)), active); + if clicked && !active { + to_present = Some(tab.id); } - icons::paint(ui.painter(), Rect::from_center_size(pos2(x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); - ui.painter().text(pos2(x + 34.0, r.center().y), Align2::LEFT_CENTER, tr("Present"), regular(12.0), t.text); - let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0)); - if resp.hovered() || close_resp.hovered() || active { - if close_resp.hovered() { - ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); - } - icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); - } - if close_resp.clicked() { - app.ui.present = None; - app.ui.paused_present = None; - app.ui.present_uid = None; - } else if resp.clicked() && !active { - resume_presentation(app); + if closed { + close_present_tab = Some(tab.id); } x += w; ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border)); } + if present_layout.overflow { + let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT)); + let resp = ui.interact(r, ui.id().with("present_overflow"), Sense::click()).tip(tr("Open presentations")); + crate::a11y::menu_button(&resp, tr("Open presentations"), crate::a11y::menu_open(&resp)); + if resp.hovered() { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon); + crate::right::menu_popup(&resp).show(|ui| { + egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { + for tab in &app.ui.presents { + let on = present_on == Some(tab.id); + if crate::right::menu_check(ui, on, &present_tab_name(tab), "") { + if !on { + to_present = Some(tab.id); + } + ui.close(); + } + } + }); + }); + x += OVERFLOW_WIDTH; + } // New tab. let nr = Rect::from_center_size(pos2(x + 20.0, rect.center().y), vec2(28.0, 28.0)); let nresp = ui.interact(nr, ui.id().with("newtab"), Sense::click()).tip(crate::shortcuts::tip(tr("New design file"), "file.new")); @@ -325,9 +334,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } icons::paint(ui.painter(), nr.shrink(7.0), Icon::Plus, t.icon); if nresp.clicked() { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); - } + pause_present(app); app.run("file.new", json!({})); app.ui.home = false; } @@ -355,12 +362,15 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } } if let Some(i) = switch { - if let Some(p) = app.ui.present.take() { - app.ui.paused_present = Some(p); - } + pause_present(app); app.run("doc.switch", json!({"index": i})); app.ui.home = false; } + if let Some(id) = close_present_tab { + close_present(app, id); + } else if let Some(id) = to_present { + show_present(app, id); + } if let Some((i, at)) = detach { // Where the pointer let go, on the screen. let at = at.and_then(|p| ui.input(|inp| inp.viewport().inner_rect).map(|r| r.min + p.to_vec2() - vec2(60.0, HEIGHT / 2.0))); @@ -388,11 +398,6 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { } else { app.run("file.close", json!({"index": i})); } - if app.ui.present_uid.is_some_and(|uid| !app.session.docs.iter().any(|d| d.uid == uid)) { - app.ui.present = None; - app.ui.paused_present = None; - app.ui.present_uid = None; - } } if !opening.is_empty() { ui.ctx().request_repaint(); @@ -400,16 +405,164 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { }); } -fn resume_presentation(app: &mut FigApp) { - let Some(uid) = app.ui.present_uid else { return }; - let Some(index) = app.session.docs.iter().position(|d| d.uid == uid) else { - app.ui.paused_present = None; - app.ui.present_uid = None; +/// One presentation tab in `r`: whether it was clicked and whether it was closed (its X or a +/// middle click). +fn present_tab(ui: &mut Ui, t: &Tokens, tab: &PresentTab, r: Rect, active: bool) -> (bool, bool) { + let label = present_tab_name(tab); + let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y)); + let close_r = Rect::from_min_max(body_r.right_top(), r.max); + let resp = ui.interact(body_r, ui.id().with(("presentation_tab", tab.id)), Sense::click()).tip(tr("Present")); + let cresp = ui.interact(close_r, ui.id().with(("presentation_close", tab.id)), Sense::click()).tip(tr("Close preview")); + crate::a11y::tab(&resp, &label, active); + let hovered = resp.hovered() || cresp.hovered(); + if active { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.tab_active); + } else if hovered { + ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover); + } + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 19.0, r.center().y), vec2(14.0, 14.0)), Icon::Play, t.icon); + crate::bidi::paint_name(ui.painter(), pos2(r.min.x + 34.0, r.center().y), &label, regular(12.0), t.text, r.width() - 62.0); + if hovered || active { + let cr = Rect::from_center_size(close_r.center(), vec2(18.0, 18.0)); + if cresp.hovered() { + ui.painter().rect_filled(cr, CornerRadius::same(4), t.pressed); + } + icons::paint(ui.painter(), cr.shrink(4.0), Icon::Close, t.icon_secondary); + } + let closed = cresp.clicked() || resp.middle_clicked() || cresp.middle_clicked(); + (resp.clicked(), closed) +} + +/// The part of the strip's free `room` the presentation tabs may use: all they want, but at most +/// half of it while file tabs share the strip, so they shrink or overflow before covering controls. +fn present_room(room: f32, preferred: &[f32], files: bool) -> f32 { + preferred.iter().sum::().min(if files { room / 2.0 } else { room }) +} + +fn present_tab_width(tab: &PresentTab) -> f32 { + (regular(12.0).size * 0.6 * present_tab_name(tab).chars().count() as f32 + 64.0).clamp(140.0, 260.0) +} + +/// "file - page", or the file alone when the page has no name. +fn present_tab_name(tab: &PresentTab) -> String { + if tab.page_name.is_empty() { tab.file.clone() } else { format!("{} - {}", tab.file, tab.page_name) } +} + +/// The presentation tabs for `ui.inspect`: each one's name and whether it is on screen. +pub fn present_tabs_inspect(app: &FigApp) -> serde_json::Value { + let on_screen = |id| app.ui.present.is_some() && app.ui.present_active == Some(id); + app.ui.presents.iter().map(|t| json!({"name": present_tab_name(t), "active": on_screen(t.id)})).collect() +} + +/// The presenter on screen, else the paused one of the active presentation tab. +pub fn shown_or_paused(app: &FigApp) -> Option<&Presenter> { + app.ui.present.as_ref().or_else(|| { + let id = app.ui.present_active?; + app.ui.presents.iter().find(|t| t.id == id)?.pres.as_ref() + }) +} + +/// The index of the open file with `uid`, when a file tab shows it. +fn owner(app: &FigApp, uid: u64) -> Option { + app.session.docs.iter().position(|d| d.uid == uid && app.shows(uid)) +} + +/// The presentation on screen plays the file in front (or no tab holds it). Only then may it read +/// and change that file. +pub fn present_owner_active(app: &FigApp) -> bool { + let Some(tab) = app.ui.present_active.and_then(|id| app.ui.presents.iter().find(|t| t.id == id)) else { return true }; + owner(app, tab.doc_uid).is_some_and(|i| i == app.session.active) +} + +/// Puts the presenter on screen back into its tab, where it keeps its state. +fn pause_present(app: &mut FigApp) { + let Some(p) = app.ui.present.take() else { return }; + let id = app.ui.present_active; + if let Some(t) = app.ui.presents.iter_mut().find(|t| Some(t.id) == id) { + t.pres = Some(p); + } +} + +/// Presents from `start`: the tab of its page in this file shows again (from `start` when that +/// changed), else a new tab opens. +pub fn open_present(app: &mut FigApp, start: NodeId) { + let Ok(ds) = app.session.ds() else { return }; + let (uid, path) = (ds.uid, ds.path.clone()); + let page = ds.doc.page_of(start).unwrap_or(ds.page); + let file = ds.doc.name.clone(); + let page_name = ds.doc.node(page).map(|n| n.name.clone()).unwrap_or_default(); + let mut pres = Presenter::new(start); + pres.view = app.ui.present_view; + let same = |t: &&PresentTab| t.page == page && (t.doc_uid == uid || (path.is_some() && t.path == path)); + if let Some(id) = app.ui.presents.iter().find(same).map(|t| t.id) { + show_present(app, id); + if app.ui.present.as_ref().is_some_and(|p| p.start() != start) { + app.ui.present = Some(pres); + } return; - }; - app.run("doc.switch", json!({"index": index})); + } + pause_present(app); + let id = app.ui.present_next; + app.ui.present_next += 1; + app.ui.presents.push(PresentTab { id, pres: None, doc_uid: uid, path, page, file, page_name }); + app.ui.present = Some(pres); + app.ui.present_active = Some(id); app.ui.home = false; - app.ui.present = app.ui.paused_present.take(); +} + +/// Brings tab `id` to the front and its file too, when that file is open. +fn show_present(app: &mut FigApp, id: u64) { + app.ui.home = false; + if app.ui.present.is_some() && app.ui.present_active == Some(id) { + return; + } + pause_present(app); + let Some(tab) = app.ui.presents.iter_mut().find(|t| t.id == id) else { return }; + let Some(p) = tab.pres.take() else { return }; + let uid = tab.doc_uid; + if let Some(i) = owner(app, uid).filter(|i| *i != app.session.active) { + app.run("doc.switch", json!({"index": i})); + } + app.ui.present = Some(p); + app.ui.present_active = Some(id); +} + +/// Closes tab `id`. If it was on screen, its file comes to the front, or Home when it is closed. +pub fn close_present(app: &mut FigApp, id: u64) { + let Some(k) = app.ui.presents.iter().position(|t| t.id == id) else { return }; + let tab = app.ui.presents.remove(k); + if app.ui.present_active != Some(id) { + return; + } + app.ui.present_active = None; + if app.ui.present.take().is_none() { + return; + } + match owner(app, tab.doc_uid) { + Some(i) => { + if i != app.session.active { + app.run("doc.switch", json!({"index": i})); + } + app.ui.home = false; + } + None => app.ui.home = true, + } +} + +/// A file that opens again has a new uid. Find it by its path to give its tabs back. +pub fn relink_presents(app: &mut FigApp) { + let docs = &app.session.docs; + for t in &mut app.ui.presents { + let found = docs.iter().find(|d| d.uid == t.doc_uid).or_else(|| docs.iter().find(|d| t.path.is_some() && d.path == t.path)); + if let Some(d) = found { + t.doc_uid = d.uid; + t.path = d.path.clone(); + t.file = d.doc.name.clone(); + if let Some(n) = d.doc.node(t.page) { + t.page_name = n.name.clone(); + } + } + } } /// What the tab of document `uid` says about its live session: others get in (`true`: a session @@ -493,9 +646,22 @@ pub fn resize_edges(ctx: &egui::Context) { #[cfg(test)] mod layout_tests { - use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, tab_drop_position, tab_layout}; + use super::{MIN_TAB_WIDTH, OPEN_TIME, OVERFLOW_WIDTH, TabMotion, open_progress, present_room, tab_drop_position, tab_layout}; use egui::{Rect, pos2}; + #[test] + fn many_presentation_tabs_stay_inside_their_share_of_the_strip() { + let preferred = vec![260.0; 8]; + let room = 900.0; + let layout = tab_layout(&preferred, present_room(room, &preferred, true), Some(7)); + assert!(layout.overflow); + assert!(layout.first <= 7 && 7 < layout.first + layout.widths.len()); + assert!(layout.widths.iter().all(|&width| width >= MIN_TAB_WIDTH)); + assert!(layout.widths.iter().sum::() + OVERFLOW_WIDTH <= room / 2.0 + 0.01); + assert_eq!(present_room(room, &preferred, false), room); + assert_eq!(present_room(room, &[200.0], true), 200.0); + } + #[test] fn tabs_shrink_to_fit_before_overflowing() { let preferred = vec![120.0; 10]; diff --git a/crates/ui-egui/src/type_settings_tests.rs b/crates/ui-egui/src/type_settings_tests.rs new file mode 100644 index 00000000..675abfa0 --- /dev/null +++ b/crates/ui-egui/src/type_settings_tests.rs @@ -0,0 +1,29 @@ +//! Regression for #1029: a Type settings row whose label is wider than its column (French "Retrait +//! de paragraphe") stays inside the popover, which otherwise widened a little every frame. +use egui::{Area, Id, Pos2, RawInput, Rect, vec2}; + +#[test] +fn a_long_label_does_not_widen_the_type_settings_popover() { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, false, 0); + let id = Id::new("ty_row_test"); + let mut widths = Vec::new(); + for i in 0..30 { + let raw = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1200.0, 900.0))), time: Some(i as f64 / 60.0), ..Default::default() }; + ctx.run_ui(raw, |ui| { + // As `egui::Popup::width` shows it: a default width the area grows past to fit. + Area::new(id).fixed_pos(Pos2::ZERO).default_width(264.0).show(ui.ctx(), |ui| { + for label in ["List spacing", "Retrait de paragraphe, plus long que la colonne"] { + crate::typography::field_row(ui, label, |ui, fw| ui.allocate_exact_size(vec2(fw, 20.0), egui::Sense::hover())); + } + }); + }) + .textures_delta + .clear(); + widths.push(ctx.memory(|m| m.area_rect(id)).expect("the area is shown").width()); + } + let (first, last) = (widths[1], widths[widths.len() - 1]); + assert!((last - first).abs() < 0.01, "the row widens the popover every frame: {first} → {last}"); + assert!((last - 264.0).abs() < 0.01, "{last}"); +} diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 06185d0c..eefe7c9b 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -468,17 +468,15 @@ fn type_settings( // Indent applies to start-aligned text only; elsewhere the field stays visible but off. let start_aligned = common(doc, ids, |n| n.kind.text().map(|t| t.align_h != TextAlignH::Center && t.align_h != TextAlignH::Justified)).flatten(); - ui.horizontal(|ui| { - ui.add_enabled_ui(start_aligned != Some(false), |ui| { - ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(tr("Paragraph indent")).font(regular(11.0)).color(t.text))); - let field = widgets::number_field(ui, Id::new("ty_indent"), Lead::None, indent, "", w - 118.0, 1.0); - if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = field { - app.set_props(ids, json!({"paragraphIndent": v.clamp(0.0, 10_000.0)})); - } - }) - .response - .on_disabled_hover_text(tr("Paragraph indent applies to left-aligned text")); - }); + ui.add_enabled_ui(start_aligned != Some(false), |ui| { + let field = + field_row(ui, tr("Paragraph indent"), |ui, fw| widgets::number_field(ui, Id::new("ty_indent"), Lead::None, indent, "", fw, 1.0)); + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = field { + app.set_props(ids, json!({"paragraphIndent": v.clamp(0.0, 10_000.0)})); + } + }) + .response + .on_disabled_hover_text(tr("Paragraph indent applies to left-aligned text")); ui.add_space(4.0); for (key, label) in [("hangingPunctuation", tr("Hanging punctuation")), ("hangingList", tr("Hanging list"))] { let all_on = ids.iter().all(|i| { @@ -508,16 +506,13 @@ fn type_settings( if resize == Some(TextAutoResize::Truncate) { let max = common(doc, ids, |n| n.kind.text().map(|t| t.max_lines)).flatten(); ui.add_space(4.0); - ui.horizontal(|ui| { - ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(tr("Max lines")).font(regular(11.0)).color(t.text))); - let shown = max.map(f64::from).filter(|m| *m > 0.0); - let empty = if max.is_some() { tr("Auto") } else { tr(MIXED) }; - if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = - widgets::number_field_or(ui, Id::new("ty_maxlines"), shown, empty, w - 118.0, 1.0) - { - app.run("text.maxLines", json!({"ids": ids, "lines": v.round().clamp(0.0, 10_000.0)})); - } - }); + let shown = max.map(f64::from).filter(|m| *m > 0.0); + let empty = if max.is_some() { tr("Auto") } else { tr(MIXED) }; + if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = + field_row(ui, tr("Max lines"), |ui, fw| widgets::number_field_or(ui, Id::new("ty_maxlines"), shown, empty, fw, 1.0)) + { + app.run("text.maxLines", json!({"ids": ids, "lines": v.round().clamp(0.0, 10_000.0)})); + } } } else if let (2, Some(face)) = (tab, &vf) { variable_axes(app, ui, ids, styles, face); @@ -542,13 +537,14 @@ fn type_settings( } } -/// A Type settings row: a label, then `add` with the rest of the width. -fn field_row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui, f32) -> R) -> R { +/// A Type settings row: a label, then `add` with the rest of the width. A label wider than its +/// column (French, say) narrows the field rather than the row outgrowing the popover, which then +/// widened every frame (#1029). +pub(crate) fn field_row(ui: &mut Ui, label: &str, add: impl FnOnce(&mut Ui, f32) -> R) -> R { let t = Tokens::get(ui.ctx()); - let w = ui.available_width(); ui.horizontal(|ui| { ui.add_sized(vec2(110.0, FIELD_H), egui::Label::new(egui::RichText::new(label).font(regular(11.0)).color(t.text))); - add(ui, w - 118.0) + add(ui, ui.available_width()) }) .inner } diff --git a/crates/ui-egui/src/ui_scale_tests.rs b/crates/ui-egui/src/ui_scale_tests.rs index 018c963a..4f2a478b 100644 --- a/crates/ui-egui/src/ui_scale_tests.rs +++ b/crates/ui-egui/src/ui_scale_tests.rs @@ -157,14 +157,18 @@ fn the_interface_size_menu_works_from_the_keyboard() { use egui::{Event, Key, Modifiers, Popup}; // A key press, then two quiet frames for the focus (and the name a screen reader reads) to settle. - let press = |u: &mut Ui, key: Key| { - u.frame(vec![ - Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }, - Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }, - ]); + let press_mod = |u: &mut Ui, key: Key, modifiers: Modifiers| { + u.frame_with( + vec![ + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers }, + ], + modifiers, + ); u.frame(vec![]); u.frame(vec![]); }; + let press = |u: &mut Ui, key: Key| press_mod(u, key, Modifiers::NONE); let open = |u: &mut Ui| { // The Preferences dialog is not modal for the keyboard: Tab crosses the main window first. let mut tabs = 0; @@ -201,6 +205,14 @@ fn the_interface_size_menu_works_from_the_keyboard() { press(&mut u, Key::ArrowDown); } assert_eq!(focused_name(&u.ctx), "125%"); + for expected in ["150%", "175%", "200%", "200%"] { + press(&mut u, Key::Tab); + assert_eq!(focused_name(&u.ctx), expected, "Tab stays in the size menu"); + } + for expected in ["175%", "150%", "125%"] { + press_mod(&mut u, Key::Tab, Modifiers::SHIFT); + assert_eq!(focused_name(&u.ctx), expected, "Shift+Tab stays in the size menu"); + } press(&mut u, Key::Enter); assert_eq!(chosen(&u), json!(1.25)); assert!(!Popup::is_any_open(&u.ctx), "picking a size closes the menu"); diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 2fadcf39..7947950a 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -322,8 +322,37 @@ fn close_current(app: &mut FigApp, ctx: &Context) { ctx.request_repaint_of(ViewportId::ROOT); } -/// The unsaved-changes question when a window closes. +/// What the unsaved-changes question was answered with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CloseAnswer { + /// Save every file with unsaved changes, then close (Save as for a file without a path). + Save, + /// Close and lose the changes. + DontSave, + /// Keep the window open. + Cancel, +} + +/// The unsaved-changes question when a window closes: Don't Save / Cancel / Save, as the other +/// Craft apps ask it. pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { + if std::mem::take(&mut app.ui.save_on_close) { + save_then_close(app, ctx); + return; + } + // A Save as this question started: once its dialog is answered, closing goes on if the file + // was saved; cancelled or failed, the window stays open. + if let Some(uid) = app.ui.close_after_save_as { + if app.pending_dialog.is_some() { + return; + } + app.ui.close_after_save_as = None; + // Saved: go on with the other files, then close. + if app.session.docs.iter().find(|d| d.uid == uid).is_none_or(|d| !d.is_dirty()) { + save_then_close(app, ctx); + } + return; + } if !app.ui.confirm_close { return; } @@ -333,20 +362,77 @@ pub(crate) fn confirm_close(app: &mut FigApp, ctx: &Context) { return; } let t = crate::theme::Tokens::get(ctx); - let (mut open, mut discard, mut cancel) = (true, false, false); + let mut open = true; + let mut answer = None; crate::dialogs::window(tr("Unsaved changes"), ctx).order(egui::Order::Foreground).open(&mut open).show(ctx, |ui| { ui.set_width(340.0); - crate::widgets::label(ui, &trf("Unsaved changes in {names}. Close the window and lose them?", &[("names", &names.join(", "))]), t.text); + crate::widgets::label(ui, &trf("Unsaved changes in {names}. Save them before closing?", &[("names", &names.join(", "))]), t.text); ui.add_space(12.0); ui.horizontal(|ui| { - discard = crate::widgets::button(ui, tr("Close without saving"), true, true).clicked(); - cancel = crate::widgets::button(ui, tr("Cancel"), false, true).clicked(); + if crate::widgets::button(ui, tr("Don't Save"), false, true).clicked() { + answer = Some(CloseAnswer::DontSave); + } + if crate::widgets::button(ui, tr("Cancel"), false, true).clicked() { + answer = Some(CloseAnswer::Cancel); + } + if crate::widgets::button(ui, tr("Save"), true, true).clicked() { + answer = Some(CloseAnswer::Save); + } }); + // Save is the default: Enter answers it when no button has the keyboard. + if answer.is_none() && ui.memory(|m| m.focused().is_none()) && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + answer = Some(CloseAnswer::Save); + } }); - app.ui.confirm_close = open && !discard && !cancel; - if discard { - close_current(app, ctx); + if !open { + answer = Some(CloseAnswer::Cancel); } + if let Some(a) = answer { + answer_close(app, ctx, a); + } +} + +/// Act on the unsaved-changes question's answer. +pub(crate) fn answer_close(app: &mut FigApp, ctx: &Context, answer: CloseAnswer) { + app.ui.confirm_close = false; + match answer { + CloseAnswer::Cancel => {} + CloseAnswer::DontSave => close_current(app, ctx), + CloseAnswer::Save => { + app.ui.save_on_close = true; + ctx.request_repaint(); + } + } +} + +/// Save each file of this window with unsaved changes, then close it. A file without a path (or +/// one saved as .fig, which is never overwritten silently) asks where with Save as, and closing goes +/// on when that dialog saved it ([`confirm_close`]). A save that fails shows why and keeps the +/// window open. +fn save_then_close(app: &mut FigApp, ctx: &Context) { + let files: Vec = app.session.docs.iter().filter(|d| app.shows(d.uid) && d.is_dirty() && d.branch_of.is_none()).map(|d| d.uid).collect(); + for uid in files { + let Some(i) = app.session.docs.iter().position(|d| d.uid == uid) else { continue }; + app.session.active = i; + let native = + app.session.docs.get(i).and_then(|d| d.path.as_deref()).is_some_and(|p| { + !std::path::Path::new(p).extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("fig")) + }); + if !native { + app.ui.close_after_save_as = Some(uid); + app.save_as_dialog(); + if app.pending_dialog.is_none() { + // No dialog could start: stay open. + app.ui.close_after_save_as = None; + } + ctx.request_repaint(); + return; + } + if app.run("file.save", json!({})).is_none() { + return; + } + } + close_current(app, ctx); } /// Before the main window draws: windows asked to close close, closed files leave their windows diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 99276baa..fe63cd86 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -170,3 +170,191 @@ fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() { assert!(ui.try_recv().is_err(), "answered in the other window's frame"); assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap()); } + +/// Answers each Save as with the next of `paths` (none left: cancelled) and counts the dialogs +/// asked for. +struct SaveAs { + paths: Vec, + asked: std::rc::Rc>, +} + +impl crate::Services for SaveAs { + fn pick_save(&mut self, _title: &str, _name: &str, _exts: &[&str]) -> crate::DialogFuture { + self.asked.set(self.asked.get() + 1); + let next = if self.paths.is_empty() { None } else { Some(self.paths.remove(0)) }; + Box::pin(std::future::ready(next)) + } +} + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-close-save-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// The main window with one file that has unsaved changes, saved at `path` first when given, and +/// the window asked to close (the question shows). +fn asking(path: Option<&std::path::Path>, save_as: Vec) -> (egui::Context, FigApp, std::rc::Rc>) { + let mut s = Session::new(); + if let Some(p) = path { + s.execute("file.saveAs", &json!({"path": p.to_string_lossy()})).unwrap(); + } + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + let ctx = egui::Context::default(); + let mut app = FigApp::new(s, Box::new(SaveAs { paths: save_as, asked: asked.clone() })); + frame(&ctx, &mut app, false); + frame(&ctx, &mut app, true); + assert!(app.ui.confirm_close, "the question shows"); + (ctx, app, asked) +} + +fn rects_in(path: &std::path::Path) -> usize { + let mut s = Session::new(); + s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap(); + s.doc().unwrap().nodes().filter(|n| n.name.starts_with("Rectangle")).count() +} + +/// A few frames, as the window draws them. +fn settle(ctx: &egui::Context, app: &mut FigApp) { + for _ in 0..4 { + frame(ctx, app, false); + } +} + +#[test] +fn the_unsaved_changes_question_offers_save_dont_save_and_cancel() { + // As the other Craft apps ask it (#1170): Don't Save, Cancel, and Save as the default. + let (ctx, mut app, _) = asking(None, vec![]); + ctx.enable_accesskit(); + frame(&ctx, &mut app, false); + let out = ctx.run_ui(RawInput::default(), |ui| app.ui(ui)); + let nodes = out.platform_output.accesskit_update.expect("an AccessKit update").nodes; + let buttons: Vec = + nodes.iter().filter(|(_, n)| n.role() == egui::accesskit::Role::Button).filter_map(|(_, n)| n.label().map(str::to_string)).collect(); + for want in ["Don't Save", "Cancel", "Save"] { + assert!(buttons.iter().any(|b| b == want), "{want} in {buttons:?}"); + } + assert!(!buttons.iter().any(|b| b == "Close without saving")); +} + +#[test] +fn save_saves_a_file_with_a_path_and_then_closes() { + let d = temp_dir("path"); + let path = d.join("a.figcraft"); + let (ctx, mut app, asked) = asking(Some(&path), vec![]); + assert_eq!(rects_in(&path), 0); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + settle(&ctx, &mut app); + assert_eq!(rects_in(&path), 1, "saved where it was"); + assert_eq!(asked.get(), 0, "no Save as for a file with a path"); + assert!(app.ui.quit, "then the window closes"); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn save_waits_for_a_panel_field_to_commit() { + // The click on Save takes the keyboard from a panel field, which commits what was typed next + // frame: the save comes after that commit (stood in for by a command run before the frame). + let d = temp_dir("field"); + let path = d.join("a.figcraft"); + let (ctx, mut app, _) = asking(Some(&path), vec![]); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + settle(&ctx, &mut app); + assert_eq!(rects_in(&path), 2, "the committed edit is saved too"); + assert!(app.ui.quit); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn save_asks_where_for_an_untitled_file_and_closes_once_saved() { + let d = temp_dir("untitled"); + let path = d.join("new.figcraft"); + let (ctx, mut app, asked) = asking(None, vec![path.clone()]); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + settle(&ctx, &mut app); + assert_eq!(asked.get(), 1, "Save as asks where"); + assert_eq!(rects_in(&path), 1, "saved there"); + assert!(app.ui.quit, "then the window closes"); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn a_cancelled_save_as_or_a_failed_save_keeps_the_window_open() { + // Save as cancelled: nothing is written, the file keeps its changes and the window stays. + let (ctx, mut app, asked) = asking(None, vec![]); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + settle(&ctx, &mut app); + assert_eq!(asked.get(), 1); + assert!(!app.ui.quit && !app.ui.confirm_close, "open, and not asking again"); + assert!(app.session.ds().unwrap().is_dirty()); + assert_eq!(app.ui.close_after_save_as, None); + + // The save fails (its folder is gone): the error shows and the window stays. + let d = temp_dir("gone"); + let path = d.join("a.figcraft"); + let (ctx, mut app, _) = asking(Some(&path), vec![]); + std::fs::remove_dir_all(&d).unwrap(); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + settle(&ctx, &mut app); + assert!(!app.ui.quit, "a failed save keeps the window open"); + assert!(app.session.ds().unwrap().is_dirty()); +} + +#[test] +fn dont_save_closes_and_cancel_keeps_the_window() { + let d = temp_dir("answers"); + let path = d.join("a.figcraft"); + let (ctx, mut app, _) = asking(Some(&path), vec![]); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Cancel); + settle(&ctx, &mut app); + assert!(!app.ui.quit && !app.ui.confirm_close); + frame(&ctx, &mut app, true); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::DontSave); + settle(&ctx, &mut app); + assert!(app.ui.quit, "Don't Save closes"); + assert_eq!(rects_in(&path), 0, "without saving"); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn one_save_saves_every_untitled_file_of_the_window_then_closes() { + // Two new files with changes: Save asks where for each in turn, then the window closes, + // without asking the question again in between (#1173 review). + let d = temp_dir("two"); + let (a, b) = (d.join("a.figcraft"), d.join("b.figcraft")); + let (ctx, mut app, asked) = asking(None, vec![a.clone(), b.clone()]); + app.session.execute("file.new", &json!({"name": "Second"})).unwrap(); + app.session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + crate::windows::answer_close(&mut app, &ctx, crate::windows::CloseAnswer::Save); + for _ in 0..8 { + frame(&ctx, &mut app, false); + assert!(!app.ui.confirm_close, "not asked again"); + } + assert_eq!(asked.get(), 2); + assert_eq!((rects_in(&a), rects_in(&b)), (1, 1), "both saved"); + assert!(app.ui.quit); + std::fs::remove_dir_all(&d).ok(); +} + +#[test] +fn enter_answers_save() { + let d = temp_dir("enter"); + let path = d.join("a.figcraft"); + let (ctx, mut app, _) = asking(Some(&path), vec![]); + frame(&ctx, &mut app, false); + let enter = |pressed| egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::NONE }; + let input = RawInput { events: vec![enter(true), enter(false)], ..Default::default() }; + ctx.run_ui(input, |ui| { + crate::windows::on_close_request(&mut app, ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + settle(&ctx, &mut app); + assert_eq!(rects_in(&path), 1, "Enter saved"); + assert!(app.ui.quit); + std::fs::remove_dir_all(&d).ok(); +} diff --git a/crates/ui-egui/tests/type_settings_long_labels.rs b/crates/ui-egui/tests/type_settings_long_labels.rs new file mode 100644 index 00000000..113b4daf --- /dev/null +++ b/crates/ui-egui/tests/type_settings_long_labels.rs @@ -0,0 +1,47 @@ +//! #1029, through the whole app: in a language whose labels run past Type settings' label column, +//! the popover keeps its width while it stays open. French does with Inter (release builds, the +//! report); Georgian does with egui's own fonts too (CI, which builds without craft-fonts). Its own +//! test binary, since the UI language is one setting for the whole process. +#![allow(clippy::unwrap_used)] + +use egui::{Id, Popup, Pos2, RawInput, Rect, vec2}; +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, NoServices}; +use serde_json::json; + +/// The Type settings popover's width over the frames it is open, the UI in `language`. +fn widths(language: &str) -> Vec { + let mut s = Session::new(); + s.execute("pref.language", &json!({"language": language})).unwrap(); + // Truncating text also shows Max lines. + let text = s.execute("text.create", &json!({"text": "Type", "props": {"textAutoResize": "truncate"}})).unwrap()["id"].clone(); + s.execute("select.set", &json!({"ids": [text]})).unwrap(); + let ctx = egui::Context::default(); + let mut app = FigApp::new(s, Box::new(NoServices)); + let open = Id::new("ty_settings_open"); + let mut widths = Vec::new(); + for i in 0..40 { + if i == 3 { + Popup::open_id(&ctx, open); + } + let raw = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(i as f64 / 60.0), ..Default::default() }; + ctx.run_ui(raw, |ui| app.ui(ui)).textures_delta.clear(); + // From the frame after the one it opened on (egui's sizing pass). + if i > 3 { + assert!(Popup::is_id_open(&ctx, open), "{language}: Type settings closed on frame {i}"); + widths.push(ctx.memory(|m| m.area_rect(open)).unwrap().width()); + } + } + widths +} + +#[test] +fn type_settings_keeps_its_width_with_labels_longer_than_their_column() { + for language in ["ka", "fr"] { + let w = widths(language); + assert_ne!(figcraft_ui_egui::i18n::tr("Paragraph indent"), "Paragraph indent", "{language} is shown"); + // The width it asks for (`Popup::width`), every frame. + assert!(w.iter().all(|x| (x - 264.0).abs() < 0.01), "{language}: Type settings widens: {} → {}", w[0], w[w.len() - 1]); + } +} diff --git a/docs/cli.md b/docs/cli.md index 699b6ba2..91288f14 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -4,7 +4,9 @@ FigCraft without a window: inspect, export, convert and render documents, run co commands, serve MCP and measure performance. Build it with `cargo build --release -p figcraft-cli` (binary `target/release/figcraft-cli`). -Bad arguments print usage and exit with code 2; failures exit with 1. The CLI never panics on input. +`figcraft-cli --help` (or `-h`, or `help`) prints usage on stdout and exits with 0, and so does +`--help` or `-h` after a subcommand (`figcraft-cli mcp --help`). Bad arguments print usage and exit +with code 2; failures exit with 1. The CLI never panics on input. ## Fonts @@ -51,6 +53,10 @@ page 1 "Screens": 380 nodes, 4 top-level - `` is a directory: one file per top-level frame of the page (default the first page), named after the frame (`Home.png`, `Settings.png`…); with no frames, one file per top-level layer. + The name is kept as it is, except for the characters file systems refuse (`/ \ : * ? " < > |` + and control characters), which become `_`, and leading or trailing spaces and dots, which are + dropped. A name is cut at 100 characters. The app's export replaces the same characters (there a + `/` makes a folder). Two layers with the same name get `Name 2`, `Name 3`… - `` has an image extension: one file with the given `--node`, or the whole page composited. - `--node` takes a layer id (`12` or `0:12`) or a layer name. - `--format fig` (or an `` ending in `.fig`) writes the whole file as `.fig`, experimental: @@ -186,7 +192,9 @@ Serve MCP on stdio: headless by default, or attached to a running app started wi `figcraft --control PORT`. See [mcp.md](mcp.md). The automation roots (each repeatable; existing folders) confine the files the agent's calls read (inside a read or write root) and write (inside a write root); everything else is refused ([mcp.md › Confining file access](mcp.md#confining-file-access)). -With `--attach` they are refused: start the app with them instead. The headless session can host, join and publish +They apply to the headless session only, and that is why `--attach` refuses them: an attached server +is confined by the app it attaches to, from the flags that app was started with. The headless +session can host, join and publish live sessions (`collab.*`: without a key on a server open to the public, with `collab.serverInfo`, `collab.manageFile` for the owner token and `collab.report`) and use plugin directories (`plugin.directory.*`); the one-shot `run` can't (no network link). diff --git a/docs/components-parity.md b/docs/components-parity.md index fad4c6fe..7f7c162f 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-20 · **Change:** minor (duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -29,7 +29,7 @@ accounts). | Assets panel: folders by page, frame and slash names (fold away), thumbnails, search, drag into frames and auto layout slots | done | virtualized; `component.list` gives each one's `folder` | | Hidden from publishing: a leading `.` or `_` in the name | done | the name decides both ways (adding the prefix takes a published component out, removing it brings it back); Hide when publishing in the Assets panel's context menu and Libraries ▸ Hide when publishing (see [ui-parity](ui-parity.md)) | | Instances pasted into other files follow their mains | done | missing main restorable | -| Component playground (try properties without placing) | partial | variant, boolean and text properties in the Inspect tab and `component.playground`; instance swaps and slots have no control there yet (0.5–1 h) | +| Component playground (try properties without placing) | partial | variant, boolean, text and instance-swap properties in the Inspect tab (the swap menu lists the preferred components first, then every component in the file, as in the Design panel) and `component.playground`; slots have no control there yet | | Real-world libraries QA | **not checked** | [gaps](gaps.md) #3 | The behaviour of instances, overrides, variants and swaps is checked against the public @@ -77,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-23 | minor | Instance-swap properties in the component playground: the Inspect tab's Playground shows a dropdown for each, with the same menu as the Design panel's (the property's preferred components first, then every component in the file). The component picked changes the preview and the code only, never the file. Left: slots in the playground. Numbers unchanged. | | 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. | | 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged | | 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3941bd8a..f86ce764 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -131,7 +131,8 @@ component/instance links, overrides, `slot` (a slot layer's property name), boun `devStatus` (handoff status), `devChanged` (changed since it was marked ready for dev), `annotations` (Dev Mode notes: `[{label, properties, category?}]`), `codeLinks` (a component's links to code: `[{language, importPath, name, template}]`), `devResources` (links for developers, -its own and inherited: `[{name, url, index?, from?}]`), `pluginData` (layer data written with +its own and inherited: `[{name, url, index?, from?}]`), `measurements` (Dev Mode measurements +starting from the layer: `[{to} | {side}]`), `pluginData` (layer data written with `node.set {props: {pluginData: {key: value}}}`, pages included: `{key: value}`, values as strings; only when there is some). The auto layout props that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know). diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index bf3cc737..7f8a7fbe 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -1,16 +1,16 @@ # Dev Mode parity -> **Last reviewed:** 2026-10-15 · **Last updated:** 2026-10-15 · **Change:** minor (import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-24 · **Change:** minor (measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) -**Breadth ~97%, ready ~76% (estimated).** The inspect surface is broad, components connect +**Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect to the code components of a repository (with their imports, also inside generated layer trees), -changes compare with any saved version and the canvas only inspects in Dev Mode. Left: measurements -kept on the canvas, a read-only layers panel in Dev Mode, publishing mappings from the code -repository. Remaining: 2–4 h. +changes compare with any saved version, the canvas and the layers panel only inspect in Dev Mode, +and measurements stay on the canvas for everyone. Left: publishing mappings from the code +repository, third-party integrations. Remaining: 1–2 h. | Feature | Status | Notes | |---|---|---| -| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel and dropped files still edit (ours to tighten) | +| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why); files dropped on the canvas still edit (ours to tighten) | | Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) | | Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration | | Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours | @@ -20,7 +20,7 @@ repository. Remaining: 2–4 h. | Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions | | Code connect (map components to repository code) | done | per language (React, Leptos, SwiftUI, Compose): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. No CLI publishing mappings from the code repository | | Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab, with their panels (callbacks can only read); no third-party integrations | -| Measurements (redlines on hover, Alt in the design) | partial | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas for others aren't there | +| Measurements (redlines on hover, Alt in the design) | done | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas (`devMode.addMeasurement` between two layers, along a side or to the layer around it; stored on the layer, saved, synced, undoable) drawn in Dev Mode as labelled redlines that follow the layers, listed, placed and removed in the Inspect tab; a measurement whose other layer was deleted isn't drawn until an undo brings it back | | VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors | | Dev resources links on layers | done | a URL and a name per link on any layer (`devResource.*`, saved, synced, undoable); instances list their main component's (and its set's) read-only; URLs checked as http/https and never fetched (no page titles: a link without a name is named after its URL); the Inspect tab lists, adds, edits and removes them; `get_node` returns them | @@ -28,6 +28,7 @@ repository. Remaining: 2–4 h. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | 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. Breadth 97% → 98%, ready 76% → 78%, 2–4 h → 1–2 h | | 2026-10-15 | minor | Dev Mode tail (gap 16): generated layer trees (React, Leptos, SwiftUI, Compose) start with the import lines of the connected components they call, once each, also those in a component's definition, in slots an instance fills and placed by a connected template's instance swaps; Compare changes takes any saved version as the before (`dev.compare {version}`, the view's Compare with menu; frames that aren't ready for dev compare with versions). Behaviour suite (5 tests; 4 failed before the fixes): the Dev Mode canvas only inspects (no moving, resizing, drawing, text or vector editing; menus, shortcuts, nudges, opacity keys and paste refuse design edits with a note; toolbar drawing tools go back to the design), the ready list filters and badges Changed items, statuses are refused on layers inside frames. Read-only plugins run from the Inspect tab show their panels (the runtime's panels, reused). Breadth 96% → 97% (measurements kept on the canvas and the read-only layers panel counted as missing), ready 72% → 76%, 0–1 h → 2–4 h | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | | 2026-10-11 | minor | Export and inspect behaviour suite written from the public documentation (139 cases in 19 tests; 47 failed before the fixes) and its fixes: an export covers what the layer paints, so a drop shadow adds room only where it falls, strokes on boxes and ellipses reach their real width (they added up to four times as much), hidden layers take no room and a mask group exports the mask's area; a slice exports everything under it; slashes in layer names export into folders; Copy as PNG copies the whole selection. Added: the export settings' contents only and include bounding box, and for SVG outline text (off writes ``), unique id attributes and simplified strokes (stroke attributes where exact); Inspect colour formats (hex, RGB, CSS, HSL, HSB), CSS in rem, a Blend row, positions measured from the frame around a layer (groups aren't frames), CSS for rotation, blend modes, gradient directions and kinds, text alignment, case and decoration; icons and images in the asset list (`file.exportLayers {assets}`). Dev Mode breadth 90% → 93%, ready 64% → 70%, hours unchanged (1–3, dev resources); feature breadth and ready for real work unchanged at this rounding | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index a0ccd076..73fbc602 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -20,7 +20,7 @@ and prototype interactions are still unchecked there. | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes, shapes with text, connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message. Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, table cells beyond their own layers, filled areas with their own fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 | | `.jam` (whiteboards), `.deck` (Slides) | native to other Figma products | no | no | `.jam`: not read or written (boards are FigCraft files, see [boards-parity](boards-parity.md)); `.deck` out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | -| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer | — | +| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Dev Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — | | Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) | ## Import (place / paste / drop) @@ -115,17 +115,17 @@ each. properties, arc fields, font weight: written as the values they give), text styles on part of a text (written as values), variables' publishing, code syntax and inheritance settings, extended collections (written as collections of their own), comments, Dev Mode annotations, statuses, - resources and code links, plugin data, ruler + resources, measurements and code links, plugin data, ruler guides, prototype device settings, slot properties and slot content, brush strokes (written plain), strokes wider on one side of their path (written as filled outlines, or with the width on both sides when the shape has a fill too), pattern fills, WebP export settings (written as PNG), image adjustments, glass settings other than frost, springs other than Gentle (written as ease out), set-variable, set-variable-mode and conditional actions, scroll-to offsets, text decoration looks, variable font axes, text on a path, nested list levels, list spacing, hanging punctuation, - leading trim, OpenType features, per-layer operations inside boolean groups, grid tracks of fixed - or content size, different stroke ends on lines. Not written at all: stroke outlines of dashed and - per-side strokes and of inside or outside strokes on polygons, stars and vectors, glyph outlines - of text (a reader lays text out with its own fonts), version history and branches. + leading trim, OpenType features, per-layer operations inside boolean groups, different stroke ends + on lines. Not written at all: stroke outlines of dashed and per-side strokes and of inside or + outside strokes on polygons, stars and vectors, glyph outlines of text (a reader lays text out + with its own fonts), version history and branches. - **Checked by opening written files in other software:** the example documents and a small test file open there, and layers, fills, gradients, strokes, shadows, vectors, components and text draw as in FigCraft. A file saved again there and read back keeps the layer tree and properties; two @@ -167,6 +167,7 @@ each. |---|---|---| | 2026-10-24 | minor | Boards (M12): `.figcraft` saves the file kind (`kind: "board"`, absent for design files) and each board object's `board` metadata; the `.jam` row points at boards | | 2026-10-15 | minor | `.fig` import (#279, #281; #266, #267): resized instances lay out their layers by their constraints, and explicit variable modes whose collection is named by its asset key come across (before, they were dropped and bound layers resolved in the default mode); fixture-tested, real files still owed. `.figcraft`: atomic saves and exports through unique staging files (#284), optional auto-save (#305). SVG export keeps a nested frame's fill and stroke inside its parent's clip (#327). Numbers unchanged | +| 2026-10-20 | minor | `.fig` export writes grid track sizes (`gridColumnsSizing` / `gridRowsSizing`): fixed px, fractional fill and content-sized tracks keep their sizes and come back through our own importer (#465). Tracks whose size the format can't hold (a negative or out-of-range value) keep their id and read back equal, with a warning. Numbers unchanged | | 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged | | 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged | | 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index 2f9d5ea3..36a22c60 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 24: the boards dot grid, connector ends dragged to re-attach, curved connectors; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -29,7 +29,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) | | 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | -| 16 | Dev Mode: measurements kept on the canvas, a read-only layers panel, publishing mappings from the code repository; the editor extension is left to the MCP server (import lines, compare with versions and an inspect-only canvas landed 2026-10-15) | Feature | 2–4 | [dev-mode-parity](dev-mode-parity.md) | +| 16 | Dev Mode: publishing mappings from the code repository; the editor extension is left to the MCP server (measurements kept on the canvas and a read-only layers panel landed 2026-10-24) | Feature | 1–2 | [dev-mode-parity](dev-mode-parity.md) | | 17 | Branching: branches in live sessions, lists other than fills, strokes and effects merged whole (a branch's own version history and per-item list merges landed 2026-10-12) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | | 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | | 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | @@ -309,11 +309,14 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Estimate:** 5–10 h (video playback in the presenter is most of it; it can reuse the GIF clock). ### 13. Vector tail: none left +- **Landed (2026-10-22):** the transform box around points selected in several layers at once, in + the page's axes: its corners and edges scale them, outside a corner rotates them and inside drags + them, each in its own layer and one undo step for all (`transform_tests`). - **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together, sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG, PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a - selected open path. Left: corner smoothing beyond rectangles and frames, the transform box around - points of several layers, per-point caps and joins on dashed, variable-width and brush strokes. + selected open path. Left: corner smoothing beyond rectangles and frames, per-point caps and joins + on dashed, variable-width and brush strokes. - **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked; "Select layer" lists the group only where its result is), live @@ -383,11 +386,16 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ready list filters and badges Changed items (`inspect.readyForDev {status: changed}`); statuses are refused on layers inside frames. Read-only plugins run from the Inspect tab show their panels. -- **Missing:** measurements kept on the canvas for others (only hover redlines); the layers panel - and files dropped on the canvas still edit in Dev Mode; publishing mappings from the code - repository with a CLI. An editor extension is out of scope: the MCP server already serves design - data, code and the connected snippets to agents and editors. -- **Estimate:** 2–4 h. +- **Landed (2026-10-24):** measurements kept on the canvas: between two layers, along one side of + a layer or to the layer around it (`devMode.addMeasurement` / `removeMeasurement` / + `measurements`), stored on the layer they start from (saved, synced, undoable; old files open + without them), drawn in Dev Mode as labelled redlines that follow the layers, and listed, placed + and removed in the Inspect tab. The layers panel is read-only in Dev Mode (rows select and + expand; no renaming, reordering, hiding or locking). +- **Missing:** files dropped on the canvas still edit in Dev Mode; publishing mappings from the + code repository with a CLI. An editor extension is out of scope: the MCP server already serves + design data, code and the connected snippets to agents and editors. +- **Estimate:** 1–2 h. ### 17. Branching and merging - **Landed (2026-10-11):** branches of a file (`branch.*`): File ▸ Create branch… (or the Branches @@ -472,6 +480,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| | 2026-10-24 | minor | Gap 24: the dot grid on board pages, dragging a connector's end to re-attach it, curved connectors and elbows around the objects they join landed; connector waypoints placed by hand stay missing. 30–60 h → 28–56 h; the ranking is unchanged | +| 2026-10-24 | minor | 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. Left: publishing mappings with a CLI; 2–4 h → 1–2 h | | 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. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | | 2026-10-17 | minor | The 2026-10-17 landings. Gap 1: a new Landed entry (community batches #727 and #728, then #745: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments, images in the message, instance layers' size and resizing overrides, Hug where nothing is recorded; budgets from the file's size; the import report; a `.fig` copy from Save as; paste from another design app), its Missing list cut to what is still left, and the wrong "whether other apps open its files is unknown" replaced by what was checked; all on synthetic files, so the beta blocker and hours stand. Gap 2: server limits, log recovery and web join links in the evidence. Gap 4: menus, the Actions palette and the editor fixes (#733, #735, #736, #729), then Selection colors and smart-selection spacing (#753) landed, none compared side by side. Gap 5: catalog counts re-measured (2,272 entries; drafts at 95–99%; Indonesian, Czech, Turkish and Swedish listed). Gap 8: the Linux and BSD clipboard changes and Wayland window controls, not run there by the maintainers. Ranking and hours unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 82fa39c7..08901454 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -34,6 +34,10 @@ of the window itself (`screenshot` with `window`) needs it on screen: it is then ### Confining file access +The roots below belong to the **headless** side: the session `figcraft-cli mcp` runs itself, with +no window. A server attached with `--attach` runs the commands inside the app instead, so the app +confines them — from the flags it was started with, not from anything passed to the CLI. + ```sh figcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out ``` @@ -144,6 +148,8 @@ Or in a project's `.mcp.json`: there is none, so they are page coordinates then). Groups and boolean groups have no coordinate space of their own: a layer in a group is placed in the frame (or on the page) the group is in. `inspect_document` reports `x`/`y` the same way, plus `absoluteBounds` in page coordinates. + In `props`, `fill` and `stroke` also accept `"none"` (either case) or `null` as no paint, the + same as an empty `fills`/`strokes` list, so an outline-only shape can be made in one call. - Frames, groups, sections and masks: drawing a frame or section with the `pointer` tool around layers puts those fully inside it in it; sections go only on the page or in sections (moves, drags, pastes and new sections elsewhere are refused or land on the page); resizing a section @@ -267,9 +273,11 @@ Or in a project's `.mcp.json`: - **Styles stay true to their layers**: editing a styled fill, stroke, effect or layout grid list (`node.set`, `fill.set`, `fill.add`, a bound variable…) unlinks that style (`styles.fill` etc. go `null`), so a later `style.update` doesn't overwrite the edit; `style.apply` replaces variables - bound to what the style sets; a text style goes on text layers only. Names are trimmed per `/` - folder part. `style.move {styles, before?, folder?}` reorders (and with `folder`, renames into it), - `style.duplicate {style}` adds " copy" right below, `style.renameFolder {folder, name}`. + bound to what the style sets; a text style goes on text layers only. In one call, a style link + (`fillStyle`, `textStyle`…) wins over values it sets; set a deviation in a later call, which + unlinks it. Names are trimmed per `/` folder part. `style.move {styles, before?, folder?}` + reorders (and with `folder`, renames into it), `style.duplicate {style}` adds " copy" right + below, `style.renameFolder {folder, name}`. - **Variables**: `variable.bind` refuses a variable of a type the field doesn't take (a colour on `width`); scopes only filter pickers. `variable.delete` / `variable.deleteCollection` unbind the layers using them (they keep their last value) and give aliases to them their values. An @@ -640,7 +648,8 @@ rotation?, color?, width?, height?, responsive?}` (`None`, `iPhone 16`, `iPhone `Black`, `White`, `Pink`, `Teal`, `Blue` for the iPhone 16 (an error lists them), `""` for the default; `width` / `height` set the custom size's screen, 1–10000 points, and pick `Custom size`, which is a bare screen without a body). On a device the presented frame is sized to the device's -screen: what doesn't fit is clipped, or scrolls when the frame's `overflow` allows it, while `fixed` +screen: a frame bigger than the screen scrolls through its whole size whatever its `overflow`, content +past the frame's own edges scrolls only as its `overflow` allows (else it's clipped), and `fixed` layers stay on screen (pinned to the edges their constraints name). With `responsive: true` (saved in the file) the presented frame is instead resized to the device's screen, or without a device to the presenter's window at 100%, and its constraints and auto layout respond as when it is resized @@ -776,7 +785,9 @@ most when it can't be reached). - `instance.swap {ids?, component}` shows another component in the instances (overrides on layers with matching names stay; property values with the same names too). Nested instances can be - swapped on their own (an override of the instance they're in). + swapped on their own (an override of the instance they're in); a swapped nested instance keeps + what its outer main overrode on matching layers as its own, so later edits of those in the main + don't reach it. - `instance.swapAt {component, x, y}` swaps the instance at a page point: of the instances under it (innermost first), the first showing a variant of the same component set, else the innermost (never one inside the dropped component); only text layers keep their overrides. The Assets @@ -1386,6 +1397,22 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path are clipped to 200 characters; a layer holds at most 32 links. Links are data: never fetched or opened by the engine (the app opens one when it is clicked). `get_node` and `inspect_document` list them as `devResources`; the Inspect tab lists, adds, edits and removes them. +- **Measurements kept on the canvas:** distances a developer leaves on the canvas for everyone. + `devMode.addMeasurement {from? (default the first selected layer), to?, side?}` → `{from, index, + lines}` places one between `from` and `to` (a layer on the same page), along one side of `from` + (`side`: `top`, `right`, `bottom` or `left`: that side's length) or, with neither, to the layer + around `from`; `devMode.removeMeasurement {from?, index?}` → `{removed}` (no index: every one + starting from the layer); both undoable. `devMode.measurements {page?, id?}` → `[{from, fromName, + index, to?, toName?, side?, labels, lines: [{x0, y0, x1, y1, label, dashed}]}]` lists those on a + page (default the current one; `id`: only those from or to that layer), with their lines in page + space measured from the layers' current bounds, so they follow the layers. A measurement is + stored on the layer it starts from (`get_node` lists them as `measurements: [{to} | {side}]`), + saved with the file and synced; the same two layers are measured once (either way round), a + layer starts at most 64. One whose other layer was deleted or moved to another page isn't listed + or drawn (an undo brings it back); a copy keeps the side measurements and those to layers copied + with it. In the app, Dev Mode draws them with the hover redlines, and the Inspect tab lists them + (with nothing selected, every one on the page), selects a measurement's layer on a click, removes + one with −, and places new ones from its Add measurement menu. - **Annotations:** notes for developers on any layer, each with optional pinned properties that Dev Mode shows with their current values (and the style or variable behind them). `annotation.add {id?, label?, properties?}` (a note, pinned properties or both; returns `{id, @@ -1422,8 +1449,8 @@ permissions: without it nothing is downloaded); `plugin.directory.publish {path button to open it in the focus view). In the app's Dev Mode the canvas only inspects: presses select (and pan, and comment), never move, resize, draw or edit text and vectors, and the menus and shortcuts refuse commands that edit the design (Dev Mode's own annotations, statuses, dev - resources, code connections and comments still work); choosing a drawing tool in the toolbar - goes back to the design. Commands sent over MCP or the control channel aren't affected. + resources, measurements, code connections and comments still work); choosing a drawing tool in + the toolbar goes back to the design, and the layers panel only selects and expands. Commands sent over MCP or the control channel aren't affected. - **Compare changes:** when a layer marked Ready for dev first changes (it turns "Changed"), the file keeps a copy of it and everything in it as it was just before that edit (saved in the `.figcraft` file only while there is a change; resolving the change, marking it again, another diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index e4cf8c7f..2ded779d 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -1,14 +1,15 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-17 · **Change:** minor (a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** patch (top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) **Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except media control, every transition and easing exist; key/gamepad triggers take controller buttons, read from controllers on Windows and in the browser. Every rule of the public documentation on triggers, actions, overlays, animations, easing, scrolling and interactive components is a test case with what it implies (`crates/engine/src/proto_behaviour_tests.rs`, 7 -tests on the model, and `crates/ui-egui/src/present_behaviour_tests.rs`, 12 tests driving the -player, 21 gestures among them; the tail: `crates/engine/src/proto_tail_tests.rs`, 4 tests, and +tests on the model, and `crates/ui-egui/src/present_behaviour_tests.rs`, 14 tests driving the +player, 21 gestures among them; presentation tabs: `crates/ui-egui/src/present_tab_tests.rs`, 6 +tests; the tail: `crates/engine/src/proto_tail_tests.rs`, 4 tests, and `crates/ui-egui/src/present_tail_tests.rs`, 5 tests), and all pass. Not yet checked against larger real prototypes. Remaining: 2–5 h. @@ -54,8 +55,10 @@ case pins the current behaviour). | Open link | pass | always a new tab or browser window (opening in place would leave the prototype) | | Change to: another variant of the instance, in place, with the interaction's animation | pass | `interactive_components_change_in_place` | | Interactive components: every instance plays the interactions of the variant it shows; an interaction on the instance with the same trigger wins | **fixed** | instances played only interactions copied onto them when they were placed: an instance placed before its main got interactions did nothing, and one placed after kept the first variant's interactions after changing (a toggle went on but never off). New instances no longer copy them, and copies in older files give way to the variant shown; `instances_play_their_variants_interactions` | +| Presentation tabs: one per page of a file, named "file - page" (renames follow). Play on a page that has a tab shows it as it was (from another start frame it starts again). Tabs keep their own copy of the prototype and play on after their file closes; a saved file opened again gets its tabs back. A tab never changes a file that isn't in front. Its X or Escape ends it and goes back to its file, or Home | **fixed** | one "Present" tab: a second presentation replaced the first, Play restarted it, and closing the file ended it; `present_tab_tests` | | Change to on a layer inside a nested instance changes the instance whose variant set holds the destination | **fixed** | the innermost instance (an icon inside a button) was changed instead | | Set variable, set variable mode, conditional | pass | `proto_run_tests`, `proto_conditional_tests`, `present_variables_tests` | +| Change to, set variable and set variable mode lay out what they change as the canvas does: text sizes to what it shows, hugging frames around it | **fixed** | the player didn't lay out after them: an instance with a longer label changed to the main's width, and a bound text kept its old size; `change_to_lays_out_the_instance_around_its_own_content`, `set_variable_and_set_mode_lay_out_the_text_they_change` | | Expressions: a variable reads the mode where the interaction runs (a frame that chose a mode, else the page's); `name[Mode]` reads a given mode whatever is chosen; operators on numbers, text (`+` joins), booleans and comparisons | pass | `mode_lookups_read_a_given_mode_or_the_layers_current_one`, `proto_expr_mode_tests` | | Set variable mode, then a conditional or set variable later in the same interaction: they see the new mode | pass | `set_variable_mode_changes_what_later_expressions_read` | | A set variable writes every mode of the variable, so a lookup of any mode reads the new value; a frame inside the screen that chose a mode keeps it after set variable mode | silent | pinned as stated | @@ -86,7 +89,7 @@ case pins the current behaviour). | Widgets react to clicks in a presented prototype | done | clicks only, like on the canvas | | Interactive components: instances play their variant's interactions, overridden per trigger | done | | | Drag-driven animations that follow the pointer, in their direction | done | | -| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens its own Present tab, which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put, as the frame's overflow setting allows ("No scrolling" frames clip); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's | +| Presenter: device frames and colours, rotation, custom sizes, fit/fill/100%, responsive frames | done | device bodies drawn in code. Presenting opens a tab for the page, named "file - page", which keeps its screen and history while you go back to the design, with a thumbnail of the current frame, previous / next, device presets and scale choices; the frame is laid out again for the device's screen on the prototype's copy (constraints and auto layout; the file is untouched); on phones and tablets overflowing content drags while the device stays put: a top-level frame bigger than the screen always scrolls (as in Figma, with no overflow setting), content past the frame's own edges as its overflow setting allows ("No scrolling" clips it); closing the window asks about unsaved changes above the presentation (#537). Devices not compared with Figma's | | Hotspot hints, inspect panel in the presenter, comments in the presenter | done | | | Preview pane following edits live | done | | | Edit interactions on several layers at once | done | | @@ -100,6 +103,9 @@ case pins the current behaviour). | Date | Change | Summary | |---|---|---| +| 2026-10-23 | patch | A top-level frame taller (or wider) than the device's screen scrolls with no overflow setting, as Figma's documentation says; before, "No scrolling" frames such as the landing page template's Phone frame only clipped. Content past a frame's own edges still scrolls only as its overflow allows. Checked in the app on macOS. Numbers unchanged | +| 2026-10-22 | minor | Change to, set variable and set variable mode lay out what they change in the player as the canvas does: text sizes to what it shows and hugging frames follow (an instance with a longer label changed to the main's width; a bound text kept its old size). Two player tests. Breadth and ready unchanged. | +| 2026-10-17 | minor | Presentation tabs: one per page of a file instead of a single "Present" tab, named "file - page" (renames follow); Play shows a page's tab as it was; tabs outlive their file and come back when a saved file opens again. Numbers unchanged. | | 2026-10-17 | minor | Community batch #753: a Present tab (#537): playback in its own tab that keeps its screen and history, a current-frame thumbnail, previous / next, device presets and scale; the frame reflows to the device's screen on the prototype's copy; phone and tablet content drags inside a fixed device as its overflow allows ("No scrolling" frames clip; iPad mini, MacBook Air and Desktop keep their bodies); the unsaved-changes question shows above the presentation. Checked in the app on Windows. Percentages and hours unchanged | | 2026-10-13 | minor | Prototyping, delays on mouse triggers and manual overlay offsets (M5): mouse enter, leave, down and up wait `delayMs` before their actions run (once while waiting; dropped when the screen or overlay they were on goes away; a pointer that leaves early doesn't cancel, the documentation is silent), and a manual overlay sits at `offset: [x, y]` from the top-left of the layer that opens it (without one, right below it, as before). Delay and X/Y fields in the interaction editor, the player on its own clock, REST import (`delay`, `overlayRelativePosition`), `.fig` import and export (the export's field for mouse delays is unchecked against other software). Numbers unchanged. | | 2026-10-12 | minor | Controller buttons on key/gamepad triggers (Controller menu, player polling on Windows and in the browser, REST import instead of skipping, control channel injection), widgets reacting to clicks in presented prototypes on the prototype's own copy, `proto.evaluate`; the mode lookup and set-variable-mode rules written as cases (they already held). Breadth 90% → 92%, ready unchanged at 74% (controllers on Windows and the browser only; no real prototype QA yet), 3–6 h → 2–5 h | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 37c9ff53..722c28d2 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -99,18 +99,18 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | | 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | | 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | -| 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; the transform box around points of several layers; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | +| 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | | 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | | 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | | 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing, hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | -| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean and text properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | +| 11 | Components & variants | 9 | 96% | 72% | Real (not synthetic) libraries, `.fig` libraries (a component playground for variant, boolean, text and instance-swap properties, a behaviour suite from the documentation, overrides of padding, gap, grids, exports and interactions, refused geometry overrides, variant-switch and Assets-swap override rules, top-left default variant landed) ([components-parity](components-parity.md)) | 4–9 | | 12 | Styles & variables | 5 | 95% | 76% | Real-library QA (variables inside styles, behaviour suite from the documentation, style folders and order, mode order, code syntax, extended collections with their tail (new library variables as updates, chains through libraries) and variables import/export landed) ([components-parity](components-parity.md)) | 2–4 | | 13 | Prototyping | 6 | 92% | 74% | Video triggers and media actions, controllers on macOS, Linux and FreeBSD, real prototype QA (behaviour suite from the documentation, delays on mouse triggers, manual overlay offsets, key combinations, controller buttons on key/gamepad triggers (Windows and the browser), widgets reacting in prototypes, interactive components playing their variants, drag-driven animations, physical springs landed) ([prototyping-parity](prototyping-parity.md)) | 2–5 | | 14 | Import/export | 6 | 86% | 58% | `.fig` import's tail (best-effort import, editable vector networks, stand-ins for non-design layers and variable prototype actions landed, then (2026-10-17) grid tracks, slots, labels of connectors saved with their path, per-point joins, text on a path, image adjustments, budgets for large files, an import report and paste from another design app; all on synthetic files, no real-file checks yet), checking the rest of the experimental `.fig` export (landed; written files open and draw in other software) on effects, images, prototypes and real files, HEIC/video import, CMYK output intent ([file-format-parity](file-format-parity.md)) | 5–10 | -| 15 | Dev Mode / inspect | 2 | 96% | 72% | Import lines for connected instances inside generated layer trees; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode and dev resources links landed) ([dev-mode-parity](dev-mode-parity.md)) | 0–1 | +| 15 | Dev Mode / inspect | 2 | 98% | 78% | Publishing mappings from the code repository; an editor extension is left to the MCP server (inspect behaviour suite with colour formats, rem and icon and image assets, code connect, plugins in Dev Mode, dev resources links, import lines, compare with versions, an inspect-only canvas and layers panel and measurements kept on the canvas landed) ([dev-mode-parity](dev-mode-parity.md)) | 1–2 | | 16 | Comments, versions, find, misc | 3 | 87% | 63% | Comments are local only; comparing two versions; branches in live sessions; text runs and other lists besides fills, strokes and effects merge whole (branches with review and merge, their own version history and per-item list merges, and a comments and version history behaviour suite from the documentation landed) | 1–3 | | 17 | Multiplayer & sharing | 8 | 17% | 5% | Core landed (`crates/collab`: ids, change sets, server ordering, convergence tested); live sessions (preview): WebSocket transport, hosting from the desktop app, joining from the app or the browser, invite links that open the app, cursors, selections and follow, cursor chat and spotlight, per-person undo, offline resume; a self-hosted `figcraft-server` with persistence and share links to edit or view, which can be open to the public (owner tokens, limits per address, expiry, moderation). Left: accounts and teams, audio ([collaboration-parity](collaboration-parity.md)) | 42–84 | | 18 | Plugins & widgets | 4 | 60% | 35% | Own sandboxed WASM plugins landed (#101: manifest, permissions, menu, Run last, Manage, SDK), with log byte/count limits checked before allocation and host forwarding ([logging limits](plugins.md#logging-limits)), plugin panels called back per button or changed value and per-plugin settings (ABI v2), then widgets (API 3: plugin-drawn layers on the canvas with state kept in the document, redrawn on clicks; a Counter example), panels side by side and a colour picker for plugin colour fields, then a plugin directory on `figcraft-server` (publish with the server key, browse, install after a permissions confirmation, hash-checked downloads, updates by version); layer data written with `node.set {props: {pluginData}}` reads back through `node.get` (#879). Left: a public catalogue, publisher accounts and signatures, ratings, background update checks, the directory in the browser; widgets take clicks only (no text input, property menus, hover or timers) and draw frames, text, shapes and images only; Figma's plugins are JavaScript and don't run (a JS engine would be an owner decision) ([plugins.md](plugins.md)) | 8–26 | @@ -643,6 +643,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th |---|---|---| | 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. Area 22 added at weight 0 (breadth ~35%, ready ~20%); headline numbers unchanged (the weighted sum leaves area 22 out) | | 2026-10-15 | minor | Localization (#263, #469, #477, #288): Turkish reviewed by a native speaker, Czech and Indonesian drafts, the Japanese draft caught up; coverage re-measured (2,177 entries; the five older drafts at 95%). Localization 39% and 29–51 h unchanged at this rounding (see [localization-parity](localization-parity.md)) | +| 2026-10-22 | minor | Vector edit mode: the transform box around points selected in several layers (in the page's axes; scale, rotate and drag in one undo step). Shapes & vector's remaining work loses that item; numbers unchanged | | 2026-10-20 | minor | Layer data reads back (#879): `node.get` (the command, the control method and MCP `get_node`) and `document.inspect` report a layer's or page's `pluginData` as `{key: value}` when it has any, so a tool that keeps state in a file with `node.set {props: {pluginData}}` can read it on its next run (`plugin_data_tests`). Numbers unchanged | | 2026-10-17 | minor | Menu breadth re-run with `cargo xtask parity`: 212 / 212 (100%; the catalog gained View ▸ Full screen with #227 on 2026-10-14), 561 commands. Rows for the 2026-10-17 landings: import/export (area 14) and the file formats dimension name the `.fig` import's new fidelity (instance identity, overrides by key, text style precedence, grid tracks, slots, connector labels, per-point joins, text on a path, image adjustments, list start numbers, vertical trim), budgets from the file's size, the import report, a `.fig` copy from Save as and paste from another design app; the localization evidence takes the re-measured catalog counts (2,272 entries, drafts at 95–99%, Indonesian and Czech drafts, Swedish and Turkish catalogs); the Automation line adds control requests answered while the window is minimised (#720); batch #753's Selection colors, smart-selection spacing, Present tab and auto layout on empty frames are in ui-parity, prototyping-parity and auto-layout-parity. Numbers unchanged: every `.fig` and paste change is checked on synthetic files or clipboard HTML made from our own export, and real files are what gap 1's blocker and the file-exchange discounts wait for; the UI changes (menus, the Actions palette, editor fixes) are not compared side by side, so UI/UX stays at 58%; the localization grade and hours stay (Indonesian's draft is offset by the drafts' new gaps) | | 2026-10-17 | minor | Automation roots (M8, #715, #481): `figcraft-cli mcp --automation-read-root DIR --automation-write-root DIR` (each repeatable, existing folders) and the desktop app with the same flags confine every file a command reads to the read and write roots and every file it writes to the write roots; paths are resolved (links followed, `..`, Windows `\\?\` and UNC paths, case) and compared by whole folder names, anything else is an ordinary error. The engine reaches the file system through one module (enforced by `crates/engine/clippy.toml`), and a sweep runs every command with paths outside the roots. `--attach` refuses the flags (the app confines itself); `ui.screenshot` and `ui.render` return PNG data without a `path`, so the attached MCP server writes no temporary files. Numbers unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 980889ec..2b8c66be 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -82,7 +82,7 @@ interaction feel not yet compared. | Rectangle, ellipse (arc handles and Design panel arc fields), polygon, star, line, arrow; Shift/Alt | done | Alt draws lines from the centre too (`shapes_behaviour_tests`) | | Corner radius handles (inside each corner of a rectangle or frame, a polygon's or star's top point; Alt: one corner), star ratio handle; Count and Ratio fields | done | handles hide under 48 px on screen; no count handle on the canvas (the Count field instead); corner smoothing rounds rectangles and frames only | | Pen: vector networks, branch, close, bend; pencil | done | pen pressure on Windows and from browser styluses only; outside edit mode the Pen closes on its first point, connects to its other points and continues a selected open path from an end point (`vector_multi_behaviour_tests`); Escape or Enter ends the path and keeps its layer in edit mode with the pen, so the next path joins the same vector and another Escape leaves edit mode (#760, `pen_end_tests`); switching from Design to Prototype or Inspect ends the path and returns to Move (#308) | -| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: the transform box around points of several layers; areas of other layers edited at once | +| Vector edit mode: bend, paint bucket, lasso, cut, eraser, transform box, point snapping, mirroring | done | a double-click a few pixels off a thin stroke enters edit mode (#646); entering edit mode and picking a region keep the canvas still (#322); a point dropped on another point merges with it, and dropping one end of an open path on the other closes it (a 2-point line's end just moves, #401); Flatten keeps open paths open (#377); paint bucket drags across areas; cut drags divide a shape into layers; delete and heal follows the curve; rounded points are drawn; Shift-click deselects a point at a corner of the box (`vector_behaviour_tests`); several vectors edited at once, Shift-click handles to drag them together, a cap and join per point, rounded points beside curves (`vector_multi_behaviour_tests`); filled regions with the Move tool: the area under the pointer is hatched ("/" lines in the selection colour, clipped by the area's own fill rule, holes left out, gone 3.5 px from a point or 2 px from a segment at any zoom), the line spacing follows octave folding (fitted to measurements at several zoom levels: 8 px at every power-of-two zoom, growing with the zoom and stepping back at √2 · 2ⁿ) and stays fixed to the shape when panning; a click selects the area and its points (moved with it but drawn unselected), Shift adds or removes an area, a press on a selected area drags the whole selection, no hover hatch during drags, a click on an empty spot (a hole) clears it and stays in edit mode; `vector.edit.select {regions}` (`regions_tests`, `vector_regions_tests`). Not yet: areas of other layers edited at once | | Shape builder (Shift+M) on fills, strokes and open paths; variable width (Shift+W), one side with Alt | done | | | Boolean groups: clicked where the result is, double-click or Cmd/Ctrl-click into operands, live results while dragging or editing points, per-operand operations | done | | | Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | @@ -174,6 +174,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look |---|---|---| | 2026-10-24 | minor | Boards (M12): eight board bindings pinned (tool keys Q sticky, B shapes, X connector, M marker, U highlighter, E eraser, W stamp, and Shift+Cmd/Ctrl+Enter for the next sticky; boards only, on keys no design-file shortcut uses): 49 → 57 pinned | | 2026-10-15 | minor | Community fixes (#262, #272, #277, #300, #308, #311, #322, #352, #363, #372, #390): first runs follow the system theme; new text is readable on the frame it is drawn in; a long pages list scrolls on its own and new pages take the current page's colour; the pen shows its handles while dragging a point out and ends its path when you leave Design; vector edit mode keeps the canvas still; a drag with the Text tool makes a fixed-size box; colour emoji; the selection highlight on the last line; Up and Down in the font picker with a live preview. Numbers unchanged | +| 2026-10-22 | minor | Vector edit mode: points selected in several layers get the transform box, in the page's axes since the layers can be turned differently; its corners and edges scale them, outside a corner rotates them and inside drags them, each layer's points in its own coordinates and one undo step for all, and a click inside clears every layer's points (`transform_tests`). Numbers unchanged | | 2026-10-20 | minor | Readable disabled and selected text (#890): the dim text colour (disabled buttons, placeholders, hints) is at least 3:1 against panels, fields and selected rows in both themes (it was 2.1:1 in the light theme), a disabled button is grey with that text instead of fading to 40%, and selected text (renaming a page or layer, any text box) takes the theme's readable green on the highlight (1.4:1 in the dark theme before, now 4.2:1 or more); checked with the colour contrast helper (`theme::tests`). Numbers unchanged | | 2026-10-20 | minor | Measuring while nudging (#894): with Alt held over another layer, the arrows nudge the selection (Shift: the big step) and the distances follow every nudge (Alt+arrows used to do nothing); while measuring, the selection's handles and size label hide and both layers keep their outlines (`measure_nudge_tests`). Numbers unchanged | | 2026-10-18 | minor | A rename (layers double-click, F2, File ▸ Rename, a page) selects the whole name in the frame its field first draws, so the first key replaces the name instead of being appended. Checked in the app on Windows over the control channel | diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh index e11a58a0..9ee0394b 100755 --- a/packaging/freebsd/package.sh +++ b/packaging/freebsd/package.sh @@ -31,12 +31,28 @@ while [[ $# -gt 0 ]]; do done # FreeBSD's uname says amd64; release file names use x86_64 like the Linux and Windows ones. -MACHINE="$(uname -m)" -case "$MACHINE" in - amd64 | x86_64) ARCH=x86_64 ;; - arm64 | aarch64) ARCH=aarch64 ;; - *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;; +release_arch() { + local arch="$1" + case "$arch" in + amd64 | x86_64) echo x86_64 ;; + arm64 | aarch64) echo aarch64 ;; + *) echo "unsupported architecture $arch" >&2; return 2 ;; + esac + return +} +# The release workflow cross-compiles aarch64 in an x86_64 VM with CARGO_BUILD_TARGET set to +# aarch64-unknown-freebsd (craft-common-actions#39): name the archive after the target. +case "${CARGO_BUILD_TARGET:-}" in + "") ARCH="$(release_arch "$(uname -m)")" ;; + aarch64-unknown-freebsd | x86_64-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;; + *) echo "CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET is not a supported FreeBSD target" >&2; exit 2 ;; esac +# Only binaries for this machine run here (the --version check at the end); a FreeBSD target built +# on another OS never does. +HOST_ARCH="$(release_arch "$(uname -m)" 2>/dev/null)" || HOST_ARCH="$(uname -m)" +if [[ -n "${CARGO_BUILD_TARGET:-}" ]] && [[ "$(uname -s)" != FreeBSD ]]; then + HOST_ARCH="$(uname -s) $HOST_ARCH" +fi if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2 exit 2 @@ -55,7 +71,8 @@ STAGE="$WORK/$BASENAME" rm -rf "$WORK" mkdir -p "$WORK" -BIN="$CARGO_TARGET_DIR/release" +# Cargo puts a cross build under target//release. +BIN="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release" OUT_DIR="$DIST" if [[ "$DRY_RUN" = 1 ]]; then # Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds. @@ -99,7 +116,20 @@ mkdir -p "$OUT_DIR" tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME" echo "wrote $OUT_DIR/$BASENAME.tar.gz" -"$STAGE/bin/$(smoke_bin)" --version +if [[ "$DRY_RUN" = 1 ]] || [[ "$ARCH" = "$HOST_ARCH" ]]; then + "$STAGE/bin/$(smoke_bin)" --version +else + # They cannot run here, so check what they are instead: executables, not libraries or objects. + # An older file(1) calls a PIE a shared object; its interpreter still marks it as a program. + want="$ARCH"; [[ "$ARCH" != x86_64 ]] || want=x86-64 + for b in "$STAGE"/bin/*; do + case "$(file -b "$b")" in + ELF*" executable, "*"$want"*FreeBSD* | ELF*"$want"*FreeBSD*interpreter*) ;; + *) echo "error: $b is not a FreeBSD $ARCH executable: $(file -b "$b")" >&2; exit 1 ;; + esac + done + echo "skipping --version: an $ARCH binary does not run on this $HOST_ARCH machine" +fi if [[ "$DRY_RUN" = 1 ]]; then echo "==> dry run: tarball contents" tar -tzvf "$OUT_DIR/$BASENAME.tar.gz"