From b1f0eafbf7e226b29ecb211b2fe8cee1efce00e5 Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 20 Oct 2026 16:29:32 +0500 Subject: [PATCH 001/142] MCP: inspect_document answers within what a client can read MCP stdio is newline-delimited JSON, so one tool result is one message and has to fit the client's read buffer whole; there is no streaming of part of one. The TypeScript SDK's stdio transport defaults to 10 MiB and hosts rarely raise it, so a document whose tree went past that killed the server: the client stopped reading, the pipe broke, and every later call in the session failed. The tree is now reported at the deepest depth that fits, found by bisection, with a `truncated` object giving the depth asked for, the depth used, both sizes, the limit and a pointer to get_node for reading one subtree deeply. `maxBytes` lets a caller that knows its own buffer say so; the default is 8 MiB, leaving the envelope 2 MiB of the usual 10. The size is measured as the transport sends it -- pretty-printed and then escaped inside the text block, which adds about a tenth (342,136 bytes of tree became 379,954 on the wire in the fixture). A kilobyte is reserved for the marker itself, which is added after the measurement. A limit below even the shallowest tree still answers, at depth 0, with the marker saying so: there is nothing smaller to send, and an error would leave the caller with nothing at all. The tool description now says that each level of depth multiplies the response and points at get_node, which the report notes would have been enough on its own to avoid the crash. Fixes #912. --- crates/mcp/src/tools.rs | 87 +++++++++++++++++++++- crates/mcp/tests/inspect_response_limit.rs | 72 ++++++++++++++++++ 2 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 crates/mcp/tests/inspect_response_limit.rs diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 7714da5a..92773e03 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -173,9 +173,13 @@ pub fn tool_definitions() -> Vec { tool( "inspect_document", "Inspect document", - "The active document as JSON: name, path, dirty flag, current page, selection, the current page's layer tree (id, name, type, x/y relative to the parent frame (groups don't count), width, height, absoluteBounds in page coordinates, fills, strokes, effects, cornerRadius, layout, sizing, text properties, component links, overrides, bound variables), other pages summarised, styles, variable count and the undo history. Use it to verify every change.", + "The active document as JSON: name, path, dirty flag, current page, selection, the current page's layer tree (id, name, type, x/y relative to the parent frame (groups don't count), width, height, absoluteBounds in page coordinates, fills, strokes, effects, cornerRadius, layout, sizing, text properties, component links, overrides, bound variables), other pages summarised, styles, variable count and the undo history. Use it to verify every change. Each level of `depth` multiplies the response, and `allPages` multiplies it again: on a large document the tree is reported at a shallower depth than asked for, with a `truncated` object saying so. Use get_node to read one subtree deeply instead.", obj( - json!({"depth": int("Tree depth for the current page (default 4)"), "allPages": boolean("Expand every page, not only the current one")}), + json!({ + "depth": int("Tree depth for the current page (default 4)"), + "allPages": boolean("Expand every page, not only the current one"), + "maxBytes": int("Largest response to send, in bytes on the wire (default 8388608, which fits the usual 10 MiB client buffer)"), + }), &[], ), true, @@ -562,6 +566,27 @@ fn set_properties(b: &mut dyn Backend, a: &Map) -> Result Result<(Value, usize), String> { + let value = b.call("document.inspect", json!({"depth": depth, "allPages": all}))?; + let text = serde_json::to_string_pretty(&value).unwrap_or_default(); + let wire = serde_json::to_string(&text).map_or(text.len(), |s| s.len()); + Ok((value, wire)) +} + fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result { let depth = n_arg(a, "depth")?.unwrap_or(4.0).clamp(0.0, 64.0) as u64; let all = match a.get("allPages") { @@ -569,7 +594,63 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result *x, Some(_) => return Err("`allPages` must be a boolean".into()), }; - b.call("document.inspect", json!({"depth": depth, "allPages": all})).map(|v| ToolResult::json(&v)) + // A caller that knows its own buffer can say so; 1 KiB is below the shallowest tree, so the + // floor only keeps the arithmetic sane. + let max_bytes = match n_arg(a, "maxBytes")? { + Some(v) if v < 1024.0 => return Err("`maxBytes` must be at least 1024".into()), + Some(v) => v.min(usize::MAX as f64) as usize, + None => MAX_RESPONSE, + }; + + let (value, wire) = inspect_at(b, depth, all)?; + if wire <= max_bytes || depth == 0 { + return Ok(ToolResult::json(&value)); + } + + // The deepest tree that fits, by bisection: a deeper tree holds every node of a shallower one, + // so the size does not fall as the depth grows. Bisecting keeps this to about six more walks + // of the document rather than one per level. + let (mut lo, mut hi) = (0u64, depth - 1); + let mut best: Option<(u64, Value, usize)> = None; + while lo <= hi { + let mid = lo + (hi - lo) / 2; + let (v, w) = inspect_at(b, mid, all)?; + if w + MARKER_RESERVE <= max_bytes { + best = Some((mid, v, w)); + if mid == hi { + break; + } + lo = mid + 1; + } else if mid == 0 { + // Not even the shallowest tree fits. Send it anyway and say so: there is nothing + // smaller to send, and an error would leave the caller with nothing at all. + best = Some((0, v, w)); + break; + } else { + hi = mid - 1; + } + } + + let Some((used, mut value, used_wire)) = best else { + return Ok(ToolResult::json(&value)); + }; + if let Some(obj) = value.as_object_mut() { + obj.insert( + "truncated".into(), + json!({ + "requestedDepth": depth, + "depth": used, + "requestedDepthBytes": wire, + "bytes": used_wire, + "maxBytes": max_bytes, + "reason": format!( + "the tree at depth {depth} would be {wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; it is reported at depth {used} instead" + ), + "hint": "get_node({id, depth}) walks one subtree at a time, so a deep tree can be read in parts without a size limit", + }), + ); + } + Ok(ToolResult::json(&value)) } fn get_node(b: &mut dyn Backend, a: &Map) -> Result { diff --git a/crates/mcp/tests/inspect_response_limit.rs b/crates/mcp/tests/inspect_response_limit.rs new file mode 100644 index 00000000..0d9f9e6d --- /dev/null +++ b/crates/mcp/tests/inspect_response_limit.rs @@ -0,0 +1,72 @@ +//! `inspect_document` must not emit a message larger than a client can read in one piece: MCP +//! stdio is newline-delimited JSON, so one result is one message, and a client whose read buffer +//! it exceeds closes the pipe and kills the server for the rest of the session (#912). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod common; +use common::Client; +use serde_json::{Value, json}; + +/// A document whose tree is far larger than the limits used below. +fn busy() -> Client { + let mut c = Client::new(); + for i in 0..40 { + let frame = c.create(json!({"type": "frame", "x": i * 10, "y": 0, "width": 200, "height": 200})); + for j in 0..12 { + c.create(json!({"type": "rectangle", "parent": frame, "x": j * 5, "y": 5, "width": 20, "height": 20})); + } + } + c +} + +/// The response and what its text block takes on the wire, escaped as the transport sends it. +fn inspect(c: &mut Client, args: Value) -> (Value, usize) { + let r = c.call("inspect_document", args.clone()); + assert_eq!(r["isError"], false, "{args}: {}", r["content"][0]["text"]); + let text = r["content"][0]["text"].as_str().expect("a text block"); + let wire = serde_json::to_string(&text).unwrap().len(); + (serde_json::from_str(text).expect("the block is JSON"), wire) +} + +#[test] +fn a_tree_too_large_for_the_client_is_reported_shallower_and_says_so() { + let mut c = busy(); + let (full, full_wire) = inspect(&mut c, json!({"depth": 6, "allPages": true})); + assert!(full["truncated"].is_null(), "a tree within the limit is not truncated: {}", full["truncated"]); + assert!(full_wire > 30_000, "the fixture has to outgrow the limits below: {full_wire}"); + + // Asked for more than the caller can read: a shallower tree, inside the limit, and a marker. + let (cut, cut_wire) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": 30_000})); + assert!(cut_wire <= 30_000, "the response still exceeds maxBytes: {cut_wire}"); + let t = &cut["truncated"]; + assert_eq!(t["requestedDepth"], 6, "{t}"); + assert!(t["depth"].as_u64().unwrap() < 6, "the depth was reduced: {t}"); + assert_eq!(t["maxBytes"], 30_000, "{t}"); + assert_eq!(t["requestedDepthBytes"], full_wire, "the marker says what the full tree would have taken: {t}"); + // The marker points at the way to read the rest. + assert!(t["hint"].as_str().unwrap_or("").contains("get_node"), "{t}"); + // Still a usable document, not just a marker: the page and its top-level layers are there. + assert!(!cut["currentPage"].is_null(), "{cut}"); + assert!(cut["pages"][0]["children"].as_array().is_some_and(|c| !c.is_empty()), "{cut}"); + + // The deepest tree that fits is the one sent: a limit that admits the whole tree is not cut. + let (whole, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": full_wire})); + assert!(whole["truncated"].is_null(), "a limit at the exact size is not a truncation: {}", whole["truncated"]); +} + +#[test] +fn a_limit_below_the_shallowest_tree_still_answers() { + // Nothing smaller than depth 0 exists, so it is sent with the marker rather than an error: + // an error would leave the caller with nothing at all. + let mut c = busy(); + let (v, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": 1024})); + assert_eq!(v["truncated"]["depth"], 0, "{}", v["truncated"]); + assert_eq!(v["truncated"]["requestedDepth"], 6, "{}", v["truncated"]); +} + +#[test] +fn maxbytes_is_checked() { + let mut c = Client::new(); + let r = c.call("inspect_document", json!({"depth": 1, "maxBytes": 10})); + assert_eq!(r["isError"], true, "{r}"); + assert!(r["content"][0]["text"].as_str().unwrap_or("").contains("at least 1024"), "{r}"); +} From a55963841973ae4fc31122c49349fe6aacc7cdee Mon Sep 17 00:00:00 2001 From: YarilaFox <102650794+YarilaFox@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:57:10 +0500 Subject: [PATCH 002/142] M7: avoid Windows GNU UIA crash --- .cargo/config.toml | 7 +++++++ apps/figcraft/build.rs | 1 + apps/figcraft/src/main.rs | 5 +++++ 3 files changed, 13 insertions(+) diff --git a/.cargo/config.toml b/.cargo/config.toml index acebfe9f..8abc94d7 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,5 +3,12 @@ [env] CRAFT_FONTS_DIR = { value = "../craft-fonts", relative = true } +# The Windows UI Automation adapter can be asked about a node that disappeared in the +# same frame. On MinGW, converting that HRESULT through windows-result's thread COM +# error-info path has crashed in GetErrorInfo. Keep the HRESULT without that extra +# COM lookup; this leaves the AccessKit tree and screen-reader support enabled. +[target.x86_64-pc-windows-gnu] +rustflags = ["--cfg=windows_slim_errors"] + [alias] xtask = "run -q -p xtask --" diff --git a/apps/figcraft/build.rs b/apps/figcraft/build.rs index 64f97835..6ed8a047 100644 --- a/apps/figcraft/build.rs +++ b/apps/figcraft/build.rs @@ -1,6 +1,7 @@ //! Windows: embed the icon and version info in figcraft.exe and size its main thread's stack //! (no-op elsewhere). fn main() { + println!("cargo::rustc-check-cfg=cfg(windows_slim_errors)"); println!("cargo::rerun-if-changed=../../assets/app-icon/figcraft.ico"); let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); if target_os == "windows" { diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 00856c5d..887db45a 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -15,6 +15,11 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +// A missing Windows GNU flag re-enables the COM error-info lookup that crashes while AccessKit +// reports a removed UI Automation node. Catch build configurations that drop the workaround. +#[cfg(all(target_os = "windows", target_env = "gnu", not(windows_slim_errors)))] +compile_error!("Windows GNU builds must set windows_slim_errors to keep UI Automation responsive"); + #[cfg(target_os = "macos")] mod apple_events; mod args; From cabe2e3cfb14a26cf4d5049ece9d27b812781f66 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 20 Oct 2026 15:58:55 +0200 Subject: [PATCH 003/142] i18n(pl): native review, catch-up and terminology fixes - Mark Polish catalog as reviewed and approved by native speaker (@lukasz-sklad) - Replace "Lekki" with "Jasny" for Light mode - Translate disambiguated context keys (Fill|sizing, Join|stroke, Light|glass, Open|comments) - Catch up with 15 UI strings added in recent main commits - Update docs/translating.md to list Polish as reviewed --- crates/ui-egui/locales/pl.toml | 30 +++++++++++++++++++++++------- docs/translating.md | 12 ++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 23f7dee8..b6a7bf53 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1,10 +1,11 @@ # FigCraft interface strings: Polish. # -# Draft: machine-drafted, waiting for a native speaker's review. +# Wording reviewed and approved by Lucas (@lukasz-sklad). +# Complete Polish localization for FigCraft. +# See docs/translating.md; plural forms follow Polish CLDR integer rules. [meta] name = "Polski" -status = "draft — needs native review" [ui] "% of all commits" = "% wszystkich commitów" @@ -171,6 +172,7 @@ status = "draft — needs native review" "Both directions" = "W obu kierunkach" "Bottom" = "Dół" "Bottom left radius" = "Promień lewego dolnego narożnika" +"Bottom padding" = "Margines dolny" "Bottom right radius" = "Promień prawego dolnego narożnika" "Branch" = "Gałąź" "Branch archived" = "Gałąź zarchiwizowana" @@ -501,7 +503,7 @@ status = "draft — needs native review" "Fill container" = "Wypełnij kontener" "Fill window" = "Wypełnij okno" "Fills" = "Wypełnienia" -"Fill|sizing" = "Fill|sizing" +"Fill|sizing" = "Wypełnij" "Filter and sort" = "Filtruj i sortuj" "Find" = "Znajdź" "Find and replace" = "Znajdź i zamień" @@ -632,6 +634,7 @@ status = "draft — needs native review" "Included" = "Uwzględniono" "Included in layout" = "Uwzględniono w układzie" "Individual corners" = "Niezależne narożniki" +"Individual padding" = "Niezależne marginesy" "Individual strokes" = "Niezależne obrysy" "Ink" = "Farba" "Inner shadow" = "Cień wewnętrzny" @@ -653,6 +656,7 @@ status = "draft — needs native review" "Interaction" = "Interakcja" "Interactions" = "Interakcje" "Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps." = "Projektowanie interfejsów i prototypowanie. Darmowe, otwartoźródłowe, czysto implementowane narzędzie projektowe napisane od podstaw w Rust, część ArtCraft Crafting Apps." +"Interface size" = "Rozmiar interfejsu" "Intersect" = "Przetnij" "Intersects" = "Przecina" "Invalid color" = "Nieprawidłowy kolor" @@ -669,7 +673,7 @@ status = "draft — needs native review" "Joined" = "Dołączono" "Joined {file}" = "Dołączono do: {file}" "Joining…" = "Dołączanie…" -"Join|stroke" = "Join|stroke" +"Join|stroke" = "Łączenie" "Jul" = "Lip" "Jun" = "Cze" "Just now" = "Przed chwilą" @@ -702,6 +706,7 @@ status = "draft — needs native review" "Leaves" = "Opuszcza" "Left" = "Do lewej" "Left & right" = "Lewa i prawa" +"Left padding" = "Margines lewy" "Left side" = "Lewa strona" "Left side width" = "Szerokość lewej strony" "Letter case" = "Wielkość liter" @@ -712,9 +717,9 @@ status = "draft — needs native review" "Library: click to collapse" = "Biblioteka: kliknij, aby zwinąć" "Library: click to expand" = "Biblioteka: kliknij, aby rozwinąć" "Library: its variables are read-only here; edit them in the library file and publish it" = "Biblioteka: jej zmienne są tutaj tylko do odczytu; edytuj je w pliku biblioteki i opublikuj" -"Light" = "Lekki" +"Light" = "Jasny" "Lighten" = "Jaśniej" -"Light|glass" = "Light|glass" +"Light|glass" = "Światło" "Line" = "Linia" "Line arrow" = "Strzałka liniowa" "Line delta" = "Różnica wierszy" @@ -927,7 +932,7 @@ status = "draft — needs native review" "Opened “{name}” in a new tab" = "Otwarto „{name}” w nowej karcie" "Opening the file…" = "Otwieranie pliku…" "Opening {n} files as new files" = { one = "Otwieranie jako nowy plik", few = "Otwieranie {n} plików jako nowe pliki", many = "Otwieranie {n} plików jako nowe pliki", other = "Otwieranie {n} plików jako nowe pliki" } -"Open|comments" = "Open|comments" +"Open|comments" = "Otwarte" "Options" = "Opcje" "Orange" = "Pomarańczowy" "Order" = "Kolejność" @@ -950,6 +955,7 @@ status = "draft — needs native review" "PDF print settings" = "Ustawienia druku PDF" "Padding" = "Marginesy wewnętrzne" "Page" = "Strona" +"Page deleted" = "Strona została usunięta" "Pages" = "Strony" "Pages added to the document" = "Strony dodane do dokumentu" "Placed on the current page" = "Umieszczono na bieżącej stronie" @@ -1061,6 +1067,7 @@ status = "draft — needs native review" "Reduce motion" = "Ogranicz ruch" "Refraction" = "Załamanie światła (refrakcja)" "Regular expression" = "Wyrażenie regularne" +"Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Względem skalowania ekranu Twojego systemu (100% to domyślny rozmiar systemu). Powiększenie dokumentu jest niezależne." "Remove" = "Usuń" "Remove all" = "Usuń wszystko" "Remove annotation" = "Usuń adnotację" @@ -1099,6 +1106,7 @@ status = "draft — needs native review" "Rename on the server…" = "Zmień nazwę na serwerze…" "Rename page" = "Zmień nazwę strony" "Rename to" = "Zmień nazwę na" +"Reorder selection" = "Zmień kolejność zaznaczenia" "Replace all" = "Zamień wszystko" "Replace mixed export settings" = "Zastąp zróżnicowane ustawienia eksportu" "Replace with" = "Zamień na" @@ -1134,6 +1142,7 @@ status = "draft — needs native review" "Review and merge" = "Przejrzyj i scal" "Ribbon" = "Wstążka" "Right" = "Do prawej" +"Right padding" = "Margines prawy" "Right side" = "Prawa strona" "Right side width" = "Szerokość prawej strony" "Rotate 90°" = "Obróć o 90°" @@ -1184,6 +1193,7 @@ status = "draft — needs native review" "Search results" = "Wyniki wyszukiwania" "Search fonts" = "Szukaj czcionek" "Search layers" = "Szukaj warstw" +"Search pages" = "Szukaj stron" "Search shortcuts" = "Szukaj skrótów" "Section" = "Sekcja" "Section color" = "Kolor sekcji" @@ -1298,6 +1308,7 @@ status = "draft — needs native review" "Start from a blank canvas" = "Zacznij od pustego płótna" "Start from a template" = "Zacznij od szablonu" "Start on path" = "Początek na ścieżce" +"Start on the Home screen" = "Rozpoczynaj od ekranu głównego" "Start point" = "Punkt początkowy" "Start sharing" = "Rozpocznij udostępnianie" "Start sharing with the Share button, then pass on the code it gives" = "Rozpocznij udostępnianie przyciskiem Udostępnij, a następnie przekaż wygenerowany kod" @@ -1371,6 +1382,7 @@ status = "draft — needs native review" "The selection uses several text styles: click to apply one" = "Zaznaczenie używa kilku stylów tekstu: kliknij, aby zastosować jeden" "The server deletes this file if nobody opens it before {date}." = "Serwer usunie ten plik, jeśli nikt go nie otworzy przed {date}." "The whole file" = "Cały plik" +"The window is too small for {size}%, so the interface is drawn at {drawn}%. Make the window larger to use {size}%." = "Okno jest zbyt małe dla {size}%, więc interfejs jest wyświetlany w {drawn}%. Powiększ okno, aby użyć {size}%." "Theme" = "Motyw" "There's no destination for that action" = "Brak miejsca docelowego dla tej akcji" "Thickness" = "Grubość" @@ -1381,6 +1393,7 @@ status = "draft — needs native review" "This condition is edited as an expression" = "Ten warunek jest edytowany jako wyrażenie" "This file" = "Ten plik" "This file has unsaved changes" = "Ten plik ma niezapisane zmiany" +"This file is too large to join here (up to {mb} MB)" = "Ten plik jest zbyt duży, aby do niego dołączyć (maksymalnie {mb} MB)" "This file uses fonts that aren't installed on this computer. Their text is drawn in a fallback font until you install them." = "Ten plik używa czcionek, które nie są zainstalowane na tym komputerze. Do czasu ich zainstalowania tekst jest wyświetlany czcionką zastępczą." "This file is on {server}, open only to you. Share it to let others in with a new code." = "Ten plik znajduje się na {server}, otwarty tylko dla Ciebie. Udostępnij go, aby wpuścić innych nowym kodem." "This frame is new on the branch." = "Ta ramka jest nowa w gałęzi." @@ -1402,6 +1415,7 @@ status = "draft — needs native review" "Top" = "Góra" "Top & bottom" = "Góra i dół" "Top left radius" = "Promień górnego lewego narożnika" +"Top padding" = "Margines górny" "Top right radius" = "Promień górnego prawego narożnika" "Transform" = "Przekształć" "Triangle arrow" = "Strzałka trójkątna" @@ -1450,6 +1464,7 @@ status = "draft — needs native review" "Use number keys for opacity" = "Użyj klawiszy numerycznych dla nieprzezroczystości" "Use the .figcraft or .fig extension." = "Użyj rozszerzenia .figcraft lub .fig." "Use the layer's fill" = "Użyj wypełnienia warstwy" +"Use your browser's zoom to change the interface size." = "Użyj powiększenia przeglądarki, aby zmienić rozmiar interfejsu." "Used in {files} · {instances}" = "Używane w {files} · {instances}" "Username" = "Nazwa użytkownika" "Value" = "Wartość" @@ -1721,6 +1736,7 @@ status = "draft — needs native review" "{n}m" = "{n} min" "{n}w" = "{n} tyg." "{page}, nothing selected" = "{page}, nic nie zaznaczono" +"{percent}%" = "{percent}%" "{profile} (current)" = "{profile} (bieżący)" "{property} is its value; {property ? \"a\" : \"b\"} picks a string. Other {…} stays as written." = "{property} to jej wartość; {property ? \"a\" : \"b\"} wybiera tekst. Pozostałe wyrażenia {…} pozostają bez zmian." "{server} can't say what became of your files (it is older than this app)." = "{server} nie może określić stanu Twoich plików (jest starszy niż ta aplikacja)." diff --git a/docs/translating.md b/docs/translating.md index eaa59755..7b01d391 100644 --- a/docs/translating.md +++ b/docs/translating.md @@ -17,12 +17,12 @@ Simplified. The catalogs: English (`en`, the source), and Spanish (`es`, with the Latin American overlay `es-419`), French (`fr`), German (`de`), Dutch (`nl`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`), Simplified Chinese (`zh-Hans`), Czech (`cs`), Indonesian (`id`), Finnish -(`fi`), Polish (`pl`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Georgian -(`ka`) was written with AI assistance and reviewed by a native speaker, and stays a draft until a -maintainer changes its status. Russian (`ru`, Русский) was -reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and Smart animate as familiar -tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and reviewed by a native speaker, -and Swedish (`sv`) translates every UI string, command label and menu wording. +(`fi`) and Bengali (`bn`), machine-drafted and waiting for native speakers' review. Polish (`pl`, +Polski) was reviewed and approved by Lucas (@lukasz-sklad). Georgian (`ka`) was written with AI +assistance and reviewed by a native speaker, and stays a draft until a maintainer changes its status. +Russian (`ru`, Русский) was reviewed and approved by VanTan; its catalog keeps Frame, Dev Mode and +Smart animate as familiar tool names and uses « » quotes. Turkish (`tr`) was machine-drafted and +reviewed by a native speaker, and Swedish (`sv`) translates every UI string, command label and menu wording. ## The catalogs From 728f5c48ef1a0357f1fd86b0c0a6b4939c2add45 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 20 Oct 2026 16:46:58 +0200 Subject: [PATCH 004/142] i18n(pl): add Polish labels for remaining 7 engine commands - Add Polish command labels for arrange.reorder, file.moveTab, pref.startOnHome, pref.uiScale, text.loadFont, text.refit, and text.rescanFonts. - Ensures 100% command coverage with zero fallback to English in the Actions palette. --- crates/ui-egui/locales/pl.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index b6a7bf53..209de490 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1779,6 +1779,7 @@ name = "Polski" "arrange.front" = "Przesuń na sam wierzch" "arrange.packH" = "Upakuj w poziomie" "arrange.packV" = "Upakuj w pionie" +"arrange.reorder" = "Zmień kolejność zaznaczenia" "arrange.roundToPixels" = "Zaokrąglij do pikseli" "arrange.spacing" = "Odstępy" "arrange.tidy" = "Uporządkuj" @@ -1906,6 +1907,7 @@ name = "Polski" "file.exportLayers" = "Eksportuj warstwy" "file.exportSvg" = "Eksportuj zaznaczenie jako SVG" "file.import" = "Importuj z pliku…" +"file.moveTab" = "Przenieś kartę" "file.new" = "Nowy plik projektu" "file.newFromTemplate" = "Nowy z szablonu" "file.open" = "Otwórz…" @@ -2048,6 +2050,8 @@ name = "Polski" "pref.reduceMotion" = "Ogranicz animacje" "pref.renameDuplicates" = "Zmieniaj nazwy duplikatów" "pref.showDimensions" = "Pokazuj wymiary przy zaznaczeniu" +"pref.startOnHome" = "Rozpoczynaj od ekranu głównego" +"pref.uiScale" = "Rozmiar interfejsu" "pref.userName" = "Twoja nazwa w komentarzach" "proto.action.add" = "Dodaj akcję prototypu" "proto.action.move" = "Zmień kolejność akcji prototypu" @@ -2154,6 +2158,7 @@ name = "Polski" "text.listIndent" = "Zwiększ wcięcie listy" "text.listOutdent" = "Zmniejsz wcięcie listy" "text.listStyle" = "Styl listy" +"text.loadFont" = "Wczytaj plik czcionki" "text.maxLines" = "Maksymalna liczba wierszy" "text.missingFonts" = "Brakujące czcionki" "text.numberList" = "Lista numerowana" @@ -2162,7 +2167,9 @@ name = "Polski" "text.paragraphSpacing" = "Odstęp między akapitami" "text.pathOptions" = "Opcje ścieżki tekstu" "text.preview" = "Podgląd zmiany tekstu" +"text.refit" = "Dopasuj tekst do czcionek" "text.removeFromPath" = "Usuń tekst ze ścieżki" +"text.rescanFonts" = "Wyszukaj ponownie zainstalowane czcionki" "text.set" = "Ustaw tekst" "text.setFeatures" = "Funkcje OpenType" "text.setVariations" = "Osie czcionki zmiennej (Variable Font)" From 3df5673627026deba90fcfbf089c0ac1fa0d49dc Mon Sep 17 00:00:00 2001 From: Hamza Date: Tue, 20 Oct 2026 20:56:08 +0500 Subject: [PATCH 005/142] MCP: maxBytes is the whole message, and depth 0 says when it is over Two things the limit got wrong, both reported by CodeRabbit on #915. maxBytes was compared with the escaped text block alone, but what goes on the wire is that block inside a content object, inside the JSON-RPC result, with the request id and the newline the transport writes: a block measured at exactly maxBytes left as a larger message than the caller said it could read. A kilobyte of the caller's figure is now kept for that wrapping. About ninety bytes of it is fixed and the rest is the request id, so the figure holds for any id up to roughly 900 bytes -- every id in practice, a number or a UUID. Sizing the real envelope here would mean handing the transport's request id to every tool, and the assumption is written down instead, in the schema and at the constant. A depth: 0 request over the limit returned early, so the one response that cannot be made any smaller was the one sent without the truncated marker -- while a bisection that walked down to depth 0 sent the same tree with it. Depth 0 now takes the same path as the bisection: the tree goes out, over the limit, with the marker saying so. Without the first fix the test reports a limit at the exact size of the tree as no truncation; without the second the depth-0 marker is absent (left: (None, None), right: (Some(0), Some(0))). --- crates/mcp/src/tools.rs | 68 ++++++++++++++-------- crates/mcp/tests/inspect_response_limit.rs | 29 ++++++++- 2 files changed, 70 insertions(+), 27 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 92773e03..d04ea620 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -178,7 +178,9 @@ pub fn tool_definitions() -> Vec { json!({ "depth": int("Tree depth for the current page (default 4)"), "allPages": boolean("Expand every page, not only the current one"), - "maxBytes": int("Largest response to send, in bytes on the wire (default 8388608, which fits the usual 10 MiB client buffer)"), + "maxBytes": int( + "Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id" + ), }), &[], ), @@ -578,6 +580,14 @@ const MAX_RESPONSE: usize = 8 << 20; /// otherwise be able to push a response that only just fitted back over the limit. const MARKER_RESERVE: usize = 1 << 10; +/// Room kept for everything the text block is wrapped in: the content object and `isError`, the +/// JSON-RPC envelope (`jsonrpc`, `id`, `result`) and the newline the transport writes after it. +/// About ninety bytes of that is fixed and the rest is the request id, so `maxBytes` holds for +/// any id up to roughly 900 bytes -- which is every id in practice, a number or a UUID. An id +/// larger than that is the one case where the message can pass the caller's figure, and sizing +/// the real envelope here would mean handing the transport's request id to every tool. +const ENVELOPE_RESERVE: usize = 1 << 10; + /// The document at `depth`, and what its text block would take on the wire. The tree is sent as a /// text block, so it is measured pretty-printed *and* escaped -- escaping adds about a tenth. fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool) -> Result<(Value, usize), String> { @@ -602,37 +612,45 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result MAX_RESPONSE, }; + // What the tree itself may take: the caller's figure is for the whole message. + let budget = max_bytes.saturating_sub(ENVELOPE_RESERVE); + let (value, wire) = inspect_at(b, depth, all)?; - if wire <= max_bytes || depth == 0 { + if wire <= budget { return Ok(ToolResult::json(&value)); } - // The deepest tree that fits, by bisection: a deeper tree holds every node of a shallower one, - // so the size does not fall as the depth grows. Bisecting keeps this to about six more walks - // of the document rather than one per level. - let (mut lo, mut hi) = (0u64, depth - 1); - let mut best: Option<(u64, Value, usize)> = None; - while lo <= hi { - let mid = lo + (hi - lo) / 2; - let (v, w) = inspect_at(b, mid, all)?; - if w + MARKER_RESERVE <= max_bytes { - best = Some((mid, v, w)); - if mid == hi { + let (used, mut value, used_wire) = if depth == 0 { + // Depth 0 is the shallowest tree there is, so there is nothing smaller to send: it goes + // out over the limit with the marker saying so, the same as a bisection that reaches + // depth 0. An error would leave the caller with nothing at all. + (0, value, wire) + } else { + // The deepest tree that fits, by bisection: a deeper tree holds every node of a shallower + // one, so the size does not fall as the depth grows. Bisecting keeps this to about six + // more walks of the document rather than one per level. + let (mut lo, mut hi) = (0u64, depth - 1); + let mut best: Option<(u64, Value, usize)> = None; + while lo <= hi { + let mid = lo + (hi - lo) / 2; + let (v, w) = inspect_at(b, mid, all)?; + if w + MARKER_RESERVE <= budget { + best = Some((mid, v, w)); + if mid == hi { + break; + } + lo = mid + 1; + } else if mid == 0 { + best = Some((0, v, w)); break; + } else { + hi = mid - 1; } - lo = mid + 1; - } else if mid == 0 { - // Not even the shallowest tree fits. Send it anyway and say so: there is nothing - // smaller to send, and an error would leave the caller with nothing at all. - best = Some((0, v, w)); - break; - } else { - hi = mid - 1; } - } - - let Some((used, mut value, used_wire)) = best else { - return Ok(ToolResult::json(&value)); + match best { + Some(found) => found, + None => return Ok(ToolResult::json(&value)), + } }; if let Some(obj) = value.as_object_mut() { obj.insert( diff --git a/crates/mcp/tests/inspect_response_limit.rs b/crates/mcp/tests/inspect_response_limit.rs index 0d9f9e6d..5e36b5a9 100644 --- a/crates/mcp/tests/inspect_response_limit.rs +++ b/crates/mcp/tests/inspect_response_limit.rs @@ -6,6 +6,10 @@ mod common; use common::Client; use serde_json::{Value, json}; +/// The allowance `inspect_document` keeps for the content object, the JSON-RPC envelope and the +/// request id (`ENVELOPE_RESERVE`). +const ENVELOPE: usize = 1024; + /// A document whose tree is far larger than the limits used below. fn busy() -> Client { let mut c = Client::new(); @@ -49,8 +53,29 @@ fn a_tree_too_large_for_the_client_is_reported_shallower_and_says_so() { assert!(cut["pages"][0]["children"].as_array().is_some_and(|c| !c.is_empty()), "{cut}"); // The deepest tree that fits is the one sent: a limit that admits the whole tree is not cut. - let (whole, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": full_wire})); - assert!(whole["truncated"].is_null(), "a limit at the exact size is not a truncation: {}", whole["truncated"]); + // maxBytes is the whole message, so the tree fits under it with room for the envelope, not + // at it: a text block of exactly maxBytes would be sent inside a larger message than that. + let (whole, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": full_wire + ENVELOPE})); + assert!(whole["truncated"].is_null(), "a limit that leaves room for the envelope is not a truncation: {}", whole["truncated"]); + let (tight, _) = inspect(&mut c, json!({"depth": 6, "allPages": true, "maxBytes": full_wire})); + assert_eq!(tight["truncated"]["requestedDepth"], 6, "a limit at the exact size of the tree leaves none for the envelope: {tight}"); +} + +/// Every over-limit response carries the marker, however the depth was arrived at: a caller that +/// asked for depth 0 has no other way to learn that its limit was passed. +#[test] +fn a_depth_0_response_over_the_limit_says_so_too() { + let mut c = busy(); + let (deep, _) = inspect(&mut c, json!({"depth": 0, "allPages": true})); + assert!(deep["truncated"].is_null(), "within the default limit: {}", deep["truncated"]); + let (v, wire) = inspect(&mut c, json!({"depth": 0, "allPages": true, "maxBytes": 1024})); + let t = &v["truncated"]; + assert_eq!((t["requestedDepth"].as_u64(), t["depth"].as_u64()), (Some(0), Some(0)), "{t}"); + // `bytes` is the tree, measured before the marker was added to it, so the message that goes + // out is larger still -- there being nothing smaller to send. + assert_eq!(t["bytes"], t["requestedDepthBytes"], "{t}"); + assert!(wire > 1024, "the depth-0 tree is sent over the limit, with the marker saying so: {wire}"); + assert!(!v["pages"].is_null(), "the tree is still sent, there being nothing smaller: {v}"); } #[test] From 570144ee90e217da2ef27c1c5a4e839f6f0c06df Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:49:45 +0000 Subject: [PATCH 006/142] Reject multiple leading '#' in Color::from_hex Found while testing crates/color/src/lib.rs:125 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 3 ++- crates/color/src/tests.rs | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 8e9f98d9..e2ca7016 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -122,7 +122,8 @@ impl Color { /// Parse hex the way the colour field does: `#` optional, 1, 2, 3, 4, 6 or 8 digits /// (`F` → `FFFFFF`, `AB` → `ABABAB`, `ABC` → `AABBCC`). pub fn from_hex(s: &str) -> Option { - let s = s.trim().trim_start_matches('#'); + let s = s.trim(); + let s = s.strip_prefix('#').unwrap_or(s); if !s.chars().all(|c| c.is_ascii_hexdigit()) { return None; } diff --git a/crates/color/src/tests.rs b/crates/color/src/tests.rs index 4cf875bb..fdec5b56 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -114,3 +114,12 @@ fn infinite_channels_clamp() { let c = Color::new(f32::INFINITY, f32::NEG_INFINITY, f32::NAN, f32::INFINITY); assert_eq!((c.r, c.g, c.b, c.a), (1.0, 0.0, 0.0, 1.0)); } + +#[test] +fn hex_allows_at_most_one_leading_hash() { + assert!(Color::from_hex("##ff0000").is_none()); + assert!(Color::from_hex("###fff").is_none()); + assert!(Color::from_hex_or_name("##ff0000").is_none()); + assert_eq!(Color::from_hex("#ff0000").unwrap().to_hex(), "FF0000"); + assert_eq!(Color::from_hex("ff0000").unwrap().to_hex(), "FF0000"); +} From f3e5d2abc4c5db3289dce999bc1c1ed47303975d Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 21 Oct 2026 00:06:57 +0500 Subject: [PATCH 007/142] .fig import: a note says how much of the file it covers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A .fig import's notes were deduplicated and left uncounted, so each kind was named once with no sign of how much of the file it applied to: a file whose every text layer lost a setting read exactly like one where a single layer did. That is what made the loss in #467 invisible -- the reporter saw one line while three kinds of data went, one of them on all 83 text layers. The mapper now collects its notes through the same `Warnings` the REST importer already uses, which keeps the first-seen order and the cap and appends `(×n)` where a note was met more than once. Nothing else changes: the note text stays the catalog key it was, and the import report already reads a `(×n)` suffix off before translating the base (`ui-egui`'s `note_text`), so a counted note is still translated. `figcraft-cli info` prints these notes, so it now names each kind with its count. With the count dropped again, the test reports the two notes plain: ["connector layers import as static frame/vector snapshots ...", "connector label was skipped: depth limit prevents nested ..."] Part of #467. --- crates/format/src/fig/map.rs | 18 +++++---- .../map_connector_snapshot_budget_tests.rs | 40 +++++++++++++++++-- crates/format/src/rest/mod.rs | 19 +++++++-- 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 8e467151..f36cbb83 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -58,7 +58,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec { library_contexts: Vec, /// Layers visited while scanning internal canvases, across all of them. library_scan_count: usize, - warnings: Vec, + warnings: crate::rest::Warnings, count: usize, /// Most layers produced or scanned, instance expansion included; the one place the cap comes /// from (see [`super::layer_budget`]). @@ -464,7 +464,7 @@ impl<'a> Mapper<'a> { override_scope_entries: 0, library_contexts: Vec::new(), library_scan_count: 0, - warnings: Vec::new(), + warnings: crate::rest::Warnings::default(), count: 0, max_nodes, networks: Vec::new(), @@ -493,12 +493,14 @@ impl<'a> Mapper<'a> { .cloned() } + /// A note about what this file's mapping left out or changed. Counted, as the REST importer + /// counts its own: these notes are met once per layer, so the kinds were named once each with + /// no sign of how much of the file they covered -- a file whose every text layer lost a + /// setting read the same as one where a single layer did (#467). The collector keeps the + /// first-seen order and the same cap, since some notes name fields of the file (image + /// filters). fn warn(&mut self, w: impl Into) { - let w = w.into(); - // Capped like the REST importer's list: some warnings name fields of the file (image filters). - if self.warnings.len() < crate::rest::MAX_WARNINGS && !self.warnings.contains(&w) { - self.warnings.push(w); - } + self.warnings.add(w.into()); } fn children(&self, id: &str) -> Vec { diff --git a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs index ca47578d..8727c31b 100644 --- a/crates/format/src/fig/map_connector_snapshot_budget_tests.rs +++ b/crates/format/src/fig/map_connector_snapshot_budget_tests.rs @@ -115,14 +115,14 @@ fn connector_snapshot_respects_last_and_zero_child_slots() { assert!(!children[0]["fillGeometry"].as_array().unwrap().is_empty(), "saved outline survives the budget limit"); assert_eq!(children[0]["fillGeometry"][0]["path"], "M 0 0 L 20 10"); assert_eq!(mapper.count, MAX_NODES); - assert!(mapper.warnings.iter().any(|warning| warning == &format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); + assert!(mapper.warnings.seen().any(|warning| warning == format!("the file has more than {MAX_NODES} layers; connector label was skipped"))); let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES; let children = mapper.connector_snapshot_children(&view, "1:123"); assert!(children.is_empty()); assert_eq!(mapper.count, MAX_NODES, "the node budget is never exceeded"); - assert!(mapper.warnings.iter().any(|warning| warning.contains("connector snapshot children were skipped"))); + assert!(mapper.warnings.seen().any(|warning| warning.contains("connector snapshot children were skipped"))); } #[test] @@ -133,7 +133,7 @@ fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { assert_eq!(depth_limited["type"], "VECTOR"); assert!(!depth_limited["fillGeometry"].as_array().unwrap().is_empty()); assert!(depth_limited.get("children").is_none()); - assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); + assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: depth limit prevents nested snapshot children")); let mut mapper = Mapper::new(&message, MAX_NODES); mapper.count = MAX_NODES - 1; @@ -142,5 +142,37 @@ fn connector_snapshot_flattens_vector_at_depth_and_node_limits() { assert!(!budget_limited["fillGeometry"].as_array().unwrap().is_empty()); assert!(budget_limited.get("children").is_none()); assert_eq!(mapper.count, MAX_NODES); - assert!(mapper.warnings.iter().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children")); + assert!(mapper.warnings.seen().any(|warning| warning == "connector label was skipped: node budget prevents nested snapshot children")); +} + +/// A mapper note is met once per layer, so the kinds were named once each with no sign of how much +/// of the file they covered: a file whose every layer lost a setting read the same as one where a +/// single layer did (#467). They are counted now, with the `(×n)` the REST importer already uses +/// and the import report already reads back off before translating a note. +#[test] +fn a_note_met_more_than_once_carries_its_count() { + let message = message(); + + let mut mapper = Mapper::new(&message, MAX_NODES); + for _ in 0..2 { + mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + } + let notes = std::mem::take(&mut mapper.warnings).finish(); + let counted: Vec<&String> = notes.iter().filter(|n| n.contains("(×")).collect(); + assert!( + counted.iter().any(|n| n.ends_with("(×2)") && n.contains("connector label was skipped: depth limit")), + "the note met twice says so: {notes:?}" + ); + assert!(notes.iter().all(|n| !n.ends_with("(×1)")), "a count of one is left off: {notes:?}"); + + // One layer, and the same notes have no count at all. + let mut mapper = Mapper::new(&message, MAX_NODES); + mapper.layer("1:123", None, Affine::IDENTITY, None, None, MAX_DEPTH).expect("terminal connector"); + let once = std::mem::take(&mut mapper.warnings).finish(); + assert!(once.iter().all(|n| !n.contains("(×")), "{once:?}"); + assert_eq!( + once.len(), + counted.len() + notes.iter().filter(|n| !n.contains("(×")).count(), + "the same kinds, counted or not: {once:?} vs {notes:?}" + ); } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 78279e91..b86b0b91 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -329,15 +329,16 @@ fn children(v: &Value) -> &[Value] { v.get("children").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() } -/// Distinct warnings in first-seen order, with repeat counts. +/// Distinct warnings in first-seen order, with repeat counts. Shared with the `.fig` mapper +/// (`crate::fig::map`), whose notes repeat per layer in the same way. #[derive(Default)] -struct Warnings { +pub(crate) struct Warnings { list: Vec<(String, usize)>, index: BTreeMap, } impl Warnings { - fn add(&mut self, msg: String) { + pub(crate) fn add(&mut self, msg: String) { if let Some(i) = self.index.get(&msg) { if let Some(e) = self.list.get_mut(*i) { e.1 += 1; @@ -348,7 +349,17 @@ impl Warnings { } } - fn finish(self) -> Vec { + /// The notes met so far, without their counts, for the tests that ask whether one was met + /// (the product reads them through [`Self::finish`], with the counts). + #[cfg(test)] + pub(crate) fn seen(&self) -> impl Iterator { + self.list.iter().map(|(msg, _)| msg.as_str()) + } + + /// The notes, each with `(×n)` after it where it was met more than once. The import report + /// reads that suffix off before it looks the note up in the catalogue, so a counted note is + /// still translated (`ui-egui`'s `note_text`). + pub(crate) fn finish(self) -> Vec { self.list.into_iter().map(|(m, n)| if n > 1 { format!("{m} (×{n})") } else { m }).collect() } } From df5ffec87b8a1efbbef7ffe397ba33312df9345c Mon Sep 17 00:00:00 2001 From: Mex <44814932+MMexicann@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:25:49 +0200 Subject: [PATCH 008/142] M0.9: allow literal dash-prefixed CLI filenames --- ROADMAP.md | 4 ++++ apps/figcraft-cli/src/args.rs | 4 ++++ apps/figcraft-cli/src/main.rs | 39 +++++++++++++++++++++------------- apps/figcraft-cli/tests/cli.rs | 32 ++++++++++++++++++++++++++++ docs/cli.md | 4 ++++ 5 files changed, 68 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4e0d088d..f80b829d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-20** · CLI option terminator (M0.9): `--` ends option parsing, so dash-prefixed + filenames open normally; the global `--no-system-fonts` flag also respects the terminator. + Binary regression coverage opens five such native files and checks their JSON info. + - **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. diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 4c162dbb..665f7ee0 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -18,6 +18,10 @@ impl Args { let mut a = Args::default(); let mut it = argv.iter(); while let Some(arg) = it.next() { + if arg == "--" { + a.positional.extend(it.cloned()); + break; + } let name = if let Some(n) = arg.strip_prefix("--") { n } else if let Some(n) = arg.strip_prefix('-').filter(|n| n.len() == 1 && !n.starts_with(|c: char| c.is_ascii_digit())) { diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index badc5bc3..c5d7833c 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -101,15 +101,24 @@ impl From for CliError { pub type CliResult = Result; -fn run(argv: &[String]) -> CliResult { - let system_fonts = !argv.iter().any(|a| a == "--no-system-fonts"); - let argv: Vec = argv.iter().filter(|a| *a != "--no-system-fonts").cloned().collect(); - if system_fonts { +fn parse_args(argv: &[String], valued: &[&str], flags: &[&str]) -> CliResult { + let flags = [flags, &["no-system-fonts"]].concat(); + let args = Args::parse(argv, valued, &flags)?; + if !args.flag("no-system-fonts") { // Text lays out with the installed fonts, like the desktop app, so headless renders and // auto layout sizes match what users see. figcraft_text::scan_system_fonts_on_demand(); } - let Some((cmd, rest)) = argv.split_first() else { return Err(CliError::Usage("missing subcommand".into())) }; + Ok(args) +} + +fn run(argv: &[String]) -> CliResult { + let Some(command_index) = argv.iter().position(|a| a != "--no-system-fonts") else { + return Err(CliError::Usage("missing subcommand".into())); + }; + let cmd = argv.get(command_index).ok_or_else(|| CliError::Usage("missing subcommand".into()))?; + // Keep leading global flags with the subcommand's options, where values and `--` are parsed. + let rest: Vec = argv.iter().enumerate().filter(|(i, _)| *i != command_index).map(|(_, a)| a.clone()).collect(); match cmd.as_str() { "help" | "--help" | "-h" => { println!("{USAGE}"); @@ -120,13 +129,13 @@ fn run(argv: &[String]) -> CliResult { Ok(ExitCode::SUCCESS) } "info" => { - let a = Args::parse(rest, &ROOT_FLAGS, &["json"])?; + let a = parse_args(&rest, &ROOT_FLAGS, &["json"])?; let file = a.one_positional("info ")?; confined(&a, || files::info(&file, a.flag("json"))) } "export" => { let valued = [&["format", "scale", "page", "node", "o", "out", "bleed"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; + let a = parse_args(&rest, &valued, &["crop-marks", "bleed-marks", "cmyk"])?; let file = a.one_positional("export -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("export needs -o ".into()))?; let target = @@ -134,7 +143,7 @@ fn run(argv: &[String]) -> CliResult { confined(&a, || files::export(&file, &out, &target)) } "convert" => { - let a = Args::parse(rest, &ROOT_FLAGS, &[])?; + let a = parse_args(&rest, &ROOT_FLAGS, &[])?; match a.positional.as_slice() { [i, o] => confined(&a, || files::convert(i, o)), _ => Err(CliError::Usage("convert ".into())), @@ -142,19 +151,19 @@ fn run(argv: &[String]) -> CliResult { } "render" => { let valued = [&["o", "out", "scale", "page"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; let file = a.one_positional("render -o ")?; let out = a.value("o").or_else(|| a.value("out")).ok_or_else(|| CliError::Usage("render needs -o ".into()))?; confined(&a, || files::render(&file, &out, a.scale()?, a.value("page").as_deref())) } "run" => { let valued = [&["in", "out"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; let script = a.one_positional("run ")?; confined(&a, || files::run_script(&script, a.value("in").as_deref(), a.value("out").as_deref())) } "commands" => { - let a = Args::parse(rest, &["filter"], &["json"])?; + let a = parse_args(&rest, &["filter"], &["json"])?; if !a.positional.is_empty() { return Err(CliError::Usage("commands takes no positional arguments".into())); } @@ -163,7 +172,7 @@ fn run(argv: &[String]) -> CliResult { } "mcp" => { let valued = [&["attach"][..], &ROOT_FLAGS[..]].concat(); - let a = Args::parse(rest, &valued, &[])?; + let a = parse_args(&rest, &valued, &[])?; if !a.positional.is_empty() { return Err(CliError::Usage("mcp takes no positional arguments".into())); } @@ -178,7 +187,7 @@ fn run(argv: &[String]) -> CliResult { mcp(attach.as_deref(), roots_of(&a)?) } "plugin" => { - let a = Args::parse(rest, &["server"], &[])?; + let a = parse_args(&rest, &["server"], &[])?; match a.positional.as_slice() { [verb, folder] if verb == "publish" => { let server = a.value("server").ok_or_else(|| CliError::Usage("plugin publish needs --server ws://host:port/".into()))?; @@ -188,10 +197,10 @@ fn run(argv: &[String]) -> CliResult { } } "perf" => { - let a = Args::parse(rest, &["layers", "instances"], &[])?; + let a = parse_args(&rest, &["layers", "instances"], &[])?; let sizes: Vec = a.usizes("layers", &[1000, 10_000])?.into_iter().map(|n| n.clamp(10, 2_000_000)).collect(); let instances: Vec = a.usizes("instances", &[2000])?.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect(); - perf::run(&sizes, &instances, system_fonts) + perf::run(&sizes, &instances, !a.flag("no-system-fonts")) } other => Err(CliError::Usage(format!("unknown subcommand `{other}`"))), } diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index eb42b8d8..1928cab5 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -14,6 +14,38 @@ fn tmp(name: &str) -> std::path::PathBuf { d } +#[test] +fn option_terminator_allows_dash_prefixed_filenames() { + let dir = tmp("option-terminator"); + let doc = figcraft_doc::Document::new("Dash-prefixed file"); + let bytes = figcraft_format::save_bytes(&doc, None).unwrap(); + for name in ["--drawing.figcraft", "-h", "--json", "--no-system-fonts", "--"] { + std::fs::write(dir.join(name), &bytes).unwrap(); + let output = bin().current_dir(&dir).args(["info", "--json", "--no-system-fonts", "--", name]).output().unwrap(); + assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr)); + let info: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(info["file"], name); + assert_eq!(info["name"], "Dash-prefixed file"); + } + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn global_font_flag_respects_option_values() { + for filter in ["--", "--no-system-fonts"] { + let output = bin().args(["commands", "--filter", filter, "--no-system-fonts", "--json"]).output().unwrap(); + assert!(output.status.success(), "{filter}: {}", String::from_utf8_lossy(&output.stderr)); + assert!(serde_json::from_slice::(&output.stdout).unwrap().is_array()); + } +} + +#[test] +fn global_font_flag_can_precede_the_subcommand() { + let output = bin().args(["--no-system-fonts", "commands", "--json"]).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(!serde_json::from_slice::>(&output.stdout).unwrap().is_empty()); +} + #[test] fn bad_arguments_exit_2_without_panicking() { for args in [ diff --git a/docs/cli.md b/docs/cli.md index 699b6ba2..37e4b594 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -6,6 +6,10 @@ commands, serve MCP and measure performance. Build it with Bad arguments print usage and exit with code 2; failures exit with 1. The CLI never panics on input. +Put options before `--` to pass a filename beginning with a dash, for example +`figcraft-cli info --json -- --drawing.figcraft`. Every argument after `--` is literal, +including a filename named `--no-system-fonts`. + ## Fonts Text lays out and renders with the same fonts as the desktop app: the fonts built into FigCraft From 1aa1d61bd4aeb2aa93bfc04e2a3156227631bab4 Mon Sep 17 00:00:00 2001 From: Hamza Date: Wed, 21 Oct 2026 00:29:14 +0500 Subject: [PATCH 009/142] MCP: get_node keeps to the limit too, and windows a wide node inspect_document sends a shallower tree when the document's is too large and points at get_node for the rest, so get_node was the way out of that limit -- and it had none of its own. Measured against a document with a frame of 15,000 children, through figcraft-cli mcp: inspect_document depth 64, allPages 14.29 MiB (8.00 MiB cap not applied before #915) get_node depth 64 10.51 MiB Both are over the 10 MiB buffer the report is about, and for get_node no depth makes it smaller: the width is at depth 1, so depth 1 is already 9.12 MiB of text and depth 0 has no children at all. get_node now takes the same maxBytes, cuts the subtree by depth as the document's tree is cut, and then takes a window over the children when even depth 1 is too large. The marker says how many children were shown of how many, and the offset to ask for next; `offset` is a new argument for reading the rest. After this: get_node depth 64 8.00 MiB, children 11427 of 15000 from 0, next 11427 The hint in inspect_document's marker no longer promises that get_node has no size limit, since that was the hole: it now says a wide node answers with a window and an offset. The test pages through a windowed node and asserts every child is reported exactly once, in order, with every page inside the limit. Unbounded, the first page is 23,003 bytes against a 7,667-byte limit. --- crates/mcp/src/tools.rs | 185 +++++++++++++++++++-- crates/mcp/tests/inspect_response_limit.rs | 57 +++++++ 2 files changed, 224 insertions(+), 18 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d04ea620..19f2be35 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -189,8 +189,18 @@ pub fn tool_definitions() -> Vec { tool( "get_node", "Get layer", - "One layer as JSON (same fields as inspect_document) with its children to `depth`.", - obj(json!({"id": id_schema("Layer id"), "depth": int("Child depth (default 1)")}), &["id"]), + "One layer as JSON (same fields as inspect_document) with its children to `depth`. A subtree too large for one message is reported at a shallower depth, or with a window of the children, and a `truncated` object says so and gives the `offset` to ask for next.", + obj( + json!({ + "id": id_schema("Layer id"), + "depth": int("Child depth (default 1)"), + "offset": int("First child to report (default 0), for reading a node with more children than fit in one message"), + "maxBytes": int( + "Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id" + ), + }), + &["id"], + ), true, ), tool( @@ -588,12 +598,38 @@ const MARKER_RESERVE: usize = 1 << 10; /// the real envelope here would mean handing the transport's request id to every tool. const ENVELOPE_RESERVE: usize = 1 << 10; +/// What the caller says it can read, as the bytes its *tree* may take: the figure is for the whole +/// message, so the envelope's share comes off it here. +fn budget(a: &Map) -> Result<(usize, usize), String> { + // A caller that knows its own buffer can say so; 1 KiB is below the shallowest tree, so the + // floor only keeps the arithmetic sane. + let max_bytes = match n_arg(a, "maxBytes")? { + Some(v) if v < 1024.0 => return Err("`maxBytes` must be at least 1024".into()), + Some(v) => v.min(usize::MAX as f64) as usize, + None => MAX_RESPONSE, + }; + Ok((max_bytes, max_bytes.saturating_sub(ENVELOPE_RESERVE))) +} + +/// What a value's text block would take on the wire. It is sent as a text block, so it is measured +/// pretty-printed *and* escaped -- escaping adds about a tenth. +fn wire_size(value: &Value) -> usize { + let text = serde_json::to_string_pretty(value).unwrap_or_default(); + serde_json::to_string(&text).map_or(text.len(), |s| s.len()) +} + /// The document at `depth`, and what its text block would take on the wire. The tree is sent as a /// text block, so it is measured pretty-printed *and* escaped -- escaping adds about a tenth. fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool) -> Result<(Value, usize), String> { let value = b.call("document.inspect", json!({"depth": depth, "allPages": all}))?; - let text = serde_json::to_string_pretty(&value).unwrap_or_default(); - let wire = serde_json::to_string(&text).map_or(text.len(), |s| s.len()); + let wire = wire_size(&value); + Ok((value, wire)) +} + +/// One layer's subtree at `depth`, and what its text block would take on the wire. +fn node_at(b: &mut dyn Backend, id: u64, depth: u64) -> Result<(Value, usize), String> { + let value = b.call("node.get", json!({"id": id, "depth": depth}))?; + let wire = wire_size(&value); Ok((value, wire)) } @@ -604,16 +640,7 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result *x, Some(_) => return Err("`allPages` must be a boolean".into()), }; - // A caller that knows its own buffer can say so; 1 KiB is below the shallowest tree, so the - // floor only keeps the arithmetic sane. - let max_bytes = match n_arg(a, "maxBytes")? { - Some(v) if v < 1024.0 => return Err("`maxBytes` must be at least 1024".into()), - Some(v) => v.min(usize::MAX as f64) as usize, - None => MAX_RESPONSE, - }; - - // What the tree itself may take: the caller's figure is for the whole message. - let budget = max_bytes.saturating_sub(ENVELOPE_RESERVE); + let (max_bytes, budget) = budget(a)?; let (value, wire) = inspect_at(b, depth, all)?; if wire <= budget { @@ -664,21 +691,143 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result) -> Result { let id = id_arg(a.get("id").ok_or("`id` is required")?)?; let depth = n_arg(a, "depth")?.unwrap_or(1.0).clamp(0.0, 64.0) as u64; - let v = b.call("node.get", json!({"id": id, "depth": depth}))?; - if v.is_null() { + let offset = n_arg(a, "offset")?.unwrap_or(0.0).max(0.0).min(u32::MAX as f64) as usize; + let (max_bytes, budget) = budget(a)?; + + let (value, wire) = node_at(b, id, depth)?; + if value.is_null() { return Err(format!("no such layer {id}")); } - Ok(ToolResult::json(&v)) + if wire <= budget && offset == 0 { + return Ok(ToolResult::json(&value)); + } + + // The deepest subtree that fits, by bisection, as the document's tree is cut. A node asked for + // at a depth of its own children only (1) cannot be made shallower without losing them all, + // so that is where the window takes over. + let (mut used, mut value, mut used_wire) = (depth, value, wire); + if depth > 1 && offset == 0 { + let (mut lo, mut hi) = (1u64, depth - 1); + while lo <= hi { + let mid = lo + (hi - lo) / 2; + let (v, w) = node_at(b, id, mid)?; + if w + MARKER_RESERVE <= budget { + (used, value, used_wire) = (mid, v, w); + if mid == hi { + break; + } + lo = mid + 1; + } else if mid == 1 { + (used, value, used_wire) = (1, v, w); + break; + } else { + hi = mid - 1; + } + } + if used_wire + MARKER_RESERVE <= budget { + return Ok(ToolResult::json(&with_marker(value, depth, used, wire, used_wire, max_bytes, None))); + } + } + + // Still over, or a window was asked for: the children from `offset` that fit. + let total = value.get("children").and_then(Value::as_array).map_or(0, Vec::len); + let window = children_window(&mut value, offset, budget.saturating_sub(MARKER_RESERVE)); + let shown = value.get("children").and_then(Value::as_array).map_or(0, Vec::len); + let next = (offset + shown < total).then_some(offset + shown); + let used_wire = wire_size(&value); + let mut out = with_marker( + value, + depth, + used, + wire, + used_wire, + max_bytes, + Some(json!({ + "from": offset, + "shown": shown, + "total": total, + "nextOffset": next, + })), + ); + if window && next.is_none() && offset == 0 && used == depth { + // Nothing was left out after all (an offset of zero that fitted whole). + out.as_object_mut().map(|o| o.remove("truncated")); + } + Ok(ToolResult::json(&out)) +} + +/// Keep the children from `offset` that fit in `budget`, and say whether any were left out. The +/// window is found by bisection: a node with thousands of children would otherwise be serialised +/// once per child. +fn children_window(value: &mut Value, offset: usize, budget: usize) -> bool { + let Some(all) = value.get("children").and_then(Value::as_array).cloned() else { return false }; + let rest: Vec = all.iter().skip(offset).cloned().collect(); + let fits = |value: &mut Value, n: usize| { + if let Some(obj) = value.as_object_mut() { + obj.insert("children".into(), Value::Array(rest.iter().take(n).cloned().collect())); + } + wire_size(value) <= budget + }; + if fits(value, rest.len()) { + return rest.len() != all.len(); + } + // The largest window that fits, between none and all of the rest. + let (mut lo, mut hi) = (0usize, rest.len()); + while lo < hi { + let mid = lo + (hi - lo).div_ceil(2); + if fits(value, mid) { lo = mid } else { hi = mid - 1 } + } + fits(value, lo); + true +} + +/// `value` with the `truncated` object saying how it was cut. `children`: the window, when the +/// children were cut as well as (or instead of) the depth. +fn with_marker(mut value: Value, asked: u64, used: u64, asked_wire: usize, used_wire: usize, max_bytes: usize, children: Option) -> Value { + if let Some(obj) = value.as_object_mut() { + let mut marker = json!({ + "requestedDepth": asked, + "depth": used, + "requestedDepthBytes": asked_wire, + "bytes": used_wire, + "maxBytes": max_bytes, + }); + match &children { + Some(window) => { + marker["children"] = window.clone(); + marker["reason"] = json!(format!( + "the subtree at depth {asked} would be {asked_wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; its children are reported from {} instead", + window.get("from").and_then(Value::as_u64).unwrap_or(0) + )); + marker["hint"] = json!("ask again with the `offset` in `children.nextOffset` for the rest, or get_node({id, depth}) on one child"); + } + None => { + marker["reason"] = json!(format!( + "the subtree at depth {asked} would be {asked_wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; it is reported at depth {used} instead" + )); + marker["hint"] = json!("get_node({id, depth}) on a child reads deeper, one subtree at a time"); + } + } + obj.insert("truncated".into(), marker); + } + value } fn select(b: &mut dyn Backend, a: &Map) -> Result { diff --git a/crates/mcp/tests/inspect_response_limit.rs b/crates/mcp/tests/inspect_response_limit.rs index 5e36b5a9..b6d8b92e 100644 --- a/crates/mcp/tests/inspect_response_limit.rs +++ b/crates/mcp/tests/inspect_response_limit.rs @@ -95,3 +95,60 @@ fn maxbytes_is_checked() { assert_eq!(r["isError"], true, "{r}"); assert!(r["content"][0]["text"].as_str().unwrap_or("").contains("at least 1024"), "{r}"); } + +/// `inspect_document` sends a shallower tree and points at `get_node` for the rest, so `get_node` +/// must keep to the same limit -- it was unbounded. Measured on `main`: a frame with 15,000 +/// children answers with a 10.5 MiB message at depth 1, over the 10 MiB buffer the report is +/// about, and no depth makes that smaller because the width is at depth 1. The children are +/// windowed instead, with the offset to ask for next. +#[test] +fn a_node_with_more_children_than_fit_is_windowed() { + let mut c = Client::new(); + let frame = c.create(json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 400})); + for i in 0..40 { + c.create(json!({"type": "rectangle", "parent": frame, "x": i * 5, "y": 5, "width": 20, "height": 20})); + } + + // Whole, to measure against: every child, no marker. + let (whole, whole_wire) = get_node(&mut c, json!({"id": frame, "depth": 1})); + let total = whole["children"].as_array().expect("children").len(); + assert_eq!(total, 40, "{whole}"); + assert!(whole["truncated"].is_null(), "within the default limit: {}", whole["truncated"]); + + // A limit that holds only some of them: a window, and the marker says how many and what next. + let limit = whole_wire / 3; + assert!(limit >= 1024, "the fixture has to outgrow the floor: {whole_wire}"); + let (cut, cut_wire) = get_node(&mut c, json!({"id": frame, "depth": 1, "maxBytes": limit})); + assert!(cut_wire <= limit, "the response still exceeds maxBytes: {cut_wire} > {limit}"); + let window = &cut["truncated"]["children"]; + let shown = cut["children"].as_array().expect("children").len(); + assert_eq!(window["from"], json!(0), "{window}"); + assert_eq!(window["shown"], json!(shown), "{window}"); + assert_eq!(window["total"], json!(total), "{window}"); + assert!(shown > 0 && shown < total, "some children, not all: {shown} of {total}"); + assert_eq!(window["nextOffset"], json!(shown), "the offset to ask for next: {window}"); + // The layer itself is still there, not just a marker. + assert_eq!(cut["id"], whole["id"]); + + // Paging through with that offset reaches every child exactly once, in order. + let mut seen: Vec = Vec::new(); + let mut offset = Some(0u64); + while let Some(at) = offset { + let (page, wire) = get_node(&mut c, json!({"id": frame, "depth": 1, "maxBytes": limit, "offset": at})); + assert!(wire <= limit, "page at {at} exceeds maxBytes: {wire}"); + seen.extend(page["children"].as_array().cloned().unwrap_or_default()); + offset = page["truncated"]["children"]["nextOffset"].as_u64(); + assert!(seen.len() <= total, "a page repeated a child: {} of {total}", seen.len()); + } + assert_eq!(seen.len(), total, "every child was reported once"); + assert_eq!(seen, whole["children"].as_array().cloned().unwrap_or_default(), "in the same order"); +} + +/// The response and what its text block takes on the wire, for one `get_node` call. +fn get_node(c: &mut Client, args: Value) -> (Value, usize) { + let r = c.call("get_node", args.clone()); + assert_eq!(r["isError"], false, "{args}: {}", r["content"][0]["text"]); + let text = r["content"][0]["text"].as_str().expect("a text block"); + let wire = serde_json::to_string(&text).unwrap().len() + ENVELOPE; + (serde_json::from_str(text).expect("the block is JSON"), wire) +} From e211bf5476c44512bf3238e3698c085267f5da9b Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 18:22:16 +0200 Subject: [PATCH 010/142] M1: large .fig files keep their layers and their library links (#981) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A design-system .fig file (54 pages) stopped at 244 248 layers, the rest of its pages skipped, and most of its instances came in as frames "of components that aren't in the file", with the cap note shown twice. - The layer budget follows the compressed canvas at 8 bytes a layer, not 32: design systems are mostly instances, which pack tightly. Files under about 1.6 MB keep the 200 000 floor and the 2 million ceiling stays (measured on synthetic files: about 5.4 KB and 19 µs a layer). - The library copies the pages use (the file's hidden internal canvas) are mapped before the pages: at the cap the pages' last layers are skipped, not the mains their instances link to. The REST stage keeps building pages first (its ids are first-wins) and allows for the wrapper frame of each library copy, the one layer the mapper doesn't count; that layer tripped the REST stage's own cap and repeated the note. - Instances and swap defaults whose main is in the file but past the cap say so instead of "aren't in the file" (en and es catalogs). - The .fig import gives a note the mapper already gave only once. Tests in fig/layer_cap_tests.rs: the budget for a #981-sized canvas, library mains ahead of the pages at the cap, the note for a capped local main, and a whole 210 000-layer file with one cap note and its library links kept. All four fail on the previous code. --- crates/format/src/fig/layer_cap_tests.rs | 117 +++++++++++++++++++++++ crates/format/src/fig/map.rs | 32 ++++++- crates/format/src/fig/mod.rs | 17 +++- crates/format/src/rest/mod.rs | 33 +++++-- crates/ui-egui/locales/en.toml | 2 + crates/ui-egui/locales/es.toml | 2 + 6 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 crates/format/src/fig/layer_cap_tests.rs diff --git a/crates/format/src/fig/layer_cap_tests.rs b/crates/format/src/fig/layer_cap_tests.rs new file mode 100644 index 00000000..6eeb15f4 --- /dev/null +++ b/crates/format/src/fig/layer_cap_tests.rs @@ -0,0 +1,117 @@ +//! Large `.fig` files at the layer cap (#981): the budget a design-system file gets, the library +//! copies its instances need kept ahead of the pages, honest notes for what the cap kept out, and +//! one note for the cap itself. + +use figcraft_doc::{Document, NodeKind}; + +use super::kiwi::{Kv, Schema}; +use super::*; + +type Fields = Vec<(&'static str, Kv<'static>)>; + +fn o(fields: Fields) -> Kv<'static> { + Kv::Object(fields) +} + +fn g(id: u64) -> Kv<'static> { + o(vec![("sessionID", Kv::Uint(1)), ("localID", Kv::Uint(id))]) +} + +fn xy(x: f64, y: f64) -> Kv<'static> { + o(vec![("x", Kv::Float(x as f32)), ("y", Kv::Float(y as f32))]) +} + +/// A layer under `parent`; siblings come in id order. +fn node(id: u64, parent: Option, ty: &'static str, name: &str, more: Fields) -> Kv<'static> { + let mut fields = vec![("guid", g(id)), ("phase", Kv::Enum("CREATED")), ("type", Kv::Enum(ty)), ("name", Kv::Str(name.into()))]; + if let Some(parent) = parent { + fields.push(("parentIndex", o(vec![("guid", g(parent)), ("position", Kv::Str(format!("{id:08}")))]))); + } + fields.extend(more); + o(fields) +} + +fn instance(id: u64, page: u64, main: u64) -> Kv<'static> { + node(id, Some(page), "INSTANCE", "Instance", vec![("symbolData", o(vec![("symbolID", g(main))]))]) +} + +/// A main `id` of `layers` rectangles under `parent`. +fn main(id: u64, parent: u64, name: &str, layers: u64) -> Vec> { + let mut out = vec![node(id, Some(parent), "SYMBOL", name, vec![("size", xy(10.0, 10.0))])]; + out.extend((1..=layers).map(|i| node(id * 10_000 + i, Some(id), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); + out +} + +/// A page (2) of `count` instances of a main kept on the file's hidden library canvas (3), as a +/// design system's file carries the library components it uses. +fn library_message(count: u64, main_layers: u64) -> Kv<'static> { + let mut nodes = vec![ + node(1, None, "DOCUMENT", "Document", vec![]), + node(2, Some(1), "CANVAS", "Screens", vec![]), + node(3, Some(1), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + ]; + nodes.extend(main(20, 3, "Library main", main_layers)); + nodes.extend((0..count).map(|i| instance(1_000 + i, 2, 20))); + o(vec![("nodeChanges", Kv::Array(nodes))]) +} + +/// The `.fig` import's two stages at layer cap `cap`. +fn import_at(msg: &Kv, cap: usize) -> (Document, Vec) { + let (json, mut warnings, networks) = map::to_rest(msg, "Capped", cap); + let (doc, more) = crate::rest::import_with_networks(&json, &|_: &str| None, networks, cap).unwrap(); + warnings.extend(more); + (doc, warnings) +} + +fn linked_to(doc: &Document, main_name: &str) -> usize { + doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component, .. } if doc.node(component).is_some_and(|c| c.name == main_name))).count() +} + +#[test] +fn a_design_system_sized_canvas_gets_room() { + // The file in #981 stopped at 244 248 layers: a 7.8 MB canvas at 32 bytes a layer. + let reported = 244_248 * 32; + assert!(layer_budget(reported) >= 4 * 244_248, "{}", layer_budget(reported)); + // Small files still can't claim more than the REST import's cap, nor any file the ceiling. + assert_eq!(layer_budget(1 << 20), crate::rest::MAX_NODES); + assert_eq!(layer_budget(usize::MAX), MAX_FIG_NODES); +} + +#[test] +fn library_mains_come_before_the_pages_at_the_cap() { + // 40 instances of an 11-layer library main on a page, with room for 100 layers: mapped after + // the pages, the main found no room and every instance became a frame of "a component that + // isn't in the file". + let (doc, warnings) = import_at(&library_message(40, 10), 100); + assert!(warnings.iter().any(|w| w.contains("more than 100 layers")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); + let linked = linked_to(&doc, "Library main"); + assert!(linked >= 5, "{linked} instances linked to the library main: {warnings:?}"); + assert!(doc.nodes().any(|n| n.name == "Library main" && doc.is_remote(n.id))); +} + +#[test] +fn instances_of_a_main_the_cap_kept_out_say_so() { + // The page's instances come before their main: at the cap, the main is skipped though the + // file has it. + let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])]; + nodes.extend((0..20).map(|i| instance(100 + i, 2, 900))); + nodes.extend(main(900, 2, "Local main", 10)); + let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 60); + assert!(warnings.iter().any(|w| w.starts_with("instances whose component was past the layer limit were imported as frames")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); + assert!(!doc.nodes().any(|n| n.name == "Local main")); +} + +#[test] +fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() { + // A 1 001-layer library main used 210 times: about 210 000 layers in a file far too small to + // lift the cap above the REST import's 200 000. + let file = super::tests::canvas(&Schema::parse(export::SCHEMA).unwrap(), &library_message(210, 1_000)); + assert_eq!(layer_budget(file.len()), crate::rest::MAX_NODES); + let (doc, warnings) = import(&file, "Design system.fig").unwrap(); + let cap: Vec<_> = warnings.iter().filter(|w| w.contains("layers; the rest were skipped")).collect(); + assert_eq!(cap.len(), 1, "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); + assert!(linked_to(&doc, "Library main") >= 190, "{warnings:?}"); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 8e467151..7eb974da 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -42,6 +42,14 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec= m.max_nodes { + let capped: Vec = m.instance_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect(); + if !capped.is_empty() { + out[crate::rest::FIG_CAPPED_MAINS_FIELD] = Value::Array(capped); + } + } if !m.styles.is_empty() { out["styles"] = Value::Object(std::mem::take(&mut m.styles)); } @@ -389,6 +397,10 @@ struct Mapper<'a> { library_scan_count: usize, warnings: Vec, count: usize, + /// The mains in the file that instances name, and the mains mapped as layers: the difference + /// is what the layer cap kept out. + instance_mains: BTreeSet, + mapped_mains: BTreeSet, /// Most layers produced or scanned, instance expansion included; the one place the cap comes /// from (see [`super::layer_budget`]). max_nodes: usize, @@ -466,6 +478,8 @@ impl<'a> Mapper<'a> { library_scan_count: 0, warnings: Vec::new(), count: 0, + instance_mains: BTreeSet::new(), + mapped_mains: BTreeSet::new(), max_nodes, networks: Vec::new(), network_ids: HashMap::new(), @@ -556,9 +570,8 @@ impl<'a> Mapper<'a> { Some(d) => self.children(d), None => self.nodes.iter().filter(|(_, n)| n.get("type").and_then(Kv::str) == Some("CANVAS")).map(|(id, _)| id.clone()).collect(), }; - let mut out = Vec::new(); let mut internal = Vec::new(); - let mut visible_pages = Vec::new(); + let mut visible = Vec::new(); for p in pages { let Some(&n) = self.nodes.get(&p) else { continue }; if n.get("type").and_then(Kv::str) != Some("CANVAS") { @@ -568,17 +581,20 @@ impl<'a> Mapper<'a> { // The hidden page that holds styles, variables and library copies. if n.get("internalOnly").and_then(Kv::bool) == Some(true) { internal.push((p, n)); - continue; + } else { + visible.push((p, n)); } - visible_pages.push(p.clone()); - out.push(self.page(&p, n)); } + // The library copies the pages use come first: in a file past the layer cap, the pages' + // last layers are skipped rather than the mains every instance of them needs (#981). + let visible_pages: Vec = visible.iter().map(|(p, _)| p.clone()).collect(); let required_mains = self.referenced_component_mains(&visible_pages); for (id, canvas) in internal { if let Some(context) = self.library_context(&id, canvas, &required_mains) { self.library_contexts.push(context); } } + let out: Vec = visible.into_iter().map(|(p, n)| self.page(&p, n)).collect(); if out.is_empty() { self.warn("the file has no pages"); } @@ -879,6 +895,9 @@ impl<'a> Mapper<'a> { self.warn("connector layers import as static frame/vector snapshots with labels when present; live endpoint bindings are not retained"); } self.count += 1; + if own == "SYMBOL" && inst.is_none() { + self.mapped_mains.insert(id.to_string()); + } let mut o = Obj::new(); o.insert("id".into(), json!(rest_id)); // Private provenance for the .fig importer; ordinary REST import ignores it. @@ -1994,6 +2013,9 @@ impl<'a> Mapper<'a> { self.warn("instances of components that aren't in the file show no layers"); return None; } + if !self.instance_mains.contains(&sym) { + self.instance_mains.insert(sym.clone()); + } if outer.is_some_and(|i| i.symbols.contains(&sym)) { return None; } diff --git a/crates/format/src/fig/mod.rs b/crates/format/src/fig/mod.rs index 4c103d08..c04043ae 100644 --- a/crates/format/src/fig/mod.rs +++ b/crates/format/src/fig/mod.rs @@ -23,6 +23,8 @@ mod export_tests; #[cfg(test)] mod interop_tests; pub mod kiwi; +#[cfg(test)] +mod layer_cap_tests; mod map; mod network; #[cfg(test)] @@ -64,8 +66,11 @@ const MAX_CHUNKS: usize = 16; /// compressed canvas, at least the REST import's cap and at most [`MAX_FIG_NODES`]. Instances /// expand into a layer per layer of their component, and chunks inflate up to [`MAX_CHUNK`], so the /// budget follows what the file takes, not what it unpacks to: a small file can't claim a large -/// count, however it's built; a large one gets room. -const PACKED_BYTES_PER_LAYER: usize = 32; +/// count, however it's built; a large one gets room. Design systems are mostly instances, which +/// pack tightly: a 7.8 MB canvas stopped at 244 248 layers at 32 bytes a layer (#981). At 8, files +/// under about 1.6 MB keep the floor and the ceiling comes at a 16 MB canvas (about 11 GB and 40 s +/// to import 2 million synthetic layers; memory grows with the count, about 5.4 KB a layer). +const PACKED_BYTES_PER_LAYER: usize = 8; const MAX_FIG_NODES: usize = 2_000_000; /// Most layers imported from a `.fig` file whose compressed canvas is `packed` bytes. @@ -108,7 +113,13 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma drop(msg); let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).cloned()); let (doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; - warnings.extend(more.into_iter().filter_map(map::adapt_warning)); + // Both stages stop at the same cap and say so in the same words: one note (#981). + for w in more.into_iter().filter_map(map::adapt_warning) { + let base = w.rsplit_once(" (×").map_or(w.as_str(), |(b, _)| b); + if !warnings.iter().any(|seen| seen == base) { + warnings.push(w); + } + } Ok((doc, warnings)) } diff --git a/crates/format/src/rest/mod.rs b/crates/format/src/rest/mod.rs index 78279e91..40c091c6 100644 --- a/crates/format/src/rest/mod.rs +++ b/crates/format/src/rest/mod.rs @@ -65,6 +65,8 @@ const MAX_DEPTH: usize = 256; pub(crate) const FIG_SOURCE_FIELD: &str = "_figSource"; /// Hidden .fig library context; accepted only by the `.fig` import ([`import_with_networks`]). pub(crate) const FIG_LIBRARY_CONTEXTS_FIELD: &str = "_figLibraryContexts"; +/// Ids of mains in a `.fig` file that its layer cap kept out; read only by the `.fig` import. +pub(crate) const FIG_CAPPED_MAINS_FIELD: &str = "_figCappedMains"; /// Longest SVG path string read from one geometry entry. const MAX_PATH_BYTES: usize = 4 << 20; /// Most path elements in one vector. @@ -215,6 +217,14 @@ fn import_parts( cx.style_definitions(v); cx.networks = networks; cx.max_nodes = max_nodes; + // The `.fig` mapper already kept the layers, library copies included, within the cap; each + // library copy's wrapper frame is the one layer here it doesn't count (#981). + let contexts = if fig_sources { v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default() } else { &[] }; + if fig_sources { + cx.capped_mains = + v.get(FIG_CAPPED_MAINS_FIELD).and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str).map(str::to_owned).collect(); + cx.max_nodes = max_nodes.saturating_add(contexts.len()); + } for e in entries { let Some(root) = e.get("document") else { continue }; match text(root, "type") { @@ -236,11 +246,9 @@ fn import_parts( None => cx.warn("entries without a node type were skipped"), } } - if fig_sources { - for context in v.get(FIG_LIBRARY_CONTEXTS_FIELD).and_then(Value::as_array).into_iter().flatten() { - let store = cx.doc.ensure_library_store(); - cx.build(context, store, Space::default(), None, 0, false); - } + for context in contexts { + let store = cx.doc.ensure_library_store(); + cx.build(context, store, Space::default(), None, 0, false); } // Drop the empty page the new document started with once real pages exist. if let Some(p) = blank @@ -404,6 +412,8 @@ pub(crate) struct Ctx<'a> { networks: Vec>, /// Most layers imported: [`MAX_NODES`], or the `.fig` import's own cap. max_nodes: usize, + /// Mains the `.fig` file has but its layer cap kept out ([`FIG_CAPPED_MAINS_FIELD`]). + capped_mains: BTreeSet, } impl<'a> Ctx<'a> { @@ -432,6 +442,7 @@ impl<'a> Ctx<'a> { var_modes: BTreeMap::new(), networks: Vec::new(), max_nodes: MAX_NODES, + capped_mains: BTreeSet::new(), } } @@ -998,7 +1009,11 @@ impl<'a> Ctx<'a> { fn resolve_swap_defaults(&mut self) { for (owner, name, rest_id) in std::mem::take(&mut self.swap_defaults) { let Some(c) = self.component(&rest_id) else { - self.warn("instance swap properties whose default component isn't in the file were skipped"); + if self.capped_mains.contains(&rest_id) { + self.warn("instance swap properties whose default component was past the layer limit were skipped"); + } else { + self.warn("instance swap properties whose default component isn't in the file were skipped"); + } continue; }; let def = PropDef::InstanceSwap { default: c, preferred: Vec::new() }; @@ -1017,7 +1032,11 @@ impl<'a> Ctx<'a> { let frames: BTreeSet = self.instances.iter().map(|(i, _)| *i).collect(); let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new(); for (inst, j) in std::mem::take(&mut self.instances) { - match text(j, "componentId").and_then(|r| self.component(r)) { + let main = text(j, "componentId"); + match main.and_then(|r| self.component(r)) { + None if main.is_some_and(|r| self.capped_mains.contains(r)) => { + self.warn("instances whose component was past the layer limit were imported as frames"); + } None => self.warn("instances of components that aren't in the file were imported as frames"), Some(c) if self.doc.is_ancestor(c, inst) => self.warn("instances inside their own main component were imported as frames"), Some(c) => links.push((inst, c, j)), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..32045d4e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1595,10 +1595,12 @@ name = "English" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "image filters are ambiguous because both filter containers are present; filter conversion was skipped" "image filters use FigCraft's approximate renderer" = "image filters use FigCraft's approximate renderer" "instance swap properties whose default component isn't in the file were skipped" = "instance swap properties whose default component isn't in the file were skipped" +"instance swap properties whose default component was past the layer limit were skipped" = "instance swap properties whose default component was past the layer limit were skipped" "instances inside their own main component were imported as frames" = "instances inside their own main component were imported as frames" "instances of components that aren't in the file show no layers" = "instances of components that aren't in the file show no layers" "instances of components that aren't in the file were imported as frames" = "instances of components that aren't in the file were imported as frames" "instances that would nest a component inside itself were imported as frames" = "instances that would nest a component inside itself were imported as frames" +"instances whose component was past the layer limit were imported as frames" = "instances whose component was past the layer limit were imported as frames" "instances without a main component were imported as empty frames" = "instances without a main component were imported as empty frames" "invalid internal .fig text path data was ignored" = "invalid internal .fig text path data was ignored" "invalid or oversized vector networks import as outlines" = "invalid or oversized vector networks import as outlines" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..c6e77866 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1596,10 +1596,12 @@ status = "draft — needs native review" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "los filtros de imagen son ambiguos porque están los dos contenedores de filtros; se omitió la conversión de filtros" "image filters use FigCraft's approximate renderer" = "los filtros de imagen usan el renderizado aproximado de FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado no está en el archivo" +"instance swap properties whose default component was past the layer limit were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado quedó fuera del límite de capas" "instances inside their own main component were imported as frames" = "las instancias dentro de su propio componente principal se importaron como marcos" "instances of components that aren't in the file show no layers" = "las instancias de componentes que no están en el archivo no muestran capas" "instances of components that aren't in the file were imported as frames" = "las instancias de componentes que no están en el archivo se importaron como marcos" "instances that would nest a component inside itself were imported as frames" = "las instancias que anidarían un componente dentro de sí mismo se importaron como marcos" +"instances whose component was past the layer limit were imported as frames" = "las instancias cuyo componente quedó fuera del límite de capas se importaron como marcos" "instances without a main component were imported as empty frames" = "las instancias sin componente principal se importaron como marcos vacíos" "invalid internal .fig text path data was ignored" = "se ignoraron datos internos no válidos de texto en trazado del .fig" "invalid or oversized vector networks import as outlines" = "las redes vectoriales no válidas o demasiado grandes se importan como contornos" From 5e2957a9011d71ff8fc3469830eb28ca17a2d3ad Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 19:15:58 +0200 Subject: [PATCH 011/142] M1: Save, Save as and Export arrive as downloads in the browser (#969) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause: in the web build no file has a path, so Save goes to Save as, and Save as and every Export go through Services::pick_save / pick_folder. WebServices keeps the trait's defaults there, which return no path, so the callback never ran: no download, no message. Only Variables ▸ Export used the platform's downloads (#368). Fix: where services.downloads() is true, those routes run their command without a path and hand the bytes to services.download(): - engine: file.saveAs takes `data: true` and returns the .figcraft file as base64 `data` with its `name`, marking it saved (no path, nothing remembered). Programmatic Save as without either still asks for a path. - ui: FigApp::download decodes a result's `data` and says when nothing arrived; Save as, one-off export (PNG/JPG/WebP/SVG/PDF), .fig export, frames to PDF and multi-layer export (one download per file) use it. Tests: engine save_as_data_hands_back_the_file_and_marks_it_saved; ui-egui web_save_tests (save, export, refused download) with the fake browser Services from variables_export_ui_tests. The UI tests fail without the fix. --- crates/engine/src/cmd/file.rs | 58 +++++++++++++++---- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/dialogs.rs | 15 ++++- crates/ui-egui/src/export_panel.rs | 17 ++++++ crates/ui-egui/src/lib.rs | 22 +++++++ crates/ui-egui/src/menus.rs | 9 +++ .../ui-egui/src/variables_export_ui_tests.rs | 8 +-- crates/ui-egui/src/web_save_tests.rs | 55 ++++++++++++++++++ 9 files changed, 169 insertions(+), 17 deletions(-) create mode 100644 crates/ui-egui/src/web_save_tests.rs diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7380711f..8a75efb7 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -51,7 +51,7 @@ pub fn specs() -> Vec { spec!( "file.saveAs", "Save as", - "{path} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", + "{path | data: true (no path: returns the file as base64 `data` with its `name`, for a download)} (no extension adds .figcraft); a .fig path writes an experimental export copy of the shown document with `warnings`, keeping the native path, unsaved changes and version preview", false, save_as ), @@ -843,37 +843,53 @@ fn color_profile(s: &mut Session, p: &Value) -> Result { pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { let path = string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); - path.is_some_and(|path| ext(&path) != "fig") + path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p)) +} + +/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download). +fn wants_data(p: &Value) -> bool { + p.get("data").and_then(Value::as_bool) == Some(true) } /// Save the file of the active tab (the main file for a branch tab, with its branches), or export /// the active document as an experimental `.fig` copy without changing its native save state. -fn save_to(s: &mut Session, path: String) -> Result { - if ext(&path) == "fig" { +/// Without a path the file comes back as base64 `data` for the caller to deliver (#969). +fn save_to(s: &mut Session, path: Option) -> Result { + if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") { return export_fig(s, &json!({"path": path})); } let i = crate::branches::file_tab(s); let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; let mut snapshot = FileSnapshot::of(ds); let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); - if follows_path && ds.path.as_deref() != Some(path.as_str()) { - Arc::make_mut(&mut snapshot.doc).name = file_stem(&path); + if let Some(path) = path.as_deref() + && follows_path + && ds.path.as_deref() != Some(path) + { + Arc::make_mut(&mut snapshot.doc).name = file_stem(path); } let bytes = snapshot.encode()?; - write(&path, &bytes)?; + if let Some(path) = &path { + write(path, &bytes)?; + } ds.doc = snapshot.doc; - ds.path = Some(path.clone()); + if path.is_some() { + ds.path.clone_from(&path); + } ds.mark_saved(); ds.revision += 1; - let uid = ds.uid; + let (uid, name) = (ds.uid, format!("{}.figcraft", ds.doc.name)); branch_tabs_saved(s, uid); + let Some(path) = path else { + return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); + }; remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } fn save(s: &mut Session, p: &Value) -> Result { if let Some(path) = string(p, "path") { - return save_to(s, path); + return save_to(s, Some(path)); } match s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?.path.clone() { // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. @@ -881,7 +897,7 @@ fn save(s: &mut Session, p: &Value) -> Result { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) } - Some(path) => save_to(s, path), + Some(path) => save_to(s, Some(path)), None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) @@ -895,8 +911,9 @@ fn save_as(s: &mut Session, p: &Value) -> Result { if ext(&path).is_empty() { path.push_str(".figcraft"); } - save_to(s, path) + save_to(s, Some(path)) } + None if wants_data(p) => save_to(s, None), None => { s.request_ui("dialog.saveAs", json!({})); Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into())) @@ -1563,6 +1580,23 @@ mod rest_import_tests { assert_eq!(s.docs.len(), open, "a refused open adds nothing"); } + #[test] + fn save_as_data_hands_back_the_file_and_marks_it_saved() { + // The browser has no paths: Save as returns the file for a download (#969). + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path"); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); + assert_eq!(r["name"], "Untitled.figcraft"); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + let mut t = Session::new(); + t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap(); + assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen"); + } + #[test] fn open_and_import_rest_json() { let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ae566fc8..3ce0659d 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -319,6 +319,7 @@ name = "English" "Corner radius" = "Corner radius" "Corner smoothing" = "Corner smoothing" "Couldn't download the variables" = "Couldn't download the variables" +"Couldn't download {name}" = "Couldn't download {name}" "Couldn't reach {server}: showing what this computer remembers." = "Couldn't reach {server}: showing what this computer remembers." "Couldn't read {name}." = "Couldn't read {name}." "Couldn't save automatically: {error}" = "Couldn't save automatically: {error}" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..bd572dbe 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -10,6 +10,7 @@ status = "draft — needs native review" [ui] "Compatibility notes" = "Notas de compatibilidad" "Couldn't download the variables" = "No se pudieron descargar las variables" +"Couldn't download {name}" = "No se pudo descargar {name}" "% of all commits" = "% del total de commits" "(no longer published)" = "(ya no publicado)" diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index bf9f1b29..9695faee 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -665,7 +665,11 @@ pub(crate) fn file_saved(app: &mut FigApp, result: &Value) { } fn result_file_name(result: &Value) -> String { - result["path"].as_str().and_then(|p| Path::new(p).file_name()).map(|f| f.to_string_lossy().into_owned()).unwrap_or_default() + match result["path"].as_str() { + Some(p) => Path::new(p).file_name().map(|f| f.to_string_lossy().into_owned()).unwrap_or_default(), + // A download (#969). + None => result["name"].as_str().unwrap_or_default().to_owned(), + } } /// The `.fig` result stays an export: native save state is unchanged, and omissions are reported. @@ -681,6 +685,15 @@ pub(crate) fn fig_exported(app: &mut FigApp, result: &Value) { /// Save the whole file as `.fig`; the toast says how much was left out. fn save_fig(app: &mut FigApp, name: &str) { let filename = format!("{name}.fig"); + if app.services.downloads() { + if let Some(mut r) = app.run("file.export", json!({"format": "fig"})) + && app.download(&filename, &r) + { + r["name"] = json!(filename); + fig_exported(app, &r); + } + return; + } app.pick_save(tr("Export"), &filename, &["fig"], |app, p| { if let Some(r) = app.run("file.export", json!({"format": "fig", "path": p.to_string_lossy()})) { fig_exported(app, &r); diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 8617c35a..6a4ca8ee 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -320,6 +320,15 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { [] => {} // The suggested name keeps only the file's own name (a layer's folders apply to folders). [one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)), + // No folders in the browser: each file arrives as its own download (#969). + _ if app.services.downloads() => { + let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets})) else { return }; + let files = r["files"].as_array().map_or(&[][..], Vec::as_slice); + let n = files.iter().filter(|f| app.download(f["name"].as_str().unwrap_or_default(), f)).count(); + if n > 0 { + app.toast(trn("Exported {n} files", n, &[])); + } + } _ => { let ids = ids.to_vec(); app.pick_folder(tr("Export"), move |app, dir| { @@ -337,6 +346,14 @@ pub(crate) fn save_one(app: &mut FigApp, ids: &[NodeId], setting: &ExportSetting let ext = setting.format.ext(); let mut params = setting_params(setting); params["ids"] = json!(ids); + if app.services.downloads() { + if let Some(r) = app.run("file.export", params) + && app.download(file, &r) + { + app.toast(trf("Exported {name}", &[("name", &file)])); + } + return; + } app.pick_save(tr("Export"), file, &[ext], move |app, p| { params["path"] = json!(p.to_string_lossy()); if app.run("file.export", params).is_some() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..788bbf4f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -223,6 +223,8 @@ mod vector_tail_ui_tests; #[cfg(test)] mod web_clipboard_tests; #[cfg(test)] +mod web_save_tests; +#[cfg(test)] mod windows_qa_tests; #[cfg(test)] mod windows_tests; @@ -1140,6 +1142,14 @@ impl FigApp { } pub fn save_as_dialog(&mut self) { + if self.services.downloads() { + if let Some(r) = self.run("file.saveAs", json!({"data": true})) + && self.download(r["name"].as_str().unwrap_or_default(), &r) + { + dialogs::file_saved(self, &r); + } + return; + } let Ok(name) = self.session.doc().map(|d| d.name.clone()) else { return }; self.pick_save(i18n::tr("Save as"), &format!("{name}.figcraft"), &["figcraft", "fig"], |app, mut p| { if p.extension().is_none_or(|e| e.is_empty()) { @@ -1244,6 +1254,18 @@ impl FigApp { self.start_dialog(|services| services.pick_save(title, name, exts), true, done); } + /// Hand a command result's base64 `data` over as a download named `name`, where the platform + /// saves by downloads rather than save dialogs (the browser, #969). Says so when nothing arrived. + pub(crate) fn download(&mut self, name: &str, result: &Value) -> bool { + use base64::Engine as _; + let bytes = result["data"].as_str().and_then(|d| base64::engine::general_purpose::STANDARD.decode(d).ok()); + if bytes.is_some_and(|b| self.services.download(name, &b)) { + return true; + } + self.toast(i18n::trf("Couldn't download {name}", &[("name", &name)])); + false + } + /// Pick a folder; `done` runs as [`FigApp::pick_open`]'s does. pub fn pick_folder(&mut self, title: &str, done: impl FnOnce(&mut FigApp, PathBuf) + 'static) { self.start_dialog(|services| services.pick_folder(title), true, done); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9659c6b3..afa34873 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -248,6 +248,15 @@ pub fn invoke(app: &mut FigApp, id: &str) { /// Every visible top-level frame of the page as one PDF page each, through a save dialog. fn export_frames_pdf(app: &mut FigApp) { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_else(|_| "Untitled".into()); + if app.services.downloads() { + let file = format!("{name}.pdf"); + if let Some(r) = app.run("file.exportFramesPdf", json!({})) + && app.download(&file, &r) + { + app.toast(trf("Exported {name}", &[("name", &file)])); + } + return; + } app.pick_save(tr("Export"), &format!("{name}.pdf"), &["pdf"], |app, p| { if app.run("file.exportFramesPdf", json!({"path": p.to_string_lossy()})).is_some() { app.toast(trf("Exported {name}", &[("name", &p.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_default())])); diff --git a/crates/ui-egui/src/variables_export_ui_tests.rs b/crates/ui-egui/src/variables_export_ui_tests.rs index 40a84393..ce51a096 100644 --- a/crates/ui-egui/src/variables_export_ui_tests.rs +++ b/crates/ui-egui/src/variables_export_ui_tests.rs @@ -9,12 +9,12 @@ use serde_json::{Value, json}; use crate::variables_panel::export_dialog; use crate::{FigApp, NoServices, Services}; -type Downloads = Arc)>>>; +pub(crate) type Downloads = Arc)>>>; /// Services that save by downloads and keep them (`deliver` false: the browser refused). -struct Browser { - got: Downloads, - deliver: bool, +pub(crate) struct Browser { + pub(crate) got: Downloads, + pub(crate) deliver: bool, } impl Services for Browser { diff --git a/crates/ui-egui/src/web_save_tests.rs b/crates/ui-egui/src/web_save_tests.rs new file mode 100644 index 00000000..7d1d75d2 --- /dev/null +++ b/crates/ui-egui/src/web_save_tests.rs @@ -0,0 +1,55 @@ +//! Save, Save as and Export where the platform saves by downloads (the browser, which has no save +//! dialogs or paths): the file arrives as a download instead of nothing happening (#969). + +use base64::Engine as _; +use figcraft_doc::{ExportSetting, NodeId}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::FigApp; +use crate::variables_export_ui_tests::{Browser, Downloads}; + +fn browser(deliver: bool) -> (FigApp, Downloads, NodeId) { + let mut s = Session::new(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let id = serde_json::from_value(r["id"].clone()).unwrap(); + let got = Downloads::default(); + (FigApp::new(s, Box::new(Browser { got: got.clone(), deliver })), got, id) +} + +fn toast(app: &FigApp) -> String { + app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +#[test] +fn save_downloads_the_figcraft_file() { + let (mut app, got, _) = browser(true); + app.save(); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert_eq!(name, "Untitled.figcraft"); + let mut reopened = Session::new(); + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + reopened.execute("file.open", &json!({"data": data, "name": name})).unwrap(); + assert!(!app.session.ds().unwrap().is_dirty()); + assert_eq!(toast(&app), "Saved Untitled.figcraft"); +} + +#[test] +fn export_downloads_the_exported_file() { + let (mut app, got, id) = browser(true); + crate::export_panel::save_one(&mut app, &[id], &ExportSetting::default(), "Rectangle.png"); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert_eq!(name, "Rectangle.png"); + assert!(bytes.starts_with(b"\x89PNG")); + assert_eq!(toast(&app), "Exported Rectangle.png"); +} + +#[test] +fn a_refused_download_says_so() { + let (mut app, got, _) = browser(false); + app.save_as_dialog(); + assert_eq!(got.lock().unwrap().len(), 1); + assert_eq!(toast(&app), "Couldn't download Untitled.figcraft"); +} From 3146ad2b2f87331f3d3c90bed51ee4fc997e1848 Mon Sep 17 00:00:00 2001 From: Kameron Date: Wed, 21 Oct 2026 11:03:08 -0700 Subject: [PATCH 012/142] node.reparent: index is the layer's final position (#992) `node.reparent {ids, parent, index}` read `index` against the parent's children as they were before the move, so within the same parent moving a layer up one place did nothing and asking for two places moved it one. `index` now counts the parent's children after the move, the way `node.reorder` already does, in the same parent or a new one; several layers still land together in their stacking order. The layers panel was the only caller passing an index other than 0. It drops on a gap between rows counted before the move, so it now subtracts the dragged layers below that gap and drags behave as before. Documents `index` in the command's params and in docs/mcp.md. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/edit.rs | 15 +++++-- crates/engine/src/panels_behaviour_tests.rs | 44 +++++++++++++++++---- crates/ui-egui/src/layers.rs | 6 ++- crates/ui-egui/src/layers_drag_tests.rs | 23 +++++++++++ docs/mcp.md | 6 +++ 5 files changed, 80 insertions(+), 14 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..4ec26887 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -216,7 +216,13 @@ pub fn specs() -> Vec { let i = req_id("node.get", p, "id")?; Ok(crate::inspect::node_json(s.doc()?, i, 1)) }), - spec!("node.reparent", "Move layer into", "{ids?, parent, index?}", true, reparent), + spec!( + "node.reparent", + "Move layer into", + "{ids?, parent, index? (final position among parent's children, bottom first; default top)}", + true, + reparent + ), spec!("node.reorder", "Reorder layer", "{id, index}", true, |s, p| { let i = req_id("node.reorder", p, "id")?; let idx = req_num("node.reorder", p, "index")?.max(0.0) as usize; @@ -1302,9 +1308,10 @@ fn reparent(s: &mut Session, p: &Value) -> Result { let index = num(p, "index").map(|i| i.max(0.0) as usize); let doc = s.doc_mut()?; let ids = z_sorted(doc, &roots(doc, &ids)); - // The layers land together under the first sibling at or above `index` that stays put (none: on - // top), wherever they came from. - let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().skip(at).find(|c| !ids.contains(c)).copied()); + // `index` is where the first moved layer ends up among the parent's children (bottom first), + // counted after the move, the way `node.reorder` counts it. The layers land together, keeping + // their order, under the sibling that stays put and ends up just after them (none: on top). + let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied()); for i in &ids { // Counted with the layer still in place, as `reparent` expects. let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a)); diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index d299b0df..9870aa31 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -99,16 +99,18 @@ fn scene(s: &mut Session) -> Scene { #[test] fn dragging_in_the_layers_panel_reorders_by_the_documented_rules() { - // The panel drops layers with `node.reparent {ids, parent, index}`: `index` counts the - // parent's children bottom first, as they are before the move. + // The panel drops layers with `node.reparent {ids, parent, index}`: `index` is where the first + // moved layer ends up among the parent's children, bottom first, counted after the move. // (moved, index in F, F's children after (bottom first), derivation) - let rows: [(&[&str], usize, [&str; 4], &str); 6] = [ - (&["a"], 4, ["b", "c", "d", "a"], "a layer dragged to the top of its parent's list comes to the front"), + let rows: [(&[&str], usize, [&str; 4], &str); 8] = [ + (&["a"], 3, ["b", "c", "d", "a"], "a layer dragged to the top of its parent's list comes to the front"), + (&["a"], 9, ["b", "c", "d", "a"], "an index past the end is the front"), (&["d"], 0, ["d", "a", "b", "c"], "a layer dragged to the bottom of the list goes to the back"), - (&["a"], 2, ["b", "a", "c", "d"], "a layer dropped between two layers lands between them"), - (&["a", "b"], 4, ["c", "d", "a", "b"], "several selected layers move together, keeping their order"), - (&["a", "d"], 2, ["b", "a", "d", "c"], "several layers from apart land together where they are dropped"), - (&["b", "c"], 1, ["a", "b", "c", "d"], "silent: dropping a block of layers onto itself changes nothing (pinned)"), + (&["a"], 1, ["b", "a", "c", "d"], "a layer moved up one place goes up one place (#992)"), + (&["a"], 0, ["a", "b", "c", "d"], "silent: a layer asked to stay where it is stays (pinned)"), + (&["a", "b"], 2, ["c", "d", "a", "b"], "several selected layers move together, keeping their order"), + (&["a", "d"], 1, ["b", "a", "d", "c"], "several layers from apart land together where they are asked to"), + (&["b", "c"], 1, ["a", "b", "c", "d"], "silent: moving a block of layers to where it is changes nothing (pinned)"), ]; for (moved, index, after, why) in rows { let mut s = Session::new(); @@ -172,6 +174,32 @@ fn dragging_into_frames_groups_and_auto_layout() { ); } +#[test] +fn reparent_index_is_the_final_position_in_auto_layout_and_across_parents() { + // #992: `index` is the layer's place among the parent's children after the move. + let mut s = Session::new(); + let row = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Row", "props": {"layout": "horizontal"}}))); + let text = |s: &mut Session, t: &str, parent: NodeId| id(&ex(s, "node.create", json!({"type": "text", "name": t, "text": t, "parent": parent}))); + let (first, second, third) = (text(&mut s, "first", row), text(&mut s, "second", row), text(&mut s, "third", row)); + let names = |s: &Session, p: NodeId| kids(s, p).iter().map(|i| name(s, *i)).collect::>(); + ex(&mut s, "node.reparent", json!({"ids": [first], "parent": row, "index": 1})); + assert_eq!(names(&s, row), ["second", "first", "third"], "moved down one place"); + ex(&mut s, "node.reparent", json!({"ids": [first], "parent": row, "index": 2})); + assert_eq!(names(&s, row), ["second", "third", "first"], "moved to the end"); + ex(&mut s, "node.reparent", json!({"ids": [first], "parent": row, "index": 0})); + assert_eq!(names(&s, row), ["first", "second", "third"], "moved to the start"); + let x = |s: &Session, i: NodeId| s.doc().unwrap().node(i).unwrap().transform.as_coeffs()[4]; + assert!(x(&s, first) < x(&s, second) && x(&s, second) < x(&s, third), "the flow follows the new order"); + // Into another parent, `index` means the same thing. + let other = id(&ex(&mut s, "node.create", json!({"type": "frame", "name": "Other", "props": {"layout": "horizontal"}}))); + let (p, q) = (text(&mut s, "p", other), text(&mut s, "q", other)); + ex(&mut s, "node.reparent", json!({"ids": [second], "parent": other, "index": 1})); + assert_eq!(kids(&s, other), [p, second, q], "lands at index 1 of the new parent"); + ex(&mut s, "node.reparent", json!({"ids": [first, third], "parent": other, "index": 3})); + assert_eq!(kids(&s, other), [p, second, q, first, third], "several layers start at index 3, keeping their order"); + assert!(kids(&s, row).is_empty()); +} + #[test] fn renaming_several_layers_by_the_documented_codes() { // Three layers named `names`, bottom to top; (names, params, new names, derivation). diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index f172b309..81d3fbcb 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -1075,8 +1075,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Above in the list = higher z = after the target among siblings. if let Some(parent) = doc.node(target).and_then(|n| n.parent) { let idx = doc.index_in_parent(target).unwrap_or(0); - let index = if z < 0 { idx + 1 } else { idx }; - app.run("node.reparent", json!({"ids": ids, "parent": parent, "index": index})); + let gap = if z < 0 { idx + 1 } else { idx }; + // `index` is the final position: the dragged layers below the gap leave it. + let below = doc.node(parent).map_or(0, |p| p.children.iter().take(gap).filter(|c| ids.contains(c)).count()); + app.run("node.reparent", json!({"ids": ids, "parent": parent, "index": gap - below})); } } } diff --git a/crates/ui-egui/src/layers_drag_tests.rs b/crates/ui-egui/src/layers_drag_tests.rs index 37002f9f..fb74e155 100644 --- a/crates/ui-egui/src/layers_drag_tests.rs +++ b/crates/ui-egui/src/layers_drag_tests.rs @@ -165,3 +165,26 @@ fn dropping_layers_on_another_page_moves_them_there() { let doc = u.app.session.doc().unwrap(); assert_eq!(doc.ancestors(rect).last().copied(), Some(p2), "moved to page 2"); } + +#[test] +fn dropping_a_layer_one_place_over_moves_it_one_place() { + // #992: the panel drops on a gap between rows; the layer ends up in that gap. + let mut s = Session::new(); + let rect = + |s: &mut Session, x: i32| id(s.execute("node.create", &json!({"type": "rectangle", "x": x, "y": 0, "width": 10, "height": 10})).unwrap()); + let (a, b, c) = (rect(&mut s, 0), rect(&mut s, 20), rect(&mut s, 40)); + s.execute("select.none", &json!({})).unwrap(); + let page = s.page().unwrap(); + let mut u = Ui::new(s); + let kids = |u: &Ui| u.app.session.doc().unwrap().node(page).unwrap().children.clone(); + // The list shows c, b, a top to bottom: `a` dragged onto the top edge of `b` goes just above it. + let (from, to) = (u.row_at(a), u.row_at(b) - vec2(0.0, 12.0)); + u.drag_to(from, to); + u.button(to, false); + assert_eq!(kids(&u), [b, a, c], "dropped between b and c"); + // And `c` dragged onto the bottom edge of `a` goes just below it. + let (from, to) = (u.row_at(c), u.row_at(a) + vec2(0.0, 12.0)); + u.drag_to(from, to); + u.button(to, false); + assert_eq!(kids(&u), [b, c, a], "dropped between a and b"); +} diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..64ff024e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -190,6 +190,12 @@ Or in a project's `.mcp.json`: the CSS `fr` unit: a track never gets less than its content (a fixed-size child bigger than its share keeps it; the other `fr` tracks share the rest). `inspect` reports a grid child's resolved `gridCell`. +- Layer order: `node.reparent {ids?, parent, index?}` moves layers into `parent` (keeping their place + on the canvas, or taking their place in its auto layout flow) and `node.reorder {id, index}` moves + one within its parent. `index` counts the parent's children bottom first (in auto layout, the + first in the flow) and is where the (first) moved layer ends up after the move, in the same parent + or a new one; several layers land together in their stacking order. No `index`, or one past the + end, puts them on top (last in the flow). - **Every menu action is a command** (`list_commands`), and most commands act on `ids` when given, else on the selection. New layers become the selection. - Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills and strokes, `color` may also be a hex From 9315e5e014705e5866fa1d74fa8e9b2075a00b1c Mon Sep 17 00:00:00 2001 From: Kameron Date: Wed, 21 Oct 2026 11:19:47 -0700 Subject: [PATCH 013/142] command.batch: named steps ($ref) and slash paths in field A batch step can name itself with "as" and later steps refer to it with {"$ref": "name"}, alongside positional {"$step": n}. "field" now takes a slash path into the result as well as a key, so {"$ref": "copy", "field": "ids/0"} reaches the first id edit.duplicate returned (the convention variable.bind uses for fills/0/color; a leading "/" as in JSON Pointer also works, and a key holding "/" still matches first). Resolution moves to crates/engine/src/batch.rs (BatchStep, BatchScope), shared by command.batch, the CLI script runner, the control channel and the MCP batch tool, which now passes "as" through. Duplicate names fail before any step runs; unknown, same-step or later names, bad paths and out-of-range indexes fail the batch with the step, name and full path. $ref is reserved like $step; nested batches keep their own names. Fixes #994. Co-Authored-By: Claude Opus 5.5 --- apps/figcraft-cli/src/files.rs | 65 ++++++-- crates/engine/src/batch.rs | 192 ++++++++++++++++++++++ crates/engine/src/batch_step_ref_tests.rs | 148 +++++++++++++++++ crates/engine/src/cmd/edit.rs | 9 +- crates/engine/src/collab_live_tests.rs | 6 +- crates/engine/src/lib.rs | 79 +++------ crates/engine/src/tests.rs | 5 + crates/mcp/src/tools.rs | 10 +- crates/mcp/tests/agent_tasks.rs | 29 ++++ docs/cli.md | 14 +- docs/control-protocol.md | 14 +- docs/mcp.md | 52 ++++-- 12 files changed, 516 insertions(+), 107 deletions(-) create mode 100644 crates/engine/src/batch.rs diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 46215599..d18e071a 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -4,7 +4,7 @@ use std::path::Path; use std::process::ExitCode; use figcraft_doc::{Document, ExportFormat, ExportSetting, NodeId, NodeKind, PrintSettings}; -use figcraft_engine::Session; +use figcraft_engine::{BatchScope, BatchStep, Session}; use serde_json::{Value, json}; use crate::{CliError, CliResult}; @@ -309,7 +309,7 @@ pub fn render(file: &str, out: &str, scale: f64, page: Option<&str>) -> CliResul } /// Parse a script: a JSON array of steps, `{"commands": [...]}`, or JSON lines. -pub fn parse_script(text: &str) -> Result, String> { +pub fn parse_script(text: &str) -> Result, String> { let steps: Vec = match serde_json::from_str::(text) { Ok(Value::Array(a)) => a, Ok(Value::Object(o)) if o.contains_key("commands") => { @@ -334,7 +334,8 @@ pub fn parse_script(text: &str) -> Result, String> { Some(p @ Value::Object(_)) => p.clone(), Some(_) => return Err(format!("step {i} ({cmd}): `params` must be an object")), }; - Ok((cmd.to_string(), params)) + let name = figcraft_engine::batch::step_name(i, &s).map_err(|e| e.to_string())?; + Ok(BatchStep { name, ..BatchStep::new(cmd, params) }) }) .collect() } @@ -352,31 +353,30 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe None => Session::new(), }; // One undo step and all-or-nothing, the way the same commands behave through `command.batch` - // (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and - // a step that fails takes the whole script back instead of leaving the earlier steps applied. - // The result lines are held back until the script is through, so none of them reports work that - // was rolled back. - let mut results: Vec = Vec::with_capacity(steps.len()); + // (MCP, the control channel, plugins): `{"$step": n}` and `{"$ref": "name"}` resolve against the + // steps before it, and a step that fails takes the whole script back instead of leaving the + // earlier steps applied. The result lines are held back until the script is through, so none of + // them reports work that was rolled back. + let mut scope = BatchScope::new(&steps).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let mut failed_at = 0; let outcome = s.grouped(&format!("run {script}"), |s| { - for (i, (cmd, params)) in steps.iter().enumerate() { + for (i, step) in steps.iter().enumerate() { failed_at = i; - let v = s.batch_step(&results, i, cmd, params)?; + s.batch_step(&mut scope, &step.command, &step.params)?; s.ui_requests.clear(); - results.push(v); } Ok(()) }); if let Err(e) = outcome { - let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str()); + let cmd = steps.get(failed_at).map_or("", |step| step.command.as_str()); let i = failed_at; println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true})); eprintln!("error: step {i} ({cmd}) failed: {e}"); eprintln!("the script's document changes were rolled back"); return Ok(ExitCode::from(1)); } - for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() { - println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})); + for (i, (step, v)) in steps.iter().zip(scope.results()).enumerate() { + println!("{}", json!({"step": i, "command": step.command, "ok": true, "result": v})); } if let Some(out) = out { let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?; @@ -494,6 +494,43 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// A script names its steps with `as` and reads paths into their results, like `command.batch`. + #[test] + fn a_script_refers_to_named_steps_and_paths() { + let dir = temp_dir("run-ref"); + let script = dir.join("script.json"); + std::fs::write( + &script, + r#"[ + {"command": "node.create", "as": "card", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}}, + {"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copy", "field": "ids/0"}], "props": {"name": "Copy"}}} + ]"#, + ) + .unwrap(); + let out = dir.join("out.figcraft"); + run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap(); + + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap(); + let doc = s.doc().unwrap(); + let top = doc.node(s.page().unwrap()).unwrap().children.clone(); + let names: Vec<_> = top.iter().map(|id| doc.node(*id).unwrap().name.clone()).collect(); + assert_eq!(names, ["Card", "Copy"]); + std::fs::remove_dir_all(dir).unwrap(); + } + + /// Two steps with one name fail before anything runs. + #[test] + fn a_script_with_a_name_twice_runs_nothing() { + let dir = temp_dir("run-dup-name"); + let script = dir.join("script.json"); + std::fs::write(&script, r#"[{"command": "select.none", "as": "a"}, {"command": "select.none", "as": "a"}]"#).unwrap(); + let Err(CliError::Failed(e)) = run_script(script.to_str().unwrap(), None, None) else { panic!("a name twice must fail") }; + assert!(e.contains("step 1: `as` \"a\" is already step 0's name"), "{e}"); + std::fs::remove_dir_all(dir).unwrap(); + } + /// A step that fails takes the whole script back: nothing is written, and the failure says so. #[test] fn a_failed_step_leaves_no_output() { diff --git a/crates/engine/src/batch.rs b/crates/engine/src/batch.rs new file mode 100644 index 00000000..5c4550ec --- /dev/null +++ b/crates/engine/src/batch.rs @@ -0,0 +1,192 @@ +//! Step references in `command.batch`: a step's params can use what an earlier step returned. +//! +//! `{"$step": n}` stands for the `id` in the result of step `n` (zero-based), and `{"$ref": "name"}` +//! for the `id` in the result of the step that called itself `name` with `"as": "name"`. Either +//! takes a `field`, a key of that result or a slash path into it (`ids/0`, `map/3`, `bounds/x`), the +//! way `variable.bind` names `fills/0/color`. The CLI's script runner resolves its steps the same way. + +use std::collections::HashMap; + +use serde_json::{Map, Value}; + +use crate::{Result, bad}; + +/// Deepest params nesting the resolver walks before giving up. +const MAX_DEPTH: usize = 64; + +/// One step of a batch: a command, its params and the name later steps can call it by. +#[derive(Clone, Debug, PartialEq)] +pub struct BatchStep { + pub command: String, + pub params: Value, + /// The step's `as`, for `{"$ref": name}` in later steps. + pub name: Option, +} + +impl BatchStep { + /// A step without a name. + pub fn new(command: impl Into, params: Value) -> Self { + Self { command: command.into(), params, name: None } + } +} + +/// A step's `as` (`step` is its index, for the error): `None` when it has none. +pub fn step_name(step: usize, json: &Value) -> Result> { + match json.get("as") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(name)) if !name.is_empty() => Ok(Some(name.clone())), + Some(_) => Err(bad("command.batch", format!("step {step}: `as` must be a name (a non-empty string)"))), + } +} + +/// The steps of one batch run so far: their results, and which step each `as` name belongs to. +/// A nested `command.batch` step gets its own scope, so its references are to its own steps. +#[derive(Clone, Debug, Default)] +pub struct BatchScope { + names: HashMap, + results: Vec, +} + +impl BatchScope { + /// The scope for running `steps`. Two steps with the same name fail here, before any step runs. + pub fn new(steps: &[BatchStep]) -> Result { + let mut names = HashMap::new(); + for (i, step) in steps.iter().enumerate() { + let Some(name) = &step.name else { continue }; + if let Some(first) = names.insert(name.clone(), i) { + return Err(bad("command.batch", format!("step {i}: `as` \"{name}\" is already step {first}'s name"))); + } + } + Ok(Self { names, results: Vec::with_capacity(steps.len()) }) + } + + /// The results of the steps run so far, in order. + pub fn results(&self) -> &[Value] { + &self.results + } + + pub fn into_results(self) -> Vec { + self.results + } + + /// The index of the step that runs next. + pub fn next_step(&self) -> usize { + self.results.len() + } + + pub(crate) fn push(&mut self, result: Value) { + self.results.push(result); + } + + /// `params` of the next step with every reference in it replaced. + pub(crate) fn resolve(&self, params: &Value) -> Result { + self.resolve_in(params, 0) + } + + fn resolve_in(&self, v: &Value, depth: usize) -> Result { + let step = self.next_step(); + if depth > MAX_DEPTH { + return Err(bad("command.batch", format!("step {step}: params nest deeper than {MAX_DEPTH} levels"))); + } + match v { + Value::Object(o) if o.contains_key("$step") || o.contains_key("$ref") => self.reference(o), + Value::Object(o) => { + let mut out = Map::with_capacity(o.len()); + for (k, x) in o { + out.insert(k.clone(), self.resolve_in(x, depth + 1)?); + } + Ok(Value::Object(out)) + } + Value::Array(a) => a.iter().map(|x| self.resolve_in(x, depth + 1)).collect::>>().map(Value::Array), + _ => Ok(v.clone()), + } + } + + /// The value a `{"$step": n}` or `{"$ref": "name"}` object (with an optional `field`) stands for. + fn reference(&self, o: &Map) -> Result { + let step = self.next_step(); + let fail = |msg: String| bad("command.batch", format!("step {step}: {msg}")); + let key = if o.contains_key("$step") { "$step" } else { "$ref" }; + if let Some(k) = o.keys().find(|k| *k != key && *k != "field") { + return Err(fail(format!("a `{key}` reference takes only `field`, not `{k}`"))); + } + let (n, who) = if key == "$step" { + let n = o.get("$step").and_then(Value::as_u64).ok_or_else(|| fail("`$step` must be a step index (0, 1, …)".into()))?; + let n = usize::try_from(n).ok().filter(|n| *n < step).ok_or_else(|| { + let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; + fail(format!("`$step` {n} must be an earlier step ({range})")) + })?; + (n, format!("step {n}")) + } else { + let name = o.get("$ref").and_then(Value::as_str).ok_or_else(|| fail("`$ref` must be a step name (a string)".into()))?; + let n = *self.names.get(name).ok_or_else(|| fail(format!("`$ref` \"{name}\" names no step ({})", self.known_names())))?; + if n >= step { + return Err(fail(format!("`$ref` \"{name}\" is step {n}, not an earlier step"))); + } + (n, format!("step {n} (\"{name}\")")) + }; + let path = match o.get("field") { + None => "id", + Some(Value::String(f)) => f.as_str(), + Some(_) => return Err(fail("`field` must be a string".into())), + }; + let result = self.results.get(n).ok_or_else(|| fail(format!("{who} has no result")))?; + lookup(result, path).cloned().map_err(|why| fail(format!("{who}'s result has no `{path}`{why}"))) + } + + fn known_names(&self) -> String { + let mut names: Vec<_> = self.names.iter().collect(); + names.sort_by_key(|(_, i)| **i); + if names.is_empty() { + return "no step has an `as` name".into(); + } + let list: Vec<_> = names.iter().map(|(name, i)| format!("\"{name}\" is step {i}")).collect(); + list.join(", ") + } +} + +/// `path` in `v`: a key as written, else a slash path (`ids/0`, `bounds/x`; a leading `/` as in +/// JSON Pointer is allowed). The error is the detail after "has no `path`". +fn lookup<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> { + // A key as written first, so a key holding `/` still works as it did before paths. + if let Some(x) = v.get(path) { + return Ok(x); + } + let hint = if path.contains('.') && !path.contains('/') { " (paths use `/`, as in `ids/0`)" } else { "" }; + walk(v, path.strip_prefix('/').unwrap_or(path)).map_err(|why| format!("{why}{hint}")) +} + +fn walk<'a>(v: &'a Value, path: &str) -> std::result::Result<&'a Value, String> { + let mut cur = v; + let mut walked = String::new(); + for seg in path.split('/') { + let here = if walked.is_empty() { "the result".to_string() } else { format!("`{walked}`") }; + cur = match cur { + Value::Object(o) => o.get(seg).ok_or_else(|| if walked.is_empty() { String::new() } else { format!(": {here} has no `{seg}`") })?, + Value::Array(a) => match seg.parse::() { + Ok(i) => a.get(i).ok_or_else(|| { + let items = if a.len() == 1 { "1 item".to_string() } else { format!("{} items", a.len()) }; + format!(": {here} has {items}") + })?, + Err(_) => return Err(format!(": {here} is a list, so `{seg}` must be an index (0, 1, …)")), + }, + _ => return Err(format!(": {here} is {}", kind(cur))), + }; + if !walked.is_empty() { + walked.push('/'); + } + walked.push_str(seg); + } + Ok(cur) +} + +fn kind(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "a boolean", + Value::Number(_) => "a number", + Value::String(_) => "a string", + Value::Array(_) => "a list", + Value::Object(_) => "an object", + } +} diff --git a/crates/engine/src/batch_step_ref_tests.rs b/crates/engine/src/batch_step_ref_tests.rs index ac69118e..451a6300 100644 --- a/crates/engine/src/batch_step_ref_tests.rs +++ b/crates/engine/src/batch_step_ref_tests.rs @@ -110,3 +110,151 @@ fn a_nested_batch_refers_to_its_own_steps() { let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own first frame, not the outer one"); } + +/// Runs `commands` as a batch and returns the error message, which must be `BadParams`. +fn batch_error(s: &mut Session, commands: serde_json::Value) -> String { + match s.execute("command.batch", &json!({"commands": commands})) { + Err(EngineError::BadParams { msg, .. }) => msg, + other => panic!("expected BadParams, got {other:?}"), + } +} + +#[test] +fn a_named_step_is_referred_to_by_its_name() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "as": "card", "params": {"type": "frame", "width": 200, "height": 100}}, + {"command": "node.create", "as": "swatch", "params": {"type": "rectangle", "parent": {"$ref": "card"}, "width": 20, "height": 20}}, + {"command": "node.set", "params": {"ids": [{"$ref": "swatch", "field": "id"}], "props": {"name": "Swatch"}}}, + {"command": "node.create", "params": {"type": "text", "text": "hi", "parent": {"$step": 0}}} + ]}), + ) + .unwrap(); + let (frame, rect, text) = (id(&results[0]), id(&results[1]), id(&results[3])); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(rect).unwrap().parent, Some(frame)); + assert_eq!(doc.node(rect).unwrap().name, "Swatch"); + assert_eq!(doc.node(text).unwrap().parent, Some(frame), "`$step` and `$ref` mix in one batch"); + assert_eq!(s.ds().unwrap().history.undo.len(), 1, "still one undo step"); +} + +/// `edit.duplicate` answers `{"ids": [...]}`: a slash path in `field` takes one of them. +#[test] +fn a_field_path_indexes_into_a_list_in_the_result() { + let mut s = Session::new(); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + {"command": "node.create", "as": "a", "params": {"type": "frame", "name": "A", "width": 10, "height": 10}}, + {"command": "node.create", "as": "b", "params": {"type": "frame", "name": "B", "width": 10, "height": 10}}, + {"command": "edit.duplicate", "as": "copies", "params": {"ids": [{"$ref": "a"}, {"$ref": "b"}]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "copies", "field": "ids/1"}], "props": {"name": "B copy"}}}, + {"command": "node.set", "params": {"ids": [{"$step": 2, "field": "/ids/0"}], "props": {"name": "A copy"}}} + ]}), + ) + .unwrap(); + let copies = results[2]["ids"].as_array().unwrap().clone(); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(crate::params::id_of(&copies[0]).unwrap()).unwrap().name, "A copy", "a leading `/` works too"); + assert_eq!(doc.node(crate::params::id_of(&copies[1]).unwrap()).unwrap().name, "B copy"); +} + +#[test] +fn a_field_path_walks_nested_objects_and_takes_whole_values() { + let mut scope = crate::BatchScope::default(); + scope.push(json!({"ids": [7, 8], "bounds": {"x": 1.5, "corners": [{"r": 4}]}, "a/b": "slash key"})); + let resolved = scope + .resolve(&json!({ + "x": {"$step": 0, "field": "bounds/x"}, + "r": {"$step": 0, "field": "bounds/corners/0/r"}, + "all": {"$step": 0, "field": "ids"}, + "key": {"$step": 0, "field": "a/b"}, + "pointer": {"$step": 0, "field": "/ids"} + })) + .unwrap(); + assert_eq!(resolved, json!({"x": 1.5, "r": 4, "all": [7, 8], "key": "slash key", "pointer": [7, 8]}), "a key holding `/` is still read as a key"); +} + +#[test] +fn bad_names_and_paths_fail_and_roll_back() { + let mut s = Session::new(); + let before = s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(); + let frame = |name: &str| json!({"command": "node.create", "as": name, "params": {"type": "frame", "width": 10, "height": 10}}); + let dup = json!({"command": "edit.duplicate", "as": "copy", "params": {"ids": [{"$ref": "card"}]}}); + let set = |r: serde_json::Value| json!({"command": "node.set", "params": {"ids": [r], "props": {"name": "x"}}}); + let later = json!({"command": "node.create", "as": "later", "params": {"type": "frame", "width": 10, "height": 10}}); + for (commands, needle) in [ + (json!([frame("card"), frame("card")]), "step 1: `as` \"card\" is already step 0's name"), + (json!([frame("card"), set(json!({"$ref": "nope"}))]), "step 1: `$ref` \"nope\" names no step (\"card\" is step 0)"), + (json!([{"command": "node.create", "params": {"type": "frame"}}, set(json!({"$ref": "nope"}))]), "no step has an `as` name"), + (json!([frame("card"), set(json!({"$ref": "later"})), later]), "step 1: `$ref` \"later\" is step 2, not an earlier step"), + ( + json!([frame("card"), {"command": "node.set", "as": "self", "params": {"ids": [{"$ref": "self"}], "props": {}}}]), + "is step 1, not an earlier step", + ), + (json!([frame("card"), set(json!({"$ref": 0}))]), "`$ref` must be a step name"), + (json!([frame("card"), set(json!({"$ref": "card", "id": 3}))]), "a `$ref` reference takes only `field`, not `id`"), + (json!([frame("card"), set(json!({"$ref": "card", "$step": 0}))]), "a `$step` reference takes only `field`, not `$ref`"), + (json!([frame("card"), {"command": "node.create", "as": "", "params": {}}]), "step 1: `as` must be a name"), + (json!([frame("card"), {"command": "node.create", "as": 3, "params": {}}]), "step 1: `as` must be a name"), + ( + json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/1"}))]), + "step 2: step 1 (\"copy\")'s result has no `ids/1`: `ids` has 1 item", + ), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/first"}))]), "`ids` is a list, so `first` must be an index"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids/0/x"}))]), "has no `ids/0/x`: `ids/0` is a number"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "map/0"}))]), "step 1 (\"copy\")'s result has no `map/0`"), + (json!([frame("card"), dup.clone(), set(json!({"$ref": "copy", "field": "ids.0"}))]), "has no `ids.0` (paths use `/`, as in `ids/0`)"), + (json!([frame("card"), set(json!({"$step": 0, "field": "id/0"}))]), "has no `id/0`: `id` is a number"), + ] { + let msg = batch_error(&mut s, commands); + assert!(msg.contains(needle), "{msg:?} should contain {needle:?}"); + assert_eq!(s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len(), before, "rolled back"); + } +} + +#[test] +fn text_that_looks_like_a_named_reference_is_left_alone() { + let mut s = Session::new(); + let text = "{\"$ref\": \"card\"} $ref"; + let results = s.execute("command.batch", &json!({"commands": [{"command": "node.create", "params": {"type": "text", "text": text}}]})).unwrap(); + let n = s.doc().unwrap().node(id(&results[0])).unwrap().clone(); + assert!(matches!(n.kind, figcraft_doc::NodeKind::Text(t) if t.characters == text)); +} + +/// A nested batch has its own names: it can't see the outer batch's, may reuse them, and the +/// outer batch can't see its. +#[test] +fn a_nested_batch_has_its_own_names() { + let mut s = Session::new(); + let frame = |name: &str| json!({"command": "node.create", "as": "frame", "params": {"type": "frame", "name": name, "width": 100, "height": 100}}); + let results = s + .execute( + "command.batch", + &json!({"commands": [ + frame("Outer"), + {"command": "command.batch", "as": "inner", "params": {"commands": [ + frame("Inner"), + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}} + ]}}, + {"command": "node.set", "params": {"ids": [{"$ref": "inner", "field": "1/id"}], "props": {"name": "In inner"}}} + ]}), + ) + .unwrap(); + let inner = &results[1]; + let rect = s.doc().unwrap().node(id(&inner[1])).unwrap().clone(); + assert_eq!(rect.parent, Some(id(&inner[0])), "in the nested batch's own frame, not the outer one"); + assert_eq!(rect.name, "In inner", "a path indexes into a nested batch's result list"); + + let msg = batch_error( + &mut s, + json!([frame("Outer"), {"command": "command.batch", "params": {"commands": [ + {"command": "node.create", "params": {"type": "rectangle", "parent": {"$ref": "frame"}, "width": 10, "height": 10}} + ]}}]), + ); + assert!(msg.contains("`$ref` \"frame\" names no step"), "the outer names aren't visible inside: {msg}"); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..c033c2cd 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use super::{CommandSpec, ok}; use crate::params::*; -use crate::{EngineError, Result, Session, bad, spec}; +use crate::{BatchStep, EngineError, Result, Session, bad, spec}; pub fn specs() -> Vec { vec![ @@ -336,15 +336,16 @@ pub fn specs() -> Vec { } ok() }), - spec!("command.batch", "Run several commands", "{label?, commands: [{command, params}]} (one undo step)", false, |s, p| { + spec!("command.batch", "Run several commands", "{label?, commands: [{command, params, as?}]} (one undo step)", false, |s, p| { let label = string(p, "label").unwrap_or_else(|| "Batch".into()); - let cmds: Vec<(String, Value)> = match get(p, "commands") { + let cmds: Vec = match get(p, "commands") { Some(Value::Array(a)) => { let mut out = Vec::new(); for (i, c) in a.iter().enumerate() { let cmd = c.get("command").and_then(Value::as_str).ok_or_else(|| bad("command.batch", format!("step {i} has no `command`")))?; - out.push((cmd.to_string(), c.get("params").cloned().unwrap_or(json!({})))); + let params = c.get("params").cloned().unwrap_or(json!({})); + out.push(BatchStep { name: crate::batch::step_name(i, c)?, ..BatchStep::new(cmd, params) }); } out } diff --git a/crates/engine/src/collab_live_tests.rs b/crates/engine/src/collab_live_tests.rs index 5b759391..d53eeff2 100644 --- a/crates/engine/src/collab_live_tests.rs +++ b/crates/engine/src/collab_live_tests.rs @@ -246,9 +246,9 @@ fn a_group_of_commands_syncs_as_one_step() { let (f, r) = frame_and_rect(room.server.document()); let start = room.server.replica().clone(); let cmds = vec![ - ("node.set".to_string(), json!({"id": r, "props": {"name": "A"}})), - ("node.set".to_string(), json!({"id": f, "props": {"name": "B"}})), - ("shape.rectangle".to_string(), json!({"x": 0, "y": 0, "width": 5, "height": 5})), + crate::BatchStep::new("node.set", json!({"id": r, "props": {"name": "A"}})), + crate::BatchStep::new("node.set", json!({"id": f, "props": {"name": "B"}})), + crate::BatchStep::new("shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5})), ]; room.s(0).batch("Three", &cmds).unwrap(); room.pump(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..048c0722 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -13,6 +13,7 @@ pub mod al_keys; pub mod annotations; pub mod arc_handles; pub mod background; +pub mod batch; pub mod booleans; pub mod branches; pub mod catalog; @@ -64,6 +65,7 @@ pub mod tools; pub mod vectoredit; pub mod widgets; +pub use batch::{BatchScope, BatchStep}; pub use cmd::{CommandSpec, all_commands, find_command}; pub use figcraft_collab; pub use figcraft_doc as doc; @@ -115,47 +117,6 @@ pub fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() } } -/// Deepest params nesting `resolve_step_refs` walks before giving up. -const STEP_REF_MAX_DEPTH: usize = 64; - -/// Replace every `{"$step": n}` / `{"$step": n, "field": "…"}` in `v` with that field (default -/// `id`) of `results[n]`, the result of an earlier batch step. -fn resolve_step_refs(v: &Value, results: &[Value], step: usize, depth: usize) -> Result { - if depth > STEP_REF_MAX_DEPTH { - return Err(bad("command.batch", format!("step {step}: params nest deeper than {STEP_REF_MAX_DEPTH} levels"))); - } - match v { - Value::Object(o) if o.contains_key("$step") => { - if let Some(k) = o.keys().find(|k| *k != "$step" && *k != "field") { - return Err(bad("command.batch", format!("step {step}: a `$step` reference takes only `field`, not `{k}`"))); - } - let n = o - .get("$step") - .and_then(Value::as_u64) - .ok_or_else(|| bad("command.batch", format!("step {step}: `$step` must be a step index (0, 1, …)")))?; - let field = match o.get("field") { - None => "id", - Some(Value::String(f)) => f.as_str(), - Some(_) => return Err(bad("command.batch", format!("step {step}: `field` must be a string"))), - }; - let Some(result) = usize::try_from(n).ok().and_then(|n| results.get(n)) else { - let range = if step == 0 { "there is none before step 0".to_string() } else { format!("0 to {}", step - 1) }; - return Err(bad("command.batch", format!("step {step}: `$step` {n} must be an earlier step ({range})"))); - }; - result.get(field).cloned().ok_or_else(|| bad("command.batch", format!("step {step}: step {n}'s result has no `{field}`"))) - } - Value::Object(o) => { - let mut out = serde_json::Map::with_capacity(o.len()); - for (k, x) in o { - out.insert(k.clone(), resolve_step_refs(x, results, step, depth + 1)?); - } - Ok(Value::Object(out)) - } - Value::Array(a) => a.iter().map(|x| resolve_step_refs(x, results, step, depth + 1)).collect::>>().map(Value::Array), - _ => Ok(v.clone()), - } -} - /// One undo step. #[derive(Clone, Debug)] pub struct HistoryEntry { @@ -1245,28 +1206,30 @@ impl Session { } /// Run several commands as one undo step. A param value `{"$step": n}` is replaced by the `id` - /// in the result of the earlier step `n` (`{"$step": n, "field": "…"}` picks another field), - /// so a step can target a layer that an earlier step of the same batch created. Any object - /// with a `$step` key in a step's params is such a reference, except inside a nested - /// `command.batch` step: its references are to its own steps. - pub fn batch(&mut self, label: &str, cmds: &[(String, Value)]) -> Result> { + /// in the result of the earlier step `n`, and `{"$ref": "name"}` by the `id` in the result of + /// the earlier step whose `as` is `name`; either takes a `field`, a key or slash path (`ids/0`) + /// into that result. So a step can target a layer that an earlier step of the same batch + /// created. Any object with a `$step` or `$ref` key in a step's params is such a reference, + /// except inside a nested `command.batch` step: its references are to its own steps. + pub fn batch(&mut self, label: &str, steps: &[BatchStep]) -> Result> { + let mut scope = BatchScope::new(steps)?; self.grouped(label, |s| { - let mut results: Vec = Vec::with_capacity(cmds.len()); - for (step, (id, p)) in cmds.iter().enumerate() { - let v = s.batch_step(&results, step, id, p)?; - results.push(v); + for step in steps { + s.batch_step(&mut scope, &step.command, &step.params)?; } - Ok(results) + Ok(scope.into_results()) }) } - /// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of - /// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one - /// undo step; a caller with its own list — the CLI's script runner — uses this so its steps - /// resolve `$step` the same way, inside its own [`Session::grouped`]. - pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result { - let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? }; - self.execute(cmd, &p) + /// The next step of a batch: the references in `params` resolve against `scope` (the steps + /// before it), the command runs and its result joins `scope`. [`Session::batch`] runs a list + /// of these as one undo step; a caller with its own list — the CLI's script runner — uses this + /// so its steps resolve references the same way, inside its own [`Session::grouped`]. + pub fn batch_step(&mut self, scope: &mut BatchScope, cmd: &str, params: &Value) -> Result<()> { + let p = if cmd == "command.batch" { params.clone() } else { scope.resolve(params)? }; + let v = self.execute(cmd, &p)?; + scope.push(v); + Ok(()) } /// Run `f` as one undo step called `label`: the commands it executes push no steps of their diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 646c7c4b..4b811673 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -425,6 +425,11 @@ fn every_command_survives_junk_params() { // Rotation origins and placing with a tool as junk. json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}), json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}), + // Batch step names and reference paths as junk. + json!({"commands": [{"command": "select.none", "as": "a"}, {"command": "select.none", "as": [1]}, {"command": "select.none", "as": "a"}]}), + json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [ + {"$ref": "\u{0}", "field": "ids/18446744073709551616/x"}, {"$ref": {"x": 1}}, {"$step": 0, "field": "//"}, {"$ref": "\u{0}", "field": "/"}], + "props": {"name": {"$ref": "nope", "field": 5}}}}]}), ]; for spec in all_commands() { if matches!( diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 7714da5a..1e4c887f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -128,10 +128,10 @@ pub fn tool_definitions() -> Vec { tool( "batch", "Run commands as one step", - "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use a layer an earlier step created: anywhere in its params, {\"$step\": n} stands for the `id` in step n's result (zero-based; {\"$step\": n, \"field\": \"…\"} picks another field). Example: [{\"command\":\"node.create\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$step\":0}}}] builds a frame and a text inside it in one call.", + "Run several commands in order as ONE undo step (engine `command.batch`). If any command fails, everything is rolled back and the error names the failing step. Returns an array of the results in order. A later step can use what an earlier step returned: name a step with \"as\": \"card\", then anywhere in a later step's params {\"$ref\": \"card\"} stands for the `id` in its result ({\"$step\": n} does the same by zero-based position). Add \"field\" for another part of the result: a key or a slash path, e.g. {\"$ref\": \"copy\", \"field\": \"ids/0\"} for the first id edit.duplicate returned. Any object with a `$ref` or `$step` key is a reference. Example: [{\"command\":\"node.create\",\"as\":\"card\",\"params\":{\"type\":\"frame\"}}, {\"command\":\"node.create\",\"params\":{\"type\":\"text\",\"text\":\"Hi\",\"parent\":{\"$ref\":\"card\"}}}] builds a frame and a text inside it in one call.", obj( json!({ - "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}}), &["command"])}, + "commands": {"type": "array", "minItems": 1, "description": "Steps to run", "items": obj(json!({"command": string("Command id"), "params": {"type": "object"}, "as": string("Name for this step, unique in the batch; later steps use its result with {\"$ref\": name}")}), &["command"])}, "label": string("Undo history label (default \"Batch\")"), }), &["commands"], @@ -497,7 +497,11 @@ fn batch(b: &mut dyn Backend, a: &Map) -> Result Date: Wed, 21 Oct 2026 11:21:24 -0700 Subject: [PATCH 014/142] Let node.set replace a vector's path node.set took no `path`, so redrawing a vector meant deleting and recreating it and losing its id, place in the layer list, paints, styles, bindings and step references. `path` now takes SVG path data for vector layers and replaces the geometry through the same reader node.create uses (path_network, shared with create). width/height in the same call scale it as create does. The path's own box goes at the layer's current origin, so rotation and flips stay; with x/y in the same call it goes at x/y plus the box's offset, which is where create puts it. On several layers, layers that aren't vectors are skipped with a warning, as auto layout keys already are (the skip logic is shared now). A single non-vector is refused, and so is a vector inside an instance: there is no geometry override, so the next sync would undo it. Data that doesn't parse, draws nothing or has non-finite bounds is refused cleanly; create now refuses non-finite bounds too. Tests cover keeping the layer, placement and scaling against create, rotation, undo/redo, invalid data, non-vectors, instances, boolean operands, edit mode, and a junk-path sweep. The engine-wide junk sweep has node.set path entries. Fixes #1001 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/create.rs | 57 +++-- crates/engine/src/cmd/edit.rs | 57 +++-- crates/engine/src/cmd/props.rs | 39 ++- crates/engine/src/lib.rs | 2 + crates/engine/src/tests.rs | 4 + crates/engine/src/vector_path_set_tests.rs | 263 +++++++++++++++++++++ docs/mcp.md | 11 + 7 files changed, 391 insertions(+), 42 deletions(-) create mode 100644 crates/engine/src/vector_path_set_tests.rs diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 3a9f6d67..013b7f56 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -166,6 +166,42 @@ pub fn parse_path_data(d: &str) -> Option { figcraft_geom::BezPath::from_svg(d).ok() } +/// A vector's geometry made from SVG path data (see [`path_network`]). +pub(crate) struct PathShape { + /// The network, with the path's own box moved to the origin and scaled to the size asked for. + pub network: VectorNetwork, + pub width: f64, + pub height: f64, + /// Where the path's own box starts in its coordinates (unscaled). + pub offset: (f64, f64), +} + +/// The network SVG path data `d` draws, for `node.create` and `node.set`: the path's own box moves +/// to the origin, and `width`/`height` scale it as [`vector_scale`] says. Data that doesn't parse +/// or draws nothing is refused. +pub(crate) fn path_network(cmd: &str, d: &str, width: Option, height: Option) -> Result { + use figcraft_geom::Shape; + let empty = || bad(cmd, "`path` draws nothing: a vector needs a visible line or area"); + let mut path = parse_path_data(d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?; + let bb = path.bounding_box(); + if !(bb.x0.is_finite() && bb.y0.is_finite() && bb.width().is_finite() && bb.height().is_finite()) { + return Err(bad(cmd, "`path` has coordinates too large to draw")); + } + if bb.width() <= 1e-9 && bb.height() <= 1e-9 { + return Err(empty()); + } + path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0))); + let (sx, sy) = vector_scale(width, height, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?; + if sx != 1.0 || sy != 1.0 { + path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); + } + let network = VectorNetwork::from_path(&path); + if network.segments.is_empty() { + return Err(empty()); + } + Ok(PathShape { network, width: bb.width() * sx, height: bb.height() * sy, offset: (bb.x0, bb.y0) }) +} + /// How much a vector's path is scaled to take the size asked for: with both sides, each scales /// independently (as resizing a vector does); with one, the other keeps its proportion. A size on /// an axis the geometry has none of is an error unless it is 0 (a line's height), and a size of 0 @@ -228,27 +264,12 @@ pub fn create(s: &mut Session, p: &Value) -> Result { let asked = (num(p, "width"), num(p, "height")); if let NodeKind::Vector { network } = &mut kind { if let Some(d) = string(p, "path") { - let mut path = parse_path_data(&d).ok_or_else(|| bad(cmd, "`path` is not valid SVG path data"))?; - use figcraft_geom::Shape; - let bb = path.bounding_box(); - if bb.width() <= 1e-9 && bb.height() <= 1e-9 { - return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); - } - path.apply_affine(figcraft_geom::Affine::translate((-bb.x0, -bb.y0))); - let (sx, sy) = vector_scale(asked.0, asked.1, bb.width(), bb.height(), "a path").map_err(|e| bad(cmd, e))?; - if sx != 1.0 || sy != 1.0 { - path.apply_affine(figcraft_geom::Affine::scale_non_uniform(sx, sy)); - } - *network = VectorNetwork::from_path(&path); - if network.segments.is_empty() { - return Err(bad(cmd, "`path` draws nothing: a vector needs a visible line or area")); - } + let shape = path_network(cmd, &d, asked.0, asked.1)?; + *network = shape.network; if boolean(p, "closed") == Some(false) { network.regions.clear(); } - w = bb.width() * sx; - h = bb.height() * sy; - offset = (bb.x0, bb.y0); + (w, h, offset) = (shape.width, shape.height, shape.offset); } else if let Some(Value::Array(pts)) = get(p, "points") { let pts: Vec = pts.iter().filter_map(|v| Some(Point::new(f64_of(v.get(0)?)?, f64_of(v.get(1)?)?))).collect(); if pts.len() < 2 { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a16fbb38..422c75eb 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet}; use figcraft_doc::*; use figcraft_geom::Affine; -use serde_json::{Value, json}; +use serde_json::{Map, Value, json}; use super::{CommandSpec, ok}; use crate::params::*; @@ -1159,6 +1159,9 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { Ok(json!({"matches": hits})) } +/// Keys only some layer types take, the test for those layers, and why the others skip them. +type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str); + fn set(s: &mut Session, p: &Value) -> Result { let ids = req_targets("node.set", s, p)?; let props = match get(p, "props") { @@ -1189,34 +1192,44 @@ fn set(s: &mut Session, p: &Value) -> Result { None => Vec::new(), }; let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); - // Auto layout keys go to the layers that can have auto layout; with none among the targets, - // the first refuses them (`props::layout_mut`). + // Type-specific keys go to the layers that take them and are skipped, with a warning, on the + // others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets + // that take them, the first refuses them (`props::layout_mut`, `props::apply`). + let doc = &s.ds()?.doc; let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect(); - let mut without_layout = props.clone(); - let mut skipped = BTreeSet::new(); - if !layout_keys.is_empty() { - let doc = &s.ds()?.doc; - let frame_like = |i: &NodeId| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()); - if ids.iter().any(frame_like) { - skipped = ids.iter().copied().filter(|i| !frame_like(i)).collect(); - without_layout.retain(|k, _| !layout_keys.contains(&k.as_str())); + let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect(); + let frame_like = |n: &Node| n.kind.is_frame_like(); + let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. }); + let typed: [TypedKeys; 2] = [ + (&layout_keys, &frame_like, "only frames, components and instances have auto layout"), + (&path_keys, &vector, "only vector layers have a path"), + ]; + let mut skipped: BTreeMap> = BTreeMap::new(); + for (keys, takes, why) in typed { + let takes = |i: &NodeId| doc.node(*i).is_some_and(takes); + if keys.is_empty() || !ids.iter().any(takes) { + continue; } - if !skipped.is_empty() { - let names: Vec = - skipped.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); - let more = if skipped.len() > names.len() { format!(" and {} more", skipped.len() - names.len()) } else { String::new() }; - warnings.push(format!( - "{} skipped on {}{more}: only frames, components and instances have auto layout", - layout_keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), - names.join(", ") - )); + let others: Vec = ids.iter().copied().filter(|i| !takes(i)).collect(); + if others.is_empty() { + continue; } + for i in &others { + skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str())); + } + let names: Vec = + others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); + let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() }; + warnings.push(format!( + "{} skipped on {}{more}: {why}", + keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), + names.join(", ") + )); } let doc = s.doc_mut()?; let before = super::styles::bound_before(doc, &ids); for i in ids { - let props = if skipped.contains(&i) { &without_layout } else { &props }; - super::props::apply("node.set", doc, i, props)?; + super::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?; } super::styles::detach_edited(doc, &before); if let Some(o) = pivot { diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index f180b53c..67e22834 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -26,7 +26,9 @@ fontSize, lineHeight (auto|px|\"150%\"), letterSpacing (px|\"2%\"), textAlign (l textDecorationColor (colour, or auto/null: the text's), textCase (original|upper|lower|title|smallCaps), paragraphSpacing, paragraphIndent, \ listStyle (none|bulleted|numbered; with start/end: the paragraphs those chars touch), indentation (list nesting level 0-8, same range rule), \ listSpacing (px between list items, in place of paragraphSpacing), hangingPunctuation, hangingList (markers outside the box), leadingTrim (vertical trim: none|capHeight), maxLines, features ({tag: bool}, e.g. {\"liga\": false, \"tnum\": true}), \ -fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), pointCount, innerRadius, arcStart, arcSweep, \ +fontVariations / variationSettings ({axis: number}, e.g. {\"wght\": 550, \"wdth\": 85}; null = none), \ +path (a vector's SVG path data: replaces its geometry, keeping the layer, its paints, styles and bindings; width/height in the same call scale it as \ +node.create does; its own box goes at the layer's x/y, or with x/y given, at x + its left edge and y + its top as node.create places it), pointCount, innerRadius, arcStart, arcSweep, \ arcInner, booleanOperation (a boolean group's operation, or an operand's own; null: the group's), operandOperation (a layer in a boolean \ group: how it combines with the operands below it; null: the group's), constrainProportions, description, interactions, overflow, pluginData {k: v}"; @@ -376,7 +378,7 @@ fn set_paints(n: &mut Node, fills: bool, paints: Vec) { /// 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, + "path" | "width" | "height" | "w" | "h" => 0, "rotation" => 2, "x" | "y" => 3, "boundVariables" => 4, @@ -389,11 +391,17 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map = props.iter().collect(); keys.sort_by_key(|(k, _)| apply_order(k)); + // Where a new `path` had its own box, for `x`/`y` in the same call (as `node.create` places it). + let mut path_offset = (0.0, 0.0); for (k, v) in keys { let k = k.as_str(); match k { + "path" => path_offset = set_path(cmd, doc, id, v, props)?, + // A new path takes the size asked for as its scale (`set_path`). + "width" | "height" | "w" | "h" if props.contains_key("path") => {} "x" | "y" => { let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; + let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; // X/Y are the node's own origin (its rotated top-left for rotated nodes), in the // frame it is in (groups have no space of their own). let n = doc.get(id)?; @@ -920,6 +928,33 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<(f64, f64)> { + let d = v.as_str().ok_or_else(|| bad(cmd, "`path` must be SVG path data (a string)"))?; + let n = doc.get(id)?; + if !matches!(n.kind, NodeKind::Vector { .. }) { + return Err(bad(cmd, format!("`path` applies to vector layers, and layer {} “{}” is a {}", n.id, n.name, n.kind.type_name()))); + } + if crate::components::inside_instance(doc, id) { + return Err(bad(cmd, format!("`path`: layer {} “{}” is inside an instance: edit its main component, or detach the instance", n.id, n.name))); + } + let size = |long: &str, short: &str| -> Result> { + props.get(long).or_else(|| props.get(short)).map(|v| f64_of(v).ok_or_else(|| bad(cmd, format!("`{long}` must be a number")))).transpose() + }; + let shape = super::create::path_network(cmd, d, size("width", "w")?, size("height", "h")?)?; + let n = doc.get_mut(id)?; + if let NodeKind::Vector { network } = &mut n.kind { + *network = shape.network; + } + n.width = shape.width; + n.height = shape.height; + Ok(shape.offset) +} + /// The override group a property key belongs to. pub fn override_group(k: &str) -> &'static str { match k { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..bfc7f3c2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1891,6 +1891,8 @@ mod vector_multi_behaviour_tests; #[cfg(test)] mod vector_offset_tests; #[cfg(test)] +mod vector_path_set_tests; +#[cfg(test)] mod version_preview_tests; #[cfg(test)] mod version_tests; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 646c7c4b..b5cdd7fa 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -425,6 +425,10 @@ fn every_command_survives_junk_params() { // Rotation origins and placing with a tool as junk. json!({"tool": [1], "x": 1e308, "y": "NaN", "reset": "maybe", "on": {"x": 1}}), json!({"tool": "frame", "x": -1e308, "y": 1e308, "reset": true, "on": "yes"}), + // Vector paths (`node.set {path}`) as junk. (Not a string `path` at the top: file commands + // would write to it.) + json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}), + json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}), ]; for spec in all_commands() { if matches!( diff --git a/crates/engine/src/vector_path_set_tests.rs b/crates/engine/src/vector_path_set_tests.rs new file mode 100644 index 00000000..7e714853 --- /dev/null +++ b/crates/engine/src/vector_path_set_tests.rs @@ -0,0 +1,263 @@ +//! `node.set {path}` replaces a vector's geometry and keeps the layer (#1001): its id, name, place in +//! the layer list, paints, effects, styles, bindings and constraints. The path is read as +//! `node.create` reads it (`width`/`height` scale it), its own box goes at the layer's origin (or at +//! `x`/`y` plus the box's offset, as create places it), undo brings the old path back, and data that +//! can't draw, layers that aren't vectors and layers inside an instance are refused. + +use figcraft_doc::{NodeId, NodeKind, VectorNetwork}; +use serde_json::{Value, json}; + +use crate::{EngineError, Session}; + +fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) +} + +fn id(v: &Value) -> NodeId { + crate::params::id_of(&v["id"]).unwrap() +} + +fn vector(s: &mut Session, p: Value) -> NodeId { + let mut p = p; + p["type"] = json!("vector"); + id(&ex(s, "node.create", p)) +} + +fn net(s: &Session, v: NodeId) -> VectorNetwork { + crate::vectoredit::network(s.doc().unwrap(), v).unwrap().clone() +} + +/// The layer's x, y, width and height as `get_node` reports them. +fn frame(s: &Session, v: NodeId) -> (f64, f64, f64, f64) { + let d = crate::cmd::props::describe(s.doc().unwrap(), v); + let f = |k: &str| d[k].as_f64().unwrap(); + (f("x"), f("y"), f("width"), f("height")) +} + +fn bad_msg(r: crate::Result) -> String { + match r { + Err(EngineError::BadParams { msg, .. }) => msg, + other => panic!("expected BadParams, got {other:?}"), + } +} + +#[test] +fn a_new_path_keeps_the_layer() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "T"})); + ex(&mut s, "variable.create", json!({"collection": "T", "name": "brand", "type": "color", "value": "#FF0000"})); + let below = vector(&mut s, json!({"path": "M0 0 L5 5"})); + let v = vector(&mut s, json!({"name": "Chart line", "path": "M0 0 L10 10", "x": 40, "y": 30})); + let above = vector(&mut s, json!({"path": "M0 0 L5 5"})); + ex( + &mut s, + "node.set", + json!({"id": v, "props": {"stroke": "#00FF00", "strokeWeight": 3, "opacity": 0.5, + "effects": [{"type": "dropShadow", "color": "#00000040", "offset": [0, 2], "radius": 4}], + "constraints": {"horizontal": "right", "vertical": "bottom"}}}), + ); + ex(&mut s, "variable.bind", json!({"ids": [v], "field": "fill", "variable": "brand"})); + let before = s.doc().unwrap().node(v).unwrap().clone(); + + let out = ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L20 0 L20 8 Z"}})); + assert!(out.get("warnings").is_none(), "{out}"); + let doc = s.doc().unwrap(); + let n = doc.node(v).unwrap(); + assert_eq!((n.id, n.name.as_str()), (v, "Chart line")); + assert_eq!((&n.fills, &n.strokes, &n.stroke, &n.effects), (&before.fills, &before.strokes, &before.stroke, &before.effects)); + assert_eq!((n.opacity, &n.bound, &n.styles, &n.constraints), (before.opacity, &before.bound, &before.styles, &before.constraints)); + assert!(n.bound.contains_key("fill"), "the fill stays bound"); + let page = doc.node(n.parent.unwrap()).unwrap(); + assert_eq!(page.children, [below, v, above], "same place in the layer list"); + // The new geometry, at the layer's origin. + assert_eq!(frame(&s, v), (40.0, 30.0, 20.0, 8.0)); + let net = net(&s, v); + assert_eq!((net.vertices.len(), net.regions.len()), (3, 1), "a closed triangle: {net:?}"); +} + +#[test] +fn width_height_and_x_y_follow_node_create() { + // Each row: props given with the path, and the frame node.set gives a layer at (100, 50). + let rows: [(Value, (f64, f64, f64, f64)); 6] = [ + // The path's own box goes at the layer's origin: its offset (5, 5) doesn't move it. + (json!({}), (100.0, 50.0, 10.0, 20.0)), + // Both sides scale independently; one keeps the proportion. + (json!({"width": 30, "height": 10}), (100.0, 50.0, 30.0, 10.0)), + (json!({"w": 20}), (100.0, 50.0, 20.0, 40.0)), + // With x/y the path's offset is added, as create places it. + (json!({"x": 0, "y": 0}), (5.0, 5.0, 10.0, 20.0)), + (json!({"x": 10, "height": 10}), (15.0, 50.0, 5.0, 10.0)), + (json!({"x": 10, "y": 20, "width": 20, "height": 10}), (15.0, 25.0, 20.0, 10.0)), + ]; + for (extra, want) in rows { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L1 1", "x": 100, "y": 50})); + let mut props = json!({"path": "M5 5 L15 5 L15 25 Z"}); + props.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + ex(&mut s, "node.set", json!({"ids": [v], "props": props})); + assert_eq!(frame(&s, v), want, "{extra}"); + // With both x and y, create makes the same layer. (With one, the other axis keeps the + // layer's current place.) + if extra.get("x").is_some() && extra.get("y").is_some() { + let c = vector(&mut s, props.clone()); + assert_eq!(frame(&s, c), want, "create {extra}"); + } + } +} + +#[test] +fn rotation_stays_and_turns_about_the_new_box() { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 10", "x": 0, "y": 0})); + ex(&mut s, "node.set", json!({"ids": [v], "props": {"rotation": 90}})); + let t = s.doc().unwrap().node(v).unwrap().transform; + ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L40 0 L40 20 Z"}})); + let n = s.doc().unwrap().node(v).unwrap(); + assert_eq!(n.transform, t, "the layer keeps its origin and rotation"); + assert_eq!((n.width, n.height), (40.0, 20.0)); +} + +#[test] +fn undo_brings_the_old_path_back() { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 10", "x": 3, "y": 4})); + let (old_net, old_frame) = (net(&s, v), frame(&s, v)); + ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 C10 0 20 10 20 20", "width": 50}})); + let (new_net, new_frame) = (net(&s, v), frame(&s, v)); + assert_ne!(new_net, old_net); + ex(&mut s, "edit.undo", json!({})); + assert_eq!((net(&s, v), frame(&s, v)), (old_net, old_frame)); + ex(&mut s, "edit.redo", json!({})); + assert_eq!((net(&s, v), frame(&s, v)), (new_net, new_frame)); +} + +#[test] +fn data_that_cant_draw_is_refused_and_nothing_changes() { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 10", "name": "V"})); + let before = s.doc().unwrap().node(v).unwrap().clone(); + for (props, says) in [ + (json!({"path": "not a path"}), "not valid SVG path data"), + (json!({"path": "M0 0 L10"}), "not valid SVG path data"), + (json!({"path": ""}), "draws nothing"), + (json!({"path": "M5 5"}), "draws nothing"), + (json!({"path": "M5 5 L5 5 Z"}), "draws nothing"), + (json!({"path": "M0 0 L1e308 0 L-1e308 0"}), "too large"), + (json!({"path": 5}), "must be SVG path data"), + (json!({"path": null}), "must be SVG path data"), + (json!({"path": "M0 0 L10 10", "width": 0}), "positive"), + (json!({"path": "M0 0 L10 10", "height": -4}), "positive"), + (json!({"path": "M0 0 L10 10", "width": "wide"}), "must be a number"), + // A size on an axis the path has none of. + (json!({"path": "M0 0 L10 0", "height": 5}), "no vertical extent"), + (json!({"path": "M0 0 L10 10", "name": "renamed", "width": -1}), "positive"), + ] { + let msg = bad_msg(s.execute("node.set", &json!({"ids": [v], "props": props}))); + assert!(msg.contains(says), "{props}: {msg}"); + assert_eq!(s.doc().unwrap().node(v).unwrap(), &before, "{props} changed nothing"); + } + // A horizontal line can still be given height 0, as create allows. + ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L10 0", "height": 0}})); + assert_eq!(frame(&s, v).2, 10.0); +} + +#[test] +fn layers_that_arent_vectors_are_refused_alone_and_skipped_among_vectors() { + let mut s = Session::new(); + let r = id(&ex(&mut s, "node.create", json!({"type": "rectangle", "name": "Box"}))); + let star = id(&ex(&mut s, "node.create", json!({"type": "star"}))); + let msg = bad_msg(s.execute("node.set", &json!({"ids": [r], "props": {"path": "M0 0 L5 5"}}))); + assert!(msg.contains("vector") && msg.contains("Box"), "{msg}"); + assert!(s.execute("node.set", &json!({"ids": [r, star], "props": {"path": "M0 0 L5 5"}})).is_err(), "no vector among them"); + assert!(matches!(s.doc().unwrap().node(r).unwrap().kind, NodeKind::Rectangle)); + + let v = vector(&mut s, json!({"path": "M0 0 L10 10"})); + let out = ex(&mut s, "node.set", json!({"ids": [r, v], "props": {"path": "M0 0 L30 0", "opacity": 0.5}})); + let w = out["warnings"][0].as_str().unwrap(); + assert!(w.contains("`path` skipped on") && w.contains("Box") && w.contains("vector"), "{w}"); + let doc = s.doc().unwrap(); + assert_eq!((doc.node(r).unwrap().width, doc.node(r).unwrap().opacity), (100.0, 0.5), "the rectangle gets the other keys"); + assert_eq!(frame(&s, v).2, 30.0); +} + +#[test] +fn a_vector_inside_an_instance_is_refused_and_its_main_reaches_it() { + let mut s = Session::new(); + let f = id(&ex(&mut s, "node.create", json!({"type": "frame", "width": 50, "height": 50}))); + let v = vector(&mut s, json!({"path": "M0 0 L10 10", "parent": f})); + let comp = id(&ex(&mut s, "component.create", json!({"ids": [f]}))); + let inst = id(&ex(&mut s, "instance.create", json!({"component": comp, "x": 200, "y": 0}))); + let copy = s.doc().unwrap().node(inst).unwrap().children[0]; + assert!(matches!(s.doc().unwrap().node(copy).unwrap().kind, NodeKind::Vector { .. })); + let msg = bad_msg(s.execute("node.set", &json!({"ids": [copy], "props": {"path": "M0 0 L30 0"}}))); + assert!(msg.contains("inside an instance"), "{msg}"); + // The main's vector takes it, and the instance follows. + let main_vector = s.doc().unwrap().node(comp).unwrap().children[0]; + assert_eq!(main_vector, v); + ex(&mut s, "node.set", json!({"ids": [main_vector], "props": {"path": "M0 0 L30 0 L30 12 Z"}})); + assert_eq!(net(&s, copy), net(&s, main_vector)); + assert_eq!((frame(&s, copy).2, frame(&s, copy).3), (30.0, 12.0)); +} + +#[test] +fn a_boolean_operand_takes_a_new_path_and_the_group_follows() { + let mut s = Session::new(); + let a = vector(&mut s, json!({"path": "M0 0 L40 0 L40 40 L0 40 Z"})); + let b = vector(&mut s, json!({"path": "M0 0 L40 0 L40 40 L0 40 Z", "x": 20, "y": 20})); + let g = id(&ex(&mut s, "bool.union", json!({"ids": [a, b]}))); + assert_eq!(s.doc().unwrap().node(b).unwrap().parent, Some(g)); + ex(&mut s, "node.set", json!({"ids": [b], "props": {"path": "M0 0 L100 0 L100 100 L0 100 Z"}})); + let bounds = s.doc().unwrap().world_bounds(g).unwrap(); + assert_eq!((bounds.width(), bounds.height()), (120.0, 120.0), "{bounds:?}"); +} + +#[test] +fn junk_paths_never_panic() { + let long = "M0 0 ".to_string() + &"L1 1 ".repeat(5000); + let junk = [ + json!("M"), + json!("Z"), + json!("M0 0 Z Z Z"), + json!("M NaN 0 L 1 1"), + json!("M inf 0 L 1 1"), + json!("M0 0 L1e308 1e308"), + json!("M-1e308 -1e308 L1e308 1e308"), + json!("M0 0 A 1e308 1e308 0 1 1 10 10"), + json!("M0 0 A 0 0 0 0 0 10 10"), + json!("m0 0 l1 1 h-1e308 v1e308 z"), + json!("M0 0 Q 5 5"), + json!("M0,0L10,10\u{0}"), + json!("\u{1F4A5}"), + json!(long), + json!([1, 2]), + json!({"d": "M0 0 L1 1"}), + json!(true), + ]; + let sizes = [json!(null), json!(1e308), json!(-1e308), json!(1e-308), json!("NaN"), json!([1])]; + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 10"})); + for path in &junk { + for size in &sizes { + for props in [json!({"path": path, "width": size}), json!({"path": path, "height": size, "x": size, "rotation": size})] { + if let Err(EngineError::Internal { msg, .. }) = s.execute("node.set", &json!({"ids": [v], "props": props})) { + panic!("node.set panicked on {props}: {msg}"); + } + let n = s.doc().unwrap().node(v).unwrap(); + assert!(n.width.is_finite() && n.height.is_finite() && n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{props}"); + } + } + } +} + +#[test] +fn a_new_path_while_editing_drops_points_that_are_gone() { + let mut s = Session::new(); + let v = vector(&mut s, json!({"path": "M0 0 L10 0 L10 10 L0 10 Z"})); + ex(&mut s, "vector.edit.begin", json!({"id": v})); + ex(&mut s, "vector.edit.selectAll", json!({})); + ex(&mut s, "node.set", json!({"ids": [v], "props": {"path": "M0 0 L20 0"}})); + let ve = s.ds().unwrap().vector_edit.clone().expect("still editing"); + let n = net(&s, v); + assert!(ve.vertices.iter().all(|i| *i < n.vertices.len()) && ve.segments.iter().all(|i| *i < n.segments.len()), "{ve:?}"); + ex(&mut s, "vector.edit.state", json!({})); +} diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..ac3be60e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -448,6 +448,17 @@ its proportion). A path or point set that draws nothing is refused instead of ma 0×0 layer, as is a size on an axis the geometry has none of (except 0, such as a horizontal line's height) and a size of 0 or less on an axis it has, which would flatten it. +`node.set {path}` (or `set_properties`) replaces a vector's geometry with new SVG path data and +keeps the layer: its id, name, place in the layer list, paints, effects, styles, variable bindings +and constraints. The path is read as `vector.create` reads it, and `width`/`height` in the same call +scale it the same way. The path's own box goes at the layer's current x/y (rotation and flips +stay); with `x`/`y` in the same call it goes at `x` + its left edge and `y` + its top, as +`vector.create` places it. Points' own settings (per-point corner radius, caps, joins) go with the +old geometry. On several layers, layers that aren't vectors are skipped with a warning; on a +single one, or when none is a vector, the call is refused (convert shapes with +`vector.edit.begin` or Flatten first). A layer inside an instance is refused too: its geometry +comes from its main component, so edit that or detach the instance. Undo brings the old path back. + Vector edit mode is driven with `run_command` (or `pointer`/`key` for gestures): `vector.edit.begin {id? | ids?}` (default the selection; converts shapes to vectors; several layers are edited at once, the first active; `key` Enter with shapes selected does the same), From 014098b6c95294a92cfbc63cef9577bcb5896668 Mon Sep 17 00:00:00 2001 From: Kameron Date: Wed, 21 Oct 2026 11:27:46 -0700 Subject: [PATCH 015/142] fix(components): a boolean property's default is its bound layers' visibility on the main (#993) component.addProperty {type: "boolean", default: false, layer} hid the bound layer in new instances but left it showing in the main component, so the main didn't show its own default. On the main, a boolean property's value is now the visibility of the layers bound to it: - addProperty, bindProperty {field: "visible"} and editProperty {default} show or hide the main's bound layers to match the default (every variant of a set for editProperty). Without a default, addProperty takes the bound layer's current visibility instead of always `true`. - Showing or hiding a bound layer on the main sets the default, and the property's other bound layers (in every variant) follow. Edits inside instances stay overrides. - Existing instances keep their stored values; documents open as saved (no normalisation on load). Fixes #993 Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/component.rs | 18 ++++-- crates/engine/src/components.rs | 64 ++++++++++++++++++++ crates/engine/src/components_tests.rs | 84 +++++++++++++++++++++++++++ docs/mcp.md | 8 +++ 4 files changed, 170 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 6a5b1012..6b0e3e17 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -217,14 +217,14 @@ pub fn specs() -> Vec { spec!( "component.addProperty", "Add component property", - "{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default?, layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}", + "{id? (component, set, or a layer inside a component: it gets bound), name, type: boolean|text|instanceSwap|variant|slot, default? (boolean: the bound layer's visibility in the main; without it, the layer's current visibility), layer? (node bound to it; a frame for slots), preferred? (instance swap and slot properties: component or set ids offered first)}", true, add_prop ), spec!( "component.editProperty", "Edit component property", - "{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}", + "{id? (component or set), name, newName?, default? (variant properties: that variant takes the top-left place; boolean: the main's bound layers show or hide to match), value? + newValue? (rename a variant value), index? (variant properties: move to this place; the variants' names follow), preferred? (instance swap and slot properties: component or set ids offered first)}", true, edit_prop ), @@ -1141,7 +1141,8 @@ fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { } let layer_node = layer.and_then(|l| doc.node(l)).cloned(); let def = match t.to_ascii_lowercase().as_str() { - "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").unwrap_or(true) }, + // Without a default, a bound layer keeps showing as it does: its visibility is the default. + "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) }, "text" => PropDef::Text { default: string(p, "default") .or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone())) @@ -1245,7 +1246,12 @@ fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, fi } _ => {} } - doc.get_mut(layer)?.prop_refs.insert(field.to_string(), property.to_string()); + let n = doc.get_mut(layer)?; + n.prop_refs.insert(field.to_string(), property.to_string()); + // On the main, a boolean property's value is the visibility of the layers it controls. + if let PropDef::Boolean { default } = def { + n.visible = *default; + } Ok(()) } @@ -1414,6 +1420,10 @@ fn edit_prop(s: &mut Session, p: &Value) -> Result { rename_prop_refs(doc, *c, &name, Some(nn))?; } } + // The main's bound layers show the new default. + if let PropDef::Boolean { default } = new_def { + set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default); + } ok() } diff --git a/crates/engine/src/components.rs b/crates/engine/src/components.rs index 67259d03..98fd85f1 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -1110,9 +1110,73 @@ fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) false } +/// The components sharing the properties of component `c`: the variants of its set, or `c` alone. +pub fn property_owners(doc: &Document, c: NodeId) -> Vec { + match variant_set(doc, c).and_then(|s| doc.node(s)) { + Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(), + None => vec![c], + } +} + +/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding +/// their layers bound to it: on a main component, a boolean property's value is the visibility of +/// its layers. Returns the components and layers it changed. +pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec { + let mut out = Vec::new(); + for &c in comps { + let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue }; + let Some(PropDef::Boolean { default }) = props.get(name) else { continue }; + if *default != value + && let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind) + { + props.insert(name.to_string(), PropDef::Boolean { default: value }); + out.push(c); + } + // The main's own layers (not those of instances nested in it, which follow their mains). + let layers: Vec = doc + .subtree(c) + .into_iter() + .filter(|d| *d != c && !inside_instance(doc, *d)) + .filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name))) + .collect(); + for d in layers { + if let Some(n) = doc.node_mut(d) { + n.visible = value; + out.push(d); + } + } + } + out +} + +/// A main component's layer bound to a boolean property was shown or hidden: that is the +/// property's new default, which its other bound layers (in every variant) take too. Returns the +/// components and layers that changed as a result. +fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec { + let mut out = Vec::new(); + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + if n.visible == b.visible || inside_instance(doc, id) { + continue; + } + let Some(name) = n.prop_refs.get("visible").cloned() else { continue }; + let visible = n.visible; + let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue }; + out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible)); + } + out +} + /// After an edit: record overrides inside instances, then rebuild instances whose main changed, /// whose properties or main were switched, or whose overrides were reset. pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet) { + let mut changed = changed.to_vec(); + for id in follow_boolean_defaults(doc, before, &changed) { + if !changed.contains(&id) { + changed.push(id); + } + } + let changed = changed.as_slice(); // 0. A slot whose layers were added, removed or reordered holds the instance's own content // from now on; copies of instance layers placed elsewhere stop mirroring their main. for &id in changed { diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/components_tests.rs index 209560cd..30254651 100644 --- a/crates/engine/src/components_tests.rs +++ b/crates/engine/src/components_tests.rs @@ -429,6 +429,90 @@ fn component_properties_apply_to_instances() { healthy(&s); } +/// The default of boolean property `name` on component `c`. +fn bool_default(s: &Session, c: NodeId, name: &str) -> Option { + match &node(s, c).kind { + NodeKind::Component { props, .. } => match props.get(name) { + Some(PropDef::Boolean { default }) => Some(*default), + _ => None, + }, + _ => None, + } +} + +#[test] +fn boolean_property_default_is_the_mains_visibility() { + let mut s = Session::new(); + let (c, bg, title, dot) = card(&mut s); + // #993: a `false` default hides the bound layer in the main, not only in instances. + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show dot", "type": "boolean", "default": false, "layer": dot})); + assert!(!node(&s, dot).visible); + let i = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 300, "y": 0}))); + assert!(!node(&s, copy_of(&s, i, dot)).visible); + // Without a default, the property takes the layer's visibility. + ex(&mut s, "node.set", json!({"id": bg, "props": {"visible": false}})); + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Show bg", "type": "boolean", "layer": bg})); + assert_eq!(bool_default(&s, c, "Show bg"), Some(false)); + assert!(!node(&s, bg).visible); + // Binding a layer to an existing property shows it as the default says. + ex(&mut s, "component.bindProperty", json!({"layer": title, "property": "Show dot"})); + assert!(!node(&s, title).visible); + // A new default shows or hides every bound layer of the main; instances keep their values. + ex(&mut s, "instance.setProperties", json!({"ids": [i], "properties": {"Show dot": false}})); + ex(&mut s, "component.editProperty", json!({"id": c, "name": "Show dot", "default": true})); + assert!(node(&s, dot).visible && node(&s, title).visible); + assert!(!node(&s, copy_of(&s, i, dot)).visible, "the instance's own value stays"); + // Renamed with a new default in one go. + ex(&mut s, "component.editProperty", json!({"id": c, "name": "Show dot", "newName": "Dot", "default": false})); + assert!(!node(&s, dot).visible && !node(&s, title).visible); + assert_eq!(bool_default(&s, c, "Dot"), Some(false)); + // Showing a bound layer on the main is the property's new default, for its other layers too. + ex(&mut s, "node.set", json!({"id": dot, "props": {"visible": true}})); + assert_eq!(bool_default(&s, c, "Dot"), Some(true)); + assert!(node(&s, title).visible); + let j = idv(&ex(&mut s, "instance.create", json!({"component": c, "x": 600, "y": 0}))); + assert!(node(&s, copy_of(&s, j, dot)).visible && node(&s, copy_of(&s, j, title)).visible); + assert!(!node(&s, copy_of(&s, i, dot)).visible, "existing instances keep their values"); + // Hiding a layer inside an instance is an override there, not a new default. + let jdot = copy_of(&s, j, dot); + ex(&mut s, "node.set", json!({"id": jdot, "props": {"visible": false}})); + assert_eq!(bool_default(&s, c, "Dot"), Some(true)); + assert!(node(&s, dot).visible); + // Undo puts the default and the layers back together. + s.undo().unwrap(); + s.undo().unwrap(); + s.undo().unwrap(); + assert_eq!(bool_default(&s, c, "Dot"), Some(false)); + assert!(!node(&s, dot).visible && !node(&s, title).visible); + healthy(&s); +} + +#[test] +fn boolean_property_default_follows_across_variants() { + let mut s = Session::new(); + let mut variants = Vec::new(); + let mut labels = Vec::new(); + for (k, state) in ["Default", "Hover"].iter().enumerate() { + let f = + idv(&ex(&mut s, "frame.create", json!({"x": k as f64 * 150.0, "y": 0, "width": 100, "height": 40, "name": format!("State={state}")}))); + labels.push(idv(&ex(&mut s, "text.create", json!({"text": *state, "x": 10, "y": 10, "parent": f, "props": {"name": "Label"}})))); + variants.push(idv(&ex(&mut s, "component.create", json!({"ids": [f]})))); + } + let set = idv(&ex(&mut s, "component.combineVariants", json!({"ids": variants.clone()}))); + ex(&mut s, "component.addProperty", json!({"id": set, "name": "Show label", "type": "boolean", "default": false, "layer": labels[0]})); + ex(&mut s, "component.bindProperty", json!({"layer": labels[1], "property": "Show label"})); + assert!(labels.iter().all(|l| !node(&s, *l).visible)); + ex(&mut s, "component.editProperty", json!({"id": set, "name": "Show label", "default": true})); + assert!(labels.iter().all(|l| node(&s, *l).visible)); + // Hiding the label in one variant is the set's default: the other variant's label follows. + ex(&mut s, "node.set", json!({"id": labels[1], "props": {"visible": false}})); + assert!(variants.iter().all(|v| bool_default(&s, *v, "Show label") == Some(false))); + assert!(!node(&s, labels[0]).visible); + let i = idv(&ex(&mut s, "instance.create", json!({"component": set, "x": 0, "y": 200}))); + assert!(!node(&s, kids(&s, i)[0]).visible); + healthy(&s); +} + #[test] fn push_changes_to_main() { let mut s = Session::new(); diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..3d412727 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -713,6 +713,14 @@ most when it can't be reached). moves the chosen variant there, `{name, index}` reorders variant properties (the variants' names follow). `component.properties` on a set reports `conflicts`: variants sharing one combination of values. +- **Boolean properties**: on the main component, a boolean property's value is the visibility of + the layers bound to it. `component.addProperty {type: "boolean", default, layer}`, + `component.bindProperty {field: "visible"}` and `component.editProperty {name, default}` show or + hide the main's bound layers to match the default (without `default`, `addProperty` takes the + bound layer's visibility). Showing or hiding a bound layer on the main sets the default, and the + property's other bound layers (in every variant of a set) follow. New instances start from the + default; existing instances keep the values they have. Files open as saved: a main whose bound + layer disagrees with the default stays that way until one of them is edited. - `component.pushChanges {id?}` copies the instance's overrides into its main (from a layer of a nested instance too: always into the outer instance's main). From 09ff20ffd6d01d14fb3ab8c0f5b40f0a4855facf Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 20:35:13 +0200 Subject: [PATCH 016/142] M1: several exported layers arrive as one zip in the browser (#969) Cause: in the browser, exporting several layers started one download per file. Chrome holds back every download after the first until the site is allowed automatic downloads, so the other files didn't arrive. Fix: file.exportLayers takes `zip: true` (without `dir`) and returns the files together as one base64 zip `data`, written by a new figcraft_format::zip_files (the workspace's existing zip dependency). The UI asks for it when the platform saves by downloads and hands over one `.zip`. Test: ui-egui several_layers_download_as_one_zip (one download, a zip holding both layers). It fails on the previous commit (two downloads). --- crates/engine/src/cmd/file.rs | 13 +++++++++++-- crates/format/src/lib.rs | 14 ++++++++++++++ crates/ui-egui/src/export_panel.rs | 13 +++++++------ crates/ui-egui/src/web_save_tests.rs | 16 ++++++++++++++++ 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 8a75efb7..d5023372 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -65,7 +65,7 @@ pub fn specs() -> Vec { spec!( "file.exportLayers", "Export layers", - "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings)}", + "{ids? (default the selection, else every layer on the page with export settings), assets? (true: the Inspect tab's assets in and under them instead: layers with export settings, icons as SVG, images as PNG), dir? (folder to write into; else each file comes back as base64 `data`), zip? (no dir: the files come back together as one base64 zip `data` instead), bleed? (PDF: points, or with a unit: \"3mm\", \"0.125in\", \"9pt\"), cropMarks?, bleedMarks?, cmyk? (PDF print options): for every PDF instead of each setting's own} every layer once per export setting, named `.` (\"@2x\" when a scaled export has no suffix; repeats get \" 2\"; slashes in a layer name make folders). Returns {files: [{name, id, bytes, path | data}], skipped (layers without settings), data? (zip)}", false, export_layers ), @@ -1209,6 +1209,9 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); } let dir = string(p, "dir"); + // The browser saves one download at a time: several files go as one zip (#969). + let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false); + let mut members = Vec::new(); // Print options passed here stand in for every PDF setting's own. let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; let mut files = Vec::with_capacity(jobs.len()); @@ -1228,12 +1231,18 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { write(&path, &bytes)?; f["path"] = json!(path); } + None if zip => members.push((job.file.as_str(), bytes)), None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), } files.push(f); } let with_settings: std::collections::BTreeSet = jobs.iter().map(|j| j.id).collect(); - Ok(json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())})) + let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}); + if zip { + let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?; + r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive)); + } + Ok(r) } /// A PDF with one page per group of layers (an sRGB document), titled after the file; `area` diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9f7e51bc..d0775323 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -343,6 +343,20 @@ pub(crate) fn put( z.write_all(bytes).map_err(io_err) } +/// `files` (name, bytes; a `/` in a name makes a folder) as one zip archive: several exports +/// delivered as a single download (#969). +pub fn zip_files<'a>(files: impl IntoIterator) -> Result, FormatError> { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + for (name, bytes) in files { + put(&mut z, name, zip::write::SimpleFileOptions::default(), bytes)?; + } + z.finish().map_err(io_err)?; + } + Ok(buf.into_inner()) +} + /// Add `doc` as a `document.json`-style member, serialized straight into the compressor. fn put_json(z: &mut zip::ZipWriter, name: &str, doc: &Document) -> Result<(), FormatError> { z.start_file(name, json_options()).map_err(io_err)?; diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 6a4ca8ee..39fc4bd1 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -320,13 +320,14 @@ pub(crate) fn export_layers(app: &mut FigApp, ids: &[NodeId], assets: bool) { [] => {} // The suggested name keeps only the file's own name (a layer's folders apply to folders). [one] => save_one(app, &[one.id], &one.setting, one.file.rsplit('/').next().unwrap_or(&one.file)), - // No folders in the browser: each file arrives as its own download (#969). + // No folders in the browser, and it lets a page start one download at a time: the files + // arrive together as one zip (#969). _ if app.services.downloads() => { - let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets})) else { return }; - let files = r["files"].as_array().map_or(&[][..], Vec::as_slice); - let n = files.iter().filter(|f| app.download(f["name"].as_str().unwrap_or_default(), f)).count(); - if n > 0 { - app.toast(trn("Exported {n} files", n, &[])); + let name = format!("{}.zip", app.session.doc().map_or("Export", |d| d.name.as_str())); + if let Some(r) = app.run("file.exportLayers", json!({"ids": ids, "assets": assets, "zip": true})) + && app.download(&name, &r) + { + app.toast(trn("Exported {n} files", r["files"].as_array().map_or(0, Vec::len), &[])); } } _ => { diff --git a/crates/ui-egui/src/web_save_tests.rs b/crates/ui-egui/src/web_save_tests.rs index 7d1d75d2..5fbb2524 100644 --- a/crates/ui-egui/src/web_save_tests.rs +++ b/crates/ui-egui/src/web_save_tests.rs @@ -53,3 +53,19 @@ fn a_refused_download_says_so() { assert_eq!(got.lock().unwrap().len(), 1); assert_eq!(toast(&app), "Couldn't download Untitled.figcraft"); } + +#[test] +fn several_layers_download_as_one_zip() { + // The browser lets a page start one download at a time: two downloads lost the second. + let (mut app, got, a) = browser(true); + let r = app.session.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap(); + let b: NodeId = serde_json::from_value(r["id"].clone()).unwrap(); + app.session.execute("export.add", &json!({"ids": [a, b]})).unwrap(); + crate::export_panel::export_layers(&mut app, &[a, b], false); + let got = got.lock().unwrap(); + let [(name, bytes)] = got.as_slice() else { panic!("one download, got {}", got.len()) }; + assert_eq!(name, "Untitled.zip"); + assert!(bytes.starts_with(b"PK\x03\x04")); + assert!(bytes.windows(15).any(|w| w == b"Rectangle 2.png"), "both layers are in the zip"); + assert_eq!(toast(&app), "Exported 2 files"); +} From 40fd169bece498c121535ad03fe3f7baa6df5537 Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Wed, 21 Oct 2026 19:38:30 +0100 Subject: [PATCH 017/142] variable.export css: number lengths in px MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A radius exported as `--radius: 12` was invalid where CSS reads it as a length (`border-radius: var(--radius)` ignores a bare number). Number values now carry px unless the layers bound to them, their scopes, or the variables aliasing them make them an opacity or a font weight, which stay bare. The roles come from a small token_roles module. Updates the variables_io_tests expectation for the radius (4 → 4px, 6.5 → 6.5px), and adds a test for a bound radius, a bound opacity and an opacity alias's target. --- crates/engine/src/cmd/variables_io.rs | 13 +++- crates/engine/src/lib.rs | 1 + crates/engine/src/token_roles.rs | 98 +++++++++++++++++++++++++ crates/engine/src/variables_io_tests.rs | 24 +++++- docs/mcp.md | 4 +- 5 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/token_roles.rs diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index f3b9a820..0f55ea2c 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "variable.export", "Export variables", - "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]), path? (write it there)} → {text}, or {path} when written", + "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight), path? (write it there)} → {text}, or {path} when written", false, export ), @@ -46,7 +46,7 @@ fn export(s: &mut Session, p: &Value) -> Result { let text = match string(p, "format").as_deref().unwrap_or("json") { "json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls)) .map_err(|e| EngineError::Other(e.to_string()))?, - "css" => css(&doc.variables, &colls), + "css" => css(doc, &colls), _ => return Err(bad(cmd, "format: json|css")), }; match string(p, "path") { @@ -66,7 +66,11 @@ fn css_safe(text: &str) -> String { /// Each collection's variables as custom properties (named as generated code names them): the /// default mode's values on `:root`, every other mode's on `[data-=""]`; /// aliases are `var(--name)`. -fn css(vars: &Variables, colls: &[&Collection]) -> String { +fn css(doc: &Document, colls: &[&Collection]) -> String { + let vars = &doc.variables; + // Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and + // font weight don't. + let roles = crate::token_roles::Roles::new(doc); let mut out = String::new(); for c in colls { let attr = css_var_name(&c.name); @@ -76,7 +80,8 @@ fn css(vars: &Variables, colls: &[&Collection]) -> String { for v in vars.variables.iter().filter(|v| v.collection == c.id) { let value = match v.values.get(&m.id) { Some(VariableValue::Color(col)) => col.to_css(), - Some(VariableValue::Number(x)) => trim_num(*x), + Some(VariableValue::Number(x)) if roles.role(v) == crate::token_roles::NumberRole::Unitless => trim_num(*x), + Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)), Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)), Some(VariableValue::Boolean(b)) => b.to_string(), Some(VariableValue::Alias(a)) => match vars.get(a) { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..b0839695 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -60,6 +60,7 @@ pub mod synthetic_ds; pub mod templates; pub mod textedit; pub mod textpreview; +pub mod token_roles; pub mod tools; pub mod vectoredit; pub mod widgets; diff --git a/crates/engine/src/token_roles.rs b/crates/engine/src/token_roles.rs new file mode 100644 index 00000000..2f6cd41b --- /dev/null +++ b/crates/engine/src/token_roles.rs @@ -0,0 +1,98 @@ +//! What a file's number variables measure, so exported tokens carry the right unit: a radius, +//! font size, spacing or other length is in pixels, opacity and font weight are bare numbers. +//! A number's role comes from the layer fields bound to it, else its scopes, and an alias target +//! with no role of its own takes the role of the variables that read it (the target of an opacity +//! alias stays unitless). A number nothing tells about is a length: design tokens mostly are. + +use std::collections::HashMap; + +use figcraft_doc::*; + +use crate::codegen::num; + +/// What a number variable measures. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NumberRole { + Radius, + FontSize, + Spacing, + /// Another length (stroke, line height, letter spacing, effects): pixels. + Length, + /// Opacity or font weight: no unit. + Unitless, +} + +fn role_of_field(field: &str) -> Option { + Some(match field { + "cornerRadius" | "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => NumberRole::Radius, + "fontSize" => NumberRole::FontSize, + "opacity" | "fontWeight" => NumberRole::Unitless, + f if f == "gap" || f == "counterAxisSpacing" || f.starts_with("padding") || f == "width" || f == "height" => NumberRole::Spacing, + f if f.starts_with("min") || f.starts_with("max") => NumberRole::Spacing, + "strokeWeight" | "lineHeight" | "letterSpacing" | "paragraphSpacing" | "paragraphIndent" => NumberRole::Length, + _ => return None, + }) +} + +fn role_of_scope(scope: &str) -> Option { + Some(match scope { + "CORNER_RADIUS" => NumberRole::Radius, + "FONT_SIZE" => NumberRole::FontSize, + "GAP" | "WIDTH_HEIGHT" => NumberRole::Spacing, + "OPACITY" | "FONT_WEIGHT" => NumberRole::Unitless, + "STROKE_FLOAT" | "EFFECT_FLOAT" | "LINE_HEIGHT" | "LETTER_SPACING" | "PARAGRAPH_SPACING" | "PARAGRAPH_INDENT" => NumberRole::Length, + _ => return None, + }) +} + +/// The roles of a file's number variables, computed once per export. +#[derive(Default)] +pub struct Roles(HashMap); + +impl Roles { + pub fn new(doc: &Document) -> Self { + let vars = &doc.variables.variables; + let mut roles: HashMap = HashMap::new(); + for n in doc.nodes() { + for (field, id) in &n.bound { + if let Some(r) = role_of_field(field) { + roles.entry(id.clone()).or_insert(r); + } + } + } + for v in vars.iter().filter(|v| v.kind == VariableType::Number) { + if let Some(r) = v.scopes.as_ref().and_then(|s| s.iter().find_map(|s| role_of_scope(s))) { + roles.entry(v.id.clone()).or_insert(r); + } + } + // Alias chains are short; a few rounds reach their ends. + for _ in 0..8 { + let mut changed = false; + for v in vars { + let Some(r) = roles.get(&v.id).copied() else { continue }; + for value in v.values.values() { + if let VariableValue::Alias(target) = value + && !roles.contains_key(target) + { + roles.insert(target.clone(), r); + changed = true; + } + } + } + if !changed { + break; + } + } + Roles(roles) + } + + /// What `v` measures; a number nothing tells about is a length. + pub fn role(&self, v: &Variable) -> NumberRole { + self.0.get(&v.id).copied().unwrap_or(NumberRole::Length) + } + + /// A number variable's CSS value: pixels for lengths, bare for opacity and font weight. + pub fn number_css(&self, v: &Variable, x: f64) -> String { + if self.role(v) == NumberRole::Unitless { num(x) } else { format!("{}px", num(x)) } + } +} diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 3a57d9ea..8d21be61 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -214,9 +214,9 @@ fn variables_export_and_import_round_trip() { let css = ex(&mut s, "variable.export", json!({"format": "css", "collections": ["Theme"]}))["text"].as_str().unwrap().to_string(); for want in [ ":root {\n", - " --radius: 4;\n", + " --radius: 4px;\n", "[data-theme=\"Dark\"] {\n", - " --radius: 6.5;\n", + " --radius: 6.5px;\n", " --accent: var(--color-primary);\n", " --label: \"Say hi \";\n", ] { @@ -397,3 +397,23 @@ fn paints_replaced_through_node_set_keep_unchanged_stop_bindings() { assert_eq!(stops(&s, r), ["FFFFFF", "0000FF"]); healthy(&s); } + +#[test] +fn css_export_gives_lengths_a_unit_and_keeps_opacity_bare() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Brand"})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "card", "type": "number", "value": 12})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "fade", "type": "number", "value": 0.5})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "half", "type": "number", "value": 0.5})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "dim", "type": "number", "value": 1})); + ex(&mut s, "variable.set", json!({"variable": "dim", "value": {"alias": "half"}})); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + let g = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "node.set", json!({"id": f, "props": {"cornerRadius": 12, "opacity": 0.5}})); + ex(&mut s, "variable.bind", json!({"ids": [f], "field": "cornerRadius", "variable": "card"})); + ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "fade"})); + ex(&mut s, "variable.bind", json!({"ids": [g], "field": "opacity", "variable": "dim"})); + let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string(); + // A radius is a length; opacity stays a number, also through an alias it reads. + assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;") && css.contains("--half: 0.5;"), "{css}"); +} diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..fa16f5b7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -903,7 +903,9 @@ frame back to Auto, inheriting its parent's mode). The same JSON carries variables between FigCraft files: `variable.export {collections?, format?, path?}` writes this file's collections (default: all but library copies) as a local variables response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default -mode on `:root`, each other mode on `[data-=""]`, aliases as `var(--name)`), and +mode on `:root`, each other mode on `[data-=""]`, aliases as `var(--name)`, numbers +in px unless the layers bound to them, their scopes or the variables aliasing them make them an +opacity or a font weight, so `border-radius: var(--radius)` reads `12px`), and returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level) reads one back: a collection joins this file's collection of the same name From 196e3432dd558e68cc256f2b39e4173bc1fb38ba Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Wed, 21 Oct 2026 19:54:47 +0100 Subject: [PATCH 018/142] data.fill and text.fit: fill copies of a frame from rows of data - data.fill {id, rows, gap?, formats?, dir?, format?, scale?}: repeats a frame once per row in one undo step; each row key names layers in the copy (text, or {text?, fill?, visible?, fit?}). `formats` makes each row once per size (feed, story, square), the copy resized so its auto layout reflows. With `dir`, each copy is exported under a unique file name; a failed export is reported in `errors` and keeps the copies. - text.fit {ids?, maxLines?, minSize?, orphans?}: the largest font size, down to a floor, at which fixed-width text stays within N lines and its box; avoids a one-word last line; `fits` comes from the final text. - Plugins can't call data.fill: it runs other commands inside, past the plugin policy, like command.batch. --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/datafill.rs | 171 ++++++++++++++++++++++++++++ crates/engine/src/datafill_tests.rs | 142 +++++++++++++++++++++++ crates/engine/src/lib.rs | 6 + crates/engine/src/plugins.rs | 2 + crates/engine/src/plugins_tests.rs | 1 + crates/engine/src/textfit.rs | 115 +++++++++++++++++++ crates/engine/src/textfit_tests.rs | 104 +++++++++++++++++ crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/locales/pt-BR.toml | 2 + docs/mcp.md | 32 ++++++ 11 files changed, 579 insertions(+) create mode 100644 crates/engine/src/datafill.rs create mode 100644 crates/engine/src/datafill_tests.rs create mode 100644 crates/engine/src/textfit.rs create mode 100644 crates/engine/src/textfit_tests.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index f011238d..1d73cc4c 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -109,6 +109,8 @@ fn registry() -> &'static Registry { list.extend(crate::online::specs()); list.extend(crate::invite_links::specs()); list.extend(crate::templates::specs()); + list.extend(crate::datafill::specs()); + list.extend(crate::textfit::specs()); let mut index = HashMap::new(); for (i, s) in list.iter().enumerate() { if index.insert(s.id, i).is_some() { diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/datafill.rs new file mode 100644 index 00000000..d0226936 --- /dev/null +++ b/crates/engine/src/datafill.rs @@ -0,0 +1,171 @@ +//! `data.fill`: one frame repeated per row of data (a carousel, a set of posts, cards from a +//! list): each copy gets its row's texts, colours and visibility on the layers named by the row's +//! keys, in one undo step, and optionally each copy exported as a file. Agents make a series with +//! one call instead of a duplicate, a lookup and an edit per layer and an export per copy. + +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::*; +use crate::{Result, Session, bad, spec}; + +const CMD: &str = "data.fill"; +const MAX_ROWS: usize = 500; + +pub fn specs() -> Vec { + vec![spec!( + "data.fill", + "Fill copies with data", + "{id (the frame to repeat), rows: [{: text | {text?, fill? (#rrggbb), visible?, fit? (true or text.fit's {maxLines?, minSize?, orphans?}: size the text to its box)}, name? (the copy's name)}] (every layer with that name in the copy; up to 500 rows), gap? (between copies, placed in a row to the right; default 100), formats? ([{name?, width, height}]: each row once per size, the copy resized so its auto layout reflows and named -; e.g. feed 1080×1350, story 1080×1920, square 1080×1080), dir? (export each copy there as .), format? (png|jpg|webp|svg|pdf; default png), scale? (default 1)} one undo step → {ids (the copies, in row order), missing (keys no layer is named), files? (paths written, one per copy: same file names get a numbered suffix), errors? ([{id, path, error}] for exports that failed; the copies stay)}", + false, + fill + )] +} + +/// A file name from a layer name: letters, digits, spaces, `-` and `_` kept, the rest `-`. +fn file_name(name: &str) -> String { + let s: String = name.chars().take(80).map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_') { c } else { '-' }).collect(); + let s = s.trim().to_string(); + if s.is_empty() { "copy".into() } else { s } +} + +fn fill(s: &mut Session, p: &Value) -> Result { + let src = id(p, "id").ok_or_else(|| bad(CMD, "`id` (the frame to repeat) required"))?; + let rows = match get(p, "rows") { + Some(Value::Array(a)) if a.iter().all(Value::is_object) && a.len() <= MAX_ROWS => a.clone(), + _ => return Err(bad(CMD, "rows: an array of objects (up to 500)")), + }; + let gap = num_or(p, "gap", 100.0); + if !gap.is_finite() { + return Err(bad(CMD, "gap: a number")); + } + let format = string(p, "format").unwrap_or_else(|| "png".into()).to_ascii_lowercase(); + if !["png", "jpg", "webp", "svg", "pdf"].contains(&format.as_str()) { + return Err(bad(CMD, "format: png|jpg|webp|svg|pdf")); + } + let (width, base) = { + let doc = s.doc()?; + let n = doc.node(src).ok_or_else(|| bad(CMD, "no such layer"))?; + (n.width, n.name.clone()) + }; + // `formats`: each row once per size, the copy resized so its auto layout reflows. + let formats: Vec> = match get(p, "formats") { + None | Some(Value::Null) => vec![None], + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 12 => a + .iter() + .map(|f| { + let (w, h) = (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)); + let name = f.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_string(); + match (w, h) { + (Some(w), Some(h)) if w.is_finite() && h.is_finite() && (1.0..=20_000.0).contains(&w) && (1.0..=20_000.0).contains(&h) => { + Ok(Some((if name.is_empty() { format!("{}x{}", w as i64, h as i64) } else { name }, w, h))) + } + _ => Err(bad(CMD, "formats: [{name?, width, height}] with sizes from 1 to 20000")), + } + }) + .collect::>()?, + Some(_) => return Err(bad(CMD, "formats: [{name?, width, height}] (1 to 12 sizes)")), + }; + let column = formats.iter().flatten().map(|f| f.1).fold(width, f64::max); + let mut missing: Vec = Vec::new(); + let ids = s.grouped("Fill copies with data", |s| { + let mut ids = Vec::with_capacity(rows.len() * formats.len()); + for (i, row) in rows.iter().enumerate() { + let mut dy = 0.0; + for fmt in &formats { + // Rows go right; a row's formats stack downwards. + let dx = (column + gap) * (i as f64 + 1.0); + let dup = s.execute("edit.duplicate", &json!({"ids": [src], "dx": dx, "dy": dy}))?; + let copy = dup["ids"].get(0).and_then(id_of).ok_or_else(|| bad(CMD, "the frame couldn't be duplicated"))?; + let Some(fields) = row.as_object() else { continue }; + let name = fields.get("name").and_then(Value::as_str).map_or_else(|| format!("{base} {}", i + 1), str::to_string); + let name = match fmt { + Some((suffix, w, h)) => { + s.execute("node.set", &json!({"id": copy, "props": {"width": w, "height": h}}))?; + dy += h + gap; + format!("{name}-{suffix}") + } + None => name, + }; + s.execute("node.set", &json!({"id": copy, "props": {"name": name}}))?; + for (key, value) in fields.iter().filter(|(k, _)| k.as_str() != "name") { + let doc = s.doc()?; + let targets: Vec<_> = + doc.subtree(copy).into_iter().filter(|t| *t != copy && doc.node(*t).is_some_and(|n| n.name == *key)).collect(); + if targets.is_empty() { + if !missing.contains(key) { + missing.push(key.clone()); + } + continue; + } + let (text, fill, visible, fit) = match value { + Value::Object(o) => ( + o.get("text").map(text_of), + o.get("fill").and_then(Value::as_str), + o.get("visible").and_then(Value::as_bool), + o.get("fit").cloned().filter(|f| !matches!(f, Value::Bool(false) | Value::Null)), + ), + v => (Some(text_of(v)), None, None, None), + }; + let mut texts = Vec::new(); + for t in targets { + let is_text = s.doc()?.node(t).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))); + if let Some(text) = text.as_ref().filter(|_| is_text) { + s.execute("text.set", &json!({"id": t, "characters": text}))?; + } + if is_text { + texts.push(t.0); + } + if let Some(f) = fill { + s.execute("node.set", &json!({"id": t, "props": {"fills": [f]}}))?; + } + if let Some(v) = visible { + s.execute("node.set", &json!({"id": t, "props": {"visible": v}}))?; + } + } + // `fit: true` or `{maxLines?, minSize?, orphans?}`: size the text to its box. + if let Some(f) = fit.filter(|_| !texts.is_empty()) { + let mut params = if f.is_object() { f } else { json!({}) }; + params["ids"] = json!(texts); + s.execute("text.fit", ¶ms)?; + } + } + ids.push((copy, name)); + } + } + Ok(ids) + })?; + let mut out = json!({"ids": ids.iter().map(|(i, _)| i.0).collect::>(), "missing": missing}); + // Exports after the edits landed: a failed edit writes no files. A failed export keeps the + // copies (one undo step removes them) and is reported with the files that were written. + if let Some(dir) = string(p, "dir") { + let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::::new()); + for (copy, name) in &ids { + // One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively, + // as macOS and Windows file systems compare names). + let base = file_name(name); + let stem = + (1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base); + used.push(stem.to_lowercase()); + let path = std::path::Path::new(&dir).join(format!("{stem}.{format}")).to_string_lossy().to_string(); + match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": num_or(p, "scale", 1.0), "path": path})) { + Ok(_) => files.push(path), + Err(e) => errors.push(json!({"id": copy.0, "path": path, "error": e.to_string()})), + } + } + out["files"] = json!(files); + if !errors.is_empty() { + out["errors"] = json!(errors); + } + } + Ok(out) +} + +/// A cell as text: strings as they are, numbers and booleans as written, null as empty. +fn text_of(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Null => String::new(), + v => v.to_string(), + } +} diff --git a/crates/engine/src/datafill_tests.rs b/crates/engine/src/datafill_tests.rs new file mode 100644 index 00000000..bbb5d4b6 --- /dev/null +++ b/crates/engine/src/datafill_tests.rs @@ -0,0 +1,142 @@ +//! `data.fill`: a frame repeated per row with that row's texts, fills and visibility, in one undo +//! step, optionally exported. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use figcraft_doc::*; +use serde_json::json; + +/// A 1080×1350 "Post" with "Title" and "Price" texts and a "Badge" rectangle. Returns the frame. +fn post(s: &mut Session) -> NodeId { + let f = idv(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); + ex(s, "text.create", json!({"text": "Title", "x": 80, "y": 80, "parent": f, "props": {"name": "Title"}})); + ex(s, "text.create", json!({"text": "R$ 0", "x": 80, "y": 1200, "parent": f, "props": {"name": "Price"}})); + ex(s, "shape.rectangle", json!({"x": 80, "y": 40, "width": 200, "height": 32, "parent": f, "props": {"name": "Badge", "fill": "#3DDC97"}})); + f +} + +fn text(s: &Session, frame: NodeId, name: &str) -> String { + let doc = s.doc().unwrap(); + doc.subtree(frame) + .into_iter() + .filter_map(|i| doc.node(i)) + .find(|n| n.name == name) + .and_then(|n| match &n.kind { + NodeKind::Text(t) => Some(t.characters.clone()), + _ => None, + }) + .unwrap_or_default() +} + +#[test] +fn each_row_fills_a_copy_in_one_undo_step() { + let mut s = Session::new(); + let f = post(&mut s); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [ + {"name": "Slide 1", "Title": "Site em 7 dias", "Price": 5500}, + {"name": "Slide 2", "Title": "WhatsApp com IA", "Badge": {"visible": false}}, + {"Title": "Agenda sozinha", "Badge": {"fill": "#7C5CFF"}, "Subtitle": "nope"} + ]}), + ); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 3); + assert_eq!(r["missing"], json!(["Subtitle"])); + assert_eq!(text(&s, ids[0], "Title"), "Site em 7 dias"); + assert_eq!(text(&s, ids[0], "Price"), "5500"); + assert_eq!(text(&s, ids[2], "Title"), "Agenda sozinha"); + assert_eq!(text(&s, f, "Title"), "Title", "the source frame is untouched"); + let doc = s.doc().unwrap(); + assert_eq!(doc.node(ids[0]).unwrap().name, "Slide 1"); + assert_eq!(doc.node(ids[2]).unwrap().name, "Post 3"); + // Copies sit in a row to the right, 100 apart. + let x = |i: NodeId| doc.node(i).unwrap().transform.as_coeffs()[4]; + assert_eq!((x(ids[0]), x(ids[1])), (1180.0, 2360.0)); + let badge = |i: NodeId| doc.subtree(i).into_iter().filter_map(|k| doc.node(k)).find(|n| n.name == "Badge").cloned().unwrap(); + assert!(!badge(ids[1]).visible && badge(ids[0]).visible); + assert_eq!(badge(ids[2]).fills[0].solid_color().map(|c| c.to_hex()), Some("7C5CFF".into())); + // One undo removes every copy. + ex(&mut s, "edit.undo", json!({})); + assert!(ids.iter().all(|i| !s.doc().unwrap().contains(*i))); +} + +#[test] +fn copies_export_to_a_folder_and_bad_input_is_refused() { + let mut s = Session::new(); + let f = post(&mut s); + let dir = std::env::temp_dir().join(format!("figcraft-datafill-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [{"name": "Post/1: hero", "Title": "A"}, {"Title": "B"}], "dir": dir.to_string_lossy(), "scale": 0.25}), + ); + let files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + assert_eq!(files.len(), 2); + assert!(files[0].ends_with("Post-1- hero.png"), "{files:?}"); + assert!(files.iter().all(|p| std::fs::metadata(p).is_ok_and(|m| m.len() > 0))); + let _ = std::fs::remove_dir_all(&dir); + for bad in [json!({"id": f}), json!({"id": f, "rows": [1, 2]}), json!({"id": f, "rows": [], "format": "gif"}), json!({"id": 999_999, "rows": []})] + { + assert!(s.execute("data.fill", &bad).is_err(), "{bad}"); + } +} + +#[test] +fn each_row_comes_in_every_format() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); + ex(&mut s, "layout.add", json!({"ids": [f], "direction": "vertical"})); + ex(&mut s, "node.set", json!({"id": f, "props": {"sizingV": "fixed", "padding": 80}})); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Título", "x": 0, "y": 0, "parent": f, "props": {"name": "Title", "fontSize": 96}}))); + ex(&mut s, "node.set", json!({"id": t, "props": {"sizingH": "fill", "textAutoResize": "height"}})); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "formats": [ + {"name": "feed", "width": 1080, "height": 1350}, {"name": "story", "width": 1080, "height": 1920}, {"width": 1080, "height": 1080} + ], "rows": [{"name": "capa", "Title": "Site em 7 dias"}, {"name": "prova", "Title": "Lighthouse 100"}]}), + ); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + assert_eq!(ids.len(), 6); + let doc = s.doc().unwrap(); + let names: Vec = ids.iter().map(|i| doc.node(*i).unwrap().name.clone()).collect(); + assert_eq!(names, ["capa-feed", "capa-story", "capa-1080x1080", "prova-feed", "prova-story", "prova-1080x1080"]); + let size = |i: NodeId| (doc.node(i).unwrap().width, doc.node(i).unwrap().height); + assert_eq!(size(ids[1]), (1080.0, 1920.0)); + assert_eq!(size(ids[2]), (1080.0, 1080.0)); + assert_eq!(text(&s, ids[4], "Title"), "Lighthouse 100"); + // Formats of a row stack downwards; rows go right without overlapping. + let at = |i: NodeId| (doc.node(i).unwrap().transform.as_coeffs()[4], doc.node(i).unwrap().transform.as_coeffs()[5]); + assert_eq!(at(ids[0]).0, at(ids[1]).0); + assert!(at(ids[1]).1 >= at(ids[0]).1 + 1350.0); + assert!(at(ids[3]).0 >= at(ids[0]).0 + 1080.0); + for bad in [json!([]), json!([{"width": 0, "height": 10}]), json!("feed")] { + assert!(s.execute("data.fill", &json!({"id": f, "rows": [{}], "formats": bad})).is_err(), "{bad}"); + } +} + +#[test] +fn review_export_names_are_unique_and_failures_are_reported() { + let mut s = Session::new(); + let f = post(&mut s); + let dir = std::env::temp_dir().join(format!("figcraft-datafill-names-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [{"name": "Post"}, {"name": "post"}, {"name": "a/b"}, {"name": "a:b"}], "dir": dir.to_string_lossy(), "scale": 0.1}), + ); + let files: Vec = r["files"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + let lower: std::collections::HashSet = files.iter().map(|f| f.to_lowercase()).collect(); + assert_eq!(lower.len(), 4, "one file per copy: {files:?}"); + assert!(files.iter().all(|p| std::fs::metadata(p).is_ok()), "{files:?}"); + let _ = std::fs::remove_dir_all(&dir); + // A folder that can't be written: the copies stay and every failure is reported. + let r = ex(&mut s, "data.fill", json!({"id": f, "rows": [{"name": "x"}], "dir": "/nonexistent-figcraft-dir/out"})); + assert_eq!(r["ids"].as_array().unwrap().len(), 1); + assert_eq!(r["errors"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(s.doc().unwrap().contains(NodeId(r["ids"][0].as_u64().unwrap()))); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..1690d0a9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -22,6 +22,7 @@ pub mod codegen; pub mod collab; pub mod component_codegen; pub mod components; +pub mod datafill; pub mod dev_resources; pub mod devcompare; pub mod devinspect; @@ -59,6 +60,7 @@ pub mod synthetic; pub mod synthetic_ds; pub mod templates; pub mod textedit; +pub mod textfit; pub mod textpreview; pub mod tools; pub mod vectoredit; @@ -1643,6 +1645,8 @@ mod cursor_tests; #[cfg(test)] mod dark_page_text_tests; #[cfg(test)] +mod datafill_tests; +#[cfg(test)] mod design_paste_tests; #[cfg(test)] mod dev_resources_tests; @@ -1867,6 +1871,8 @@ mod text_style_picker_tests; #[cfg(test)] mod text_tail_tests; #[cfg(test)] +mod textfit_tests; +#[cfg(test)] mod theme_pages_tests; #[cfg(test)] mod tiff_tests; diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index be997079..be222293 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -111,6 +111,8 @@ const DENIED: &[&str] = &[ "edit.redo", "edit.repeat", "command.batch", + // Runs other commands inside (duplicate, edit, export), past this policy, like a batch. + "data.fill", // Network sessions, online files and their recovery copies on disk. "collab.", // Opens files in new windows. diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 657b6825..7ff16cf3 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -169,6 +169,7 @@ fn files_dialogs_history_and_plugins_are_off_limits() { ("plugin.remove", json!({"id": "org.test.deny"})), ("edit.undo", json!({})), ("command.batch", json!({"commands": [{"command": "file.saveAs", "params": {"path": target}}]})), + ("data.fill", json!({"id": a, "rows": [{}], "dir": dir.to_string_lossy()})), ("variable.export", json!({"format": "css", "path": target})), ("codeConnect.export", json!({"path": target})), ("codeConnect.import", json!({"path": "/etc/passwd"})), diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs new file mode 100644 index 00000000..f0cf3bba --- /dev/null +++ b/crates/engine/src/textfit.rs @@ -0,0 +1,115 @@ +//! `text.fit`: text sized to fit its box, for posts and cards whose copy changes (a carousel +//! filled from data, a translated headline). The largest font size, down to a floor, at which the +//! text stays within `maxLines` lines and its box's height; then a last line that is a single word +//! (an orphan) is avoided by shrinking a little more, else by keeping the last two words together +//! with a no-break space. Every style run scales by the same factor, so mixed sizes keep their +//! proportions. + +use figcraft_doc::*; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::*; +use crate::{Result, Session, bad, spec}; + +const CMD: &str = "text.fit"; + +pub fn specs() -> Vec { + vec![spec!( + "text.fit", + "Fit text to its box", + "{ids? (text layers; default the selection), maxLines? (default: no limit but the box height), minSize? (smallest font size, px; default 60% of the current), orphans? (avoid a one-word last line; default true)} needs a fixed width (fixed or auto-height text) → {fitted: [{id, fontSize, lines, fits, orphan}]}", + true, + fit + )] +} + +/// The layout of `t` with every run's font size times `k`, at `width`: (line count, height, +/// words on the last line). +fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, usize) { + let mut t = t.clone(); + let n = t.char_count(); + t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0)); + let l = figcraft_text::layout(&t, Some(width), None); + let last = l.lines.last().map_or(0, |line| { + t.characters.chars().skip(line.char_start).take(line.char_end.saturating_sub(line.char_start)).collect::().split_whitespace().count() + }); + (l.lines.len(), l.height, last) +} + +fn fit(s: &mut Session, p: &Value) -> Result { + let ids = req_targets(CMD, s, p)?; + let max_lines = num(p, "maxLines").filter(|v| v.is_finite() && *v >= 1.0).map(|v| v.min(1000.0) as usize); + let orphans = boolean(p, "orphans").unwrap_or(true); + let mut out = Vec::new(); + let doc = s.doc_mut()?; + for id in ids { + let n = doc.node(id).ok_or_else(|| bad(CMD, format!("no layer {id}")))?; + let NodeKind::Text(t) = &n.kind else { return Err(bad(CMD, format!("{id} is not text"))) }; + if t.auto_resize == TextAutoResize::WidthAndHeight { + return Err(bad(CMD, format!("{id} grows to its text: give it a fixed width (auto height or fixed) first"))); + } + let (width, height_cap) = (n.width, if t.auto_resize == TextAutoResize::None { n.height } else { f64::INFINITY }); + let size = t.style.font_size.max(1.0); + let min = num(p, "minSize").filter(|v| v.is_finite() && *v >= 1.0).unwrap_or(size * 0.6).min(size); + let fits = |m: (usize, f64, usize)| max_lines.is_none_or(|ml| m.0 <= ml) && m.1 <= height_cap + 0.5; + // The largest factor in [min/size, 1] that fits: binary search to a tenth of a pixel. + let (mut lo, mut hi) = (min / size, 1.0); + let k = if fits(measure(t, 1.0, width)) { + 1.0 + } else if !fits(measure(t, lo, width)) { + lo + } else { + for _ in 0..24 { + let mid = (lo + hi) / 2.0; + if fits(measure(t, mid, width)) { + lo = mid + } else { + hi = mid + } + if (hi - lo) * size < 0.1 { + break; + } + } + lo + }; + // Whole-tenth sizes read better in the panel. + let mut k = ((size * k * 10.0).floor() / 10.0) / size; + let mut m = measure(t, k, width); + let mut orphan = orphans && m.0 > 1 && m.2 == 1; + let mut nbsp = false; + if orphan { + // Up to 8% smaller (not below the floor) may pull a word down to the last line. + let mut tries = 0; + while orphan && tries < 8 && size * k * 0.99 >= min { + k *= 0.99; + m = measure(t, k, width); + orphan = m.0 > 1 && m.2 == 1; + tries += 1; + } + if orphan { + nbsp = true; + } + } + let n = doc.get_mut(id)?; + let NodeKind::Text(t) = &mut n.kind else { continue }; + let count = t.char_count(); + t.restyle(0, count, |st| st.font_size = ((st.font_size * k * 10.0).round() / 10.0).clamp(1.0, 5000.0)); + // Same character count, so style runs keep their ranges. + let joined = if nbsp && let Some(i) = t.characters.trim_end().rfind(' ') { + t.characters.replace_range(i..i + 1, "\u{a0}"); + Some(i) + } else { + None + }; + let mut after = measure(t, 1.0, width); + // Keeping the last words together must not break the limit: an orphan is better. + if let Some(i) = joined.filter(|_| !fits(after)) { + t.characters.replace_range(i..i + '\u{a0}'.len_utf8(), " "); + after = measure(t, 1.0, width); + } + // Reported from the final text (rounded sizes, the no-break space), which callers rely on. + out.push(json!({"id": id.0, "fontSize": (t.style.font_size * 10.0).round() / 10.0, "lines": after.0, "fits": fits(after), "orphan": orphans && after.0 > 1 && after.2 == 1})); + } + Ok(json!({"fitted": out})) +} diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs new file mode 100644 index 00000000..8d0a2ee5 --- /dev/null +++ b/crates/engine/src/textfit_tests.rs @@ -0,0 +1,104 @@ +//! `text.fit`: the largest size that fits the box and a line limit, orphans avoided, and +//! data.fill sizing each copy's text. + +use crate::Session; +use crate::components::tests::{ex, idv}; +use figcraft_doc::*; +use serde_json::json; + +/// A fixed-width (auto-height) headline of `width` px at 64px. Returns its id. +fn headline(s: &mut Session, text: &str, width: f64) -> NodeId { + let t = idv(&ex(s, "text.create", json!({"text": text, "x": 0, "y": 0, "props": {"name": "Headline", "fontSize": 64}}))); + ex(s, "node.set", json!({"id": t, "props": {"width": width, "textAutoResize": "height"}})); + t +} + +fn size(s: &Session, id: NodeId) -> f64 { + match &s.doc().unwrap().node(id).unwrap().kind { + NodeKind::Text(t) => t.style.font_size, + _ => 0.0, + } +} + +#[test] +fn text_shrinks_to_its_line_limit_and_no_further() { + let mut s = Session::new(); + let t = headline(&mut s, "Seu site e seu WhatsApp com IA, prontos em 7 dias", 600.0); + let r = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 2})); + let f = &r["fitted"][0]; + assert_eq!(f["lines"], 2, "{r}"); + assert!(f["fits"].as_bool().unwrap()); + let fitted = size(&s, t); + assert!((64.0 * 0.6..64.0).contains(&fitted), "{fitted}"); + // The largest that fits: a pixel more breaks the limit. + ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": fitted + 1.0}})); + let again = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 2})); + assert!((again["fitted"][0]["fontSize"].as_f64().unwrap() - fitted).abs() <= 0.2, "{again}"); + // Text that already fits keeps its size; one undo restores the original. + let short = headline(&mut s, "7 dias", 600.0); + ex(&mut s, "text.fit", json!({"ids": [short], "maxLines": 1})); + assert_eq!(size(&s, short), 64.0); + // A floor that can't fit reports it instead of going below (fit never grows text). + ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": 64}})); + let r = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 1, "minSize": 50})); + assert_eq!(r["fitted"][0]["fits"], false); + assert_eq!(size(&s, t), 50.0); + // Text that grows to its content has no box to fit. + let auto = idv(&ex(&mut s, "text.create", json!({"text": "x", "x": 0, "y": 0}))); + assert!(s.execute("text.fit", &json!({"ids": [auto]})).is_err()); +} + +#[test] +fn a_one_word_last_line_is_avoided() { + let mut s = Session::new(); + // At 64px in 520px this breaks with "1s." alone on the last line. + let t = headline(&mut s, "Lighthouse 100, abre em menos de 1s.", 520.0); + let r = ex(&mut s, "text.fit", json!({"ids": [t]})); + assert_eq!(r["fitted"][0]["orphan"], false, "{r}"); + let doc = s.doc().unwrap(); + let NodeKind::Text(tx) = &doc.node(t).unwrap().kind else { panic!() }; + let l = figcraft_text::layout(tx, Some(520.0), None); + let last = l.lines.last().unwrap(); + let words = tx.characters.chars().skip(last.char_start).take(last.char_end - last.char_start).collect::(); + assert!(l.lines.len() == 1 || words.split_whitespace().count() > 1 || words.contains('\u{a0}'), "{words:?}"); +} + +#[test] +fn data_fill_sizes_each_copys_text() { + let mut s = Session::new(); + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Curto", "x": 80, "y": 80, "parent": f, "props": {"name": "Headline", "fontSize": 96}}))); + ex(&mut s, "node.set", json!({"id": t, "props": {"width": 920, "textAutoResize": "height"}})); + let r = ex( + &mut s, + "data.fill", + json!({"id": f, "rows": [ + {"Headline": {"text": "Curto", "fit": {"maxLines": 2}}}, + {"Headline": {"text": "Landing page e atendente de WhatsApp com IA, prontos em sete dias úteis", "fit": {"maxLines": 2}}} + ]}), + ); + let ids: Vec = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect(); + let head = |s: &Session, frame: NodeId| s.doc().unwrap().node(frame).unwrap().children[0]; + assert_eq!(size(&s, head(&s, ids[0])), 96.0, "short copy keeps its size"); + assert!(size(&s, head(&s, ids[1])) < 96.0, "long copy shrinks"); + assert_eq!(size(&s, t), 96.0, "the source is untouched"); +} + +#[test] +fn review_fits_is_reported_from_the_final_text() { + let mut s = Session::new(); + for (text, width, lines) in [ + ("Seu site e seu WhatsApp com IA, prontos em 7 dias", 600.0, 2.0), + ("Lighthouse 100, abre em menos de 1s.", 520.0, 2.0), + ("Curto", 300.0, 1.0), + ] { + let t = headline(&mut s, text, width); + let r = ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": lines})); + let f = &r["fitted"][0]; + let doc = s.doc().unwrap(); + let NodeKind::Text(tx) = &doc.node(t).unwrap().kind else { panic!() }; + let real = figcraft_text::layout(tx, Some(width), None).lines.len(); + assert_eq!(f["lines"].as_u64().unwrap() as usize, real, "{r}"); + assert_eq!(f["fits"].as_bool().unwrap(), real as f64 <= lines, "{r}"); + } +} diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 596feda6..1e6799a9 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2171,6 +2171,8 @@ status = "draft — needs native review" "text.removeFromPath" = "Quitar texto del trazado" "text.rescanFonts" = "Volver a buscar fuentes instaladas" "text.set" = "Establecer texto" +"data.fill" = "Rellenar copias con datos" +"text.fit" = "Ajustar texto a su caja" "text.setFeatures" = "Funciones OpenType" "text.setVariations" = "Ejes de fuente variable" "text.sizeDown" = "Reducir tamaño de fuente" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index d8f857e0..9a816e2c 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -2155,6 +2155,8 @@ status = "draft — needs native review" "text.preview" = "Visualizar alteração de texto" "text.removeFromPath" = "Remover texto do caminho" "text.set" = "Definir texto" +"data.fill" = "Preencher cópias com dados" +"text.fit" = "Ajustar texto à caixa" "text.setFeatures" = "Recursos OpenType" "text.setVariations" = "Eixos de fonte variável" "text.sizeDown" = "Diminuir tamanho da fonte" diff --git a/docs/mcp.md b/docs/mcp.md index dfea95cc..5af124f9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -330,6 +330,38 @@ tips; headless, it only sets the preference. {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"export","arguments":{"format":"png","path":"dashboard.png"}}} ``` +### Filling copies with data + +`data.fill {id, rows, gap?, dir?, format?, scale?}` repeats a frame once per row (a carousel, a set +of posts, a card per item) in one undo step: each key of a row names layers in the copy, and a +string (or number) sets their text, `{text?, fill?, visible?}` sets more; `name` names the copy +(default " "). Copies go in a row to the right, `gap` apart (default 100). With `dir`, each +copy is exported there as `.` (png by default) after the edits land. Returns `{ids, +missing (keys no layer is named), files?}`. + +Copy that changes length per row can size itself: `{"Headline": {"text": "…", "fit": {"maxLines": 2}}}` runs +`text.fit` on those layers after the text is set. + +`formats: [{name?, width, height}]` makes every row once per size: the copy is resized so its auto layout +reflows, named `-` (default `x`), and placed under the row's previous +format. Text `fit` runs after the resize. Use case: one call turns a 1080×1350 post into feed, story +(1080×1920) and square (1080×1080) files for each slide of a carousel; the frame must be auto layout (fixed- +position children keep their place). + +`text.fit {ids?, maxLines?, minSize?, orphans?}` gives a text layer with a fixed width (auto height or fixed size) +the largest font size, down to `minSize` (default 60% of the current), at which it stays within `maxLines` lines +and its box's height. Every style run scales by the same factor. A one-word last line (an orphan) is avoided by +shrinking up to 8% more, else by keeping the last two words together with a no-break space. Text never grows. +Returns `{fitted: [{id, fontSize, lines, fits, orphan}]}`. Use case: a carousel slide "Lighthouse 100. Loads in under +1s." at 96px broke into three lines with "1s." alone; with `fit: {maxLines: 3}` it is two balanced lines. + +```json +{"command": "data.fill", "params": {"id": 80, "dir": "out/carousel", "scale": 1, "rows": [ + {"name": "01-cover", "Headline": "Your website and AI chat assistant in 7 days", "Badge": {"visible": true}}, + {"name": "02-proof", "Headline": "Lighthouse 100, loads in under a second", "Badge": {"visible": false}} +]}} +``` + ### Sections, handoff and arrow keys `run_command` with `object.wrapInSection {ids?, name?}` (Wrap in new section: layers on the page or From 2118480e7af3c50334bf0b4c599ecb6a3ee010bd Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Wed, 21 Oct 2026 20:03:46 +0100 Subject: [PATCH 019/142] Dev Mode: React + Tailwind page code and Tailwind theme export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - inspect.code {language: "tailwind"}: a layer tree as a React component styled with Tailwind CSS v4 utilities, no stylesheet. Auto layout maps to flex utilities on the spacing scale; fixed-width auto layout frames, text and wide layers become fluid (w-full max-w-[…]); instances are inlined with their overrides, except those connected to code (codeConnect), which are their snippet with one import each; bound variables are theme utilities (bg-accent, rounded-card). - mobile: (same layer names): mobile-first code, desktop classes that differ behind lg:, resets for desktop defaults, padding compared per side; layers only on desktop are hidden below lg, those only on mobile are listed in the header. - variable.export {format: "tailwind"}: @import "tailwindcss" and an @theme block (--color-*, --radius-*, --text-*, --spacing-*, --font-*), names unique per file and shared with page code. Builds on the token roles of the CSS export px fix (#1008). --- crates/engine/src/cmd/variables_io.rs | 24 +- crates/engine/src/codegen.rs | 87 ++++- crates/engine/src/lib.rs | 3 + crates/engine/src/tailwind.rs | 490 ++++++++++++++++++++++++++ crates/engine/src/tailwind_tests.rs | 335 ++++++++++++++++++ docs/mcp.md | 16 + 6 files changed, 934 insertions(+), 21 deletions(-) create mode 100644 crates/engine/src/tailwind.rs create mode 100644 crates/engine/src/tailwind_tests.rs diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 0f55ea2c..c74c46fe 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "variable.export", "Export variables", - "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight), path? (write it there)} → {text}, or {path} when written", + "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file), path? (write it there)} → {text}, or {path} when written", false, export ), @@ -46,8 +46,9 @@ fn export(s: &mut Session, p: &Value) -> Result { let text = match string(p, "format").as_deref().unwrap_or("json") { "json" => serde_json::to_string_pretty(&figcraft_format::rest::export_variables(&doc.variables, &colls)) .map_err(|e| EngineError::Other(e.to_string()))?, - "css" => css(doc, &colls), - _ => return Err(bad(cmd, "format: json|css")), + "css" => css(doc, &colls, false), + "tailwind" => css(doc, &colls, true), + _ => return Err(bad(cmd, "format: json|css|tailwind")), }; match string(p, "path") { Some(path) => { @@ -66,16 +67,23 @@ fn css_safe(text: &str) -> String { /// Each collection's variables as custom properties (named as generated code names them): the /// default mode's values on `:root`, every other mode's on `[data-=""]`; /// aliases are `var(--name)`. -fn css(doc: &Document, colls: &[&Collection]) -> String { +fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { let vars = &doc.variables; + // Tailwind theme names, unique per file and the same ones page code reads. + let theme = crate::tailwind::Theme::new(doc); + let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) }; // Lengths need a unit where CSS reads them (`border-radius: var(--radius)`); opacity and // font weight don't. let roles = crate::token_roles::Roles::new(doc); - let mut out = String::new(); + let mut out = if tailwind { "@import \"tailwindcss\";\n\n".to_string() } else { String::new() }; for c in colls { let attr = css_var_name(&c.name); for (i, m) in c.modes.iter().enumerate() { - let sel = if i == 0 { ":root".to_string() } else { format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)) }; + let sel = match i { + 0 if tailwind => "@theme".to_string(), + 0 => ":root".to_string(), + _ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)), + }; out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name))); for v in vars.variables.iter().filter(|v| v.collection == c.id) { let value = match v.values.get(&m.id) { @@ -85,12 +93,12 @@ fn css(doc: &Document, colls: &[&Collection]) -> String { Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)), Some(VariableValue::Boolean(b)) => b.to_string(), Some(VariableValue::Alias(a)) => match vars.get(a) { - Some(t) => format!("var({})", css_var_of(t)), + Some(t) => format!("var({})", name(t)), None => continue, }, None => continue, }; - out.push_str(&format!(" {}: {value};\n", css_var_of(v))); + out.push_str(&format!(" {}: {value};\n", name(v))); } out.push_str("}\n"); } diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 0871f886..8997587c 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { vec![spec!( "inspect.code", "Code for layer", - "{id?, language?: css|html|react|leptos|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", + "{id?, language?: css|html|react|leptos|tailwind|swiftui|compose (default css; html, react and leptos are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React and Leptos; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", false, |s, p| { let cmd = "inspect.code"; @@ -40,7 +40,14 @@ pub fn specs() -> Vec { return Err(bad(cmd, "no such layer")); } let connected = crate::code_connect::link(doc, id, lang).is_some(); - let code = code_for(doc, id, lang); + let mobile = match get(p, "mobile") { + None | Some(serde_json::Value::Null) => None, + Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?), + }; + let code = match (lang, mobile) { + (CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)), + _ => code_for(doc, id, lang), + }; let code = match rem { Some(root) if lang.is_web() => px_to_rem(&code, root), _ => code, @@ -61,18 +68,22 @@ pub enum CodeLang { React, /// A Leptos component (`view!` markup) with its stylesheet. Leptos, + /// A React function component styled with Tailwind CSS v4 utilities (no stylesheet). + Tailwind, SwiftUi, Compose, } impl CodeLang { - pub const ALL: [CodeLang; 6] = [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::SwiftUi, CodeLang::Compose]; + pub const ALL: [CodeLang; 7] = + [CodeLang::Css, CodeLang::Html, CodeLang::React, CodeLang::Leptos, CodeLang::Tailwind, CodeLang::SwiftUi, CodeLang::Compose]; pub fn id(self) -> &'static str { match self { CodeLang::Css => "css", CodeLang::Html => "html", CodeLang::React => "react", CodeLang::Leptos => "leptos", + CodeLang::Tailwind => "tailwind", CodeLang::SwiftUi => "swiftui", CodeLang::Compose => "compose", } @@ -83,13 +94,14 @@ impl CodeLang { CodeLang::Html => "HTML + CSS", CodeLang::React => "React", CodeLang::Leptos => "Leptos", + CodeLang::Tailwind => "React + Tailwind", CodeLang::SwiftUi => "SwiftUI", CodeLang::Compose => "Compose", } } /// Languages whose lengths are CSS pixels (they can be shown in rem). pub fn is_web(self) -> bool { - matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos) + matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Tailwind) } pub fn from_id(s: &str) -> Option { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { @@ -194,6 +206,7 @@ pub fn code_for(doc: &Document, id: NodeId, lang: CodeLang) -> String { match lang { CodeLang::Css => css(doc, id), CodeLang::Html => html(doc, id), + CodeLang::Tailwind => crate::tailwind::code(doc, id, None), CodeLang::React | CodeLang::Leptos | CodeLang::SwiftUi | CodeLang::Compose => crate::component_codegen::code(doc, id, lang), } } @@ -316,25 +329,32 @@ pub(crate) fn indent(depth: usize) -> String { /// The CSS custom properties generated code uses: name → fallback value, in first-use order. #[derive(Default)] -pub(crate) struct Tokens(pub Vec<(String, String)>); +pub(crate) struct Tokens { + vars: Vec<(String, String)>, + /// Name variables as Tailwind theme variables (`--color-accent`), not by their CSS name. + pub tailwind: bool, + /// The file's theme names (computed on first use). + theme: Option, +} impl Tokens { /// `var(--name, value)`, remembering the variable. - fn var(&mut self, variable: &Variable, value: String) -> String { - let name = css_var_of(variable); + fn var(&mut self, doc: &Document, variable: &Variable, value: String) -> String { + let name = + if self.tailwind { self.theme.get_or_insert_with(|| crate::tailwind::Theme::new(doc)).name(variable) } else { css_var_of(variable) }; let out = format!("var({name}, {value})"); - if !self.0.iter().any(|(n, _)| *n == name) { - self.0.push((name, value)); + if !self.vars.iter().any(|(n, _)| *n == name) { + self.vars.push((name, value)); } out } /// A `:root` rule declaring the variables (empty without any). pub fn root_rule(&self) -> String { - if self.0.is_empty() { + if self.vars.is_empty() { return String::new(); } - let decls: String = self.0.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect(); + let decls: String = self.vars.iter().map(|(n, v)| format!(" {n}: {v};\n")).collect(); format!(":root {{\n{decls}}}\n") } } @@ -365,7 +385,7 @@ pub fn css_var_of(v: &Variable) -> String { /// `value`, or a reference to the variable bound to the first of `fields` that has one. fn tok(doc: &Document, n: &Node, fields: &[&str], value: String, t: &mut Tokens) -> String { match fields.iter().find_map(|f| crate::devinspect::bound_variable(doc, n, f)) { - Some(v) => t.var(v, value), + Some(v) => t.var(doc, v, value), None => value, } } @@ -659,6 +679,14 @@ pub(crate) struct Markup<'c, 'a> { /// Each layer's path in the tree (names with sibling counts), class and declarations, so /// variants can be compared layer by layer. pub rules: Vec<(String, String, String)>, + /// Instances as their own (overridden) layers instead of component calls, except those + /// connected to code (page code). + pub inline: bool, + /// Auto layout frames and text as fluid boxes (`width: 100%` up to their width) for pages. + pub fluid: bool, + /// With `fluid`, other layers at least this share of the root's width are fluid too (a + /// divider, a banner image); smaller ones (icons) keep their size. Default: none. + pub fluid_leaves: f64, } impl<'c, 'a> Markup<'c, 'a> { @@ -673,6 +701,9 @@ impl<'c, 'a> Markup<'c, 'a> { root_class: None, root_attrs: String::new(), rules: Vec::new(), + inline: false, + fluid: false, + fluid_leaves: f64::INFINITY, } } @@ -754,7 +785,10 @@ impl<'c, 'a> Markup<'c, 'a> { } } // Nested instances are calls (component code), in a wrapper that places them when needed. - if self.code() && !root && matches!(n.kind, NodeKind::Instance { .. }) { + // Page code inlines instances, except those connected to code (codeConnect.set): their + // snippet, e.g. a design-system `"}), + ); + let p = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 300, "width": 1440, "height": 400, "name": "Landing"}))); + ex(&mut s, "layout.add", json!({"ids": [p], "direction": "vertical"})); + for text in ["Agendar diagnóstico", "Ver exemplos"] { + let i = idv(&ex(&mut s, "instance.create", json!({"component": comp, "x": 0, "y": 0, "parent": p}))); + ex(&mut s, "instance.setProperties", json!({"id": i, "properties": {"Label": text}})); + } + let out = code(&mut s, p, json!({})); + assert_eq!(out.matches("import { Button } from \"@/components/ui/button\";").count(), 1, "{out}"); + assert!(out.contains("`, with each import once at the top. A design + system mapped to shadcn/ui this way exports pages that use the project's own components. Bound variables become theme + utilities (`bg-accent`, `rounded-card`, `text-muted`) that `variable.export {format: "tailwind"}` + declares: `@import "tailwindcss";` and an `@theme` block named by namespace (`--color-*`, + `--radius-*`, `--text-*`, `--spacing-*`, `--font-*`; a number's namespace and unit come from what + layers bind it to), other modes on `[data-=""]`. With `mobile: ` (the + same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the + desktop frame's that differ behind `lg:`, and layers only on desktop `hidden` plus `lg:flex` + (auto layout frames) or `lg:block` (other layers). - **Component code:** `inspect.code {id, language: react|leptos|swiftui|compose}` on a component, a component set or a variant writes the component as a reusable unit: React (TSX) with a props interface, Leptos with a `#[component]` function (`#[prop]` defaults, `view!` markup), SwiftUI From f0e75e183b86b31c16334aea39be2fd9e98768dd Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 21:06:59 +0200 Subject: [PATCH 020/142] M1: swap defaults past the layer cap say so (#981) Instance swap defaults that name an in-file main skipped by the layer cap were reported as "isn't in the file": only instance targets fed the capped-mains list. Defaults now feed it too. --- crates/format/src/fig/layer_cap_tests.rs | 21 +++++++++++++++++++++ crates/format/src/fig/map.rs | 21 +++++++++++++-------- 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/crates/format/src/fig/layer_cap_tests.rs b/crates/format/src/fig/layer_cap_tests.rs index 6eeb15f4..2c562ba7 100644 --- a/crates/format/src/fig/layer_cap_tests.rs +++ b/crates/format/src/fig/layer_cap_tests.rs @@ -103,6 +103,27 @@ fn instances_of_a_main_the_cap_kept_out_say_so() { assert!(!doc.nodes().any(|n| n.name == "Local main")); } +#[test] +fn swap_defaults_the_cap_kept_out_say_so() { + // A kept main's instance swap default names a main with no instances, which comes after + // enough layers to be skipped at the cap. + let swap = o(vec![ + ("id", g(70)), + ("name", Kv::Str("Icon".into())), + ("type", Kv::Enum("INSTANCE_SWAP")), + ("initialValue", o(vec![("guidValue", g(900))])), + ]); + let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])]; + nodes.push(node(50, Some(2), "SYMBOL", "Card", vec![("size", xy(10.0, 10.0)), ("componentPropDefs", Kv::Array(vec![swap]))])); + nodes.extend(main(60, 2, "Filler", 40)); + nodes.extend(main(900, 2, "Icon main", 10)); + let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 30); + assert!(doc.nodes().any(|n| n.name == "Card")); + assert!(!doc.nodes().any(|n| n.name == "Icon main")); + assert!(warnings.iter().any(|w| w.starts_with("instance swap properties whose default component was past the layer limit")), "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); +} + #[test] fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() { // A 1 001-layer library main used 210 times: about 210 000 layers in a file far too small to diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 7eb974da..b18f8a2b 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -45,7 +45,7 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec= m.max_nodes { - let capped: Vec = m.instance_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect(); + let capped: Vec = m.referenced_mains.difference(&m.mapped_mains).map(|id| json!(id)).collect(); if !capped.is_empty() { out[crate::rest::FIG_CAPPED_MAINS_FIELD] = Value::Array(capped); } @@ -397,9 +397,9 @@ struct Mapper<'a> { library_scan_count: usize, warnings: Vec, count: usize, - /// The mains in the file that instances name, and the mains mapped as layers: the difference - /// is what the layer cap kept out. - instance_mains: BTreeSet, + /// The mains in the file that instances or instance swap defaults name, and the mains mapped + /// as layers: the difference is what the layer cap kept out. + referenced_mains: BTreeSet, mapped_mains: BTreeSet, /// Most layers produced or scanned, instance expansion included; the one place the cap comes /// from (see [`super::layer_budget`]). @@ -478,7 +478,7 @@ impl<'a> Mapper<'a> { library_scan_count: 0, warnings: Vec::new(), count: 0, - instance_mains: BTreeSet::new(), + referenced_mains: BTreeSet::new(), mapped_mains: BTreeSet::new(), max_nodes, networks: Vec::new(), @@ -1869,7 +1869,12 @@ impl<'a> Mapper<'a> { "BOOL" => json!({"type": "BOOLEAN", "defaultValue": init.and_then(|i| i.get("boolValue")).and_then(Kv::bool).unwrap_or(true)}), "TEXT" => json!({"type": "TEXT", "defaultValue": init.and_then(prop_text).unwrap_or("")}), "INSTANCE_SWAP" => match init.and_then(|i| i.get("guidValue")).and_then(guid) { - Some(g) => json!({"type": "INSTANCE_SWAP", "defaultValue": g}), + Some(g) => { + if self.nodes.contains_key(&g) { + self.referenced_mains.insert(g.clone()); + } + json!({"type": "INSTANCE_SWAP", "defaultValue": g}) + } None => continue, }, "VARIANT" => json!({"type": "VARIANT"}), @@ -2013,8 +2018,8 @@ impl<'a> Mapper<'a> { self.warn("instances of components that aren't in the file show no layers"); return None; } - if !self.instance_mains.contains(&sym) { - self.instance_mains.insert(sym.clone()); + if !self.referenced_mains.contains(&sym) { + self.referenced_mains.insert(sym.clone()); } if outer.is_some_and(|i| i.symbols.contains(&sym)) { return None; From 9f2470b5bd132ac0fb889b21fbf8cdb4726b5f2e Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Wed, 21 Oct 2026 21:00:02 +0100 Subject: [PATCH 021/142] Tailwind theme names: resolve collisions in linear time Clash detection compared every variable with every other, and suffixes rescanned the taken list, so a file with very many variables made the export quadratic. Counts in a HashMap, taken names in a HashSet and a next suffix per base keep it linear. --- crates/engine/src/tailwind.rs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/tailwind.rs b/crates/engine/src/tailwind.rs index 7f54c8aa..2a6453e4 100644 --- a/crates/engine/src/tailwind.rs +++ b/crates/engine/src/tailwind.rs @@ -32,14 +32,30 @@ impl Theme { let mut t = Theme { roles, names: HashMap::new() }; // Short names ("accent") where they are unique, the full path ("palette-accent") where two // variables would share one, and a numeric suffix as a last resort. - let short: Vec<(VariableId, String)> = vars.iter().map(|v| (v.id.clone(), t.candidate(v, false))).collect(); - let mut taken: Vec = Vec::new(); - for (v, (id, s)) in vars.iter().zip(&short) { - let clash = short.iter().any(|(o, n)| o != id && n == s); - let base = if clash { t.candidate(v, true) } else { s.clone() }; - let name = (1..).map(|k| if k == 1 { base.clone() } else { format!("{base}-{k}") }).find(|n| !taken.contains(n)).unwrap_or(base); - taken.push(name.clone()); - t.names.insert(id.clone(), name); + let short: Vec = vars.iter().map(|v| t.candidate(v, false)).collect(); + // Linear in the number of variables: counts for clashes, a set of names taken and the next + // suffix per base, so a file with very many variables can't make this quadratic. + let mut count: HashMap<&str, usize> = HashMap::new(); + for n in &short { + *count.entry(n.as_str()).or_default() += 1; + } + let mut taken: std::collections::HashSet = std::collections::HashSet::new(); + let mut next: HashMap = HashMap::new(); + for (v, s) in vars.iter().zip(&short) { + let base = if count.get(s.as_str()).copied().unwrap_or(0) > 1 { t.candidate(v, true) } else { s.clone() }; + let mut name = base.clone(); + if taken.contains(&name) { + let k = next.entry(base.clone()).or_insert(1); + loop { + *k += 1; + name = format!("{base}-{k}"); + if !taken.contains(&name) { + break; + } + } + } + taken.insert(name.clone()); + t.names.insert(v.id.clone(), name); } t } From 82b05803b18f6ebfd38e423aabf90b95cc4995f1 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Wed, 21 Oct 2026 22:48:51 +0200 Subject: [PATCH 022/142] M1: a refused browser download keeps the unsaved changes (#969) `file.saveAs {data: true}` marked the file saved before the UI handed the bytes to `Services::download`. When the download was refused, the toast said "Couldn't download" but the document was clean, so closing it no longer warned and the edits could be lost. The data result now leaves the save state alone. A new `file.markSaved` (the bookkeeping a native save already did: the file and its branch tabs) runs from `save_as_dialog` only once the download was delivered. It ends a version preview like the other commands that act on the document itself. Tests: the engine save test checks the file stays dirty until `file.markSaved`; `a_refused_download_says_so` checks the document is still dirty. --- crates/engine/src/cmd/file.rs | 39 +++++++++++++++++++--------- crates/engine/src/lib.rs | 3 ++- crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/web_save_tests.rs | 1 + 5 files changed, 32 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d5023372..aa2ea340 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -55,6 +55,13 @@ pub fn specs() -> Vec { false, save_as ), + spec!( + "file.markSaved", + "Mark saved", + "{} clears the unsaved changes of the active tab's file and its branch tabs: run it once the file from `file.saveAs {data: true}` was delivered (a refused download keeps them)", + false, + mark_saved + ), spec!( "file.export", "Export", @@ -869,24 +876,30 @@ fn save_to(s: &mut Session, path: Option) -> Result { Arc::make_mut(&mut snapshot.doc).name = file_stem(path); } let bytes = snapshot.encode()?; - if let Some(path) = &path { - write(path, &bytes)?; - } - ds.doc = snapshot.doc; - if path.is_some() { - ds.path.clone_from(&path); - } - ds.mark_saved(); - ds.revision += 1; - let (uid, name) = (ds.uid, format!("{}.figcraft", ds.doc.name)); - branch_tabs_saved(s, uid); let Some(path) = path else { + // The download can still be refused: the caller marks the file saved once it was delivered. + let name = format!("{}.figcraft", ds.doc.name); return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); }; + write(&path, &bytes)?; + ds.doc = snapshot.doc; + ds.path = Some(path.clone()); + mark_saved(s, &json!({}))?; remember(s, &path); Ok(json!({"path": path, "bytes": bytes.len()})) } +/// The file of the active tab (with its branch tabs) now matches what was written or delivered. +fn mark_saved(s: &mut Session, _: &Value) -> Result { + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + ds.mark_saved(); + ds.revision += 1; + let uid = ds.uid; + branch_tabs_saved(s, uid); + Ok(json!({"saved": true})) +} + fn save(s: &mut Session, p: &Value) -> Result { if let Some(path) = string(p, "path") { return save_to(s, Some(path)); @@ -1590,7 +1603,7 @@ mod rest_import_tests { } #[test] - fn save_as_data_hands_back_the_file_and_marks_it_saved() { + fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { // The browser has no paths: Save as returns the file for a download (#969). let mut s = Session::new(); s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); @@ -1598,6 +1611,8 @@ mod rest_import_tests { let recent = s.prefs.recent_files.clone(); let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); assert_eq!(r["name"], "Untitled.figcraft"); + assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); + s.execute("file.markSaved", &json!({})).unwrap(); assert!(!s.ds().unwrap().is_dirty()); assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 79e7be0d..e32d7543 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -402,7 +402,8 @@ pub const PREVIEW_READ_ONLY: &str = "you're viewing an earlier version: restore /// Commands that first end a version preview: they act on the document itself. Saving does so /// only with a native destination; export copies and pending format choices keep the preview. -const ENDS_PREVIEW: &[&str] = &["file.save", "file.saveAs", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; +const ENDS_PREVIEW: &[&str] = + &["file.save", "file.saveAs", "file.markSaved", "file.close", "file.revert", "file.saveVersion", "file.duplicate", "version.restore"]; static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index bd572dbe..25f3f258 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1920,6 +1920,7 @@ status = "draft — needs native review" "file.revert" = "Volver a lo guardado" "file.save" = "Guardar" "file.saveAs" = "Guardar como" +"file.markSaved" = "Marcar como guardado" "file.saveVersion" = "Guardar en el historial de versiones" "file.versionHistory" = "Mostrar historial de versiones" "fill.add" = "Añadir relleno" diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 788bbf4f..0707723d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1146,6 +1146,7 @@ impl FigApp { if let Some(r) = self.run("file.saveAs", json!({"data": true})) && self.download(r["name"].as_str().unwrap_or_default(), &r) { + self.run("file.markSaved", json!({})); dialogs::file_saved(self, &r); } return; diff --git a/crates/ui-egui/src/web_save_tests.rs b/crates/ui-egui/src/web_save_tests.rs index 5fbb2524..4879add1 100644 --- a/crates/ui-egui/src/web_save_tests.rs +++ b/crates/ui-egui/src/web_save_tests.rs @@ -52,6 +52,7 @@ fn a_refused_download_says_so() { app.save_as_dialog(); assert_eq!(got.lock().unwrap().len(), 1); assert_eq!(toast(&app), "Couldn't download Untitled.figcraft"); + assert!(app.session.ds().unwrap().is_dirty(), "no file arrived: closing must still warn about the edits"); } #[test] From 85010e491afbb1fd1cbbd94281e7e8a92884015f Mon Sep 17 00:00:00 2001 From: RealtaNua Date: Wed, 21 Oct 2026 09:52:50 +0700 Subject: [PATCH 023/142] M7: missing fonts: follow each font variable's aliases once (#752) A file whose font family variables alias each other in several modes made the missing-fonts walk branch in every mode, billions of steps before the depth limit stopped it, freezing the app on open and `figcraft-cli info`. The walk now visits each variable once. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/fonts.rs | 12 ++++++++---- crates/engine/src/fonts_tests.rs | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 672d4f2c..bdda411c 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -64,16 +64,20 @@ pub fn missing(doc: &Document) -> Vec { used.into_values().filter(|(family, _)| !figcraft_text::has_family(family)).map(|(family, layers)| Missing { family, layers }).collect() } -/// Every string value of variable `id` in any mode, following aliases (at most 32 deep). +/// Every string value of variable `id` in any mode, following aliases (each variable once). fn string_values(vars: &Variables, id: &str) -> Vec { let mut out = Vec::new(); - let mut todo = vec![(id.to_string(), 0)]; - while let Some((id, depth)) = todo.pop() { + let mut seen = BTreeSet::new(); + let mut todo = vec![id.to_string()]; + while let Some(id) = todo.pop() { + if !seen.insert(id.clone()) { + continue; + } let Some(var) = vars.get(&id) else { continue }; for v in var.values.values() { match v { VariableValue::String(s) => out.push(s.clone()), - VariableValue::Alias(a) if depth < 32 => todo.push((a.clone(), depth + 1)), + VariableValue::Alias(a) => todo.push(a.clone()), _ => {} } } diff --git a/crates/engine/src/fonts_tests.rs b/crates/engine/src/fonts_tests.rs index 0fe6dcb3..5df9a854 100644 --- a/crates/engine/src/fonts_tests.rs +++ b/crates/engine/src/fonts_tests.rs @@ -119,6 +119,25 @@ fn blank_families_and_aliases_are_handled() { assert_eq!(families(&mut s), vec![("FigCraft Missing F".to_string(), 1), ("FigCraft Missing G".to_string(), 1)]); } +/// Aliases in several modes are followed once per variable: a chain that branches in every mode +/// would otherwise grow to billions of steps (a file can hold one). +#[test] +fn branching_alias_chains_are_walked_once_per_variable() { + let mut s = Session::new(); + s.execute("variable.createCollection", &json!({"name": "Type"})).unwrap(); + for i in 0..32 { + s.execute("variable.create", &json!({"collection": "Type", "name": format!("V{i}"), "type": "string", "value": "x"})).unwrap(); + } + let doc = s.doc_mut().unwrap(); + let ids: Vec = doc.variables.variables.iter().map(|v| v.id.clone()).collect(); + for (i, var) in doc.variables.variables.iter_mut().enumerate() { + let next = ids.get(i + 1).cloned().unwrap_or_else(|| "FigCraft Missing H".into()); + let leaf = |v: String| if i + 1 < ids.len() { VariableValue::Alias(v) } else { VariableValue::String(v) }; + var.values = [("m1".to_string(), leaf(next.clone())), ("m2".to_string(), leaf(next))].into(); + } + assert_eq!(string_values(&doc.variables, &ids[0]), vec!["FigCraft Missing H", "FigCraft Missing H"]); +} + #[test] fn needs_an_open_document() { let mut s = Session::empty(); From 0f85c2294e4637d2e8b09faea048a8469a382b56 Mon Sep 17 00:00:00 2001 From: F3R9 <214804886+F3R9@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:40:52 +0300 Subject: [PATCH 024/142] M9: avoid read-only snapshot rewrites and duplicate buffers Validated formatting, workspace Clippy, workspace tests excluding the two pre-existing localized startup assertion failures tracked in #1017, layers, assets, WASM and plugins. The startup tests are unchanged in this independent fix. --- ROADMAP.md | 5 +++ apps/figcraft-server/src/disk.rs | 9 +++--- apps/figcraft-server/src/store.rs | 14 ++++++--- apps/figcraft-server/src/store_tests.rs | 41 +++++++++++++++++++++++++ crates/collab/src/codec_tests.rs | 10 ++++++ crates/collab/src/message.rs | 12 +++++--- docs/server-memory-retention.md | 23 ++++++++++++++ 7 files changed, 100 insertions(+), 14 deletions(-) create mode 100644 docs/server-memory-retention.md diff --git a/ROADMAP.md b/ROADMAP.md index f43cd015..cdabaeca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,10 @@ # FigCraft Roadmap +M9 / M7.4 server memory follow-up: read-only disconnects retain their durable client-id journal +without rebuilding the image-bearing snapshot. Snapshot encoding appends into its header buffer, +avoiding a second full payload copy while preserving the existing disk format. See +[`docs/server-memory-retention.md`](docs/server-memory-retention.md). + **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-17 · **Change:** minor (community batch #753: Selection colors, smart-selection spacing, a Present tab; language table re-measured from the catalogs; community fixes from #524 up (55 PRs: `.fig` import fidelity, paste from other apps, the Actions palette, editor fixes, server limits) and a boolean-op crash guard; number variables in the text content picker (#629); frames and sections convert into each other (#719); bounded canvas pan overscan; community fixes from #478 up and a control-channel fix (#589); bounded canvas pan overscan; Typography spacing icons support scrubbing (#373); font scans skip named pipes; system theme on first run; recent files as cards with thumbnails on the home screen; community fixes: lost images reported on loading (#207), SVG import budgets (#190), Leptos code (#223), full screen (#227), server snapshots readable again (#188), owner-only preferences (#200), ruzstd 0.9.1 (#182), layer cycle repair (#205), batch rollback, Direct3D 12 on Windows, plugin metering (#192); vector edit mode regions: hover hatch, selection and drag; SVG/PDF exports refuse over-depth documents (#208); bounded MCP/control framing, connections and queues (#201); plugin log limits (#191); live-session revert guard (#198); attached MCP no longer replays after a lost reply (#194); repeat command recursion fix; the Layout panel's constrain-proportions link scales the other side, #220; core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; starter templates, a Templates tab and Getting started tips on the home screen; your online files; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages; 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; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; 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; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; Windows QA pass with eight fixes; auto layout behaviour suite and fixes; 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; feature breadth ~80%; ready ~49%) · **Target:** Figma Design (desktop app 126.10.7) diff --git a/apps/figcraft-server/src/disk.rs b/apps/figcraft-server/src/disk.rs index af6e0f99..b0699736 100644 --- a/apps/figcraft-server/src/disk.rs +++ b/apps/figcraft-server/src/disk.rs @@ -127,13 +127,14 @@ fn u64_at(b: &[u8], at: usize) -> Option { /// A snapshot file's bytes. pub fn encode_snapshot(generation: u64, state: &SessionState) -> Result, String> { - let body = message::encode(state).map_err(|e| e.to_string())?; - let mut out = Vec::with_capacity(SNAP_HEADER + body.len()); + let mut out = Vec::with_capacity(SNAP_HEADER); out.extend_from_slice(SNAP_MAGIC); out.push(FORMAT); out.extend_from_slice(&generation.to_le_bytes()); - out.extend_from_slice(&crc(&body).to_le_bytes()); - out.extend_from_slice(&body); + out.extend_from_slice(&0u32.to_le_bytes()); + let mut out = message::encode_into(state, out).map_err(|e| e.to_string())?; + let sum = crc(&out[SNAP_HEADER..]); + out[13..SNAP_HEADER].copy_from_slice(&sum.to_le_bytes()); Ok(out) } diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index ea9bc626..1fadf3a1 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -1385,14 +1385,18 @@ impl Backend for Store { } fn idle(&mut self, file: &str, server: &mut Server) -> bool { - let dirty = self.files.get_mut(file).and_then(|e| e.log.as_mut()).is_some_and(|log| { - let _ = flush_log(file, log); - log.dirty + let compact = self.files.get_mut(file).is_some_and(|entry| { + let snapshot_seq = entry.meta.seq; + entry.log.as_mut().is_some_and(|log| { + // Issued client ids and refusals are durable in the log; only edits need a + // new image-bearing snapshot. Still try a snapshot if syncing the log failed. + flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq) + }) }); - if dirty { + if compact { self.compact(file, server); } - // It closes anyway, everything in its snapshot: a compaction cut short needs no report. + // It closes anyway, with its state in the snapshot and durable log. self.cut_short.retain(|f| f != file); if let Some(e) = self.files.get_mut(file) { e.log = None; diff --git a/apps/figcraft-server/src/store_tests.rs b/apps/figcraft-server/src/store_tests.rs index f380568c..2bbbaf0e 100644 --- a/apps/figcraft-server/src/store_tests.rs +++ b/apps/figcraft-server/src/store_tests.rs @@ -127,6 +127,34 @@ fn a_file_survives_a_crash_and_compacts() { assert_eq!(store.list(&|_| None)[0].seq, seq, "the description follows"); } +#[test] +fn read_only_sessions_keep_client_ids_without_rewriting_the_snapshot() { + let dir = TempDir::new("read-only-reconnects"); + let c = StoreConfig { compact_after: 1000, ..cfg(dir.path()) }; + let (id, original) = { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let (id, _, _) = publish(&mut store, "Read only"); + let bytes = fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(); + (id, bytes) + }; + let mut last_client = 0; + for _ in 0..6 { + let mut store = Store::open(dir.path(), c.clone()).unwrap(); + let mut server = store.open(&id).unwrap(); + let client = join(&mut server, Access::View); + assert!(client.id() > last_client, "issued ids survive closing and reopening the store"); + last_client = client.id(); + store.changed(&id, &mut server); + // Idle must sync even if no periodic flush ran before the connection closed. + assert!(store.idle(&id, &mut server)); + assert_eq!(store.compactions(), 0, "a client id does not require rebuilding the image-bearing snapshot"); + assert_eq!(fs::read(dir.path().join(FILES).join(&id).join(SNAPSHOT)).unwrap(), original); + let log = disk::read_log(&fs::read(dir.path().join(FILES).join(&id).join(LOG)).unwrap()).unwrap(); + assert!(!log.entries.is_empty()); + assert_eq!(server.seq(), 0); + } +} + #[test] fn a_long_log_is_compacted_while_open() { let dir = TempDir::new("compact"); @@ -241,6 +269,19 @@ fn temporary_directories_are_removed() { assert!(!path.exists()); } +#[test] +fn snapshot_encoding_keeps_the_existing_framing_and_checksum() { + let state = Server::new(doc("Snapshot framing"), "", figcraft_collab::Limits::default()).state().unwrap(); + let body = figcraft_collab::message::encode(&state).unwrap(); + let mut legacy = b"FCSN\x01".to_vec(); + legacy.extend_from_slice(&37u64.to_le_bytes()); + legacy.extend_from_slice(&crc32fast::hash(&body).to_le_bytes()); + legacy.extend_from_slice(&body); + let encoded = disk::encode_snapshot(37, &state).unwrap(); + assert_eq!(encoded, legacy); + assert_eq!(disk::decode_snapshot(&encoded).unwrap(), (37, state)); +} + #[test] fn random_bytes_never_panic_the_readers() { let mut rng = Rng::new(7); diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 9f5d12fb..30d41490 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -11,6 +11,16 @@ use crate::message::{ use crate::sim::Rng; use crate::{Error, Replica, diff}; +#[test] +fn encoding_into_a_prefix_keeps_the_wire_body() { + let msg = moved_layer(); + let prefix = b"container header".to_vec(); + let bytes = crate::message::encode_into(&msg, prefix.clone()).unwrap(); + assert_eq!(&bytes[..prefix.len()], prefix); + assert_eq!(&bytes[prefix.len()..], encode(&msg).unwrap()); + assert_eq!(decode::(&bytes[prefix.len()..], bytes.len()).unwrap(), msg); +} + /// A typical edit: one layer dragged (its transform). fn moved_layer() -> ClientMsg { let mut doc = Document::new("f"); diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 4fda776a..6a1a0e1e 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -732,11 +732,13 @@ pub fn utc_date(secs: u64) -> String { /// The binary form of `msg`: [`WIRE_VERSION`], then postcard. pub fn encode(msg: &T) -> Result, Error> { - let body = postcard::to_allocvec(msg).map_err(|e| Error::Encode(e.to_string()))?; - let mut out = Vec::with_capacity(body.len() + 1); - out.push(WIRE_VERSION); - out.extend(body); - Ok(out) + encode_into(msg, Vec::new()) +} + +/// Append the binary form of `msg` to an owned prefix, without a second payload-sized buffer. +pub fn encode_into(msg: &T, mut prefix: Vec) -> Result, Error> { + prefix.push(WIRE_VERSION); + postcard::to_extend(msg, prefix).map_err(|e| Error::Encode(e.to_string())) } /// Read a binary message of at most `max_bytes` (untrusted input: never panics, allocates at most diff --git a/docs/server-memory-retention.md b/docs/server-memory-retention.md new file mode 100644 index 00000000..ef4e0b94 --- /dev/null +++ b/docs/server-memory-retention.md @@ -0,0 +1,23 @@ +# Server memory follow-up + +Joining a persisted session issues a client id and makes its journal dirty, even if nobody edits +the document. Previously, the last client leaving caused a full snapshot rewrite for that id. +For image-heavy documents this creates large temporary allocations and unnecessary disk writes; +an allocator can retain the freed memory after the room closes. + +On idle, the server now flushes the journal and only compacts when the document sequence is newer +than the stored snapshot, or when flushing failed and a snapshot must be attempted as a fallback. +Issued ids and refusals remain durable in the journal. Existing size/count compaction thresholds +remain in effect, and edited documents still compact when the last participant leaves. + +Snapshot encoding also appends the versioned postcard body directly to the snapshot header's +buffer, then fills the checksum. It no longer holds a separate complete encoded body alongside +the finished snapshot. The bytes, generation, checksum and file format are unchanged. + +Regression coverage uses synthetic documents: six read-only sessions across store reopenings +must preserve increasing client ids without rewriting the snapshot; exact legacy framing and +checksum must match; the binary message body must be unchanged when appended to a prefix. +Existing crash recovery, damaged-input and edit compaction tests continue to apply. + +This reduces unnecessary temporary allocations. It does not promise that the operating system +immediately reclaims every freed allocation, or that all large-document memory costs are solved. From b8a5f76471f6048e27db9406657c1e63a7ffd18a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:29:04 +0200 Subject: [PATCH 025/142] Keep context menu open after a same-frame trackpad tap Fixes #1139 Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/guides.rs | 1 + crates/ui-egui/src/layers.rs | 1 + crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/src/menus.rs | 6 +++++- crates/ui-egui/src/palette_tests.rs | 23 +++++++++++++++++++++++ 6 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 46451ae0..06bd3d52 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -685,6 +685,7 @@ pub fn open_context_menu(app: &mut FigApp, pos: Pos2, deep: bool) { } } app.ui.context_menu = Some((pos, app.session.selection())); + app.ui.context_menu_shown = false; } fn stroke(c: Color32, w: f32) -> Stroke { diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs index 69ab8ee8..befc9b53 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -268,6 +268,7 @@ pub fn input(app: &mut FigApp, ctx: &egui::Context, pos: Pos2) -> bool { app.run("guide.select", owned(g.owner, page, json!({"index": g.index}))); if secondary { app.ui.context_menu = Some((pos, Vec::new())); + app.ui.context_menu_shown = false; } else { ctx.data_mut(|d| { d.insert_temp(drag_id(), GuideDrag::Move { owner: g.owner, index: g.index, vertical: g.vertical, copy: alt, frame }) diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index f172b309..3e7fae7f 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -962,6 +962,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } let pos = ui.input(|i| i.pointer.hover_pos()).unwrap_or(r.center()); app.ui.context_menu = Some((pos, vec![row.id])); + app.ui.context_menu_shown = false; } // Drag to reorder. if resp.drag_started() { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c93333b3..5f5516a2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -513,6 +513,8 @@ pub struct UiState { pub palette_hi: usize, /// The palette ignores one outside click after opening (the click that opened it). pub palette_armed: bool, + /// The context menu has been drawn at least once (its opening frame's press is spared). + pub context_menu_shown: bool, /// This frame's Actions toolbar button rect: presses on it toggle, never dismiss. pub actions_rect: Option, /// The Variables editor, when open. @@ -681,6 +683,7 @@ impl Default for UiState { palette_tab: menus::PaletteTab::All, palette_hi: 0, palette_armed: false, + context_menu_shown: false, actions_rect: None, variables: None, about: false, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9659c6b3..87aa02d1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -945,11 +945,15 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { } }); }); - let clicked_outside = ctx.input(|i| i.pointer.any_pressed()) && !area.response.hovered() && !over_menu(ctx); + // The press of a tap that opened the menu (press and release in one frame) is spared. + let fresh = !std::mem::replace(&mut app.ui.context_menu_shown, true); + let clicked_outside = !fresh && ctx.input(|i| i.pointer.any_pressed()) && !area.response.hovered() && !over_menu(ctx); if close || clicked_outside || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.context_menu = None; forget_tree(ctx, ctx_menu_id()); } + } else { + app.ui.context_menu_shown = false; } crate::colorpicker::show(app, ctx); palette(app, ctx); diff --git a/crates/ui-egui/src/palette_tests.rs b/crates/ui-egui/src/palette_tests.rs index cfe9c1cd..b9827867 100644 --- a/crates/ui-egui/src/palette_tests.rs +++ b/crates/ui-egui/src/palette_tests.rs @@ -161,3 +161,26 @@ fn arrows_move_the_highlight_and_enter_runs_it() { assert!(u.app.ui.palette.is_none(), "Enter runs a row and closes the palette"); assert_eq!(u.app.session.prefs.recent_actions.first(), Some(&want), "the highlighted row ran"); } + +/// A trackpad tap sends the press and the release in one frame; the press must not close a +/// context menu the release just opened, but a later outside press still does. +#[test] +fn context_menu_survives_a_same_frame_tap() { + let mut u = Ui::new(Session::new()); + let p = Pos2::new(1000.0, 600.0); + let b = |pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + u.frame(vec![Event::PointerMoved(p), b(true), b(false)]); + assert!(u.app.ui.context_menu.is_some(), "the opening tap's press is spared"); + u.frame(vec![]); + assert!(u.app.ui.context_menu.is_some(), "the menu stays open"); + // A second tap elsewhere while the menu is open replaces it and survives too. + let q = Pos2::new(600.0, 400.0); + let b2 = |pressed| Event::PointerButton { pos: q, button: egui::PointerButton::Secondary, pressed, modifiers: Default::default() }; + u.frame(vec![Event::PointerMoved(q), b2(true), b2(false)]); + u.frame(vec![]); + assert!(u.app.ui.context_menu.is_some(), "a repeat tap keeps the replaced menu open"); + let far = Pos2::new(100.0, 100.0); + u.button(far, true); + u.button(far, false); + assert!(u.app.ui.context_menu.is_none(), "a later outside press closes the menu"); +} From 5a3dddc46fe1064b6e2e2b8ff04d220d0c0f9706 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 22 Oct 2026 21:29:58 +0200 Subject: [PATCH 026/142] M9: fall back to the Noto symbol and math fonts on Linux (#1156) Signs such as U+2316 and U+2912 are in no text face. The fallback list had the macOS and Windows symbol fonts (Apple Symbols, Segoe UI Symbol) but nothing a Linux install has, so they drew as empty boxes there even with Noto Sans Symbols 2 or Noto Sans Math installed. Adds Noto Sans Symbols, Noto Sans Symbols 2 (and the Symbols2 spelling some packages use) and Noto Sans Math after them. --- crates/text/src/behaviour_tests.rs | 22 ++++++++++++++++++++++ crates/text/src/fontdb.rs | 10 ++++++++-- crates/text/src/fontdb_tests.rs | 13 +++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index b3cb4884..95491fed 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -265,6 +265,28 @@ fn hangul_comes_from_a_korean_font_when_one_is_installed() { } } +#[test] +fn symbols_come_from_a_symbol_font_when_one_is_installed() { + // ⌖ (U+2316) and ⤒ (U+2912) are in no Latin text face. With a symbol font that has them (Apple + // Symbols on macOS, Segoe UI Symbol on Windows, Noto Sans Symbols 2 and Noto Sans Math on + // Linux) the fallback draws them instead of the missing-glyph box (#1156). Each character + // skips on a machine with no installed fallback family that has it. + let Some(f) = face() else { return }; + let families = ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Math"]; + for c in ['\u{2316}', '\u{2912}'] { + if f.has_char(c) || !families.iter().any(|k| find_face(k, "Regular").is_some_and(|k| k.has_char(c))) { + continue; + } + let t = text(&format!("A {c}"), TextAutoResize::WidthAndHeight); + let l = layout(&t, None, None); + let drawn: Vec<_> = l.glyphs.iter().filter(|g| g.char_start == 2).collect(); + assert!(!drawn.is_empty(), "U+{:04X} has a glyph", c as u32); + for g in drawn { + assert!(g.face.has_char(c), "U+{:04X} drawn by {} {}, which lacks it", c as u32, g.face.family, g.face.style); + } + } +} + #[test] fn break_opportunities() { // (before, after, may break between, derivation) diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 604f3eee..c3ae3abd 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -405,8 +405,10 @@ fn names(f: &skrifa::FontRef) -> (Option, Option) { (family, style) } -/// Families tried, in order, for characters a text's own font lacks. -const FALLBACK_FAMILIES: [&str; 20] = [ +/// Families tried, in order, for characters a text's own font lacks. The symbol fonts come last: +/// Apple Symbols (macOS), Segoe UI Symbol (Windows) and the Noto symbol and math fonts (Linux), for +/// signs such as ⌖ and ⤒ that no text face has. +const FALLBACK_FAMILIES: [&str; 24] = [ "Inter", "Helvetica Neue", "Arial", @@ -427,6 +429,10 @@ const FALLBACK_FAMILIES: [&str; 20] = [ "Noto Sans Arabic", "Apple Symbols", "Segoe UI Symbol", + "Noto Sans Symbols", + "Noto Sans Symbols 2", + "Noto Sans Symbols2", + "Noto Sans Math", ]; /// A system font file entry not yet loaded. diff --git a/crates/text/src/fontdb_tests.rs b/crates/text/src/fontdb_tests.rs index f2456d28..f227910e 100644 --- a/crates/text/src/fontdb_tests.rs +++ b/crates/text/src/fontdb_tests.rs @@ -192,6 +192,19 @@ fn fallback_faces_follow_the_runs_style() { } } +#[test] +fn every_platform_has_a_symbol_font_in_the_fallbacks() { + // Symbols such as ⌖ (U+2316) and ⤒ (U+2912) are in no text face (#1156): macOS has Apple + // Symbols, Windows Segoe UI Symbol, and Linux the Noto symbol and math fonts (fontconfig names + // "Noto Sans Symbols 2"; some packages name it "Noto Sans Symbols2"). + for family in ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Symbols2", "Noto Sans Math"] { + assert!(FALLBACK_FAMILIES.contains(&family), "{family} is a fallback"); + } + // Text faces come first: a symbol font only draws what they lack. + let at = |f: &str| FALLBACK_FAMILIES.iter().position(|x| *x == f).unwrap(); + assert!(at("Noto Sans") < at("Noto Sans Symbols") && at("DejaVu Sans") < at("Noto Sans Math")); +} + #[cfg(all(unix, not(target_os = "macos")))] #[test] fn user_font_dirs_follow_xdg_data_home() { From c7d2a5f019d0ec8fd1fef01f344ac0b6042b37e8 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 22 Oct 2026 21:46:12 +0200 Subject: [PATCH 027/142] M9: the symbol test also looks for the Noto Sans Symbols2 spelling (#1156) --- crates/text/src/behaviour_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index 95491fed..4db53184 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -272,7 +272,7 @@ fn symbols_come_from_a_symbol_font_when_one_is_installed() { // Linux) the fallback draws them instead of the missing-glyph box (#1156). Each character // skips on a machine with no installed fallback family that has it. let Some(f) = face() else { return }; - let families = ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Math"]; + let families = ["Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols", "Noto Sans Symbols 2", "Noto Sans Symbols2", "Noto Sans Math"]; for c in ['\u{2316}', '\u{2912}'] { if f.has_char(c) || !families.iter().any(|k| find_face(k, "Regular").is_some_and(|k| k.has_char(c))) { continue; From 6cdc36f6c3672939ee34145455e73dd17479c698 Mon Sep 17 00:00:00 2001 From: saturngod Date: Thu, 22 Oct 2026 16:03:23 -0500 Subject: [PATCH 028/142] Fix stroke-only boolean operand outlines --- crates/render/src/boolean_cache_tests.rs | 100 +++++++++++++++++++++++ crates/render/src/draw.rs | 36 +++++++- 2 files changed, 132 insertions(+), 4 deletions(-) diff --git a/crates/render/src/boolean_cache_tests.rs b/crates/render/src/boolean_cache_tests.rs index 08a89d3a..6e10f746 100644 --- a/crates/render/src/boolean_cache_tests.rs +++ b/crates/render/src/boolean_cache_tests.rs @@ -153,3 +153,103 @@ fn deep_boolean_nesting_stops_at_the_outline_depth_limit() { d.create(inner, NodeKind::Rectangle, "R", 40.0, 10.0, 20.0, 20.0).unwrap(); assert!((outline(&d, b).area().abs() - 2.0 * 20.0 * 20.0).abs() < 0.5); } + +#[test] +fn stroke_only_boolean_operands_keep_their_holes() { + use figcraft_doc::StrokeAlign; + use figcraft_geom::Point; + + for kind in [NodeKind::Polygon { count: 6 }, NodeKind::Rectangle, NodeKind::Line] { + for align in [StrokeAlign::Inside, StrokeAlign::Center, StrokeAlign::Outside] { + let mut d = Document::new("stroke operands"); + let page = d.first_page().unwrap(); + let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Subtract }, "B", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::BLACK)]; + // A nested group exercises the same path as a direct operand. + let g = d.create(b, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + let id = d.create(g, kind.clone(), "stroke", 10.0, 10.0, 80.0, 80.0).unwrap(); + let n = d.get_mut(id).unwrap(); + let mut hidden_fill = Paint::solid(Color::BLACK); + hidden_fill.visible = false; + n.fills = vec![hidden_fill]; + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 6.0; + n.stroke.align = align; + let path = outline(&d, b); + assert_eq!(path.winding(Point::new(50.0, 45.0)), 0, "{kind:?} {align:?}: empty interior"); + assert!(path.area().abs() > 100.0, "{kind:?} {align:?}: stroke survives"); + assert!(!dark(&render(&d, page), 50, 45)); + if !matches!(kind, NodeKind::Line) { + // Changing only stroke weight invalidates the boolean result. + let area = path.area().abs(); + d.get_mut(id).unwrap().stroke.weight = 3.0; + assert!(outline(&d, b).area().abs() < area); + d.get_mut(id).unwrap().fills[0].visible = true; + assert_ne!(outline(&d, b).winding(Point::new(50.0, 45.0)), 0, "visible fills keep the interior"); + } + } + } +} + +#[test] +fn boolean_operands_preserve_side_borders_and_container_children() { + use figcraft_doc::StrokeAlign; + use figcraft_geom::Point; + for kind in [NodeKind::Rectangle, NodeKind::Frame] { + let mut d = Document::new("borders"); + let page = d.first_page().unwrap(); + let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 100.0, 100.0).unwrap(); + let id = d.create(b, kind.clone(), "operand", 0.0, 0.0, 100.0, 100.0).unwrap(); + let n = d.get_mut(id).unwrap(); + n.fills.clear(); + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 8.0; + n.stroke.align = StrokeAlign::Inside; + n.stroke.sides = Some([8.0, 0.0, 0.0, 0.0]); + if matches!(kind, NodeKind::Frame) { + d.create(id, NodeKind::Rectangle, "child", 40.0, 40.0, 20.0, 20.0).unwrap(); + } + let p = outline(&d, b); + assert_ne!(p.winding(Point::new(50.0, 4.0)), 0, "top border"); + assert_eq!(p.winding(Point::new(98.0, 50.0)), 0, "no right border"); + assert_eq!(p.winding(Point::new(50.0, 98.0)), 0, "no bottom border"); + if matches!(kind, NodeKind::Frame) { + assert_ne!(p.winding(Point::new(50.0, 50.0)), 0, "child retained"); + } + d.get_mut(id).unwrap().stroke.sides = Some([0.0, 8.0, 0.0, 0.0]); + let p = outline(&d, b); + assert_eq!(p.winding(Point::new(50.0, 4.0)), 0, "side edit removes top"); + assert_ne!(p.winding(Point::new(98.0, 50.0)), 0, "side edit adds right"); + d.get_mut(id).unwrap().stroke.sides = None; + assert_ne!(outline(&d, b).winding(Point::new(50.0, 4.0)), 0, "uniform container border"); + } +} + +#[test] +fn stroke_only_text_operands_use_glyphs_instead_of_the_text_box() { + use figcraft_doc::{StrokeAlign, TextData}; + let mut d = Document::new("text strokes"); + let page = d.first_page().unwrap(); + let b = d.create(page, NodeKind::BooleanOp { op: BoolOp::Union }, "B", 0.0, 0.0, 400.0, 100.0).unwrap(); + let mut text = TextData::new("Hi"); + text.style.font_size = 32.0; + let id = d.create(b, NodeKind::Text(text), "text", 0.0, 0.0, 400.0, 100.0).unwrap(); + let n = d.get_mut(id).unwrap(); + n.fills.clear(); + n.strokes = vec![Paint::solid(Color::BLACK)]; + n.stroke.weight = 2.0; + n.stroke.align = StrokeAlign::Center; + let glyphs = outline(&d, id); + let result = outline(&d, b); + assert_eq!(result.elements().is_empty(), glyphs.elements().is_empty()); + if !glyphs.elements().is_empty() { + let expected = glyphs.bounding_box().inflate(3.0, 3.0); + let actual = result.bounding_box(); + assert!(actual.x0 >= expected.x0 && actual.x1 <= expected.x1); + assert!(actual.y0 >= expected.y0 && actual.y1 <= expected.y1); + assert!(actual.x1 < 200.0, "the unused text box must not contribute a border"); + } + let NodeKind::Text(text) = &mut d.get_mut(id).unwrap().kind else { panic!() }; + text.characters = " ".into(); + assert!(outline(&d, b).elements().is_empty(), "spaces have no stroked glyphs"); +} diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 63b38220..a80cd063 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -205,6 +205,18 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { return Arc::new(o); } } + cached_stroke_outline(n, path, closed) +} + +/// A non-zero stroke outline, shared by painting and boolean operands. +fn cached_stroke_outline(n: &Node, path: &BezPath, closed: bool) -> Arc { + let props = &n.stroke; + 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 mut h = std::collections::hash_map::DefaultHasher::new(); hash_path(path, &mut h); (props.weight.to_bits(), props.align as u8, props.cap_start as u8, props.cap_end as u8, props.join as u8, props.miter_limit.to_bits(), closed) @@ -276,7 +288,7 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F .filter_map(|c| doc.node(*c)) .filter(|c| c.visible) .filter_map(|c| { - let (mut p, _) = subtree_outline(doc, c, depth + 1)?; + let (mut p, _) = subtree_outline_at(doc, c, depth + 1, true)?; p.apply_affine(c.transform); Some((p, c.bool_op.unwrap_or(*op))) }) @@ -300,6 +312,11 @@ fn node_outline_at(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, F /// The union outline of a node and its descendants (for booleans of groups/frames). pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPath, FillRule)> { + subtree_outline_at(doc, n, depth, false) +} + +/// Boolean operands with only a stroke contribute the painted band, not their interior. +fn subtree_outline_at(doc: &Document, n: &Node, depth: usize, stroke_only: bool) -> Option<(BezPath, FillRule)> { if depth > 64 { return None; } @@ -308,21 +325,32 @@ pub fn subtree_outline(doc: &Document, n: &Node, depth: usize) -> Option<(BezPat let mut out = BezPath::new(); if n.kind.is_frame_like() && let Some(p) = figcraft_doc::geometry::shape_path(n) - && n.fills.iter().any(|f| f.visible) { - out.extend(p.elements().iter().copied()); + if n.fills.iter().any(|f| f.visible) { + out.extend(p.elements().iter().copied()); + } else if stroke_only && n.has_visible_stroke() { + out.extend(cached_stroke_outline(n, &p, true).elements().iter().copied()); + } } for c in n.children.iter().filter_map(|c| doc.node(*c)) { if !c.visible { continue; } - if let Some((mut p, _)) = subtree_outline(doc, c, depth + 1) { + if let Some((mut p, _)) = subtree_outline_at(doc, c, depth + 1, stroke_only) { p.apply_affine(c.transform); out.extend(p.elements().iter().copied()); } } Some((out, FillRule::Winding)) } + _ if stroke_only && !n.fills.iter().any(|p| p.visible) && n.has_visible_stroke() => { + let (path, _) = node_outline_at(doc, n, depth)?; + let path = if matches!(n.kind, NodeKind::Text(_)) { path } else { figcraft_doc::geometry::stroke_geometry(n).unwrap_or(path) }; + let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. }); + // Boolean folding uses non-zero winding; the canvas stroke cache also contains + // even-odd rectangle rings, so use the winding-correct stroke outline here. + Some(((*cached_stroke_outline(n, &path, closed)).clone(), FillRule::Winding)) + } _ => node_outline_at(doc, n, depth), } } From 601a9701a535cf67e2abc9eab013598bf9989a44 Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Sat, 24 Oct 2026 00:32:24 +0100 Subject: [PATCH 029/142] text.fit: keep sizes that fit, respect the floor, skip one-word paragraphs - Text that already fits keeps its exact size; shrunk sizes round to a tenth but never below minSize (per run, its share of it). - A last line after a hard break is not an orphan, and the no-break space is only placed inside the last paragraph. - docs: data.fill documents formats? and errors?. --- crates/engine/src/textfit.rs | 49 +++++++++++++++++++++--------- crates/engine/src/textfit_tests.rs | 27 ++++++++++++++++ docs/mcp.md | 8 +++-- 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs index f0cf3bba..e4e84739 100644 --- a/crates/engine/src/textfit.rs +++ b/crates/engine/src/textfit.rs @@ -25,16 +25,28 @@ pub fn specs() -> Vec { } /// The layout of `t` with every run's font size times `k`, at `width`: (line count, height, -/// words on the last line). -fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, usize) { +/// whether the last line is an orphan: one word, wrapped from the line above rather than a +/// paragraph of its own after a hard break, which no size can fix). +fn measure(t: &TextData, k: f64, width: f64) -> (usize, f64, bool) { let mut t = t.clone(); let n = t.char_count(); t.restyle(0, n, |s| s.font_size = (s.font_size * k).clamp(1.0, 5000.0)); let l = figcraft_text::layout(&t, Some(width), None); - let last = l.lines.last().map_or(0, |line| { - t.characters.chars().skip(line.char_start).take(line.char_end.saturating_sub(line.char_start)).collect::().split_whitespace().count() - }); - (l.lines.len(), l.height, last) + let orphan = l.lines.len() > 1 + && l.lines.last().is_some_and(|line| { + let mut before = t.characters.chars().skip(line.char_start.saturating_sub(1)); + let wrapped = line.char_start > 0 && before.next() != Some('\n'); + wrapped + && t.characters + .chars() + .skip(line.char_start) + .take(line.char_end.saturating_sub(line.char_start)) + .collect::() + .split_whitespace() + .count() + == 1 + }); + (l.lines.len(), l.height, orphan) } fn fit(s: &mut Session, p: &Value) -> Result { @@ -52,7 +64,7 @@ fn fit(s: &mut Session, p: &Value) -> Result { let (width, height_cap) = (n.width, if t.auto_resize == TextAutoResize::None { n.height } else { f64::INFINITY }); let size = t.style.font_size.max(1.0); let min = num(p, "minSize").filter(|v| v.is_finite() && *v >= 1.0).unwrap_or(size * 0.6).min(size); - let fits = |m: (usize, f64, usize)| max_lines.is_none_or(|ml| m.0 <= ml) && m.1 <= height_cap + 0.5; + let fits = |m: (usize, f64, bool)| max_lines.is_none_or(|ml| m.0 <= ml) && m.1 <= height_cap + 0.5; // The largest factor in [min/size, 1] that fits: binary search to a tenth of a pixel. let (mut lo, mut hi) = (min / size, 1.0); let k = if fits(measure(t, 1.0, width)) { @@ -73,10 +85,11 @@ fn fit(s: &mut Session, p: &Value) -> Result { } lo }; - // Whole-tenth sizes read better in the panel. - let mut k = ((size * k * 10.0).floor() / 10.0) / size; + // Whole-tenth sizes read better in the panel, but never below the floor; text that fits + // keeps its exact size. + let mut k = if k < 1.0 { ((size * k * 10.0).floor() / 10.0).max(min) / size } else { 1.0 }; let mut m = measure(t, k, width); - let mut orphan = orphans && m.0 > 1 && m.2 == 1; + let mut orphan = orphans && m.2; let mut nbsp = false; if orphan { // Up to 8% smaller (not below the floor) may pull a word down to the last line. @@ -84,7 +97,7 @@ fn fit(s: &mut Session, p: &Value) -> Result { while orphan && tries < 8 && size * k * 0.99 >= min { k *= 0.99; m = measure(t, k, width); - orphan = m.0 > 1 && m.2 == 1; + orphan = m.2; tries += 1; } if orphan { @@ -94,9 +107,17 @@ fn fit(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(id)?; let NodeKind::Text(t) = &mut n.kind else { continue }; let count = t.char_count(); - t.restyle(0, count, |st| st.font_size = ((st.font_size * k * 10.0).round() / 10.0).clamp(1.0, 5000.0)); + if k < 1.0 { + // Each run rounds to a tenth, but not below its share of the floor. + t.restyle(0, count, |st| { + let floor = st.font_size * min / size; + st.font_size = ((st.font_size * k * 10.0).round() / 10.0).max(floor).clamp(1.0, 5000.0) + }); + } // Same character count, so style runs keep their ranges. - let joined = if nbsp && let Some(i) = t.characters.trim_end().rfind(' ') { + // Only inside the last paragraph: a space before a hard break joins nothing on the last line. + let para = t.characters.rfind('\n').map_or(0, |i| i + 1); + let joined = if nbsp && let Some(i) = t.characters.trim_end().get(para..).and_then(|p| p.rfind(' ')).map(|i| para + i) { t.characters.replace_range(i..i + 1, "\u{a0}"); Some(i) } else { @@ -109,7 +130,7 @@ fn fit(s: &mut Session, p: &Value) -> Result { after = measure(t, 1.0, width); } // Reported from the final text (rounded sizes, the no-break space), which callers rely on. - out.push(json!({"id": id.0, "fontSize": (t.style.font_size * 10.0).round() / 10.0, "lines": after.0, "fits": fits(after), "orphan": orphans && after.0 > 1 && after.2 == 1})); + out.push(json!({"id": id.0, "fontSize": (t.style.font_size * 10.0).round() / 10.0, "lines": after.0, "fits": fits(after), "orphan": orphans && after.2})); } Ok(json!({"fitted": out})) } diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs index 8d0a2ee5..8ee0f11b 100644 --- a/crates/engine/src/textfit_tests.rs +++ b/crates/engine/src/textfit_tests.rs @@ -102,3 +102,30 @@ fn review_fits_is_reported_from_the_final_text() { assert_eq!(f["fits"].as_bool().unwrap(), real as f64 <= lines, "{r}"); } } + +#[test] +fn review_sizes_off_the_tenth_grid_keep_their_size_and_floor() { + let mut s = Session::new(); + // Fits already: 14.25px stays 14.25px, not 14.2 or 14.3. + let t = headline(&mut s, "Curto", 600.0); + ex(&mut s, "node.set", json!({"id": t, "props": {"fontSize": 14.25}})); + ex(&mut s, "text.fit", json!({"ids": [t], "maxLines": 1})); + assert_eq!(size(&s, t), 14.25); + // Can't fit: it stops at the floor, not a tenth below it. + let long = headline(&mut s, "Seu site e seu WhatsApp com IA, prontos em 7 dias", 200.0); + let r = ex(&mut s, "text.fit", json!({"ids": [long], "maxLines": 1, "minSize": 12.34})); + assert_eq!(r["fitted"][0]["fits"], false, "{r}"); + assert!(size(&s, long) >= 12.34, "{}", size(&s, long)); +} + +#[test] +fn review_a_one_word_paragraph_is_not_an_orphan() { + let mut s = Session::new(); + let t = headline(&mut s, "Two words\nOne", 600.0); + let r = ex(&mut s, "text.fit", json!({"ids": [t]})); + assert_eq!(r["fitted"][0]["orphan"], false, "{r}"); + assert_eq!(size(&s, t), 64.0, "nothing to fix, so nothing shrinks"); + let doc = s.doc().unwrap(); + let NodeKind::Text(tx) = &doc.node(t).unwrap().kind else { panic!() }; + assert_eq!(tx.characters, "Two words\nOne", "no no-break space in an earlier paragraph"); +} diff --git a/docs/mcp.md b/docs/mcp.md index 5af124f9..4bcfac09 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -332,12 +332,13 @@ tips; headless, it only sets the preference. ### Filling copies with data -`data.fill {id, rows, gap?, dir?, format?, scale?}` repeats a frame once per row (a carousel, a set +`data.fill {id, rows, gap?, formats?, dir?, format?, scale?}` repeats a frame once per row (a carousel, a set of posts, a card per item) in one undo step: each key of a row names layers in the copy, and a string (or number) sets their text, `{text?, fill?, visible?}` sets more; `name` names the copy (default " "). Copies go in a row to the right, `gap` apart (default 100). With `dir`, each copy is exported there as `.` (png by default) after the edits land. Returns `{ids, -missing (keys no layer is named), files?}`. +missing (keys no layer is named), files?, errors?}`. `errors` lists the exports that failed as +`[{id, path, error}]`; the copies stay, and the files that were written are in `files`. Copy that changes length per row can size itself: `{"Headline": {"text": "…", "fit": {"maxLines": 2}}}` runs `text.fit` on those layers after the text is set. @@ -351,7 +352,8 @@ position children keep their place). `text.fit {ids?, maxLines?, minSize?, orphans?}` gives a text layer with a fixed width (auto height or fixed size) the largest font size, down to `minSize` (default 60% of the current), at which it stays within `maxLines` lines and its box's height. Every style run scales by the same factor. A one-word last line (an orphan) is avoided by -shrinking up to 8% more, else by keeping the last two words together with a no-break space. Text never grows. +shrinking up to 8% more, else by keeping the last two words together with a no-break space. A one-word paragraph after a hard line break is not an orphan. Text never +grows, text that already fits keeps its exact size, and sizes round to a tenth without going below `minSize`. Returns `{fitted: [{id, fontSize, lines, fits, orphan}]}`. Use case: a carousel slide "Lighthouse 100. Loads in under 1s." at 96px broke into three lines with "1s." alone; with `fit: {maxLines: 3}` it is two balanced lines. From 90455cc4294078c80cf530b16b81b87b7d23741e Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Sat, 24 Oct 2026 00:43:59 +0100 Subject: [PATCH 030/142] token roles: follow alias chains to their end variable.set does not cap alias chains, so propagate until a pass adds nothing instead of a fixed eight rounds. --- crates/engine/src/token_roles.rs | 5 +++-- crates/engine/src/variables_io_tests.rs | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/token_roles.rs b/crates/engine/src/token_roles.rs index 2f6cd41b..dc609640 100644 --- a/crates/engine/src/token_roles.rs +++ b/crates/engine/src/token_roles.rs @@ -65,8 +65,9 @@ impl Roles { roles.entry(v.id.clone()).or_insert(r); } } - // Alias chains are short; a few rounds reach their ends. - for _ in 0..8 { + // Until a pass adds nothing: each pass adds a role or stops, so this ends within one pass + // per variable, however long a chain is. + loop { let mut changed = false; for v in vars { let Some(r) = roles.get(&v.id).copied() else { continue }; diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index 8d21be61..5eea5b30 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -417,3 +417,19 @@ fn css_export_gives_lengths_a_unit_and_keeps_opacity_bare() { // A radius is a length; opacity stays a number, also through an alias it reads. assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;") && css.contains("--half: 0.5;"), "{css}"); } + +#[test] +fn review_a_long_alias_chain_keeps_its_role_to_the_end() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Brand"})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "o0", "type": "number", "value": 0.5})); + // o12 -> o11 -> … -> o0: longer than any fixed number of rounds would reach. + for i in 1..=12 { + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": format!("o{i}"), "type": "number", "value": 1})); + ex(&mut s, "variable.set", json!({"variable": format!("o{i}"), "value": {"alias": format!("o{}", i - 1)}})); + } + let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "variable.bind", json!({"ids": [f], "field": "opacity", "variable": "o12"})); + let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string(); + assert!(css.contains("--o0: 0.5;"), "{css}"); +} From 7a2568fe55a26fd609dc4335343162e16f498187 Mon Sep 17 00:00:00 2001 From: vetlucasmartins Date: Sat, 24 Oct 2026 00:50:53 +0100 Subject: [PATCH 031/142] tailwind: emit @theme static; fix assertion precedence in tests Tailwind v4 drops theme variables no utility uses, but page code also reads tokens through var() and (--name) utilities, so the export keeps them all. Two test assertions mixed && and || without parentheses. --- crates/engine/src/cmd/variables_io.rs | 4 +++- crates/engine/src/tailwind_tests.rs | 6 +++--- docs/mcp.md | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index c74c46fe..eee312c2 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -80,7 +80,9 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { let attr = css_var_name(&c.name); for (i, m) in c.modes.iter().enumerate() { let sel = match i { - 0 if tailwind => "@theme".to_string(), + // `static`: Tailwind otherwise drops theme variables no utility uses, and pages also + // read them through `var()` and `(--name)` utilities. + 0 if tailwind => "@theme static".to_string(), 0 => ":root".to_string(), _ => format!("[data-{}=\"{}\"]", attr.trim_start_matches('-'), css_safe(&m.name)), }; diff --git a/crates/engine/src/tailwind_tests.rs b/crates/engine/src/tailwind_tests.rs index 615a6cec..3080a15d 100644 --- a/crates/engine/src/tailwind_tests.rs +++ b/crates/engine/src/tailwind_tests.rs @@ -66,7 +66,7 @@ fn per_side_strokes_are_borders_on_those_sides() { let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string(); assert!(css.contains("border-bottom: 1px solid") && !css.contains("border:"), "{css}"); let tw = code(&mut s, f, json!({})); - assert!(tw.contains("border-b ") && tw.contains("border-[#262a31]") || tw.contains("border-[#262A31]"), "{tw}"); + assert!(tw.contains("border-b ") && (tw.contains("border-[#262a31]") || tw.contains("border-[#262A31]")), "{tw}"); // Without per-side weights it is one border. ex(&mut s, "node.set", json!({"id": f, "props": {"strokeSides": null, "strokeWeight": 2}})); let css = ex(&mut s, "inspect.code", json!({"id": f, "language": "css"}))["code"].as_str().unwrap_or_default().to_string(); @@ -140,7 +140,7 @@ fn variables_export_with_units_and_as_a_tailwind_theme() { let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string(); assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;"), "{css}"); let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap_or_default().to_string(); - for want in ["@import \"tailwindcss\";", "@theme {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] { + for want in ["@import \"tailwindcss\";", "@theme static {", "--color-accent: #3ddc97;", "--radius-card: 12px;", "--fade: 0.5;"] { assert!(tw.to_lowercase().contains(&want.to_lowercase()), "{want} in {tw}"); } } @@ -192,7 +192,7 @@ fn slashed_layer_names_and_phone_widths_merge_right() { assert!(button.contains("self-stretch") && button.contains("lg:self-auto"), "the slashed name keeps its mobile classes: {out}"); assert!(!out.contains("max-w-[342px] ") || out.contains("lg:max-w-[342px]"), "no phone-width cap below lg: {out}"); assert!(out.contains("Only in the mobile frame") && out.contains("Tag"), "{out}"); - let divider = out.lines().filter(|l| l.contains("{"See examples"}`, with each import once at the top. A design system mapped to shadcn/ui this way exports pages that use the project's own components. Bound variables become theme utilities (`bg-accent`, `rounded-card`, `text-muted`) that `variable.export {format: "tailwind"}` - declares: `@import "tailwindcss";` and an `@theme` block named by namespace (`--color-*`, + declares: `@import "tailwindcss";` and an `@theme static` block (every token is emitted, also those only read through `var()`) named by namespace (`--color-*`, `--radius-*`, `--text-*`, `--spacing-*`, `--font-*`; a number's namespace and unit come from what layers bind it to), other modes on `[data-=""]`. With `mobile: ` (the same layer names, mobile layout) the code is mobile first: the mobile frame's classes, the From 8989e7b253c8983c73901f8ecf25f5338a0df646 Mon Sep 17 00:00:00 2001 From: SusieeTheFemboy <96988330+SusieeTheLinuxUser@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:19:47 +0200 Subject: [PATCH 032/142] Text: Backspace removes Thai and other marks one at a time (#1220) Co-Authored-By: GPT-6 Luna --- crates/engine/src/text_behaviour_tests.rs | 5 ++++- crates/engine/src/textedit.rs | 12 ++++++++++- crates/text/src/behaviour_tests.rs | 12 +++++++++++ crates/text/src/breaks.rs | 25 ++++++++++++++++++++++- crates/text/src/lib.rs | 2 +- 5 files changed, 52 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index b9a76fbc..ceaad8a7 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -60,11 +60,14 @@ fn arrows_and_deletes_step_over_whole_clusters() { key(&mut s, "Right", true, false, false).unwrap(); assert_eq!(caret(&s), (1, 6)); key(&mut s, "Left", false, false, false).unwrap(); - // Delete and Backspace remove a whole cluster. + // Delete removes a whole cluster; Backspace takes a letter's marks one at a time (#1220) and + // an emoji cluster whole. key(&mut s, "Delete", false, false, false).unwrap(); assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}e\u{0301}"); key(&mut s, "Right", false, false, true).unwrap(); // to the end key(&mut s, "Backspace", false, false, false).unwrap(); + assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}e"); + key(&mut s, "Backspace", false, false, false).unwrap(); assert_eq!(chars(&s, t), "ab\u{1F1EF}\u{1F1F5}"); key(&mut s, "Backspace", false, false, false).unwrap(); assert_eq!(chars(&s, t), "ab"); diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 85ea535b..7f1d3af6 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -243,7 +243,7 @@ pub fn key(s: &mut Session, key: &str, shift: bool, word: bool, line: bool) -> R } else if word { word_left(&chars, a) } else { - figcraft_text::grapheme_step(&t.characters, a, false) + figcraft_text::backspace_step(&t.characters, a) }; (from, a) } else { @@ -574,6 +574,16 @@ mod tests { end(&mut s).unwrap(); } + #[test] + fn backspace_removes_only_the_final_thai_mark() { + let (mut s, id) = setup("น้ำ"); + begin(&mut s, id, Some(3), false).unwrap(); + + key(&mut s, "Backspace", false, false, false).unwrap(); + + assert_eq!(data(&s, id).characters, "น้"); + } + fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent { let mut e = PointerEvent::at(kind, x, y, 1.0); e.shift = shift; diff --git a/crates/text/src/behaviour_tests.rs b/crates/text/src/behaviour_tests.rs index b3cb4884..a9baf22c 100644 --- a/crates/text/src/behaviour_tests.rs +++ b/crates/text/src/behaviour_tests.rs @@ -361,6 +361,18 @@ fn grapheme_clusters() { assert!(grapheme_starts("plain ascii").iter().all(|b| *b)); } +#[test] +fn backspace_removes_marks_but_keeps_other_graphemes_atomic() { + assert_eq!(crate::backspace_step("น้ำ", 3), 2); // Thai spacing mark + assert_eq!(crate::backspace_step("e\u{0301}", 2), 1); // Latin combining mark + assert_eq!(crate::backspace_step("👨‍👩‍👧", 5), 0); // Emoji ZWJ sequence + assert_eq!(crate::backspace_step("🇯🇵", 2), 0); // Flag + assert_eq!(crate::backspace_step("x", 1), 0); // Ordinary character + assert_eq!(crate::backspace_step("\u{1112}\u{1161}\u{11AB}", 3), 0); // Hangul syllable as conjoining jamo + assert_eq!(crate::backspace_step("a\u{200D}", 2), 0); // A letter ending in ZWJ + assert_eq!(crate::backspace_step("1\u{FE0F}\u{20E3}", 3), 0); // Keycap +} + #[test] fn a_cluster_is_shaped_with_one_face() { if face().is_none() { diff --git a/crates/text/src/breaks.rs b/crates/text/src/breaks.rs index 52284d0b..5d1ceaec 100644 --- a/crates/text/src/breaks.rs +++ b/crates/text/src/breaks.rs @@ -89,7 +89,7 @@ pub(crate) fn grapheme_starts(text: &str) -> Vec { } /// The char index of the grapheme cluster boundary after (`forward`) or before char `index` in -/// `text`: where an arrow key, Delete or Backspace moves the caret to. +/// `text`: where an arrow key or Delete moves the caret to. pub fn grapheme_step(text: &str, index: usize, forward: bool) -> usize { let mut at = 0; for g in text.graphemes(true) { @@ -102,6 +102,29 @@ pub fn grapheme_step(text: &str, index: usize, forward: bool) -> usize { at.min(index) } +/// The start of the text deleted by Backspace at char `index`. +/// +/// A letter's trailing mark (Grapheme_Cluster_Break Extend or SpacingMark, such as Thai MAI THO or +/// SARA AM) is deleted on its own; other grapheme clusters remain atomic, so emoji sequences, flags, +/// keycaps and Hangul syllables written as conjoining jamo stay intact. +pub fn backspace_step(text: &str, index: usize) -> usize { + let end = text.chars().count().min(index); + let start = grapheme_step(text, end, false); + if start == end { + return end; + } + + let cluster = text.chars().skip(start).take(end - start).collect::>(); + let letter_cluster = cluster.len() > 1 && cluster.first().is_some_and(|c| c.is_alphabetic()); + if letter_cluster && cluster.last().is_some_and(|&c| is_trailing_mark(c)) { end - 1 } else { start } +} + +/// Whether `c` is Extend or SpacingMark: it joins a plain letter's cluster. ZWJ also joins one but +/// is a joiner, not a mark. +fn is_trailing_mark(c: char) -> bool { + c != '\u{200D}' && format!("a{c}").graphemes(true).count() == 1 +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 50cd28dd..36566602 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -13,7 +13,7 @@ pub mod fontdb; mod layout; mod onpath; -pub use breaks::grapheme_step; +pub use breaks::{backspace_step, grapheme_step}; pub use color::{ColorBitmap, ColorInk, ColorLayer}; pub use fontdb::{ Axis, CRAFT_FONTS, Coords, Face, FontDb, NamedInstance, Rescan, axis_label, db, face_with_char, family_names, family_styles, find_face, From 84ca850d5fe46c1dd74e2a19f9521edd1aa19ece Mon Sep 17 00:00:00 2001 From: Salah1St <83543012+Salah1St@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:30:08 +0700 Subject: [PATCH 033/142] M4: justified text doesn't widen U+200B Thai, Lao, Khmer and Burmese are written without spaces between words. Tools that know the words insert U+200B ZERO WIDTH SPACE so lines can break between them, and line breaking already treats it as a space. But justification counted every space-class char as a gap, so a justified paragraph got extra room at every U+200B: visible gaps between all its words, which is not how these scripts are justified. Item::stretches() (a space other than U+200B) is used for the justify gap count, the trailing width and the pen advance. Line breaking is unchanged. Test: on a justified line of words joined by U+200B and one real space, the extra room goes to the real space only; with no real space the line is laid out as if left-aligned. Co-Authored-By: Claude Opus 5.5 --- crates/text/src/layout.rs | 11 ++++++++--- crates/text/src/tests.rs | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f844b571..79209957 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -257,6 +257,11 @@ impl Item { fn is_space(&self) -> bool { crate::breaks::is_space(self.ch) } + /// A space justified text widens: not U+200B, which only marks where a line may break (between + /// the words of Thai, Lao, Khmer or Burmese text, written without spaces). + fn stretches(&self) -> bool { + self.is_space() && self.ch != '\u{200B}' + } } /// Bidi embedding levels per char, and whether each char's paragraph runs right to left. @@ -587,7 +592,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv); let gstart = out.glyphs.len(); // Justify: extra space per gap. - let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.is_space()).count(); + let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count(); let justify_extra = match (text.align_h, wrap) { (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => { ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0) @@ -603,7 +608,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) .unwrap_or(&[]) .iter() .filter(|it| !is_line_break(it.ch)) - .map(|it| it.adv + if it.is_space() { justify_extra } else { 0.0 }) + .map(|it| it.adv + if it.stretches() { justify_extra } else { 0.0 }) .sum(); let mut pen = if rtl_line { -trailing } else { line_indent - hang_l }; // List marker (before the line's text; zero-length char range so carets skip it). @@ -698,7 +703,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option) }); } pen += it.adv; - if it.is_space() { + if it.stretches() { pen += justify_extra; } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 4475fbbc..d36c1589 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -519,3 +519,43 @@ fn lower_case_writes_final_sigma() { 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"); } + +/// Thai is written without spaces between words; U+200B ZERO WIDTH SPACE marks where a line may +/// break. ภาษา|ไทย|เขียน|ติดกัน ("Thai is written joined up") ไม่มี|ช่องว่าง|ระหว่าง|คำ ("with no +/// spaces between words"), with one real space between the two phrases. +#[test] +fn justified_lines_dont_widen_zero_width_spaces() { + if !have_fonts() { + return; + } + let lay_out = |s: &str, align, w| { + let mut t = TextData::new(s); + t.style.font_size = 20.0; + t.align_h = align; + t.auto_resize = figcraft_doc::TextAutoResize::Height; + layout(&t, w, None) + }; + let chars_before = |s: &str, pat: &str| s.find(pat).map(|b| s[..b].chars().count()).unwrap(); + for (s, why) in [ + ("ภาษา\u{200B}ไทย\u{200B}เขียน\u{200B}ติดกัน ไม่มี\u{200B}ช่องว่าง\u{200B}ระหว่าง\u{200B}คำ", "the real space takes the extra room"), + ("ภาษา\u{200B}ไทย\u{200B}เขียน\u{200B}ติดกัน\u{200B}ไม่มี\u{200B}ช่องว่าง\u{200B}ระหว่าง\u{200B}คำ", "no space to widen: as left-aligned"), + ] { + // A box that ends inside ช่องว่าง: the first line breaks at the U+200B before it. + let word = chars_before(s, "ช่องว่าง"); + let natural = lay_out(s, TextAlignH::Left, None); + let (a, b) = (natural.caret(word).0, natural.caret(word + 4).0); + assert!(b > a, "{why}: chars need advances"); + let w = a + (b - a) / 2.0; + let left = lay_out(s, TextAlignH::Left, Some(w)); + let just = lay_out(s, TextAlignH::Justified, Some(w)); + assert!(just.lines.len() >= 2 && just.lines[0].char_end == word, "{why}: {:?}", just.lines); + let space = s.chars().position(|c| c == ' '); + let extra = w - left.lines[0].width; + assert!(extra > 0.0, "{why}"); + assert_eq!(left.glyphs.len(), just.glyphs.len()); + for (l, j) in left.glyphs.iter().zip(&just.glyphs).filter(|(g, _)| g.char_end <= word) { + let want = if space.is_some_and(|sp| l.char_start > sp) { extra } else { 0.0 }; + assert!((j.x - l.x - want).abs() < 1e-6, "{why}: char {} moved {} (want {want})", l.char_start, j.x - l.x); + } + } +} From e1117d2a3cee6eded8bb9d3cdbcc3ad2d088d581 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 12:57:42 +0700 Subject: [PATCH 034/142] #1008: token_roles names its imports --- crates/engine/src/token_roles.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/engine/src/token_roles.rs b/crates/engine/src/token_roles.rs index dc609640..3827ecbe 100644 --- a/crates/engine/src/token_roles.rs +++ b/crates/engine/src/token_roles.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; -use figcraft_doc::*; +use figcraft_doc::{Document, Variable, VariableId, VariableType, VariableValue}; use crate::codegen::num; From 9361b230b55773782141151ee4f8d2030c7073ff Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 12:57:43 +0700 Subject: [PATCH 035/142] #1011: explicit imports, a method reference --- crates/engine/src/tailwind.rs | 4 ++-- crates/engine/src/tailwind_tests.rs | 2 +- crates/engine/src/token_roles.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/tailwind.rs b/crates/engine/src/tailwind.rs index 2a6453e4..d52aa29c 100644 --- a/crates/engine/src/tailwind.rs +++ b/crates/engine/src/tailwind.rs @@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashMap}; -use figcraft_doc::*; +use figcraft_doc::{Document, NodeId, NodeKind, Variable, VariableId, VariableType}; use crate::codegen::{CodeLang, Ctx, Markup, css_var_name, css_var_of, num}; use crate::component_codegen::component_type_name; @@ -454,7 +454,7 @@ pub fn code(doc: &Document, id: NodeId, mobile: Option) -> String { // ponytail: layers only on mobile are listed, not merged in; their place in the tree is ambiguous. let only_mobile: Vec = small .iter() - .flat_map(|m| m.keys()) + .flat_map(BTreeMap::keys) .filter(|p| !rules.iter().any(|(d, _, _)| d == *p)) .map(|p| p.trim_start_matches('/').split('/').map(|seg| seg.rsplit_once('#').map_or(seg, |(n, _)| n)).collect::>().join(" › ")) .take(20) diff --git a/crates/engine/src/tailwind_tests.rs b/crates/engine/src/tailwind_tests.rs index 3080a15d..224c4928 100644 --- a/crates/engine/src/tailwind_tests.rs +++ b/crates/engine/src/tailwind_tests.rs @@ -5,7 +5,7 @@ use crate::Session; use crate::components::tests::{ex, idv}; use crate::tailwind::{classes, merge}; -use figcraft_doc::*; +use figcraft_doc::NodeId; use serde_json::json; fn code(s: &mut Session, id: NodeId, extra: serde_json::Value) -> String { diff --git a/crates/engine/src/token_roles.rs b/crates/engine/src/token_roles.rs index dc609640..3827ecbe 100644 --- a/crates/engine/src/token_roles.rs +++ b/crates/engine/src/token_roles.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; -use figcraft_doc::*; +use figcraft_doc::{Document, Variable, VariableId, VariableType, VariableValue}; use crate::codegen::num; From 647e5f17d905fa4f84032e9202afc46085c1e71c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 12:57:52 +0700 Subject: [PATCH 036/142] #1011: single-call Tailwind variables, rem only where it converts --- crates/engine/src/codegen.rs | 5 +++-- crates/engine/src/tailwind.rs | 15 ++++++++++++++- crates/engine/src/tailwind_tests.rs | 7 +++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index c0b8e891..18db85c8 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -99,9 +99,10 @@ impl CodeLang { CodeLang::Compose => "Compose", } } - /// Languages whose lengths are CSS pixels (they can be shown in rem). + /// Languages whose lengths are CSS pixels that can be shown in rem (not Tailwind: its lengths + /// are utility classes). pub fn is_web(self) -> bool { - matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos | CodeLang::Tailwind) + matches!(self, CodeLang::Css | CodeLang::Html | CodeLang::React | CodeLang::Leptos) } pub fn from_id(s: &str) -> Option { if s.eq_ignore_ascii_case("tsx") || s.eq_ignore_ascii_case("jsx") { diff --git a/crates/engine/src/tailwind.rs b/crates/engine/src/tailwind.rs index d52aa29c..70a562a9 100644 --- a/crates/engine/src/tailwind.rs +++ b/crates/engine/src/tailwind.rs @@ -105,9 +105,22 @@ fn arb(v: &str) -> String { format!("[{}]", v.trim().replace('_', "\\_").replace(' ', "_")) } -/// The custom property a `var(--name, fallback)` value reads. +/// The custom property a `var(--name, fallback)` value reads, when that one call is the whole +/// value (not `var(--a, 4px) 8px`, four corners of which only the first is a variable). fn var_name(v: &str) -> Option<&str> { let inner = v.trim().strip_prefix("var(")?; + let mut depth = 1usize; + let close = inner.char_indices().find_map(|(i, c)| { + match c { + '(' => depth += 1, + ')' => depth -= 1, + _ => {} + } + (depth == 0).then_some(i) + })?; + if close + 1 != inner.len() { + return None; + } Some(inner.split([',', ')']).next()?.trim()) } diff --git a/crates/engine/src/tailwind_tests.rs b/crates/engine/src/tailwind_tests.rs index 224c4928..9e41fecc 100644 --- a/crates/engine/src/tailwind_tests.rs +++ b/crates/engine/src/tailwind_tests.rs @@ -333,3 +333,10 @@ fn review_desktop_without_fill_clears_a_mobile_image_or_gradient() { let got = merge(Some(&[("background".to_string(), "[background:url(image.png)_center_/_cover]".to_string())]), &[]); assert!(got.contains(&"lg:bg-none".to_string()) && got.contains(&"lg:bg-transparent".to_string()), "{got:?}"); } + +#[test] +fn per_corner_radii_with_a_variable_first_keep_every_corner() { + let got: Vec = + classes("border-radius: var(--radius-a, 4px) 8px 8px 8px;\nborder-radius: var(--radius-card, 4px);\n").into_iter().map(|(_, c)| c).collect(); + assert_eq!(got, ["rounded-[var(--radius-a,_4px)_8px_8px_8px]", "rounded-card"]); +} From 91af3782e05fd9ee4b7f606939600f890c83d895 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 12:57:58 +0700 Subject: [PATCH 037/142] #915: the inspect response-limit tests join the MCP crate's one test binary (tests/it, as on main since #1208) --- crates/mcp/tests/{ => it}/inspect_response_limit.rs | 4 ++-- crates/mcp/tests/it/main.rs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) rename crates/mcp/tests/{ => it}/inspect_response_limit.rs (99%) diff --git a/crates/mcp/tests/inspect_response_limit.rs b/crates/mcp/tests/it/inspect_response_limit.rs similarity index 99% rename from crates/mcp/tests/inspect_response_limit.rs rename to crates/mcp/tests/it/inspect_response_limit.rs index b6d8b92e..8e52514b 100644 --- a/crates/mcp/tests/inspect_response_limit.rs +++ b/crates/mcp/tests/it/inspect_response_limit.rs @@ -1,8 +1,8 @@ //! `inspect_document` must not emit a message larger than a client can read in one piece: MCP //! stdio is newline-delimited JSON, so one result is one message, and a client whose read buffer //! it exceeds closes the pipe and kills the server for the rest of the session (#912). -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; +use crate::common; + use common::Client; use serde_json::{Value, json}; diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs index b5e3d89d..b16fc98e 100644 --- a/crates/mcp/tests/it/main.rs +++ b/crates/mcp/tests/it/main.rs @@ -13,6 +13,8 @@ mod export_hidden_roots; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod garbage; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod inspect_response_limit; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod protocol; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod screenshot_pixel_limit; From b6f5c29d190a530f362ba66a9ba8f7dd2dc7d7ff Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 12:58:10 +0700 Subject: [PATCH 038/142] #915: MCP get_node pages past a child too large for the limit and keeps the truncation marker --- crates/mcp/src/tools.rs | 7 +++-- crates/mcp/tests/it/inspect_response_limit.rs | 31 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 19f2be35..4feebbb8 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -750,7 +750,10 @@ fn get_node(b: &mut dyn Backend, a: &Map) -> Result) -> Result (Value, usize) { let wire = serde_json::to_string(&text).unwrap().len() + ENVELOPE; (serde_json::from_str(text).expect("the block is JSON"), wire) } + +/// A child too large for the limit on its own is passed over, one per page of just the marker, +/// so paging moves on and every page stays within the limit. +#[test] +fn children_too_large_to_fit_page_one_at_a_time() { + let mut c = Client::new(); + let frame = c.create(json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 400})); + for i in 0..3 { + c.create(json!({"type": "rectangle", "parent": frame, "x": i * 5, "y": 5, "width": 20, "height": 20, "name": "n".repeat(3000)})); + } + let mut offset = Some(0u64); + let mut pages = 0; + while let Some(at) = offset { + let (page, wire) = get_node(&mut c, json!({"id": frame, "depth": 1, "maxBytes": 2500, "offset": at})); + assert!(wire <= 2500, "page at {at} exceeds maxBytes: {wire}"); + assert_eq!(page["children"].as_array().map_or(0, Vec::len), 0, "{}", page["truncated"]); + assert!(!page["truncated"].is_null(), "the marker stays: page at {at}"); + offset = page["truncated"]["children"]["nextOffset"].as_u64(); + assert!(offset.is_none_or(|o| o > at), "paging moves on from {at}"); + pages += 1; + assert!(pages <= 3); + } + assert_eq!(pages, 3); + + // A sole child passed over still says the answer was cut. + let single = c.create(json!({"type": "frame", "x": 500, "y": 0, "width": 40, "height": 40})); + c.create(json!({"type": "rectangle", "parent": single, "x": 0, "y": 0, "width": 20, "height": 20, "name": "n".repeat(3000)})); + let (page, _) = get_node(&mut c, json!({"id": single, "depth": 1, "maxBytes": 2500})); + assert_eq!(page["children"].as_array().map_or(0, Vec::len), 0); + assert_eq!(page["truncated"]["children"]["total"], json!(1), "{}", page["truncated"]); +} From b00d12b623891b942cff7fc7fcacedd3dbcd45ab Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:01:33 +0700 Subject: [PATCH 039/142] #915: get_node refuses a layer too large for maxBytes on its own; a window only an offset made says it is paging --- crates/mcp/src/tools.rs | 43 +++++++++++++++---- crates/mcp/tests/it/inspect_response_limit.rs | 33 ++++++++++++++ 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 4feebbb8..32ad8832 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -742,7 +742,7 @@ fn get_node(b: &mut dyn Backend, a: &Map) -> Result) -> Result budget { + // Even with every child left out, the layer's own fields (a very long name, say) pass the + // limit: an answer would be larger than the caller can read, so there is none. + return Err(format!( + "layer {id} takes {used_wire} bytes on the wire without its children, more than fit in maxBytes {max_bytes}; ask again with a larger `maxBytes`" + )); + } + // Children left out after the window were cut by size; a window that only `offset` moved, of a + // subtree within the limit, is paging. + let over = wire > budget || next.is_some(); let mut out = with_marker( value, depth, @@ -762,6 +772,7 @@ fn get_node(b: &mut dyn Backend, a: &Map) -> Result) -> Result bool { true } -/// `value` with the `truncated` object saying how it was cut. `children`: the window, when the +/// `value` with the `truncated` object saying how it was cut. `over`: the subtree asked for passed +/// the limit (else the window is only the `offset` asked for). `children`: the window, when the /// children were cut as well as (or instead of) the depth. -fn with_marker(mut value: Value, asked: u64, used: u64, asked_wire: usize, used_wire: usize, max_bytes: usize, children: Option) -> Value { +fn with_marker( + mut value: Value, + asked: u64, + used: u64, + asked_wire: usize, + used_wire: usize, + max_bytes: usize, + over: bool, + children: Option, +) -> Value { if let Some(obj) = value.as_object_mut() { let mut marker = json!({ "requestedDepth": asked, @@ -815,10 +836,16 @@ fn with_marker(mut value: Value, asked: u64, used: u64, asked_wire: usize, used_ match &children { Some(window) => { marker["children"] = window.clone(); - marker["reason"] = json!(format!( - "the subtree at depth {asked} would be {asked_wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; its children are reported from {} instead", - window.get("from").and_then(Value::as_u64).unwrap_or(0) - )); + let from = window.get("from").and_then(Value::as_u64).unwrap_or(0); + marker["reason"] = json!(if over { + format!( + "the subtree at depth {asked} would be {asked_wire} bytes on the wire, over the {max_bytes}-byte limit a client can read in one message; its children are reported from {from} instead" + ) + } else { + format!( + "its children are reported from {from}, the `offset` asked for; the subtree at depth {asked} ({asked_wire} bytes) is within the {max_bytes}-byte limit" + ) + }); marker["hint"] = json!("ask again with the `offset` in `children.nextOffset` for the rest, or get_node({id, depth}) on one child"); } None => { diff --git a/crates/mcp/tests/it/inspect_response_limit.rs b/crates/mcp/tests/it/inspect_response_limit.rs index 764e6a50..347aa96e 100644 --- a/crates/mcp/tests/it/inspect_response_limit.rs +++ b/crates/mcp/tests/it/inspect_response_limit.rs @@ -127,6 +127,7 @@ fn a_node_with_more_children_than_fit_is_windowed() { assert_eq!(window["total"], json!(total), "{window}"); assert!(shown > 0 && shown < total, "some children, not all: {shown} of {total}"); assert_eq!(window["nextOffset"], json!(shown), "the offset to ask for next: {window}"); + assert!(cut["truncated"]["reason"].as_str().unwrap_or("").contains("over the"), "cut by size: {}", cut["truncated"]); // The layer itself is still there, not just a marker. assert_eq!(cut["id"], whole["id"]); @@ -183,3 +184,35 @@ fn children_too_large_to_fit_page_one_at_a_time() { assert_eq!(page["children"].as_array().map_or(0, Vec::len), 0); assert_eq!(page["truncated"]["children"]["total"], json!(1), "{}", page["truncated"]); } + +/// A layer whose own fields pass the limit with every child left out is refused: an answer would +/// be over maxBytes, and the marker would only make it larger. +#[test] +fn a_layer_too_large_on_its_own_is_refused() { + let mut c = Client::new(); + let rect = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 20, "height": 20, "name": "n".repeat(3000)})); + let r = c.call("get_node", json!({"id": rect, "maxBytes": 2500})); + assert_eq!(r["isError"], true, "{r}"); + assert!(r["content"][0]["text"].as_str().unwrap_or("").contains("maxBytes"), "{r}"); + // A layer of ordinary size answers within the same limit. + let small = c.create(json!({"type": "rectangle", "x": 0, "y": 0, "width": 20, "height": 20})); + let (v, wire) = get_node(&mut c, json!({"id": small, "maxBytes": 2500})); + assert!(wire <= 2500 && v["truncated"].is_null(), "{wire}: {v}"); +} + +/// A window that only an `offset` made, of a subtree within the limit, is paging: the marker +/// doesn't say the limit was passed. +#[test] +fn an_offset_alone_pages_without_claiming_the_limit() { + let mut c = Client::new(); + let frame = c.create(json!({"type": "frame", "x": 0, "y": 0, "width": 400, "height": 400})); + for i in 0..3 { + c.create(json!({"type": "rectangle", "parent": frame, "x": i * 5, "y": 5, "width": 20, "height": 20})); + } + let (page, _) = get_node(&mut c, json!({"id": frame, "depth": 1, "offset": 1})); + let t = &page["truncated"]; + assert_eq!(page["children"].as_array().map_or(0, Vec::len), 2, "{t}"); + assert!(t["children"]["nextOffset"].is_null(), "{t}"); + let reason = t["reason"].as_str().unwrap_or(""); + assert!(reason.contains("offset") && !reason.contains("over the"), "{reason}"); +} From bc0f61eeee3cf03b96d76f30117809738a5e1517 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:01:46 +0700 Subject: [PATCH 040/142] #1004: a size given with a new path still fixes its auto layout axis --- crates/engine/src/al_behaviour_tests.rs | 12 ++++++++++++ crates/engine/src/cmd/props.rs | 25 ++++++++++++++++--------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/al_behaviour_tests.rs b/crates/engine/src/al_behaviour_tests.rs index 4659a597..a7a4d1d7 100644 --- a/crates/engine/src/al_behaviour_tests.rs +++ b/crates/engine/src/al_behaviour_tests.rs @@ -221,3 +221,15 @@ fn canvas_stacking_paints_and_exports_the_first_child_on_top() { s.undo().unwrap(); assert_eq!(pixel(&s, f, 40, 10), [0, 0, 255, 255]); } + +#[test] +fn a_size_given_with_a_new_path_fixes_that_axis() { + let mut s = Session::new(); + let f = al_frame(&mut s, json!({})); + let v = id(s.execute("node.create", &json!({"type": "vector", "path": "M0 0 L10 10", "closed": false, "parent": f})).unwrap()); + set(&mut s, v, json!({"sizingH": "fill"})); + assert_eq!(sizing(&s, v).0, Sizing::Fill); + set(&mut s, v, json!({"path": "M0 0 L20 5", "width": 30})); + assert_eq!(sizing(&s, v).0, Sizing::Fixed); + assert!((size(&s, v).0 - 30.0).abs() < 1e-6, "{:?}", size(&s, v)); +} diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index d0e95f80..b5889d8c 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -393,6 +393,18 @@ fn apply_order(k: &str) -> u8 { } } +/// An explicit width (`is_w`) or height makes hugging or filling on that axis fixed, like typing +/// into W or H. +fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> { + let n = doc.get_mut(id)?; + if is_w { + n.child_layout.sizing_h = Sizing::Fixed; + } else { + n.child_layout.sizing_v = Sizing::Fixed; + } + Ok(()) +} + /// Apply a property map to one node. `x`/`y` are relative to the parent. pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map) -> Result<()> { let mut unknown = Vec::new(); @@ -404,8 +416,9 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map path_offset = set_path(cmd, doc, id, v, props)?, - // A new path takes the size asked for as its scale (`set_path`). - "width" | "height" | "w" | "h" if props.contains_key("path") => {} + // A new path takes the size asked for as its scale (`set_path`); the size still fixes + // its auto layout axis. + "width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?, "x" | "y" => { let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?; let val = val + if k == "x" { path_offset.0 } else { path_offset.1 }; @@ -450,13 +463,7 @@ pub fn apply(cmd: &str, doc: &mut Document, id: NodeId, props: &Map { let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?; From 5376e4d25d24eba5c41bc06d4f1dc5f0f33daea1 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:01:47 +0700 Subject: [PATCH 041/142] #1009: explicit imports --- crates/engine/src/datafill.rs | 2 +- crates/engine/src/datafill_tests.rs | 2 +- crates/engine/src/textfit.rs | 4 ++-- crates/engine/src/textfit_tests.rs | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/datafill.rs index d0226936..865276a7 100644 --- a/crates/engine/src/datafill.rs +++ b/crates/engine/src/datafill.rs @@ -6,7 +6,7 @@ use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::params::*; +use crate::params::{get, id, id_of, num_or, string}; use crate::{Result, Session, bad, spec}; const CMD: &str = "data.fill"; diff --git a/crates/engine/src/datafill_tests.rs b/crates/engine/src/datafill_tests.rs index bbb5d4b6..3e0cff7f 100644 --- a/crates/engine/src/datafill_tests.rs +++ b/crates/engine/src/datafill_tests.rs @@ -3,7 +3,7 @@ use crate::Session; use crate::components::tests::{ex, idv}; -use figcraft_doc::*; +use figcraft_doc::{NodeId, NodeKind}; use serde_json::json; /// A 1080×1350 "Post" with "Title" and "Price" texts and a "Badge" rectangle. Returns the frame. diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs index e4e84739..26d59e93 100644 --- a/crates/engine/src/textfit.rs +++ b/crates/engine/src/textfit.rs @@ -5,11 +5,11 @@ //! with a no-break space. Every style run scales by the same factor, so mixed sizes keep their //! proportions. -use figcraft_doc::*; +use figcraft_doc::{NodeKind, TextAutoResize, TextData}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; -use crate::params::*; +use crate::params::{boolean, num, req_targets}; use crate::{Result, Session, bad, spec}; const CMD: &str = "text.fit"; diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs index 8ee0f11b..d9ffd5fd 100644 --- a/crates/engine/src/textfit_tests.rs +++ b/crates/engine/src/textfit_tests.rs @@ -3,7 +3,7 @@ use crate::Session; use crate::components::tests::{ex, idv}; -use figcraft_doc::*; +use figcraft_doc::{NodeId, NodeKind}; use serde_json::json; /// A fixed-width (auto-height) headline of `width` px at 64px. Returns its id. From 35fada1161d4a11d3cb02ae652162ec9e49cc2a8 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:01:48 +0700 Subject: [PATCH 042/142] #1009: data.fill and text.fit in smaller helpers (Sonar) --- crates/engine/src/datafill.rs | 230 ++++++++++++++++++++-------------- crates/engine/src/textfit.rs | 111 ++++++++-------- 2 files changed, 194 insertions(+), 147 deletions(-) diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/datafill.rs index 865276a7..6b8376ef 100644 --- a/crates/engine/src/datafill.rs +++ b/crates/engine/src/datafill.rs @@ -3,6 +3,7 @@ //! keys, in one undo step, and optionally each copy exported as a file. Agents make a series with //! one call instead of a duplicate, a lookup and an edit per layer and an export per copy. +use figcraft_doc::NodeId; use serde_json::{Value, json}; use crate::cmd::CommandSpec; @@ -48,89 +49,18 @@ fn fill(s: &mut Session, p: &Value) -> Result { let n = doc.node(src).ok_or_else(|| bad(CMD, "no such layer"))?; (n.width, n.name.clone()) }; - // `formats`: each row once per size, the copy resized so its auto layout reflows. - let formats: Vec> = match get(p, "formats") { - None | Some(Value::Null) => vec![None], - Some(Value::Array(a)) if !a.is_empty() && a.len() <= 12 => a - .iter() - .map(|f| { - let (w, h) = (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)); - let name = f.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_string(); - match (w, h) { - (Some(w), Some(h)) if w.is_finite() && h.is_finite() && (1.0..=20_000.0).contains(&w) && (1.0..=20_000.0).contains(&h) => { - Ok(Some((if name.is_empty() { format!("{}x{}", w as i64, h as i64) } else { name }, w, h))) - } - _ => Err(bad(CMD, "formats: [{name?, width, height}] with sizes from 1 to 20000")), - } - }) - .collect::>()?, - Some(_) => return Err(bad(CMD, "formats: [{name?, width, height}] (1 to 12 sizes)")), - }; + let formats = formats(p)?; let column = formats.iter().flatten().map(|f| f.1).fold(width, f64::max); + let grid = Grid { src, base, column, gap }; let mut missing: Vec = Vec::new(); let ids = s.grouped("Fill copies with data", |s| { let mut ids = Vec::with_capacity(rows.len() * formats.len()); for (i, row) in rows.iter().enumerate() { let mut dy = 0.0; for fmt in &formats { - // Rows go right; a row's formats stack downwards. - let dx = (column + gap) * (i as f64 + 1.0); - let dup = s.execute("edit.duplicate", &json!({"ids": [src], "dx": dx, "dy": dy}))?; - let copy = dup["ids"].get(0).and_then(id_of).ok_or_else(|| bad(CMD, "the frame couldn't be duplicated"))?; - let Some(fields) = row.as_object() else { continue }; - let name = fields.get("name").and_then(Value::as_str).map_or_else(|| format!("{base} {}", i + 1), str::to_string); - let name = match fmt { - Some((suffix, w, h)) => { - s.execute("node.set", &json!({"id": copy, "props": {"width": w, "height": h}}))?; - dy += h + gap; - format!("{name}-{suffix}") - } - None => name, - }; - s.execute("node.set", &json!({"id": copy, "props": {"name": name}}))?; - for (key, value) in fields.iter().filter(|(k, _)| k.as_str() != "name") { - let doc = s.doc()?; - let targets: Vec<_> = - doc.subtree(copy).into_iter().filter(|t| *t != copy && doc.node(*t).is_some_and(|n| n.name == *key)).collect(); - if targets.is_empty() { - if !missing.contains(key) { - missing.push(key.clone()); - } - continue; - } - let (text, fill, visible, fit) = match value { - Value::Object(o) => ( - o.get("text").map(text_of), - o.get("fill").and_then(Value::as_str), - o.get("visible").and_then(Value::as_bool), - o.get("fit").cloned().filter(|f| !matches!(f, Value::Bool(false) | Value::Null)), - ), - v => (Some(text_of(v)), None, None, None), - }; - let mut texts = Vec::new(); - for t in targets { - let is_text = s.doc()?.node(t).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))); - if let Some(text) = text.as_ref().filter(|_| is_text) { - s.execute("text.set", &json!({"id": t, "characters": text}))?; - } - if is_text { - texts.push(t.0); - } - if let Some(f) = fill { - s.execute("node.set", &json!({"id": t, "props": {"fills": [f]}}))?; - } - if let Some(v) = visible { - s.execute("node.set", &json!({"id": t, "props": {"visible": v}}))?; - } - } - // `fit: true` or `{maxLines?, minSize?, orphans?}`: size the text to its box. - if let Some(f) = fit.filter(|_| !texts.is_empty()) { - let mut params = if f.is_object() { f } else { json!({}) }; - params["ids"] = json!(texts); - s.execute("text.fit", ¶ms)?; - } + if let Some(made) = copy_row(s, &grid, (i, row), fmt.as_ref(), &mut dy, &mut missing)? { + ids.push(made); } - ids.push((copy, name)); } } Ok(ids) @@ -139,28 +69,142 @@ fn fill(s: &mut Session, p: &Value) -> Result { // Exports after the edits landed: a failed edit writes no files. A failed export keeps the // copies (one undo step removes them) and is reported with the files that were written. if let Some(dir) = string(p, "dir") { - let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::::new()); - for (copy, name) in &ids { - // One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively, - // as macOS and Windows file systems compare names). - let base = file_name(name); - let stem = - (1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base); - used.push(stem.to_lowercase()); - let path = std::path::Path::new(&dir).join(format!("{stem}.{format}")).to_string_lossy().to_string(); - match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": num_or(p, "scale", 1.0), "path": path})) { - Ok(_) => files.push(path), - Err(e) => errors.push(json!({"id": copy.0, "path": path, "error": e.to_string()})), - } - } - out["files"] = json!(files); - if !errors.is_empty() { - out["errors"] = json!(errors); - } + export_copies(s, &ids, &dir, (&format, num_or(p, "scale", 1.0)), &mut out); } Ok(out) } +/// A size the copies are made in: its name, width and height. +type Format = (String, f64, f64); + +/// `formats`: each row once per size, the copy resized so its auto layout reflows (`None`: the +/// frame's own size). +fn formats(p: &Value) -> Result>> { + match get(p, "formats") { + None | Some(Value::Null) => Ok(vec![None]), + Some(Value::Array(a)) if !a.is_empty() && a.len() <= 12 => a.iter().map(format_of).collect(), + Some(_) => Err(bad(CMD, "formats: [{name?, width, height}] (1 to 12 sizes)")), + } +} + +/// One `formats` entry, named `x` when it has no name. +fn format_of(f: &Value) -> Result> { + let (w, h) = (f.get("width").and_then(Value::as_f64), f.get("height").and_then(Value::as_f64)); + let name = f.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_string(); + match (w, h) { + (Some(w), Some(h)) if w.is_finite() && h.is_finite() && (1.0..=20_000.0).contains(&w) && (1.0..=20_000.0).contains(&h) => { + Ok(Some((if name.is_empty() { format!("{}x{}", w as i64, h as i64) } else { name }, w, h))) + } + _ => Err(bad(CMD, "formats: [{name?, width, height}] with sizes from 1 to 20000")), + } +} + +/// Where the copies go: the frame repeated, its name, the column width and the gap. +struct Grid { + src: NodeId, + base: String, + column: f64, + gap: f64, +} + +/// Row `i` in format `fmt`: duplicate the frame (rows go right; a row's formats stack downwards +/// from `dy`), name the copy and fill its layers. The copy and its name. +fn copy_row( + s: &mut Session, + g: &Grid, + (i, row): (usize, &Value), + fmt: Option<&Format>, + dy: &mut f64, + missing: &mut Vec, +) -> Result> { + let dx = (g.column + g.gap) * (i as f64 + 1.0); + let dup = s.execute("edit.duplicate", &json!({"ids": [g.src], "dx": dx, "dy": *dy}))?; + let copy = dup["ids"].get(0).and_then(id_of).ok_or_else(|| bad(CMD, "the frame couldn't be duplicated"))?; + let Some(fields) = row.as_object() else { return Ok(None) }; + let name = fields.get("name").and_then(Value::as_str).map_or_else(|| format!("{} {}", g.base, i + 1), str::to_string); + let name = match fmt { + Some((suffix, w, h)) => { + s.execute("node.set", &json!({"id": copy, "props": {"width": w, "height": h}}))?; + *dy += h + g.gap; + format!("{name}-{suffix}") + } + None => name, + }; + s.execute("node.set", &json!({"id": copy, "props": {"name": name}}))?; + for (key, value) in fields.iter().filter(|(k, _)| k.as_str() != "name") { + fill_layers(s, copy, (key, value), missing)?; + } + Ok(Some((copy, name))) +} + +/// Give every layer named `key` in `copy` the row's `value`: its text, fill, visibility and fit. +/// A key no layer has goes on `missing` (once). +fn fill_layers(s: &mut Session, copy: NodeId, (key, value): (&str, &Value), missing: &mut Vec) -> Result<()> { + let doc = s.doc()?; + let targets: Vec<_> = doc.subtree(copy).into_iter().filter(|t| *t != copy && doc.node(*t).is_some_and(|n| n.name == key)).collect(); + if targets.is_empty() { + if !missing.iter().any(|m| m == key) { + missing.push(key.to_string()); + } + return Ok(()); + } + let (text, fill, visible, fit) = match value { + Value::Object(o) => ( + o.get("text").map(text_of), + o.get("fill").and_then(Value::as_str), + o.get("visible").and_then(Value::as_bool), + o.get("fit").cloned().filter(|f| !matches!(f, Value::Bool(false) | Value::Null)), + ), + v => (Some(text_of(v)), None, None, None), + }; + let mut texts = Vec::new(); + for t in targets { + let is_text = s.doc()?.node(t).is_some_and(|n| matches!(n.kind, figcraft_doc::NodeKind::Text(_))); + if let Some(text) = text.as_ref().filter(|_| is_text) { + s.execute("text.set", &json!({"id": t, "characters": text}))?; + } + if is_text { + texts.push(t.0); + } + if let Some(f) = fill { + s.execute("node.set", &json!({"id": t, "props": {"fills": [f]}}))?; + } + if let Some(v) = visible { + s.execute("node.set", &json!({"id": t, "props": {"visible": v}}))?; + } + } + // `fit: true` or `{maxLines?, minSize?, orphans?}`: size the text to its box. + if let Some(f) = fit.filter(|_| !texts.is_empty()) { + let mut params = if f.is_object() { f } else { json!({}) }; + params["ids"] = json!(texts); + s.execute("text.fit", ¶ms)?; + } + Ok(()) +} + +/// Export each copy into `dir` as `.` at `scale`, adding `files` (and `errors`) to +/// `out`. +fn export_copies(s: &mut Session, ids: &[(NodeId, String)], dir: &str, (format, scale): (&str, f64), out: &mut Value) { + let (mut files, mut errors, mut used) = (Vec::new(), Vec::new(), Vec::::new()); + for (copy, name) in ids { + // One file per copy: same or same-looking names get " 2", " 3"… (case-insensitively, + // as macOS and Windows file systems compare names). + let base = file_name(name); + let stem = + (1..).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !used.contains(&n.to_lowercase())).unwrap_or(base); + used.push(stem.to_lowercase()); + let path = std::path::Path::new(dir).join(format!("{stem}.{format}")).to_string_lossy().to_string(); + match s.execute("file.export", &json!({"ids": [copy.0], "format": format, "scale": scale, "path": path})) { + Ok(_) => files.push(path), + Err(e) => errors.push(json!({"id": copy.0, "path": path, "error": e.to_string()})), + } + } + out["files"] = json!(files); + if !errors.is_empty() { + out["errors"] = json!(errors); + } +} + /// A cell as text: strings as they are, numbers and booleans as written, null as empty. fn text_of(v: &Value) -> String { match v { diff --git a/crates/engine/src/textfit.rs b/crates/engine/src/textfit.rs index 26d59e93..4ab6c50e 100644 --- a/crates/engine/src/textfit.rs +++ b/crates/engine/src/textfit.rs @@ -65,45 +65,8 @@ fn fit(s: &mut Session, p: &Value) -> Result { let size = t.style.font_size.max(1.0); let min = num(p, "minSize").filter(|v| v.is_finite() && *v >= 1.0).unwrap_or(size * 0.6).min(size); let fits = |m: (usize, f64, bool)| max_lines.is_none_or(|ml| m.0 <= ml) && m.1 <= height_cap + 0.5; - // The largest factor in [min/size, 1] that fits: binary search to a tenth of a pixel. - let (mut lo, mut hi) = (min / size, 1.0); - let k = if fits(measure(t, 1.0, width)) { - 1.0 - } else if !fits(measure(t, lo, width)) { - lo - } else { - for _ in 0..24 { - let mid = (lo + hi) / 2.0; - if fits(measure(t, mid, width)) { - lo = mid - } else { - hi = mid - } - if (hi - lo) * size < 0.1 { - break; - } - } - lo - }; - // Whole-tenth sizes read better in the panel, but never below the floor; text that fits - // keeps its exact size. - let mut k = if k < 1.0 { ((size * k * 10.0).floor() / 10.0).max(min) / size } else { 1.0 }; - let mut m = measure(t, k, width); - let mut orphan = orphans && m.2; - let mut nbsp = false; - if orphan { - // Up to 8% smaller (not below the floor) may pull a word down to the last line. - let mut tries = 0; - while orphan && tries < 8 && size * k * 0.99 >= min { - k *= 0.99; - m = measure(t, k, width); - orphan = m.2; - tries += 1; - } - if orphan { - nbsp = true; - } - } + let k = largest_fit(t, width, (size, min), &fits); + let (k, nbsp) = if orphans { avoid_orphan(t, k, width, (size, min)) } else { (k, false) }; let n = doc.get_mut(id)?; let NodeKind::Text(t) = &mut n.kind else { continue }; let count = t.char_count(); @@ -114,23 +77,63 @@ fn fit(s: &mut Session, p: &Value) -> Result { st.font_size = ((st.font_size * k * 10.0).round() / 10.0).max(floor).clamp(1.0, 5000.0) }); } - // Same character count, so style runs keep their ranges. - // Only inside the last paragraph: a space before a hard break joins nothing on the last line. - let para = t.characters.rfind('\n').map_or(0, |i| i + 1); - let joined = if nbsp && let Some(i) = t.characters.trim_end().get(para..).and_then(|p| p.rfind(' ')).map(|i| para + i) { - t.characters.replace_range(i..i + 1, "\u{a0}"); - Some(i) - } else { - None - }; - let mut after = measure(t, 1.0, width); - // Keeping the last words together must not break the limit: an orphan is better. - if let Some(i) = joined.filter(|_| !fits(after)) { - t.characters.replace_range(i..i + '\u{a0}'.len_utf8(), " "); - after = measure(t, 1.0, width); - } + let after = if nbsp { keep_together(t, width, &fits) } else { measure(t, 1.0, width) }; // Reported from the final text (rounded sizes, the no-break space), which callers rely on. out.push(json!({"id": id.0, "fontSize": (t.style.font_size * 10.0).round() / 10.0, "lines": after.0, "fits": fits(after), "orphan": orphans && after.2})); } Ok(json!({"fitted": out})) } + +/// The largest factor in [min/size, 1] at which `t` fits: a binary search to a tenth of a pixel, +/// then rounded down to a whole-tenth size (which reads better in the panel) but never below the +/// floor. Text that fits keeps its exact size. +fn largest_fit(t: &TextData, width: f64, (size, min): (f64, f64), fits: &dyn Fn((usize, f64, bool)) -> bool) -> f64 { + let (mut lo, mut hi) = (min / size, 1.0); + if fits(measure(t, 1.0, width)) { + return 1.0; + } + if fits(measure(t, lo, width)) { + for _ in 0..24 { + let mid = (lo + hi) / 2.0; + if fits(measure(t, mid, width)) { + lo = mid + } else { + hi = mid + } + if (hi - lo) * size < 0.1 { + break; + } + } + } + ((size * lo * 10.0).floor() / 10.0).max(min) / size +} + +/// Up to 8% smaller than `k` (not below the floor) may pull a word down to an orphaned last line. +/// The factor, and whether an orphan is left (to be kept together with a no-break space). +fn avoid_orphan(t: &TextData, mut k: f64, width: f64, (size, min): (f64, f64)) -> (f64, bool) { + let mut orphan = measure(t, k, width).2; + let mut tries = 0; + while orphan && tries < 8 && size * k * 0.99 >= min { + k *= 0.99; + orphan = measure(t, k, width).2; + tries += 1; + } + (k, orphan) +} + +/// Keep the last two words together with a no-break space, unless that breaks the limit (an +/// orphan is better). Same character count, so style runs keep their ranges. The final layout. +fn keep_together(t: &mut TextData, width: f64, fits: &dyn Fn((usize, f64, bool)) -> bool) -> (usize, f64, bool) { + // Only inside the last paragraph: a space before a hard break joins nothing on the last line. + let para = t.characters.rfind('\n').map_or(0, |i| i + 1); + let Some(i) = t.characters.trim_end().get(para..).and_then(|p| p.rfind(' ')).map(|i| para + i) else { + return measure(t, 1.0, width); + }; + t.characters.replace_range(i..i + 1, "\u{a0}"); + let after = measure(t, 1.0, width); + if fits(after) { + return after; + } + t.characters.replace_range(i..i + '\u{a0}'.len_utf8(), " "); + measure(t, 1.0, width) +} From a7b7dfe1c49115c31e269fdf9192d2121d045be2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:01:55 +0700 Subject: [PATCH 043/142] #1009: data.fill refused before it copies or writes in a read-only live file --- crates/engine/src/datafill.rs | 6 +++++- crates/engine/src/live_access_tests.rs | 7 +++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/datafill.rs b/crates/engine/src/datafill.rs index 6b8376ef..b93e5f59 100644 --- a/crates/engine/src/datafill.rs +++ b/crates/engine/src/datafill.rs @@ -8,7 +8,7 @@ use serde_json::{Value, json}; use crate::cmd::CommandSpec; use crate::params::{get, id, id_of, num_or, string}; -use crate::{Result, Session, bad, spec}; +use crate::{EngineError, Result, Session, bad, spec}; const CMD: &str = "data.fill"; const MAX_ROWS: usize = 500; @@ -31,6 +31,10 @@ fn file_name(name: &str) -> String { } fn fill(s: &mut Session, p: &Value) -> Result { + // Refused before any copy or file is made: a rollback afterwards wouldn't take files back. + if let Some(why) = s.live_read_only(s.ds()?.uid) { + return Err(EngineError::Disabled(CMD.into(), why.into())); + } let src = id(p, "id").ok_or_else(|| bad(CMD, "`id` (the frame to repeat) required"))?; let rows = match get(p, "rows") { Some(Value::Array(a)) if a.iter().all(Value::is_object) && a.len() <= MAX_ROWS => a.clone(), diff --git a/crates/engine/src/live_access_tests.rs b/crates/engine/src/live_access_tests.rs index c2c75bb5..52a08cfd 100644 --- a/crates/engine/src/live_access_tests.rs +++ b/crates/engine/src/live_access_tests.rs @@ -105,6 +105,13 @@ fn a_view_link_makes_the_file_read_only() { refused(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 5, "height": 5}), VIEW_ONLY); refused(&mut s, "node.set", json!({"ids": [card.0], "props": {"name": "Mine"}}), VIEW_ONLY); refused(&mut s, "edit.delete", json!({"ids": [card.0]}), VIEW_ONLY); + // Refused before it makes copies or writes files, which no rollback takes back. + let dir = std::env::temp_dir().join(format!("figcraft-view-fill-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + refused(&mut s, "data.fill", json!({"id": card.0, "rows": [{"name": "Copy"}], "dir": dir.to_string_lossy()}), VIEW_ONLY); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "no file was written"); + let _ = std::fs::remove_dir_all(&dir); refused(&mut s, "tool.set", json!({"tool": "text"}), VIEW_ONLY); assert!(matches!(s.begin_interaction("Move"), Err(EngineError::Disabled(..)))); assert_eq!(*s.doc().unwrap(), before); From d9d9e1d978ff119c336f5396891b653e7eecd323 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:02:11 +0700 Subject: [PATCH 044/142] #1009: Dev Mode refuses data.fill, a design edit recorded as one grouped step --- crates/engine/src/devinspect.rs | 4 +++- crates/engine/src/devmode_behaviour_tests.rs | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/devinspect.rs b/crates/engine/src/devinspect.rs index 16f4bbdc..8d9b1e2e 100644 --- a/crates/engine/src/devinspect.rs +++ b/crates/engine/src/devinspect.rs @@ -224,9 +224,11 @@ pub struct DevItem { /// 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."]; + // Edits recorded as one grouped undo step, so their spec says no history of its own. + const GROUPED: [&str; 1] = ["data.fill"]; match id.strip_prefix("tool.") { Some(t) => matches!(t, "move" | "hand" | "comment"), - None if id == "file.revert" => false, + None if id == "file.revert" || GROUPED.contains(&id) => false, None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history), } } diff --git a/crates/engine/src/devmode_behaviour_tests.rs b/crates/engine/src/devmode_behaviour_tests.rs index 3d85fdb1..38d1a9fb 100644 --- a/crates/engine/src/devmode_behaviour_tests.rs +++ b/crates/engine/src/devmode_behaviour_tests.rs @@ -124,6 +124,7 @@ fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts "tool.place", "file.import", "file.revert", + "data.fill", ] { assert!(!dev_mode_allows(id), "{id} edits the design"); } From 9b78ff8c4a17ac78a253ca1545e0d5b48aa2383f Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:02:24 +0700 Subject: [PATCH 045/142] #989: explicit imports, the .fig test helper named main_component --- crates/format/src/fig/layer_cap_tests.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/format/src/fig/layer_cap_tests.rs b/crates/format/src/fig/layer_cap_tests.rs index 2c562ba7..e7bb769c 100644 --- a/crates/format/src/fig/layer_cap_tests.rs +++ b/crates/format/src/fig/layer_cap_tests.rs @@ -5,7 +5,7 @@ use figcraft_doc::{Document, NodeKind}; use super::kiwi::{Kv, Schema}; -use super::*; +use super::{MAX_FIG_NODES, export, import, layer_budget, map}; type Fields = Vec<(&'static str, Kv<'static>)>; @@ -36,7 +36,7 @@ fn instance(id: u64, page: u64, main: u64) -> Kv<'static> { } /// A main `id` of `layers` rectangles under `parent`. -fn main(id: u64, parent: u64, name: &str, layers: u64) -> Vec> { +fn main_component(id: u64, parent: u64, name: &str, layers: u64) -> Vec> { let mut out = vec![node(id, Some(parent), "SYMBOL", name, vec![("size", xy(10.0, 10.0))])]; out.extend((1..=layers).map(|i| node(id * 10_000 + i, Some(id), "RECTANGLE", "R", vec![("size", xy(1.0, 1.0))]))); out @@ -50,7 +50,7 @@ fn library_message(count: u64, main_layers: u64) -> Kv<'static> { node(2, Some(1), "CANVAS", "Screens", vec![]), node(3, Some(1), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), ]; - nodes.extend(main(20, 3, "Library main", main_layers)); + nodes.extend(main_component(20, 3, "Library main", main_layers)); nodes.extend((0..count).map(|i| instance(1_000 + i, 2, 20))); o(vec![("nodeChanges", Kv::Array(nodes))]) } @@ -96,7 +96,7 @@ fn instances_of_a_main_the_cap_kept_out_say_so() { // file has it. let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])]; nodes.extend((0..20).map(|i| instance(100 + i, 2, 900))); - nodes.extend(main(900, 2, "Local main", 10)); + nodes.extend(main_component(900, 2, "Local main", 10)); let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 60); assert!(warnings.iter().any(|w| w.starts_with("instances whose component was past the layer limit were imported as frames")), "{warnings:?}"); assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); @@ -115,8 +115,8 @@ fn swap_defaults_the_cap_kept_out_say_so() { ]); let mut nodes = vec![node(1, None, "DOCUMENT", "Document", vec![]), node(2, Some(1), "CANVAS", "Page", vec![])]; nodes.push(node(50, Some(2), "SYMBOL", "Card", vec![("size", xy(10.0, 10.0)), ("componentPropDefs", Kv::Array(vec![swap]))])); - nodes.extend(main(60, 2, "Filler", 40)); - nodes.extend(main(900, 2, "Icon main", 10)); + nodes.extend(main_component(60, 2, "Filler", 40)); + nodes.extend(main_component(900, 2, "Icon main", 10)); let (doc, warnings) = import_at(&o(vec![("nodeChanges", Kv::Array(nodes))]), 30); assert!(doc.nodes().any(|n| n.name == "Card")); assert!(!doc.nodes().any(|n| n.name == "Icon main")); From b6489aba3bdd6d22d3509e5e563dad45aff1a76e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:02:25 +0700 Subject: [PATCH 046/142] #989: .fig pages keep room for their top-level layers past large library copies --- crates/format/src/fig/layer_cap_tests.rs | 10 ++++++++ crates/format/src/fig/map.rs | 29 +++++++++++++++++------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/format/src/fig/layer_cap_tests.rs b/crates/format/src/fig/layer_cap_tests.rs index e7bb769c..1b869ea2 100644 --- a/crates/format/src/fig/layer_cap_tests.rs +++ b/crates/format/src/fig/layer_cap_tests.rs @@ -136,3 +136,13 @@ fn a_whole_file_past_the_cap_keeps_its_library_links_and_says_so_once() { assert!(!warnings.iter().any(|w| w.contains("aren't in the file")), "{warnings:?}"); assert!(linked_to(&doc, "Library main") >= 190, "{warnings:?}"); } + +#[test] +fn a_library_main_larger_than_the_cap_leaves_the_pages_room() { + // A main of 300 layers with room for 100: mapped first, it took the whole budget and the page + // came out empty. + let (doc, warnings) = import_at(&library_message(5, 300), 100); + let page = doc.nodes().find(|n| n.name == "Screens").expect("the page"); + let top = page.children.len(); + assert!(top >= 1, "the page kept {top} layers: {warnings:?}"); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 36348ded..ca491e0b 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -404,6 +404,9 @@ struct Mapper<'a> { /// Most layers produced or scanned, instance expansion included; the one place the cap comes /// from (see [`super::layer_budget`]). max_nodes: usize, + /// Layers kept back for the visible pages' top level while library copies are mapped, so the + /// copies can't take the whole budget and leave the pages empty. + reserved: usize, /// Vector networks, each built once: the layers that show one (every instance of a main /// holding it) name it by `vectorNetworkRef`. Kept out of the JSON, where a large file's /// networks took gigabytes. @@ -481,6 +484,7 @@ impl<'a> Mapper<'a> { referenced_mains: BTreeSet::new(), mapped_mains: BTreeSet::new(), max_nodes, + reserved: 0, networks: Vec::new(), network_ids: HashMap::new(), } @@ -563,6 +567,11 @@ impl<'a> Mapper<'a> { scope } + /// How many layers may be produced now: the cap, less what is kept for the visible pages. + fn budget(&self) -> usize { + self.max_nodes.saturating_sub(self.reserved) + } + /// The DOCUMENT with its pages. fn document(&mut self) -> Value { let doc_id = self.nodes.iter().find(|(_, n)| n.get("type").and_then(Kv::str) == Some("DOCUMENT")).map(|(id, _)| id.clone()); @@ -589,11 +598,15 @@ impl<'a> Mapper<'a> { // last layers are skipped rather than the mains every instance of them needs (#981). let visible_pages: Vec = visible.iter().map(|(p, _)| p.clone()).collect(); let required_mains = self.referenced_component_mains(&visible_pages); + // The pages and their top-level layers, up to half the cap, stay for the pages. + let top: usize = visible_pages.iter().map(|p| 1 + self.children(p).len()).sum(); + self.reserved = top.min(self.max_nodes / 2); for (id, canvas) in internal { if let Some(context) = self.library_context(&id, canvas, &required_mains) { self.library_contexts.push(context); } } + self.reserved = 0; let out: Vec = visible.into_iter().map(|(p, n)| self.page(&p, n)).collect(); if out.is_empty() { self.warn("the file has no pages"); @@ -838,7 +851,7 @@ impl<'a> Mapper<'a> { self.warn(format!("layers nested deeper than {MAX_DEPTH} levels were skipped")); return None; } - if self.count >= self.max_nodes { + if self.count >= self.budget() { let max = self.max_nodes; self.warn(format!("the file has more than {max} layers; the rest were skipped")); return None; @@ -875,7 +888,7 @@ impl<'a> Mapper<'a> { let connector_snapshot = own == "CONNECTOR" && self.connector_snapshot_supported(&view); // Only a connector with a saved label needs the frame wrapper; an unlabelled one stays a single vector. let connector_terminal = - connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.max_nodes); + connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget()); // A supported connector snapshot has its own note below instead of the generic stand-in one. let stand = stand_in(own).filter(|_| !connector_snapshot); let ty = match own { @@ -1023,7 +1036,7 @@ impl<'a> Mapper<'a> { fn shape_with_text(&mut self, o: &mut Obj, v: &View<'a>, own: &str, rest_id: &str) -> Option { let shape = v.str("shapeWithTextType").unwrap_or("SQUARE"); let outlined = !v.list("fillGeometry").is_empty() || !v.list("strokeGeometry").is_empty(); - if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.max_nodes { + if own != "SHAPE_WITH_TEXT" || matches!(shape, "SQUARE" | "ROUNDED_RECTANGLE") || !outlined || self.count >= self.budget() { return None; } let mut s = Obj::new(); @@ -1055,7 +1068,7 @@ impl<'a> Mapper<'a> { /// layer filling it, inset a little; centred unless it's a note. fn inner_text(&mut self, o: &Obj, v: &View<'a>, rest_id: &str, note: bool) -> Option { let chars = own_text(v)?; - if self.count >= self.max_nodes { + if self.count >= self.budget() { return None; } self.count += 1; @@ -1190,7 +1203,7 @@ impl<'a> Mapper<'a> { out.insert("opacity".into(), json!(root_opacity * line_opacity)); } if connector_has_saved_label(v) { - let reason = if self.count >= self.max_nodes { "node budget" } else { "depth limit" }; + let reason = if self.count >= self.budget() { "node budget" } else { "depth limit" }; self.warn(format!("connector label was skipped: {reason} prevents nested snapshot children")); } } @@ -1220,7 +1233,7 @@ impl<'a> Mapper<'a> { /// Snapshot the straight, manual connector contract carried by its generated children. fn connector_snapshot_children(&mut self, v: &View<'a>, rest_id: &str) -> Vec { - if self.count >= self.max_nodes { + if self.count >= self.budget() { self.warn(format!("the file has more than {} layers; connector snapshot children were skipped", self.max_nodes)); return Vec::new(); } @@ -1283,7 +1296,7 @@ impl<'a> Mapper<'a> { }; let background_view = View::of(background).with([background_style]); if !background_view.list("fillPaints").is_empty() { - if self.count >= self.max_nodes { + if self.count >= self.budget() { self.warn(format!("the file has more than {} layers; connector label background was skipped", self.max_nodes)); } else { let mut rect = Obj::new(); @@ -1311,7 +1324,7 @@ impl<'a> Mapper<'a> { transform: Affine, view: &View<'a>, ) { - if self.count >= self.max_nodes { + if self.count >= self.budget() { self.warn(format!("the file has more than {} layers; connector label was skipped", self.max_nodes)); return; } From a6949ec2e9bf2898af8c7f4f0941acce82e63630 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 13:02:34 +0700 Subject: [PATCH 047/142] #1010: a zip export stops at 256 MB of files, which are all in memory at once --- crates/engine/src/cmd/file.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 53f51cc2..d74b8dae 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -1166,6 +1166,20 @@ fn size_param(cmd: &str, p: &Value) -> Result> { /// Most files one `file.exportLayers` writes. const MAX_EXPORT_FILES: usize = 1000; +/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy +/// are all in memory at once (in the browser too). +const MAX_ZIP_BYTES: usize = 256 << 20; + +/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together. +fn add_member<'a>(members: &mut Vec<(&'a str, Vec)>, name: &'a str, bytes: Vec, limit: usize) -> Result<()> { + let total = members.iter().map(|(_, b)| b.len()).sum::().saturating_add(bytes.len()); + if total > limit { + let mb = limit >> 20; + return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder"))); + } + members.push((name, bytes)); + Ok(()) +} /// One file of a layer export: the layer, its setting and the file name. pub struct ExportJob { @@ -1259,7 +1273,7 @@ fn export_layers(s: &mut Session, p: &Value) -> Result { write(&path, &bytes)?; f["path"] = json!(path); } - None if zip => members.push((job.file.as_str(), bytes)), + None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?, None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), } files.push(f); @@ -1803,3 +1817,18 @@ mod staging_write_tests { let _ = std::fs::remove_dir_all(&d); } } + +#[cfg(test)] +mod zip_cap_tests { + use super::add_member; + + #[test] + fn zip_members_stop_at_the_limit_and_keep_what_fit() { + let mut members = Vec::new(); + add_member(&mut members, "a.png", vec![0; 6], 10).unwrap(); + add_member(&mut members, "b.png", vec![0; 4], 10).unwrap(); + let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string(); + assert!(err.contains("as one zip"), "{err}"); + assert_eq!(members.iter().map(|(n, _)| *n).collect::>(), ["a.png", "b.png"]); + } +} From e3e39bbc4dc94d58a70775fae17ee0d7a486a95f Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 24 Oct 2026 13:33:26 +0700 Subject: [PATCH 048/142] M6: Dev Mode measurements kept on the canvas, a read-only layers panel (#1248) * M6: Dev Mode measurements kept on the canvas, a read-only layers panel Measurements placed between two layers, along a side or to the layer around one (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. In Dev Mode the layers panel only selects and expands. * M6: Dev Mode measurements: explicit imports, a measurement row of its own (Sonar) --- ROADMAP.md | 6 +- crates/collab/src/change.rs | 1 + crates/collab/src/merge.rs | 12 +- crates/doc/src/lib.rs | 6 + crates/doc/src/node.rs | 15 ++ crates/engine/src/cmd/mod.rs | 1 + crates/engine/src/components.rs | 5 + crates/engine/src/dev_measurements.rs | 222 ++++++++++++++++++++ crates/engine/src/dev_measurements_tests.rs | 204 ++++++++++++++++++ crates/engine/src/devinspect.rs | 8 +- crates/engine/src/distance.rs | 2 +- crates/engine/src/inspect.rs | 3 + crates/engine/src/lib.rs | 3 + crates/engine/src/tests.rs | 4 + crates/format/src/fig/export.rs | 9 +- crates/ui-egui/locales/bn.toml | 13 ++ crates/ui-egui/locales/cs.toml | 13 ++ crates/ui-egui/locales/de.toml | 13 ++ crates/ui-egui/locales/en.toml | 10 + crates/ui-egui/locales/es-419.toml | 2 + crates/ui-egui/locales/es.toml | 13 ++ crates/ui-egui/locales/fi.toml | 13 ++ crates/ui-egui/locales/fr.toml | 13 ++ crates/ui-egui/locales/id.toml | 13 ++ crates/ui-egui/locales/ja.toml | 13 ++ crates/ui-egui/locales/ka.toml | 13 ++ crates/ui-egui/locales/ko.toml | 13 ++ crates/ui-egui/locales/nl.toml | 13 ++ crates/ui-egui/locales/pl.toml | 13 ++ crates/ui-egui/locales/pt-BR.toml | 13 ++ crates/ui-egui/locales/ru.toml | 13 ++ crates/ui-egui/locales/sv.toml | 13 ++ crates/ui-egui/locales/tr.toml | 13 ++ crates/ui-egui/locales/zh-Hans.toml | 13 ++ crates/ui-egui/src/canvas.rs | 1 + crates/ui-egui/src/dev_mode.rs | 130 +++++++++++- crates/ui-egui/src/devmode_tail_tests.rs | 78 +++++++ crates/ui-egui/src/icons.rs | 9 + crates/ui-egui/src/layers.rs | 24 ++- crates/ui-egui/src/measure_overlay.rs | 7 +- crates/ui-egui/src/menus.rs | 4 +- crates/ui-egui/src/right.rs | 2 + docs/control-protocol.md | 3 +- docs/dev-mode-parity.md | 15 +- docs/file-format-parity.md | 4 +- docs/gaps.md | 20 +- docs/mcp.md | 20 +- docs/target-app-parity.md | 2 +- 48 files changed, 1005 insertions(+), 48 deletions(-) create mode 100644 crates/engine/src/dev_measurements.rs create mode 100644 crates/engine/src/dev_measurements_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 05b7ed65..69d3bf73 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: a board area row and progress entry; `fill`/`stroke` props accept `"none"` and `null` as no paint (#1036); 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 (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) | @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **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. @@ -328,6 +329,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 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 | 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/doc/src/lib.rs b/crates/doc/src/lib.rs index d4c556b9..70797dd8 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -856,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); 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/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/components.rs b/crates/engine/src/components.rs index 91402b58..87496c37 100644 --- a/crates/engine/src/components.rs +++ b/crates/engine/src/components.rs @@ -302,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`). @@ -720,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); @@ -797,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(); @@ -1591,6 +1595,7 @@ fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option, +} + +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/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/lib.rs b/crates/engine/src/lib.rs index 2cd224e7..3fde12eb 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; @@ -1657,6 +1658,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; 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/format/src/fig/export.rs b/crates/format/src/fig/export.rs index 234e1459..dad09d95 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -1134,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"); diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index bc7c948c..364c9632 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" = "বাইনারি অ্যাসেট সরানো হয়েছে" @@ -683,6 +689,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" = "ব্যবহারের পরেও টুল নির্বাচিত রাখুন" @@ -772,6 +779,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" = "ব্রাঞ্চটিকে প্রধান ফাইলের সাথে মার্জ করুন (একটি আনডু ধাপ) এবং আর্কাইভ করুন" @@ -1087,6 +1095,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" = "সর্বনিম্ন এবং সর্বোচ্চ প্রস্থ সরান" @@ -1420,6 +1429,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" = "দৃশ্যমানতা টগল করুন" @@ -1776,6 +1786,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 e7a2dec1..b05427b2 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" @@ -668,6 +674,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ý" @@ -757,6 +764,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" @@ -1066,6 +1074,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 +1400,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" @@ -1840,6 +1850,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" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 816fecf1..da2a177a 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" @@ -652,6 +658,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" @@ -734,6 +741,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" @@ -1034,6 +1042,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 +1361,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" @@ -1795,6 +1805,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" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 83cfcbe1..e1ed336e 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" @@ -688,6 +694,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" @@ -778,6 +785,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" @@ -1097,6 +1105,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 +1448,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" 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 e172584c..29596514 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" @@ -689,6 +695,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" @@ -779,6 +786,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" @@ -1098,6 +1106,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 +1449,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" @@ -1904,6 +1914,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" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index f18ffeaf..fe979678 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" @@ -685,6 +691,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" @@ -774,6 +781,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" @@ -1091,6 +1099,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 +1434,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" @@ -1883,6 +1893,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 e5823983..bb771e9f 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" @@ -685,6 +691,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" @@ -774,6 +781,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" @@ -1091,6 +1099,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 +1435,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" @@ -1884,6 +1894,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" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index c06f2dff..a516db9f 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" @@ -672,6 +678,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" @@ -761,6 +768,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" @@ -1070,6 +1078,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 +1404,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" @@ -1844,6 +1854,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" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index d7ee7287..641ec1f6 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" = "削除されたバイナリアセット" @@ -679,6 +685,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" = "使用後もツールを選択したままにする" @@ -768,6 +775,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 回の取り消し単位)、アーカイブする" @@ -1078,6 +1086,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 +1418,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" = "表示を切り替え" @@ -1864,6 +1874,9 @@ status = "draft — needs native review" "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/ka.toml b/crates/ui-egui/locales/ka.toml index 42f78be6..b556e8d7 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" = "წაშლილი ბინარული ფაილები" @@ -685,6 +691,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" = "ხელსაწყოს არჩეულად დატოვება გამოყენების შემდეგ" @@ -774,6 +781,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" = "განშტოების მთავარ ფაილთან შერწყმა (დაბრუნების ერთი ნაბიჯი) და მისი დაარქივება" @@ -1089,6 +1097,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" = "მინ. და მაქს. სიგანის მოხსნა" @@ -1422,6 +1431,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" = "ხილვადობის ჩართვა/გამორთვა" @@ -1879,6 +1889,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 66eb54c2..053348f2 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" = "제거된 바이너리 에셋" @@ -648,6 +654,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" = "사용 후에도 도구 선택 유지" @@ -730,6 +737,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" = "브랜치를 메인 파일에 병합(실행 취소 한 번)하고 보관" @@ -1028,6 +1036,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 +1352,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" = "표시 여부 전환" @@ -1786,6 +1796,9 @@ status = "draft — needs native review" "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/nl.toml b/crates/ui-egui/locales/nl.toml index 84f13545..bc4e0cfa 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" @@ -685,6 +691,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" @@ -774,6 +781,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" @@ -1091,6 +1099,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 +1434,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" @@ -1882,6 +1892,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 7f301a6e..25f287f9 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" @@ -682,6 +688,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" @@ -771,6 +778,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ą" @@ -1087,6 +1095,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 +1429,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ść" @@ -1878,6 +1888,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 22fffb17..b0b8dea0 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" @@ -647,6 +653,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" @@ -729,6 +736,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" @@ -1027,6 +1035,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 +1351,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" @@ -1899,6 +1909,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" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index baba56fd..ed1cc0eb 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" = "Удалено бинарных ресурсов" @@ -686,6 +692,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" = "Оставлять инструмент выбранным после использования" @@ -776,6 +783,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" = "Объединить ветку с основным файлом (один шаг отмены) и архивировать её" @@ -1094,6 +1102,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 +1442,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" = "Переключить видимость" @@ -1895,6 +1905,9 @@ name = "Русский" "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/sv.toml b/crates/ui-egui/locales/sv.toml index be25d38e..bcb819e5 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" @@ -679,6 +685,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" @@ -768,6 +775,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" @@ -1079,6 +1087,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 +1419,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" @@ -1864,6 +1874,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" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 5d15c9c9..4275f06a 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" @@ -672,6 +678,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" @@ -761,6 +768,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" @@ -1070,6 +1078,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 +1404,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" @@ -1844,6 +1854,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" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 7add99c5..da070c0e 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" = "删除的二进制资源" @@ -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" = "将分支合并到主文件(一步撤销)并归档" @@ -1029,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" = "移除最小和最大宽度" @@ -1344,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" = "切换可见性" @@ -1787,6 +1797,9 @@ status = "draft — needs native review" "dev.focus" = "聚焦视图" "dev.resolveChange" = "解决更改" "dev.unfocus" = "退出聚焦视图" +"devMode.addMeasurement" = "添加测量" +"devMode.measurements" = "列出测量" +"devMode.removeMeasurement" = "移除测量" "devResource.add" = "添加开发资源" "devResource.edit" = "编辑开发资源" "devResource.list" = "列出开发资源" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 0b13f5a8..5ced1edc 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -244,6 +244,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/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index dcece9b2..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 { @@ -690,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/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/icons.rs b/crates/ui-egui/src/icons.rs index 8275df5e..2e5af4cb 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -209,6 +209,8 @@ icons! { Connector, 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). @@ -1180,6 +1182,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. 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/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/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/right.rs b/crates/ui-egui/src/right.rs index 6738f300..e3aba95b 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -2243,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; }; @@ -2251,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/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 ff796416..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,7 +115,7 @@ 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), diff --git a/docs/gaps.md b/docs/gaps.md index 40f97032..f4f29c07 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: 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 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) | @@ -386,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 @@ -471,6 +476,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 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 ccaeb401..8ffe773c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1392,6 +1392,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, @@ -1428,8 +1444,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/target-app-parity.md b/docs/target-app-parity.md index a7d3402e..e196eb64 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -110,7 +110,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 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 | From 0b3ce29eb333ee6c611649836f4d21606778fc8c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 14:14:37 +0700 Subject: [PATCH 049/142] Canvas: hover distances go when the pointer leaves the canvas --- crates/engine/src/measure_tests.rs | 18 ++++++++++++++++++ crates/engine/src/tools.rs | 10 ++++++++++ crates/ui-egui/src/canvas.rs | 4 ++-- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/measure_tests.rs b/crates/engine/src/measure_tests.rs index 539386bd..9f793721 100644 --- a/crates/engine/src/measure_tests.rs +++ b/crates/engine/src/measure_tests.rs @@ -250,3 +250,21 @@ fn measure_distances_command() { assert!(s.execute("measure.distances", &json!({"ids": [f]})).is_err(), "top level: pass `to`"); assert!(s.execute("measure.distances", &json!({"ids": [r], "to": 99999})).is_err()); } + +#[test] +fn leaving_the_canvas_clears_the_hover_measurement() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, 50.0, 50.0, None); + rect(&mut s, 100.0, 0.0, 50.0, 50.0, None); + ex(&mut s, "select.set", json!({"ids": [a]})); + assert!(!hover(&mut s, 125.0, 25.0, true, false, false).is_empty()); + assert!(s.tool.overlay.measure_box.is_some()); + s.pointer_left(); + assert!(s.tool.overlay.guides.is_empty(), "{:?}", s.tool.overlay.guides); + assert_eq!(s.tool.overlay.measure_box, None); + // Dev Mode measures without Alt; leaving clears those too. + s.tool.dev_mode = true; + assert!(!hover(&mut s, 125.0, 25.0, false, false, false).is_empty()); + s.pointer_left(); + assert!(s.tool.overlay.guides.is_empty()); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 86feed4f..15a244f7 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -941,6 +941,16 @@ pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: ( } impl Session { + /// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode + /// measured and the box measured to, a vector region's highlight), unless a gesture holds it. + pub fn pointer_left(&mut self) { + if !self.tool.busy() { + self.tool.overlay.guides.clear(); + self.tool.overlay.measure_box = None; + } + self.vector_pointer_left(); + } + /// What a marquee starting at page point `p` selects inside: the top-level frame it starts in /// (its children), or the focus view's frame. pub(crate) fn marquee_within(&self, p: Point) -> Result> { diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5ced1edc..8942bbac 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -542,7 +542,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } // Leaving the canvas: nothing is hovered there any more. if !app.canvas.dragging && std::mem::take(&mut app.canvas.hovering) { - app.session.vector_pointer_left(); + app.session.pointer_left(); } // A pan whose button was released off the canvas is over. if !ctx.input(|i| i.pointer.button_down(egui::PointerButton::Middle) || i.pointer.primary_down()) { @@ -640,7 +640,7 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { } else { // Something over the canvas took the pointer. if std::mem::take(&mut app.canvas.hovering) { - app.session.vector_pointer_left(); + app.session.pointer_left(); } None }; From ce8e9a7d60adca72a32414ea2aa220ecdab2fe8e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 00:48:23 -0700 Subject: [PATCH 050/142] M3: apply and detach color variables from Selection colors A Selection colors row could take a color style but not a color variable, and a row linked to a style or variable could not be unlinked from there. selection.bindColorVariable binds a color variable to every solid fill and stroke slot a row stands for. It replaces a color style on that list and any earlier binding of the slot. A text range with fills of its own takes the variable's value in the layer's modes. Gradients and slots outside the variable's scopes are refused. selection.detachColor unlinks the style or variable of a row's slots and keeps their colors. In the Design panel, the Styles menu of an unlinked row lists the document's color variables after its color styles, and a linked row shows a Detach button in the Styles button's place. --- crates/engine/src/selection_colors.rs | 122 ++++++++++++++++- crates/engine/src/tests.rs | 1 + crates/engine/tests/it/selection_colors.rs | 150 ++++++++++++++++++++- crates/ui-egui/locales/cs.toml | 2 + crates/ui-egui/locales/de.toml | 2 + crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/locales/fi.toml | 2 + crates/ui-egui/locales/fr.toml | 2 + crates/ui-egui/locales/id.toml | 2 + crates/ui-egui/locales/ja.toml | 2 + crates/ui-egui/locales/ka.toml | 2 + crates/ui-egui/locales/ko.toml | 2 + crates/ui-egui/locales/nl.toml | 2 + crates/ui-egui/locales/pl.toml | 2 + crates/ui-egui/locales/pt-BR.toml | 2 + crates/ui-egui/locales/ru.toml | 2 + crates/ui-egui/locales/sv.toml | 2 + crates/ui-egui/locales/tr.toml | 2 + crates/ui-egui/locales/zh-Hans.toml | 2 + crates/ui-egui/src/selection_colors.rs | 103 +++++++++++--- docs/mcp.md | 7 + docs/ui-parity.md | 3 +- 22 files changed, 396 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/selection_colors.rs b/crates/engine/src/selection_colors.rs index 875c6416..ce349c03 100644 --- a/crates/engine/src/selection_colors.rs +++ b/crates/engine/src/selection_colors.rs @@ -5,11 +5,12 @@ use std::collections::{BTreeMap, BTreeSet}; use figcraft_color::{Paint, PaintKind}; -use figcraft_doc::{Document, NodeId, NodeKind, StyleKind}; +use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableType, VariableValue}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint}; +use crate::cmd::styles::rebind_paint_alpha; use crate::params::*; use crate::{CommandSpec, Result, Session, bad, spec}; @@ -130,6 +131,20 @@ pub fn specs() -> Vec { true, apply_style ), + spec!( + "selection.bindColorVariable", + "Apply selection color variable", + "{targets, variable (id or name)}; binds the color variable to matching solid fill/stroke slots, replacing a color style there (text ranges with fills of their own take its value in the layer's modes)", + true, + bind_variable + ), + spec!( + "selection.detachColor", + "Detach selection color", + "{targets}; unlinks the color style or variable of matching fill/stroke lists, keeping their values", + true, + detach + ), spec!( "selection.selectColor", "Select layers with color", @@ -210,6 +225,111 @@ fn apply_style(s: &mut Session, p: &Value) -> Result { Ok(json!({"ok": true})) } +/// The binding key (`fills//color` or `strokes//color`) of the slot a target stands for, when the +/// target is the node's own list or a text range that shows the node's fills. Returns None for a range +/// with fills of its own, since those fills hold no link. +fn list_field(doc: &Document, target: &PaintTarget) -> Option { + if let Some((start, _)) = target.range + && doc.node(target.id)?.kind.text()?.style_at(start).fills.is_some() + { + return None; + } + let list = if target.fills { BoundList::Fills } else { BoundList::Strokes }; + Some(format!("{}/{}/color", list.prefix(), target.index)) +} + +/// Remove every binding key of the slot (`fill` and `fills/0/color` name the same one); true when +/// one was there. +fn unbind_slot(n: &mut figcraft_doc::Node, fills: bool, index: usize) -> bool { + let list = if fills { BoundList::Fills } else { BoundList::Strokes }; + let before = n.bound.len(); + n.bound.retain(|key, _| list_binding(key).is_none_or(|(l, i, field)| !(l == list && i == index && field == "color"))); + n.bound.len() != before +} + +/// Why `variable` can't be applied to `targets` (`selection.bindColorVariable`), if it can't: a +/// variable that isn't a color, a gradient, or a slot outside the variable's scopes (a variable +/// hidden from pickers has none). +pub fn bind_refusal(doc: &Document, targets: &[PaintTarget], variable: &str) -> Option { + let Some(var) = doc.variables.get(variable) else { return Some("no such variable".into()) }; + if var.kind != VariableType::Color { + return Some("a color variable is required".into()); + } + for target in targets { + if !target.paint(doc).is_some_and(|paint| matches!(paint.kind, PaintKind::Solid { .. })) { + return Some("color variables bind to solid paints".into()); + } + let list = if target.fills { BoundList::Fills } else { BoundList::Strokes }; + let field = format!("{}/{}/color", list.prefix(), target.index); + if !crate::cmd::variables::field_takes(doc, target.id, &field, var.kind) { + return Some(format!("`{field}` doesn't take a color variable")); + } + if let Some(scope) = crate::cmd::variables::field_scope(doc, target.id, &field) + && !var.in_scope(scope) + { + return Some(format!("the variable isn't scoped to {scope}")); + } + } + None +} + +fn bind_variable(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "selection.bindColorVariable"; + let targets = checked_targets(s, p, CMD)?; + let name = req_string(CMD, p, "variable")?; + let doc = s.doc()?; + if let Some(why) = bind_refusal(doc, &targets, &name) { + return Err(bad(CMD, why)); + } + let vid = doc.variables.get(&name).map(|v| v.id.clone()).ok_or_else(|| bad(CMD, "no such variable"))?; + for target in &targets { + if let Some(field) = list_field(s.doc()?, target) { + let n = s.doc_mut()?.get_mut(target.id)?; + if target.fills { + n.styles.fill = None; + } else { + n.styles.stroke = None; + } + if !unbind_slot(n, target.fills, target.index) { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true); + } + n.bound.insert(field, vid.clone()); + continue; + } + // A range's own fills can't hold a binding: they take the value, as a range edit does. + let doc = s.doc()?; + let Some(VariableValue::Color(color)) = doc.variables.resolve(&vid, &doc.modes_for(target.id)) else { continue }; + let mut paints = target.paints(doc).ok_or_else(|| bad(CMD, "invalid target"))?; + let Some(slot) = paints.get_mut(target.index) else { return Err(bad(CMD, "invalid paint index")) }; + let own = if slot.color_alpha > 0.0 { (slot.opacity / slot.color_alpha).clamp(0.0, 1.0) } else { 1.0 }; + slot.kind = PaintKind::Solid { color: color.with_alpha(1.0) }; + slot.opacity = own * color.a; + slot.color_alpha = color.a; + let Some((start, end)) = target.range else { continue }; + if let NodeKind::Text(t) = &mut s.doc_mut()?.get_mut(target.id)?.kind { + t.restyle(start, end, |style| style.fills = Some(paints.clone())); + } + } + Ok(json!({"ok": true})) +} + +fn detach(s: &mut Session, p: &Value) -> Result { + let targets = checked_targets(s, p, "selection.detachColor")?; + for target in &targets { + let Some(field) = list_field(s.doc()?, target) else { continue }; + let n = s.doc_mut()?.get_mut(target.id)?; + if target.fills { + n.styles.fill = None; + } else { + n.styles.stroke = None; + } + if unbind_slot(n, target.fills, target.index) { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false); + } + } + Ok(json!({"ok": true})) +} + fn select_color(s: &mut Session, p: &Value) -> Result { let ts = checked_targets(s, p, "selection.selectColor")?; let ids: Vec<_> = ts.iter().map(|t| t.id).collect::>().into_iter().collect(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 14a6a3bc..09500e07 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -423,6 +423,7 @@ fn every_command_survives_junk_params() { // Selection color slots: malformed indexes, ranges and paints. json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}), json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}), + json!({"targets": [{"id": 2, "fills": false, "index": 99999999999u64, "range": null}], "variable": ["x"]}), // Smart-selection reorder targets and modes as junk. json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}), json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}), diff --git a/crates/engine/tests/it/selection_colors.rs b/crates/engine/tests/it/selection_colors.rs index 4e559d27..d676b42f 100644 --- a/crates/engine/tests/it/selection_colors.rs +++ b/crates/engine/tests/it/selection_colors.rs @@ -156,7 +156,9 @@ fn invalid_or_stale_targets_roll_back_atomically() { assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err()); assert_eq!(fill(&s, a), "FF0000"); assert_eq!(s.undo_depth(), before); - for command in ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle"] { + for command in + ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle", "selection.bindColorVariable", "selection.detachColor"] + { for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] { assert!(s.execute(command, ¶ms).is_err()); } @@ -270,3 +272,149 @@ fn whole_text_fill_keeps_a_color_style_link() { run(&mut s, "style.update", json!({"style":style,"value":"#00ff00"})); assert_eq!(fill(&s, id), "00FF00"); } + +fn theme_variable(s: &mut Session, name: &str, hex: &str) -> Value { + if s.doc().unwrap().variables.collection("Theme").is_none() { + run(s, "variable.createCollection", json!({"name":"Theme"})); + } + run(s, "variable.create", json!({"collection":"Theme","name":name,"type":"color","value":hex}))["id"].clone() +} + +#[test] +fn applying_a_color_variable_binds_matching_fill_and_stroke_slots_only() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#0000ff"); + run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"})); + let v = theme_variable(&mut s, "Brand", "#00ff00"); + let red = group(&s, &[a, b], "FF0000"); + let before = s.undo_depth(); + run(&mut s, "selection.bindColorVariable", json!({"targets":red.targets,"variable":"Brand"})); + assert_eq!(fill(&s, a), "00FF00"); + assert_eq!(fill(&s, b), "0000FF"); + assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00"); + assert_eq!(s.doc().unwrap().node(a).unwrap().bound.get("fills/0/color").map(String::as_str), v.as_str()); + assert_eq!(s.doc().unwrap().node(b).unwrap().bound.get("strokes/0/color").map(String::as_str), v.as_str()); + assert_eq!(s.undo_depth().0, before.0 + 1); + run(&mut s, "variable.set", json!({"variable":v,"value":"#ffff00"})); + assert_eq!(fill(&s, a), "FFFF00"); + assert_eq!(fill(&s, b), "0000FF"); + let bound = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.variable.is_some()).unwrap(); + assert_eq!(bound.targets.len(), 2); + s.undo().unwrap(); + s.undo().unwrap(); + assert_eq!(fill(&s, a), "FF0000"); + assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty()); +} + +#[test] +fn a_color_variable_replaces_a_style_link_and_an_earlier_binding() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#ff0000"); + let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}))["id"].clone(); + run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"})); + let old = theme_variable(&mut s, "Old", "#ff0000"); + run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":old})); + let new = theme_variable(&mut s, "New", "#0000ff"); + let targets: Vec<_> = collect(s.doc().unwrap(), &[a, b]).into_iter().flat_map(|g| g.targets).collect(); + run(&mut s, "selection.bindColorVariable", json!({"targets":targets,"variable":new})); + for id in [a, b] { + let n = s.doc().unwrap().node(id).unwrap(); + assert!(n.styles.fill.is_none()); + assert_eq!(n.bound.len(), 1, "one binding for the slot: {:?}", n.bound); + assert_eq!(n.bound.values().next().map(String::as_str), new.as_str()); + assert_eq!(fill(&s, id), "0000FF"); + } + assert_eq!(collect(s.doc().unwrap(), &[a, b]).len(), 1); +} + +#[test] +fn a_color_variable_on_a_text_range_with_its_own_fills_takes_its_value() { + let mut s = Session::new(); + let id = NodeId(run(&mut s, "text.create", json!({"text":"abc","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap()); + run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"fontSize":30,"textFills":["#0000ff"]})); + theme_variable(&mut s, "Brand", "#00ff0080"); + let blue = group(&s, &[id], "0000FF"); + run(&mut s, "selection.bindColorVariable", json!({"targets":blue.targets,"variable":"Brand"})); + let n = s.doc().unwrap().node(id).unwrap(); + let t = n.kind.text().unwrap(); + let paint = &t.style_at(1).fills.as_ref().unwrap()[0]; + assert_eq!(paint.solid_color().unwrap().to_css_hex(), "#00FF0080"); + assert_eq!(t.style_at(1).font_size, 30.0); + assert!(n.bound.is_empty()); + assert_eq!(fill(&s, id), "FF0000"); +} + +#[test] +fn detaching_a_selection_color_keeps_its_value() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#ff0000"); + let c = rectangle(&mut s, "#0000ff"); + let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"}))["id"].clone(); + run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"})); + let v = theme_variable(&mut s, "Accent", "#ffff0080"); + run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":v})); + run(&mut s, "variable.bind", json!({"id":c,"field":"fill","variable":v})); + let colors = collect(s.doc().unwrap(), &[a, b]); + assert_eq!(colors.len(), 2); + let before = s.undo_depth(); + for g in &colors { + run(&mut s, "selection.detachColor", json!({"targets":g.targets})); + } + assert_eq!(s.undo_depth().0, before.0 + 2); + assert!(s.doc().unwrap().node(a).unwrap().styles.fill.is_none()); + assert!(s.doc().unwrap().node(b).unwrap().bound.is_empty()); + assert!(!s.doc().unwrap().node(c).unwrap().bound.is_empty()); + assert_eq!(fill(&s, a), "00FF00"); + assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap().to_css_hex(), "#FFFF0080"); + run(&mut s, "variable.set", json!({"variable":v,"value":"#000000"})); + assert_eq!(fill(&s, b), "FFFF00"); + assert!(collect(s.doc().unwrap(), &[a, b]).iter().all(|g| g.style.is_none() && g.variable.is_none())); +} + +#[test] +fn color_variables_refuse_gradients_other_types_and_unknown_names() { + use figcraft_color::{Color, Gradient, GradientKind}; + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let gradient = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK)); + run(&mut s, "fill.add", json!({"id":a,"paint":gradient})); + theme_variable(&mut s, "Brand", "#00ff00"); + run(&mut s, "variable.create", json!({"collection":"Theme","name":"Gap","type":"number","value":8})); + let colors = collect(s.doc().unwrap(), &[a]); + let solid = colors.iter().find(|g| g.paint.solid_color().is_some()).unwrap(); + let ramp = colors.iter().find(|g| g.paint.solid_color().is_none()).unwrap(); + let before = s.undo_depth(); + for (targets, variable) in [(&ramp.targets, "Brand"), (&solid.targets, "Gap"), (&solid.targets, "Missing")] { + assert!(s.execute("selection.bindColorVariable", &json!({"targets":targets,"variable":variable})).is_err()); + } + assert!(s.execute("selection.bindColorVariable", &json!({"targets":solid.targets})).is_err()); + assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty()); + assert_eq!(s.undo_depth(), before); + run(&mut s, "selection.bindColorVariable", json!({"targets":solid.targets,"variable":"Brand"})); + assert_eq!(s.doc().unwrap().node(a).unwrap().bound.len(), 1); + assert_eq!(s.undo_depth().0, before.0 + 1); +} + +#[test] +fn color_variables_apply_only_within_their_scopes() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + run(&mut s, "stroke.add", json!({"id":a,"color":"#ff0000"})); + theme_variable(&mut s, "Line", "#00ff00"); + theme_variable(&mut s, "Hidden", "#0000ff"); + run(&mut s, "variable.update", json!({"variable":"Line","scopes":["STROKE_COLOR"]})); + run(&mut s, "variable.update", json!({"variable":"Hidden","scopes":[]})); + let red = group(&s, &[a], "FF0000"); + let fill_slot: Vec<_> = red.targets.iter().filter(|t| t.fills).cloned().collect(); + let stroke_slot: Vec<_> = red.targets.iter().filter(|t| !t.fills).cloned().collect(); + for (targets, variable) in [(&red.targets, "Line"), (&fill_slot, "Line"), (&stroke_slot, "Hidden")] { + assert!(s.execute("selection.bindColorVariable", &json!({"targets":targets,"variable":variable})).is_err()); + } + assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty()); + run(&mut s, "selection.bindColorVariable", json!({"targets":stroke_slot,"variable":"Line"})); + assert_eq!(s.doc().unwrap().node(a).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00"); + assert_eq!(fill(&s, a), "FF0000"); +} diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index b05427b2..8fe5fe3b 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -2086,7 +2086,9 @@ status = "draft — needs native review" "select.sameText" = "Vybrat stejné vlastnosti textu" "select.set" = "Vybrat" "selection.applyColorStyle" = "Použít barevný styl na výběr" +"selection.bindColorVariable" = "Použít barevnou proměnnou na výběr" "selection.colors" = "Barvy výběru" +"selection.detachColor" = "Odpojit barvu výběru" "selection.selectColor" = "Vybrat vrstvy s touto barvou" "selection.setPaint" = "Změnit barvu výběru" "shape.arrow" = "Šipka" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index da2a177a..26470631 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -2039,7 +2039,9 @@ status = "draft — needs native review" "select.sameText" = "Gleiche Texteigenschaften auswählen" "select.set" = "Auswählen" "selection.applyColorStyle" = "Farbstil auf Auswahl anwenden" +"selection.bindColorVariable" = "Farbvariable auf Auswahl anwenden" "selection.colors" = "Auswahlfarben" +"selection.detachColor" = "Auswahlfarbe lösen" "selection.selectColor" = "Ebenen mit dieser Farbe auswählen" "selection.setPaint" = "Farbe der Auswahl ändern" "shape.arrow" = "Pfeil" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 29596514..b0ceeefd 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -2154,7 +2154,9 @@ status = "draft — needs native review" "select.sameText" = "Seleccionar mismas propiedades de texto" "select.set" = "Seleccionar" "selection.applyColorStyle" = "Aplicar estilo de color a la selección" +"selection.bindColorVariable" = "Aplicar variable de color a la selección" "selection.colors" = "Colores de la selección" +"selection.detachColor" = "Separar color de la selección" "selection.selectColor" = "Seleccionar capas con este color" "selection.setPaint" = "Cambiar color de la selección" "shape.arrow" = "Flecha" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index fe979678..a3b6dcc0 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -2132,7 +2132,9 @@ status = "draft — needs native review" "select.sameText" = "Valitse samat tekstiominaisuudet" "select.set" = "Valitse" "selection.applyColorStyle" = "Käytä valinnan värityyliä" +"selection.bindColorVariable" = "Käytä valinnan värimuuttujaa" "selection.colors" = "Valinnan värit" +"selection.detachColor" = "Irrota valinnan väri" "selection.selectColor" = "Valitse tämänväriset tasot" "selection.setPaint" = "Muuta valinnan väriä" "shape.arrow" = "Nuoli" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index bb771e9f..1fb7d57a 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -2134,7 +2134,9 @@ status = "draft — needs native review" "select.sameText" = "Sélectionner les mêmes propriétés de texte" "select.set" = "Sélectionner" "selection.applyColorStyle" = "Appliquer un style de couleur à la sélection" +"selection.bindColorVariable" = "Appliquer une variable de couleur à la sélection" "selection.colors" = "Couleurs de la sélection" +"selection.detachColor" = "Détacher la couleur de la sélection" "selection.selectColor" = "Sélectionner les calques de cette couleur" "selection.setPaint" = "Changer la couleur de la sélection" "shape.arrow" = "Flèche" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index a516db9f..bb46c68a 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -2090,7 +2090,9 @@ status = "draft — needs native review" "select.sameText" = "Pilih properti teks yang sama" "select.set" = "Pilih" "selection.applyColorStyle" = "Terapkan gaya warna ke pilihan" +"selection.bindColorVariable" = "Terapkan variabel warna ke pilihan" "selection.colors" = "Warna pilihan" +"selection.detachColor" = "Lepas warna pilihan" "selection.selectColor" = "Pilih layer dengan warna ini" "selection.setPaint" = "Ubah warna pilihan" "shape.arrow" = "Panah" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 641ec1f6..5d5b9fe1 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -2112,7 +2112,9 @@ status = "draft — needs native review" "select.sameText" = "同じテキストプロパティを選択" "select.set" = "選択" "selection.applyColorStyle" = "選択範囲にカラースタイルを適用" +"selection.bindColorVariable" = "選択範囲にカラー変数を適用" "selection.colors" = "選択範囲のカラー" +"selection.detachColor" = "選択範囲のカラーを切り離す" "selection.selectColor" = "このカラーのレイヤーを選択" "selection.setPaint" = "選択範囲のカラーを変更" "shape.arrow" = "矢印" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index b556e8d7..af06d1bf 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -2126,7 +2126,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "select.sameText" = "ტექსტის იგივე თვისებების მონიშვნა" "select.set" = "მონიშვნა" "selection.applyColorStyle" = "მონიშნულის ფერის სტილის გამოყენება" +"selection.bindColorVariable" = "მონიშნულზე ფერის ცვლადის გამოყენება" "selection.colors" = "მონიშნულის ფერები" +"selection.detachColor" = "მონიშნულის ფერის გამოყოფა" "selection.selectColor" = "ამ ფერის შრეების მონიშვნა" "selection.setPaint" = "მონიშნულის ფერის შეცვლა" "shape.arrow" = "ისარი" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 053348f2..29383ce9 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -2030,7 +2030,9 @@ status = "draft — needs native review" "select.sameText" = "같은 텍스트 속성 선택" "select.set" = "선택" "selection.applyColorStyle" = "선택 항목에 색상 스타일 적용" +"selection.bindColorVariable" = "선택 항목에 색상 변수 적용" "selection.colors" = "선택 항목 색상" +"selection.detachColor" = "선택 항목 색상 분리" "selection.selectColor" = "이 색상의 레이어 선택" "selection.setPaint" = "선택 항목 색상 변경" "shape.arrow" = "화살표" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index bc4e0cfa..46fc575a 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -2131,7 +2131,9 @@ status = "draft — needs native review" "select.sameText" = "Dezelfde teksteigenschappen selecteren" "select.set" = "Selecteren" "selection.applyColorStyle" = "Kleurstijl op selectie toepassen" +"selection.bindColorVariable" = "Kleurvariabele op selectie toepassen" "selection.colors" = "Selectiekleuren" +"selection.detachColor" = "Selectiekleur loskoppelen" "selection.selectColor" = "Lagen op kleur selecteren" "selection.setPaint" = "Selectiekleur wijzigen" "shape.arrow" = "Pijl" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 25f287f9..d90e7a1a 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -2125,7 +2125,9 @@ status = "draft — needs native review" "select.sameText" = "Zaznacz elementy z takimi samymi właściwościami tekstu" "select.set" = "Zaznacz" "selection.applyColorStyle" = "Zastosuj styl koloru do zaznaczenia" +"selection.bindColorVariable" = "Zastosuj zmienną koloru do zaznaczenia" "selection.colors" = "Kolory zaznaczenia" +"selection.detachColor" = "Odłącz kolor zaznaczenia" "selection.selectColor" = "Zaznacz warstwy z kolorem" "selection.setPaint" = "Zmień kolor zaznaczenia" "shape.arrow" = "Strzałka" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index b0b8dea0..11e87878 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -2143,7 +2143,9 @@ status = "draft — needs native review" "select.sameText" = "Selecionar mesmas propriedades de texto" "select.set" = "Selecionar" "selection.applyColorStyle" = "Aplicar estilo de cor à seleção" +"selection.bindColorVariable" = "Aplicar variável de cor à seleção" "selection.colors" = "Cores da seleção" +"selection.detachColor" = "Desvincular cor da seleção" "selection.selectColor" = "Selecionar camadas com esta cor" "selection.setPaint" = "Alterar cor da seleção" "shape.arrow" = "Seta" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index ed1cc0eb..71efff90 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -2145,7 +2145,9 @@ name = "Русский" "select.sameText" = "Выделить с одинаковыми свойствами текста" "select.set" = "Выделить" "selection.applyColorStyle" = "Применить цветовой стиль к выделению" +"selection.bindColorVariable" = "Применить цветовую переменную к выделению" "selection.colors" = "Цвета выделения" +"selection.detachColor" = "Отвязать цвет выделения" "selection.selectColor" = "Выделить слои с этим цветом" "selection.setPaint" = "Изменить цвет выделения" "shape.arrow" = "Стрелка" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index bcb819e5..25823103 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -2112,7 +2112,9 @@ name = "Svenska" "select.sameText" = "Markera samma textegenskaper" "select.set" = "Markera" "selection.applyColorStyle" = "Använd färgformatmall på markeringen" +"selection.bindColorVariable" = "Använd färgvariabel på markeringen" "selection.colors" = "Markeringsfärger" +"selection.detachColor" = "Koppla bort markeringsfärg" "selection.selectColor" = "Markera lager med den här färgen" "selection.setPaint" = "Ändra markeringens färg" "shape.arrow" = "Pil" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 4275f06a..0fe26b89 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -2090,7 +2090,9 @@ name = "Türkçe" "select.sameText" = "Aynı metin özelliklerini seç" "select.set" = "Seç" "selection.applyColorStyle" = "Seçime renk stili uygula" +"selection.bindColorVariable" = "Seçime renk değişkeni uygula" "selection.colors" = "Seçim renkleri" +"selection.detachColor" = "Seçim rengini ayır" "selection.selectColor" = "Bu renkteki katmanları seç" "selection.setPaint" = "Seçimin rengini değiştir" "shape.arrow" = "Ok" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index da070c0e..5fddecc6 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -2031,7 +2031,9 @@ status = "draft — needs native review" "select.sameText" = "选择相同文本属性" "select.set" = "选择" "selection.applyColorStyle" = "将颜色样式应用于所选项" +"selection.bindColorVariable" = "将颜色变量应用于所选项" "selection.colors" = "所选项颜色" +"selection.detachColor" = "分离所选项颜色" "selection.selectColor" = "选择使用此颜色的图层" "selection.setPaint" = "更改所选项颜色" "shape.arrow" = "箭头" diff --git a/crates/ui-egui/src/selection_colors.rs b/crates/ui-egui/src/selection_colors.rs index f96fc609..5c90ec77 100644 --- a/crates/ui-egui/src/selection_colors.rs +++ b/crates/ui-egui/src/selection_colors.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use egui::{Id, Ui}; use figcraft_color::Color; -use figcraft_doc::{Document, NodeId, StyleKind}; -use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, collect}; +use figcraft_doc::{Document, NodeId, StyleKind, VariableType}; +use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect}; use serde_json::json; use crate::a11y::Tip; @@ -106,24 +106,48 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId paint.opacity = (value / 100.0).clamp(0.0, 1.0); app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint})); } - let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles")); - crate::right::menu_popup(&styles).show(|ui| { - let mut any = false; - for style in &doc.styles { - if !matches!(style.kind, StyleKind::Paint { .. }) { - continue; - } - any = true; - if crate::right::menu_item(ui, &style.name, "") { - app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id})); - app.ui.picker = None; - ui.close(); - } + if linked_name.is_some() { + let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") }; + if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() { + app.run("selection.detachColor", json!({"targets": group.targets})); } - if !any { - widgets::label(ui, tr("No color styles"), t.text_secondary); - } - }); + } else { + let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles")); + crate::right::menu_popup(&styles).show(|ui| { + let mut any = false; + for style in &doc.styles { + if !matches!(style.kind, StyleKind::Paint { .. }) { + continue; + } + any = true; + if crate::right::menu_item(ui, &style.name, "") { + app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id})); + app.ui.picker = None; + ui.close(); + } + } + if !any { + widgets::label(ui, tr("No color styles"), t.text_secondary); + } + let variables: Vec<_> = doc + .variables + .variables + .iter() + .filter(|v| v.kind == VariableType::Color && bind_refusal(doc, &group.targets, &v.id).is_none()) + .collect(); + if !variables.is_empty() { + ui.separator(); + widgets::label(ui, tr("Color variables"), t.text_secondary); + } + for v in variables { + if crate::right::menu_item(ui, &v.name, "") { + app.run("selection.bindColorVariable", json!({"targets": group.targets, "variable": v.id})); + app.ui.picker = None; + ui.close(); + } + } + }); + } if icons::button(ui, Icon::Move, 24.0, false, tr("Select layers with this color")).clicked() { app.run("selection.selectColor", json!({"targets": group.targets})); } @@ -273,4 +297,45 @@ mod tests { let colors = collect(p.app.session.doc().unwrap(), &[a, b]); assert_eq!(colors.len(), 2); } + + #[test] + fn styles_menu_applies_a_color_variable_to_the_row() { + let (mut p, a, b) = Panel::new(); + p.app.run("variable.createCollection", json!({"name":"Theme"})); + p.app.run("variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#00ff00"})); + p.app.run("variable.create", json!({"collection":"Theme","name":"Gap","type":"number","value":8})); + let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1; + p.click(Pos2::new(228.0, red.y)); + let texts = p.frame(vec![]); + assert!(texts.iter().any(|(s, _)| s == "Color variables")); + assert!(!texts.iter().any(|(s, _)| s == "Gap")); + let accent = texts.iter().find(|(s, _)| s == "Accent").unwrap().1; + p.click(accent); + let doc = p.app.session.doc().unwrap(); + assert_eq!(doc.node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00"); + assert!(!doc.node(a).unwrap().bound.is_empty()); + assert!(doc.node(b).unwrap().bound.is_empty()); + assert!(p.app.ui.last_error.is_none()); + assert!(p.frame(vec![]).iter().any(|(s, _)| s == "Accent")); + } + + #[test] + fn a_linked_row_detaches_its_style_or_variable() { + let (mut p, a, b) = Panel::new(); + p.app.run("style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"})); + p.app.run("style.apply", json!({"id":a,"style":"Brand","target":"fill"})); + p.app.run("variable.createCollection", json!({"name":"Theme"})); + p.app.run("variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#0000ff"})); + p.app.run("variable.bind", json!({"id":b,"field":"fill","variable":"Accent"})); + for name in ["Brand", "Accent"] { + let row = p.frame(vec![]).into_iter().find(|(s, _)| s == name).unwrap().1; + p.click(Pos2::new(228.0, row.y)); + } + let doc = p.app.session.doc().unwrap(); + assert!(doc.node(a).unwrap().styles.fill.is_none()); + assert!(doc.node(b).unwrap().bound.is_empty()); + let texts = p.frame(vec![]); + assert!(texts.iter().any(|(s, _)| s == "FF0000") && texts.iter().any(|(s, _)| s == "0000FF")); + assert!(p.app.ui.last_error.is_none()); + } } diff --git a/docs/mcp.md b/docs/mcp.md index 8ffe773c..8659b50c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1552,6 +1552,13 @@ Pass the returned target list to: - `selection.applyColorStyle {targets, style}` to apply a document paint style. As with `style.apply`, this replaces the matching node's entire fill/stroke list and links the style. Text ranges copy resolved paints and keep their typography, without a paint-style link. +- `selection.bindColorVariable {targets, variable}` to bind a document color variable (id or name) + to those solid fill and stroke slots, as `variable.bind` does with `fills//color` or + `strokes//color`. A color style on the list and an earlier binding of the slot are removed. + Text ranges with fills of their own take the variable's value in the layer's modes, without a + binding. Gradients, other variable types and slots outside the variable's scopes are refused. +- `selection.detachColor {targets}` to unlink the color style and the color variable of those + slots, keeping their values. Keep the targets fixed throughout a live interaction. A color becoming equal to another color must not add slots to the gesture. Invalid or missing targets fail atomically. Direct paint edits diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2b8c66be..f462de45 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -70,7 +70,7 @@ interaction feel not yet compared. | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | -| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a colour style of the document (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.selectColor`). No library styles or variable binding from there (#403) | +| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | ## Tools @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Selection colors: a row's Styles menu also lists the document's color variables, and picking one binds it to every fill and stroke the row stands for, replacing a color style there (`selection.bindColorVariable`; a text range with fills of its own takes the variable's value); a row linked to a style or variable has a Detach button that unlinks it and keeps the color (`selection.detachColor`). Still partial: no library styles or variables, no new style or variable from a row | | 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 | From b2cb613ef01e008e722e648f3eb9d7ed99ab0c37 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 00:48:50 -0700 Subject: [PATCH 051/142] Contributors: add dotCooCoo (Robert Lee) --- contributors/people.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 6a92a2a9..7491a05c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -76,3 +76,7 @@ sync_github_name = true real_name = "Gert Pellin" display_name = "Gert Pellin" sync_github_name = false + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true From fdc4f7403450be575d65588aa312541ce5107eac Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:28:06 -0400 Subject: [PATCH 052/142] chore: drop redundant 'static lifetimes in consts (Sonar S8863) (#1254) Co-authored-by: Claude Sonnet 5.5 --- crates/color/src/lib.rs | 2 +- crates/doc/src/props.rs | 2 +- crates/doc/src/proto.rs | 2 +- crates/ui-egui/src/home.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 1fff7667..f042e23a 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -355,7 +355,7 @@ impl ImageFilters { } /// The built-in adjustment presets: `(id, label, adjustments)`. "Reset" is the identity. - pub const PRESETS: [(&'static str, &'static str, ImageFilters); 7] = [ + pub const PRESETS: [(&str, &str, ImageFilters); 7] = [ ("vivid", "Vivid", ImageFilters { contrast: 0.2, saturation: 0.45, highlights: -0.1, shadows: 0.1, ..ImageFilters::NONE }), ("muted", "Muted", ImageFilters { contrast: -0.15, saturation: -0.45, ..ImageFilters::NONE }), ("warm", "Warm", ImageFilters { temperature: 0.5, tint: 0.1, saturation: 0.1, ..ImageFilters::NONE }), diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index a646ae62..f6187589 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -795,7 +795,7 @@ fn yes() -> bool { impl Effect { /// Effect types in the order the Design panel lists them: (label, type name). - pub const TYPES: [(&'static str, &'static str); 7] = [ + pub const TYPES: [(&str, &str); 7] = [ ("Drop shadow", "dropShadow"), ("Inner shadow", "innerShadow"), ("Layer blur", "layerBlur"), diff --git a/crates/doc/src/proto.rs b/crates/doc/src/proto.rs index a844774c..61b3628e 100644 --- a/crates/doc/src/proto.rs +++ b/crates/doc/src/proto.rs @@ -1003,7 +1003,7 @@ impl PrototypeSettings { ]; /// The device whose screen is [`custom_size`](Self::custom_size) (no body is drawn around it). - pub const CUSTOM_DEVICE: &'static str = "Custom size"; + pub const CUSTOM_DEVICE: &str = "Custom size"; /// A new custom size. pub const CUSTOM_DEFAULT: [f64; 2] = [1280.0, 720.0]; /// The largest side a custom size may have (points). diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index 326a3b14..c3f7ee3a 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -27,7 +27,7 @@ pub enum Section { impl Section { /// The sidebar, top to bottom. - const ALL: [(Section, Icon, &'static str); 3] = [ + const ALL: [(Section, Icon, &str); 3] = [ (Section::Recents, Icon::Home, tr_noop("Recents")), (Section::Online, Icon::Online, tr_noop("Online")), (Section::Templates, Icon::Assets, tr_noop("Templates")), From 996bf871cdcd9f7c37f261d84085f66ce4b5deaa Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:36:46 -0400 Subject: [PATCH 053/142] fix(security): prevent symlink traversal in asset walker, safe cert tmpdir, and atomic icon copy (#1255) --- packaging/macos/import-cert.sh | 5 +++-- xtask/src/icon.rs | 8 ++++++-- xtask/src/main.rs | 5 +++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index 77611537..aeb58fd4 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -17,12 +17,13 @@ if [[ -z "${APPLE_CERTIFICATE:-}" ]]; then exit 0 fi -TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +TMP_BASE="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +TMP="$(mktemp -d "${TMP_BASE}/figcraft-signing.XXXXXX")" +trap 'rm -rf "$TMP"' EXIT KEYCHAIN="$TMP/figcraft-signing.keychain-db" CERT="$TMP/figcraft-signing.p12" KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" -trap 'rm -f "$CERT"' EXIT printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT" security create-keychain -p "$KC_PASS" "$KEYCHAIN" security set-keychain-settings -lut 21600 "$KEYCHAIN" diff --git a/xtask/src/icon.rs b/xtask/src/icon.rs index b91fccee..91b51aef 100644 --- a/xtask/src/icon.rs +++ b/xtask/src/icon.rs @@ -67,11 +67,15 @@ pub fn generate(root: &Path) -> bool { for png in &pngs { ico.extend_from_slice(png); } - ok &= std::fs::write(dir.join("figcraft.ico"), ico).is_ok(); + let ico_path = dir.join("figcraft.ico"); + let _ = std::fs::remove_file(&ico_path); + ok &= std::fs::write(&ico_path, ico).is_ok(); // The lighter trace keeps the scalable theme icon cheap to render. let sd = dir.join("hicolor/scalable/apps"); let _ = std::fs::create_dir_all(&sd); - ok &= std::fs::copy(dir.join("figcraft-small.svg"), sd.join("ai.storyteller.figcraft.svg")).is_ok(); + let target = sd.join("ai.storyteller.figcraft.svg"); + let _ = std::fs::remove_file(&target); + ok &= std::fs::copy(dir.join("figcraft-small.svg"), &target).is_ok(); eprintln!("icon: wrote {}", dir.display()); ok } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 313de3c6..e7f7b386 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -201,10 +201,11 @@ fn assets() -> bool { fn walk(dir: &std::path::Path, f: &mut dyn FnMut(&std::path::Path)) { let Ok(rd) = std::fs::read_dir(dir) else { return }; for e in rd.flatten() { + let Ok(ft) = e.file_type() else { continue }; let p = e.path(); - if p.is_dir() { + if ft.is_dir() && !ft.is_symlink() { walk(&p, f); - } else { + } else if !ft.is_dir() { f(&p); } } From 25d48530a915c1902825408d575a09e15f97cb70 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:37:38 -0400 Subject: [PATCH 054/142] fix(security): owner-only recovery permissions, linear cli export suffixing, and web noopener (#1256) --- apps/figcraft-cli/src/files.rs | 17 +++++++++++------ apps/figcraft-web/src/web.rs | 2 +- apps/figcraft/src/recovery.rs | 19 ++++++++++++++++++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index b87aa431..3d36a7f2 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -272,14 +272,19 @@ pub fn export(file: &str, out: &str, t: &Target) -> CliResult { return Err(CliError::Failed("the page is empty: nothing to export".into())); } let mut used = std::collections::BTreeSet::new(); + let mut next_suffix: std::collections::HashMap = std::collections::HashMap::new(); for id in ids { let base = sanitize(doc.node(id).map(|n| n.name.as_str()).unwrap_or("layer")); - let mut name = base.clone(); - let mut k = 2; - while !used.insert(name.to_lowercase()) { - name = format!("{base} {k}"); - k += 1; - } + let base_lower = base.to_lowercase(); + let next = next_suffix.entry(base_lower).or_insert(1); + let name = loop { + let k = *next; + *next += 1; + let candidate = if k == 1 { base.clone() } else { format!("{base} {k}") }; + if used.insert(candidate.to_lowercase()) { + break candidate; + } + }; let path = Path::new(out).join(format!("{name}.{}", fmt.ext())).to_string_lossy().into_owned(); write(&path, &bytes_of(&[id])?)?; println!("{path}"); diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index f08ca90f..ddb876b0 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -117,7 +117,7 @@ impl Services for WebServices { } fn open_url(&mut self, url: &str) { let Some(window) = web_sys::window() else { return }; - if let Err(e) = window.open_with_url_and_target(url, "_blank") { + if let Err(e) = window.open_with_url_and_target_and_features(url, "_blank", "noopener,noreferrer") { log::error!("couldn't open {url}: {e:?}"); } } diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 06b0cbeb..d8883529 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -338,7 +338,24 @@ fn stamp(s: &Session, d: &DocState) -> Vec { fn write_atomic(path: &Path, doc: &figcraft_engine::doc::Document, versions: &[Version], branches: &[Branch]) -> bool { let Ok(bytes) = figcraft_format::save_bytes_with_history(doc, None, versions, branches) else { return false }; let tmp = path.with_extension("tmp"); - std::fs::write(&tmp, bytes).is_ok() && std::fs::rename(&tmp, path).is_ok() + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true).mode(0o600); + let Ok(mut f) = opts.open(&tmp) else { return false }; + if std::io::Write::write_all(&mut f, &bytes).is_err() { + let _ = std::fs::remove_file(&tmp); + return false; + } + } + #[cfg(not(unix))] + { + if std::fs::write(&tmp, bytes).is_err() { + return false; + } + } + std::fs::rename(&tmp, path).is_ok() } /// [`recover_all`], for a start that keeps nothing waiting: how many documents came back as From 0cca56984fcb861117c8aed723486ec399464e01 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:39:12 -0400 Subject: [PATCH 055/142] fix(security): bound decoration steps, text-on-path elements, and rename expansions (#1257) --- crates/engine/src/rename.rs | 6 ++++++ crates/text/src/layout.rs | 2 +- crates/text/src/onpath.rs | 9 ++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 8d44f5ab..b4952475 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -81,6 +81,9 @@ impl BatchRename { out.push_str(name.get(last..m.start()).unwrap_or_default()); let (before, after) = (name.get(..m.start()).unwrap_or_default(), name.get(m.end()..).unwrap_or_default()); expand(&self.replace, &Ctx { matched: m.as_str(), before, after, groups: Some(&caps), up, down }, &mut out); + if out.len() >= MAX_NAME * 4 { + break; + } last = m.end(); } out.push_str(name.get(last..).unwrap_or_default()); @@ -110,6 +113,9 @@ impl BatchRename { fn expand(template: &str, c: &Ctx, out: &mut String) { let mut it = template.chars().peekable(); while let Some(ch) = it.next() { + if out.len() >= MAX_NAME * 4 { + break; + } if ch != '$' { out.push(ch); continue; diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f844b571..54bee7e3 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -100,7 +100,7 @@ pub struct Decoration { } /// Most dots or wave samples drawn for one decoration. -const MAX_DECO_STEPS: usize = 20_000; +const MAX_DECO_STEPS: usize = 2_500; impl Decoration { /// The line's outline: a bar, a row of dots or a wave, `thickness` thick. diff --git a/crates/text/src/onpath.rs b/crates/text/src/onpath.rs index 2b506237..49378504 100644 --- a/crates/text/src/onpath.rs +++ b/crates/text/src/onpath.rs @@ -23,7 +23,14 @@ impl OnPath { /// Text on `tp` whose layout x = 0 sits `shift` before the start point and whose layout y /// `baseline` sits on the path. `None` for an empty or unmeasurable path. pub fn new(tp: &TextPath, shift: f64, baseline: f64) -> Option { - let mut line = polylines(&tp.path())?.into_iter().next()?; + let p = tp.path(); + if p.elements().len() > 20_000 { + return None; + } + let mut line = polylines(&p)?.into_iter().next()?; + if line.points.len() > 40_000 { + return None; + } // A subpath ending where it starts (an ellipse) goes round like a closed one. line.closed |= matches!((line.points.first(), line.points.last()), (Some(a), Some(b)) if (*a - *b).hypot() < 1e-6); Some(Self { origin: tp.start() * line.length() - shift, line, flip: tp.flip, baseline }) From 3739b2863aca9a0a4668ff491f41305246b0f68e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:41:31 -0400 Subject: [PATCH 056/142] fix(security): cap COLR layers, PNG glyph dimensions, and layout cache capacity (#1264) * chore: explicit test imports in engine, part 1 (Sonar S2208) Co-Authored-By: Claude Sonnet 5.5 * fix(security): cap COLR layers, PNG glyph dimensions, and layout cache capacity --------- Co-authored-by: Claude Sonnet 5.5 --- crates/engine/src/design_paste_tests.rs | 2 +- crates/text/src/color.rs | 13 +++++++++++-- crates/text/src/lib.rs | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/design_paste_tests.rs b/crates/engine/src/design_paste_tests.rs index 89de80ef..52dc4230 100644 --- a/crates/engine/src/design_paste_tests.rs +++ b/crates/engine/src/design_paste_tests.rs @@ -4,7 +4,7 @@ use base64::Engine as _; use serde_json::json; -use super::*; +use super::{NodeId, Session, Value, params}; use crate::cmd::edit::{CLIP_CLOSE, CLIP_OPEN, design_clipboard_buffer}; const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD; diff --git a/crates/text/src/color.rs b/crates/text/src/color.rs index ca927a94..0eaca197 100644 --- a/crates/text/src/color.rs +++ b/crates/text/src/color.rs @@ -29,8 +29,10 @@ const MAX_RASTER_BYTES: usize = 32 * 1024 * 1024; const MAX_RASTER_ENTRIES: usize = 8192; /// COLR layer lists kept per face (24 bytes a layer, so a count is enough). const MAX_LAYERS: usize = 4096; +/// Most COLR layers read for one glyph. +const MAX_COLR_LAYERS: usize = 256; /// Widest and tallest PNG strike decoded (strikes are at most a few hundred pixels). -const MAX_PNG_SIDE: u32 = 4096; +const MAX_PNG_SIDE: u32 = 1024; /// Largest colour raster per em (device pixels). Past this the renderer scales one up instead. pub const MAX_PX_PER_EM: f64 = 512.0; @@ -171,6 +173,9 @@ impl Face { let colors = palette.as_ref().map(|p| p.colors()).unwrap_or(&[]); let mut out = Vec::new(); for index in range { + if out.len() >= MAX_COLR_LAYERS { + break; + } let (layer, color_index) = colr.v0_layer(index)?; let rgba = colors.get(usize::from(color_index)).map(|c| [c.red(), c.green(), c.blue(), c.alpha()]).unwrap_or([0, 0, 0, 255]); out.push(ColorLayer { gid: layer.to_u32(), rgba }); @@ -230,7 +235,11 @@ fn decode_png(png: &[u8]) -> Option<(Vec, u32, u32)> { if w == 0 || h == 0 || w > MAX_PNG_SIDE || h > MAX_PNG_SIDE { return None; } - let mut buf = vec![0u8; reader.output_buffer_size()?]; + let buf_size = reader.output_buffer_size()?; + if buf_size > 4 * 1024 * 1024 { + return None; + } + let mut buf = vec![0u8; buf_size]; let info = reader.next_frame(&mut buf).ok()?; let (width, height) = (info.width, info.height); let data = buf.get(..info.buffer_size())?; diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 50cd28dd..548a7b66 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -42,7 +42,7 @@ struct LayoutCache { /// Layouts kept before the cache starts over (a page of distinct texts stays cached while it /// renders, pans and zooms). -const LAYOUT_CACHE_MAX: usize = 32_768; +const LAYOUT_CACHE_MAX: usize = 4_096; /// Entries per fingerprint (texts that compare unequal to themselves, e.g. NaN sizes, can't pile up). const BUCKET_MAX: usize = 8; From cb38f68c0c880f1dad554e1e394c0ce406fa4890 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 04:45:01 -0400 Subject: [PATCH 057/142] fix(security): linear docprops reordering and deduplicate restored deletion nodes (#1258) * fix(security): linear docprops reordering and deduplicate restored deletion nodes * style: format if-let condition --- crates/collab/src/docprops.rs | 9 +++------ crates/collab/src/server.rs | 5 ++++- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index 9b35e05d..e4ff32c2 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -132,18 +132,15 @@ impl List { let keys: Vec = value.read("order")?; let list = (self.get_mut)(doc); let mut slots: Vec> = std::mem::take(list).into_iter().map(Some).collect(); - let mut at: BTreeMap> = BTreeMap::new(); + let mut at: BTreeMap> = BTreeMap::new(); for (i, e) in slots.iter().enumerate() { if let Some(e) = e { - at.entry(e.key()).or_default().push(i); + at.entry(e.key()).or_default().push_back(i); } } for k in keys { let Some(idx) = at.get_mut(&k) else { continue }; - if idx.is_empty() { - continue; - } - let i = idx.remove(0); + let Some(i) = idx.pop_front() else { continue }; if let Some(e) = slots.get_mut(i).and_then(Option::take) { list.push(e); } diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 38ed3de8..dd654efe 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -541,11 +541,14 @@ impl Server { nodes.extend(t.write(self.state.value(&t)?.as_ref())); } } + let mut restored = std::collections::BTreeSet::new(); for id in gone { match doc.node(id) { Some(_) => { for d in doc.subtree(id) { - if let Some(n) = doc.node(d) { + if restored.insert(d) + && let Some(n) = doc.node(d) + { nodes.push(create_change(n, self.state.key(d))?); } } From 2d1fabda1f931b1dc3ef82ae6df65b38044da7e0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:10:23 +0200 Subject: [PATCH 058/142] Reject non-finite percent strings for lineHeight and letterSpacing Found while testing crates/engine/src/cmd/props.rs:145 (line_height) and :153 (letter_spacing) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/props.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index b5889d8c..9ee499fc 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -144,7 +144,9 @@ pub(crate) fn sizing(v: &Value) -> Option { pub(crate) fn line_height(v: &Value) -> Option { match v { Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto), - Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LineHeight::Percent), + Value::String(s) if s.trim().ends_with('%') => { + s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).map(LineHeight::Percent) + } Value::Object(_) => serde_json::from_value(v.clone()).ok(), other => f64_of(other).map(LineHeight::Pixels), } @@ -152,7 +154,9 @@ pub(crate) fn line_height(v: &Value) -> Option { pub(crate) fn letter_spacing(v: &Value) -> Option { match v { - Value::String(s) if s.trim().ends_with('%') => s.trim().trim_end_matches('%').trim().parse().ok().map(LetterSpacing::Percent), + Value::String(s) if s.trim().ends_with('%') => { + s.trim().trim_end_matches('%').trim().parse().ok().filter(|f: &f64| f.is_finite()).map(LetterSpacing::Percent) + } Value::Object(_) => serde_json::from_value(v.clone()).ok(), other => f64_of(other).map(LetterSpacing::Pixels), } @@ -1277,3 +1281,17 @@ pub fn describe(doc: &Document, id: NodeId) -> Value { let _ = Color::BLACK; v } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn non_finite_percent_strings_are_rejected() { + for s in ["NaN%", "inf%", "-inf%"] { + assert!(line_height(&json!(s)).is_none(), "line_height accepted {s}"); + assert!(letter_spacing(&json!(s)).is_none(), "letter_spacing accepted {s}"); + } + assert!(matches!(line_height(&json!("150%")), Some(LineHeight::Percent(p)) if p == 150.0)); + } +} From 43a255fe16fed9922732e4962644921628c66b8b Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:17:25 +0200 Subject: [PATCH 059/142] Clamp control-channel document.inspect depth to 64 (#1162) Fixes #1115 Co-authored-by: Claude Haiku 5.5 Co-authored-by: Dylan M. Taylor --- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/control_roots_tests.rs | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index edd9fff7..4629b22c 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -252,7 +252,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul .collect(), )), "document.inspect" => { - let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8) as usize; + let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize; Ok(figcraft_engine::inspect::document(&app.session, depth, b(p, "allPages"))) } "node.get" => { diff --git a/crates/ui-egui/src/control_roots_tests.rs b/crates/ui-egui/src/control_roots_tests.rs index 763f00e6..d3863248 100644 --- a/crates/ui-egui/src/control_roots_tests.rs +++ b/crates/ui-egui/src/control_roots_tests.rs @@ -100,3 +100,23 @@ fn ui_input_rejects_non_finite_coordinates() { } assert!(app.control_input.iter().all(|e| !matches!(e, egui::Event::PointerMoved(p) if !p.is_finite()))); } + +#[test] +fn document_inspect_caps_the_requested_depth() { + let (mut app, _d) = confined_app("inspect-depth"); + let mut parent: Option = None; + for _ in 0..80 { + let mut p = json!({"x": 0, "y": 0, "width": 10, "height": 10}); + if let Some(id) = &parent { + p["parent"] = id.clone(); + } + let r = app.session.execute("frame.create", &p).unwrap(); + parent = Some(r["id"].clone()); + } + let r = handle(&mut app, "document.inspect", json!({"depth": u64::MAX, "allPages": false})).unwrap(); + fn nesting(v: &Value) -> usize { + v["children"].as_array().map_or(0, |c| 1 + c.iter().map(nesting).max().unwrap_or(0)) + } + let n = nesting(&r["pages"][0]); + assert!(n <= 64, "inspect nested {n} levels"); +} From 3a1ddf339c83e5f308835bdde2371a47cc848fd0 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:22:48 +0200 Subject: [PATCH 060/142] Convert text decoration colour in colour profile conversion (#1247) Found while testing crates/doc/src/colors.rs:30 (TextStyle::for_each_color_mut, used by TextData, Style and Document::map_colors / set_color_profile) Co-authored-by: Claude Haiku 5.5 --- crates/doc/src/colors.rs | 3 +++ crates/doc/src/colors_tests.rs | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs index d480f4ea..93d32f0b 100644 --- a/crates/doc/src/colors.rs +++ b/crates/doc/src/colors.rs @@ -35,6 +35,9 @@ impl Effect { impl TextStyle { pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) { self.fills.iter_mut().flatten().for_each(|p| p.for_each_color_mut(f)); + if let Some(c) = self.decoration_look.as_deref_mut().and_then(|l| l.color.as_mut()) { + f(c); + } } } diff --git a/crates/doc/src/colors_tests.rs b/crates/doc/src/colors_tests.rs index 3a97a5d0..5cadfbbe 100644 --- a/crates/doc/src/colors_tests.rs +++ b/crates/doc/src/colors_tests.rs @@ -134,3 +134,11 @@ fn profile_and_presets_serialize_only_when_set() { assert_eq!(back.color_profile, ColorProfile::DisplayP3); assert_eq!(back.image_presets, d.image_presets); } + +#[test] +fn text_decoration_colour_is_visited() { + let mut s = TextStyle::default(); + s.set_look(|l| l.color = Some(RED)); + s.for_each_color_mut(&mut |c| *c = Color::BLACK); + assert_eq!(s.look().color, Some(Color::BLACK), "decoration colour not visited"); +} From 7df328d1ca369b0f1acbf4a350e9cd1bbf3dfb3a Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:23:23 +0200 Subject: [PATCH 061/142] Clip Inside/Outside strokes by the shape's even-odd fill rule (#1253) Found while testing crates/pathops/src/lib.rs:326-333 (stroke_outline_of, StrokeAlign::Inside and Outside arms calling binary(), which uses FillRule::NonZero at :82) Co-authored-by: Claude Opus 5.5 --- crates/pathops/src/lib.rs | 53 +++++++++++++++++++++++++++++++-------- crates/render/src/draw.rs | 5 ++-- 2 files changed, 46 insertions(+), 12 deletions(-) diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 324af4d4..f4b30e10 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -20,7 +20,7 @@ pub mod width; #[cfg(test)] mod width_tests; -use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, VectorNetwork}; +use figcraft_doc::{BoolOp, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, VectorNetwork, WindingRule}; use figcraft_geom::kurbo::{self, BezPath, Cap, Join, PathEl, PathSeg, Point, Shape, Vec2}; #[derive(Debug, PartialEq)] @@ -69,13 +69,18 @@ fn sweep(f: impl FnOnce() -> Result) -> Result Result { + binary_by(a, b, linesweeper::FillRule::NonZero, op) +} + +/// [`binary`] with both operands read by fill rule `rule`. +fn binary_by(a: &BezPath, b: &BezPath, rule: linesweeper::FillRule, op: linesweeper::BinaryOp) -> Result { if a.elements().is_empty() && b.elements().is_empty() { return Ok(BezPath::new()); } if !finite(a) || !finite(b) { return Err(PathOpsError::BadInput); } - let contours = sweep(|| linesweeper::binary_op(&closed(a), &closed(b), linesweeper::FillRule::NonZero, op).map_err(|_| PathOpsError::BadInput))?; + let contours = sweep(|| linesweeper::binary_op(&closed(a), &closed(b), rule, op).map_err(|_| PathOpsError::BadInput))?; let mut out = BezPath::new(); for c in contours.contours() { out.extend(c.path.elements().iter().copied()); @@ -323,16 +328,20 @@ pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool (None, None, None) => stroke_center(path, width, props), }; let align = if closed_shape { props.align } else { StrokeAlign::Center }; + // Inside/Outside clip against the shape's own fill, so an even-odd shape's holes stay holes. + let even_odd = net.is_some_and(|n| n.regions.iter().any(|r| r.winding == WindingRule::EvenOdd)); + let clip = |s: BezPath, op: linesweeper::BinaryOp| { + if even_odd { + // The stroke overlaps itself (non-zero): flatten it to even-odd contours first. + simplify(&s).and_then(|s| binary_by(&s, path, linesweeper::FillRule::EvenOdd, op)).unwrap_or(s) + } else { + binary(&s, path, op).unwrap_or(s) + } + }; let mut out = match align { StrokeAlign::Center => centred(w), - StrokeAlign::Inside => { - let s = centred(w * 2.0); - binary(&s, path, linesweeper::BinaryOp::Intersection).unwrap_or(s) - } - StrokeAlign::Outside => { - let s = centred(w * 2.0); - binary(&s, path, linesweeper::BinaryOp::Difference).unwrap_or(s) - } + StrokeAlign::Inside => clip(centred(w * 2.0), linesweeper::BinaryOp::Intersection), + StrokeAlign::Outside => clip(centred(w * 2.0), linesweeper::BinaryOp::Difference), }; if let Some(st) = styled.as_ref().filter(|_| !closed_shape) { out.extend(st.arrowheads(w, props).elements().iter().copied()); @@ -709,4 +718,28 @@ mod tests { let s = stroke_center(&line, 2.0, &props); assert!((s.area().abs() - 100.0).abs() < 2.0, "{}", s.area()); } + + #[test] + fn inside_and_outside_strokes_respect_an_even_odd_hole() { + // A ring: both loops run the same way, so only the even-odd rule leaves the hole open. + let mut ring = sq(0.0, 0.0, 100.0); + ring.extend(sq(30.0, 30.0, 40.0).elements().iter().copied()); + let mut net = VectorNetwork::from_path(&ring); + for r in &mut net.regions { + r.winding = figcraft_doc::WindingRule::EvenOdd; + } + let (material, hole) = (Point::new(29.0, 50.0), Point::new(31.0, 50.0)); + let inside = StrokeProps { weight: 4.0, align: StrokeAlign::Inside, ..Default::default() }; + let s = stroke_outline_of(&ring, &inside, true, Some(&net)); + assert_ne!(s.winding(material), 0, "inside stroke misses the material"); + assert_eq!(s.winding(hole), 0, "inside stroke leaks into the hole"); + let outside = StrokeProps { align: StrokeAlign::Outside, ..inside.clone() }; + let s = stroke_outline_of(&ring, &outside, true, Some(&net)); + assert_eq!(s.winding(material), 0, "outside stroke covers the material"); + assert_ne!(s.winding(hole), 0, "outside stroke misses the hole"); + // Non-zero: the inner loop doesn't cut a hole, so its inside stroke covers both sides. + let nz = VectorNetwork::from_path(&ring); + let s = stroke_outline_of(&ring, &inside, true, Some(&nz)); + assert!(s.winding(material) != 0 && s.winding(hole) != 0); + } } diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index 3718df26..92d11f48 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -217,11 +217,12 @@ pub fn stroke_area(n: &Node, path: &BezPath, closed: bool) -> Arc { d.to_bits().hash(&mut h); } props.dashes.len().hash(&mut h); - // Points' own caps and joins (vector networks). + // Points' own caps and joins and the fill rule Inside/Outside clip by (vector networks). let net = match &n.kind { - NodeKind::Vector { network } if network.has_point_styles(props.join) => Some(network), + NodeKind::Vector { network } => Some(network), _ => None, }; + net.is_some_and(|n| n.regions.iter().any(|r| r.winding == figcraft_doc::WindingRule::EvenOdd)).hash(&mut h); for v in net.map(|n| n.vertices.as_slice()).unwrap_or_default() { (v.cap.map(|c| c as u8), v.join.map(|j| j as u8)).hash(&mut h); } From 98298a351913e32355a8e5db11f009d2fc4cacb1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:23:43 +0200 Subject: [PATCH 062/142] Validate image member names when reading .figcraft archives (#1148) Fixes #1122 Co-authored-by: Claude Haiku 5.5 --- crates/format/src/image_tests.rs | 26 ++++++++++++++++++++++++++ crates/format/src/lib.rs | 10 +++++++++- crates/format/src/library.rs | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/format/src/image_tests.rs b/crates/format/src/image_tests.rs index bc925efb..e58a4ab1 100644 --- a/crates/format/src/image_tests.rs +++ b/crates/format/src/image_tests.rs @@ -86,3 +86,29 @@ fn many_lost_images_are_counted_not_listed_one_by_one() { assert_eq!(issues.len(), MAX_IMAGE_ISSUES + 1, "{issues:?}"); assert_eq!(issues.last().map(String::as_str), Some("20 more images couldn't be loaded")); } + +#[test] +fn image_members_with_path_like_names_are_dropped_and_not_saved_back() { + let (d, hash) = doc_with_image(); + let bytes = save_bytes(&d, None).unwrap(); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let doc_json = read_member(&mut z, "document.json", u64::MAX).unwrap(); + let mut buf = std::io::Cursor::new(Vec::new()); + let mut w = zip::ZipWriter::new(&mut buf); + let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored); + w.start_file("document.json", opts).unwrap(); + w.write_all(&doc_json).unwrap(); + for n in ["images/../../x", "images/a/b"] { + w.start_file(n, opts).unwrap(); + w.write_all(&png()).unwrap(); + } + w.finish().unwrap(); + let hostile = buf.into_inner(); + let loaded = load_bytes_with_versions(&hostile).unwrap(); + assert!(loaded.doc.images.keys().all(|k| k != "../../x" && k != "a/b"), "{:?}", loaded.doc.images.keys()); + assert!(!loaded.issues.is_empty()); + let saved = save_bytes_with_history(&loaded.doc, None, &loaded.versions, &loaded.branches).unwrap(); + let z = zip::ZipArchive::new(std::io::Cursor::new(saved)).unwrap(); + assert!(z.file_names().all(|n| !n.contains("..") && n.matches('/').count() <= 1), "{:?}", z.file_names().collect::>()); + let _ = hash; +} diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 9f7e51bc..36975f56 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -524,10 +524,18 @@ fn read_images( own: Option<&BTreeSet>, budget: ImageBudget, ) -> (BTreeMap, Vec<(String, FormatError)>) { - let mut hashes: Vec = z.file_names().filter_map(|n| n.strip_prefix("images/")).map(str::to_string).collect(); + let mut hashes: Vec = z.file_names().filter_map(|n| n.strip_prefix("images/")).filter(|h| !h.is_empty()).map(str::to_string).collect(); hashes.sort_by_key(|h| !own.is_some_and(|o| o.contains(h))); let mut images = BTreeMap::new(); let mut unread = Vec::new(); + // Member names that aren't plain hashes (`../x`, `a/b`) are never read, so never written back. + hashes.retain(|h| { + let ok = library::is_valid_hash(h); + if !ok { + unread.push((h.clone(), FormatError::Corrupt("its name isn't a valid image hash".to_string()))); + } + ok + }); let mut left = budget.bytes; for (i, hash) in hashes.into_iter().enumerate() { let name = format!("images/{hash}"); diff --git a/crates/format/src/library.rs b/crates/format/src/library.rs index 06dcc99e..daec05e2 100644 --- a/crates/format/src/library.rs +++ b/crates/format/src/library.rs @@ -39,7 +39,7 @@ pub fn is_valid_library_id(id: &str) -> bool { } /// Image hashes become archive member names: only plain hex-like names are looked up. -fn is_valid_hash(h: &str) -> bool { +pub(crate) fn is_valid_hash(h: &str) -> bool { (1..=128).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric()) } From 8864e42dce4b69f5a3a7d6dddb9cab6cbf75bd34 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:24:05 +0200 Subject: [PATCH 063/142] Clear plugin settings when a plugin id is re-registered (#1159) Fixes #1116 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/plugins.rs | 1 + crates/engine/src/plugins_tests.rs | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/crates/engine/src/plugins.rs b/crates/engine/src/plugins.rs index 0159a924..18fd64ef 100644 --- a/crates/engine/src/plugins.rs +++ b/crates/engine/src/plugins.rs @@ -393,6 +393,7 @@ pub(crate) fn register(s: &mut Session, entry: InstalledPlugin, wasm: Vec) - match list.iter_mut().find(|x| x.manifest.id == id) { Some(slot) => { *slot = entry; + s.prefs.plugin_settings.remove(&id); true } None => { diff --git a/crates/engine/src/plugins_tests.rs b/crates/engine/src/plugins_tests.rs index 51c997a5..172fd477 100644 --- a/crates/engine/src/plugins_tests.rs +++ b/crates/engine/src/plugins_tests.rs @@ -406,3 +406,13 @@ fn a_full_undo_history_keeps_its_steps_and_tampered_manifests_are_refused() { s.prefs.plugins[0].manifest.main = "../../elsewhere.wasm".into(); assert!(matches!(s.execute("plugin.run", &json!({"id": "org.test.many"})), Err(EngineError::BadParams { .. }))); } + +#[test] +fn reinstalling_under_an_existing_id_drops_the_old_settings() { + let mut s = Session::new(); + let m = manifest("org.test.reuse", "read", &[]); + install(&mut s, m.clone(), &script(&[], OnRefusal::Fail, "")); + s.prefs.plugin_settings.entry("org.test.reuse".into()).or_default().insert("key".into(), json!("v")); + install(&mut s, m, &script(&[], OnRefusal::Fail, "")); + assert!(!s.prefs.plugin_settings.contains_key("org.test.reuse"), "{:?}", s.prefs.plugin_settings); +} From 9c0d1c19fe963665aa9e36601897938488cbaa8f Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:24:29 +0200 Subject: [PATCH 064/142] Restore the clipboard when a command fails and is rolled back (#1167) Fixes #1100 Co-authored-by: Claude Opus 5.5 --- crates/engine/src/lib.rs | 3 +++ crates/engine/src/version_preview_tests.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 29b084f2..a2ac9da8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -966,6 +966,8 @@ impl Session { ds.end_preview(); } let before = self.ds().ok().map(|d| (d.doc.clone(), d.selection.clone(), d.page, d.uid)); + // Cut and copy write the clipboard before a later check can refuse the command. + let clipboard_before = self.clipboard.clone(); let result = guard::run(id, || (spec.run)(self, params)) .and_then(|v| self.check_instance_structure(id, &before).map(|()| v)) .and_then(|v| self.check_preview(id, &before).map(|()| v)) @@ -998,6 +1000,7 @@ impl Session { ds.selection = sel; ds.page = page; } + self.clipboard = clipboard_before; } } result diff --git a/crates/engine/src/version_preview_tests.rs b/crates/engine/src/version_preview_tests.rs index e910006c..9423c34e 100644 --- a/crates/engine/src/version_preview_tests.rs +++ b/crates/engine/src/version_preview_tests.rs @@ -97,3 +97,22 @@ fn preview_keeps_the_unsaved_state_and_saving_saves_the_document() { assert_eq!(count(&s), three); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn a_refused_cut_leaves_the_clipboard_as_it_was() { + let mut s = Session::new(); + let a = rect(&mut s, 0.0); + s.execute("file.saveVersion", &json!({"name": "One"})).unwrap(); + let b = rect(&mut s, 20.0); + s.execute("edit.copy", &json!({"ids": [b]})).unwrap(); + let roots = |s: &Session| s.clipboard.nodes.iter().map(|(r, _)| *r).collect::>(); + assert_eq!(roots(&s), vec![b]); + let source = s.clipboard.source.clone().unwrap(); + let depth = s.undo_depth(); + s.execute("version.preview", &json!({"index": 0})).unwrap(); + let err = s.execute("edit.cut", &json!({"ids": [a]})).unwrap_err(); + assert!(matches!(err, EngineError::Disabled(..)), "{err:?}"); + assert_eq!(roots(&s), vec![b], "failed edit.cut changed the clipboard"); + assert!(std::sync::Arc::ptr_eq(s.clipboard.source.as_ref().unwrap(), &source)); + assert_eq!(s.undo_depth(), depth, "history untouched"); +} From 7bc594632db86890a10b6dd8599f9141ec0a6374 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:24:53 +0200 Subject: [PATCH 065/142] Cap layout.setTrack track count at MAX_TRACKS (#1168) Fixes #1098 Co-authored-by: Claude Haiku 5.5 --- crates/engine/src/cmd/layout.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index da1301ec..5a0a4000 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -19,6 +19,9 @@ pub fn specs() -> Vec { )] } +/// Same cap the layout pass applies to grid track counts. +const MAX_TRACKS: usize = 1000; + fn set_track(s: &mut Session, p: &Value) -> Result { const CMD: &str = "layout.setTrack"; let ids = req_targets(CMD, s, p)?; @@ -38,7 +41,7 @@ fn set_track(s: &mut Session, p: &Value) -> Result { let Some(l) = n.layout.as_mut().filter(|l| l.mode == LayoutMode::Grid) else { return Err(bad(CMD, format!("node {id} is not a grid auto layout frame"))); }; - let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize); + let count = tracks_now.len().max(if columns { l.grid_columns } else { l.grid_rows } as usize).min(MAX_TRACKS); if !(0.0..count as f64).contains(&index) { return Err(bad(CMD, format!("`index` must be 0..{count}"))); } @@ -249,6 +252,17 @@ mod tests { assert!(s.execute("node.set", &json!({"id": f, "props": {"rowTracks": 5}})).is_err()); } + #[test] + fn set_track_clamps_hostile_grid_counts() { + let mut s = Session::new(); + let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 320, "height": 200, "props": {"layout": "grid"}}))); + // A crafted file can carry a count node.set would have clamped. + s.doc_mut().unwrap().get_mut(f).unwrap().layout.as_mut().unwrap().grid_columns = 5_000_000; + let p = json!({"ids": [f], "axis": "columns", "index": 4_999_999, "sizing": "hug"}); + assert!(s.execute("layout.setTrack", &p).is_err()); + assert!(node(&s, f).layout.unwrap().column_tracks.len() <= 1000); + } + #[test] fn layout_props_survive_hostile_values() { let junk = [json!(null), json!("NaN"), json!(-1e308), json!(1e308), json!([]), json!({}), json!(true), json!("-5fr")]; From 8ab0de26908448b9d294e814d6871a65dfd9ea31 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:25:17 +0200 Subject: [PATCH 066/142] Keep wrong join-code streak when a correct code is tried (#1174) Fixes #1088 Co-authored-by: Claude Haiku 5.5 --- apps/figcraft-server/src/abuse.rs | 6 +++--- apps/figcraft-server/src/codes_tests.rs | 20 ++++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/apps/figcraft-server/src/abuse.rs b/apps/figcraft-server/src/abuse.rs index baf1882b..2ea939c3 100644 --- a/apps/figcraft-server/src/abuse.rs +++ b/apps/figcraft-server/src/abuse.rs @@ -120,15 +120,15 @@ impl Abuse { open } - /// `ip` tried a join code that was right (`ok`) or wrong: a right one forgives the wrong ones - /// before it; enough wrong ones in a row lock the address out. + /// `ip` tried a join code that was right (`ok`) or wrong: a right one doesn't forgive the wrong + /// ones before it (they only decay with time); enough wrong ones lock the address out. pub fn code_tried(&mut self, ip: IpAddr, ok: bool, now: Instant) { if !ok { self.wrong_codes.take(now); } for (k, times) in ip_blocks(ip) { let a = self.address(k, f64::from(times), now); - if ok || now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT { + if now.saturating_duration_since(a.wrong_at) > CODE_LOCKOUT { a.wrong = 0; } if !ok { diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs index 6392e552..1e440a32 100644 --- a/apps/figcraft-server/src/codes_tests.rs +++ b/apps/figcraft-server/src/codes_tests.rs @@ -262,17 +262,21 @@ fn the_limits_on_codes_over_time() { let t0 = Instant::now(); let me = ip("198.51.100.9"); let mut abuse = Abuse::new(AbuseConfig { open: false, connects_per_minute: 30, publishes_per_hour: 10, messages_per_second: 200 }); - // A right code forgives the wrong ones before it. + // A right code, even from a neighbour in the same /64, doesn't forgive the wrong ones. + let a = ip("2001:db8:1:2::1"); + let b = ip("2001:db8:1:2::2"); for _ in 0..WRONG_CODES - 1 { + abuse.code_tried(a, false, t0); + } + abuse.code_tried(b, true, t0); + abuse.code_tried(a, true, t0); + assert_eq!(abuse.code_locked(a, t0), None); + abuse.code_tried(a, false, t0); + assert_eq!(abuse.code_locked(a, t0), Some(CODE_LOCKOUT)); + // Wrong codes: locked for the lockout, then free again. + for _ in 0..WRONG_CODES { abuse.code_tried(me, false, t0); } - abuse.code_tried(me, true, t0); - for _ in 0..WRONG_CODES - 1 { - abuse.code_tried(me, false, t0); - } - assert_eq!(abuse.code_locked(me, t0), None); - // One more: locked for the lockout, then free again. - abuse.code_tried(me, false, t0); assert_eq!(abuse.code_locked(me, t0), Some(CODE_LOCKOUT)); assert!(abuse.code_locked(me, t0 + CODE_LOCKOUT - Duration::from_secs(1)).is_some()); assert_eq!(abuse.code_locked(me, t0 + CODE_LOCKOUT), None); From 35f5faa2e673133a81686e88e6a6de9aba1e95f1 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:25:43 +0200 Subject: [PATCH 067/142] Check SVG nesting depth after DTD entity expansion (#1177) Fixes #1081 Co-authored-by: Claude Opus 5.5 --- crates/svg/src/import/budget.rs | 2 +- crates/svg/src/import/hostile_tests.rs | 15 ++++++++++ crates/svg/src/import/mod.rs | 38 +++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/crates/svg/src/import/budget.rs b/crates/svg/src/import/budget.rs index e7ae3fc6..75a5e673 100644 --- a/crates/svg/src/import/budget.rs +++ b/crates/svg/src/import/budget.rs @@ -74,7 +74,7 @@ fn embedded(used: &Used, limits: &Limits, data: &[u8], opts: &Options) -> Option let left = limits.embedded.saturating_sub(used.bytes.load(Ordering::Relaxed)); let plain = inflate(data, left).ok()?; used.bytes.fetch_add(plain.len(), Ordering::Relaxed); - if super::nesting(&plain, super::MAX_NESTING) > super::MAX_NESTING { + if super::too_deep(&plain) { return None; } used.depth.fetch_add(1, Ordering::Relaxed); diff --git a/crates/svg/src/import/hostile_tests.rs b/crates/svg/src/import/hostile_tests.rs index bb750286..0af1297d 100644 --- a/crates/svg/src/import/hostile_tests.rs +++ b/crates/svg/src/import/hostile_tests.rs @@ -166,3 +166,18 @@ fn nesting_is_measured_before_parsing() { let p = d.first_page().unwrap(); assert!(import_svg(&mut d, &svg(&deep), p, 0.0, 0.0, "x").is_err()); } + +#[test] +fn entity_expansion_cannot_bypass_the_nesting_limit() { + // An internal-DTD entity whose value nests 300 deep, referenced inside a body nesting 300 deep: + // the parsed tree is ~600 deep although the raw bytes never are. + let inner = format!("{}{}", "".repeat(300), "".repeat(300)); + let body = format!("{}&deep;{}", "".repeat(300), "".repeat(300)); + let data = format!( + "]>{body}" + ); + let mut d = Document::new("t"); + let p = d.first_page().unwrap(); + let err = import_svg(&mut d, data.as_bytes(), p, 0.0, 0.0, "x").err().map(|e| e.to_string()).unwrap_or_default(); + assert!(err.contains("nested more than 512 deep"), "{err:?}"); +} diff --git a/crates/svg/src/import/mod.rs b/crates/svg/src/import/mod.rs index 3aed363a..dc936260 100644 --- a/crates/svg/src/import/mod.rs +++ b/crates/svg/src/import/mod.rs @@ -672,6 +672,42 @@ pub(crate) fn nesting(data: &[u8], limit: usize) -> usize { max } +/// Whether `data` nests elements more than [`MAX_NESTING`] deep: the byte scan first, then the +/// parsed tree (walked without recursion), which also counts what DTD entity references expand to. +pub(crate) fn too_deep(data: &[u8]) -> bool { + if nesting(data, MAX_NESTING) > MAX_NESTING { + return true; + } + let Ok(text) = std::str::from_utf8(data) else { return false }; + let opt = usvg::roxmltree::ParsingOptions { allow_dtd: true, ..Default::default() }; + let Ok(xml) = usvg::roxmltree::Document::parse_with_options(text, opt) else { return false }; + // Depth-first by sibling and parent links; the root is depth 0, each child one deeper. + let (mut node, mut depth) = (xml.root(), 0usize); + loop { + if let Some(child) = node.first_child() { + node = child; + depth += 1; + if depth > MAX_NESTING { + return true; + } + continue; + } + loop { + if let Some(next) = node.next_sibling() { + node = next; + break; + } + match node.parent() { + Some(up) if depth > 0 => { + node = up; + depth -= 1; + } + _ => return false, + } + } + } +} + fn options(data: &[u8], limits: budget::Limits) -> usvg::Options<'static> { // Only data URIs, within budgets: an opened or pasted SVG never pulls other files off the disk // and never expands without limit. @@ -728,7 +764,7 @@ fn import_within( ) -> Result { // SVGZ is unpacked here, within a budget, so every check below sees the real document. let data = budget::document(data, &limits).map_err(ImportError)?; - if nesting(&data, MAX_NESTING) > MAX_NESTING { + if too_deep(&data) { return Err(ImportError(format!("elements nested more than {MAX_NESTING} deep"))); } let tree = usvg::Tree::from_data(&data, &options(&data, limits)).map_err(|e| ImportError(e.to_string()))?; From a80df8ed187d5108c50c54beb0c2404c272d289e Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:26:06 +0200 Subject: [PATCH 068/142] Count scalar kiwi fields against the decoded-value budget (#1188) Fixes #1057 Co-authored-by: Claude Opus 5.5 --- crates/format/src/fig/kiwi.rs | 4 ++++ crates/format/src/fig/tests.rs | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 5c735b28..ab8f3218 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -359,6 +359,10 @@ impl<'s> Decoder<'s, '_> { fn field(&mut self, f: &Field, depth: usize) -> Result, KiwiError> { let ty = f.ty.ok_or_else(|| KiwiError("message: untyped field".into()))?; if !f.array { + // A nested definition spends its own value in `def`. + if !matches!(ty, Type::Def(_)) { + self.spend(1)?; + } return self.value(ty, depth); } let n = self.r.var_uint()? as usize; diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index b0c3c5fe..35d8d08a 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -895,6 +895,18 @@ fn the_mapper_stops_at_the_layer_cap_it_is_given() { assert!(!warnings.iter().any(|w| w.contains("more than")), "{warnings:?}"); } +#[test] +fn scalar_fields_count_against_the_value_budget() { + // A message of 1000 scalar fields (id 1, `true`) is 1001 values: a budget of 10 refuses it. + let fields = vec![Field { name: "b".into(), ty: Some(Type::Bool), array: false, value: 1 }]; + let sc = Schema { defs: vec![Def { name: "Message".into(), kind: Kind::Message, fields }] }; + let mut data = [1u8, 1].repeat(1000); + data.push(0); + let e = sc.decode_message_within(0, &data, 10).unwrap_err(); + assert_eq!(e.0, "message: too many values"); + assert!(sc.decode_message_within(0, &data, 1001).is_ok()); +} + #[test] fn hostile_input_is_refused() { // A message that contains itself, nested past the depth cap. From 10702311f345393ee1d8868c3257679befcfa14d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:26:27 +0200 Subject: [PATCH 069/142] Require a per-client secret to resume a collab client id (#1193) Fixes #1054 Co-authored-by: Claude Opus 5.5 --- apps/figcraft/src/recovery.rs | 11 +++++--- apps/figcraft/src/recovery_online_tests.rs | 2 +- crates/collab-net/src/join.rs | 4 +-- crates/collab/src/client.rs | 15 +++++++---- crates/collab/src/codec_tests.rs | 4 ++- crates/collab/src/message.rs | 9 ++++++- crates/collab/src/reconnect_tests.rs | 19 ++++++++++++++ crates/collab/src/server.rs | 29 +++++++++++++++++----- crates/engine/src/online.rs | 5 ++-- 9 files changed, 77 insertions(+), 21 deletions(-) diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index d8883529..605a04ca 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -206,10 +206,12 @@ impl Recovery { let doc = d.doc.clone(); let (versions, branches) = (d.versions.clone(), figcraft_engine::branches::current(s, d.uid)); // Which online file it is and what of the server's this very copy has (a line each: - // the server, the file's id, then the session, client id and last change), while it + // the server, the file's id, then the session, client id, last change and the secret the + // client id resumes with), while it // is one. Written after the copy, never before: a note must not promise more of the // server's changes than its copy holds. - let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq)); + let online = + s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret)); let job = move || { if !write_atomic(&path, &doc, &versions, &branches) { return false; @@ -312,7 +314,10 @@ fn rejoin_online(file: &Path, s: &mut Session) -> Option { let (server, id, at) = (lines.next()?, lines.next()?, lines.next()?); let mut at = at.split(' ').map(|n| n.parse::().ok()); let (session, client, seq) = (at.next()??, at.next()??, at.next()??); - let params = json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq}); + // Absent in notes written before ids had secrets. + let secret = at.next().flatten().unwrap_or(0); + let params = + json!({"path": file.to_string_lossy(), "server": server, "file": id, "session": session, "client": client, "seq": seq, "secret": secret}); s.execute("collab.recoverFile", ¶ms).ok()?.get("session")?.as_u64() } diff --git a/apps/figcraft/src/recovery_online_tests.rs b/apps/figcraft/src/recovery_online_tests.rs index c9036773..f877a0d4 100644 --- a/apps/figcraft/src/recovery_online_tests.rs +++ b/apps/figcraft/src/recovery_online_tests.rs @@ -193,7 +193,7 @@ fn a_copy_ahead_of_the_server_has_its_edits_applied_and_sent() { let left = crash(&s, &root); let note = std::fs::read_to_string(left.join(format!("{}.online", s.docs[0].uid))).unwrap(); let at = s.online_of(s.docs[0].uid).unwrap().2; - assert_eq!(note, format!("{SERVER}\nf1\n{} {} {}", at.session, at.client_id, at.seq), "which file, who the app was, what it had"); + assert_eq!(note, format!("{SERVER}\nf1\n{} {} {} {}", at.session, at.client_id, at.seq, at.secret), "which file, who the app was, what it had"); assert!(!note.contains(OWNER) && !note.contains("token="), "never a secret"); let (mut next, script, mut r, opened) = restart(&s, &root); assert_eq!(opened, 0, "nothing comes back as a plain copy"); diff --git a/crates/collab-net/src/join.rs b/crates/collab-net/src/join.rs index cc3be839..43b0b7b8 100644 --- a/crates/collab-net/src/join.rs +++ b/crates/collab-net/src/join.rs @@ -189,9 +189,9 @@ pub(crate) enum Taken { /// in answer must go out on this connection. pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Seen, resume: &Resuming, welcomed: &dyn Fn()) -> Taken { match m { - ServerMsg::Welcome { session, client_id, seq, ref peers, access, .. } => { + ServerMsg::Welcome { session, client_id, seq, ref peers, access, secret, .. } => { if let Ok(mut r) = resume.lock() { - *r = Some(Resume { session, client_id, seq }); + *r = Some(Resume { session, client_id, seq, secret }); } // The sync client opens the document (and fills in its name and pages). let peers = peers.clone(); diff --git a/crates/collab/src/client.rs b/crates/collab/src/client.rs index 8f2a9841..a84a4ffd 100644 --- a/crates/collab/src/client.rs +++ b/crates/collab/src/client.rs @@ -41,6 +41,8 @@ pub struct Client { /// The session joined (see [`ServerMsg::Welcome`]). session: u64, id: u32, + /// What this client resumes its id with (see [`Resume::secret`]). + secret: u64, /// The server's state as of `seq`. confirmed: Replica, seq: u64, @@ -84,7 +86,7 @@ impl Client { /// Start from the server's `Welcome` (which must carry a snapshot). pub fn join(welcome: ServerMsg) -> Result { - let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access } = welcome else { + let ServerMsg::Welcome { session, client_id, seq, catch_up: CatchUp::Snapshot(snapshot), processed, peers, access, secret } = welcome else { return Err(Error::Protocol("expected a welcome with the document".into())); }; let mut confirmed = Replica::from_snapshot(&snapshot)?; @@ -93,6 +95,7 @@ impl Client { Ok(Client { session, id: client_id, + secret, view: confirmed.clone(), confirmed, seq, @@ -415,9 +418,10 @@ impl Client { ClientMsg::Hello(Hello { resume: Some(self.resume()), ..hello }) } - /// What a reconnect resumes with: this client's session, id and last sequence number. + /// What a reconnect resumes with: this client's session, id, last sequence number and the + /// secret its id was given with. pub fn resume(&self) -> Resume { - Resume { session: self.session, client_id: self.id, seq: self.seq } + Resume { session: self.session, client_id: self.id, seq: self.seq, secret: self.secret } } /// Handle a message from the server. Returns messages to send: batches that were waiting for @@ -458,10 +462,11 @@ impl Client { Ok(Vec::new()) } ServerMsg::Welcome { .. } if self.online => Err(Error::Protocol("already joined".into())), - ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access } => { + ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret } => { if session != self.session || client_id != self.id { - return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access }); + return self.restart(ServerMsg::Welcome { session, client_id, seq, catch_up, processed, peers, access, secret }); } + self.secret = secret; self.access = access; self.rejoin(seq, catch_up, &processed, peers, recompute) } diff --git a/crates/collab/src/codec_tests.rs b/crates/collab/src/codec_tests.rs index 9f5d12fb..3a613419 100644 --- a/crates/collab/src/codec_tests.rs +++ b/crates/collab/src/codec_tests.rs @@ -38,7 +38,7 @@ fn samples() -> (Vec, Vec) { name: "Ada".into(), color: "#FF8800".into(), client_version: "0.2.0".into(), - resume: Some(Resume { session: 77, client_id: 4, seq: 10 }), + resume: Some(Resume { session: 77, client_id: 4, seq: 10, secret: 0x5EC2E7 }), }), moved_layer(), cursor(), @@ -102,6 +102,7 @@ fn samples() -> (Vec, Vec) { processed: Processed::default(), peers: vec![peer.clone()], access: Access::Edit, + secret: 0x5EC2E7, }, ServerMsg::Welcome { session: 77, @@ -111,6 +112,7 @@ fn samples() -> (Vec, Vec) { processed: Processed { last_batch: 7, refused: vec![3, 6] }, peers: vec![], access: Access::View, + secret: 1, }, ServerMsg::Changes(Accepted { seq: 11, author: 4, batch_id: 2, changes: vec![set.clone(), Change::Delete { id: NodeId(5) }] }), ServerMsg::Reject { batch_id: 3, reason: "no".into(), fixes: vec![set] }, diff --git a/crates/collab/src/message.rs b/crates/collab/src/message.rs index 4fda776a..d128f278 100644 --- a/crates/collab/src/message.rs +++ b/crates/collab/src/message.rs @@ -94,6 +94,10 @@ pub struct Resume { pub client_id: u32, /// The last sequence number it applied. pub seq: u64, + /// The secret the server gave with this client id ([`ServerMsg::Welcome`]'s `secret`): only + /// the client the id was given to can resume it (0: none). + #[serde(default)] + pub secret: u64, } /// Someone in the session. @@ -635,7 +639,8 @@ pub enum ServerMsg { /// Joined: the session's identity (a server that restarted without its saved state is a new /// session), this connection's client id, the current sequence number, the document (or the /// batches missed since a reconnecting client's last sequence number), what the server did - /// with this client's batches so far, who else is here and what this client may do. + /// with this client's batches so far, who else is here, what this client may do and the + /// secret it resumes its id with ([`Resume::secret`]; only this client is told). Welcome { session: u64, client_id: u32, @@ -644,6 +649,8 @@ pub enum ServerMsg { processed: Processed, peers: Vec, access: Access, + #[serde(default)] + secret: u64, }, /// Accepted changes, in order, to everyone (the author's own come back as confirmation). Changes(Accepted), diff --git a/crates/collab/src/reconnect_tests.rs b/crates/collab/src/reconnect_tests.rs index 95ebbb2b..5c7c4205 100644 --- a/crates/collab/src/reconnect_tests.rs +++ b/crates/collab/src/reconnect_tests.rs @@ -253,3 +253,22 @@ fn random_drops_and_reconnects_converge() { } assert!(snapshots > 0, "no reconnect needed a snapshot"); } + +#[test] +fn resume_needs_the_issued_secret() { + let mut s = Server::new(base(), "tok", Limits { max_peers: 2, ..Limits::default() }); + let mut host = join(&mut s); + let guest = join(&mut s); + // A participant who knows the (public) session and the host's id can't take that id over, + // whether the session is full or not, and with view-only access or not. + let forged = message::Resume { client_id: host.id(), ..guest.resume() }; + assert_eq!(forged.session, host.resume().session); + let refused = s.join_as(Hello { resume: Some(forged), ..hello() }, message::Access::View); + assert!(matches!(refused, Err(ServerMsg::Refused { .. })), "a forged resume of a live id is refused: {refused:?}"); + s.leave(guest.id()); + let (id, _) = s.join_as(Hello { resume: Some(forged), ..hello() }, message::Access::View).unwrap(); + assert_ne!(id, host.id(), "a forged resume joins as new"); + // The real client still comes back as itself. + let resend = come_back(&mut s, &mut host); + assert!(resend.is_empty()); +} diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index dd654efe..52f112ad 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -7,7 +7,7 @@ use figcraft_doc::{Document, NodeId}; use serde::{Deserialize, Serialize}; use crate::change::{Change, Entry, Prop, Target}; -use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg, Signal, reads_signals}; +use crate::message::{Accepted, Access, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, Resume, ServerMsg, Signal, reads_signals}; use crate::replica::{Mode, Replica, create_change}; use crate::{Error, Limits, docprops, same_secret}; @@ -61,6 +61,9 @@ pub struct Server { /// Every client id handed out (a reconnecting client keeps its id) and what was done with /// its batches. issued: BTreeMap, + /// The secret each client id resumes with (sent only in that client's welcome). Not saved: + /// after a restart, the first to come back for an id that isn't connected sets it. + secrets: BTreeMap, /// The latest accepted batches, oldest first (a reconnecting client gets what it missed). log: VecDeque, /// Changes in `log`, and about how many bytes it holds ([`weight`]). @@ -116,6 +119,7 @@ impl Server { deleted: BTreeSet::new(), removed: BTreeSet::new(), issued: BTreeMap::new(), + secrets: BTreeMap::new(), log: VecDeque::new(), log_changes: 0, log_bytes: 0, @@ -219,6 +223,7 @@ impl Server { && let Some(old) = self.issued.keys().copied().find(|k| !self.peers.contains_key(k)) { self.issued.remove(&old); + self.secrets.remove(&old); } } @@ -266,7 +271,7 @@ impl Server { if !same_secret(&hello.token, &self.token) { return Err(refuse("wrong session token")); } - let resume = hello.resume.filter(|r| r.session == self.session && self.issued.contains_key(&r.client_id)); + let resume = hello.resume.filter(|r| r.session == self.session && self.issued.contains_key(&r.client_id) && self.may_resume(r)); if self.peers.len() >= self.limits.max_peers && resume.is_none_or(|r| !self.peers.contains_key(&r.client_id)) { return Err(refuse("the session is full")); } @@ -282,15 +287,17 @@ impl Server { Some(r) if r.seq <= self.seq && r.seq >= floor => CatchUp::Delta(self.log.iter().filter(|a| a.seq > r.seq).cloned().collect()), _ => CatchUp::Snapshot(self.state.snapshot().map_err(|e| refuse(&e.to_string()))?), }; - let id = match resume { - Some(r) => r.client_id, + let (id, secret) = match resume { + Some(r) => (r.client_id, *self.secrets.entry(r.client_id).or_insert(r.secret)), None => { let id = self.next_client; self.next_client += 1; self.issued.insert(id, Processed::default()); + let secret = new_session_id().max(1); + self.secrets.insert(id, secret); self.forget_oldest(); self.note(Journal::Issued { client: id }); - id + (id, secret) } }; let processed = self.issued.get(&id).cloned().unwrap_or_default(); @@ -298,7 +305,7 @@ impl Server { let signals = reads_signals(&hello.client_version); let peer = Peer { client_id: id, name: hello.name, color: hello.color }; let others = self.peers.values().filter(|p| p.client_id != id).cloned().collect(); - let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access }; + let welcome = ServerMsg::Welcome { session: self.session, client_id: id, seq: self.seq, catch_up, processed, peers: others, access, secret }; self.peers.insert(id, peer.clone()); if access == Access::View { self.viewers.insert(id); @@ -318,6 +325,16 @@ impl Server { Ok((id, outs)) } + /// Whether `r` may take its client id back: it carries the secret that id was given with, or + /// (after a restart, which forgets secrets) a secret for an id nobody is connected as. + fn may_resume(&self, r: &Resume) -> bool { + r.secret != 0 + && match self.secrets.get(&r.client_id) { + Some(s) => *s == r.secret, + None => !self.peers.contains_key(&r.client_id), + } + } + /// Client `id`'s connection closed. pub fn leave(&mut self, id: u32) -> Vec { self.presence.remove(&id); diff --git a/crates/engine/src/online.rs b/crates/engine/src/online.rs index 66713732..c2a0868d 100644 --- a/crates/engine/src/online.rs +++ b/crates/engine/src/online.rs @@ -521,7 +521,8 @@ fn recover_file(s: &mut Session, p: &Value) -> Result { let i = named(s, CMD, p)?; let num = |key: &str| p.get(key).and_then(Value::as_u64).ok_or_else(|| bad(CMD, format!("`{key}` must be a whole number"))); let client_id = u32::try_from(num("client")?).map_err(|_| bad(CMD, "`client` must be a client id"))?; - let at = Resume { session: num("session")?, client_id, seq: num("seq")? }; + let secret = p.get("secret").and_then(Value::as_u64).unwrap_or(0); + let at = Resume { session: num("session")?, client_id, seq: num("seq")?, secret }; if s.prefs.online_files.get(i).is_some_and(|f| s.online_session(f).is_some()) { return Err(bad(CMD, "this file is open already")); } @@ -642,7 +643,7 @@ pub fn specs() -> Vec { spec!( "collab.recoverFile", "Recover online file", - "{path (a recovery copy of one of your online files), file, server?, session, client, seq (who the app was in the file when the copy was written, and the server's last change it had: from the note beside the copy)} → {session, status: \"connecting\"}: joins the file again as its owner and keeps the copy until the file arrives. Then: the server has everything in the copy → nothing more; the copy has changes of yours the server never confirmed and nothing else changed there → they are applied on top as one edit (\"Recovered changes\", synced to everyone); anything else changed meanwhile → the copy opens beside the file as “ (recovered copy)”; the file can't be joined → the copy opens as an unsaved document. The session's message says which", + "{path (a recovery copy of one of your online files), file, server?, session, client, seq, secret? (who the app was in the file when the copy was written, and the server's last change it had: from the note beside the copy)} → {session, status: \"connecting\"}: joins the file again as its owner and keeps the copy until the file arrives. Then: the server has everything in the copy → nothing more; the copy has changes of yours the server never confirmed and nothing else changed there → they are applied on top as one edit (\"Recovered changes\", synced to everyone); anything else changed meanwhile → the copy opens beside the file as “ (recovered copy)”; the file can't be joined → the copy opens as an unsaved document. The session's message says which", false, recover_file ), From 1cb5f403fc76625e83ed182dd05d2f884246f855 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Sat, 24 Oct 2026 11:52:57 +0200 Subject: [PATCH 070/142] M1: a section's title draws above the labels of frames inside it (#1150) boards() listed a section before its contents, so the label of a frame near the section's top-left was painted over the section's title pill, and label_at (last label wins) gave clicks on the pill to that frame. A section now comes after everything inside it: its pill draws on top and takes the click. Test: a_sections_pill_is_above_the_labels_of_frames_inside_it (fails on the old order). --- crates/ui-egui/src/canvas.rs | 6 ++++-- crates/ui-egui/src/frame_label_tests.rs | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5ced1edc..2d6a19f0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1470,7 +1470,9 @@ fn hatch(polys: &[Vec], gap: f32) -> Vec<[Pos2; 2]> { } /// The layers that get canvas labels and layout grids, in z-order: the page's visible top-level -/// layers and, inside sections whose contents show, their visible children (sections nest). +/// layers and, inside sections whose contents show, their visible children (sections nest). A +/// section comes after its children, so its title pill draws over (and takes clicks from) the +/// labels of frames near its top-left (#1150). fn boards(doc: &Document, page: NodeId) -> Vec { fn walk(doc: &Document, id: NodeId, depth: usize, out: &mut Vec) { let Some(n) = doc.node(id) else { return }; @@ -1479,10 +1481,10 @@ fn boards(doc: &Document, page: NodeId) -> Vec { if !cn.visible { continue; } - out.push(*c); if matches!(cn.kind, NodeKind::Section { contents_hidden: false }) && depth < 32 { walk(doc, *c, depth + 1, out); } + out.push(*c); } } let mut out = Vec::new(); diff --git a/crates/ui-egui/src/frame_label_tests.rs b/crates/ui-egui/src/frame_label_tests.rs index 687669e8..6c15fc00 100644 --- a/crates/ui-egui/src/frame_label_tests.rs +++ b/crates/ui-egui/src/frame_label_tests.rs @@ -156,3 +156,23 @@ fn a_sections_label_renames_it_and_a_click_elsewhere_commits() { assert!(u.app.ui.rename_label.is_none()); assert_eq!(u.app.session.ds().unwrap().history.undo.len(), undo + 1, "one undo step"); } + +#[test] +fn a_sections_pill_is_above_the_labels_of_frames_inside_it() { + // A frame near the section's top-left puts its label over the section's pill (#1150): the pill + // draws last, so pressing where they overlap selects the section. + let mut s = Session::new(); + let sec = id(s.execute("node.create", &json!({"type": "section", "x": 0, "y": 0, "width": 300, "height": 200, "name": "Marketing"})).unwrap()); + let f = id(s.execute("frame.create", &json!({"x": 10, "y": 22, "width": 100, "height": 80, "name": "Icon card", "parent": sec})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + let at = mid(&label(&u, sec)); + let frame_label = egui::Rect::from_points(&label(&u, f)); + assert!(frame_label.contains(at), "the frame's label {frame_label:?} overlaps the pill at {at:?}"); + let order: Vec = u.app.session.tool.labels.iter().map(|(id, _)| *id).collect(); + assert_eq!(order, vec![f, sec], "the section's label comes after (above) its frames'"); + u.button(at, true); + u.button(at, false); + assert_eq!(u.app.session.selection(), vec![sec]); +} From 29ab17c2226cca85e59586f0ca0bf5685f17da83 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 02:22:02 -0700 Subject: [PATCH 071/142] M6: Dev Mode refuses edits from dropped files, the Pages list and the Assets panel In Dev Mode the menus, shortcuts, clipboard, canvas and layers panel refuse design edits, but an image or SVG dropped on the canvas was still imported. The Pages list still added, renamed, reordered, duplicated and deleted pages. The Assets panel still placed instances, swapped them on an Alt-drop, applied styles, changed Hide when publishing, accepted library updates and added, removed or swapped libraries. All of these now go through dev_mode_refuses with the command they would run, and show its note. A dropped document still opens as a file of its own, and the drop overlay shows the note instead of "Drop to import". A click in the Pages list still opens a page, and page rows take no drags in Dev Mode. --- crates/ui-egui/src/assets_panel.rs | 46 +++-- crates/ui-egui/src/canvas.rs | 20 ++- crates/ui-egui/src/devmode_tail_tests.rs | 218 +++++++++++++++++++++++ crates/ui-egui/src/layers.rs | 32 +++- crates/ui-egui/src/menus.rs | 7 +- docs/dev-mode-parity.md | 3 +- docs/gaps.md | 9 +- 7 files changed, 302 insertions(+), 33 deletions(-) diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 36636c3d..bd83a28f 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -346,6 +346,7 @@ fn updates_banner(app: &mut FigApp, ui: &mut Ui, w: f32, count: usize) { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(r.max.x - 70.0, r.min.y + 6.0), pos2(r.max.x - 6.0, r.max.y)))); if crate::widgets::button(&mut child, tr("Update"), true, true).clicked() + && !crate::menus::dev_mode_refuses(app, "library.acceptUpdates") && let Some(v) = app.run("library.acceptUpdates", json!({})) { let (c, s) = (v["components"].as_u64().unwrap_or(0), v["styles"].as_u64().unwrap_or(0)); @@ -666,7 +667,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } Some(Act::Style(src, style)) => apply_style(app, &src, &style), Some(Act::Hide(id, on)) => { - app.run("component.hideWhenPublishing", json!({"ids": [id], "on": on})); + if !crate::menus::dev_mode_refuses(app, "component.hideWhenPublishing") { + app.run("component.hideWhenPublishing", json!({"ids": [id], "on": on})); + } } Some(Act::Toggle(id)) => { let mut c = collapsed; @@ -683,29 +686,39 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { /// Place component `id` of `src` centred on page point (`x`, `y`). fn place(app: &mut FigApp, src: &Source, id: NodeId, x: f64, y: f64) { - match src { - Source::Local => app.run("instance.place", json!({"component": id, "x": x, "y": y})), - Source::Library(lib) => app.run("library.place", json!({"library": lib, "component": id, "x": x, "y": y})), + let (cmd, params) = match src { + Source::Local => ("instance.place", json!({"component": id, "x": x, "y": y})), + Source::Library(lib) => ("library.place", json!({"library": lib, "component": id, "x": x, "y": y})), }; + if !crate::menus::dev_mode_refuses(app, cmd) { + app.run(cmd, params); + } } fn apply_style(app: &mut FigApp, src: &Source, style: &str) { + let (cmd, params) = match src { + Source::Local => ("style.apply", json!({"style": style})), + Source::Library(lib) => ("library.applyStyle", json!({"library": lib, "style": style})), + }; + if crate::menus::dev_mode_refuses(app, cmd) { + return; + } if app.session.selection().is_empty() { app.toast(tr("Select layers to apply the style to")); return; } - match src { - Source::Local => app.run("style.apply", json!({"style": style})), - Source::Library(lib) => app.run("library.applyStyle", json!({"library": lib, "style": style})), - }; + app.run(cmd, params); } /// Swap the instance at page point (`x`, `y`) to component `id` of `src`. fn swap_at(app: &mut FigApp, src: &Source, id: NodeId, x: f64, y: f64) { - match src { - Source::Local => app.run("instance.swapAt", json!({"component": id, "x": x, "y": y})), - Source::Library(lib) => app.run("library.swapAt", json!({"library": lib, "component": id, "x": x, "y": y})), + let (cmd, params) = match src { + Source::Local => ("instance.swapAt", json!({"component": id, "x": x, "y": y})), + Source::Library(lib) => ("library.swapAt", json!({"library": lib, "component": id, "x": x, "y": y})), }; + if !crate::menus::dev_mode_refuses(app, cmd) { + app.run(cmd, params); + } } /// The component set (by name) a dragged asset belongs to, in the document it comes from: the @@ -1075,7 +1088,9 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { crate::widgets::text_field(ui, Id::new("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)")) }) .inner; - if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) { + if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) + && !crate::menus::dev_mode_refuses(app, ADD) + { app.run_background(&ui.ctx().clone(), ADD, json!({"path": link}), Box::new(|app, _, r| added(app, &r))); } if app.session.waiting_for(ADD) { @@ -1087,10 +1102,13 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { } }); } - if let Some(id) = remove { + if let Some(id) = remove + && !crate::menus::dev_mode_refuses(app, "library.remove") + { app.run("library.remove", json!({"id": id})); } if let Some((from, to)) = swap + && !crate::menus::dev_mode_refuses(app, "library.swap") && let Some(r) = app.run("library.swap", json!({"from": from, "to": to})) { let moved = ["components", "styles", "variables"].iter().map(|k| r[*k].as_u64().unwrap_or(0)).sum::(); @@ -1101,7 +1119,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { trn("Swapped {n} assets; {missed} had no match and still use the old library", moved as usize, &[("missed", &missed)]) }); } - if add { + if add && !crate::menus::dev_mode_refuses(app, ADD) { app.pick_open(tr("Add library"), &["figcraft"], |app, p| { if let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()})) { added(app, &r); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5ced1edc..cb8df52d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -183,9 +183,17 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } } let own_cursor = input(app, ui, &resp); - // A browser names no dragged file until the drop, so documents there still say "import". + // A browser names no dragged file until the drop, so documents there still say "import", or + // in Dev Mode show the Dev Mode note, though they open when dropped. let documents = ctx.input(|i| i.raw.hovered_files.iter().all(|f| f.path.as_deref().is_some_and(crate::is_document))); - crate::drop_overlay(&ctx, ctx.content_rect(), if documents { tr("Drop to open") } else { tr("Drop to import") }); + let label = if documents { + tr("Drop to open") + } else if app.ui.right_tab == crate::RightTab::Inspect { + tr("Dev Mode only inspects: switch to Design (Shift+D) to edit") + } else { + tr("Drop to import") + }; + crate::drop_overlay(&ctx, ctx.content_rect(), label); crate::guides::end_released(&ctx); crate::live::track_pointer(app, &ctx); app.sync_preedit(); @@ -510,14 +518,20 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { return false; } // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]); - // a dropped document opens as a file of its own, as on the home screen. + // a dropped document opens as a file of its own, as on the home screen. Dev Mode imports + // nothing into the design, and says so once for the whole drop. let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + let mut refused = false; for f in dropped { let path = f.path().to_path_buf(); if crate::is_document(&path) { crate::pending_open::open_dropped(app, &f); continue; } + if refused || crate::menus::dev_mode_refuses(app, "file.import") { + refused = true; + continue; + } let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default(); let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); let (params, read) = if path.exists() { diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs index b7901083..10233923 100644 --- a/crates/ui-egui/src/devmode_tail_tests.rs +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -164,6 +164,82 @@ fn dev_mode_layers_panel_selects_and_expands_but_edits_nothing() { assert!(u.app.ui.rename.is_some_and(|r| r.id == a)); } +#[test] +fn dev_mode_pages_list_opens_pages_but_edits_none() { + use egui::{Event, Pos2}; + let mut s = Session::new(); + let home = s.page().unwrap(); + s.execute("page.rename", &json!({"id": home, "name": "Home"})).unwrap(); + let specs = id(&s.execute("page.add", &json!({"name": "Specs"})).unwrap()); + s.execute("page.select", &json!({"id": home})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + let shapes = |u: &mut crate::layers_drag_tests::Ui| crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let s = shapes(&mut u); + let (rh, rs) = (crate::live_tests::text_center(&s, "Home"), crate::live_tests::text_center(&s, "Specs")); + // The add button, found by its name in the AccessKit tree. + u.ctx.enable_accesskit(); + let nodes = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; + let b = nodes.iter().find(|(_, n)| n.label() == Some("Add new page")).and_then(|(_, n)| n.bounds()).unwrap(); + let add = Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32); + u.ctx.disable_accesskit(); + // The pointer arrives first: a row that also takes drags counts a press there only then. + let click = |u: &mut crate::layers_drag_tests::Ui, p: Pos2| { + let button = |pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![button(true)]); + u.frame(vec![button(false)]); + }; + let rest = |u: &mut crate::layers_drag_tests::Ui| { + for _ in 0..30 { + u.frame(vec![]); + } + }; + let pages = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().pages.clone(); + let said_why = |u: &mut crate::layers_drag_tests::Ui| u.app.ui.toast.take().is_some_and(|(m, _)| m.contains("Shift+D")); + // A click opens the page. + click(&mut u, rs); + assert_eq!(u.app.session.page().unwrap(), specs); + // A double click doesn't rename, and says why. + click(&mut u, rs); + assert!(u.app.ui.rename_page.is_none(), "no rename"); + assert!(said_why(&mut u)); + rest(&mut u); + // Dragging a page doesn't reorder. + u.drag_to(rs, rh - egui::vec2(0.0, 10.0)); + u.button(rh - egui::vec2(0.0, 10.0), false); + rest(&mut u); + assert_eq!(pages(&u), [home, specs], "not reordered"); + // The add button adds no page, and says why. + click(&mut u, add); + assert_eq!(pages(&u), [home, specs], "no page added"); + assert!(said_why(&mut u)); + rest(&mut u); + // The row's Duplicate page and Delete page do nothing, and say why. + for item in ["Duplicate page", "Delete page"] { + u.frame(vec![ + Event::PointerMoved(rs), + Event::PointerButton { pos: rs, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }, + ]); + u.frame(vec![Event::PointerButton { pos: rs, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }]); + let s = shapes(&mut u); + click(&mut u, crate::live_tests::text_center(&s, item)); + assert_eq!(pages(&u), [home, specs], "{item} changes no page"); + assert!(said_why(&mut u), "{item} says why"); + rest(&mut u); + } + // In the design the double click renames and the add button adds a page. + u.app.ui.right_tab = crate::RightTab::Design; + u.frame(vec![]); + click(&mut u, rs); + click(&mut u, rs); + assert!(u.app.ui.rename_page.as_ref().is_some_and(|(p, _)| *p == specs)); + u.app.ui.rename_page = None; + rest(&mut u); + click(&mut u, add); + assert_eq!(pages(&u).len(), 3, "a page is added"); +} + #[test] fn measurements_kept_on_the_canvas_draw_in_dev_mode_and_follow_their_layers() { let mut s = Session::new(); @@ -186,3 +262,145 @@ fn measurements_kept_on_the_canvas_draw_in_dev_mode_and_follow_their_layers() { u.frame(vec![]); assert!(labels(&u).is_empty()); } + +/// A file dropped with its contents and no file on disk. +#[derive(Debug)] +struct DroppedBytes(std::path::PathBuf, Vec); + +impl egui::DroppedFile for DroppedBytes { + fn path(&self) -> &std::path::Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + Ok(self.1.clone()) + } +} + +/// Hover `names` over the canvas (one frame: what it shows), then drop them there with `bytes`. +fn drop_on_canvas(u: &mut crate::layers_drag_tests::Ui, names: &[&str], bytes: &[u8]) -> Vec { + let at = u.app.canvas.rect.center(); + let path = |n: &str| std::path::Path::new("figcraft-dev-mode-drop").join(n); + let hovered = names.iter().map(|n| egui::HoveredFile { path: Some(path(n)), mime: String::new() }).collect(); + let out = u.frame_raw(egui::RawInput { events: vec![egui::Event::PointerMoved(at)], hovered_files: hovered, ..Default::default() }); + let files = names.iter().map(|n| std::sync::Arc::new(DroppedBytes(path(n), bytes.to_vec())) as egui::DroppedFileHandle).collect(); + u.frame_raw(egui::RawInput { events: vec![egui::Event::PointerMoved(at)], dropped_files: files, ..Default::default() }); + crate::live_tests::flat(&out.shapes) +} + +#[test] +fn dev_mode_imports_no_dropped_file_into_the_design_and_says_why() { + let svg = br#""#; + let refusal = "Dev Mode only inspects: switch to Design (Shift+D) to edit"; + let mut u = crate::layers_drag_tests::Ui::new(Session::new()); + let layers = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(u.app.session.page().unwrap()).unwrap().children.len(); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + let s = drop_on_canvas(&mut u, &["Logo.svg", "Mark.svg"], svg); + assert!(crate::live_tests::has_text(&s, refusal), "dragging images over the canvas says why they won't import"); + assert!(u.app.ui.pending_opens.is_empty(), "nothing waits to import"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m == refusal)); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(layers(&u), 0, "the design is unchanged"); + // A dropped document still opens as a file of its own. + let s = drop_on_canvas(&mut u, &["Other.figcraft"], b""); + assert!(crate::live_tests::has_text(&s, "Drop to open")); + assert_eq!(u.app.ui.pending_opens.iter().map(|p| p.command).collect::>(), ["file.open"]); + u.app.ui.pending_opens.clear(); + // In the design the same drop imports. + u.app.ui.right_tab = crate::RightTab::Design; + u.frame(vec![]); + let s = drop_on_canvas(&mut u, &["Logo.svg"], svg); + assert!(crate::live_tests::has_text(&s, "Drop to import")); + assert_eq!(u.app.ui.pending_opens.iter().map(|p| p.command).collect::>(), ["file.import"]); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(layers(&u), 1, "the image is imported"); +} + +#[test] +fn dev_mode_assets_panel_places_and_applies_nothing() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 80, "height": 40, "name": "Card"})).unwrap()); + s.execute("component.create", &json!({"ids": [f]})).unwrap(); + s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 40, "height": 40})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.left_tab = crate::LeftTab::Assets; + u.app.ui.right_tab = crate::RightTab::Inspect; + let instances = |u: &crate::layers_drag_tests::Ui| { + u.app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).count() + }; + let styled = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(r).is_some_and(|n| n.styles.fill.is_some()); + let click = |u: &mut crate::layers_drag_tests::Ui, text: &str| { + let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let at = crate::live_tests::text_center(&s, text); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); + }; + click(&mut u, "Card"); + click(&mut u, "Brand"); + let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let (tile, canvas) = (crate::live_tests::text_center(&s, "Card"), u.app.canvas.rect.center()); + u.drag_to(tile, canvas); + u.button(canvas, false); + u.frame(vec![]); + assert_eq!(instances(&u), 0, "a click on a component, or a drag onto the canvas, places no instance"); + assert!(!styled(&u), "a click on a style doesn't apply it"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + // In the design both edit. + u.app.ui.right_tab = crate::RightTab::Design; + click(&mut u, "Card"); + assert_eq!(instances(&u), 1); + u.drag_to(tile, canvas); + u.button(canvas, false); + u.frame(vec![]); + assert_eq!(instances(&u), 2, "the same drag places one"); + u.app.session.execute("select.set", &json!({"ids": [r]})).unwrap(); + click(&mut u, "Brand"); + assert!(styled(&u)); +} + +#[test] +fn dev_mode_assets_panel_accepts_no_library_updates() { + let dir = std::env::temp_dir().join(format!("figcraft-dev-mode-updates-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Library.figcraft"); + let mut lib = Session::new(); + lib.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0000FF"}]})).unwrap(); + lib.execute("library.publish", &json!({})).unwrap(); + lib.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let mut s = Session::new(); + let added = s.execute("library.add", &json!({"path": path.to_string_lossy()})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("library.applyStyle", &json!({"library": added["id"], "style": "Brand"})).unwrap(); + lib.execute("style.update", &json!({"style": "Brand", "value": "#00FF00"})).unwrap(); + lib.execute("library.publish", &json!({})).unwrap(); + lib.execute("file.save", &json!({})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.left_tab = crate::LeftTab::Assets; + u.app.ui.right_tab = crate::RightTab::Inspect; + let updates = |u: &mut crate::layers_drag_tests::Ui| u.app.session.execute("library.updates", &json!({})).unwrap()["count"].as_u64(); + let update = |u: &mut crate::layers_drag_tests::Ui| { + let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let at = crate::live_tests::text_center(&s, "Update"); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); + }; + assert_eq!(updates(&mut u), Some(1)); + update(&mut u); + assert_eq!(updates(&mut u), Some(1), "Update in Dev Mode pulls nothing in"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + u.app.ui.right_tab = crate::RightTab::Design; + update(&mut u); + assert_eq!(updates(&mut u), Some(0), "in the design it does"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_dir(&dir); +} diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 95c0b3bd..9e1db925 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -175,6 +175,11 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { fn pages(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // Dev Mode only inspects: rows open pages, but don't add, rename, reorder, duplicate or delete. + let dev = app.ui.right_tab == crate::RightTab::Inspect; + if dev { + app.ui.rename_page = None; + } let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let tresp = ui.interact(Rect::from_min_size(hr.min, vec2(w - 40.0, 40.0)), ui.id().with("pages_toggle"), Sense::click()); @@ -205,7 +210,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon); if aresp.clicked() { app.ui.pages_focus = app.session.ds().ok().map(|d| d.uid); - app.run("page.add", json!({})); + if !crate::menus::dev_mode_refuses(app, "page.add") { + app.run("page.add", json!({})); + } } if !app.ui.pages_open { return; @@ -239,7 +246,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { if filtering && (divider || !name.to_lowercase().contains(&filter)) { continue; } - let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; + let sense = if filtering || dev { Sense::click() } else { Sense::click_and_drag() }; let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); if id == cur { current_row_rendered = true; @@ -247,7 +254,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { ui.scroll_to_rect(r, None); } } - if !filtering { + if !filtering && !dev { reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); } a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); @@ -287,7 +294,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.pages_focus = Some(uid); app.ui.layers_focus = false; } - if resp.double_clicked() { + if resp.double_clicked() && !crate::menus::dev_mode_refuses(app, "page.rename") { app.ui.rename_page = Some((id, name.clone())); // The field takes the focus next frame, once it's drawn: focusing it now would put a // node that isn't there yet in the screen reader tree, which AccessKit aborts on. @@ -295,15 +302,21 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } crate::right::context_popup(&resp).show(|ui| { if ui.button(tr("Rename page")).clicked() { - app.ui.rename_page = Some((id, name.clone())); + if !crate::menus::dev_mode_refuses(app, "page.rename") { + app.ui.rename_page = Some((id, name.clone())); + } ui.close(); } if ui.button(tr("Duplicate page")).clicked() { - app.run("page.duplicate", json!({"id": id})); + if !crate::menus::dev_mode_refuses(app, "page.duplicate") { + app.run("page.duplicate", json!({"id": id})); + } ui.close(); } if ui.button(tr("Delete page")).clicked() { - delete_page(app, id); + if !crate::menus::dev_mode_refuses(app, "page.delete") { + delete_page(app, id); + } ui.close(); } }); @@ -321,6 +334,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if let Some((from, to)) = moved.inner && let Some((id, ..)) = pages.get(from) + && !crate::menus::dev_mode_refuses(app, "page.move") { app.run("page.move", json!({"id": id, "index": to})); } @@ -328,7 +342,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.rename_page = None; app.run("page.rename", json!({"id": id, "name": new})); } - if let Some((page, name)) = move_to { + if let Some((page, name)) = move_to + && !crate::menus::dev_mode_refuses(app, "object.moveToPage") + { let ids = dragged_layers(dragging, &sel); if app.run("object.moveToPage", json!({"ids": ids, "page": page})).is_some() { app.toast(trf("Moved to {name}", &[("name", &name)])); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index eeb25e3d..c8d0159b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -107,10 +107,9 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, u32, u32)> { Some((out.into_inner(), w, h)) } -/// 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: files dropped on the canvas still edit in Dev Mode; route them through here if people -/// trip over it. +/// Dev Mode (the Inspect tab) refuses command `id` from the menus, shortcuts, clipboard, the +/// Pages list, the layers panel, files dropped on the canvas and the Assets panel: it edits the +/// design ([`figcraft_engine::devinspect::dev_mode_allows`]). Says so when it does. 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/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index 7f8a7fbe..9565730e 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -10,7 +10,7 @@ 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, 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) | +| 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), and the Pages list only opens pages (no adding, renaming, reordering, duplicating or deleting). Files dropped on the canvas aren't imported (a dropped document still opens as a file of its own), and the Assets panel places, swaps and applies nothing, doesn't change Hide when publishing, and doesn't accept library updates or add, remove or swap libraries; each says why | | 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 | @@ -28,6 +28,7 @@ repository, third-party integrations. Remaining: 1–2 h. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Dev Mode refuses design edits from files dropped on the canvas (a dropped document still opens as a file of its own, and the drop overlay shows the Dev Mode note for other files), from the Pages list's add, rename, reorder, duplicate and delete, and from the Assets panel's place, drag, Alt-drop swap, style, Hide when publishing, Update, and adding, removing and swapping libraries. Percentages and hours unchanged | | 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 | diff --git a/docs/gaps.md b/docs/gaps.md index f4f29c07..00f3c88c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -392,9 +392,12 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe 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. +- **Landed (2026-10-24):** files dropped on the canvas, the Pages list and the Assets panel no + longer edit the design in Dev Mode (a dropped document still opens as a file of its own, and + the Pages list only opens pages). +- **Missing:** 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 From 494387a6a8754f07d6884e142f76f2c630e99c37 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Sat, 24 Oct 2026 12:13:40 +0200 Subject: [PATCH 072/142] M1: a dash pattern too fine for its path strokes solid (#1060) stroke_center handed any dash pattern to kurbo::stroke, which makes one outline per dash: a 0.01/0.01 pattern on a 10,000-unit line made 2.5M path elements, and a long line with a tiny pattern ran out of memory. The variable width stroke already drew solid past MAX_DASHES (20,000); that estimate is now width::dashes_fit and stroke_center uses it too, so both strokes follow one rule. The path length is only measured for dashed strokes. Test: a_dash_pattern_too_fine_for_its_path_strokes_solid (2.5M elements on the old code). --- crates/pathops/src/lib.rs | 15 ++++++++++++++- crates/pathops/src/width.rs | 10 ++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index f4b30e10..45d5122f 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -292,7 +292,7 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath .with_miter_limit(props.miter_limit.clamp(1.0, 100.0)) .with_start_cap(kcap(props.cap_start)) .with_end_cap(kcap(props.cap_end)); - if let Some(pat) = width::dash_pattern(props) { + if let Some(pat) = width::dash_pattern(props).filter(|p| width::dashes_fit(path.perimeter(1.0), p)) { style = style.with_dashes(0.0, pat); } kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05) @@ -719,6 +719,19 @@ mod tests { assert!((s.area().abs() - 100.0).abs() < 2.0, "{}", s.area()); } + #[test] + fn a_dash_pattern_too_fine_for_its_path_strokes_solid() { + // ~500k dashes on this line (#1060): drawn solid, as a variable width stroke is past + // `MAX_DASHES`, rather than one outline per dash. + let mut line = BezPath::new(); + line.move_to((0.0, 0.0)); + line.line_to((10_000.0, 0.0)); + let props = StrokeProps { weight: 2.0, dashes: vec![0.01, 0.01], align: StrokeAlign::Center, ..Default::default() }; + let s = stroke_center(&line, 2.0, &props); + assert!(s.elements().len() < 100, "{} elements", s.elements().len()); + assert!((s.area().abs() - 20_000.0).abs() < 10.0, "{}", s.area()); + } + #[test] fn inside_and_outside_strokes_respect_an_even_odd_hole() { // A ring: both loops run the same way, so only the even-odd rule leaves the hole open. diff --git a/crates/pathops/src/width.rs b/crates/pathops/src/width.rs index 03240284..312c5b91 100644 --- a/crates/pathops/src/width.rs +++ b/crates/pathops/src/width.rs @@ -17,7 +17,7 @@ pub use figcraft_geom::measure::{Polyline, polylines}; /// Every subpath gets at least this many width samples, so the width varies smoothly along long /// straight runs. const SAMPLES_PER_SUBPATH: f64 = 128.0; -/// The most dashes a dashed variable width stroke makes. +/// The most dashes a dashed stroke makes ([`dashes_fit`]). const MAX_DASHES: usize = 20_000; /// The nearest point of the stroke path to `p`: (subpath index, t by length, distance). @@ -242,6 +242,12 @@ pub fn dash_pattern(props: &StrokeProps) -> Option> { Some(if dashes.len() % 2 == 1 { [dashes.clone(), dashes].concat() } else { dashes }) } +/// Whether `pattern` (from [`dash_pattern`]) makes at most [`MAX_DASHES`] dashes along `len`; +/// past that a stroke draws solid instead of one outline per dash (#1060). +pub(crate) fn dashes_fit(len: f64, pattern: &[f64]) -> bool { + len / pattern.iter().sum::() * (pattern.len() as f64) < MAX_DASHES as f64 +} + /// Resample a polyline so no step is longer than `max_step`; returns the points and the length at /// each, or `None` past the sample budget. fn resample(line: &Polyline, max_step: f64, budget: &mut usize) -> Option<(Vec, Vec)> { @@ -294,7 +300,7 @@ pub fn variable_outline(path: &BezPath, width: f64, props: &StrokeProps, profile }) .collect(); match &pattern { - Some(pat) if len / pat.iter().sum::() * (pat.len() as f64) < MAX_DASHES as f64 => { + Some(pat) if dashes_fit(len, pat) => { for (p, h) in dashes(&pts, &hw, pat) { outline_piece(&mut out, &p, &h, false, style); } From 2baeff3452f2c49a0017d2a826b3bdf9715c3bad Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Sat, 24 Oct 2026 12:44:55 +0200 Subject: [PATCH 073/142] M1: dash limit counts the pattern restarting at each subpath (#1060) kurbo restarts the dash pattern at every MoveTo, so many short subpaths with a pattern of tiny dashes before a long gap made ~1M dashes (5M path elements) while the length-based estimate allowed them. stroke_center now adds one pattern's length per subpath to the estimate: an upper bound on the dashes kurbo makes. Test: each_subpath_restarting_the_dash_pattern_counts_toward_the_limit (5M elements before). Reported by CodeRabbit on #1278. --- crates/pathops/src/lib.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index 45d5122f..7fe7a64e 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -292,7 +292,9 @@ pub fn stroke_center(path: &BezPath, width: f64, props: &StrokeProps) -> BezPath .with_miter_limit(props.miter_limit.clamp(1.0, 100.0)) .with_start_cap(kcap(props.cap_start)) .with_end_cap(kcap(props.cap_end)); - if let Some(pat) = width::dash_pattern(props).filter(|p| width::dashes_fit(path.perimeter(1.0), p)) { + // Each subpath restarts the pattern, so it can add up to one more pattern's worth of dashes. + let subpaths = path.elements().iter().filter(|e| matches!(e, PathEl::MoveTo(_))).count() as f64; + if let Some(pat) = width::dash_pattern(props).filter(|p| width::dashes_fit(path.perimeter(1.0) + subpaths * p.iter().sum::(), p)) { style = style.with_dashes(0.0, pat); } kurbo::stroke(path.iter(), &style, &kurbo::StrokeOpts::default(), 0.05) @@ -732,6 +734,22 @@ mod tests { assert!((s.area().abs() - 20_000.0).abs() < 10.0, "{}", s.area()); } + #[test] + fn each_subpath_restarting_the_dash_pattern_counts_toward_the_limit() { + // The pattern restarts at every subpath: on 10,000 short lines its 199 tiny dashes come + // round each time (~1M dashes), though the length alone suggests a few hundred. + let mut path = BezPath::new(); + for i in 0..10_000 { + path.move_to((0.0, f64::from(i) * 4.0)); + path.line_to((2.0, f64::from(i) * 4.0)); + } + let mut dashes = vec![0.01; 199]; + dashes.push(10_000.0); + let props = StrokeProps { weight: 1.0, dashes, align: StrokeAlign::Center, ..Default::default() }; + let s = stroke_center(&path, 1.0, &props); + assert!(s.elements().len() < 100_000, "{} elements", s.elements().len()); + } + #[test] fn inside_and_outside_strokes_respect_an_even_odd_hole() { // A ring: both loops run the same way, so only the even-odd rule leaves the hole open. From 337f9d72e0f9c35df824bc79e7ff04326f3353ef Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 04:00:58 -0700 Subject: [PATCH 074/142] M3: bind variables to a text style's fields from the style editor The style editor bound variables to paint colors, shadow colors, blur radii and grid sizes, but a text style's fields took variables only through style.bind. The font and font style menus now start with Apply variable, which opens the picker on string variables, and the size, line height and letter spacing fields show a variable button on hover for number variables, as the Design panel's number fields do. A bound field shows its variable's chip in place of the value, as in the Design panel. A click on the chip picks another variable, and its detach button unbinds it. --- crates/ui-egui/src/style_picker.rs | 140 +++++++++++++++---- crates/ui-egui/src/style_picker_tests.rs | 169 ++++++++++++++++++++++- docs/components-parity.md | 3 +- 3 files changed, 281 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 22713000..141de5b2 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -700,9 +700,24 @@ fn grid_label(g: &figcraft_doc::LayoutGrid) -> String { } } +/// The chip in `rect` of the variable `name` bound to `field` of text style `style`. A click on it +/// opens the variable picker to change it, and its detach button unbinds it. +#[allow(clippy::too_many_arguments)] +fn text_binding(ui: &mut Ui, style: &str, field: &str, kind: VariableType, rect: Rect, name: &str, lead: widgets::Lead, out: &mut Frame) { + match widgets::bound_field(ui, Id::new(("ts_bound", style, field)), lead, rect, name) { + Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, style, field, kind, at), + Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(style.to_string(), field.to_string())), + None => {} + } +} + /// A text style's typography in the editor: a sample, then font, style, size, line height and -/// letter spacing; a change redefines the style (every linked char follows). -fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { +/// letter spacing; a change redefines the style (every linked char follows). Each field takes a +/// variable: the font and style menus start with Apply variable, the other fields show a variable +/// button on hover, and a bound field shows its variable instead of its value. +fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, out: &mut Frame) { + let id = style.id.as_str(); + let bound = |field: &str| style.bound.get(field).map(|v| doc.variables.get(v).map_or_else(|| v.clone(), |x| x.name.clone())); let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(W, 40.0), Sense::hover()); ui.painter().rect_filled(r, CornerRadius::same(5), Color32::from_white_alpha(14)); @@ -722,45 +737,112 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { // The dropdowns' menus open outside the dark scope (they're regular popups). let (family_resp, style_resp) = crate::theme::on_dark(ui, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 6.0); - let family_resp = widgets::dropdown(ui, &def.font_family, W, None).on_hover_text(tr("Font")); + let family_resp = match bound("fontFamily") { + Some(name) => { + let (r, _) = ui.allocate_exact_size(vec2(W, widgets::FIELD_H), Sense::hover()); + text_binding(ui, id, "fontFamily", VariableType::String, r, &name, widgets::Lead::Named(tr("Font")), out); + None + } + None => Some(widgets::dropdown(ui, &def.font_family, W, None).on_hover_text(tr("Font"))), + }; let mut style_resp = None; ui.horizontal(|ui| { - style_resp = Some(widgets::dropdown(ui, &def.font_style, half, None).on_hover_text(tr("Font style"))); - if let Some(widgets::FieldEdit::Set(v) | widgets::FieldEdit::Scrub(v, true)) = - widgets::number_field(ui, Id::new(("ts_size", id)), widgets::Lead::Text(tr("Size")), Some(def.font_size), "", half, 1.0) - { + match bound("fontStyle") { + Some(name) => { + let (r, _) = ui.allocate_exact_size(vec2(half, widgets::FIELD_H), Sense::hover()); + text_binding(ui, id, "fontStyle", VariableType::String, r, &name, widgets::Lead::Named(tr("Font style")), out); + } + None => style_resp = Some(widgets::dropdown(ui, &def.font_style, half, None).on_hover_text(tr("Font style"))), + } + let size = bound("fontSize"); + let (edit, ev) = widgets::number_field_bindable( + ui, + Id::new(("ts_size", id)), + widgets::Lead::Text(tr("Size")), + Some(def.font_size), + "", + half, + 1.0, + Some(size.as_deref()), + widgets::Empty::Text(tr("Mixed")), + ); + if let Some(widgets::FieldEdit::Set(v) | widgets::FieldEdit::Scrub(v, true)) = edit.and_then(|e| e.settled()) { set(out, &|d| d.font_size = v.clamp(1.0, 5000.0)); } + match ev { + Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, id, "fontSize", VariableType::Number, at), + Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(id.to_string(), "fontSize".to_string())), + None => {} + } }); ui.horizontal(|ui| { - if let Some(v) = widgets::text_field(ui, Id::new(("ts_lh", id)), &def.line_height.label(), half, tr("Line height")) - && let Some(lh) = crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| crate::typography::line_height_of(&v)) - { - set(out, &|d| d.line_height = lh); + let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H)); + match bound("lineHeight") { + Some(name) => { + let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover()); + text_binding(ui, id, "lineHeight", VariableType::Number, r, &name, widgets::Lead::Named(tr("Line height")), out); + } + None => { + if let Some(v) = widgets::text_field(ui, Id::new(("ts_lh", id)), &def.line_height.label(), half, tr("Line height")) + && let Some(lh) = + crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| crate::typography::line_height_of(&v)) + { + set(out, &|d| d.line_height = lh); + } + if crate::variable_ui::variable_button(ui, area, Id::new(("ts_bind", id, "lineHeight"))) { + crate::variable_ui::open_style(ui, id, "lineHeight", VariableType::Number, area.left_bottom()); + } + } } - if let Some(v) = widgets::text_field(ui, Id::new(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing")) - && let Some(ls) = - crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing)).and_then(|v| crate::typography::letter_spacing_of(&v)) - { - set(out, &|d| d.letter_spacing = ls); + let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H)); + match bound("letterSpacing") { + Some(name) => { + let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover()); + text_binding(ui, id, "letterSpacing", VariableType::Number, r, &name, widgets::Lead::Named(tr("Letter spacing")), out); + } + None => { + if let Some(v) = widgets::text_field(ui, Id::new(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing")) + && let Some(ls) = crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing)) + .and_then(|v| crate::typography::letter_spacing_of(&v)) + { + set(out, &|d| d.letter_spacing = ls); + } + if crate::variable_ui::variable_button(ui, area, Id::new(("ts_bind", id, "letterSpacing"))) { + crate::variable_ui::open_style(ui, id, "letterSpacing", VariableType::Number, area.left_bottom()); + } + } } }); (family_resp, style_resp) }); - crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked { - set(out, &|d| { - d.font_family = f.clone(); - let styles = figcraft_text::family_styles(&f); - if !styles.is_empty() && !styles.contains(&d.font_style) { - d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default()); - } - }); - ui.close(); - } - }); + if let Some(family_resp) = family_resp { + crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + if crate::right::menu_item(ui, tr("Apply variable"), "") { + crate::variable_ui::open_style(ui, id, "fontFamily", VariableType::String, family_resp.rect.left_bottom()); + ui.close(); + return; + } + ui.separator(); + if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked { + set(out, &|d| { + d.font_family = f.clone(); + let styles = figcraft_text::family_styles(&f); + if !styles.is_empty() && !styles.contains(&d.font_style) { + d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default()); + } + }); + ui.close(); + } + }); + } if let Some(sr) = style_resp { crate::right::menu_popup(&sr).width(200.0).show(|ui| { + if crate::right::menu_item(ui, tr("Apply variable"), "") { + crate::variable_ui::open_style(ui, id, "fontStyle", VariableType::String, sr.rect.left_bottom()); + ui.close(); + return; + } + ui.separator(); egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for s in figcraft_text::family_styles(&def.font_family) { let mark = if def.font_style == s { "✓ " } else { " " }; @@ -838,7 +920,7 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { binding_cell(ui, doc, &style, &format!("layoutGrids/{i}/size"), figcraft_doc::VariableType::Number, out); } } - StyleKind::Text { style: def } => text_properties(ui, &style.id, def, out), + StyleKind::Text { style: def } => text_properties(ui, doc, &style, def, out), } }); ui.add_space(8.0); diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/style_picker_tests.rs index ad2c2971..6627716f 100644 --- a/crates/ui-egui/src/style_picker_tests.rs +++ b/crates/ui-egui/src/style_picker_tests.rs @@ -248,7 +248,15 @@ fn commit_text_field(def: &TextStyle, field: &str, typed: &str) -> Option Some(d), @@ -276,3 +284,162 @@ fn text_style_editor_commits_line_height_and_letter_spacing() { assert_eq!(TextStyle { line_height: def.line_height, ..d }, def); assert_eq!(lh(&def, "abc"), None); } + +/// A session with the text style "Body" (Inter Regular 14) and number and string variables to +/// bind to its fields; `bind` binds them all first. +fn text_variables_session(bind: bool) -> crate::layers_drag_tests::Ui { + let mut s = Session::new(); + s.execute("style.create", &json!({"type": "text", "name": "Body", "value": {"fontFamily": "Inter", "fontStyle": "Regular", "fontSize": 14}})) + .unwrap(); + s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Base"]})).unwrap(); + for (name, ty, value) in [ + ("Body size", "number", json!(18)), + ("Body leading", "number", json!(24)), + ("Body tracking", "number", json!(1)), + ("Body font", "string", json!("Inter")), + ("Body weight", "string", json!("Bold")), + ] { + s.execute("variable.create", &json!({"collection": "Type", "name": name, "type": ty, "values": {"Base": value}})).unwrap(); + } + if bind { + for (field, var) in [ + ("fontSize", "Body size"), + ("lineHeight", "Body leading"), + ("letterSpacing", "Body tracking"), + ("fontFamily", "Body font"), + ("fontStyle", "Body weight"), + ] { + s.execute("style.bind", &json!({"style": "Body", "field": field, "variable": var})).unwrap(); + } + } + let mut u = crate::layers_drag_tests::Ui::new(s); + open_for_selection(&mut u.app, StyleSlot::Text, Some("Body")); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.style_picker.as_ref().is_some_and(|p| p.edit.is_some()), "the text style editor is open"); + u +} + +/// The left edge of the style editor's content (its "Edit style" title starts 30 px in). +fn editor_left(u: &mut crate::layers_drag_tests::Ui) -> f32 { + let mut left = None; + for c in u.frame_output(vec![]).shapes { + if let egui::Shape::Text(t) = &c.shape + && t.galley.text() == "Edit style" + { + left = Some(t.pos.x - 30.0); + } + } + left.expect("the editor's title") +} + +/// The texts drawn in one frame, with their centers. +fn drawn_texts(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, Pos2)> { + fn walk(shape: &egui::Shape, out: &mut Vec<(String, Pos2)>) { + match shape { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.size() / 2.0)), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = Vec::new(); + for c in u.frame_output(vec![]).shapes { + walk(&c.shape, &mut out); + } + out +} + +fn text_at(texts: &[(String, Pos2)], s: &str) -> Pos2 { + texts + .iter() + .find(|(t, _)| t == s) + .map(|(_, p)| *p) + .unwrap_or_else(|| panic!("no text {s:?} in {:?}", texts.iter().map(|t| &t.0).collect::>())) +} + +/// Hover `p` (variable buttons show on hover), then press and release there. +fn click(u: &mut crate::layers_drag_tests::Ui, p: Pos2) { + u.frame(vec![egui::Event::PointerMoved(p)]); + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +/// The name of the variable on `field` of the style "Body". +fn bound_to(u: &crate::layers_drag_tests::Ui, field: &str) -> Option { + let doc = u.app.session.doc().unwrap(); + doc.style("Body").unwrap().bound.get(field).map(|v| doc.variables.get(v).map_or_else(|| v.clone(), |x| x.name.clone())) +} + +#[test] +fn text_style_editor_shows_bound_fields_as_variables_and_detaches_them() { + let mut u = text_variables_session(true); + let left = editor_left(&mut u); + let texts = drawn_texts(&mut u); + let half = (W - 8.0) / 2.0; + // (field, variable shown in its place, right edge of the field: full width or half) + let fields = [ + ("fontFamily", "Body font", W), + ("fontStyle", "Body weight", half), + ("fontSize", "Body size", W), + ("lineHeight", "Body leading", half), + ("letterSpacing", "Body tracking", W), + ]; + let at: Vec = fields.iter().map(|(_, name, _)| text_at(&texts, name).y).collect(); + for ((field, name, right), y) in fields.iter().zip(at) { + assert_eq!(bound_to(&u, field).as_deref(), Some(*name)); + // The chip's detach button sits at its right end. + click(&mut u, Pos2::new(left + right - 11.0, y)); + assert_eq!(bound_to(&u, field), None, "{field} is detached"); + } + assert!(u.app.ui.style_picker.is_some(), "the editor stays open"); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} + +#[test] +fn text_style_editor_binds_variables_from_its_fields() { + let mut u = text_variables_session(false); + let left = editor_left(&mut u); + let texts = drawn_texts(&mut u); + let half = (W - 8.0) / 2.0; + let size_row = text_at(&texts, "Size").y; + let spacing = { + let doc = u.app.session.doc().unwrap(); + let Some(StyleKind::Text { style: def }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") }; + def.letter_spacing.label() + }; + let in_editor: Vec<_> = texts.iter().filter(|(_, p)| p.x > left && p.x < left + W).cloned().collect(); + let spacing_row = text_at(&in_editor, &spacing).y; + // Size, line height and letter spacing: the variable button at the field's right end. + for (field, name, x, y) in [ + ("fontSize", "Body size", left + W - 11.0, size_row), + ("lineHeight", "Body leading", left + half - 11.0, spacing_row), + ("letterSpacing", "Body tracking", left + W - 11.0, spacing_row), + ] { + click(&mut u, Pos2::new(x, y)); + assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens"); + let texts = drawn_texts(&mut u); + assert!(!texts.iter().any(|(t, _)| t == "Body font"), "{field} offers number variables only"); + click(&mut u, text_at(&texts, name)); + assert_eq!(bound_to(&u, field).as_deref(), Some(name)); + } + // Font and font style: Apply variable at the top of their menus. + for (field, shown, name) in [("fontFamily", "Inter", "Body font"), ("fontStyle", "Regular", "Body weight")] { + // The menu's label, not the same name in the Local styles list behind the editor. + let texts: Vec<_> = drawn_texts(&mut u).into_iter().filter(|(_, p)| p.x > left && p.x < left + W).collect(); + click(&mut u, text_at(&texts, shown)); + let texts = drawn_texts(&mut u); + click(&mut u, text_at(&texts, "Apply variable")); + assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens"); + let texts = drawn_texts(&mut u); + // "Body size" shows once, on the size field's chip: the picker doesn't list it. + assert_eq!(texts.iter().filter(|(t, _)| t == "Body size").count(), 1, "{field} offers string variables only"); + click(&mut u, text_at(&texts, name)); + assert_eq!(bound_to(&u, field).as_deref(), Some(name)); + } + let doc = u.app.session.doc().unwrap(); + let Some(StyleKind::Text { style: def }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") }; + assert_eq!((def.font_size, def.font_style.as_str()), (18.0, "Bold"), "the style takes the variables' values"); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} diff --git a/docs/components-parity.md b/docs/components-parity.md index 7f7c162f..79d0902e 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -61,7 +61,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs` | | Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only | | Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) | -| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii and grid sizes, the rest through the command | +| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command | | Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); scopes filter pickers only | | Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay | | Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values | @@ -77,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Variables on a text style's fields from the style editor: the font and font style menus start with Apply variable (string variables), and the size, line height and letter spacing fields show a variable button on hover (number variables). A bound field shows its variable in place of the value; a click on it picks another variable and its detach button unbinds it (`style_picker_tests`). Left: the other effect and grid fields (commands only), library variables in the picker. Numbers unchanged. | | 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 | From 598a4a2867b584a8058f029cdde2c2d77f65e1fd Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 11:51:08 +0000 Subject: [PATCH 075/142] Measure distribute span to the group's outermost edge Found while testing crates/engine/src/cmd/arrange.rs:210 (distribute, also used by arrange.packH/packV) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/arrange.rs | 5 +++-- crates/engine/src/tests.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 10ad13e5..4278f66c 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -206,8 +206,9 @@ fn distribute(s: &mut Session, p: &Value, horizontal: bool) -> Result { let mut items: Vec<(NodeId, Rect)> = ids.iter().filter_map(|i| doc.world_bounds(*i).map(|b| (*i, b))).collect(); items.sort_by(|a, b| if horizontal { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) }); let total: f64 = items.iter().map(|(_, b)| if horizontal { b.width() } else { b.height() }).sum(); - let (Some(first), Some(last)) = (items.first(), items.last()) else { return ok() }; - let span = if horizontal { last.1.x1 - first.1.x0 } else { last.1.y1 - first.1.y0 }; + let Some(first) = items.first() else { return ok() }; + let end = items.iter().map(|(_, b)| if horizontal { b.x1 } else { b.y1 }).fold(f64::MIN, f64::max); + let span = if horizontal { end - first.1.x0 } else { end - first.1.y0 }; let gap = num(p, "spacing").unwrap_or((span - total) / (items.len().saturating_sub(1).max(1)) as f64); let mut pos = if horizontal { first.1.x0 } else { first.1.y0 }; for (i, b) in items.clone() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 36235426..d019621b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -275,6 +275,20 @@ fn align_distribute_zorder() { assert_eq!(s.doc().unwrap().node(page).unwrap().children[0], c); } +#[test] +fn distribute_spans_the_outermost_edge() { + let mut s = sess(); + let a = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let b = id(&s.execute("shape.rectangle", &json!({"x": 150, "y": 150, "width": 500, "height": 500})).unwrap()); + let c = id(&s.execute("shape.rectangle", &json!({"x": 160, "y": 160, "width": 10, "height": 10})).unwrap()); + s.execute("distribute.horizontal", &json!({"ids": [a, b, c]})).unwrap(); + let xs: Vec = [a, b, c].iter().map(|i| s.doc().unwrap().world_bounds(*i).unwrap().x0).collect(); + assert_eq!(xs, [0.0, 75.0, 640.0]); + s.execute("distribute.vertical", &json!({"ids": [a, b, c]})).unwrap(); + let ys: Vec = [a, b, c].iter().map(|i| s.doc().unwrap().world_bounds(*i).unwrap().y0).collect(); + assert_eq!(ys, [0.0, 75.0, 640.0]); +} + #[test] fn a_layer_alone_on_the_page_has_nothing_to_align_to() { use crate::cmd::arrange::can_align; From 9f280cb2e0f06f6176a4e294957e6d5bc1fb156d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 05:23:20 -0700 Subject: [PATCH 076/142] M3: style variable pickers offer only variables scoped to the field The variable picker opened from a style's field listed every variable of the field's type, and the Fill and Stroke style picker's Color variables listed every color variable, whatever their scopes. Both now offer only variables scoped to the property, as the Design panel's pickers do. A paint style's colors take variables scoped to a fill or to strokes, an effect style's fields take effect variables, and a text style's fields take the variables of their font property. Layout grid fields still take any number variable. style.bind still binds a variable outside its scopes, as variable.bind does. --- crates/engine/src/style_vars.rs | 23 +++++ crates/engine/src/style_vars_tests.rs | 40 +++++++++ crates/ui-egui/src/style_picker.rs | 6 +- crates/ui-egui/src/variable_ui.rs | 116 ++++++++++++++++++++++++++ 4 files changed, 184 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/style_vars.rs b/crates/engine/src/style_vars.rs index 97226aed..b2e5c182 100644 --- a/crates/engine/src/style_vars.rs +++ b/crates/engine/src/style_vars.rs @@ -42,6 +42,29 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option { } } +/// The variable scopes a picker offers for `key` of a style of `kind`: a variable scoped to any one +/// of them fits. A paint style's colors take fill and stroke variables, an effect style's fields +/// take effect variables, and a text style's fields take the variables of their font property. +/// None when every variable of the field's type fits (a grid's fields, as on layers) or the style +/// has no such field. +pub fn field_scopes(kind: &StyleKind, key: &str) -> Option<&'static [&'static str]> { + field_type(kind, key)?; + Some(match (kind, list_binding(key)) { + (StyleKind::Paint { .. }, _) => &["FRAME_FILL", "SHAPE_FILL", "TEXT_FILL", "STROKE_COLOR"], + (StyleKind::Effect { .. }, Some((_, _, "color"))) => &["EFFECT_COLOR"], + (StyleKind::Effect { .. }, _) => &["EFFECT_FLOAT"], + (StyleKind::Text { .. }, _) => match key { + "fontSize" => &["FONT_SIZE"], + "lineHeight" => &["LINE_HEIGHT"], + "letterSpacing" => &["LETTER_SPACING"], + "fontFamily" => &["FONT_FAMILY"], + "fontStyle" => &["FONT_STYLE"], + _ => return None, + }, + _ => return None, + }) +} + /// The key a style stores a binding of `key` under: the first paint's colour has one spelling. pub fn canonical(key: &str) -> String { match list_binding(key) { diff --git a/crates/engine/src/style_vars_tests.rs b/crates/engine/src/style_vars_tests.rs index dae9ef56..66768daa 100644 --- a/crates/engine/src/style_vars_tests.rs +++ b/crates/engine/src/style_vars_tests.rs @@ -182,6 +182,46 @@ fn what_a_style_cannot_bind_is_refused() { } } +#[test] +fn style_fields_offer_their_properties_scopes() { + let mut s = Session::new(); + let (_, _, a, _, sid) = two_modes(&mut s); + run(&mut s, "effect.add", json!({"ids": [a], "type": "dropShadow"})); + let fx = run(&mut s, "style.create", json!({"type": "effect", "name": "Drop", "from": a}))["id"].as_str().unwrap().to_string(); + let light = node(&s, a).parent.unwrap(); + run(&mut s, "grid.add", json!({"ids": [light]})); + let grid = run(&mut s, "style.create", json!({"type": "grid", "name": "Base", "from": light}))["id"].as_str().unwrap().to_string(); + let body = run(&mut s, "style.create", json!({"type": "text", "name": "Body", "value": {"fontFamily": "Inter", "fontSize": 14}}))["id"] + .as_str() + .unwrap() + .to_string(); + let doc = s.doc().unwrap(); + let scopes = |st: &str, key: &str| crate::style_vars::field_scopes(&doc.style(st).unwrap().kind, key); + let fills: &[&str] = &["FRAME_FILL", "SHAPE_FILL", "TEXT_FILL", "STROKE_COLOR"]; + for (st, key, want) in [ + (&sid, "fill", Some(fills)), + (&sid, "fills/0/color", Some(fills)), + (&fx, "effects/0/color", Some(&["EFFECT_COLOR"][..])), + (&fx, "effects/0/radius", Some(&["EFFECT_FLOAT"][..])), + (&fx, "effects/0/offsetX", Some(&["EFFECT_FLOAT"][..])), + (&body, "fontSize", Some(&["FONT_SIZE"][..])), + (&body, "lineHeight", Some(&["LINE_HEIGHT"][..])), + (&body, "letterSpacing", Some(&["LETTER_SPACING"][..])), + (&body, "fontFamily", Some(&["FONT_FAMILY"][..])), + (&body, "fontStyle", Some(&["FONT_STYLE"][..])), + // Grid fields take any number variable, as on layers; fields a style lacks take none. + (&grid, "layoutGrids/0/size", None), + (&sid, "fills/3/color", None), + (&body, "opacity", None), + ] { + assert_eq!(scopes(st, key), want, "{st} {key}"); + } + // As on layers, scopes limit pickers only: an out-of-scope variable still binds. + run(&mut s, "variable.update", json!({"variable": "size", "scopes": ["CORNER_RADIUS"]})); + run(&mut s, "style.bind", json!({"style": body, "field": "fontSize", "variable": "size"})); + assert!(s.doc().unwrap().style(&body).unwrap().bound.contains_key("fontSize")); +} + #[test] fn rest_and_fig_carry_style_bindings() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 22713000..53046d8a 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -555,7 +555,11 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut } } let colors = if slot.paints() { - crate::variable_ui::groups(doc, &libs, |_, v| v.kind == VariableType::Color && (q.is_empty() || v.name.to_lowercase().contains(&q))) + // Variables scoped to the selection's fill (or stroke), as the Design panel's pickers offer. + let scope = p.ids.first().and_then(|id| figcraft_engine::cmd::variables::field_scope(doc, *id, slot.key())); + crate::variable_ui::groups(doc, &libs, |_, v| { + v.kind == VariableType::Color && (q.is_empty() || v.name.to_lowercase().contains(&q)) && scope.is_none_or(|s| v.in_scope(s)) + }) } else { Vec::new() }; diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 39c5396e..3dfeeec7 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -399,6 +399,11 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { Target::Alias { variable, .. } => (None, doc.variables.get(variable)), Target::Proto(_) | Target::Style { .. } => (None, None), }; + // A style's fields offer variables scoped to one of the properties the field sets. + let style_scopes = match &p.target { + Target::Style { style, field } => doc.style(style).and_then(|st| figcraft_engine::style_vars::field_scopes(&st.kind, field)), + _ => None, + }; // Prototypes and styles use this file's variables. let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. }); let field = match &p.target { @@ -410,6 +415,7 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { && !(local_only && lib.is_some()) && (q.is_empty() || v.name.to_lowercase().contains(&q)) && scope.is_none_or(|s| v.in_scope(s)) + && style_scopes.is_none_or(|list| list.iter().any(|s| v.in_scope(s))) && (lib.is_some() || own.is_none_or(|o| figcraft_engine::cmd::variables::alias_target(&doc.variables, o, &v.id).is_ok())) }; let groups = groups(&doc, &libs, keep); @@ -518,3 +524,113 @@ mod kind_tests { assert!(kind_fits(None, None, Boolean)); } } + +#[cfg(test)] +mod scope_tests { + use figcraft_engine::Session; + + use super::*; + use crate::layers_drag_tests::Ui as TestUi; + use crate::style_picker::StyleSlot; + + fn run(s: &mut Session, cmd: &str, p: serde_json::Value) -> serde_json::Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) + } + + /// A rectangle (selected, with a drop shadow), a frame with a layout grid, the styles "Brand" + /// (paint), "Shadow" (effect, from the rectangle), "Base" (grid, from the frame) and "Body" + /// (text), and variables each scoped to one property (`any` to none). + fn session() -> Session { + let mut s = Session::new(); + let r = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))["id"].clone(); + run(&mut s, "effect.add", json!({"ids": [r], "type": "dropShadow"})); + let f = run(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100}))["id"].clone(); + run(&mut s, "grid.add", json!({"ids": [f]})); + run(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0D99FF"}]})); + run(&mut s, "style.create", json!({"type": "effect", "name": "Shadow", "from": r})); + run(&mut s, "style.create", json!({"type": "grid", "name": "Base", "from": f})); + run(&mut s, "style.create", json!({"type": "text", "name": "Body", "value": {"fontFamily": "Inter", "fontSize": 14}})); + run(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Base"]})); + for (name, ty, value, scope) in [ + ("frame fill", "color", json!("#FF0000"), Some("FRAME_FILL")), + ("shape fill", "color", json!("#00FF00"), Some("SHAPE_FILL")), + ("stroke color", "color", json!("#0000FF"), Some("STROKE_COLOR")), + ("shadow color", "color", json!("#000000"), Some("EFFECT_COLOR")), + ("radius", "number", json!(8), Some("CORNER_RADIUS")), + ("font size", "number", json!(18), Some("FONT_SIZE")), + ("blur", "number", json!(4), Some("EFFECT_FLOAT")), + ("any", "number", json!(2), None), + ("family", "string", json!("Inter"), Some("FONT_FAMILY")), + ("weight", "string", json!("Bold"), Some("FONT_STYLE")), + ] { + run(&mut s, "variable.create", json!({"collection": "Tokens", "name": name, "type": ty, "values": {"Base": value}})); + if let Some(sc) = scope { + run(&mut s, "variable.update", json!({"variable": name, "scopes": [sc]})); + } + } + run(&mut s, "select.set", json!({"ids": [r]})); + s + } + + /// The texts drawn in one frame. + fn drawn(u: &mut TestUi) -> Vec { + fn walk(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = Vec::new(); + for c in u.frame_output(vec![]).shapes { + walk(&c.shape, &mut out); + } + out + } + + fn check(texts: &[String], shown: &[&str], hidden: &[&str], what: &str) { + for name in shown { + assert!(texts.iter().any(|t| t == name), "{what}: {name:?} offered, in {texts:?}"); + } + for name in hidden { + assert!(!texts.iter().any(|t| t == name), "{what}: {name:?} not offered, in {texts:?}"); + } + } + + #[test] + fn a_styles_variable_picker_offers_the_fields_scopes_only() { + use VariableType::{Color, Number, String}; + let mut u = TestUi::new(session()); + // (style, field, type, offered, not offered) + for (style, field, kind, shown, hidden) in [ + ("Brand", "fill", Color, &["frame fill", "shape fill", "stroke color"][..], &["shadow color"][..]), + ("Shadow", "effects/0/color", Color, &["shadow color"], &["frame fill", "shape fill", "stroke color"]), + ("Shadow", "effects/0/radius", Number, &["blur", "any"], &["radius", "font size"]), + ("Base", "layoutGrids/0/size", Number, &["radius", "font size", "blur", "any"], &[]), + ("Body", "fontSize", Number, &["font size", "any"], &["radius", "blur"]), + ("Body", "fontFamily", String, &["family"], &["weight"]), + ("Body", "fontStyle", String, &["weight"], &["family"]), + ] { + let id = u.app.session.doc().unwrap().style(style).unwrap().id.clone(); + let target = Target::Style { style: id, field: field.to_string() }; + u.ctx.data_mut(|d| d.insert_temp(picker_id(), Picker { target, kind: Some(kind), at: pos2(400.0, 100.0) })); + u.frame(vec![]); + check(&drawn(&mut u), shown, hidden, &format!("{style} {field}")); + } + } + + #[test] + fn the_fill_and_stroke_style_pickers_offer_scoped_color_variables() { + let mut u = TestUi::new(session()); + for (slot, shown, hidden) in [ + (StyleSlot::Fill, &["shape fill"][..], &["frame fill", "stroke color", "shadow color"][..]), + (StyleSlot::Stroke, &["stroke color"], &["frame fill", "shape fill", "shadow color"]), + ] { + crate::style_picker::open_for_selection(&mut u.app, slot, None); + u.frame(vec![]); + let texts = drawn(&mut u); + assert!(texts.iter().any(|t| t == "Color variables"), "{slot:?}: {texts:?}"); + check(&texts, shown, hidden, &format!("{slot:?}")); + } + } +} From 268c0ead11a8902694086c45613f25d225eb382a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 05:59:28 -0700 Subject: [PATCH 077/142] M3: apply variables to a text layer's font family and font style The Design panel's font family and font style menus offered no way to bind a string variable; these bindings could be made only by commands such as variable.bind. Each menu now starts with Apply variable, which opens the variable picker on string variables for the selected text layers. A bound family or style shows its variable's chip in place of the menu. A click on the chip picks another variable, and its detach button unbinds it. While a text is being edited (a caret or selected characters), the menus have no Apply variable item and show the plain dropdown. A pick applies to the selected characters, or to the whole layer when no characters are selected, as before. --- crates/ui-egui/src/typography.rs | 220 ++++++++++++++++++++++++++++--- docs/components-parity.md | 2 +- 2 files changed, 200 insertions(+), 22 deletions(-) diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index eefe7c9b..8b1b794d 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -179,8 +179,11 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] let fstyle = uniform(styles, |s| s.font_style.clone()); row(ui, 32.0, |ui| { let w = ui.available_width(); - let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(tr(MIXED)), w, None); + let resp = font_dropdown(app, ui, ids, "fontFamily", family.as_deref(), w, editing); menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + if !editing && apply_variable_item(ui, ids, "fontFamily", &resp) { + return; + } let pick = font_picker(ui, family.as_deref(), resp.clicked()); if let Some(f) = pick.picked { remember_family(ui.ctx(), &f); @@ -197,29 +200,34 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] // Style + size. let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, fstyle.as_deref().unwrap_or(tr(MIXED)), half, None); + let resp = font_dropdown(app, ui, ids, "fontStyle", fstyle.as_deref(), half, editing); let fam = family.clone(); - menu_popup(&resp).width(200.0).show(|ui| match &fam { - Some(fam) => { - let list = figcraft_text::family_styles(fam); - if list.is_empty() { - widgets::label(ui, tr("Font not installed"), t.text_tertiary); + menu_popup(&resp).width(200.0).show(|ui| { + if !editing && apply_variable_item(ui, ids, "fontStyle", &resp) { + return; + } + match &fam { + Some(fam) => { + let list = figcraft_text::family_styles(fam); + if list.is_empty() { + widgets::label(ui, tr("Font not installed"), t.text_tertiary); + } + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for s in list { + let mark = if fstyle.as_deref() == Some(s.as_str()) { "✓ " } else { " " }; + let (clicked, hovered) = menu_item_hover(ui, &format!("{mark}{s}")); + if clicked { + apply_style(app, "fontStyle", json!(s)); + ui.close(); + } else if hovered { + // The canvas shows the style (a variable font's named instance too). + app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontStyle": s}))); + } + } + }); } - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for s in list { - let mark = if fstyle.as_deref() == Some(s.as_str()) { "✓ " } else { " " }; - let (clicked, hovered) = menu_item_hover(ui, &format!("{mark}{s}")); - if clicked { - apply_style(app, "fontStyle", json!(s)); - ui.close(); - } else if hovered { - // The canvas shows the style (a variable font's named instance too). - app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontStyle": s}))); - } - } - }); + None => widgets::label(ui, tr("Pick one font family first"), t.text_tertiary), } - None => widgets::label(ui, tr("Pick one font family first"), t.text_tertiary), }); let size = uniform(styles, |s| s.font_size); ui.spacing_mut().item_spacing.x = 0.0; @@ -355,6 +363,32 @@ fn menu_item_hover(ui: &mut Ui, label: &str) -> (bool, bool) { (r.clicked(), r.hovered()) } +/// The font family or font style dropdown for `field`, `width` wide, showing `shown` (Mixed when +/// None). While whole layers are edited and `field` is bound to the same variable on every one of +/// `ids`, the variable's chip is drawn in place of the dropdown (a click picks another variable, +/// its detach button unbinds it), and the returned response opens no menu. +fn font_dropdown(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], field: &str, shown: Option<&str>, width: f32, editing: bool) -> egui::Response { + if !editing && app.session.doc().ok().and_then(|d| crate::variable_ui::bound_name(d, ids, field)).is_some() { + let (rect, resp) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); + crate::variable_ui::bound_chip(app, ui, ids, field, VariableType::String, rect); + return resp; + } + widgets::dropdown(ui, shown.unwrap_or(tr(MIXED)), width, None) +} + +/// Apply variable at the top of the font family or font style menu opened from `anchor`: a click +/// opens the variable picker on string variables for `field` of `ids` and closes the menu (true). +fn apply_variable_item(ui: &mut Ui, ids: &[NodeId], field: &str, anchor: &egui::Response) -> bool { + let picked = menu_item(ui, tr("Apply variable"), ""); + if picked { + crate::variable_ui::open(ui, ids, field, VariableType::String, anchor.rect.left_bottom()); + ui.close(); + } else { + ui.separator(); + } + picked +} + /// Run `cmd` as a live preview while scrubbing; the gesture becomes one undo step when `done`. /// (While text is edited, run commands directly: the editing session already groups undo.) pub(crate) fn live(app: &mut FigApp, label: &str, cmd: &str, params: Value, done: bool) { @@ -1420,3 +1454,147 @@ mod tests { assert_eq!(target_styles(&doc, &[id], None).len(), 2); } } + +#[cfg(test)] +mod variable_tests { + use egui::{Event, Modifiers, PointerButton, Pos2, RawInput}; + use figcraft_engine::Session; + + use super::*; + use crate::NoServices; + + /// The Typography section and the variable picker, drawn for a selected text layer (Inter + /// Regular) in a file with the string variables "Heading font" and "Heading weight" and the + /// number variable "Heading size". + struct Panel { + app: FigApp, + ctx: egui::Context, + time: f64, + text: NodeId, + } + + impl Panel { + fn new() -> Self { + let mut s = Session::new(); + let text = NodeId(s.execute("text.create", &json!({"text": "Title"})).unwrap()["id"].as_u64().unwrap()); + s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Base"]})).unwrap(); + for (name, ty, value) in + [("Heading font", "string", json!("Inter")), ("Heading weight", "string", json!("Bold")), ("Heading size", "number", json!(32))] + { + s.execute("variable.create", &json!({"collection": "Type", "name": name, "type": ty, "values": {"Base": value}})).unwrap(); + } + s.execute("select.set", &json!({ "ids": [text] })).unwrap(); + let mut p = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0, text }; + crate::theme::install_fonts(&p.ctx, false, 0); + crate::theme::apply(&p.ctx, Tokens::light()); + for _ in 0..3 { + p.frame(vec![]); + } + p + } + + /// One frame with `events`; the texts drawn, with their centers. + fn frame(&mut self, events: Vec) -> Vec<(String, Pos2)> { + fn walk(shape: &egui::Shape, out: &mut Vec<(String, Pos2)>) { + match shape { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.size() / 2.0)), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + self.time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 600.0))), + time: Some(self.time), + events, + ..Default::default() + }; + let mut out = self.ctx.run_ui(input, |ui| { + let ds = self.app.session.ds().unwrap(); + let doc = ds.doc.clone(); + let sel = ds.selection.clone(); + typography(&mut self.app, ui, &doc, &sel); + crate::variable_ui::picker(&mut self.app, ui.ctx()); + }); + out.textures_delta.clear(); + let mut texts = Vec::new(); + for c in out.shapes { + walk(&c.shape, &mut texts); + } + texts + } + + /// Hover `p`, then press and release there; the texts drawn after. + fn click(&mut self, p: Pos2) -> Vec<(String, Pos2)> { + self.frame(vec![Event::PointerMoved(p)]); + for pressed in [true, false] { + self.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + self.frame(vec![]) + } + + /// The name of the variable `field` of the text layer is bound to. + fn bound_to(&self, field: &str) -> Option { + let doc = self.app.session.doc().unwrap(); + doc.node(self.text).unwrap().bound.get(field).and_then(|v| doc.variables.get(v)).map(|v| v.name.clone()) + } + } + + fn at(texts: &[(String, Pos2)], s: &str) -> Pos2 { + texts + .iter() + .find(|(t, _)| t == s) + .map(|(_, p)| *p) + .unwrap_or_else(|| panic!("no text {s:?} in {:?}", texts.iter().map(|t| &t.0).collect::>())) + } + + fn shows(texts: &[(String, Pos2)], s: &str) -> bool { + texts.iter().any(|(t, _)| t == s) + } + + #[test] + fn font_family_and_style_menus_start_with_apply_variable() { + let mut p = Panel::new(); + for (field, shown, name) in [("fontStyle", "Regular", "Heading weight"), ("fontFamily", "Inter", "Heading font")] { + let texts = p.frame(vec![]); + let texts = p.click(at(&texts, shown)); + let texts = p.click(at(&texts, "Apply variable")); + assert!(crate::variable_ui::picker_open(&p.ctx), "{field}: the variable picker opens"); + assert!(shows(&texts, "Heading font") && shows(&texts, "Heading weight"), "{field} offers string variables"); + assert!(!shows(&texts, "Heading size"), "{field} offers no number variables"); + p.click(at(&texts, name)); + assert_eq!(p.bound_to(field).as_deref(), Some(name)); + } + assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error); + } + + #[test] + fn bound_font_family_and_style_show_their_variables_and_detach() { + let mut p = Panel::new(); + let ids = vec![p.text]; + for (field, var) in [("fontFamily", "Heading font"), ("fontStyle", "Heading weight")] { + p.app.run("variable.bind", json!({"ids": ids, "field": field, "variable": var})); + } + let texts = p.frame(vec![]); + assert!(shows(&texts, "Heading font") && shows(&texts, "Heading weight"), "the chips are drawn in place of the dropdowns"); + assert!(!shows(&texts, "Inter"), "the family dropdown is not drawn"); + // While characters are selected in the text, the dropdowns show the values, with no Apply variable. + figcraft_engine::textedit::begin(&mut p.app.session, p.text, Some(1), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut p.app.session, 3, true).unwrap(); + let texts = p.frame(vec![]); + let texts = p.click(at(&texts, "Inter")); + assert!(!shows(&texts, "Apply variable"), "a selected range takes a font, not a variable"); + p.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + figcraft_engine::textedit::end(&mut p.app.session).unwrap(); + p.frame(vec![]); + for field in ["fontFamily", "fontStyle"] { + let rect = p.ctx.read_response(Id::new(("bound_chip", field, &ids[..])).with("bound")).expect("the chip").rect; + // The chip's detach button sits at its right end. + p.click(Pos2::new(rect.max.x - 11.0, rect.center().y)); + assert_eq!(p.bound_to(field), None, "{field} is detached"); + } + let texts = p.frame(vec![]); + assert!(shows(&texts, "Inter") && shows(&texts, "Bold"), "the dropdowns show the last values"); + assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error); + } +} diff --git a/docs/components-parity.md b/docs/components-parity.md index 7f7c162f..8a518aae 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -66,7 +66,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay | | Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values | | Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose code doesn't name variables yet | -| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | +| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | | Explicit modes per frame, library collections' modes | done | | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | From 811d5999603bfadfca734383fde747e7865bfd69 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 07:10:45 -0700 Subject: [PATCH 078/142] M4: corner smoothing on polygons and stars Polygon and star outlines did not use their corner smoothing: the value was stored, imported and exported, but `shape_path` drew their corners as plain circular arcs, and the Design panel showed the Corner smoothing field only for rectangles and frames. Rounded polygon and star corners now take smoothing the way rectangle corners do. Each corner keeps a circular arc over the unsmoothed part of its turn and eases into both edges, and it starts (1 + smoothing) times the plain corner's length from the vertex. When a smoothed corner does not fit in half of its shorter edge, smoothing is reduced first, then the radius, as for rectangles. The Individual corners button opens the panel for polygons and stars too, with the Corner smoothing row and no field per corner. --- crates/doc/src/geometry.rs | 6 +- crates/doc/src/node.rs | 5 + crates/engine/src/shapes_behaviour_tests.rs | 36 ++++++++ crates/geom/src/lib.rs | 91 ++++++++++++++++++- crates/geom/src/tests.rs | 54 +++++++++++ crates/ui-egui/src/corner_individual_tests.rs | 28 +++++- crates/ui-egui/src/right.rs | 9 +- docs/gaps.md | 5 +- docs/target-app-parity.md | 2 +- 9 files changed, 226 insertions(+), 10 deletions(-) diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index b4f89dbc..01bc4e70 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -17,8 +17,10 @@ pub fn shape_path(n: &Node) -> Option { | NodeKind::Section { .. } => figcraft_geom::rounded_rect(w, h, c.radii, c.smoothing), NodeKind::Slice => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0), NodeKind::Ellipse { start, sweep, inner } => figcraft_geom::ellipse(w, h, *start, *sweep, *inner), - NodeKind::Polygon { count } => figcraft_geom::rounded_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0]), - NodeKind::Star { count, inner_ratio } => figcraft_geom::rounded_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0]), + NodeKind::Polygon { count } => figcraft_geom::smoothed_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0], c.smoothing), + NodeKind::Star { count, inner_ratio } => { + figcraft_geom::smoothed_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0], c.smoothing) + } NodeKind::Line => { let mut p = BezPath::new(); p.move_to((0.0, 0.0)); diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index ca06fe53..2a284b05 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -397,6 +397,11 @@ impl NodeKind { self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. }) } + /// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it). + pub fn has_corner_smoothing(&self) -> bool { + self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. }) + } + pub fn text(&self) -> Option<&TextData> { match self { NodeKind::Text(t) => Some(t), diff --git a/crates/engine/src/shapes_behaviour_tests.rs b/crates/engine/src/shapes_behaviour_tests.rs index 62069da3..e628e168 100644 --- a/crates/engine/src/shapes_behaviour_tests.rs +++ b/crates/engine/src/shapes_behaviour_tests.rs @@ -205,6 +205,42 @@ fn corner_radius_follows_the_documented_rules() { ); } +#[test] +fn corner_smoothing_follows_the_documented_rules() { + // Smoothing applies to any shape a radius applies to, and only to the entire shape. + // (layer, radius, derivation) + let rows: Vec<(&str, f64, &str)> = vec![ + ("shape.rectangle", 20.0, "rectangles smooth their rounded corners"), + ("shape.polygon", 12.0, "polygons smooth their rounded corners"), + ("shape.star", 4.0, "stars smooth their rounded corners"), + ]; + for (cmd, radius, why) in rows { + let mut s = Session::new(); + let id = create(&mut s, cmd, json!({"x": 0, "y": 0, "width": 100, "height": 100})); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": radius}})); + let round = figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap().area().abs(); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0.6}})); + let smooth = figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap().area().abs(); + assert!((smooth - round).abs() > 1.0, "{why}: {round} then {smooth}"); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": 0}})); + let sharp = figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap(); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0}})); + assert_eq!(sharp, figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap(), "{why}: smoothing needs a radius"); + } + // (kind, takes smoothing, derivation) + let doc_rows: Vec<(NodeKind, bool, &str)> = vec![ + (NodeKind::Rectangle, true, "rectangles"), + (NodeKind::Frame, true, "frames"), + (NodeKind::Polygon { count: 5 }, true, "polygons"), + (NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, "stars"), + (NodeKind::Vector { network: Default::default() }, false, "documented for closed vectors; their points round without it here"), + (NodeKind::Line, false, "lines take no radius, so no smoothing"), + ]; + for (kind, smoothing, why) in doc_rows { + assert_eq!(kind.has_corner_smoothing(), smoothing, "{why}"); + } +} + #[test] fn rounded_vector_points_are_drawn() { // A converted 100 × 100 square: a point's radius rounds that corner only (it wasn't drawn). diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index e0a4dde0..012fca42 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -2,7 +2,7 @@ //! //! Re-exports [`kurbo`] and adds the shape builders a design tool needs: rectangles with independent //! corner radii and corner smoothing (the "squircle" continuous-curvature corner), regular polygons -//! and stars (with rounded vertices), and ellipse arcs / rings. Coordinates are y-down. +//! and stars (with rounded and smoothed vertices), and ellipse arcs / rings. Coordinates are y-down. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub use kurbo; @@ -230,6 +230,95 @@ pub fn rounded_polygon(points: &[Point], radius: f64) -> BezPath { path } +/// [`rounded_polygon`] with corner smoothing (0..=1, as [`rounded_rect`] takes it): each rounded +/// vertex keeps a circular arc over `1 - smoothing` of its turn and eases into both edges with +/// curves that start `1 + smoothing` times the plain corner's length from the vertex. As in +/// [`rounded_rect`], when a smoothed corner doesn't fit in half of its shorter edge, smoothing is +/// reduced first, then the radius. Without smoothing this is [`rounded_polygon`]. +pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPath { + let s = finite(smoothing).clamp(0.0, 1.0); + let radius = finite(radius).max(0.0); + let n = points.len(); + if s <= 0.0 || radius <= 0.0 || n < 3 { + return rounded_polygon(points, radius); + } + let mut path = BezPath::new(); + for i in 0..n { + let (Some(prev), Some(cur), Some(next)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { + continue; + }; + let (v1, v2) = (*prev - *cur, *next - *cur); + let (l1, l2) = (v1.hypot(), v2.hypot()); + let angle = if l1 < 1e-9 || l2 < 1e-9 { 0.0 } else { (v1 / l1).dot(v2 / l2).clamp(-1.0, 1.0).acos() }; + if angle < 1e-6 || (PI - angle) < 1e-6 { + line_or_move(&mut path, *cur); + continue; + } + let (u1, u2) = (v1 / l1, v2 / l2); + // The plain corner's length along each edge, then the fit: smoothing first, then the radius. + let max_p = l1.min(l2) / 2.0; + let mut r = radius; + let mut t = r / (angle / 2.0).tan(); + let mut sm = s; + if (1.0 + sm) * t > max_p { + sm = (max_p / t - 1.0).clamp(0.0, sm); + } + if t > max_p { + r *= max_p / t; + t = max_p; + } + let p = ((1.0 + sm) * t).min(max_p); + // Each easing curve covers `beta` of the turn and ends on the arc at `back` along the edge + // and `side` across it; its last handle points along the arc there. + let beta = (PI - angle) * sm / 2.0; + let back = t - r * beta.sin(); + let side = r * (1.0 - beta.cos()); + let c = r * (beta / 2.0).tan() * beta.cos(); + let b = ((p - back - c) / 3.0).max(0.0); + let a = 2.0 * b; + let bisector = (u1 + u2).normalize(); + let center = *cur + bisector * (r / (angle / 2.0).sin()); + // A point `along` an edge from the vertex and `across` it toward the arc's center. + let at = |u: Vec2, along: f64, across: f64| *cur + u * along + (bisector - u * bisector.dot(u)).normalize() * across; + line_or_move(&mut path, at(u1, p, 0.0)); + let p3 = at(u1, back, side); + let p4 = at(u2, back, side); + if a + b + c > 1e-9 { + path.curve_to(at(u1, p - a, 0.0), at(u1, p - a - b, 0.0), p3); + } else { + path.line_to(p3); + } + let a0 = (p3 - center).atan2(); + let mut sweep = (p4 - center).atan2() - a0; + while sweep <= -PI { + sweep += TAU; + } + while sweep > PI { + sweep -= TAU; + } + if sweep.abs() > 1e-9 { + kurbo::Arc::new(center, (r, r), a0, sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q)); + } + let end = at(u2, p, 0.0); + if a + b + c > 1e-9 { + path.curve_to(at(u2, p - a - b, 0.0), at(u2, p - a, 0.0), end); + } else { + path.line_to(end); + } + } + path.close_path(); + path +} + +/// Starts `path` at `p`, or continues it there with a straight segment. +fn line_or_move(path: &mut BezPath, p: Point) { + if path.elements().is_empty() { + path.move_to(p); + } else { + path.line_to(p); + } +} + /// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in /// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1. pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath { diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 15e24d49..37e6ccab 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -47,6 +47,10 @@ fn degenerate_inputs_dont_panic() { let _ = star_points(10.0, 10.0, 1, -3.0); let _ = rounded_polygon(&[], 4.0); let _ = rounded_polygon(&[Point::ZERO, Point::ZERO, Point::ZERO], 4.0); + let _ = smoothed_polygon(&[Point::ZERO, Point::ZERO, Point::ZERO], 4.0, 0.6); + let _ = smoothed_polygon(&[Point::ZERO, Point::new(10.0, 0.0), Point::new(20.0, 0.0)], 4.0, 1.0); + let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), f64::INFINITY, f64::NAN); + let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), 3.0, f64::INFINITY); let _ = ellipse(f64::NAN, 10.0, f64::NAN, 100.0, 2.0); } @@ -70,6 +74,56 @@ fn rounded_polygon_shrinks_area() { assert!(round < sharp && round > sharp * 0.8); } +#[test] +fn smoothed_polygon_matches_the_rectangle_corner() { + // A four-sided polygon is a square turned 45°: smoothed, it has the rounded rectangle's area. + let side = 50.0 * std::f64::consts::SQRT_2; + let pts = polygon_points(100.0, 100.0, 4); + for (r, s) in [(10.0, 0.0), (10.0, 0.6), (12.0, 1.0), (30.0, 0.6), (1000.0, 0.6)] { + let poly = area(&smoothed_polygon(&pts, r, s)); + let rect = area(&rounded_rect(side, side, [r; 4], s)); + assert!((poly - rect).abs() < 0.05, "r={r} s={s}: {poly} vs {rect}"); + } +} + +#[test] +fn smoothed_polygon_smooths_every_corner() { + use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let near = |p: &BezPath, at: Point| p.segments().map(|seg| seg.nearest(at, 1e-9).distance_sq).fold(f64::INFINITY, f64::min); + // (points, convex: smoothing only cuts area away) + let shapes = [(polygon_points(100.0, 100.0, 3), true), (polygon_points(120.0, 80.0, 6), true), (star_points(100.0, 100.0, 5, 0.38), false)]; + for (pts, convex) in shapes { + let plain = smoothed_polygon(&pts, 8.0, 0.0); + assert_eq!(plain, rounded_polygon(&pts, 8.0), "no smoothing draws the plain rounded corners"); + let sharp = area(&rounded_polygon(&pts, 0.0)); + let mut last = area(&plain); + for s in [0.3, 0.6, 1.0] { + let p = smoothed_polygon(&pts, 8.0, s); + let a = area(&p); + assert!(if convex { a < last } else { (a - last).abs() > 1e-3 }, "s={s}: more smoothing changes the area ({a} after {last})"); + assert!((a - sharp).abs() < sharp * 0.2, "s={s}: the shape stays close to its points"); + last = a; + // The outline has no kinks: each piece leaves in the direction the one before arrives. + let segs: Vec = p.segments().filter(|seg| seg.arclen(1e-6) > 1e-6).collect(); + for (i, seg) in segs.iter().enumerate() { + let next = segs[(i + 1) % segs.len()]; + let out = (seg.eval(1.0) - seg.eval(1.0 - 1e-6)).normalize(); + let into = (next.eval(1e-6) - next.eval(0.0)).normalize(); + assert!(out.dot(into) > 0.999, "s={s}: piece {i} joins the next smoothly ({out:?} then {into:?})"); + } + let b = p.bounding_box(); + assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 120.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "s={s} {b:?}"); + // Every vertex is cut off, and the middle of every edge is still on the outline. + for (i, v) in pts.iter().enumerate() { + let next = pts[(i + 1) % pts.len()]; + let mid = v.midpoint(next); + assert!(near(&p, mid) < 1e-6, "s={s}: edge {i} keeps its middle"); + assert!(near(&p, *v) > 0.25, "s={s}: vertex {i} is rounded"); + } + } + } +} + #[test] fn ellipse_ring_area() { let full = area(&ellipse(100.0, 100.0, 0.0, TAU, 0.0)); diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index 5a36f073..e1b317ce 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -1,5 +1,6 @@ //! 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. +//! their corners are (top left, top right / bottom left, bottom right) and each sets its own; +//! for a polygon or star the panel shows corner smoothing only. use egui::accesskit::Role; use egui::{Event, Id, Key, Modifiers, Pos2, Rect, pos2}; @@ -73,3 +74,28 @@ fn corner_fields_sit_at_their_corners_and_set_their_own() { assert_eq!(radii(&u, r), [30.0, 0.0, 12.0, 30.0]); assert_eq!(u.app.session.undo_depth().0, history + 1); } + +/// For a rounded polygon or star the same panel shows corner smoothing, which applies to the +/// whole shape: no field per corner, and typing a percentage sets the smoothing in one undo step. +#[test] +fn polygons_and_stars_take_corner_smoothing() { + for cmd in ["shape.polygon", "shape.star"] { + let mut s = Session::new(); + let id = NodeId(s.execute(cmd, &json!({"x": 0, "y": 0, "width": 120, "height": 120})).unwrap()["id"].as_u64().unwrap()); + s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let mut u = Ui::new(s); + let t = named_button(&mut u, "Individual corners"); + click(&mut u, t); + assert!((0..4).all(|k| u.ctx.read_response(Id::new(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields"); + let smoothing = u.ctx.read_response(Id::new("smoothing").with("field")).expect("the smoothing field shows").rect.center(); + let history = u.app.session.undo_depth().0; + click(&mut u, smoothing); + u.frame(vec![Event::Text("60".into())]); + enter(&mut u); + u.frame(vec![]); + let n = u.app.session.doc().unwrap().node(id).unwrap().clone(); + assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{cmd}: {}", n.corners.smoothing); + assert_eq!(u.app.session.undo_depth().0, history + 1, "{cmd}: one undo step"); + } +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index e3aba95b..57a0a8a7 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -852,6 +852,8 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let radius_ok = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_radius())); // Only rectangles and frames round each corner on its own. let indep_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_independent_corners())); + // For polygons and stars the same panel holds only the Corner smoothing row. + let smooth_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_smoothing())); let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius"))); row(ui, 32.0, |ui| { @@ -874,12 +876,12 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)); app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0))); let open = ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false); - if indep_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tr("Individual corners")).clicked() { + if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tr("Individual corners")).clicked() { ui.data_mut(|d| d.insert_temp(Id::new("indep_corners"), !open)); } } }); - if indep_ok && ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false) { + if smooth_ok && ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false) { let radii = common(doc, sel, |n| n.corners.radii); // `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. @@ -889,7 +891,8 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ("bottomLeftRadius", tr("Bottom left radius"), Icon::CornerBottomLeft, 3), ("bottomRightRadius", tr("Bottom right radius"), Icon::CornerBottomRight, 2), ]; - for pair in corners.chunks(2) { + // Polygons and stars have one radius, so no field per corner. + for pair in corners.chunks(2).filter(|_| indep_ok) { row(ui, 32.0, |ui| { for (key, name, icon, k) in pair { let e = crate::variable_ui::number_field( diff --git a/docs/gaps.md b/docs/gaps.md index f4f29c07..4b5783ef 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -315,8 +315,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **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, per-point caps and joins - on dashed, variable-width and brush strokes. + selected open path. Left: corner smoothing on vectors, corner radius and smoothing on boolean + groups that contain rectangles, 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 diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e196eb64..50fc63ef 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -99,7 +99,7 @@ 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; 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 vectors, corner radius and smoothing on boolean groups that contain rectangles; 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, corner smoothing on polygons and stars, 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 | From 6fa00af2be054a79ae887b33022c6e9ba9a99432 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 07:32:51 -0700 Subject: [PATCH 079/142] Docs: components parity status line for the text style variables --- docs/components-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/components-parity.md b/docs/components-parity.md index 79d0902e..f1357f09 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-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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (variables on a text style's fields from the style editor; 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 From 41fe7d95d89f3fe20cf1e0e951875c1f6e776064 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 07:57:36 -0700 Subject: [PATCH 080/142] M1: the iOS button next to Corner smoothing sets 60% The Corner smoothing row showed "iOS: 60%" as plain text. The help page on corner radius and smoothing says to click iOS to set corner smoothing to 60%. The row now has an iOS button that sets cornerSmoothing to 0.6 on the selection in one undo step, with the hover text "Set corner smoothing to 60%". --- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/corner_individual_tests.rs | 15 +++++++++++++++ crates/ui-egui/src/right.rs | 4 +++- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index e1ed336e..e8b64043 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1249,6 +1249,7 @@ name = "English" "Server address" = "Server address" "Server for online sharing" = "Server for online sharing" "Set as default" = "Set as default" +"Set corner smoothing to 60%" = "Set corner smoothing to 60%" "Set variable" = "Set variable" "Set variable mode" = "Set variable mode" "Set variable {name}" = "Set variable {name}" diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index 5a36f073..b0d1f27e 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -54,6 +54,21 @@ fn field(u: &Ui, k: usize) -> Rect { u.ctx.read_response(Id::new(("corner", k)).with("field")).expect("corner field shows").rect } +/// The iOS button next to Corner smoothing sets 60% in one undo step, as the help page's "click +/// iOS to set corner smoothing to 60%" describes. +#[test] +fn ios_button_sets_corner_smoothing_to_60_percent() { + let (mut u, r) = scene(); + let t = named_button(&mut u, "Individual corners"); + click(&mut u, t); + let history = u.app.session.undo_depth().0; + let ios = named_button(&mut u, "iOS"); + click(&mut u, ios); + let smoothing = u.app.session.doc().unwrap().node(r).unwrap().corners.smoothing; + assert!((smoothing - 0.6).abs() < 1e-9, "smoothing is {smoothing}"); + assert_eq!(u.app.session.undo_depth().0, history + 1); +} + /// 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] diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index e3aba95b..ab7f1e5c 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -912,7 +912,9 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { row(ui, 32.0, |ui| { let e = widgets::number_field(ui, Id::new("smoothing"), Lead::None, sm, "%", col, 1.0); app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); - widgets::label(ui, "iOS: 60%", Tokens::get(ui.ctx()).text_tertiary); + if widgets::button(ui, tr("iOS"), false, true).on_hover_text(tr("Set corner smoothing to 60%")).clicked() { + app.set_props(&ids, json!({"cornerSmoothing": 0.6})); + } }); } let blend = common(doc, sel, |n| n.blend); From ac399dbe739ac3114d834b1bd1336a46bf14af3a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 08:34:13 -0700 Subject: [PATCH 081/142] M6: Dev Mode says why when a page or layer drag is refused Page rows and layer rows take drags in Dev Mode again. A drag on either leaves the order as it was and shows the Dev Mode note, as a double click or the row menu already does. --- crates/ui-egui/src/devmode_tail_tests.rs | 7 +++++-- crates/ui-egui/src/layers.rs | 9 ++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs index 10233923..5845dcdc 100644 --- a/crates/ui-egui/src/devmode_tail_tests.rs +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -151,11 +151,13 @@ fn dev_mode_layers_panel_selects_and_expands_but_edits_nothing() { 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. + // Dragging a row doesn't reorder, and says why. + u.app.ui.toast = None; 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"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D")), "the drag says why"); // Back in the design, the same double click renames. u.app.ui.right_tab = crate::RightTab::Design; u.frame(vec![]); @@ -205,11 +207,12 @@ fn dev_mode_pages_list_opens_pages_but_edits_none() { assert!(u.app.ui.rename_page.is_none(), "no rename"); assert!(said_why(&mut u)); rest(&mut u); - // Dragging a page doesn't reorder. + // Dragging a page doesn't reorder, and says why. u.drag_to(rs, rh - egui::vec2(0.0, 10.0)); u.button(rh - egui::vec2(0.0, 10.0), false); rest(&mut u); assert_eq!(pages(&u), [home, specs], "not reordered"); + assert!(said_why(&mut u), "the drag says why"); // The add button adds no page, and says why. click(&mut u, add); assert_eq!(pages(&u), [home, specs], "no page added"); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 9e1db925..53a4e89d 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -246,8 +246,11 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { if filtering && (divider || !name.to_lowercase().contains(&filter)) { continue; } - let sense = if filtering || dev { Sense::click() } else { Sense::click_and_drag() }; + let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); + if dev && resp.drag_started() { + crate::menus::dev_mode_refuses(app, "page.move"); + } if id == cur { current_row_rendered = true; if scroll_to_cur { @@ -846,7 +849,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), if dev { Sense::click() } else { Sense::click_and_drag() }); + let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag()); let selected = sel.contains(&row.id); a11y::layer_row(&mut app.ui.nav, &resp); if sel.last() == Some(&row.id) { @@ -990,7 +993,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { app.ui.context_menu = Some((pos, vec![row.id])); } // Drag to reorder. - if resp.drag_started() { + if resp.drag_started() && !crate::menus::dev_mode_refuses(app, "node.reparent") { ui.data_mut(|d| d.insert_temp(drag_id, row.id)); } let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p)); From 09b28f9b2d69051e2d77c259578167782deebb0c Mon Sep 17 00:00:00 2001 From: watchakorn-18k Date: Sat, 24 Oct 2026 22:34:35 +0700 Subject: [PATCH 082/142] M8: Copy link to selection in the layer context menu Edit > Copy as > Copy link to selection (edit.copyLink) was only in the main menu. Add it to the right-click menu on a layer, after Copy as PNG, labelled with the command's own translated label. The command now says "Link copied" when it copies, and copies nothing when no layer is selected (it used to put figcraft://node/0 on the clipboard). --- crates/ui-egui/src/context_menu_tests.rs | 55 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 10 ++++- 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/context_menu_tests.rs diff --git a/crates/ui-egui/src/context_menu_tests.rs b/crates/ui-egui/src/context_menu_tests.rs new file mode 100644 index 00000000..eb594c69 --- /dev/null +++ b/crates/ui-egui/src/context_menu_tests.rs @@ -0,0 +1,55 @@ +//! The right-click menu on a layer: "Copy link to selection" puts `figcraft://node/` on the +//! clipboard and says so; with nothing selected the command copies nothing. + +use std::sync::{Arc, Mutex}; + +use egui::Pos2; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; +use crate::present_tests::id; + +/// A clipboard that keeps what was copied where the test can read it. +struct Copied(Arc>>); + +impl crate::Services for Copied { + fn clipboard_set_text(&mut self, s: &str) { + if let Ok(mut copied) = self.0.lock() { + copied.push(s.to_string()); + } + } +} + +#[test] +fn the_context_menu_copies_a_link_to_the_selected_frame() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "หน้าแรก"})).unwrap()); + let copied = Arc::new(Mutex::new(Vec::new())); + let mut u = Ui::new(s); + u.app.services = Box::new(Copied(copied.clone())); + u.frame(vec![]); + u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![f])); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + let at = text_center(&shapes, "Copy link to selection"); + u.button(at, true); + u.button(at, false); + assert_eq!(*copied.lock().unwrap(), vec![format!("figcraft://node/{}", f.0)], "the link to the frame"); + assert_eq!(u.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Link copied")); + assert!(u.app.ui.context_menu.is_none(), "the menu closes"); +} + +#[test] +fn copying_a_link_with_nothing_selected_copies_nothing() { + let mut s = Session::new(); + s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200})).unwrap(); + s.execute("select.none", &json!({})).unwrap(); + let copied = Arc::new(Mutex::new(Vec::new())); + let mut u = Ui::new(s); + u.app.services = Box::new(Copied(copied.clone())); + crate::menus::invoke(&mut u.app, "edit.copyLink"); + assert!(copied.lock().unwrap().is_empty(), "no link to node 0"); + assert!(u.app.ui.toast.is_none()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index da40bf0c..165ea5db 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -98,6 +98,8 @@ mod comments_tests; #[cfg(test)] mod component_props_reorder_tests; #[cfg(test)] +mod context_menu_tests; +#[cfg(test)] mod corner_individual_tests; #[cfg(test)] mod cursors_tests; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index eeb25e3d..ef48719d 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -219,8 +219,10 @@ pub fn invoke(app: &mut FigApp, id: &str) { app.toast(tr("Copied as text")); } "edit.copyLink" => { - let ids = app.session.selection(); - app.services.clipboard_set_text(&format!("figcraft://node/{}", ids.first().map(|i| i.0).unwrap_or(0))); + if let Some(id) = app.session.selection().first() { + app.services.clipboard_set_text(&format!("figcraft://node/{}", id.0)); + app.toast(tr("Link copied")); + } } "edit.findReplace" => app.ui.find = Some((String::new(), String::new())), // The Actions palette invokes commands without parameters. These values are edited in @@ -895,6 +897,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { .ok() .filter(|doc| figcraft_engine::cmd::component::can_combine_variants(doc, &app.session.selection())) .map(|_| i18n::command("component.combineVariants", "Combine as variants")); + let copy_link = i18n::command("edit.copyLink", "Copy link to selection"); let mut entries: Vec<(&str, &str)> = if has_sel { vec![ (tr("Copy"), "edit.copy"), @@ -957,6 +960,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, (label, "component.combineVariants")); } + if let Some(at) = entries.iter().position(|e| e.1 == "edit.copyAsPng") { + entries.insert(at + 1, (©_link, "edit.copyLink")); + } // A right-clicked ruler guide (selected, with no layers) can be removed. if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) { entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]); From d1d3b3a86c3c14c2c2558d8011ae3054926847b3 Mon Sep 17 00:00:00 2001 From: watchakorn-18k Date: Sat, 24 Oct 2026 23:12:11 +0700 Subject: [PATCH 083/142] M8: group the copy commands under Copy as in the layer context menu Copy as CSS, SVG, PNG and Copy link to selection move into a Copy as submenu, placed under Paste to replace, instead of four flat rows near the bottom of the menu. --- crates/ui-egui/src/context_menu_tests.rs | 22 ++++++++++++--- crates/ui-egui/src/menus.rs | 34 +++++++++++++++++++----- 2 files changed, 45 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/context_menu_tests.rs b/crates/ui-egui/src/context_menu_tests.rs index eb594c69..61df3fdb 100644 --- a/crates/ui-egui/src/context_menu_tests.rs +++ b/crates/ui-egui/src/context_menu_tests.rs @@ -1,5 +1,6 @@ -//! The right-click menu on a layer: "Copy link to selection" puts `figcraft://node/` on the -//! clipboard and says so; with nothing selected the command copies nothing. +//! The right-click menu on a layer: the copy commands sit in a Copy as submenu under Paste to +//! replace; "Copy link to selection" puts `figcraft://node/` on the clipboard and says so, and +//! with nothing selected the command copies nothing. use std::sync::{Arc, Mutex}; @@ -8,7 +9,7 @@ use figcraft_engine::Session; use serde_json::json; use crate::layers_drag_tests::Ui; -use crate::live_tests::{flat, text_center}; +use crate::live_tests::{flat, has_text, text_center}; use crate::present_tests::id; /// A clipboard that keeps what was copied where the test can read it. @@ -23,7 +24,7 @@ impl crate::Services for Copied { } #[test] -fn the_context_menu_copies_a_link_to_the_selected_frame() { +fn the_context_menu_groups_the_copy_commands_under_copy_as() { let mut s = Session::new(); let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 200, "name": "หน้าแรก"})).unwrap()); let copied = Arc::new(Mutex::new(Vec::new())); @@ -33,6 +34,19 @@ fn the_context_menu_copies_a_link_to_the_selected_frame() { u.app.ui.context_menu = Some((Pos2::new(700.0, 300.0), vec![f])); u.frame(vec![]); let shapes = flat(&u.frame_output(vec![]).shapes); + // A row under Paste to replace; the items are in its submenu, not in the menu itself. + let y = |s: &str| text_center(&shapes, s).y; + assert!(y("Paste to replace") < y("Copy as") && y("Copy as") < y("Bring to front"), "Copy as sits under Paste to replace"); + for item in ["Copy as CSS", "Copy as SVG", "Copy as PNG", "Copy link to selection"] { + assert!(!has_text(&shapes, item), "{item} is inside the submenu"); + } + let at = text_center(&shapes, "Copy as"); + u.button(at, true); + u.button(at, false); + let shapes = flat(&u.frame_output(vec![]).shapes); + for item in ["Copy as CSS", "Copy as SVG", "Copy as PNG", "Copy link to selection"] { + assert!(has_text(&shapes, item), "{item} is in the submenu"); + } let at = text_center(&shapes, "Copy link to selection"); u.button(at, true); u.button(at, false); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ef48719d..abdae1b2 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -903,6 +903,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { (tr("Copy"), "edit.copy"), (tr("Paste here"), "edit.paste"), (tr("Paste to replace"), "edit.pasteReplace"), + (tr("Copy as"), COPY_AS), ("-", ""), (tr("Bring to front"), "arrange.front"), (tr("Bring forward"), "arrange.forward"), @@ -927,10 +928,6 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { (tr("Flip horizontal"), "object.flipH"), (tr("Flip vertical"), "object.flipV"), ("-", ""), - (tr("Copy as CSS"), "edit.copyAsCss"), - (tr("Copy as SVG"), "edit.copyAsSvg"), - (tr("Copy as PNG"), "edit.copyAsPng"), - ("-", ""), (tr("Delete"), "edit.delete"), ] } else { @@ -960,9 +957,6 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { { entries.insert(at + 1, (label, "component.combineVariants")); } - if let Some(at) = entries.iter().position(|e| e.1 == "edit.copyAsPng") { - entries.insert(at + 1, (©_link, "edit.copyLink")); - } // A right-clicked ruler guide (selected, with no layers) can be removed. if !has_sel && app.session.ds().is_ok_and(|d| d.selected_guide().is_some()) { entries.splice(0..0, [(tr("Remove guide"), "guide.remove"), ("-", "")]); @@ -972,6 +966,29 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { separator(ui); continue; } + if *id == COPY_AS { + let copy_items = [ + (tr("Copy as CSS"), "edit.copyAsCss"), + (tr("Copy as SVG"), "edit.copyAsSvg"), + (tr("Copy as PNG"), "edit.copyAsPng"), + (&*copy_link, "edit.copyLink"), + ]; + let picked = submenu(ui, COPY_AS, label, &t, |ui| { + let mut picked = None; + for (label, id) in copy_items { + let sc = crate::shortcuts::shortcut(id).unwrap_or(""); + if item(ui, label, sc, available(id), false, &t) { + picked = Some(id); + } + } + picked + }); + if let Some(id) = picked { + invoke(app, id); + close = true; + } + continue; + } let sc = crate::shortcuts::shortcut(id).unwrap_or(""); if item(ui, label, sc, available(id), false, &t) { if *id == "edit.paste" { @@ -998,6 +1015,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { /// The context menu's Select layer submenu (its key in the context menu's tree). const SELECT_LAYER: &str = "Select layer"; +/// The context menu's Copy as submenu: its key in the tree, and the marker row in the entries. +const COPY_AS: &str = "Copy as"; + fn select_layer_ask_id() -> Id { Id::new("ctx_select_layer_ask") } From 954f9036daa02fd56f7f20a342fca8718fbbbca3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 09:19:45 -0700 Subject: [PATCH 084/142] Docs: parity rows for mouse-trigger delays, outline options and missing fonts Three parity rows still listed work that has landed on main. The prototyping Triggers row listed delays on mouse enter, leave, down and up as missing; they landed on 2026-10-13. Canvas & navigation's remaining work in target-app-parity listed outline mode's Include hidden layers and Include object bounds, which View > Outlines has. The file-format Fonts row said missing fonts give warnings only; the desktop app lists them in the Missing fonts window, and replacing them is still missing. Each file gets its status line and a revision-history row. The percentages and hours are unchanged. --- docs/file-format-parity.md | 5 +++-- docs/prototyping-parity.md | 5 +++-- docs/target-app-parity.md | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 73fbc602..c4d1797d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (`.figcraft` carries the file kind (boards; `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards; `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -159,12 +159,13 @@ each. | Local fonts | yes (font helper / desktop app) | yes (system scan) | | Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) | | Variable fonts | yes | yes (axes, named instances, per-range axis values) | -| Missing-font replacement on open | yes | warnings only (REST import) | +| Missing-font replacement on open | yes | listed, not replaced: in the desktop app the Missing fonts window opens after a file that uses them opens and from Text ▸ Missing fonts (`text.missingFonts`; `figcraft-cli info` prints `missing font` lines), and their text draws in a fallback font | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged | | 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 | diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index 2ded779d..c7e50867 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -1,6 +1,6 @@ # Prototyping parity -> **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) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** patch (the Triggers row no longer lists delays on mouse triggers as missing; 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, @@ -15,7 +15,7 @@ larger real prototypes. Remaining: 2–5 h. | Group | Figma | FigCraft | Missing | |---|---|---|---| -| Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress (keys, combinations and controller buttons) | media triggers, delays on mouse enter/leave/down/up; controllers on macOS, Linux and FreeBSD | +| Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress (keys, combinations and controller buttons) | media triggers; controllers on macOS, Linux and FreeBSD | | Actions | Navigate to, change to, back, scroll to, open link, open / swap / close overlay, set variable, set variable mode, conditional, play/pause/mute/seek media | 11: Navigate, Back, ScrollTo (with an offset), OpenOverlay, SwapOverlay, CloseOverlay, OpenUrl, ChangeTo, SetVariable, SetVariableMode, Conditional | media actions | | Transitions | Instant, dissolve, smart animate, move in/out, push, slide in/out | all 8 | — | | Easing | Linear, ease in/out/in-and-out, back variants, gentle, quick, bouncy, slow, custom bezier, custom spring | all 13 (springs physical) | — | @@ -103,6 +103,7 @@ case pins the current behaviour). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | patch | The Triggers row's Missing column no longer lists delays on mouse enter, leave, down and up, which landed on 2026-10-13 (`crates/engine/src/proto_delay_tests.rs`, `crates/ui-egui/src/present_delay_tests.rs`). Numbers unchanged | | 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. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e196eb64..953033c8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; 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; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; 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; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; 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; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; 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; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; 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; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; 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; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; 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; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; 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; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -96,7 +96,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | # | Area | Weight | Breadth | Ready | Remaining | Hours | |---|---|---|---|---|---|---| -| 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 | +| 1 | Canvas & navigation | 6 | 98% | 74% | 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; outline mode's Include hidden layers and Include object bounds landed) | 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; 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 | @@ -641,6 +641,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-24 | patch | Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds, which landed (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `crates/engine/src/outline_options_tests.rs`). Numbers unchanged | | 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 | From 544a29c93b5b3f4965c40d6ce5582e4b350a7f65 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:17:22 +0000 Subject: [PATCH 085/142] Keep style runs disjoint when inserting text at a run boundary Found while testing crates/doc/src/text.rs:592 (TextData::replace_range, with normalize at :573) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/doc/src/tests.rs | 10 ++++++++++ crates/doc/src/text.rs | 9 ++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 1a006c26..f8e69f15 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -559,3 +559,13 @@ fn is_ancestor_holds_for_very_deep_chains() { assert!(d.is_ancestor(root, leaf)); assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..)))); } + +#[test] +fn text_insert_at_run_boundary_keeps_runs_disjoint() { + let mut t = TextData::new("0123456789"); + t.restyle(0, 5, |s| s.font_size = 20.0); + t.restyle(5, 10, |s| s.font_size = 30.0); + t.replace_range(5, 5, "xx"); + let runs: Vec<(usize, usize)> = t.runs.iter().map(|r| (r.start, r.end)).collect(); + assert_eq!(runs, vec![(0, 7), (7, 12)]); +} diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index 458426ad..d2abfed9 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -579,7 +579,14 @@ impl TextData { } self.runs.sort_by_key(|r| r.start); let mut merged: Vec = Vec::with_capacity(self.runs.len()); - for r in std::mem::take(&mut self.runs) { + for mut r in std::mem::take(&mut self.runs) { + // Runs never overlap: a later run starts where the previous one ends at the earliest. + if let Some(prev) = merged.last() { + r.start = r.start.max(prev.end); + if r.start >= r.end { + continue; + } + } match merged.last_mut() { Some(prev) if prev.end == r.start && prev.style == r.style => prev.end = r.end, _ => merged.push(r), From 27fc3cec81e5ae8d2fb8f3573f663921d2b883a5 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 10:34:29 -0700 Subject: [PATCH 086/142] Docs: list each file-format status topic once The status line of docs/file-format-parity.md repeated eight topics, from SVG/PDF export depth refusal (#208) to "last major". Each topic is now listed once, and the boards entry closes its parenthesis. --- docs/file-format-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c4d1797d..95c827a8 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards; `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's From d9a1de91d3fb96148c36d4b190390c8ab5dcdd5d Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:39:10 +0000 Subject: [PATCH 087/142] Fix code export printing whole numbers of 2^63 or more as i64::MAX Found while testing crates/engine/src/codegen.rs:112 (num) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/codegen.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs index 7ed81b30..0a4a5d2e 100644 --- a/crates/engine/src/codegen.rs +++ b/crates/engine/src/codegen.rs @@ -109,7 +109,12 @@ pub(crate) fn num(v: f64) -> String { return "0".into(); } let r = (v * 100.0).round() / 100.0; - if (r - r.round()).abs() < 1e-9 { format!("{}", r.round() as i64) } else { format!("{r}") } + if (r - r.round()).abs() < 1e-9 { + // `as i64` saturates past 2^63, so larger whole values are printed from the float. + if r.abs() < 9e18 { format!("{}", r.round() as i64) } else { format!("{:.0}", r.round()) } + } else { + format!("{r}") + } } /// `code` with every pixel length (`12px`) in rem of `root` pixels (`0.75rem`); names that end @@ -1142,6 +1147,14 @@ mod tests { use crate::params::id_of; use serde_json::json; + #[test] + fn num_keeps_huge_whole_numbers() { + assert_eq!(num(1e20), "100000000000000000000"); + assert_eq!(num(-1e20), "-100000000000000000000"); + assert_eq!(num(1e18), "1000000000000000000"); + assert_eq!(num(-0.0), "0"); + } + fn card(s: &mut Session) -> NodeId { let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 100, "name": "Card"})).unwrap()["id"]).unwrap(); s.execute("node.set", &json!({"id": f, "props": {"fills": ["#FFFFFF"], "cornerRadius": 12}})).unwrap(); From d8900e1c16ffa372ce20fd4f11a082cc6035e60c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 10:51:58 -0700 Subject: [PATCH 088/142] M4: the Text on a path tool Text went onto a path only with the Design panel's Put text on path button (a text layer and a path selected together) or with text.onPath. The Text on a path tool, in the Text flyout of the toolbar (tool.textOnPath), shows the I-beam over a shape's or vector's path, and a click there puts new, empty text on the path, starting at the click, and starts typing. The text takes the path layer's place, transform, box, constraints and effects, and its fills when one of them shows; the path layer is removed. Ending the edit with nothing typed puts the path back with no undo step. The tool measures the distance on the page, so a layer scaled more one way than the other, or shown smaller, is reached where it shows. Shapes in front whose outline is away from the click do not hide a path behind them, and paths that a mask or a clipping frame hides at the click are skipped, as are locked layers and layers inside instances. A double-click into a text layer that was just made no longer makes it count as an existing layer, so leaving it empty records no undo step, with this tool and with the Text tool. --- crates/doc/src/geometry.rs | 2 +- crates/engine/src/cmd/view.rs | 3 + crates/engine/src/cursor_tests.rs | 4 + crates/engine/src/draw_tools_tests.rs | 143 ++++++++++++++++++++++++++ crates/engine/src/textedit.rs | 8 +- crates/engine/src/tools.rs | 139 ++++++++++++++++++++++++- crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/src/icons.rs | 7 ++ crates/ui-egui/src/toolbar.rs | 2 +- docs/mcp.md | 7 +- docs/ui-parity.md | 5 +- 11 files changed, 312 insertions(+), 9 deletions(-) diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index b4f89dbc..095c0c60 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -126,7 +126,7 @@ pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolR /// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask /// below it, a hidden one masks nothing) doesn't cover the point, so nothing of `c` shows there. -fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool { +pub fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool { if !doc.has_masks() { return false; } diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 17f71b49..a81a222c 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -535,6 +535,9 @@ pub fn specs() -> Vec { spec!("tool.pen", "Pen", "{}", false, |s, _| tool(s, ToolId::Pen)), spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)), spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)), + spec!("tool.textOnPath", "Text on a path", "{} a click on a shape's or vector's path puts new text on it", false, |s, _| { + tool(s, ToolId::TextOnPath) + }), spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)), spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)), // `apply: false` is for the UI colour picker: the eyedropper only samples (into diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 7a9e0621..6ffb33e4 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -70,6 +70,7 @@ fn scene() -> (Session, NodeId, NodeId) { const EMPTY: (f64, f64) = (700.0, 600.0); const ON_SQUARE: (f64, f64) = (150.0, 150.0); +const ON_SQUARE_EDGE: (f64, f64) = (101.0, 150.0); const ON_TEXT: (f64, f64) = (420.0, 120.0); #[test] @@ -101,6 +102,9 @@ fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() { (ToolId::Text, NONE, EMPTY, Crosshair, "the Text tool makes a text where it is pressed"), (ToolId::Text, NONE, ON_SQUARE, Crosshair, "over a shape too"), (ToolId::Text, NONE, ON_TEXT, Text, "the I-beam over a text: a click edits it there"), + (ToolId::TextOnPath, NONE, EMPTY, Default, "text on a path: a click on empty canvas does nothing"), + (ToolId::TextOnPath, NONE, ON_SQUARE, Default, "nor inside a shape, away from its path"), + (ToolId::TextOnPath, NONE, ON_SQUARE_EDGE, Text, "the I-beam over a shape's path: a click puts text on it"), (ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"), (ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"), (ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"), diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index d67b0af4..39fc545f 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -60,6 +60,149 @@ fn text_goes_onto_a_path_and_off_again() { assert!(s.doc().unwrap().node(v).is_some() && text_path(&s, t).is_none(), "undo brings the path back"); } +/// A click (press and release) at (`x`, `y`) at 100%. +fn click(s: &mut Session, x: f64, y: f64) { + use crate::tools::{PointerEvent, PointerKind}; + s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap(); +} + +fn editing(s: &Session) -> Option { + s.ds().unwrap().text_edit.as_ref().map(|te| te.id) +} + +#[test] +fn the_text_on_a_path_tool_types_new_text_onto_the_path_it_clicks() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100, "props": {"fill": "#FF0000"}})).unwrap()); + s.execute("effect.add", &json!({"ids": [v], "type": "dropShadow"})).unwrap(); + let (vxf, vw, fills, effects) = { + let n = s.doc().unwrap().node(v).unwrap(); + (n.transform, n.width, n.fills.clone(), n.effects.clone()) + }; + assert!(!fills.is_empty() && !effects.is_empty()); + s.set_tool(crate::tools::ToolId::TextOnPath); + // Away from the path a click does nothing. + click(&mut s, 140.0, 160.0); + assert!(editing(&s).is_none() && s.doc().unwrap().node(v).is_some(), "only a path takes text"); + // A quarter of the way along, a few pixels off the line. + click(&mut s, 90.0, 103.0); + let t = editing(&s).expect("typing starts on the path"); + assert!(s.doc().unwrap().node(v).is_none(), "the path layer became the text's path"); + crate::textedit::insert(&mut s, "Along").unwrap(); + crate::textedit::end(&mut s).unwrap(); + let doc = s.doc().unwrap(); + let n = doc.node(t).unwrap(); + assert_eq!((n.transform, n.width), (vxf, vw), "in the path's place"); + assert_eq!(n.kind.text().map(|t| t.characters.as_str()), Some("Along")); + assert_eq!((&n.fills, &n.effects), (&fills, &effects), "the path's fill and effects carry over to the text"); + let tp = text_path(&s, t).unwrap(); + assert!((tp.start - 0.25).abs() < 0.01, "the text starts where the path was clicked: {}", tp.start); + assert_eq!(s.tool.tool, crate::tools::ToolId::Move, "back to Move after placing, as with the Text tool"); + s.execute("edit.undo", &json!({})).unwrap(); + let doc = s.doc().unwrap(); + assert!(doc.node(v).is_some() && doc.node(t).is_none(), "one undo step brings the path back"); +} + +#[test] +fn a_shape_in_front_whose_path_is_away_from_the_click_does_not_hide_the_path_behind() { + let mut s = Session::new(); + let back = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100})).unwrap()); + // A filled square over the line: the click is inside it, 20 or more from its outline. + let front = id(s.execute("shape.rectangle", &json!({"x": 60, "y": 80, "width": 100, "height": 60})).unwrap()); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 90.0, 100.0); + assert!(editing(&s).is_some(), "typing starts on the path behind"); + let doc = s.doc().unwrap(); + assert!(doc.node(back).is_none() && doc.node(front).is_some(), "the clicked path took the text"); +} + +#[test] +fn a_path_scaled_more_one_way_is_reached_where_it_shows() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 L100 100", "x": 0, "y": 0})).unwrap()); + // Ten times wider than tall: on the page the line runs from (0, 0) to (1000, 100). + s.doc_mut().unwrap().get_mut(v).unwrap().transform = figcraft_geom::Affine::scale_non_uniform(10.0, 1.0); + s.set_tool(crate::tools::ToolId::TextOnPath); + // 5 below the line on the page, halfway along it. + click(&mut s, 500.0, 55.0); + let t = editing(&s).expect("typing starts on the path"); + let tp = text_path(&s, t).unwrap(); + assert!((tp.start - 0.5).abs() < 0.01, "{}", tp.start); + // A layer shown at a tenth of its size: its outline is still reached 5 screen pixels away. + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1000, "height": 1000})).unwrap()); + s.doc_mut().unwrap().get_mut(r).unwrap().transform = figcraft_geom::Affine::scale(0.1); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 105.0, 50.0); + assert!(editing(&s).is_some() && s.doc().unwrap().node(r).is_none(), "typing starts on the small square's path"); +} + +#[test] +fn paths_a_mask_or_a_turned_clipping_frame_hides_take_no_text() { + let mut s = Session::new(); + // A square masked by a circle in its top-left corner: its right edge doesn't show. + let m = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + s.execute("object.mask", &json!({"ids": [m, r]})).unwrap(); + // A clipping frame turned 45°, with a line outside it: inside the frame's bounding box, but clipped. + let f = id(s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 100, "props": {"clip": true}})).unwrap()); + let l = id(s.execute("vector.create", &json!({"path": "M0 0 L40 0", "x": -60, "y": 50, "parent": f})).unwrap()); + s.execute("node.set", &json!({"id": f, "props": {"rotation": 45}})).unwrap(); + let hidden = s.doc().unwrap().world_transform(l) * figcraft_geom::Point::new(20.0, 0.0); + let frame_box = s.doc().unwrap().world_bounds(f).unwrap(); + assert!(frame_box.contains(hidden), "the line's middle is inside the frame's bounding box"); + s.set_tool(crate::tools::ToolId::TextOnPath); + for (x, y) in [(100.0, 50.0), (hidden.x, hidden.y)] { + click(&mut s, x, y); + assert!(editing(&s).is_none(), "nothing shows at ({x}, {y})"); + } + let doc = s.doc().unwrap(); + assert!(doc.node(r).is_some() && doc.node(l).is_some()); +} + +#[test] +fn text_on_a_stroke_only_path_gets_the_usual_fill() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100, "props": {"fills": []}})).unwrap()); + assert!(!s.doc().unwrap().node(v).unwrap().fills.iter().any(|f| f.visible), "a path with a stroke only"); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 40.0, 100.0); + let t = editing(&s).expect("typing starts on the path"); + assert!(s.doc().unwrap().node(t).unwrap().fills.iter().any(|f| f.visible), "the text can be seen"); +} + +#[test] +fn text_on_a_path_left_empty_puts_the_path_back_without_an_undo_step() { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()); + let locked = id(s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 100, "height": 100, "props": {"locked": true}})).unwrap()); + let before = (s.doc().unwrap().clone(), s.undo_labels(100).len()); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 400.0, 150.0); + assert!(editing(&s).is_none(), "a locked layer's path doesn't take text"); + // Inside the square, away from its outline, nothing happens either. + click(&mut s, 150.0, 150.0); + assert!(editing(&s).is_none()); + click(&mut s, 100.0, 150.0); + assert!(editing(&s).is_some() && s.doc().unwrap().node(r).is_none()); + crate::textedit::end(&mut s).unwrap(); + assert_eq!(*s.doc().unwrap(), before.0, "the square is back as it was"); + assert_eq!(s.undo_labels(100).len(), before.1, "no undo step"); + assert!(s.doc().unwrap().node(locked).is_some()); + // A double-click on the path, then nothing typed: the same (with this tool and the Text tool). + use crate::tools::{PointerEvent, PointerKind, ToolId}; + for tool in [ToolId::TextOnPath, ToolId::Text] { + s.set_tool(tool); + click(&mut s, 100.0, 150.0); + s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 100.0, 150.0, 1.0)).unwrap(); + assert!(editing(&s).is_some(), "{tool:?}"); + crate::textedit::end(&mut s).unwrap(); + assert_eq!(*s.doc().unwrap(), before.0, "{tool:?}: the file is back as it was"); + assert_eq!(s.undo_labels(100).len(), before.1, "{tool:?}: no undo step"); + } +} + #[test] fn svg_text_paths_import_as_text_on_a_path() { // SVG text is parsed with Inter standing in for every family (CI has no fonts). diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 85ea535b..2dfc774c 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -17,6 +17,8 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - let n = t.char_count(); s.begin_interaction("Edit text")?; let ds = s.ds_mut()?; + // Editing the same layer again (a double-click into a text just made) keeps it new. + let created = created || ds.text_edit.as_ref().is_some_and(|te| te.id == id && te.created); let (caret, anchor) = match caret { Some(c) => (c.min(n), c.min(n)), None => (n, 0), @@ -44,9 +46,13 @@ pub fn end(s: &mut Session) -> Result<()> { // Nothing to undo for a text box that was never filled. The gesture snapshot was taken // after the layer was added, so it still holds it: re-base it on the document without the // layer, and committing then records no step (and leaves the redo stack alone). + // A snapshot taken before the layer was added (the Text on a path tool, which also took + // the path layer away) is put back whole instead. let ds = s.ds_mut()?; let doc = ds.doc.clone(); - if let Some(it) = ds.interaction.as_mut() { + if ds.interaction.as_ref().is_some_and(|it| it.doc.node(te.id).is_none()) { + s.cancel_interaction()?; + } else if let Some(it) = ds.interaction.as_mut() { it.doc = doc; } } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 86feed4f..ae97f0e8 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -36,6 +36,7 @@ pub enum ToolId { Pen, Pencil, Text, + TextOnPath, Comment, Eyedropper, ShapeBuilder, @@ -50,7 +51,7 @@ pub enum ToolId { } impl ToolId { - pub const ALL: [ToolId; 25] = [ + pub const ALL: [ToolId; 26] = [ ToolId::Move, ToolId::Scale, ToolId::Hand, @@ -66,6 +67,7 @@ impl ToolId { ToolId::Pen, ToolId::Pencil, ToolId::Text, + ToolId::TextOnPath, ToolId::Comment, ToolId::Eyedropper, ToolId::ShapeBuilder, @@ -94,6 +96,7 @@ impl ToolId { ToolId::Pen => "pen", ToolId::Pencil => "pencil", ToolId::Text => "text", + ToolId::TextOnPath => "textOnPath", ToolId::Comment => "comment", ToolId::Eyedropper => "eyedropper", ToolId::ShapeBuilder => "shapeBuilder", @@ -126,6 +129,7 @@ impl ToolId { ToolId::Pen => "Pen", ToolId::Pencil => "Pencil", ToolId::Text => "Text", + ToolId::TextOnPath => "Text on a path", ToolId::Comment => "Comment", ToolId::Eyedropper => "Pick color", ToolId::ShapeBuilder => "Shape builder", @@ -277,7 +281,8 @@ impl ToolId { ToolId::Pen => Cursor::Pen(PenCursor::New), ToolId::Comment => Cursor::Comment, ToolId::Eyedropper => Cursor::Eyedropper, - ToolId::Move | ToolId::Scale => Cursor::Default, + // Text on a path acts only over a path, where it shows the I-beam. + ToolId::Move | ToolId::Scale | ToolId::TextOnPath => Cursor::Default, // The other tools make something where they are pressed: shapes, frames, slices, pencil // strokes, a new text (over a text the Text tool shows the I-beam instead). _ => Cursor::Crosshair, @@ -643,6 +648,29 @@ pub(crate) const SNAP_PX: f64 = 5.0; /// Screen pixels the pointer travels after a press before a move or resize starts: a click or a /// double-click made with an unsteady hand doesn't drag what it lands on. const DRAG_SLOP_PX: f64 = 3.0; +/// Screen pixels from a path within which the Text on a path tool takes it. +const PATH_REACH_PX: f64 = 6.0; + +/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most +/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a +/// mask hides at `p` and the operands of boolean groups are left out. +fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec) { + let Some(n) = doc.node(id) else { return }; + if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) { + return; + } + if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) { + return; + } + for c in n.paint_order().rev() { + if !figcraft_doc::geometry::masked_out(doc, n, c, p) { + path_candidates(doc, c, p, reach, depth + 1, out); + } + } + if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) { + out.push(id); + } +} /// Corners of the selection's handle box in page coordinates (tl, tr, br, bl). pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> { @@ -996,7 +1024,7 @@ impl Session { let _ = crate::shapebuilder::arrangement(self, &sel); } if let Ok(ds) = self.ds_mut() { - if t != ToolId::Text && t != ToolId::Move { + if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) { ds.text_edit = None; } ds.revision += 1; @@ -1056,6 +1084,7 @@ impl Session { Ok(()) } ToolId::Text => self.text_down(ev), + ToolId::TextOnPath => self.text_on_path_down(ev), ToolId::Pen => self.pen_down(ev), ToolId::Pencil => { self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; @@ -1111,6 +1140,9 @@ impl Session { if tool == ToolId::Text && (on_edited || on_text(self)) { cursor = Cursor::Text; } + if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) { + cursor = Cursor::Text; + } let mut hover = None; let mut guides = Vec::new(); let mut measure_box = None; @@ -2124,6 +2156,107 @@ impl Session { Ok(()) } + /// The layer whose path is under `ev` for the Text on a path tool and where along it the press + /// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its + /// first subpath (the one text follows), not locked and not inside an instance. Layers in front + /// whose path is further away don't hide it. + pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> { + let reach = ev.tol() * PATH_REACH_PX; + let near = |id: NodeId| -> Option<(NodeId, f64)> { + let n = doc.node(id)?; + let shape = matches!( + n.kind, + NodeKind::Rectangle + | NodeKind::Ellipse { .. } + | NodeKind::Polygon { .. } + | NodeKind::Star { .. } + | NodeKind::Line + | NodeKind::Vector { .. } + ); + if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) { + return None; + } + let geom = figcraft_doc::geometry::stroke_geometry(n)?; + let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?; + // Measured on the page (a layer scaled more one way than the other is still reached + // where it shows), the start by length along the layer's own path. + let world = doc.world_transform(id); + let p = ev.p(); + let mut best: Option<(f64, f64)> = None; + for (i, w) in line.points.windows(2).enumerate() { + let (a, b) = (world * w[0], world * w[1]); + let d = b - a; + let l2 = d.hypot2(); + let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let dist = (a.lerp(b, u) - p).hypot(); + if best.is_none_or(|(_, bd)| dist < bd) { + let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?); + best = Some((la + u * (lb - la), dist)); + } + } + let (along, dist) = best?; + let len = line.length(); + (len > 1e-9 && dist <= reach).then_some((id, along / len)) + }; + let mut candidates = Vec::new(); + path_candidates(doc, page, ev.p(), reach, 0, &mut candidates); + candidates.into_iter().find_map(near) + } + + /// A press with the Text on a path tool: on a path, a new empty text layer goes onto it, + /// starting where it was pressed, and typing starts. The text takes the path layer's place, + /// geometry, transform, constraints and effects, and its fills when one of them shows; the path + /// layer is removed. Ending the edit with nothing typed puts the path back without an undo step + /// ([`textedit::end`]). Elsewhere a press does nothing. + fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> { + if let Some(te) = self.ds()?.text_edit.clone() { + if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) }; + let Some(pn) = doc.node(path) else { return Ok(()) }; + let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) }; + let index = doc.index_in_parent(path); + self.begin_interaction("Text on a path")?; + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height); + n.name = String::new(); + n.transform = pn.transform; + n.constraints = pn.constraints; + n.child_layout = pn.child_layout.clone(); + n.effects = pn.effects.clone(); + n.styles.effect = pn.styles.effect.clone(); + // A path drawn with a stroke only has no fill to give: the text gets the usual one. + let filled = pn.fills.iter().any(|f| f.visible); + if filled { + n.fills = pn.fills.clone(); + n.styles.fill = pn.styles.fill.clone(); + } else if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + let carried = |k: &str| k.starts_with("effects/") || (filled && (k == "fill" || k.starts_with("fills/"))); + n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + if let NodeKind::Text(t) = &mut n.kind { + let mut tp = TextPath::new(&geom); + tp.start = start; + t.path = Some(Box::new(tp)); + } + d.insert(n, parent, index)?; + d.remove(path)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line /// tall) and its top-left, and that size is fixed. fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index e1ed336e..20a13346 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1401,6 +1401,7 @@ name = "English" "Text" = "Text" "Text align" = "Text align" "Text content" = "Text content" +"Text on a path" = "Text on a path" "Text size" = "Text size" "Text style" = "Text style" "Text styles" = "Text styles" diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 2e5af4cb..aeb35096 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -40,6 +40,7 @@ icons! { Pen, Pencil, Text, + TextOnPath, Comment, Actions, Chevron, @@ -561,6 +562,12 @@ fn draw(p: &P, icon: Icon) { p.line(11.5, 3.0, 11.5, 5.5); p.line(5.5, 12.5, 9.5, 12.5); } + TextOnPath => { + // A small T above the curve it follows. + p.line(4.5, 3.0, 11.5, 3.0); + p.line(8.0, 3.0, 8.0, 8.5); + p.poly(&[(2.0, 13.5), (5.0, 11.5), (8.0, 10.8), (11.0, 11.5), (14.0, 13.5)], false); + } Comment => { let mut b: Vec<(f32, f32)> = arc_pts(8.5, 7.5, 5.0, 2.75, TAU + 1.95).collect(); b.push((2.5, 13.5)); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index b95575b8..8f526acc 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -70,7 +70,7 @@ const GROUPS: &[Group] = &[ tr_noop("Drawing tools"), &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)], ), - Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text)]), + Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text), (ToolId::TextOnPath, Icon::TextOnPath)]), Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), ]; diff --git a/docs/mcp.md b/docs/mcp.md index 8361286d..6254b207 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -926,7 +926,12 @@ click or drag across regions with `pointer` to merge, add `alt` to remove; `shap them, `text.removeFromPath {ids?}` makes it plain auto-width text again. Resizing the layer scales the path. The text stays editable (`text.edit.*`, `type_text`); `get_node` shows `textPath {d (SVG path data in the layer's space), start, flip}`. Exports draw it as outlines - (PDF too: not selectable); underline and strikethrough aren't drawn on a path. + (PDF too: not selectable); underline and strikethrough aren't drawn on a path. With the tool + `tool.textOnPath`, a `pointer` press within 6 screen pixels of a shape's or vector's first + subpath puts a new, empty text layer on it, starting at the press, and starts typing: the text + also takes the path layer's constraints and effects, and its fills when one of them shows. Ending + the edit with nothing typed puts the path layer back with no undo step. Locked layers and layers + inside instances are skipped. - **Pattern fills**: `fill.pattern {source (layer id), ids?, index?, tile? (grid|horizontalHex| verticalHex), scale?, spacing? [x, y], align? (start|center|end), opacity?}` makes the fill at `index` (default the top one) the source layer repeated, at `scale` × its own size with `spacing` diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2b8c66be..adc90ba4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -88,7 +88,7 @@ interaction feel not yet compared. | 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 | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) | | Comment (C), Shift+C to show/hide | done | local only | -| Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | +| Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only | | Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | | Image crop handles | done | | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | The Text on a path tool (`tool.textOnPath`, in the Text flyout): a click on a shape's or vector's path puts new text on it, starting at the click, with the path's fill (when one shows) and effects, as the help center's Guide to text describes; the path layer is removed, and an edit left empty puts it back with no undo step (`draw_tools_tests`, `cursor_tests`) | | 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 | From 42fbe8f00c0a8a4fc80548d5ca5440847306b5ba Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sat, 24 Oct 2026 18:52:03 +0000 Subject: [PATCH 089/142] Skip zero-length segments when finding arrowhead direction Found while testing crates/pathops/src/lib.rs:352 (path_end, used by arrowhead) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/pathops/src/lib.rs | 69 ++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 26 deletions(-) diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index f4b30e10..46793f98 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -359,36 +359,35 @@ pub fn stroke_outline_of(path: &BezPath, props: &StrokeProps, closed_shape: bool /// End point and outward direction of an open path at its start or end. fn path_end(path: &BezPath, at_end: bool) -> Option<(Point, Vec2)> { - let segs: Vec = path.segments().collect(); - let seg = if at_end { segs.last()? } else { segs.first()? }; use kurbo::ParamCurve; use kurbo::ParamCurveDeriv; - let (p, d) = if at_end { - let d = match seg { - PathSeg::Line(l) => l.p1 - l.p0, - PathSeg::Quad(q) => q.deriv().eval(1.0).to_vec2(), - PathSeg::Cubic(c) => { - let d = c.deriv().eval(1.0).to_vec2(); - if d.hypot() < 1e-9 { c.p3 - c.p2 } else { d } - } + let dir_of = |seg: &PathSeg| -> Option<(Point, Vec2)> { + let (p, d) = if at_end { + let d = match seg { + PathSeg::Line(l) => l.p1 - l.p0, + PathSeg::Quad(q) => q.deriv().eval(1.0).to_vec2(), + PathSeg::Cubic(c) => { + let d = c.deriv().eval(1.0).to_vec2(); + if d.hypot() < 1e-9 { c.p3 - c.p2 } else { d } + } + }; + (seg.end(), d) + } else { + let d = match seg { + PathSeg::Line(l) => l.p0 - l.p1, + PathSeg::Quad(q) => -q.deriv().eval(0.0).to_vec2(), + PathSeg::Cubic(c) => { + let d = -c.deriv().eval(0.0).to_vec2(); + if d.hypot() < 1e-9 { c.p0 - c.p1 } else { d } + } + }; + (seg.start(), d) }; - (seg.end(), d) - } else { - let d = match seg { - PathSeg::Line(l) => l.p0 - l.p1, - PathSeg::Quad(q) => -q.deriv().eval(0.0).to_vec2(), - PathSeg::Cubic(c) => { - let d = -c.deriv().eval(0.0).to_vec2(); - if d.hypot() < 1e-9 { c.p0 - c.p1 } else { d } - } - }; - (seg.start(), d) + let len = d.hypot(); + (len >= 1e-12).then(|| (p, d / len)) }; - let len = d.hypot(); - if len < 1e-12 { - return None; - } - Some((p, d / len)) + // Skip zero-length segments (repeated vertices) and use the nearest one with a direction. + if at_end { path.segments().collect::>().iter().rev().find_map(dir_of) } else { path.segments().find_map(|s| dir_of(&s)) } } /// The filled arrowhead shape for `cap` at one end of an open path. @@ -742,4 +741,22 @@ mod tests { let s = stroke_outline_of(&ring, &inside, true, Some(&nz)); assert!(s.winding(material) != 0 && s.winding(hole) != 0); } + + #[test] + fn arrowhead_skips_zero_length_end_segments() { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.line_to((100.0, 0.0)); + assert!(arrowhead(&p, StrokeCap::TriangleArrow, 2.0, true).is_some()); + p.line_to((100.0, 0.0)); + let (tip, dir) = path_end(&p, true).expect("end direction"); + assert_eq!((tip, dir), (Point::new(100.0, 0.0), Vec2::new(1.0, 0.0))); + assert!(arrowhead(&p, StrokeCap::TriangleArrow, 2.0, true).is_some()); + let mut q = BezPath::new(); + q.move_to((0.0, 0.0)); + q.line_to((0.0, 0.0)); + q.line_to((100.0, 0.0)); + let (tip, dir) = path_end(&q, false).expect("start direction"); + assert_eq!((tip, dir), (Point::new(0.0, 0.0), Vec2::new(-1.0, 0.0))); + } } From 98d061df10c1ab150b7dba84bfa1416967a1a193 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 12:28:00 -0700 Subject: [PATCH 090/142] M4: text put on a path takes the path's fill and effects text.onPath, and the Design panel's Put text on path button that runs it, kept the text layer's own fill and effects when the text went onto a path. The help center's Guide to text says that the vector object's fill and effects are transferred to the text layer. The text now takes the path layer's fills, with their fill style and variable bindings, when one of them shows, and the path layer's effects, with their effect style and bindings, when it has any. The text's own bindings on those fields are dropped. When the path has no visible fill or no effects, the text keeps its own. --- crates/engine/src/cmd/text.rs | 18 ++++++++-- crates/engine/src/draw_tools_tests.rs | 49 +++++++++++++++++++++++++++ docs/mcp.md | 2 ++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a2f76115..2998885f 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -126,7 +126,9 @@ fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result { } /// Put a text layer on a path: the text takes the path layer's place, geometry and transform, and -/// the path layer is removed. +/// the path layer is removed. The path's effects move to the text when it has any, and its fills +/// when one of them shows, each with its style and variable bindings; otherwise the text keeps +/// its own. fn on_path(s: &mut Session, p: &Value) -> Result { const CMD: &str = "text.onPath"; let doc = s.doc()?; @@ -144,6 +146,10 @@ fn on_path(s: &mut Session, p: &Value) -> Result { } let (parent, index) = (pn.parent.ok_or_else(|| bad(CMD, "pages have no path"))?, doc.index_in_parent(path)); let (transform, w, h) = (pn.transform, pn.width, pn.height); + let fills = pn.fills.iter().any(|f| f.visible).then(|| (pn.fills.clone(), pn.styles.fill.clone())); + let effects = (!pn.effects.is_empty() || pn.styles.effect.is_some()).then(|| (pn.effects.clone(), pn.styles.effect.clone())); + let moved = |k: &str| (fills.is_some() && (k == "fill" || k.starts_with("fills/"))) || (effects.is_some() && k.starts_with("effects/")); + let bound: Vec<(String, VariableId)> = pn.bound.iter().filter(|(k, _)| moved(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); let mut tp = TextPath::new(&geom); tp.start = num(p, "start").filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); tp.flip = boolean(p, "flip").unwrap_or(false); @@ -153,6 +159,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(text)?; n.transform = transform; (n.width, n.height) = (w, h); + n.bound.retain(|k, _| !moved(k)); + n.bound.extend(bound); + if let Some((fills, style)) = fills { + (n.fills, n.styles.fill) = (fills, style); + } + if let Some((effects, style)) = effects { + (n.effects, n.styles.effect) = (effects, style); + } if let NodeKind::Text(t) = &mut n.kind { t.path = Some(Box::new(tp)); } @@ -165,7 +179,7 @@ pub fn specs() -> Vec { spec!( "text.onPath", "Text on path", - "{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and the path layer is removed → {id}", + "{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and takes its effects and its fills when one shows, and the path layer is removed → {id}", true, on_path ), diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index d67b0af4..c989a7b7 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -79,6 +79,55 @@ fn svg_text_paths_import_as_text_on_a_path() { assert_eq!(t.align_h, figcraft_doc::TextAlignH::Center); } +/// A text layer with its own green fill bound to `ink` and a layer blur, and a curve; the +/// variables `ink` and `shadow` exist. +fn text_and_curve(s: &mut Session, path_props: Value) -> (NodeId, NodeId) { + let t = id(s.execute("text.create", &json!({"text": "Around", "x": 0, "y": 0})).unwrap()); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100, "props": path_props})).unwrap()); + s.execute("variable.createCollection", &json!({"name": "Paint"})).unwrap(); + for (name, value) in [("ink", "#00FF00"), ("shadow", "#FF000080")] { + s.execute("variable.create", &json!({"collection": "Paint", "name": name, "type": "color", "value": value})).unwrap(); + } + s.execute("variable.bind", &json!({"ids": [t], "field": "fills/0/color", "variable": "ink"})).unwrap(); + s.execute("effect.add", &json!({"ids": [t], "type": "layerBlur"})).unwrap(); + (t, v) +} + +#[test] +fn text_put_on_a_filled_path_takes_its_fill_and_effects() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fill": "#FF0000"})); + let brand = s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": "#3366FF"})).unwrap()["id"].clone(); + s.execute("style.apply", &json!({"ids": [v], "style": brand})).unwrap(); + s.execute("effect.add", &json!({"ids": [v], "type": "dropShadow"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [v], "field": "effects/0/color", "variable": "shadow"})).unwrap(); + let path = s.doc().unwrap().node(v).unwrap().clone(); + let text = s.doc().unwrap().node(t).unwrap().clone(); + assert!(path.styles.fill.is_some() && path.bound.contains_key("effects/0/color")); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + // "the vector object's fill and effects are automatically transferred to the text layer" + assert_eq!((&n.fills, &n.styles.fill), (&path.fills, &path.styles.fill), "the path's fill and its style"); + assert_eq!((&n.effects, &n.styles.effect), (&path.effects, &path.styles.effect), "the path's effects"); + assert_eq!(n.bound, path.bound, "the path's bindings replace the text's fill binding"); + s.execute("edit.undo", &json!({})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!((&n.fills, &n.styles.fill), (&text.fills, &text.styles.fill), "undo gives the text its own fill back"); + assert_eq!((&n.effects, &n.styles.effect), (&text.effects, &text.styles.effect), "undo gives the text its own effects back"); + assert_eq!(n.bound, text.bound, "undo gives the text its own fill binding back"); +} + +#[test] +fn text_put_on_a_stroke_only_path_keeps_its_own_fill_and_effects() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fills": []})); + let text = s.doc().unwrap().node(t).unwrap().clone(); + assert!(!text.fills.is_empty() && !text.effects.is_empty()); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!((&n.fills, &n.effects, &n.bound), (&text.fills, &text.effects, &text.bound), "a path with no fill or effects to give"); +} + #[test] fn pattern_fills_tile_their_source_live() { let mut s = Session::new(); diff --git a/docs/mcp.md b/docs/mcp.md index 8361286d..65e3669e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -919,6 +919,8 @@ click or drag across regions with `pointer` to merge, add `alt` to remove; `shap - **Text on a path**: `text.onPath {text?, path?, start?, flip?}` (default: the selected text layer and the other selected layer) puts a text layer on a vector's or shape's path: the text takes the path layer's place, transform and box, and the path layer is removed (`edit.undo` brings it back). + The text also takes the path layer's effects when it has any, and its fills when one of them + shows, each with its style and variable bindings; otherwise the text keeps its own. The baseline follows the path from `start` (0..1 of its length); the text's horizontal alignment places it there (left starts, centre centres, right ends at it); `flip` runs it on the other side the other way. Glyphs past an open path's end are hidden; a closed path (ellipse, rectangle) wraps From 949d6c2830344162fb302baa558dff906b821c8b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 13:51:32 -0700 Subject: [PATCH 091/142] M4: text on a path keeps its effects when the path's effect style has none A path linked to an effect style with no effects gave the text an empty effect list and the style link. The text now takes the path's effects only when there are any. --- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/draw_tools_tests.rs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 2998885f..0334a72a 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -147,7 +147,7 @@ fn on_path(s: &mut Session, p: &Value) -> Result { let (parent, index) = (pn.parent.ok_or_else(|| bad(CMD, "pages have no path"))?, doc.index_in_parent(path)); let (transform, w, h) = (pn.transform, pn.width, pn.height); let fills = pn.fills.iter().any(|f| f.visible).then(|| (pn.fills.clone(), pn.styles.fill.clone())); - let effects = (!pn.effects.is_empty() || pn.styles.effect.is_some()).then(|| (pn.effects.clone(), pn.styles.effect.clone())); + let effects = (!pn.effects.is_empty()).then(|| (pn.effects.clone(), pn.styles.effect.clone())); let moved = |k: &str| (fills.is_some() && (k == "fill" || k.starts_with("fills/"))) || (effects.is_some() && k.starts_with("effects/")); let bound: Vec<(String, VariableId)> = pn.bound.iter().filter(|(k, _)| moved(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); let mut tp = TextPath::new(&geom); diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index c989a7b7..d33a48ff 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -128,6 +128,20 @@ fn text_put_on_a_stroke_only_path_keeps_its_own_fill_and_effects() { assert_eq!((&n.fills, &n.effects, &n.bound), (&text.fills, &text.effects, &text.bound), "a path with no fill or effects to give"); } +#[test] +fn text_put_on_a_path_with_an_empty_effect_style_keeps_its_own_effects() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fills": []})); + let none = s.execute("style.create", &json!({"type": "effect", "name": "None", "value": []})).unwrap()["id"].clone(); + s.execute("style.apply", &json!({"ids": [v], "style": none})).unwrap(); + let path = s.doc().unwrap().node(v).unwrap().clone(); + assert!(path.effects.is_empty() && path.styles.effect.is_some()); + let text = s.doc().unwrap().node(t).unwrap().clone(); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!((&n.effects, &n.styles.effect), (&text.effects, &text.styles.effect), "a path whose effect style draws no effects"); +} + #[test] fn pattern_fills_tile_their_source_live() { let mut s = Session::new(); From e46825b1cbc557521931aaaf9ff62c393fcf9a23 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 13:51:31 -0700 Subject: [PATCH 092/142] M4: Text on a path checks masks only for layers that give a candidate The hover search now recurses into each child first and looks up its mask only when the child added a candidate, as layers_at in crates/doc/src/geometry.rs does. --- crates/engine/src/tools.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index ae97f0e8..63b10558 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -663,8 +663,10 @@ fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usiz return; } for c in n.paint_order().rev() { - if !figcraft_doc::geometry::masked_out(doc, n, c, p) { - path_candidates(doc, c, p, reach, depth + 1, out); + let before = out.len(); + path_candidates(doc, c, p, reach, depth + 1, out); + if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) { + out.truncate(before); } } if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) { From f4d3f796d66dd04ff9a2aa19a88d3aaf10641daa Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 24 Oct 2026 22:58:07 +0200 Subject: [PATCH 093/142] Dialogs start in the folder of the last file picked (fixes #1290) --- ROADMAP.md | 1 + apps/figcraft/src/services.rs | 60 ++++++++++++++++++++++++++--- apps/figcraft/src/services_tests.rs | 25 ++++++++++++ 3 files changed, 80 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 69d3bf73..a89790a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. - **2026-10-24** · 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. diff --git a/apps/figcraft/src/services.rs b/apps/figcraft/src/services.rs index 7ee41ada..d9cd53ea 100644 --- a/apps/figcraft/src/services.rs +++ b/apps/figcraft/src/services.rs @@ -2,8 +2,8 @@ use std::cell::RefCell; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use figcraft_engine::{Prefs, Session}; use figcraft_ui_egui::{DialogFuture, FigApp, Services}; @@ -20,6 +20,8 @@ pub struct Desktop { language: Option, /// Recovery copies of unsaved files (written at once after a panic). recovery: crate::recovery::Shared, + /// The folder of the last file or folder a dialog picked, where the next one starts. + last_folder: Arc>>, } pub fn config_dir() -> Option { @@ -54,7 +56,15 @@ impl Desktop { let reduce_motion = Arc::new(AtomicBool::new(false)); let flag = reduce_motion.clone(); std::thread::spawn(move || flag.store(system_reduce_motion(), Ordering::Relaxed)); - Self { clipboard: arboard::Clipboard::new().ok(), dir, prefs, reduce_motion, language: system_language(), recovery } + Self { + clipboard: arboard::Clipboard::new().ok(), + dir, + prefs, + reduce_motion, + language: system_language(), + recovery, + last_folder: Arc::default(), + } } } @@ -262,19 +272,57 @@ fn save_filters<'a>(exts: &'a [&'a str]) -> Vec<(&'static str, &'a [&'a str])> { } } +/// The folder to start the next dialog in once `path` was picked: the file's folder, or the folder +/// picked itself. +pub(crate) fn folder_to_remember(path: &Path, is_folder: bool) -> Option { + let folder = if is_folder { Some(path) } else { path.parent() }; + folder.filter(|f| !f.as_os_str().is_empty()).map(Path::to_path_buf) +} + +/// The remembered folder, unless it is gone since: then the system's own choice. +pub(crate) fn start_folder(last: Option) -> Option { + last.filter(|f| f.is_dir()) +} + +impl Desktop { + /// A dialog that starts in the last folder used. The Linux portal does not remember it between + /// dialogs as macOS and Windows do. + fn dialog_in_last_folder(&self, title: &str) -> rfd::AsyncFileDialog { + let dialog = dialog(title); + match start_folder(self.last_folder.lock().ok().and_then(|l| l.clone())) { + Some(folder) => dialog.set_directory(folder), + None => dialog, + } + } +} + +/// `pick`, with the folder of what it picks kept in `last` for the next dialog. +pub(crate) fn remembering(last: Arc>>, pick: DialogFuture, is_folder: bool) -> DialogFuture { + Box::pin(async move { + let path = pick.await?; + if let (Some(folder), Ok(mut last)) = (folder_to_remember(&path, is_folder), last.lock()) { + *last = Some(folder); + } + Some(path) + }) +} + impl Services for Desktop { fn pick_open(&mut self, title: &str, exts: &[&str]) -> DialogFuture { - picked(dialog(title).add_filter("Supported files", exts).pick_file()) + let pick = picked(self.dialog_in_last_folder(title).add_filter("Supported files", exts).pick_file()); + remembering(self.last_folder.clone(), pick, false) } fn pick_save(&mut self, title: &str, name: &str, exts: &[&str]) -> DialogFuture { - let mut dialog = dialog(title).set_file_name(name); + let mut dialog = self.dialog_in_last_folder(title).set_file_name(name); for (label, extensions) in save_filters(exts) { dialog = dialog.add_filter(label, extensions); } - picked(dialog.save_file()) + let pick = picked(dialog.save_file()); + remembering(self.last_folder.clone(), pick, false) } fn pick_folder(&mut self, title: &str) -> DialogFuture { - picked(dialog(title).pick_folder()) + let pick = picked(self.dialog_in_last_folder(title).pick_folder()); + remembering(self.last_folder.clone(), pick, true) } fn clipboard_set_text(&mut self, s: &str) { if let Some(c) = self.clipboard.as_mut() { diff --git a/apps/figcraft/src/services_tests.rs b/apps/figcraft/src/services_tests.rs index 2abec5bb..970f1729 100644 --- a/apps/figcraft/src/services_tests.rs +++ b/apps/figcraft/src/services_tests.rs @@ -117,3 +117,28 @@ fn quitting_keeps_the_preferences_and_ends_the_live_sessions() { assert_eq!(start(&dir).session.prefs.user_name, "Ada", "the preferences were kept"); let _ = std::fs::remove_dir_all(&dir); } + +/// #1290: the Linux portal opens every dialog where it likes, so the app keeps the folder itself. +#[test] +fn the_next_dialog_starts_in_the_folder_of_the_last_pick() { + use crate::services::{folder_to_remember, remembering, start_folder}; + use std::path::PathBuf; + let dir = root("last-folder"); + let file = dir.join("Poster.figcraft"); + assert_eq!(folder_to_remember(&file, false), Some(dir.clone()), "a file: its folder"); + assert_eq!(folder_to_remember(&dir, true), Some(dir.clone()), "a folder pick: the folder itself"); + assert_eq!(folder_to_remember(std::path::Path::new("Poster.figcraft"), false), None, "a bare name has no folder"); + + let last = Arc::new(Mutex::new(None::)); + let mut pick = remembering(last.clone(), Box::pin(std::future::ready(Some(file.clone()))), false); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert_eq!(pick.as_mut().poll(&mut cx), std::task::Poll::Ready(Some(file))); + assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a pick is remembered"); + let mut cancelled = remembering(last.clone(), Box::pin(std::future::ready(None)), false); + assert_eq!(cancelled.as_mut().poll(&mut cx), std::task::Poll::Ready(None)); + assert_eq!(*last.lock().unwrap(), Some(dir.clone()), "a cancelled dialog keeps the folder"); + + assert_eq!(start_folder(Some(dir.clone())), Some(dir.clone())); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(start_folder(Some(dir)), None, "a folder deleted since: the system's own"); +} From 2c38768872fc3881e4f40b1bea3c8e70b8005146 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:01:22 -0700 Subject: [PATCH 094/142] M6: a layer drag or eye and padlock sweep that ends in Dev Mode changes nothing A drag or sweep started in Design and released after switching to Dev Mode leaves the layers as they were and shows the Dev Mode note. --- crates/ui-egui/src/devmode_tail_tests.rs | 25 ++++++++++++++++++++++++ crates/ui-egui/src/layers.rs | 10 ++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs index 5845dcdc..89d68603 100644 --- a/crates/ui-egui/src/devmode_tail_tests.rs +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -164,6 +164,31 @@ fn dev_mode_layers_panel_selects_and_expands_but_edits_nothing() { click(&mut u, ra); click(&mut u, ra); assert!(u.app.ui.rename.is_some_and(|r| r.id == a)); + // A drag started in the design and dropped after switching to Dev Mode doesn't reorder either. + u.app.ui.rename = None; + rest(&mut u); + u.app.ui.toast = None; + let rf = u.row_at(f); + u.drag_to(ra, rf); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + u.button(rf, false); + rest(&mut u); + assert_eq!(order(&u), before, "not reordered after the switch"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D")), "the drop says why"); + // So does a sweep down the eye column. + u.app.ui.right_tab = crate::RightTab::Design; + rest(&mut u); + u.app.ui.toast = None; + let (ea, eb) = (Pos2::new(eye, u.row_at(a).y), Pos2::new(eye, u.row_at(b).y)); + u.drag_to(ea, eb); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + u.button(eb, false); + rest(&mut u); + let doc = u.app.session.doc().unwrap(); + assert!(doc.node(a).unwrap().visible && doc.node(b).unwrap().visible, "not hidden after the switch"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D")), "the sweep says why"); } #[test] diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 53a4e89d..ffb74aef 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -1080,7 +1080,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.primary_down())); if released { let (cmd, key) = if sw.lock { ("object.toggleLock", "locked") } else { ("object.toggleVisible", "visible") }; - app.run(cmd, json!({"ids": sw.ids, key: if sw.lock { sw.on } else { !sw.on }})); + // A sweep started in the design can end after a switch to Dev Mode. + if !crate::menus::dev_mode_refuses(app, cmd) { + app.run(cmd, json!({"ids": sw.ids, key: if sw.lock { sw.on } else { !sw.on }})); + } } // A sweep whose release the panel never saw (it wasn't shown) is dropped. if released || !down { @@ -1094,7 +1097,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { d.remove::(drag_id); d.remove::<(NodeId, f64)>(Id::new("layer_drag_hold")); }); - if let (Some(_), Some((target, zone))) = (dragging, drop) { + // A drag started in the design can end after a switch to Dev Mode. + if let (Some(_), Some((target, zone))) = (dragging, drop) + && !crate::menus::dev_mode_refuses(app, "node.reparent") + { let ids = moving.clone(); match zone { 0 => { From 0978b44385c2908f2573dcec5fedc84b9090dcd0 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 14:02:50 -0700 Subject: [PATCH 095/142] M4: text on a path takes a fill binding made through `fills` The bindings that move with the path's fills and effects are now found with list_binding, which reads `fills` as the first fill's color, as style_sets in cmd/styles.rs does. --- crates/engine/src/cmd/text.rs | 6 +++++- crates/engine/src/draw_tools_tests.rs | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0334a72a..813a6036 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -148,7 +148,11 @@ fn on_path(s: &mut Session, p: &Value) -> Result { let (transform, w, h) = (pn.transform, pn.width, pn.height); let fills = pn.fills.iter().any(|f| f.visible).then(|| (pn.fills.clone(), pn.styles.fill.clone())); let effects = (!pn.effects.is_empty()).then(|| (pn.effects.clone(), pn.styles.effect.clone())); - let moved = |k: &str| (fills.is_some() && (k == "fill" || k.starts_with("fills/"))) || (effects.is_some() && k.starts_with("effects/")); + let moved = |k: &str| match super::lists::list_binding(k) { + Some((super::lists::BoundList::Fills, ..)) => fills.is_some(), + Some((super::lists::BoundList::Effects, ..)) => effects.is_some(), + _ => false, + }; let bound: Vec<(String, VariableId)> = pn.bound.iter().filter(|(k, _)| moved(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); let mut tp = TextPath::new(&geom); tp.start = num(p, "start").filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index d33a48ff..726ee884 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -142,6 +142,20 @@ fn text_put_on_a_path_with_an_empty_effect_style_keeps_its_own_effects() { assert_eq!((&n.effects, &n.styles.effect), (&text.effects, &text.styles.effect), "a path whose effect style draws no effects"); } +#[test] +fn text_put_on_a_path_takes_a_fill_binding_made_through_fills() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fill": "#FF0000"})); + s.execute("variable.bind", &json!({"ids": [v], "field": "fills", "variable": "shadow"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [t], "field": "fills", "variable": "ink"})).unwrap(); + let path = s.doc().unwrap().node(v).unwrap().clone(); + assert!(path.bound.contains_key("fills")); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!(n.bound, path.bound, "the path's `fills` binding replaces the text's"); + assert_eq!(n.fills, path.fills); +} + #[test] fn pattern_fills_tile_their_source_live() { let mut s = Session::new(); From ae57bf84d0d77c7df8c5b96ab2740e2c91939ee6 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 13:47:42 -0700 Subject: [PATCH 096/142] M5: a notice before a prototype's Open link leaves for a website The player opened a prototype's Open link at once, also from an After delay trigger with no click. The help center's Prototype actions page says that the viewer is told they are leaving before an external website opens, with a checkbox that skips this page on the current device. An Open link to an http or https address now shows a notice with the link, Open link, Cancel and "Don't show this again". Open link opens it; Cancel, the close button or Escape drops it. While the notice shows, the prototype takes no keys, and the presenter's C, Shift+C and F keys do nothing. When Open link is pressed with the box ticked, the desktop app saves the choice with its preferences; the web build keeps it until the page reloads. proto.linkNotice {show} turns the notice on or off. A mail link opens at once, as before, also while the notice shows. A second link to a website that fires while the notice shows is dropped. --- crates/engine/src/cmd/proto.rs | 11 ++ crates/engine/src/lib.rs | 4 + crates/ui-egui/locales/en.toml | 3 + crates/ui-egui/src/present.rs | 67 ++++++++++- crates/ui-egui/src/present_links_tests.rs | 140 ++++++++++++++++++++-- docs/control-protocol.md | 3 +- docs/mcp.md | 5 +- docs/prototyping-parity.md | 2 + 8 files changed, 218 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index be6907b5..23e24aac 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -155,6 +155,17 @@ pub fn specs() -> Vec { ok() } ), + spec!( + "proto.linkNotice", + "Notice before leaving for a link", + "{show? (default true; false skips it)} whether a presented prototype's Open link to a website first says that it leaves the prototype, with Open link and Cancel; kept with the preferences → {shown}", + false, + |s, p| { + let show = boolean(p, "show").unwrap_or(true); + s.prefs.link_notice_skipped = !show; + Ok(json!({"shown": show})) + } + ), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2ac9da8..99b0f6f2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -691,6 +691,9 @@ pub struct Prefs { /// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them /// again). pub getting_started_dismissed: bool, + /// A prototype's Open link goes to a website without first telling the viewer that it leaves + /// the prototype (the notice's "Don't show this again"; `proto.linkNotice` shows it again). + pub link_notice_skipped: bool, /// How each home screen section shows its files, by section id (`"recents"`): `"grid"` (cards /// with the picture each file carries) or `"list"` (a table). Missing: the grid. #[serde(skip_serializing_if = "BTreeMap::is_empty")] @@ -740,6 +743,7 @@ impl Default for Prefs { recent_files: Vec::new(), online_files: Vec::new(), getting_started_dismissed: false, + link_notice_skipped: false, home_views: BTreeMap::new(), home_sort: "modified".into(), home_order: "desc".into(), diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index e1ed336e..89465e13 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -419,6 +419,7 @@ name = "English" "Distribute vertical spacing" = "Distribute vertical spacing" "Divider" = "Divider" "Don't Save" = "Don't Save" +"Don't show this again" = "Don't show this again" "Don't truncate" = "Don't truncate" "Done" = "Done" "Done editing" = "Done editing" @@ -721,6 +722,7 @@ name = "English" "Layout grids" = "Layout grids" "Leave session" = "Leave session" "Leaves" = "Leaves" +"Leaving the prototype" = "Leaving the prototype" "Left" = "Left" "Left & right" = "Left & right" "Left padding" = "Left padding" @@ -1435,6 +1437,7 @@ name = "English" "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." "This frame isn't on the branch." = "This frame isn't on the branch." +"This link opens a website outside the prototype:" = "This link opens a website outside the prototype:" "This server is open to the public: no key needed." = "This server is open to the public: no key needed." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." "This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced." diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index fae1e1a7..5f719dca 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -288,6 +288,9 @@ pub struct Presenter { /// The layer the Inspect panel shows (the last one under the pointer). inspected: Option, urls: Vec, + /// A link to a website waiting for the viewer to open it ([`link_notice`]), and whether "Don't + /// show this again" is ticked. + link_notice: Option<(String, bool)>, /// The editor's document as last copied (the player copies it again when it changes). source: Option>, /// Scrolling by dragging: what it scrolls and its offset when the drag began. @@ -368,6 +371,7 @@ impl Presenter { clock: 0.0, inspected: None, urls: Vec::new(), + link_notice: None, source: None, drag_scroll: None, view: PresentView::default(), @@ -630,6 +634,7 @@ impl Presenter { "zoom": self.zoom, "flinging": self.fling.is_some(), "gamepad": self.uses_pads, + "linkNotice": self.link_notice.as_ref().map(|(url, _)| url), }) } @@ -1693,8 +1698,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } poll_pads(app, &mut pres, &ctx); let mut action = None; - // Typing a reply to a comment keeps the keys from the prototype; Shift+C shows the comments. - let typing = ctx.egui_wants_keyboard_input(); + // Typing a reply to a comment keeps the keys from the prototype, and so does the link notice + // while it shows; Shift+C shows the comments. + let typing = ctx.egui_wants_keyboard_input() || pres.link_notice.is_some(); if !typing && ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::C)) { action = Some(BarAction::Comments); } @@ -1734,6 +1740,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { app.ui.comments.close(); } finish(app, &mut pres); + link_notice(app, &mut pres, &ctx, Id::new("present_link_notice")); // The options carry over to the next presentation. app.ui.present_view = pres.view; if live && let Some(BarAction::Responsive(on)) = action { @@ -1879,16 +1886,61 @@ fn poll_pads(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context) { crate::repaint_in(ctx, PAD_POLL_SECS); } -/// After a player's frame: the links it opens and the widget clicks it took. +/// After a player's frame: the links it opens and the widget clicks it took. A link to a website +/// waits in [`link_notice`] unless the viewer chose to skip the notice; a second link to a website +/// that fires while the notice shows is dropped. fn finish(app: &mut FigApp, p: &mut Presenter) { for u in p.urls.drain(..) { - app.services.open_url(&u); + if app.session.prefs.link_notice_skipped || !goes_to_a_website(&u) { + app.services.open_url(&u); + } else if p.link_notice.is_none() { + p.link_notice = Some((u, false)); + } } if let Some(e) = p.click_widgets(&app.session) { app.toast(e); } } +/// Whether `url` is an `http` or `https` link (a `mailto:` link opens the mail app instead). +fn goes_to_a_website(url: &str) -> bool { + url.trim().split_once(':').is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) +} + +/// The notice before a link leaves the prototype for a website: the link, "Don't show this +/// again" (saved with the preferences when Open link is pressed), Open link and Cancel. Escape or +/// the close button cancels. +fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id) { + let Some((url, skip)) = p.link_notice.as_mut() else { return }; + let t = Tokens::get(ctx); + let (mut open, mut go, mut cancel) = (true, false, false); + crate::dialogs::window(tr("Leaving the prototype"), ctx).id(id).open(&mut open).show(ctx, |ui| { + ui.set_width(340.0); + widgets::label(ui, tr("This link opens a website outside the prototype:"), t.text); + ui.add(egui::Label::new(egui::RichText::new(url.as_str()).font(regular(11.0)).color(t.text_secondary)).wrap()); + ui.add_space(8.0); + if widgets::checkbox(ui, *skip, tr("Don't show this again")) { + *skip = !*skip; + } + ui.add_space(12.0); + ui.horizontal(|ui| { + go = widgets::button(ui, tr("Open link"), true, true).clicked(); + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); + }); + }); + cancel |= !open || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if go { + let Some((url, skip)) = p.link_notice.take() else { return }; + if skip { + app.run("proto.linkNotice", json!({"show": false})); + app.services.save_prefs(&app.session.prefs); + } + app.services.open_url(&url); + } else if cancel { + p.link_notice = None; + } +} + /// The width of the presenter's Inspect panel. const INSPECT_W: f32 = 280.0; @@ -1993,6 +2045,7 @@ pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) { action = player(&mut pane.player, ui, rect, now, chrome); }); finish(app, &mut pane.player); + link_notice(app, &mut pane.player, ctx, pane_id().with("link_notice")); match action { Some(BarAction::Close) => {} Some(BarAction::Present) => { @@ -2167,8 +2220,10 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) let resp = ui.interact(area, ui.id().with("present_area"), Sense::click_and_drag()); let over = resp.contains_pointer(); if let Some(l) = layout(p, area, chrome) { - // The pane only takes the keyboard while the pointer is over it. - input(p, &ctx, &l, over, chrome.keys && (!chrome.pane || over), chrome.pane, now); + // The pane only takes the keyboard while the pointer is over it, and neither takes it + // while a link notice shows (Escape there cancels the notice). + let keys = chrome.keys && p.link_notice.is_none() && (!chrome.pane || over); + input(p, &ctx, &l, over, keys, chrome.pane, now); } let Some(l) = layout(p, area, chrome) else { return action }; p.shown = Some((p.current, l.place, l.clip.intersect(area))); diff --git a/crates/ui-egui/src/present_links_tests.rs b/crates/ui-egui/src/present_links_tests.rs index 8223890c..2fc816cc 100644 --- a/crates/ui-egui/src/present_links_tests.rs +++ b/crates/ui-egui/src/present_links_tests.rs @@ -1,7 +1,9 @@ //! A prototype's Open link action carries a URL from the document and fires from whatever trigger //! the prototype sets, including a delay, so the player hands the opener only a web link //! ([`figcraft_engine::doc_urls`]). Without that, a saved document opens `javascript:` in the web -//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. +//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. A link to a +//! website then waits behind a notice that it leaves the prototype, unless the viewer chose to skip +//! the notice. use std::sync::{Arc, Mutex}; @@ -11,9 +13,9 @@ use serde_json::{Value, json}; use crate::Services; use crate::present_tests::{Ui as Harness, id}; -/// Records every URL the player asks the platform to open. +/// Records every URL the player asks the platform to open, and every save of the preferences. #[derive(Default)] -struct Opened(Arc>>); +struct Opened(Arc>>, Arc>>); impl Services for Opened { fn open_url(&mut self, url: &str) { @@ -21,15 +23,28 @@ impl Services for Opened { v.push(url.to_string()); } } + + fn save_prefs(&mut self, prefs: &figcraft_engine::Prefs) { + if let Ok(mut v) = self.1.lock() { + v.push(prefs.clone()); + } + } } fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) } -/// A presented frame whose layer opens `url` 100ms after the screen appears — no click anywhere. -fn opens_after_a_delay(url: &str) -> Vec { +/// A presented frame whose layer opens `url` 100ms after the screen appears, with no click +/// anywhere, and with the notice skipped or not. Returns the harness and what the player opened. +fn presented(url: &str, skip_notice: bool) -> (Harness, Opened) { + played("proto.present", url, skip_notice) +} + +/// [`presented`], played by `cmd` (`proto.present` or `proto.preview`). +fn played(cmd: &str, url: &str, skip_notice: bool) -> (Harness, Opened) { let mut s = Session::new(); + s.prefs.link_notice_skipped = skip_notice; let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"}))); let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": f}))); ex( @@ -37,19 +52,126 @@ fn opens_after_a_delay(url: &str) -> Vec { "proto.interaction.add", json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": {"type": "openUrl", "url": url}, "transition": {"type": "instant"}}), ); - let seen = Arc::new(Mutex::new(Vec::new())); + let seen = Opened::default(); let mut u = Harness::new(s); - u.app.services = Box::new(Opened(seen.clone())); - u.run("proto.present", json!({"start": f})); + u.app.services = Box::new(Opened(seen.0.clone(), seen.1.clone())); + u.ctx.enable_accesskit(); + u.run(cmd, json!({"start": f})); // Well past the delay. u.frames(40, Vec::new); - seen.lock().map(|v| v.clone()).unwrap_or_default() + (u, seen) +} + +fn opened(seen: &Opened) -> Vec { + seen.0.lock().map(|v| v.clone()).unwrap_or_default() +} + +fn opens_after_a_delay(url: &str) -> Vec { + opened(&presented(url, true).1) +} + +/// The URL the presenter's notice waits to open. +fn notice(u: &Harness) -> Value { + u.app.inspect()["presenter"]["linkNotice"].clone() +} + +/// Clicks the control labeled `label`. +fn click(u: &mut Harness, label: &str) { + let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; + let n = tree.into_iter().map(|(_, n)| n).find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no {label:?}")); + let b = n.bounds().unwrap(); + let at = egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32); + u.button(at, true); + u.button(at, false); } #[test] fn a_prototype_opens_web_links() { assert_eq!(opens_after_a_delay("https://example.com/a"), ["https://example.com/a"]); assert_eq!(opens_after_a_delay("mailto:a@b.c"), ["mailto:a@b.c"]); + // A mail link opens the mail app without the notice. + assert_eq!(opened(&presented("mailto:a@b.c", false).1), ["mailto:a@b.c"]); +} + +#[test] +fn a_link_to_a_website_waits_for_the_notice() { + let (mut u, seen) = presented("https://example.com/a", false); + assert!(opened(&seen).is_empty(), "the link waits for the viewer"); + assert_eq!(notice(&u), "https://example.com/a"); + click(&mut u, "Open link"); + assert_eq!(opened(&seen), ["https://example.com/a"]); + assert!(notice(&u).is_null()); + assert!(!u.app.session.prefs.link_notice_skipped && seen.1.lock().unwrap().is_empty(), "the notice shows next time"); +} + +#[test] +fn the_preview_pane_shows_the_notice_too() { + let (mut u, seen) = played("proto.preview", "https://example.com/a", false); + assert!(opened(&seen).is_empty()); + assert_eq!(u.app.inspect()["preview"]["linkNotice"], "https://example.com/a"); + click(&mut u, "Open link"); + assert_eq!(opened(&seen), ["https://example.com/a"]); +} + +#[test] +fn cancel_or_escape_drops_the_link() { + let (mut u, seen) = presented("https://example.com/a", false); + click(&mut u, "Cancel"); + assert!(notice(&u).is_null() && opened(&seen).is_empty()); + let (mut u, seen) = presented("https://example.com/a", false); + u.key(egui::pos2(700.0, 450.0), egui::Key::Escape); + assert!(notice(&u).is_null() && opened(&seen).is_empty()); + assert!(u.app.ui.present.is_some(), "Escape closes the notice, not the presentation"); +} + +#[test] +fn the_prototype_takes_no_keys_while_the_notice_shows() { + let mut s = Session::new(); + let a = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"}))); + let b = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 400, "name": "B"}))); + let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": a}))); + let link = json!({"type": "openUrl", "url": "https://example.com/a"}); + ex(&mut s, "proto.interaction.add", json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": link})); + let to_b = json!({"type": "navigate", "dest": b}); + ex(&mut s, "proto.interaction.add", json!({"id": a, "trigger": {"type": "keyPress", "key": "Escape"}, "action": to_b})); + let mut u = Harness::new(s); + u.app.services = Box::new(Opened::default()); + u.run("proto.present", json!({"start": a})); + u.frames(40, Vec::new); + assert_eq!(notice(&u), "https://example.com/a"); + u.key(egui::pos2(700.0, 450.0), egui::Key::Escape); + assert!(notice(&u).is_null(), "Escape cancels the notice"); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "the prototype's Escape trigger did not run"); +} + +#[test] +fn the_presenter_keys_wait_while_the_notice_shows() { + let (mut u, _) = presented("https://example.com/a", false); + assert_eq!(notice(&u), "https://example.com/a"); + let press = |key, modifiers| { + let k = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + vec![k(true), k(false)] + }; + let out = u.frame_output(press(egui::Key::F, egui::Modifiers::NONE)); + let cmds = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default(); + assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Fullscreen(_))), "F: {cmds:?}"); + u.frame(press(egui::Key::C, egui::Modifiers::NONE)); + assert_eq!(u.app.inspect()["presenter"]["comments"], false, "C"); + u.frame(press(egui::Key::C, egui::Modifiers::SHIFT)); + assert_eq!(u.app.inspect()["presenter"]["comments"], false, "Shift+C"); + assert_eq!(notice(&u), "https://example.com/a"); +} + +#[test] +fn dont_show_this_again_is_saved_with_the_preferences() { + let (mut u, seen) = presented("https://example.com/a", false); + click(&mut u, "Don't show this again"); + click(&mut u, "Open link"); + assert_eq!(opened(&seen), ["https://example.com/a"]); + assert!(u.app.session.prefs.link_notice_skipped); + assert!(seen.1.lock().unwrap().last().is_some_and(|p| p.link_notice_skipped), "the choice is saved"); + assert_eq!(u.run("proto.linkNotice", json!({})), json!({"shown": true})); + assert!(!u.app.session.prefs.link_notice_skipped); } #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f86ce764..c617f0ab 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -167,7 +167,8 @@ far the view is panned (`pan`, `panRange`), whether a scroll is still coasting ( active screen's `hotspots` (`[{id, rect: [x0, y0, x1, y1]}]` in window points, bottom-most first), whether a click where nothing reacts flashes them (`hotspotHints`) and they are flashing now (`hotspotFlash`), and whether the Inspect panel shows (`inspect`) with the layer it shows -(`inspected`) (`null` when nothing plays). `presentOptions` holds the options the next presentation +(`inspected`), and the link an Open link notice waits to open (`linkNotice`, else `null`) (`null` +when nothing plays). `presentOptions` holds the options the next presentation starts with (`scale`, `deviceFrame`, `comments`, `hotspotHints`, `inspect`). `textPreview` is true while a live text preview shows on the canvas (a hovered text style or named instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)). diff --git a/docs/mcp.md b/docs/mcp.md index 8361286d..0f2c6f73 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -708,7 +708,10 @@ device, whether a click where nothing reacts briefly flashes the screen's hotspo interactions, on by default; also in the presenter's Options menu) and whether its Inspect panel shows the size, colours and typography of the layer under the pointer (also the Inspect button in its bar); the options are kept for later presentations (in the presenter Z cycles them and R -restarts). The player's state is +restarts). An Open link to an `http` or `https` address first shows a notice that the link leaves +the prototype, with Open link, Cancel and "Don't show this again"; `proto.linkNotice {show?}` +turns the notice on (the default) or off, kept with the desktop app's preferences (the web build +keeps it until the page reloads). The player's state is in `inspect_ui` (`presenter`, `preview`: screen, scroll, `scale`, `zoom`, `pan`, the screen's `hotspots` and whether they are flashing, the `inspected` layer) when attached, and `ui.set {presentScroll: [x, y]}` scrolls its screen (see [control-protocol.md](control-protocol.md)). diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index 2ded779d..79ecdd3c 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -93,6 +93,7 @@ case pins the current behaviour). | 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 | | +| Open link: a notice before a website opens, with a checkbox to skip it on this device | done | `http` and `https` links (a mail link opens at once); Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) | | Opening a link in place | missing | | | Controllers on macOS, Linux and FreeBSD | missing | the usual Rust controller library links the system's libudev on Linux; the pure-Rust desktop builds read none there yet | | Prototype in a separate window; mirror to a phone | missing | see [hardware-parity](hardware-parity.md) | @@ -103,6 +104,7 @@ case pins the current behaviour). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | patch | Open link to a website first shows a notice that it leaves the prototype, with the link, Open link, Cancel and "Don't show this again", which the desktop app saves with its preferences and the web build keeps until the page reloads (`proto.linkNotice` turns the notice back on); a second link to a website that fires while the notice shows is dropped, and a mail link still opens. Before, the player opened the link at once, also from an After delay trigger. Numbers unchanged | | 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. | From 9c20d06abd7091429ee92aeb2e7bff2deda24764 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 15:53:33 -0700 Subject: [PATCH 097/142] M7: with Contents only on, a slice in a frame or group exports only that container A slice exported every layer of its page that showed in its area, whatever its Contents only setting said. With Contents only on (the default), a slice inside a frame or group now exports only that frame or group and the layers in it. With it off, or for a slice on the page or in a section, the slice exports everything in its area as before. The Export section's preview of a slice follows its first export setting in the same way. --- crates/engine/src/cmd/file.rs | 37 +++++++++++++++++---- crates/engine/src/export_behaviour_tests.rs | 37 +++++++++++++++++++++ crates/ui-egui/src/assets_panel.rs | 15 +++++---- crates/ui-egui/src/export_preview_tests.rs | 17 ++++++++++ docs/file-format-parity.md | 3 +- 5 files changed, 94 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ec579d51..4bafa56d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -941,14 +941,37 @@ pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> O .reduce(|a, b| a.union(b)) } -/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off, -/// and always for slices) every top-level layer of their page, so whatever shows in the area is in. -fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { - let slice = ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice))); - match ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) { - Some(page) if slice || !setting.contents_only => page.children.clone(), - _ => super::edit::z_sorted(doc, ids), +/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off) +/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing +/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a +/// slice anywhere else draws every top-level layer of its page. +pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { + let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else { + return super::edit::z_sorted(doc, ids); + }; + if !setting.contents_only { + return page.children.clone(); } + let mut own = Vec::with_capacity(ids.len()); + for &i in ids { + if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) { + own.push(i); + continue; + } + match slice_container(doc, i) { + Some(c) => own.push(c), + None => return page.children.clone(), + } + } + own.sort_unstable(); + own.dedup(); + super::edit::z_sorted(doc, &super::edit::roots(doc, &own)) +} + +/// The frame or group a slice sits in: None on the page or in a section. +fn slice_container(doc: &Document, slice: NodeId) -> Option { + let parent = doc.node(slice)?.parent?; + doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent) } /// [`export_bytes`] with an export setting's format and options (its size is `scale`: see diff --git a/crates/engine/src/export_behaviour_tests.rs b/crates/engine/src/export_behaviour_tests.rs index 6896ce3e..ec1a56e9 100644 --- a/crates/engine/src/export_behaviour_tests.rs +++ b/crates/engine/src/export_behaviour_tests.rs @@ -256,6 +256,43 @@ fn masks_and_slices_export_what_shows() { let _ = under; } +/// Ignore overlapping layers (contents only) on a slice: inside a frame or group the slice exports +/// only what is in that container; off, or on the page, everything in its area. +#[test] +fn a_slice_in_a_frame_or_group_exports_only_that_container() { + let mut s = Session::new(); + let slice_in = |s: &mut Session, parent: Option, x: f64| { + let mut p = json!({"type": "slice", "x": x, "y": 25, "width": 50, "height": 50}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(&ex(s, "node.create", p)) + }; + let at = |s: &mut Session, p: Value| pixels(&export(s, p)).get_pixel(40, 40).0; + // A frame holding a red square and a slice, with a blue square on the page above it. + let f = frame(&mut s, 0.0, 0.0, 100.0, 100.0, false); + rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#FF0000", Some(f)); + let in_frame = slice_in(&mut s, Some(f), 25.0); + rect(&mut s, 50.0, 50.0, 100.0, 100.0, "#0000FF", None); + assert_eq!(png_size(&export(&mut s, json!({"ids": [in_frame]}))), (50, 50), "the slice still exports its own area"); + assert_eq!(at(&mut s, json!({"ids": [in_frame]})), [255, 0, 0, 255], "on by default: only the frame's content, the blue square left out"); + assert_eq!(at(&mut s, json!({"ids": [in_frame], "contentsOnly": false})), [0, 0, 255, 255], "off: everything in the slice's area"); + // The same in a group. + let red = rect(&mut s, 300.0, 0.0, 100.0, 100.0, "#FF0000", None); + let grouped = slice_in(&mut s, None, 325.0); + let g = id(&ex(&mut s, "object.group", json!({"ids": [red, grouped]}))); + assert_eq!(s.doc().unwrap().node(grouped).unwrap().parent, Some(g)); + rect(&mut s, 350.0, 50.0, 100.0, 100.0, "#0000FF", None); + assert_eq!(at(&mut s, json!({"ids": [grouped]})), [255, 0, 0, 255], "in a group: only the group's content"); + assert_eq!(at(&mut s, json!({"ids": [grouped], "contentsOnly": false})), [0, 0, 255, 255], "off: everything in its area"); + // On the page the option has no effect. + rect(&mut s, 600.0, 0.0, 100.0, 100.0, "#FF0000", None); + let top = slice_in(&mut s, None, 625.0); + rect(&mut s, 650.0, 50.0, 100.0, 100.0, "#0000FF", None); + assert_eq!(at(&mut s, json!({"ids": [top]})), [0, 0, 255, 255], "on the page: everything in its area, with the option on"); + assert_eq!(at(&mut s, json!({"ids": [top], "contentsOnly": false})), [0, 0, 255, 255], "on the page: everything in its area, with it off"); +} + #[test] fn several_layers_and_frames_to_pdf() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 36636c3d..288e3d2a 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -195,19 +195,20 @@ pub(crate) fn thumbnail(ctx: &egui::Context, doc: &Document, id: NodeId, px: f32 Some(ctx.load_texture(format!("asset-thumb-{}", id.0), img, egui::TextureOptions::LINEAR)) } -/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the page content -/// under it, exactly as exporting one does (`figcraft_engine::cmd::file::drawn_layers`): without -/// this a slice's preview is a blank checkerboard next to a correct export. +/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the content under it +/// that exporting it with its first export setting writes (`figcraft_engine::cmd::file::drawn_layers`): +/// without this a slice's preview is a blank checkerboard next to a correct export. pub(crate) fn thumbnail_pixmap(doc: &Document, id: NodeId, px: f32) -> Option { - let slice = doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Slice)); - let b = if slice { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? }; + let slice = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Slice)); + let b = if slice.is_some() { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? }; let side = b.width().max(b.height()); if !(side.is_finite() && side > 0.0) { return None; } let scale = (f64::from(px) / side).min(2.0); - if slice { - let layers = doc.page_of(id).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default(); + if let Some(n) = slice { + let setting = n.exports.first().cloned().unwrap_or_default(); + let layers = figcraft_engine::cmd::file::drawn_layers(doc, &[id], &setting); return figcraft_render::render_area(doc, &layers, b, scale); } figcraft_render::render_node(doc, id, scale) diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index d57e294d..468a5f17 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -33,6 +33,23 @@ fn a_slice_previews_the_content_under_it() { assert!(paints(&pix), "the rectangle under the slice shows"); } +/// The preview follows the slice's first export setting: with Ignore overlapping layers (contents +/// only) on, a slice in a frame shows only the frame's content, as its export does. +#[test] +fn a_slice_in_a_frame_previews_only_the_frame() { + let mut s = Session::new(); + let new = |s: &mut Session, p: Value| NodeId(s.execute("node.create", &p).unwrap()["id"].as_u64().unwrap()); + let f = new(&mut s, json!({"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100, "props": {"fills": []}})); + let slice = new(&mut s, json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100, "parent": f})); + new(&mut s, json!({"type": "rectangle", "x": 0, "y": 0, "width": 200, "height": 100})); + let pix = thumbnail_pixmap(s.doc().unwrap(), slice, 200.0).expect("the slice previews"); + assert_eq!([pix.width(), pix.height()], [200, 100]); + assert!(!paints(&pix), "the rectangle on the page above the empty frame is left out"); + s.execute("export.add", &json!({"ids": [slice], "setting": {"size": "1x", "contentsOnly": false}})).unwrap(); + let pix = thumbnail_pixmap(s.doc().unwrap(), slice, 200.0).expect("the slice previews"); + assert!(paints(&pix), "with the option off, the rectangle shows"); +} + #[test] fn a_slice_with_nothing_under_it_is_blank() { let (doc, ids) = doc_after(&[("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100}))]); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 73fbc602..6ccc80d1 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -49,7 +49,7 @@ export limit, not a native document nesting limit. | Format | Figma | FigCraft | Notes | Hours | |---|---|---|---|---| -| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them; sides round up to whole pixels | — | +| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with contents only on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels | — | | JPG | yes | yes | white where transparent; fixed quality (90) | — | | SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `` with one `` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — | | PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 | @@ -165,6 +165,7 @@ each. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before | | 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 | From 4f0649ad527f9cdb5895773e80e5043bdddbfad2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 17:14:08 -0700 Subject: [PATCH 098/142] M7: the export settings name Ignore overlapping layers and offer Include bounding box for text layers only The export setting's advanced options name the contentsOnly option "Ignore overlapping layers" and offer Include bounding box only when every selected layer is a text layer. A setting that already has Include bounding box on still shows the option, so it can be turned off, and the export still applies it. The saved keys and the contentsOnly and includeBoundingBox params of export.add, export.update and file.export are unchanged. Every catalog's "Contents only" row becomes an "Ignore overlapping layers" row. --- crates/ui-egui/locales/bn.toml | 2 +- crates/ui-egui/locales/cs.toml | 2 +- crates/ui-egui/locales/de.toml | 2 +- crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 2 +- crates/ui-egui/locales/fi.toml | 2 +- crates/ui-egui/locales/fr.toml | 2 +- crates/ui-egui/locales/id.toml | 2 +- crates/ui-egui/locales/ja.toml | 2 +- crates/ui-egui/locales/ka.toml | 2 +- crates/ui-egui/locales/ko.toml | 2 +- crates/ui-egui/locales/nl.toml | 2 +- crates/ui-egui/locales/pl.toml | 2 +- crates/ui-egui/locales/pt-BR.toml | 2 +- crates/ui-egui/locales/ru.toml | 2 +- crates/ui-egui/locales/sv.toml | 2 +- crates/ui-egui/locales/tr.toml | 2 +- crates/ui-egui/locales/zh-Hans.toml | 2 +- crates/ui-egui/src/export_panel.rs | 24 ++++++++++------ crates/ui-egui/src/export_preview_tests.rs | 32 +++++++++++++++++++++- docs/target-app-parity.md | 2 +- 21 files changed, 65 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 364c9632..c3453096 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -304,7 +304,6 @@ status = "draft — needs native review" "Constraints" = "সীমাবদ্ধতা (Constraints)" "Content" = "কন্টেন্ট" "Contents hidden" = "কন্টেন্ট লুকানো আছে" -"Contents only" = "শুধু কন্টেন্ট" "Contextual alternates" = "প্রসঙ্গভিত্তিক বিকল্প" "Contrast" = "কনট্রাস্ট (Contrast)" "Contributors" = "অবদানকারীগণ" @@ -628,6 +627,7 @@ status = "draft — needs native review" "If" = "যদি (If)" "If {condition}" = "যদি {condition}" "Ignore auto layout (absolute position)" = "অটো লেআউট উপেক্ষা করুন (অ্যাবসোলিউট পজিশন)" +"Ignore overlapping layers" = "ওভারল্যাপ করা লেয়ার উপেক্ষা করুন" "Image" = "ছবি" "Images" = "ছবিসমূহ" "Import" = "ইম্পোর্ট" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index b05427b2..2ffcec56 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -295,7 +295,6 @@ status = "draft — needs native review" "Constraints" = "Omezení" "Content" = "Obsah" "Contents hidden" = "Obsah skryt" -"Contents only" = "Pouze obsah" "Contextual alternates" = "Kontextové alternativy" "Contrast" = "Kontrast" "Contributors" = "Přispěvatelé" @@ -613,6 +612,7 @@ status = "draft — needs native review" "If" = "Pokud" "If {condition}" = "Pokud {condition}" "Ignore auto layout (absolute position)" = "Ignorovat automatické rozvržení (absolutní poloha)" +"Ignore overlapping layers" = "Ignorovat překrývající se vrstvy" "Image" = "Obrázek" "Images" = "Obrázky" "Import" = "Import" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index da2a177a..96bad45b 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -291,7 +291,6 @@ status = "draft — needs native review" "Constraints" = "Constraints" "Content" = "Inhalt" "Contents hidden" = "Inhalt ausgeblendet" -"Contents only" = "Nur Inhalt" "Contextual alternates" = "Kontextbedingte Varianten" "Contrast" = "Kontrast" "Contributors" = "Mitwirkende" @@ -600,6 +599,7 @@ status = "draft — needs native review" "If" = "Wenn" "If {condition}" = "Wenn {condition}" "Ignore auto layout (absolute position)" = "Auto-Layout ignorieren (absolute Position)" +"Ignore overlapping layers" = "Überlappende Ebenen ignorieren" "Image" = "Bild" "Images" = "Bilder" "Import" = "Importieren" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index e1ed336e..636b9aa4 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -305,7 +305,6 @@ name = "English" "Constraints" = "Constraints" "Content" = "Content" "Contents hidden" = "Contents hidden" -"Contents only" = "Contents only" "Contextual alternates" = "Contextual alternates" "Contrast" = "Contrast" "Contributors" = "Contributors" @@ -631,6 +630,7 @@ name = "English" "If" = "If" "If {condition}" = "If {condition}" "Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)" +"Ignore overlapping layers" = "Ignore overlapping layers" "Image" = "Image" "Images" = "Images" "Import" = "Import" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index a4323c87..25967704 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -305,7 +305,6 @@ status = "draft — needs native review" "Constraints" = "Restricciones" "Content" = "Contenido" "Contents hidden" = "Contenido oculto" -"Contents only" = "Solo el contenido" "Contextual alternates" = "Alternativas contextuales" "Contrast" = "Contraste" "Contributors" = "Colaboradores" @@ -632,6 +631,7 @@ status = "draft — needs native review" "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)" +"Ignore overlapping layers" = "Ignorar capas superpuestas" "Image" = "Imagen" "Images" = "Imágenes" "Import" = "Importar" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index fe979678..1d40942c 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -303,7 +303,6 @@ status = "draft — needs native review" "Constraints" = "Rajoitteet" "Content" = "Sisältö" "Contents hidden" = "Sisältö kätketty" -"Contents only" = "Vain sisältö" "Contextual alternates" = "Kontekstuaaliset vaihtoehdot" "Contrast" = "Kontrasti" "Contributors" = "Tekijät" @@ -630,6 +629,7 @@ status = "draft — needs native review" "If it keeps happening, please report it at {link} with this message." = "Jos ongelma jatkuu, ilmoita siitä osoitteessa {link} ja liitä tämä viesti mukaan." "If {condition}" = "Jos {condition}" "Ignore auto layout (absolute position)" = "Ohita automaattinen asettelu (absoluuttinen sijainti)" +"Ignore overlapping layers" = "Ohita päällekkäiset tasot" "Image" = "Kuva" "Images" = "Kuvat" "Import" = "Tuo" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index bb771e9f..c660de85 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -301,7 +301,6 @@ status = "draft — needs native review" "Constraints" = "Contraintes" "Content" = "Contenu" "Contents hidden" = "Contenu masqué" -"Contents only" = "Contenu uniquement" "Contextual alternates" = "Variantes contextuelles" "Contrast" = "Contraste" "Contributors" = "Contributeurs" @@ -629,6 +628,7 @@ status = "draft — needs native review" "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorer la mise en page auto (position absolue)" +"Ignore overlapping layers" = "Ignorer les calques superposés" "Image" = "Image" "Images" = "Images" "Import" = "Importer" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index a516db9f..a7c13930 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -299,7 +299,6 @@ status = "draft — needs native review" "Constraints" = "Batasan" "Content" = "Konten" "Contents hidden" = "Konten disembunyikan" -"Contents only" = "Hanya konten" "Contextual alternates" = "Alternatif kontekstual" "Contrast" = "Kontras" "Contributors" = "Kontributor" @@ -617,6 +616,7 @@ status = "draft — needs native review" "If" = "Jika" "If {condition}" = "Jika {condition}" "Ignore auto layout (absolute position)" = "Abaikan auto layout (posisi absolut)" +"Ignore overlapping layers" = "Abaikan layer yang tumpang tindih" "Image" = "Gambar" "Images" = "Gambar" "Import" = "Impor" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 641ec1f6..61e0b81b 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -302,7 +302,6 @@ status = "draft — needs native review" "Constraints" = "制約" "Content" = "コンテンツ" "Contents hidden" = "コンテンツ非表示" -"Contents only" = "コンテンツのみ" "Contextual alternates" = "前後関係に依存する字形" "Contrast" = "コントラスト" "Contributors" = "貢献者" @@ -624,6 +623,7 @@ status = "draft — needs native review" "If" = "条件" "If {condition}" = "条件:{condition}" "Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)" +"Ignore overlapping layers" = "重なっているレイヤーを無視" "Image" = "画像" "Images" = "画像" "Import" = "インポート" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index b556e8d7..9e542f1d 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -306,7 +306,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Constraints" = "შეზღუდვები" "Content" = "შიგთავსი" "Contents hidden" = "შიგთავსი დამალულია" -"Contents only" = "მხოლოდ შიგთავსი" "Contextual alternates" = "კონტექსტური ალტერნატივები" "Contrast" = "კონტრასტი" "Contributors" = "მონაწილეები" @@ -630,6 +629,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "If" = "თუ" "If {condition}" = "თუ {condition}" "Ignore auto layout (absolute position)" = "ავტოგანლაგების იგნორირება (აბსოლუტური პოზიცია)" +"Ignore overlapping layers" = "გადაფარული შრეების იგნორირება" "Image" = "გამოსახულება" "Images" = "გამოსახულებები" "Import" = "იმპორტი" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 053348f2..59009ac9 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -291,7 +291,6 @@ status = "draft — needs native review" "Constraints" = "제약" "Content" = "콘텐츠" "Contents hidden" = "콘텐츠 숨김" -"Contents only" = "콘텐츠만" "Contextual alternates" = "문맥 대체 글자" "Contrast" = "대비" "Contributors" = "기여자" @@ -596,6 +595,7 @@ status = "draft — needs native review" "If" = "조건" "If {condition}" = "조건: {condition}" "Ignore auto layout (absolute position)" = "자동 레이아웃 무시(절대 위치)" +"Ignore overlapping layers" = "겹치는 레이어 무시" "Image" = "이미지" "Images" = "이미지" "Import" = "가져오기" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index bc4e0cfa..9c2bccc4 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -303,7 +303,6 @@ status = "draft — needs native review" "Constraints" = "Constraints" "Content" = "Inhoud" "Contents hidden" = "Inhoud verborgen" -"Contents only" = "Alleen de inhoud" "Contextual alternates" = "Contextuele alternatieven" "Contrast" = "Contrast" "Contributors" = "Bijdragers" @@ -630,6 +629,7 @@ status = "draft — needs native review" "If it keeps happening, please report it at {link} with this message." = "Als dit blijft gebeuren, meld het dan op {link} met dit bericht." "If {condition}" = "Als {condition}" "Ignore auto layout (absolute position)" = "Auto layout negeren (absolute positie)" +"Ignore overlapping layers" = "Overlappende lagen negeren" "Image" = "Afbeelding" "Images" = "Afbeeldingen" "Import" = "Importeren" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 25f287f9..130726c0 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -302,7 +302,6 @@ status = "draft — needs native review" "Constraints" = "Więzy" "Content" = "Zawartość" "Contents hidden" = "Zawartość ukryta" -"Contents only" = "Tylko zawartość" "Contextual alternates" = "Alternatywy kontekstowe" "Contrast" = "Kontrast" "Contributors" = "Współtwórcy" @@ -627,6 +626,7 @@ status = "draft — needs native review" "If" = "Jeżeli" "If {condition}" = "Jeżeli {condition}" "Ignore auto layout (absolute position)" = "Ignoruj automatyczny układ (pozycja bezwzględna)" +"Ignore overlapping layers" = "Ignoruj nakładające się warstwy" "Image" = "Obraz" "Images" = "Obrazy" "Import" = "Importuj" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index cb7b08b5..102a18ff 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -289,7 +289,6 @@ status = "draft — needs native review" "Constraints" = "Restrições" "Content" = "Conteúdo" "Contents hidden" = "Conteúdo oculto" -"Contents only" = "Somente o conteúdo" "Contextual alternates" = "Alternativas contextuais" "Contrast" = "Contraste" "Contributors" = "Colaboradores" @@ -595,6 +594,7 @@ status = "draft — needs native review" "If" = "Se" "If {condition}" = "Se {condition}" "Ignore auto layout (absolute position)" = "Ignorar layout automático (posição absoluta)" +"Ignore overlapping layers" = "Ignorar camadas sobrepostas" "Image" = "Imagem" "Images" = "Imagens" "Import" = "Importar" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index ed1cc0eb..36ad201f 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -302,7 +302,6 @@ name = "Русский" "Constraints" = "Ограничения" "Content" = "Содержимое" "Contents hidden" = "Содержимое скрыто" -"Contents only" = "Только содержимое" "Contextual alternates" = "Контекстные альтернативы" "Contrast" = "Контраст" "Contributors" = "Участники" @@ -629,6 +628,7 @@ name = "Русский" "If it keeps happening, please report it at {link} with this message." = "Если это повторяется, сообщите об этом по адресу {link}, приложив это сообщение." "If {condition}" = "Если {condition}" "Ignore auto layout (absolute position)" = "Игнорировать автолейаут (абсолютная позиция)" +"Ignore overlapping layers" = "Игнорировать перекрывающиеся слои" "Image" = "Изображение" "Images" = "Изображения" "Import" = "Импорт" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index bcb819e5..415be191 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -299,7 +299,6 @@ name = "Svenska" "Constraints" = "Begränsningar" "Content" = "Innehåll" "Contents hidden" = "Innehållet är dolt" -"Contents only" = "Endast innehåll" "Contextual alternates" = "Kontextuella alternativ" "Contrast" = "Kontrast" "Contributors" = "Bidragsgivare" @@ -624,6 +623,7 @@ name = "Svenska" "If" = "Om" "If {condition}" = "Om {condition}" "Ignore auto layout (absolute position)" = "Ignorera automatisk layout (absolut position)" +"Ignore overlapping layers" = "Ignorera överlappande lager" "Image" = "Bild" "Images" = "Bilder" "Import" = "Importera" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 4275f06a..f43a97fc 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -299,7 +299,6 @@ name = "Türkçe" "Constraints" = "Kısıtlamalar" "Content" = "İçerik" "Contents hidden" = "İçerik gizli" -"Contents only" = "Yalnızca içerik" "Contextual alternates" = "Bağlamsal alternatifler" "Contrast" = "Kontrast" "Contributors" = "Katkıda bulunanlar" @@ -617,6 +616,7 @@ name = "Türkçe" "If" = "Eğer" "If {condition}" = "Eğer {condition}" "Ignore auto layout (absolute position)" = "Otomatik yerleşimi yok say (mutlak konum)" +"Ignore overlapping layers" = "Çakışan katmanları yok say" "Image" = "Görüntü" "Images" = "Görüntüler" "Import" = "İçe aktar" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index da070c0e..547c2373 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -292,7 +292,6 @@ status = "draft — needs native review" "Constraints" = "约束" "Content" = "内容" "Contents hidden" = "内容已隐藏" -"Contents only" = "仅内容" "Contextual alternates" = "上下文替代字" "Contrast" = "对比度" "Contributors" = "贡献者" @@ -597,6 +596,7 @@ status = "draft — needs native review" "If" = "如果" "If {condition}" = "如果 {condition}" "Ignore auto layout (absolute position)" = "忽略自动布局(绝对定位)" +"Ignore overlapping layers" = "忽略重叠图层" "Image" = "图片" "Images" = "图片" "Import" = "导入" diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 8617c35a..5e05cc19 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -40,8 +40,9 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { if mixed { widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary); } + let text = sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))); for (i, e) in list.iter().enumerate() { - setting_row(app, ui, &ids, i, e); + setting_row(app, ui, &ids, i, e, text); } let jobs = export_jobs(doc, sel); if jobs.is_empty() { @@ -61,8 +62,9 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } /// One export setting: size (typed or picked; images only, SVG and PDF are vector), suffix, -/// format, advanced options and remove; a PDF's print settings under it. -fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting) { +/// format, advanced options and remove; a PDF's print settings under it. `text`: every layer in +/// `ids` is a text layer. +fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting, text: bool) { row(ui, 32.0, |ui| { // Tighter spacing so the advanced options and remove buttons fit beside the fields. ui.spacing_mut().item_spacing.x = 4.0; @@ -98,7 +100,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo .width(216.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { - if let Some(mut params) = advanced_options(ui, e) { + if let Some(mut params) = advanced_options(ui, e, text) { params["ids"] = json!(ids); params["index"] = json!(i); app.run("export.update", params); @@ -113,17 +115,21 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo } } -/// A setting's advanced options: contents only and include bounding box, and for SVG outline -/// text, the id attribute and simplified strokes. Returns the changed option as command params. -fn advanced_options(ui: &mut Ui, e: &ExportSetting) -> Option { +/// A setting's advanced options: ignore overlapping layers (`contentsOnly`), include bounding box +/// for text layers (and for any setting that has it on, so it can be turned off), and for SVG +/// outline text, the id attribute and simplified strokes. Returns the changed option as command +/// params. +fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option { let mut out = None; let mut option = |ui: &mut Ui, key: &str, on: bool, label: &str| { if widgets::checkbox(ui, on, label) { out = Some(json!({ key: !on })); } }; - option(ui, "contentsOnly", e.contents_only, tr("Contents only")); - option(ui, "includeBoundingBox", e.include_bounding_box, tr("Include bounding box")); + option(ui, "contentsOnly", e.contents_only, tr("Ignore overlapping layers")); + if text || e.include_bounding_box { + option(ui, "includeBoundingBox", e.include_bounding_box, tr("Include bounding box")); + } if e.format == ExportFormat::Svg { option(ui, "svgOutlineText", e.svg_outline_text, tr("Outline text")); option(ui, "svgIdAttribute", e.svg_id_attribute, tr("Include id attribute")); diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index d57e294d..6a70faa9 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -1,7 +1,7 @@ //! The Export section's Preview row: a slice paints nothing itself, so its preview must show the //! page content under it — the same thing exporting the slice writes — and not a blank //! transparency checkerboard next to a correct export. And its setting rows: a size only for -//! images. +//! images, and advanced options named as the reference app names them. use figcraft_doc::{Document, NodeId}; use figcraft_engine::Session; @@ -76,3 +76,33 @@ fn export_rows_offer_a_size_for_images_only() { assert!(size_menu(&u), "{format} has sizes again"); } } + +/// The advanced options: Ignore overlapping layers (the `contentsOnly` setting), and Include +/// bounding box for text layers only. A setting that already has the bounding box on keeps it, and +/// the option stays so it can be turned off. +#[test] +fn advanced_options_offer_the_bounding_box_for_text_layers() { + let mut s = Session::new(); + let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()); + let t = crate::present_tests::id(&s.execute("text.create", &json!({"text": "Hi", "x": 200, "y": 0})).unwrap()); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + s.execute("export.add", &json!({"ids": [t]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + let options = |u: &mut crate::layers_drag_tests::Ui, id: NodeId| { + u.app.run("select.set", json!({"ids": [id]})); + u.frame(vec![]); + egui::Popup::open_id(&u.ctx, egui::Id::new(("export_more", 0))); + u.frame(vec![]); + crate::import_report_tests::shown_texts(u.frame_output(vec![]).shapes) + }; + let shown = options(&mut u, r); + assert!(shown.iter().any(|s| s == "Ignore overlapping layers"), "the reference app's name: {shown:?}"); + assert!(!shown.iter().any(|s| s == "Contents only")); + assert!(!shown.iter().any(|s| s == "Include bounding box"), "a rectangle has no bounding box option"); + let shown = options(&mut u, t); + assert!(shown.iter().any(|s| s == "Include bounding box"), "a text layer has it: {shown:?}"); + u.app.run("export.update", json!({"ids": [r], "index": 0, "includeBoundingBox": true})); + let shown = options(&mut u, r); + assert!(shown.iter().any(|s| s == "Include bounding box"), "a rectangle that has it on shows it: {shown:?}"); + assert!(u.app.session.doc().unwrap().node(r).unwrap().exports[0].include_bounding_box, "the setting is kept"); +} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e196eb64..dfe8d127 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -423,7 +423,7 @@ Hours in this narrative are the builders' estimates; the tables above supersede presenter) and variables (the local variables JSON passed along, next to the file or embedded: collections, modes, aliases, scopes, layer bindings and explicit modes). Export panel: a row per export setting (typed or preset sizes like `2x` / `512w` / `512h`, PNG, - JPG, WebP, SVG and PDF, suffix, advanced options: contents only, include bounding box, and for + JPG, WebP, SVG and PDF, suffix, advanced options: ignore overlapping layers, include bounding box for text layers, and for SVG outline text, id attributes and simplified strokes), a preview, and "Export N layers" (`file.exportLayers`) in the panel and the Export dialog. Paste from other apps: images, SVG and text (the newest clipboard content wins over our own copy; pasted SVG lands centred in the view), and layers copied from From 9f78875338ea2fda13983cd7d1f73d4b3f0b8c74 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sat, 24 Oct 2026 21:17:36 -0400 Subject: [PATCH 099/142] security: linear vector import, stroking and rounding, validation, and cyclic dev snapshots (#1306) * security: linear vector import, stroking and rounding, validation and a cycle check for dev snapshots Closing a subpath, rounding corners and chaining stroke runs each rescanned the whole network per step, and Document::validate searched a parent's children per child. They now update only the current subpath's last segment, rebuild each region loop in one pass, walk per-vertex adjacency lists, and check edges against a once-built index. A dev snapshot whose layers contain a cycle under the root is refused instead of reconstructed. Co-Authored-By: Claude Sonnet 5.5 * fix(doc): address review findings for snapshot children and 2-segment rounded loops --------- Co-authored-by: Claude Sonnet 5.5 --- crates/doc/src/handoff.rs | 30 +++++++++++ crates/doc/src/handoff_tests.rs | 51 ++++++++++++++++++ crates/doc/src/lib.rs | 8 ++- crates/doc/src/tests.rs | 57 ++++++++++++++++++++ crates/doc/src/vector.rs | 94 ++++++++++++++++++++++----------- 5 files changed, 208 insertions(+), 32 deletions(-) diff --git a/crates/doc/src/handoff.rs b/crates/doc/src/handoff.rs index 9700b56c..04bf5e95 100644 --- a/crates/doc/src/handoff.rs +++ b/crates/doc/src/handoff.rs @@ -71,8 +71,38 @@ impl Document { if let (Some(now), Some(r)) = (self.node(root), d.nodes.get_mut(&root)) { Arc::make_mut(r).parent = now.parent; } + // A damaged file can give a historical layer itself (or an ancestor) as a child; a copy + // with a cycle under the root would send every walk of it round forever. + if !d.is_tree_under(root) { + return None; + } d.dev_snapshots.clear(); d.dirty = crate::Dirty::default(); Some(d) } + + /// Whether every layer under `root` is reached once (no layer is its own descendant or listed + /// by two parents), walking iteratively and stopping at the first repeat. + fn is_tree_under(&self, root: NodeId) -> bool { + let mut seen = BTreeSet::new(); + let mut stack = vec![root]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + return false; + } + let Some(n) = self.nodes.get(&id) else { + return false; + }; + for &c in &n.children { + let Some(child) = self.nodes.get(&c) else { + return false; + }; + if child.parent != Some(id) { + return false; + } + stack.push(c); + } + } + true + } } diff --git a/crates/doc/src/handoff_tests.rs b/crates/doc/src/handoff_tests.rs index 7fc26898..79b97194 100644 --- a/crates/doc/src/handoff_tests.rs +++ b/crates/doc/src/handoff_tests.rs @@ -34,3 +34,54 @@ fn a_snapshot_shows_the_layer_as_it_was() { doc.dev_snapshots.clear(); assert!(serde_json::to_value(&doc).unwrap().get("dev_snapshots").is_none()); } + +#[test] +fn a_snapshot_with_a_layer_inside_itself_is_refused() { + let mut doc = Document::new("d"); + let page = doc.first_page().unwrap(); + let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap(); + let mut snap = doc.snapshot_subtree(f).unwrap(); + // A damaged file: the historical frame lists itself as a child. + let mut bad = (*snap.nodes[0]).clone(); + bad.children.push(f); + snap.nodes[0] = Arc::new(bad); + doc.dev_snapshots.insert(f, Arc::new(snap)); + assert!(doc.with_dev_snapshot(f).is_none()); +} + +#[test] +fn a_snapshot_with_missing_or_nonreciprocal_child_is_refused() { + use crate::NodeId; + let mut doc = Document::new("d"); + let page = doc.first_page().unwrap(); + let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap(); + let _c1 = doc.create(f, NodeKind::Rectangle, "R1", 0.0, 0.0, 10.0, 10.0).unwrap(); + let snap = doc.snapshot_subtree(f).unwrap(); + + let mut missing = snap.clone(); + let mut bad_root = (*missing.nodes[0]).clone(); + bad_root.children.push(NodeId(99999)); + missing.nodes[0] = Arc::new(bad_root); + doc.dev_snapshots.insert(f, Arc::new(missing)); + assert!(doc.with_dev_snapshot(f).is_none()); + + let mut nonrecip = snap.clone(); + let mut bad_child = (*nonrecip.nodes[1]).clone(); + bad_child.parent = Some(NodeId(88888)); + nonrecip.nodes[1] = Arc::new(bad_child); + doc.dev_snapshots.insert(f, Arc::new(nonrecip)); + assert!(doc.with_dev_snapshot(f).is_none()); +} + +#[test] +fn validate_is_linear_in_a_wide_parent() { + let mut doc = Document::new("d"); + let page = doc.first_page().unwrap(); + for i in 0..5_000 { + doc.create(page, NodeKind::Rectangle, "R", f64::from(i), 0.0, 1.0, 1.0).unwrap(); + } + assert!(doc.validate().is_empty()); + let kid = doc.node(page).unwrap().children[0]; + doc.get_mut(page).unwrap().children.retain(|c| *c != kid); + assert!(doc.validate().iter().any(|m| m.contains("not listed in parent"))); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 70797dd8..9ff1d968 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1023,6 +1023,10 @@ impl Document { } } let mut issues = Vec::new(); + // Every (parent, child) edge and the page list, indexed once so each node's checks are + // lookups: a wide parent or a long page list doesn't make validation quadratic. + let edges: std::collections::BTreeSet<(NodeId, NodeId)> = self.nodes.iter().flat_map(|(p, n)| n.children.iter().map(|c| (*p, *c))).collect(); + let page_ids: std::collections::BTreeSet = self.pages.iter().copied().collect(); for (id, n) in self.nodes.iter() { if n.id != *id { note(&mut issues, || format!("node key {id} has id {}", n.id)); @@ -1039,12 +1043,12 @@ impl Document { } } if let Some(p) = n.parent - && !self.node(p).is_some_and(|pn| pn.children.contains(id)) + && !(self.node(p).is_some() && edges.contains(&(p, *id))) { 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)); + let listed = page_ids.contains(id) || (n.is_page() && self.libraries.store == Some(*id)); if n.is_page() != listed { note(&mut issues, || format!("page list mismatch for {id}")); } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 1a006c26..928dca55 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -559,3 +559,60 @@ fn is_ancestor_holds_for_very_deep_chains() { assert!(d.is_ancestor(root, leaf)); assert!(matches!(d.reparent(root, leaf, None), Err(DocError::Cycle(..)))); } + +#[test] +fn many_small_vector_subpaths_build_and_stroke_without_rescans() { + // Many closed subpaths whose last point repeats the start (#F-332), a long open chain (#F-334) + // and a big rounded polygon (#F-333): each used to scan the whole network per step. + let mut p = figcraft_geom::kurbo::BezPath::new(); + for i in 0..3_000 { + let x = f64::from(i) * 10.0; + p.move_to((x, 0.0)); + p.line_to((x + 5.0, 0.0)); + p.line_to((x + 5.0, 5.0)); + p.line_to((x, 0.0)); + p.close_path(); + } + let net = VectorNetwork::from_path(&p); + assert_eq!(net.vertices.len(), 3 * 3_000); + assert_eq!(net.regions[0].loops.len(), 3_000); + assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len())); + assert_eq!(net.stroke_runs().len(), 3_000); + + let pts: Vec = (0..20_000).map(|i| Point::new(f64::from(i), f64::from(i % 7))).collect(); + let chain = VectorNetwork::from_points(&pts, false); + let runs = chain.stroke_runs(); + assert_eq!(runs.len(), 1); + assert!(!runs[0].closed); + + let ring: Vec = (0..4_000) + .map(|i| { + let t = f64::from(i) / 4_000.0 * std::f64::consts::TAU; + Point::new(1000.0 * t.cos(), 1000.0 * t.sin() * if i % 2 == 0 { 1.0 } else { 0.9 }) + }) + .collect(); + let mut poly = VectorNetwork::from_points(&ring, true); + for v in &mut poly.vertices { + v.radius = 0.5; + } + let rounded = poly.rounded().unwrap(); + assert!(rounded.regions[0].loops[0].len() > poly.segments.len(), "arcs are walked between the sides"); + assert_eq!(rounded.regions[0].loops[0].len(), rounded.segments.len()); +} + +#[test] +fn two_segment_closed_loop_rounded_corners() { + let mut v0 = Vertex::new(0.0, 0.0); + v0.radius = 2.0; + let mut v1 = Vertex::new(10.0, 0.0); + v1.radius = 2.0; + let net = VectorNetwork { + vertices: vec![v0, v1], + segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }], + regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }], + }; + let r = net.rounded().unwrap(); + assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop"); + let path = net.fill_path(); + assert!(!path.elements().is_empty()); +} diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index 1165bd60..b3040b1e 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -134,6 +134,16 @@ pub struct VectorNetwork { pub regions: Vec, } +/// Point the subpath's last segment (the only one ending at the fresh vertex `from`) at `to`, +/// so merging a duplicate endpoint doesn't rescan the segments of earlier subpaths. +fn redirect_last_segment(net: &mut VectorNetwork, loop_segs: &[usize], from: usize, to: usize) { + if let Some(s) = loop_segs.last().and_then(|i| net.segments.get_mut(*i)) + && s.b == from + { + s.b = to; + } +} + impl VectorNetwork { /// A network from a polyline / polygon. pub fn from_points(points: &[Point], closed: bool) -> Self { @@ -169,11 +179,7 @@ impl VectorNetwork { if a == f || loop_segs.len() < 2 || (pa - pf).hypot() > 1e-6 || a + 1 != net.vertices.len() { return; } - for s in net.segments.iter_mut() { - if s.b == a { - s.b = f; - } - } + redirect_last_segment(net, loop_segs, a, f); net.vertices.pop(); all_loops.push(std::mem::take(loop_segs)); }; @@ -219,11 +225,7 @@ impl VectorNetwork { let same = matches!((close_pt, first_pt), (Some(c), Some(f)) if (c - f).hypot() <= 1e-6); if same && a != f { // Merge the duplicate end vertex into the first one. - for s in net.segments.iter_mut() { - if s.b == a { - s.b = f; - } - } + redirect_last_segment(&mut net, &loop_segs, a, f); if a + 1 == net.vertices.len() { net.vertices.pop(); } @@ -385,6 +387,7 @@ impl VectorNetwork { s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y); s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y); } + let mut by_vertex: std::collections::BTreeMap = std::collections::BTreeMap::new(); for c in corners { if let Some(v) = out.vertices.get_mut(c.v) { (v.x, v.y) = (c.a.x, c.a.y); @@ -400,15 +403,28 @@ impl VectorNetwork { } out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb }); let arc = out.segments.len() - 1; - // The arc goes between the two segments wherever a loop walks through the point. - for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { - let n = lp.len(); - let between = - |k: usize| matches!((lp.get(k), lp.get((k + 1) % n)), (Some(&x), Some(&y)) if (x, y) == (c.s1, c.s2) || (x, y) == (c.s2, c.s1)); - if let Some(k) = (0..n).find(|k| between(*k)) { - lp.insert(k + 1, arc); + by_vertex.insert(c.v, arc); + } + // The arc goes between the two segments wherever a loop walks through the point. Each loop + // is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic. + for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { + let Some(&first_idx) = lp.first() else { continue }; + let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) { + (Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b, + (Some(first), _) => first.a, + _ => 0, + }; + let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len())); + let mut cur = start_v; + for &x in lp.iter() { + rebuilt.push(x); + let Some(s) = self.segments.get(x) else { continue }; + cur = if s.a == cur { s.b } else { s.a }; + if let Some(&arc) = by_vertex.get(&cur) { + rebuilt.push(arc); } } + *lp = rebuilt; } Some(out) } @@ -547,25 +563,40 @@ impl VectorNetwork { } } let degree = |v: usize| degrees.get(v).copied().unwrap_or(0); - let next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> { - self.segments.iter().enumerate().find_map(|(i, s)| { - if used.get(i).copied().unwrap_or(true) { - return None; + // Each vertex's segments in segment order, and how far down that list the used ones reach, + // so finding the next unused segment never rescans the whole network. + let mut adjacent: Vec> = vec![Vec::new(); self.vertices.len()]; + for (i, s) in self.segments.iter().enumerate() { + if let Some(l) = adjacent.get_mut(s.a) { + l.push((i, false)); + } + if s.b != s.a + && let Some(l) = adjacent.get_mut(s.b) + { + l.push((i, true)); + } + } + let mut cursor = vec![0usize; self.vertices.len()]; + let mut remaining = self.segments.len(); + let mut next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> { + let list = adjacent.get(v)?; + let at = cursor.get_mut(v)?; + while let Some(&(i, rev)) = list.get(*at) { + if !used.get(i).copied().unwrap_or(true) { + return Some((i, rev)); } - if s.a == v { - Some((i, false)) - } else if s.b == v { - Some((i, true)) - } else { - None - } - }) + *at += 1; + } + None }; let stop = |v: usize| degree(v) != 2 || breaks(v); // Start runs at endpoints, junctions and breaks first, then whatever remains (pure loops). let mut starts: Vec = (0..self.vertices.len()).filter(|v| stop(*v)).collect(); starts.extend((0..self.vertices.len()).filter(|v| !stop(*v))); for v0 in starts { + if remaining == 0 { + break; + } while let Some((si, rev)) = next_from(v0, &used) { let Some(start) = self.vertices.get(v0) else { break }; let mut path = BezPath::new(); @@ -574,8 +605,11 @@ impl VectorNetwork { let mut closed = false; let mut seg = Some((si, rev)); while let Some((i, r)) = seg { - if let Some(u) = used.get_mut(i) { + if let Some(u) = used.get_mut(i) + && !*u + { *u = true; + remaining = remaining.saturating_sub(1); } let Some(s) = self.segments.get(i) else { break }; self.push_seg(&mut path, s, r); From 6e86e26d143f7968a15f03ec5d59188809007afe Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 19:00:42 -0700 Subject: [PATCH 100/142] M9: the hello deadline refuses a connection even when a read times out early On Windows a socket's read timeout counts whole milliseconds, so a read bounded by the hello deadline can time out a moment before it. `next_message` then took the timeout for a closed connection: the client got no "no hello in time" refusal, and the socket closed only after the 2 s linger. `Duplex::read` now reads again until the deadline has passed. Tests: - `connections_without_a_hello_are_told_why_they_end` (new): twelve connections that never say hello each get the refusal. Failed 5 of 10 runs without the fix. - `pre_hello_lifetime` notes the end of the stream on a thread of its own instead of reading one message per 300 ms poke, where pongs queued ahead of the close delayed it. - `quiet_guests_stay_and_silent_connections_are_dropped`: the guest takes only the host's keepalive. Its 600 ms idle timeout also bounded the wait for the welcome. - `two_files_are_hosted_and_joined_independently`: the two joins race, so the file whose welcome arrived last is in front. The test checks that, then brings B to the front. - `a_local_share_is_joined_by_its_code_and_edits_sync`: once the share stops, another program's host can take the freed port and refuse the code. --- crates/collab-net/src/code_tests.rs | 5 ++- crates/collab-net/src/loopback_tests.rs | 55 ++++++++++++++++++------- crates/collab-net/src/net.rs | 28 ++++++++----- crates/collab-net/src/sessions_tests.rs | 5 ++- 4 files changed, 66 insertions(+), 27 deletions(-) diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index 7fe766ec..c2745e9a 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -87,7 +87,10 @@ fn a_local_share_is_joined_by_its_code_and_edits_sync() { let mut late = session("Cy"); late.execute("collab.join", &json!({"code": shown})).unwrap(); pump(&mut [&mut late], "the refusal", |s| matches!(s[0].live().status, Status::Failed { .. })); - assert!(late.live().status.label().contains("Couldn't join"), "{:?}", late.live().status); + // The port is free again: another program's host may have taken it, and that host refuses + // the code's secret. + let label = late.live().status.label(); + assert!(label.contains("Couldn't join") || label.contains("The host refused"), "{label}"); } #[test] diff --git a/crates/collab-net/src/loopback_tests.rs b/crates/collab-net/src/loopback_tests.rs index d5a38d9d..fe9b4782 100644 --- a/crates/collab-net/src/loopback_tests.rs +++ b/crates/collab-net/src/loopback_tests.rs @@ -488,6 +488,21 @@ fn silent_connections_time_out() { host.stop(); } +#[test] +fn connections_without_a_hello_are_told_why_they_end() { + // A socket's read timeout can end a moment before the hello's deadline; the connection still + // gets the refusal. Twelve at once, so that happens to some of them. + 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(); + std::thread::scope(|s| { + let waits: Vec<_> = (0..12).map(|_| s.spawn(move || Raw::connect(addr).refusal())).collect(); + for w in waits { + assert_eq!(w.join().unwrap().as_deref(), Some("no hello in time")); + } + }); + host.stop(); +} + #[test] fn guests_reconnect_after_a_network_failure() { let (mut host, mut hp, invite) = start(); @@ -540,7 +555,9 @@ fn quiet_guests_stay_and_silent_connections_are_dropped() { let (mut host, mut hp, invite) = start_with(limits, Limits::default()); let probe = Probe::new(); let mut cfg = quick(GuestConfig::new(invite.clone(), "Ada".into())); - cfg.limits = limits; + // The guest pings as often as the host expects. Its own idle timeout stays long: it also bounds + // the wait for the welcome, which a loaded machine can take longer than 600 ms to send. + cfg.limits.keepalive = limits.keepalive; let _g = Guest::start(cfg, probe.inbox.clone()).unwrap(); let mut gp = probe; gp.wait("welcome", welcomed); @@ -672,34 +689,42 @@ fn a_welcome_over_the_size_limit_says_the_file_is_too_large() { } /// 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. +/// 300 ms (told whether it's the first time); `None`: still open after 6 s. A thread of its own +/// reads the socket and notes when the host ends the stream, so pongs queued ahead of the close +/// and a slow poke loop don't delay it. 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 mut watch = r.0.get_ref().try_clone().unwrap(); let t0 = Instant::now(); + let (ended_tx, ended) = std::sync::mpsc::channel(); + let reader = std::thread::spawn(move || { + let mut buf = [0u8; 1024]; + let until = Instant::now() + WAIT; + loop { + match watch.read(&mut buf) { + Ok(0) => break, + Ok(_) => {} + Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut) && Instant::now() < until => {} + Err(_) => break, + } + } + let _ = ended_tx.send(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; - } - _ => {} + if let Ok(at) = ended.recv_timeout(Duration::from_millis(300)) { + closed = Some(at.saturating_duration_since(t0)); + break; } } host.stop(); + let _ = reader.join(); closed } diff --git a/crates/collab-net/src/net.rs b/crates/collab-net/src/net.rs index c6628662..43e56001 100644 --- a/crates/collab-net/src/net.rs +++ b/crates/collab-net/src/net.rs @@ -195,17 +195,25 @@ 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()); + let n = loop { + 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))?; } - 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), - }?; + let read = match &self.tls { + Some(t) => tls::read(t, &mut self.read, &self.write, buf), + None => self.read.read(buf), + }; + match read { + // The socket's timeout can run out a moment before the deadline (it counts whole + // milliseconds): wait out the rest, so a read times out only once it has passed. + Err(e) if self.deadline.is_some() && matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut) => {} + read => break read?, + } + }; if let Some(m) = self.meter.as_mut() { m.add(n)?; } diff --git a/crates/collab-net/src/sessions_tests.rs b/crates/collab-net/src/sessions_tests.rs index 27523fce..404244fa 100644 --- a/crates/collab-net/src/sessions_tests.rs +++ b/crates/collab-net/src/sessions_tests.rs @@ -123,8 +123,11 @@ fn two_files_are_hosted_and_joined_independently() { host.execute("doc.switch", &json!({"index": 0})).unwrap(); assert_eq!(host.live_summary()["link"], a["link"]); assert_eq!(host.live_summary()["document"], 0); + // Each joined file opens in front when its welcome arrives, and the two joins race: the file + // whose welcome came last is in front. + assert_eq!(guest.ds().unwrap().uid, guest.docs[2].uid, "the file that arrived last is in front"); + guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap(); // An edit in A reaches the guest's copy of A only, while B is in front at the guest. - assert_eq!(guest.ds().unwrap().uid, gb, "the file joined last is in front"); let in_a = rect(&mut host); host.execute("doc.switch", &json!({"index": 1})).unwrap(); let in_b = rect(&mut host); From 32199cc8e8eb16a36622e3a1b635291773791127 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 02:40:12 +0000 Subject: [PATCH 101/142] Text on a path tool: carry fill and effect bindings as text.onPath does (#1298, #1301) --- crates/engine/src/draw_tools_tests.rs | 15 +++++++++++++++ crates/engine/src/tools.rs | 14 +++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index f4041d60..607de266 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -351,3 +351,18 @@ fn a_pattern_of_itself_draws_nothing_instead_of_recursing() { assert!(figcraft_svg::export_svg(doc, &[r], &figcraft_svg::SvgOptions::default()).is_some()); assert!(figcraft_pdf::export_nodes_pdf(doc, &[r]).is_ok()); } + +#[test] +fn the_text_on_a_path_tool_carries_a_fill_bound_through_fills() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100, "props": {"fill": "#FF0000"}})).unwrap()); + s.execute("variable.createCollection", &json!({"name": "Paint"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Paint", "name": "ink", "type": "color", "value": "#00FF00"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [v], "field": "fills", "variable": "ink"})).unwrap(); + let bound = s.doc().unwrap().node(v).unwrap().bound.clone(); + assert!(bound.contains_key("fills")); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 90.0, 103.0); + let t = editing(&s).expect("typing starts on the path"); + assert_eq!(s.doc().unwrap().node(t).unwrap().bound, bound, "the fill keeps following its variable"); +} diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 63b10558..eb20de49 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -2232,8 +2232,11 @@ impl Session { n.transform = pn.transform; n.constraints = pn.constraints; n.child_layout = pn.child_layout.clone(); - n.effects = pn.effects.clone(); - n.styles.effect = pn.styles.effect.clone(); + let effects = !pn.effects.is_empty(); + if effects { + n.effects = pn.effects.clone(); + n.styles.effect = pn.styles.effect.clone(); + } // A path drawn with a stroke only has no fill to give: the text gets the usual one. let filled = pn.fills.iter().any(|f| f.visible); if filled { @@ -2242,7 +2245,12 @@ impl Session { } else if let Some(fills) = readable_text_fills(d, parent) { n.fills = fills; } - let carried = |k: &str| k.starts_with("effects/") || (filled && (k == "fill" || k.starts_with("fills/"))); + // As `text.onPath`: a binding goes with the list it binds into (`fill`, `fills`, `fills/N/…`). + let carried = |k: &str| match crate::cmd::lists::list_binding(k) { + Some((crate::cmd::lists::BoundList::Fills, ..)) => filled, + Some((crate::cmd::lists::BoundList::Effects, ..)) => effects, + _ => false, + }; n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); if let NodeKind::Text(t) = &mut n.kind { let mut tp = TextPath::new(&geom); From d135620b9f5d2982460e4a4bfc352495b95ee7d1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 02:40:12 +0000 Subject: [PATCH 102/142] Dev Mode: the Assets panel's Publish refuses too (#1277) --- crates/ui-egui/src/assets_panel.rs | 5 ++++- crates/ui-egui/src/devmode_tail_tests.rs | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 4dd54bfd..3f1fcc77 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -936,7 +936,10 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { None => {} } let params = (publish || to_server).then(|| json!({"description": form.description.clone().unwrap_or(description), "include": include})); - if publish && let Some(r) = params.clone().and_then(|p| app.run("library.publish", p)) { + if publish + && !crate::menus::dev_mode_refuses(app, "library.publish") + && let Some(r) = params.clone().and_then(|p| app.run("library.publish", p)) + { form = PublishForm::default(); // Other files read the saved file. app.save(); diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs index 89d68603..b7c7a755 100644 --- a/crates/ui-egui/src/devmode_tail_tests.rs +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -432,3 +432,20 @@ fn dev_mode_assets_panel_accepts_no_library_updates() { let _ = std::fs::remove_file(&path); let _ = std::fs::remove_dir(&dir); } + +#[test] +fn dev_mode_publishes_no_library() { + let mut s = Session::new(); + s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.left_tab = crate::LeftTab::Assets; + u.app.ui.libraries = true; + u.app.ui.right_tab = crate::RightTab::Inspect; + let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let at = crate::live_tests::text_center(&s, "Publish"); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); + assert!(u.app.session.doc().unwrap().published.is_none(), "Publish in Dev Mode publishes nothing"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); +} From e44ef587e5f8a7233cbf85aadad1ea77ea953860 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 02:40:12 +0000 Subject: [PATCH 103/142] docs/mcp.md: a slice in a frame or group with contentsOnly (#1305) --- docs/mcp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index ea4d7cf5..fe137e55 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -315,7 +315,7 @@ Or in a project's `.mcp.json`: | `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | | `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. | | `select {ids}` | Replace the selection (`[]` deselects). | -| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | +| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | | `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. | | `undo` / `redo` | Step through history. | From a6a8d7149cb2a70b2bec86c0b5544b77a8b70785 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:52:14 +0000 Subject: [PATCH 104/142] Scale the path of text on a path in transform.scale Found while testing crates/engine/src/cmd/arrange.rs:~495 (scale_node, the Text branch). Compare crates/layout/src/lib.rs:136, which calls TextPath::scale on resize. Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/arrange.rs | 3 +++ crates/engine/src/draw_tools_tests.rs | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 10ad13e5..72f2118a 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -494,6 +494,9 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { t.paragraph_spacing *= k; t.paragraph_indent *= k; t.list_spacing *= k; + if let Some(tp) = &mut t.path { + tp.scale(k, k); + } } if let NodeKind::Vector { network } = &mut n.kind { network.scale(k, k); diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index d67b0af4..adfbb32d 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -60,6 +60,18 @@ fn text_goes_onto_a_path_and_off_again() { assert!(s.doc().unwrap().node(v).is_some() && text_path(&s, t).is_none(), "undo brings the path back"); } +#[test] +fn scaling_text_scales_its_path() { + let mut s = Session::new(); + let t = id(s.execute("text.create", &json!({"text": "Around", "x": 0, "y": 0})).unwrap()); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100})).unwrap()); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let w0 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width(); + s.execute("transform.scale", &json!({"ids": [t], "factor": 2.0})).unwrap(); + let w1 = figcraft_geom::Shape::bounding_box(&text_path(&s, t).unwrap().path()).width(); + assert!((w1 - w0 * 2.0).abs() < 1.0, "path width {w0} -> {w1}"); +} + #[test] fn svg_text_paths_import_as_text_on_a_path() { // SVG text is parsed with Inter standing in for every family (CI has no fonts). From 2ef3ebb45d225217d1031d32e424bb61f15721aa Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 05:17:15 +0100 Subject: [PATCH 105/142] E21: require release font coverage and stabilize capture deadline tests --- .github/workflows/ci.yml | 9 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 9 +- ATTRIBUTION.md | 7 +- ROADMAP.md | 5 +- crates/text/build.rs | 173 +++++++++++++++--- crates/text/tests/build_input.rs | 4 + crates/text/tests/fixtures/latin-families.txt | 52 ++++++ crates/ui-egui/src/idle_tests.rs | 7 +- crates/ui-egui/src/windows_tests.rs | 4 +- docs/egui-037-local-followups.md | 51 ++++-- docs/egui-037-migration-plan.md | 4 + docs/releasing.md | 13 +- 13 files changed, 274 insertions(+), 66 deletions(-) create mode 100644 crates/text/tests/build_input.rs create mode 100644 crates/text/tests/fixtures/latin-families.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7abae66a..b79af502 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,10 @@ jobs: # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: - # The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter): - # crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts' - # pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin - # once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 + require-craft-fonts: true packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index e030b167..80adf16a 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,4 +32,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: run-package-check: true - craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b37ee39..f93219fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,13 +24,12 @@ jobs: with: app-name: figcraft app-title: FigCraft - # Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only - # craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. - # Re-pin once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 has-server: true # The browser has no system fonts: without craft-fonts the web build can't draw text (#885). - # crates/text/build.rs keeps the web build to Inter. + # crates/text/build.rs keeps the web allowlist small (Inter and Georgian). web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index bf6fdece..46b789a8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -26,9 +26,12 @@ Release builds embed fonts from the optional craft-fonts build input |---|---|---| | Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter | | Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) | -| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | +| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | | Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts | -| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | +| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | + +The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4. +FigCraft filters both families from every build in accordance with its asset policy. egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates. diff --git a/ROADMAP.md b/ROADMAP.md index a27cc4df..0af8cbac 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 (Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -132,6 +132,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. + - **2026-10-24** · 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. @@ -329,6 +331,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | 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 | diff --git a/crates/text/build.rs b/crates/text/build.rs index f46547cd..ecca6a45 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -1,6 +1,7 @@ //! Embeds the optional craft-fonts build input (`CRAFT_FONTS_DIR`): both `fonts/manifest.txt` and the //! opt-in Latin presentation fonts in `fonts/latin-manifest.txt` (Inter, Roboto, …). Unset or missing, -//! `CRAFT_FONTS` is empty and the app uses system fonts. Recipe: craft-fonts `docs/integration.md`. +//! `CRAFT_FONTS` is empty and native apps use system fonts. Required web releases enforce Inter +//! Regular/SemiBold and Noto Sans Georgian Regular after filtering. Recipe: craft-fonts `docs/integration.md`. //! Noto Sans CJK only with `FIGCRAFT_EMBED_CJK` set (the web build's only way to show CJK UI text). use std::fmt::Write as _; use std::path::PathBuf; @@ -10,30 +11,13 @@ fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); println!("cargo::rerun-if-env-changed=FIGCRAFT_EMBED_CJK"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); - if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - let mut ok = false; - for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] { - // Presentation faces are an optional extension of craft-fonts. Required mode - // validates the core manifest and every extension that is actually supplied. - let path = dir.join(manifest); - if manifest == "fonts/latin-manifest.txt" && !path.exists() { - watch(&path); - continue; - } - match craft_fonts(&dir, manifest) { - Ok(entries) => { - ok = true; - src.push_str(&entries); - } - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } - Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()), - } - } - if !ok { - println!("cargo::warning=building without craft-fonts (once it is there, `cargo clean -p figcraft-text` makes the next build use it)"); - } + let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let embed_cjk = std::env::var_os("FIGCRAFT_EMBED_CJK").is_some(); + match input_fonts(dir.as_deref(), wasm, required, embed_cjk) { + Ok(entries) => src.push_str(&entries), + Err(e) => println!("cargo::error={e}"), } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); @@ -42,13 +26,45 @@ fn main() { } } +/// Collect the effective catalog and enforce the required web release faces after filtering. +fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_cjk: bool) -> Result { + let Some(dir) = dir else { + return if required { Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()) } else { Ok(String::new()) }; + }; + let mut out = String::new(); + let mut faces = Vec::new(); + for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] { + let path = dir.join(manifest); + if manifest == "fonts/latin-manifest.txt" && !path.exists() { + watch(&path); + continue; + } + match craft_fonts(dir, manifest, wasm, embed_cjk) { + Ok(catalog) => { + out.push_str(&catalog.entries); + faces.extend(catalog.faces); + } + Err(e) if required => return Err(format!("CRAFT_FONTS_DIR={}: {e}", dir.display())), + Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()), + } + } + if required && wasm { + for (family, style) in [("Inter", "Regular"), ("Inter", "SemiBold"), ("Noto Sans Georgian", "Regular")] { + if !faces.iter().any(|(f, s)| f == family && s == style) { + return Err(format!("required web font missing: {family} {style}; CRAFT_FONTS_DIR must include both Latin and core release faces")); + } + } + } + Ok(out) +} + /// Rebuild when `manifest` changes or, while it is missing, when it is added to its folder. /// /// Cargo takes a watched path that doesn't exist as changed on every build, and then rebuilds this /// crate and every crate that depends on it each time. So a missing manifest is never watched /// itself: its folder is, when there is one (a craft-fonts checkout without the Latin manifest). /// With no craft-fonts at all there is nothing to watch, so the build can't notice it arriving: -/// the warning in `main` says how to pick it up. +/// rebuild this crate after adding a previously absent checkout. fn watch(manifest: &std::path::Path) { let watched = if manifest.exists() { Some(manifest) } else { manifest.parent().filter(|folder| folder.is_dir()) }; if let Some(path) = watched { @@ -56,20 +72,29 @@ fn watch(manifest: &std::path::Path) { } } -fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result { +struct Catalog { + entries: String, + faces: Vec<(String, String)>, +} + +fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result { let manifest = dir.join(manifest_rel); watch(&manifest); let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; - let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); let mut out = String::new(); + let mut faces = Vec::new(); for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; + // FigCraft's asset policy excludes Adobe-authored visual assets on every target. + if matches!(*family, "Source Sans 3" | "Source Serif 4") { + continue; + } // CJK fonts are large; the desktop app finds system CJK fonts instead unless asked. let cjk = scripts.contains("Hans"); - if cjk && std::env::var_os("FIGCRAFT_EMBED_CJK").is_none() { + if cjk && !embed_cjk { continue; } // Web builds have a size budget: Inter, Noto Sans Georgian (small, for the Georgian UI), only the @@ -80,7 +105,95 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result Self { + let dir = std::env::temp_dir().join(format!("fig-font-input-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(dir.join("fonts")).unwrap(); + // The build script validates manifest/file presence; font bytes are checked centrally. + std::fs::write(dir.join("fonts/face.ttf"), b"synthetic build-input fixture").unwrap(); + let fixture = Self(dir); + fixture.manifest("manifest.txt", &[("Noto Sans Georgian", "Regular")]); + fixture + } + + fn manifest(&self, name: &str, faces: &[(&str, &str)]) { + let text = faces.iter().map(|(family, style)| format!("{family} | {style} | fonts/face.ttf | Latn\n")).collect::(); + std::fs::write(self.0.join("fonts").join(name), text).unwrap(); + } + } + + impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() { + let f = Fixture::new(); + std::fs::write(f.0.join("fonts/latin-manifest.txt"), include_str!("tests/fixtures/latin-families.txt")).unwrap(); + let native = craft_fonts(&f.0, "fonts/latin-manifest.txt", false, false).unwrap(); + assert_eq!(native.faces.len(), 43, "all prior permitted presentation faces remain available"); + let web = craft_fonts(&f.0, "fonts/latin-manifest.txt", true, false).unwrap(); + assert_eq!(web.faces.len(), 4, "the web catalog keeps only the four Inter faces"); + for catalog in [&native, &web] { + assert!(!catalog.entries.contains("Source Sans 3")); + assert!(!catalog.entries.contains("Source Serif 4")); + } + assert!(native.faces.iter().any(|(family, _)| family == "Carlito")); + assert!(native.faces.iter().any(|(family, _)| family == "Liberation Serif")); + assert!(input_fonts(Some(&f.0), true, true, false).is_ok()); + } + + #[test] + fn required_web_rejects_core_only_and_partial_latin_catalogs() { + let f = Fixture::new(); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter Regular")); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Unrelated", "SemiBold")]); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold")); + } + + #[test] + fn required_web_embeds_both_catalogs_and_requires_georgian() { + let f = Fixture::new(); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]); + let entries = input_fonts(Some(&f.0), true, true, false).unwrap(); + assert_eq!(entries.matches("CraftFont {").count(), 3); + assert!(entries.contains("family: \"Inter\"")); + assert!(entries.contains("family: \"Noto Sans Georgian\"")); + f.manifest("manifest.txt", &[("Other", "Regular")]); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular")); + } + + #[test] + fn optional_and_native_inputs_keep_their_existing_policy() { + let f = Fixture::new(); + assert!(input_fonts(None, true, false, false).unwrap().is_empty()); + assert!(input_fonts(None, true, true, false).unwrap_err().contains("CRAFT_FONTS_DIR is missing")); + assert!(input_fonts(Some(&f.0), true, false, false).unwrap().contains("Noto Sans Georgian")); + assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian")); + } + + #[test] + fn required_web_rejects_missing_asset_even_with_all_face_rows() { + let f = Fixture::new(); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]); + std::fs::remove_file(f.0.join("fonts/face.ttf")).unwrap(); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("fonts/face.ttf")); + } } diff --git a/crates/text/tests/build_input.rs b/crates/text/tests/build_input.rs new file mode 100644 index 00000000..6f87ac1f --- /dev/null +++ b/crates/text/tests/build_input.rs @@ -0,0 +1,4 @@ +//! Exercise the actual build-script catalog policy without changing process environment. +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/crates/text/tests/fixtures/latin-families.txt b/crates/text/tests/fixtures/latin-families.txt new file mode 100644 index 00000000..e9db6f41 --- /dev/null +++ b/crates/text/tests/fixtures/latin-families.txt @@ -0,0 +1,52 @@ +# Synthetic paths; family/style inventory from craft-fonts 76ba5a0496503c0e2841abd626295038cbe07cde. +Inter | Regular | fonts/face.ttf | Latn +Inter | Italic | fonts/face.ttf | Latn +Inter | SemiBold | fonts/face.ttf | Latn +Inter | Bold | fonts/face.ttf | Latn +Carlito | Regular | fonts/face.ttf | Latn +Carlito | Bold | fonts/face.ttf | Latn +Carlito | Italic | fonts/face.ttf | Latn +Carlito | Bold Italic | fonts/face.ttf | Latn +Caladea | Regular | fonts/face.ttf | Latn +Caladea | Bold | fonts/face.ttf | Latn +Caladea | Italic | fonts/face.ttf | Latn +Caladea | Bold Italic | fonts/face.ttf | Latn +Liberation Sans | Regular | fonts/face.ttf | Latn +Liberation Sans | Bold | fonts/face.ttf | Latn +Liberation Sans | Italic | fonts/face.ttf | Latn +Liberation Sans | Bold Italic | fonts/face.ttf | Latn +Liberation Serif | Regular | fonts/face.ttf | Latn +Liberation Serif | Bold | fonts/face.ttf | Latn +Liberation Serif | Italic | fonts/face.ttf | Latn +Liberation Serif | Bold Italic | fonts/face.ttf | Latn +Liberation Mono | Regular | fonts/face.ttf | Latn +Liberation Mono | Bold | fonts/face.ttf | Latn +Liberation Mono | Italic | fonts/face.ttf | Latn +Liberation Mono | Bold Italic | fonts/face.ttf | Latn +Source Sans 3 | Regular | fonts/face.ttf | Latn +Source Sans 3 | Italic | fonts/face.ttf | Latn +Source Sans 3 | Semibold | fonts/face.ttf | Latn +Source Sans 3 | Bold | fonts/face.ttf | Latn +Source Serif 4 | Regular | fonts/face.ttf | Latn +Source Serif 4 | Italic | fonts/face.ttf | Latn +Source Serif 4 | Semibold | fonts/face.ttf | Latn +Source Serif 4 | Bold | fonts/face.ttf | Latn +Montserrat | Variable | fonts/face.ttf | Latn +Montserrat | Italic Variable | fonts/face.ttf | Latn +Lato | Regular | fonts/face.ttf | Latn +Lato | Bold | fonts/face.ttf | Latn +Lato | Italic | fonts/face.ttf | Latn +Open Sans | Variable | fonts/face.ttf | Latn +Open Sans | Italic Variable | fonts/face.ttf | Latn +Roboto | Variable | fonts/face.ttf | Latn +Roboto | Italic Variable | fonts/face.ttf | Latn +Merriweather | Regular | fonts/face.ttf | Latn +Merriweather | Bold | fonts/face.ttf | Latn +Merriweather | Italic | fonts/face.ttf | Latn +Playfair Display | Variable | fonts/face.ttf | Latn +Playfair Display | Italic Variable | fonts/face.ttf | Latn +Poppins | Regular | fonts/face.ttf | Latn +Poppins | Bold | fonts/face.ttf | Latn +Poppins | Italic | fonts/face.ttf | Latn +Nunito Sans | Variable | fonts/face.ttf | Latn +Nunito Sans | Italic Variable | fonts/face.ttf | Latn diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 4c35ae40..c5da205c 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -345,12 +345,15 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { fn a_hidden_window_answers_at_once() { // #269, #720: minimised or covered, the window draws no frames, but the app's logic still runs: // it answers edits, inspection, saves and the interface's state; what needs a frame (a capture - // of the window) is an error at once instead of waiting for the window to show. + // of the window) queues for a forced hidden paint and otherwise expires at its deadline. let dir = std::env::temp_dir().join(format!("figcraft-hidden-save-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("hidden.figcraft"); let (mut h, _) = document(); h.settle(); + // Exercise admission well after startup, independently of settling speed. + h.time += 120.0; + h.app.clock.set_test_now(Some(h.time)); let (tx, rx) = std::sync::mpsc::channel(); let request = |id: u64, method: &str, params: Value| ControlRequest { id: json!(id), @@ -376,7 +379,7 @@ fn a_hidden_window_answers_at_once() { assert_eq!(h.app.capture.shots.len(), 1, "one admitted capture"); assert!(h.app.control_queue.is_empty(), "nothing waits for the window"); // Its deadline expires it once, with the same error the transport used to get. - h.app.clock.set_test_now(Some(crate::control::SHOT_TIMEOUT + 1.0)); + h.app.clock.set_test_now(Some(h.time + crate::control::SHOT_TIMEOUT + 1.0)); let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx)); let shot = rx.try_recv().expect("expired"); assert_eq!(shot.id, json!(3)); diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 1537f426..6da3a011 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -137,6 +137,8 @@ fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { // A capture that needs a frame is admitted and asks the hidden window to paint, rather than // failing at once; only its deadline expires it (once). + // Model a request admitted long after startup, without waiting in real time. + app.clock.set_test_now(Some(120.0)); let shot = request(&mut app, "ui.screenshot", json!({"window": w, "path": "unused.png"})); let unknown = request(&mut app, "window.list", json!({"window": 99})); logic(&ctx, &mut app, &[ViewportId::ROOT, other]); @@ -145,7 +147,7 @@ fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { assert!(unknown.try_recv().expect("answered").result.unwrap_err().contains("no window 99")); assert!(app.control_queue.is_empty()); // The whole request lifetime is over: one timeout reply, and nothing retained. - app.clock.set_test_now(Some(crate::control::SHOT_TIMEOUT + 1.0)); + app.clock.set_test_now(Some(app.clock.now() + crate::control::SHOT_TIMEOUT + 1.0)); logic(&ctx, &mut app, &[ViewportId::ROOT, other]); assert!(shot.try_recv().expect("answered").result.unwrap_err().contains("didn't deliver a screenshot")); assert!(app.capture.shots.is_empty(), "released after its deadline"); diff --git a/docs/egui-037-local-followups.md b/docs/egui-037-local-followups.md index dde4e5c1..670fc30f 100644 --- a/docs/egui-037-local-followups.md +++ b/docs/egui-037-local-followups.md @@ -1,7 +1,24 @@ # egui 0.37 local implementation — stage 1 notes and follow-ups +Current candidate font pin: `37827adc41fd165f9a9234ac40f94dbad353aa69` restores the complete prior Latin catalog +alongside the migration fallbacks. Central font tests and license checks passed; +publication is pending approval. Managed run `egui037-figcraft-latin-ci-20261025-0711` +passed with required fonts: five targeted build-input tests plus full CI, including +WASM and plugin builds (3,257 passing executions, zero failures, nine ignored across +72 summaries; repeated tests are counted). This supersedes the optional-font-only +qualification below for the restored catalog. + ## Current local qualification — 2026-10-25 +The published refresh preserves main `9f788753` and passed managed full CI +`egui037-figcraft-main-refresh-ci-20261025-0618`: 3,247 passing executions, zero +failures and nine ignored. The 3,242 counts below describe the earlier `10702311` +qualification, before five additional main-branch tests. These are execution counts, +not distinct regressions. The release font input is under review separately; a +passing optional-font host build does not qualify the required web font catalog. + +### Earlier qualification at `10702311` + The migration preserves captured main `10702311f345393ee1d8868c3257679befcfa14d` (53 commits and 114 paths beyond the first integration). The final review commit records both the migration history and that upstream target as parents. The selected egui revision is @@ -147,19 +164,19 @@ an unhandled command for hidden tabs. Hidden-browser-tab `ui.screenshot`/`ui.inp unqualified even though the native path now queues and settles them. This is a forkfix prerequisite, not a passing claim. -## Local dependency and lock prerequisites (parent) +## Dependency and lock prerequisites -- Parent config `local-integration-config.toml` patches the egui family and winit, and `craft-ui`/ - `craft-print`. It does **not** patch `craft-window`. FigCraft now disables craft-window's optional - `egui` feature (it only needs the frame probe), but Cargo still resolves a package's optional - dependencies into the lock, and that rev declares `egui = "0.36"`. The managed graph must - therefore also patch/repoint `craft-window` to the reviewed craft-libs revision whose workspace - targets 0.37 (or otherwise prove the lock has exactly one egui family); otherwise the lock will - contain a second egui 0.36. -- No `[patch]` rows or path dependencies live in this repository's manifests; the parent generates - `Cargo.lock`. Do not publish this tree's resolved paths. -- Fonts: production keeps its central `CRAFT_FONTS_DIR`/system-font policy; no font binaries are - added here. Color-emoji claims still require a separate `epaint/color_fonts` feature job. +- The committed root `[patch.crates-io]` table pins the egui family and winit to + immutable published fork revisions. `craft-window` is pinned to the reviewed + craft-libs revision targeting egui 0.37; its optional `egui` feature stays disabled + because FigCraft needs only the frame probe. +- `Cargo.lock` is committed and resolves the canonical Git sources without local + path overrides. Use `--locked`; the earlier parent integration config is not + required to reproduce this graph. +- Fonts remain an optional central `CRAFT_FONTS_DIR` input with native system-font + fallback; no font binaries are added here. Required release-font qualification + is separate from optional-font host CI. Color emoji still needs a separate + `epaint/color_fonts` feature check. ## Qualification gaps and baseline backports/perf operations @@ -182,6 +199,12 @@ operations to measure once the parent can build: `#8731` text tessellation, `#87 startup, `#8512` font discovery, `#8763`/PR `#8764` TextEdit click-away, `#8748` hidden deferred children. None of these were applied, removed or claimed as fixed. -## Optional Latin manifest compatibility +## Required web font catalog -The pinned central font catalog has a required `fonts/manifest.txt` and no optional `fonts/latin-manifest.txt`. Required-font builds now validate the core and all supplied extension assets, while an absent opt-in Latin manifest is watched but not required. This follows the existing build script documentation and keeps malformed or incomplete supplied manifests fatal in required mode. No font binary was copied into FigCraft. Both required-core and no-font configurations remain validation gates. +Native optional-font builds retain the system-font policy. A required web build must +embed Inter Regular and SemiBold plus Noto Sans Georgian Regular after filtering; +missing required face/style coverage fails the build, regardless of which manifest supplies it. Required mode +also rejects an unset `CRAFT_FONTS_DIR`. The migration's original `b076417` catalog +lacks Inter and is not a qualified required-web input. A combined central font pin restores the prior Latin catalog while preserving the new +core fallbacks. Source Sans 3 and Source Serif 4 are excluded on every target; no font binary belongs in this repository. Required-web, required-native +and optional-font configurations remain separate validation gates. diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index fc7ecbc4..27ce7c5c 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -1188,6 +1188,10 @@ validation and generates the lockfile from its local dependency config. ## Current local qualification — 2026-10-25 +Historical qualification: the following counts apply to the earlier `10702311` +integration. The current `9f788753` refresh and its 3,247 passing executions are +recorded in the status update above. + The migration preserves captured main `10702311f345393ee1d8868c3257679befcfa14d` (53 commits and 114 paths beyond the first integration). The final review commit records both the migration history and that upstream target as parents. The selected egui revision is diff --git a/docs/releasing.md b/docs/releasing.md index 07492f6c..3dbc0fbf 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -101,11 +101,14 @@ builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUIRED=1`, so releases embed it rather than ship without them. The browser has no system fonts, so a web build without craft-fonts can't draw text. `crates/text/build.rs` reads both `fonts/manifest.txt` and `fonts/latin-manifest.txt`, so the pin -must be a commit that has the Latin manifest (today `4391305`, on craft-fonts' -`pr/latin-presentation-fonts` branch; re-pin to `main` once that lands). That commit has no -Noto Sans Georgian (craft-fonts' main has it but no Latin manifest), so desktop Georgian uses an -installed font; re-pin when one commit has both (craft-fonts#42). The web build embeds only Inter, -Noto Sans Georgian and the Archivo SemiBold wordmark, when the pin has them. Packages carry each embedded font's licence as `OFL-.txt` +must combine the Latin presentation catalog and the current core UI faces. Required web builds +validate the emitted Inter Regular/SemiBold and Noto Sans Georgian Regular faces after filtering; +a missing required face, style or asset fails the required web build. Excluded Adobe rows +are skipped before resolving their asset paths. +Native builds retain the permitted Latin presentation faces. Source Sans 3 and Source Serif 4 +are filtered on every target under FigCraft's asset policy. The web build keeps its smaller +allowlist: Inter, Noto Sans Georgian and Archivo SemiBold when supplied. Packages carry each +embedded font's licence as `OFL-.txt` (`copy_font_licences` in `packaging/env.sh`; `Contents/Resources/Licenses` in the macOS app). ### macOS From d7c7986347c85d27b2fe80411dd5b04003e108c0 Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 05:32:03 +0100 Subject: [PATCH 106/142] E21: separate capture, IME and field-save regression phases --- crates/ui-egui/src/control.rs | 414 +++++++++++++++++------------- crates/ui-egui/src/field_tests.rs | 280 ++++++++++---------- crates/ui-egui/src/shortcuts.rs | 300 ++++++++++++---------- 3 files changed, 547 insertions(+), 447 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index fdecaf18..b7ca225d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -455,171 +455,18 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { let viewport = ctx.viewport_id(); // This pass's geometry defines the epoch: a resize, DPI change or zoom change (back or not) // invalidates captures issued under the old one. - let spec = current_spec(ctx, viewport); - if let Some(spec) = spec { - let changed = app.capture.geometry.get(&viewport).is_none_or(|(old, _)| *old != spec); - if changed { - app.capture.epoch = app.capture.epoch.wrapping_add(1); - app.capture.geometry.insert(viewport, (spec, app.capture.epoch)); - } - } - let epoch = app.capture.geometry.get(&viewport).map_or(0, |(_, e)| *e); + let (spec, epoch) = pass_geometry(app, ctx, viewport); expire(app, ctx, now); // Drain completed captures first, so their budget is free before new requests are admitted. - let mut i = 0; - while i < app.capture.shots.len() { - // Each viewport drains its own captures: their geometry and document differ. - if app.capture.shots[i].viewport != viewport { - i += 1; - continue; - } - let Some(slot) = app.capture.shots[i].slot.clone() else { - i += 1; - continue; - }; - let Some(image) = slot.take() else { - i += 1; - continue; - }; - let mut r = app.capture.shots.remove(i); - r.release(); - let cancelled = r.cancelled.load(std::sync::atomic::Ordering::Relaxed); - let current = current_spec(ctx, viewport); - let (document, presentation) = app.windows.presentation(&app.session, r.window); - if cancelled { - // The transport stopped waiting: no reply, no file. - } else if now >= r.deadline { - reply(&mut r, Err("the window didn't deliver a screenshot in time (is it visible?)".into())); - } else if r.window != app.windows.current || r.document != document || r.presentation != presentation { - reply(&mut r, Err("the document changed while the screenshot was being captured".into())); - } else if current.is_none() { - // Fail closed: with no representable current geometry the pixels cannot be proven to - // belong to the surface that was admitted, so they are never the capture's reply. - reply(&mut r, Err("the window's current geometry is unavailable (is it minimised?)".into())); - } else if let Some(current) = current - && (r.epoch != epoch || r.spec.is_none_or(|s| s.size_px != current.size_px || s.pixels_per_point != current.pixels_per_point)) - { - // Geometry moved between issuance and arrival (even back): retry against the new pass, - // never accept the old pixels. Document changes are errors above. - if r.retries >= 2 { - reply(&mut r, Err("the window kept changing size while the screenshot was captured".into())); - } else { - r.retries += 1; - r.spec = Some(current); - r.epoch = epoch; - r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); - r.settle = 0; - r.settle_frame = None; - r.issued = false; - app.capture.shots.push(r); - } - } else if let (Some(current), Some(spec)) = (current, r.spec) - // The admitted surface is in whole UI points; the readback is in physical pixels. Up - // to one pixel of rounding either way is the same surface, not a scale change. The - // difference is absolute: an undersized readback is a scale mismatch too. - && (image.size[0].abs_diff(spec.size_px[0]) > 1 || image.size[1].abs_diff(spec.size_px[1]) > 1) - { - if r.retries >= 2 { - reply( - &mut r, - Err(format!( - "the screenshot is {}x{} but the window is {}x{} (a scale mismatch)", - image.size[0], image.size[1], spec.size_px[0], spec.size_px[1] - )), - ); - } else { - r.retries += 1; - r.spec = Some(current); - r.epoch = epoch; - r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); - r.settle = 0; - r.settle_frame = None; - r.issued = false; - app.capture.shots.push(r); - } - } else { - let result = shot_reply(&image, r.path.as_deref()); - reply(&mut r, result); - } - ctx.request_repaint(); - } + drain_completed(app, ctx, viewport, now, epoch); // Admit queued captures for the window drawing now; other windows keep waiting for their frame. - for r in std::mem::take(&mut app.control_queue) { - if r.is_cancelled() { - continue; - } - let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); - if target != app.windows.current { - if app.windows.exists(target) { - ctx.request_repaint_of(crate::windows::viewport(target)); - app.control_queue.push(r); - } else { - let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); - } - continue; - } - if r.method == "ui.screenshot" { - admit(app, r, target, now); - continue; - } - // Every control-channel gesture is fenced from the user's system clipboard (the flag is - // restored so a batch already in a frame's raw input keeps its own provenance). - let before = app.control_synthetic; - app.control_synthetic = true; - let res = handle(app, ctx, &r.method, &r.params); - app.control_synthetic = before; - if r.method == "ui.input" && std::mem::take(&mut app.control_input_queued) { - // Acknowledge once the pass that applies the batch is presented, never from a sizing - // pass whose input is thrown away. - app.input_acks.push(crate::PendingAck { viewport, tx: r.reply, reply: ControlReply { id: r.id, result: res } }); - } else { - let _ = r.reply.send(ControlReply { id: r.id, result: res }); - } - ctx.request_repaint(); - } + admit_queued(app, ctx, viewport, now); // Fill in this pass's geometry, enforce the byte budget (a post-callback check alone would // not bound peak memory), and release anything over it before issuing. - let mut i = 0; - while i < app.capture.shots.len() { - let total: usize = app.capture.shots.iter().map(|s| s.reserved).sum(); - let mut remove = false; - { - let r = &mut app.capture.shots[i]; - if r.viewport != viewport { - i += 1; - continue; - } - if !r.issued && r.spec != spec { - // A request can resize during its settling frames. Reserve and validate this - // actual surface before issuance, never the old (possibly much smaller) one. - r.spec = spec; - r.settle = 0; - r.settle_frame = None; - if spec.is_none() { - r.reserved = 0; - } - } - if let Some(spec) = r.spec { - let raw = spec.bytes().unwrap_or(usize::MAX); - let cost = spec.cost().unwrap_or(usize::MAX); - if raw > MAX_SHOT_IMAGE_BYTES || total.saturating_sub(r.reserved).saturating_add(cost) > MAX_SHOT_TOTAL_BYTES { - reply(r, Err(format!("the screenshot needs {cost} bytes, over the capture budget"))); - remove = true; - } else if r.reserved != cost { - r.reserved = cost; - } - } - } - if remove { - let mut r = app.capture.shots.remove(i); - r.release(); - } else { - i += 1; - } - } + enforce_capture_budget(app, viewport, spec); // Settle on completed UI frames only, then issue once. (The screenshot command itself forces // the paint that delivers it.) The request's document and window were fixed at admission and @@ -630,6 +477,243 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { // first pass calls `request_discard` is therefore counted once, after it presents its layout, // not when its discarded sizing pass runs. Passes after the first (`current_pass_index > 0`) // belong to such a frame and never add another count. + settle_and_issue(app, ctx, viewport, now, epoch, spec); + request_paint(app, ctx); +} + +/// Establish this pass's screenshot geometry and epoch: a resize, DPI change or zoom change (even +/// back) bumps the epoch captures issued under the old one were admitted against. +fn pass_geometry(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId) -> (Option, u64) { + let spec = current_spec(ctx, viewport); + if let Some(spec) = spec { + let changed = app.capture.geometry.get(&viewport).is_none_or(|(old, _)| *old != spec); + if changed { + app.capture.epoch = app.capture.epoch.wrapping_add(1); + app.capture.geometry.insert(viewport, (spec, app.capture.epoch)); + } + } + let epoch = app.capture.geometry.get(&viewport).map_or(0, |(_, e)| *e); + (spec, epoch) +} + +/// Take the image delivery for the shot at `index`; `None` when no image has arrived yet. +fn arrived_shot(app: &mut FigApp, index: usize) -> Option<(ShotRequest, Arc)> { + let slot = app.capture.shots.get(index)?.slot.clone()?; + let image = slot.take()?; + let mut r = app.capture.shots.remove(index); + r.release(); + Some((r, image)) +} + +/// Drain images delivered for this viewport: reply, re-queue for a geometry retry, or drop a +/// cancelled capture. Each viewport drains its own captures: their geometry and document differ. +fn drain_completed(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64) { + let mut i = 0; + while i < app.capture.shots.len() { + if app.capture.shots[i].viewport != viewport { + i += 1; + continue; + } + let Some((r, image)) = arrived_shot(app, i) else { + i += 1; + continue; + }; + settle_arrival(app, ctx, viewport, now, epoch, r, &image); + ctx.request_repaint(); + } +} + +/// Whether the pass geometry differs from the one the capture was issued at (even back to an old +/// size): the pixels cannot be accepted for the admitted surface. +fn geometry_moved(r: &ShotRequest, current: CaptureSpec, epoch: u64) -> bool { + r.epoch != epoch || r.spec.is_none_or(|s| s.size_px != current.size_px || s.pixels_per_point != current.pixels_per_point) +} + +/// The admitted surface is in whole UI points; the readback is in physical pixels. Up to one +/// pixel of rounding either way is the same surface, not a scale change. The difference is +/// absolute: an undersized readback is a scale mismatch too. +fn scale_mismatch(image: &egui::ColorImage, spec: CaptureSpec) -> bool { + image.size[0].abs_diff(spec.size_px[0]) > 1 || image.size[1].abs_diff(spec.size_px[1]) > 1 +} + +/// Re-queue a capture against the new pass, or hand it back when its retries are exhausted. +fn requeue_capture(app: &mut FigApp, mut r: ShotRequest, current: CaptureSpec, epoch: u64) -> Option { + if r.retries >= 2 { + return Some(r); + } + r.retries += 1; + r.spec = Some(current); + r.epoch = epoch; + r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); + r.settle = 0; + r.settle_frame = None; + r.issued = false; + app.capture.shots.push(r); + None +} + +/// Settle an arrived image: reply with it, retry against the new pass on a geometry/scale change, +/// or drop it when the transport cancelled or the window/document moved on. +fn settle_arrival(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, mut r: ShotRequest, image: &egui::ColorImage) { + let cancelled = r.cancelled.load(std::sync::atomic::Ordering::Relaxed); + let current = current_spec(ctx, viewport); + let (document, presentation) = app.windows.presentation(&app.session, r.window); + if cancelled { + // The transport stopped waiting: no reply, no file. + return; + } + if now >= r.deadline { + reply(&mut r, Err("the window didn't deliver a screenshot in time (is it visible?)".into())); + return; + } + if r.window != app.windows.current || r.document != document || r.presentation != presentation { + reply(&mut r, Err("the document changed while the screenshot was being captured".into())); + return; + } + let Some(current) = current else { + // Fail closed: with no representable current geometry the pixels cannot be proven to + // belong to the surface that was admitted, so they are never the capture's reply. + reply(&mut r, Err("the window's current geometry is unavailable (is it minimised?)".into())); + return; + }; + if geometry_moved(&r, current, epoch) { + // Geometry moved between issuance and arrival (even back): retry against the new pass, + // never accept the old pixels. Document changes are errors above. + if let Some(mut r) = requeue_capture(app, r, current, epoch) { + reply(&mut r, Err("the window kept changing size while the screenshot was captured".into())); + } + return; + } + if let Some(spec) = r.spec + && scale_mismatch(image, spec) + { + if let Some(mut r) = requeue_capture(app, r, current, epoch) { + reply( + &mut r, + Err(format!( + "the screenshot is {}x{} but the window is {}x{} (a scale mismatch)", + image.size[0], image.size[1], spec.size_px[0], spec.size_px[1] + )), + ); + } + return; + } + let result = shot_reply(image, r.path.as_deref()); + reply(&mut r, result); +} + +/// Admit queued captures for the window drawing now; other windows keep waiting for their frame. +fn admit_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64) { + for r in std::mem::take(&mut app.control_queue) { + route_queued(app, ctx, viewport, now, r); + } +} + +/// Route one queued control request: retarget another window, admit a capture, or run a +/// state-only request for the window drawing now. +fn route_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, r: ControlRequest) { + if r.is_cancelled() { + return; + } + let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); + if target != app.windows.current { + if app.windows.exists(target) { + ctx.request_repaint_of(crate::windows::viewport(target)); + app.control_queue.push(r); + } else { + let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); + } + return; + } + if r.method == "ui.screenshot" { + admit(app, r, target, now); + return; + } + // Every control-channel gesture is fenced from the user's system clipboard (the flag is + // restored so a batch already in a frame's raw input keeps its own provenance). + let before = app.control_synthetic; + app.control_synthetic = true; + let res = handle(app, ctx, &r.method, &r.params); + app.control_synthetic = before; + if r.method == "ui.input" && std::mem::take(&mut app.control_input_queued) { + // Acknowledge once the pass that applies the batch is presented, never from a sizing + // pass whose input is thrown away. + app.input_acks.push(crate::PendingAck { viewport, tx: r.reply, reply: ControlReply { id: r.id, result: res } }); + } else { + let _ = r.reply.send(ControlReply { id: r.id, result: res }); + } + ctx.request_repaint(); +} + +/// Reserve this request's actual surface against the capture budget; `true` when it must be +/// removed for exceeding it. +fn reserve_for_spec(r: &mut ShotRequest, spec: Option, total: usize) -> bool { + if !r.issued && r.spec != spec { + // A request can resize during its settling frames. Reserve and validate this + // actual surface before issuance, never the old (possibly much smaller) one. + r.spec = spec; + r.settle = 0; + r.settle_frame = None; + if spec.is_none() { + r.reserved = 0; + } + } + let Some(spec) = r.spec else { return false }; + let raw = spec.bytes().unwrap_or(usize::MAX); + let cost = spec.cost().unwrap_or(usize::MAX); + if raw > MAX_SHOT_IMAGE_BYTES || total.saturating_sub(r.reserved).saturating_add(cost) > MAX_SHOT_TOTAL_BYTES { + reply(r, Err(format!("the screenshot needs {cost} bytes, over the capture budget"))); + return true; + } + if r.reserved != cost { + r.reserved = cost; + } + false +} + +/// Fill in this pass's geometry and enforce the byte budget (a post-callback check alone would +/// not bound peak memory), releasing anything over it before issuing. +fn enforce_capture_budget(app: &mut FigApp, viewport: egui::ViewportId, spec: Option) { + let mut i = 0; + while i < app.capture.shots.len() { + let total: usize = app.capture.shots.iter().map(|s| s.reserved).sum(); + let remove = { + let r = &mut app.capture.shots[i]; + if r.viewport != viewport { + i += 1; + continue; + } + reserve_for_spec(r, spec, total) + }; + if remove { + let mut r = app.capture.shots.remove(i); + r.release(); + } else { + i += 1; + } + } +} + +/// Count one completed frame into the capture's settling, reporting whether it may now be issued. +fn settled_for_issue(r: &mut ShotRequest, frame_nr: u64) -> bool { + match r.settle_frame { + None => { + // Anchor the count at the first pass that sees this request's geometry; this + // frame has not completed yet. + r.settle_frame = Some(frame_nr); + false + } + Some(seen) if frame_nr > seen => { + r.settle_frame = Some(frame_nr); + r.settle = r.settle.saturating_add(1); + r.settle >= SHOT_SETTLE_PASSES + } + _ => false, + } +} + +/// Settle on completed UI frames only, then issue once (against this pass's surface). +fn settle_and_issue(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, spec: Option) { let first_pass = ctx.current_pass_index() == 0; let frame_nr = ctx.cumulative_frame_nr_for(viewport); for r in &mut app.capture.shots { @@ -639,20 +723,7 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { if r.cancelled.load(std::sync::atomic::Ordering::Relaxed) || now >= r.deadline { continue; } - match r.settle_frame { - None => { - // Anchor the count at the first pass that sees this request's geometry; this - // frame has not completed yet. - r.settle_frame = Some(frame_nr); - continue; - } - Some(seen) if frame_nr > seen => { - r.settle_frame = Some(frame_nr); - r.settle = r.settle.saturating_add(1); - } - _ => continue, - } - if r.settle < SHOT_SETTLE_PASSES { + if !settled_for_issue(r, frame_nr) { continue; } let slot = CaptureSlot::new(ctx, viewport); @@ -665,7 +736,6 @@ pub fn drain(app: &mut FigApp, ctx: &Context) { r.issued = true; ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::Screenshot(CaptureSlot::callback(&slot))); } - request_paint(app, ctx); } /// A screenshot's reply: the PNG written to `path` (through the automation roots, when the app diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 68a6564b..e09df06e 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -872,148 +872,146 @@ fn a_prepared_field_save_cannot_commit_into_a_same_pass_replacement_document() { for route in ["control", "tab"] { for save_as in [false, true] { for discard in [false, true] { - let (mut u, a, _) = setup(); - let dir = std::env::temp_dir().join(format!("figcraft-field-save-owner-{}-{route}-{save_as}-{discard}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let (path, other, copy) = (dir.join("a.figcraft"), dir.join("b.figcraft"), dir.join("copy.figcraft")); - u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); - let owner = u.app.session.ds().unwrap().uid; - let owner_bytes = std::fs::read(&path).unwrap(); - u.app.session.execute("file.new", &json!({"name": "Same-pass B"})).unwrap(); - let b = id(u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 40, "width": 30, "height": 30})).unwrap()); - u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); - u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 10, "height": 10})).unwrap(); - u.app.session.execute("select.set", &json!({"ids": [b]})).unwrap(); - let other_index = u.app.session.active; - let other_uid = u.app.session.ds().unwrap().uid; - let other_name = u.app.session.doc().unwrap().name.clone(); - let other_model = u.app.session.ds().unwrap().doc.clone(); - let before_switch_revision = u.app.session.ds().unwrap().revision; - let other_history = u.app.session.undo_depth(); - let other_selection = u.app.session.ds().unwrap().selection.clone(); - let other_dirty = u.app.session.ds().unwrap().is_dirty(); - let other_bytes = std::fs::read(&other).unwrap(); - assert!(other_dirty, "B has independent unsaved content"); - let owner_index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); - u.app.session.execute("doc.switch", &json!({"index": owner_index})).unwrap(); - // Every command bumps the outgoing document's revision, even a nonediting switch. - let other_revision = u.app.session.docs.iter().find(|d| d.uid == other_uid).unwrap().revision; - assert_eq!(other_revision, before_switch_revision + 1, "the setup switch advances only B's command revision"); - let asked = std::rc::Rc::new(std::cell::Cell::new(0)); - u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); - let output = u.frame_output(vec![]); - let nodes = output.platform_output.accesskit_update.unwrap().nodes; - let tab = nodes - .iter() - .map(|(_, node)| node) - .find(|node| node.role() == Role::Tab && node.label() == Some(other_name.as_str())) - .expect("the actual B tab"); - let bounds = tab.bounds().unwrap(); - let tab_at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); - let (at, _) = field(&mut u, "X"); - click(&mut u, at); - assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); - assert!(u.ctx.text_edit_focused()); - u.frame(vec![Event::Text("77".into())]); - assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into())); - assert_eq!(geom(&u, a).0, 10.0, "A's raw draft is not committed yet"); - assert_eq!(u.app.session.ds().unwrap().uid, owner, "the save begins with A presented"); - let reply = if route == "control" { - let (reply, rx) = std::sync::mpsc::channel(); - u.app.control_queue.push(crate::ControlRequest { - id: json!("same-pass-switch"), - method: "engine.execute".into(), - params: json!({"command": "doc.switch", "params": {"index": other_index}, "window": 0}), - reply, - cancelled: Default::default(), - }); - Some(rx) - } else { - None - }; - let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; - if save_as { - modifiers = modifiers.plus(Modifiers::SHIFT); - } - let mut events = vec![ - Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, - Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, - ]; - if route == "tab" { - events.push(Event::PointerMoved(tab_at)); - for pressed in [true, false] { - events.push(Event::PointerButton { - pos: tab_at, - button: egui::PointerButton::Primary, - pressed, - modifiers: Default::default(), - }); - } - } - if discard { - events.insert(0, Event::ModifiersChanged(modifiers)); - u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() }); - } else { - u.frame_with(events, modifiers); - } - if let Some(reply) = reply { - let reply = reply.try_recv().expect("real control::drain acknowledged doc.switch in this pass"); - assert!(reply.result.is_ok(), "{:?}", reply.result); - } - let unchanged = |u: &Ui| { - assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the real same-pass route presents B"); - assert_eq!( - geom(u, b), - (200.0, 40.0, 30.0, 30.0), - "A's draft cannot enter B's field: route={route}, save_as={save_as}, discard={discard}" - ); - assert!(std::sync::Arc::ptr_eq(&u.app.session.ds().unwrap().doc, &other_model), "B's model is untouched"); - assert_eq!(u.app.session.ds().unwrap().revision, other_revision); - assert_eq!(u.app.session.undo_depth(), other_history, "B's undo/redo history is unchanged"); - assert_eq!(u.app.session.ds().unwrap().selection, other_selection); - assert_eq!(u.app.session.ds().unwrap().is_dirty(), other_dirty); - assert_eq!(std::fs::read(&other).unwrap(), other_bytes); - assert_eq!(std::fs::read(&path).unwrap(), owner_bytes); - assert_eq!(u.app.session.docs.iter().find(|d| d.uid == owner).unwrap().doc.world_bounds(a).unwrap().x0, 10.0); - assert_eq!(asked.get(), 0, "the cancelled request never asks for a path"); - assert!(!copy.exists()); - assert!(u.app.ui.after_field.is_none(), "cancel before late fallback or queueing"); - }; - unchanged(&u); - for _ in 0..3 { - u.frame(vec![]); - unchanged(&u); - } - // A cancelled draft is not a permanent veto: B accepts a later real field edit - // and saves its own value through the same focused-field shortcut path. - let (at, _) = field(&mut u, "X"); - click(&mut u, at); - assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); - u.frame(vec![Event::Text("88".into())]); - assert_eq!(geom(&u, b).0, 200.0, "fresh B text still waits for a normal commit"); - assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("88".into())); - u.frame_with( - vec![ - Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, - Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, - ], - modifiers, - ); - assert_eq!(geom(&u, b).0, 88.0, "a fresh B field commits normally"); - assert!(u.app.ui.after_field.is_some()); - for _ in 0..3 { - u.frame(vec![]); - } - assert_eq!(u.app.session.ds().unwrap().uid, other_uid); - assert_eq!(saved_x(if save_as { © } else { &other }, b), 88.0, "a later valid save belongs to B"); - if save_as { - assert_eq!(std::fs::read(&other).unwrap(), other_bytes); - } - assert_eq!(std::fs::read(&path).unwrap(), owner_bytes, "A stays unsaved"); - assert_eq!(asked.get(), usize::from(save_as)); - assert!(u.app.ui.after_field.is_none()); - std::fs::remove_dir_all(&dir).ok(); + prepared_save_cannot_enter_a_replacement(route, save_as, discard); } } } } + +/// One route/save-as/discard combination of the same-pass replacement test: a save prepared for A +/// must not commit A's cached X editor into B when routing presents B before the fields draw, +/// even though both panels use the same widget ID. +fn prepared_save_cannot_enter_a_replacement(route: &str, save_as: bool, discard: bool) { + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-field-save-owner-{}-{route}-{save_as}-{discard}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, other, copy) = (dir.join("a.figcraft"), dir.join("b.figcraft"), dir.join("copy.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let owner = u.app.session.ds().unwrap().uid; + let owner_bytes = std::fs::read(&path).unwrap(); + u.app.session.execute("file.new", &json!({"name": "Same-pass B"})).unwrap(); + let b = id(u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 40, "width": 30, "height": 30})).unwrap()); + u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 10, "height": 10})).unwrap(); + u.app.session.execute("select.set", &json!({"ids": [b]})).unwrap(); + let other_index = u.app.session.active; + let other_uid = u.app.session.ds().unwrap().uid; + let other_name = u.app.session.doc().unwrap().name.clone(); + let other_model = u.app.session.ds().unwrap().doc.clone(); + let before_switch_revision = u.app.session.ds().unwrap().revision; + let other_history = u.app.session.undo_depth(); + let other_selection = u.app.session.ds().unwrap().selection.clone(); + let other_dirty = u.app.session.ds().unwrap().is_dirty(); + let other_bytes = std::fs::read(&other).unwrap(); + assert!(other_dirty, "B has independent unsaved content"); + let owner_index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": owner_index})).unwrap(); + // Every command bumps the outgoing document's revision, even a nonediting switch. + let other_revision = u.app.session.docs.iter().find(|d| d.uid == other_uid).unwrap().revision; + assert_eq!(other_revision, before_switch_revision + 1, "the setup switch advances only B's command revision"); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + let output = u.frame_output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let tab = nodes + .iter() + .map(|(_, node)| node) + .find(|node| node.role() == Role::Tab && node.label() == Some(other_name.as_str())) + .expect("the actual B tab"); + let bounds = tab.bounds().unwrap(); + let tab_at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); + assert!(u.ctx.text_edit_focused()); + u.frame(vec![Event::Text("77".into())]); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into())); + assert_eq!(geom(&u, a).0, 10.0, "A's raw draft is not committed yet"); + assert_eq!(u.app.session.ds().unwrap().uid, owner, "the save begins with A presented"); + let reply = if route == "control" { + let (reply, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(crate::ControlRequest { + id: json!("same-pass-switch"), + method: "engine.execute".into(), + params: json!({"command": "doc.switch", "params": {"index": other_index}, "window": 0}), + reply, + cancelled: Default::default(), + }); + Some(rx) + } else { + None + }; + let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + if save_as { + modifiers = modifiers.plus(Modifiers::SHIFT); + } + let mut events = vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ]; + if route == "tab" { + events.push(Event::PointerMoved(tab_at)); + for pressed in [true, false] { + events.push(Event::PointerButton { pos: tab_at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + } + if discard { + events.insert(0, Event::ModifiersChanged(modifiers)); + u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() }); + } else { + u.frame_with(events, modifiers); + } + if let Some(reply) = reply { + let reply = reply.try_recv().expect("real control::drain acknowledged doc.switch in this pass"); + assert!(reply.result.is_ok(), "{:?}", reply.result); + } + let unchanged = |u: &Ui| { + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the real same-pass route presents B"); + assert_eq!(geom(u, b), (200.0, 40.0, 30.0, 30.0), "A's draft cannot enter B's field: route={route}, save_as={save_as}, discard={discard}"); + assert!(std::sync::Arc::ptr_eq(&u.app.session.ds().unwrap().doc, &other_model), "B's model is untouched"); + assert_eq!(u.app.session.ds().unwrap().revision, other_revision); + assert_eq!(u.app.session.undo_depth(), other_history, "B's undo/redo history is unchanged"); + assert_eq!(u.app.session.ds().unwrap().selection, other_selection); + assert_eq!(u.app.session.ds().unwrap().is_dirty(), other_dirty); + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + assert_eq!(std::fs::read(&path).unwrap(), owner_bytes); + assert_eq!(u.app.session.docs.iter().find(|d| d.uid == owner).unwrap().doc.world_bounds(a).unwrap().x0, 10.0); + assert_eq!(asked.get(), 0, "the cancelled request never asks for a path"); + assert!(!copy.exists()); + assert!(u.app.ui.after_field.is_none(), "cancel before late fallback or queueing"); + }; + unchanged(&u); + for _ in 0..3 { + u.frame(vec![]); + unchanged(&u); + } + // A cancelled draft is not a permanent veto: B accepts a later real field edit + // and saves its own value through the same focused-field shortcut path. + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); + u.frame(vec![Event::Text("88".into())]); + assert_eq!(geom(&u, b).0, 200.0, "fresh B text still waits for a normal commit"); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("88".into())); + u.frame_with( + vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ], + modifiers, + ); + assert_eq!(geom(&u, b).0, 88.0, "a fresh B field commits normally"); + assert!(u.app.ui.after_field.is_some()); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(u.app.session.ds().unwrap().uid, other_uid); + assert_eq!(saved_x(if save_as { © } else { &other }, b), 88.0, "a later valid save belongs to B"); + if save_as { + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + } + assert_eq!(std::fs::read(&path).unwrap(), owner_bytes, "A stays unsaved"); + assert_eq!(asked.get(), usize::from(save_as)); + assert!(u.app.ui.after_field.is_none()); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 9aa72c12..2abb1cfc 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -674,153 +674,185 @@ fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event], ime_input: Optio } match ev { Event::Text(t) => { - if skip_text { - skip_text = false; - continue; - } - let before = ime_stamp(app); - if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) { - app.toast(e.to_string()); - } - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); + if !std::mem::take(&mut skip_text) { + insert_text(app, ctx, t); } } - // An accepted IME commit earns an explicit receipt only around the exact mutation. - // List AutoFormat inside the same command is an application transformation. - Event::Ime(egui::ImeEvent::Commit(t)) => { - let before = ime_stamp(app); - match figcraft_engine::textedit::insert_ime_commit(&mut app.session, t) { - Ok(ImeInsertion::Plain) => ime_receipt(app, ctx, before), - Ok(ImeInsertion::Transformed) => note_ime_application_change(app, ctx), - Ok(ImeInsertion::Nothing) => {} - Err(e) => app.toast(e.to_string()), - } - } - // The composition shows inline at the caret; the document gets it on Commit. - Event::Ime(egui::ImeEvent::Preedit { text, active_range_chars }) => { - let cursor = active_range_chars.as_ref().map(|r| r.start); - if let Err(e) = figcraft_engine::textedit::preedit(&mut app.session, text, cursor) { - app.toast(e.to_string()); - } - } - // An IME replacing text around the caret (0.37's incoming byte-counted deletion) - // deletes it before the paired Commit inserts the replacement. - Event::Ime(egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes }) => { - let before = ime_stamp(app); - match figcraft_engine::textedit::delete_surrounding_bytes(&mut app.session, *before_bytes, *after_bytes) { - Ok(true) => ime_receipt(app, ctx, before), - Ok(false) => {} - Err(e) => app.toast(e.to_string()), - } - } - Event::Ime(egui::ImeEvent::DeleteSurrounding { before_chars, after_chars }) => { - let before = ime_stamp(app); - match figcraft_engine::textedit::delete_surrounding_chars(&mut app.session, *before_chars, *after_chars) { - Ok(true) => ime_receipt(app, ctx, before), - Ok(false) => {} - Err(e) => app.toast(e.to_string()), - } - } - Event::Paste(t) => { - // Plain text only: the engine normalises line endings and drops control chars. - let before = ime_stamp(app); - if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) { - app.toast(e.to_string()); - } - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); - } - } - Event::Copy | Event::Cut => { - let before = ime_stamp(app); - let selected = figcraft_engine::textedit::selected_text(&app.session).filter(|t| !t.is_empty()); - // Input injected through the control channel must never read or replace the user's - // system clipboard. It may still edit the document: a synthetic Cut deletes the - // selection exactly like a user's Cut; only the OS clipboard write is fenced. - if !app.control_synthetic - && let Some(text) = &selected - { - app.clipboard_set_text(text); - } - if matches!(ev, Event::Cut) && selected.is_some() { - let _ = figcraft_engine::textedit::key(&mut app.session, "Delete", false, false, false); - } - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); - } - } - Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => { - // Keys during a composition are the IME's (Enter picks a candidate, Escape cancels). - if app.session.ds().is_ok_and(|d| d.text_edit.as_ref().is_some_and(|te| te.preedit.is_some())) { - continue; - } - if m.command || m.alt || m.ctrl { - let before = ime_stamp(app); - if text_shortcut(app, *key, *physical_key, *m) { - // A text shortcut that changed the committed text/selection (a list, - // Bold, alignment) is an application edit; Save and friends are not. - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); - } - skip_text = true; - continue; - } - } - let name = match key { - Key::Backspace => "Backspace", - Key::Delete => "Delete", - Key::ArrowLeft => "Left", - Key::ArrowRight => "Right", - Key::ArrowUp => "Up", - Key::ArrowDown => "Down", - Key::Home => "Home", - Key::End => "End", - Key::Enter => "Enter", - Key::Escape => "Escape", - Key::Tab => "Tab", - Key::A if m.command => "SelectAll", - Key::Z if m.command => { - note_ime_application_change(app, ctx); - let _ = figcraft_engine::textedit::end(&mut app.session); - app.run(if m.shift { "edit.redo" } else { "edit.undo" }, json!({})); - continue; - } - _ => continue, - }; - if name == "Enter" && m.command { - let _ = figcraft_engine::textedit::end(&mut app.session); - continue; - } - let before = ime_stamp(app); - let (word, line) = keymap::text_motion(platform(), name, mods(*m)); - if let Err(e) = figcraft_engine::textedit::key(&mut app.session, name, m.shift, word, line) { - app.toast(e.to_string()); - } - if ime_stamp(app) != before { - note_ime_application_change(app, ctx); - } + Event::Ime(ime) => ime_event(app, ctx, ime), + Event::Paste(t) => insert_text(app, ctx, t), + Event::Copy => copy_or_cut(app, ctx, false), + Event::Cut => copy_or_cut(app, ctx, true), + Event::Key { key, physical_key, pressed: true, modifiers: m, .. } if text_key_event(app, ctx, *key, *physical_key, *m) => { + skip_text = true; } _ => {} } } // Triple-click on the edited text selects its line (double-click is handled by the engine). - if ctx.input(|i| i.pointer.button_triple_clicked(egui::PointerButton::Primary)) - && let Some(pos) = ctx.input(|i| i.pointer.interact_pos()) - && app.canvas.rect.contains(pos) - && let Some(te) = app.session.ds().ok().and_then(|d| d.text_edit.clone()) - { - let p = app.to_page(pos); - let inside = app.session.doc().ok().and_then(|d| d.world_bounds(te.id)).is_some_and(|b| b.contains(p)); - if inside && let Ok(idx) = app.session.text_index_at(te.id, p) { + triple_click_line(app, ctx); + app.sync_preedit(); +} + +/// Insert plain text (the engine normalises line endings and drops control chars) into the edit, +/// noting the application change the mutation caused. +fn insert_text(app: &mut FigApp, ctx: &Context, t: &str) { + let before = ime_stamp(app); + if let Err(e) = figcraft_engine::textedit::insert(&mut app.session, t) { + app.toast(e.to_string()); + } + if ime_stamp(app) != before { + note_ime_application_change(app, ctx); + } +} + +/// An IME commit, preedit or surrounding-text deletion for the edit. +fn ime_event(app: &mut FigApp, ctx: &Context, ev: &egui::ImeEvent) { + match ev { + // An accepted IME commit earns an explicit receipt only around the exact mutation. + // List AutoFormat inside the same command is an application transformation. + egui::ImeEvent::Commit(t) => { let before = ime_stamp(app); - app.run("text.edit.selectLine", json!({"index": idx})); + match figcraft_engine::textedit::insert_ime_commit(&mut app.session, t) { + Ok(ImeInsertion::Plain) => ime_receipt(app, ctx, before), + Ok(ImeInsertion::Transformed) => note_ime_application_change(app, ctx), + Ok(ImeInsertion::Nothing) => {} + Err(e) => app.toast(e.to_string()), + } + } + // The composition shows inline at the caret; the document gets it on Commit. + egui::ImeEvent::Preedit { text, active_range_chars } => { + let cursor = active_range_chars.as_ref().map(|r| r.start); + if let Err(e) = figcraft_engine::textedit::preedit(&mut app.session, text, cursor) { + app.toast(e.to_string()); + } + } + // An IME replacing text around the caret (0.37's incoming byte-counted deletion) + // deletes it before the paired Commit inserts the replacement. + egui::ImeEvent::DeleteSurroundingBytes { before_bytes, after_bytes } => { + let before = ime_stamp(app); + match figcraft_engine::textedit::delete_surrounding_bytes(&mut app.session, *before_bytes, *after_bytes) { + Ok(true) => ime_receipt(app, ctx, before), + Ok(false) => {} + Err(e) => app.toast(e.to_string()), + } + } + egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } => { + let before = ime_stamp(app); + match figcraft_engine::textedit::delete_surrounding_chars(&mut app.session, *before_chars, *after_chars) { + Ok(true) => ime_receipt(app, ctx, before), + Ok(false) => {} + Err(e) => app.toast(e.to_string()), + } + } + // Legacy Enabled/Disabled events were ignored by the original event dispatcher too. + _ => {} + } +} + +/// Copy or cut the edit's selection. Input injected through the control channel must never read +/// or replace the user's system clipboard; it may still edit the document: a synthetic Cut +/// deletes the selection exactly like a user's Cut; only the OS clipboard write is fenced. +fn copy_or_cut(app: &mut FigApp, ctx: &Context, cut: bool) { + let before = ime_stamp(app); + let selected = figcraft_engine::textedit::selected_text(&app.session).filter(|t| !t.is_empty()); + if !app.control_synthetic + && let Some(text) = &selected + { + app.clipboard_set_text(text); + } + if cut && selected.is_some() { + let _ = figcraft_engine::textedit::key(&mut app.session, "Delete", false, false, false); + } + if ime_stamp(app) != before { + note_ime_application_change(app, ctx); + } +} + +/// A text-editing key press. `true` when it ran a shortcut, whose following Text event is +/// swallowed (a shortcut that changed committed text/selection is also an application edit). +fn text_key_event(app: &mut FigApp, ctx: &Context, key: Key, physical: Option, m: Modifiers) -> bool { + // Keys during a composition are the IME's (Enter picks a candidate, Escape cancels). + if app.session.ds().is_ok_and(|d| d.text_edit.as_ref().is_some_and(|te| te.preedit.is_some())) { + return false; + } + if m.command || m.alt || m.ctrl { + let before = ime_stamp(app); + if text_shortcut(app, key, physical, m) { + // A text shortcut that changed the committed text/selection (a list, + // Bold, alignment) is an application edit; Save and friends are not. if ime_stamp(app) != before { note_ime_application_change(app, ctx); } + return true; } } - app.sync_preedit(); + if key == Key::Z && m.command { + note_ime_application_change(app, ctx); + let _ = figcraft_engine::textedit::end(&mut app.session); + app.run(if m.shift { "edit.redo" } else { "edit.undo" }, json!({})); + return false; + } + let Some(name) = text_key_name(key, m.command) else { return false }; + if name == "Enter" && m.command { + let _ = figcraft_engine::textedit::end(&mut app.session); + return false; + } + let before = ime_stamp(app); + let (word, line) = keymap::text_motion(platform(), name, mods(m)); + if let Err(e) = figcraft_engine::textedit::key(&mut app.session, name, m.shift, word, line) { + app.toast(e.to_string()); + } + if ime_stamp(app) != before { + note_ime_application_change(app, ctx); + } + false +} + +/// The text-edit command a named key runs, if any. +fn text_key_name(key: Key, command: bool) -> Option<&'static str> { + Some(match key { + Key::Backspace => "Backspace", + Key::Delete => "Delete", + Key::ArrowLeft => "Left", + Key::ArrowRight => "Right", + Key::ArrowUp => "Up", + Key::ArrowDown => "Down", + Key::Home => "Home", + Key::End => "End", + Key::Enter => "Enter", + Key::Escape => "Escape", + Key::Tab => "Tab", + Key::A if command => "SelectAll", + _ => return None, + }) +} + +/// Triple-click on the edited text selects its line (double-click is handled by the engine). +fn triple_click_line(app: &mut FigApp, ctx: &Context) { + if !ctx.input(|i| i.pointer.button_triple_clicked(egui::PointerButton::Primary)) { + return; + } + let Some(pos) = ctx.input(|i| i.pointer.interact_pos()).filter(|pos| app.canvas.rect.contains(*pos)) else { + return; + }; + let Some(te) = app.session.ds().ok().and_then(|d| d.text_edit.clone()) else { return }; + let p = app.to_page(pos); + if let Some(idx) = text_line_index(app, te.id, p) { + let before = ime_stamp(app); + app.run("text.edit.selectLine", json!({"index": idx})); + if ime_stamp(app) != before { + note_ime_application_change(app, ctx); + } + } +} + +/// The text index of the line under `p`, when the edit's node contains it. +fn text_line_index(app: &FigApp, id: figcraft_doc::NodeId, p: figcraft_geom::Point) -> Option { + let inside = app.session.doc().ok().and_then(|d| d.world_bounds(id)).is_some_and(|b| b.contains(p)); + if !inside { + return None; + } + app.session.text_index_at(id, p).ok() } /// Shortcuts live while editing text (Bold, lists, alignment, size/weight/spacing steps, Save…). From 92682728e9d6a3a97ea25a99395be7a34a8db993 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:09:29 -0400 Subject: [PATCH 107/142] Follow-ups from the #1311 review: link notice clicks, variable scopes, corner tooltip (#1316) * Prototype player: no clicks reach the prototype behind the link notice * Selection colors: scopes filter the variable menu; the command binds as variable.bind does * Style picker: color variables scoped to every selected layer's fill or stroke * Corner button: named Corner smoothing on polygons and stars * docs/gaps.md: known gaps from the #1311 review * cargo fmt * Style picker: clippy --- crates/engine/src/selection_colors.rs | 20 ++++++++---- crates/engine/tests/it/selection_colors.rs | 13 +++++--- crates/ui-egui/src/corner_individual_tests.rs | 3 +- crates/ui-egui/src/present.rs | 6 ++-- crates/ui-egui/src/present_links_tests.rs | 32 +++++++++++++++++++ crates/ui-egui/src/right.rs | 4 ++- crates/ui-egui/src/selection_colors.rs | 8 +++-- crates/ui-egui/src/style_picker.rs | 6 ++-- crates/ui-egui/src/variable_ui.rs | 15 +++++++++ docs/gaps.md | 16 +++++++--- docs/mcp.md | 3 +- 11 files changed, 99 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/selection_colors.rs b/crates/engine/src/selection_colors.rs index ce349c03..56bc4b35 100644 --- a/crates/engine/src/selection_colors.rs +++ b/crates/engine/src/selection_colors.rs @@ -248,8 +248,8 @@ fn unbind_slot(n: &mut figcraft_doc::Node, fills: bool, index: usize) -> bool { } /// Why `variable` can't be applied to `targets` (`selection.bindColorVariable`), if it can't: a -/// variable that isn't a color, a gradient, or a slot outside the variable's scopes (a variable -/// hidden from pickers has none). +/// variable that isn't a color, or a gradient. Scopes don't refuse, as with `variable.bind`; they +/// only filter what the menu offers ([`in_scope`]). pub fn bind_refusal(doc: &Document, targets: &[PaintTarget], variable: &str) -> Option { let Some(var) = doc.variables.get(variable) else { return Some("no such variable".into()) }; if var.kind != VariableType::Color { @@ -264,15 +264,21 @@ pub fn bind_refusal(doc: &Document, targets: &[PaintTarget], variable: &str) -> if !crate::cmd::variables::field_takes(doc, target.id, &field, var.kind) { return Some(format!("`{field}` doesn't take a color variable")); } - if let Some(scope) = crate::cmd::variables::field_scope(doc, target.id, &field) - && !var.in_scope(scope) - { - return Some(format!("the variable isn't scoped to {scope}")); - } } None } +/// Whether `variable`'s scopes cover every slot of `targets` (a variable hidden from pickers has +/// none), so the Selection colors menu offers it there. +pub fn in_scope(doc: &Document, targets: &[PaintTarget], variable: &str) -> bool { + let Some(var) = doc.variables.get(variable) else { return false }; + targets.iter().all(|target| { + let list = if target.fills { BoundList::Fills } else { BoundList::Strokes }; + let field = format!("{}/{}/color", list.prefix(), target.index); + crate::cmd::variables::field_scope(doc, target.id, &field).is_none_or(|scope| var.in_scope(scope)) + }) +} + fn bind_variable(s: &mut Session, p: &Value) -> Result { const CMD: &str = "selection.bindColorVariable"; let targets = checked_targets(s, p, CMD)?; diff --git a/crates/engine/tests/it/selection_colors.rs b/crates/engine/tests/it/selection_colors.rs index d676b42f..c3fc4f97 100644 --- a/crates/engine/tests/it/selection_colors.rs +++ b/crates/engine/tests/it/selection_colors.rs @@ -399,7 +399,7 @@ fn color_variables_refuse_gradients_other_types_and_unknown_names() { } #[test] -fn color_variables_apply_only_within_their_scopes() { +fn scopes_filter_the_menu_but_the_command_binds_anyway() { let mut s = Session::new(); let a = rectangle(&mut s, "#ff0000"); run(&mut s, "stroke.add", json!({"id":a,"color":"#ff0000"})); @@ -410,11 +410,14 @@ fn color_variables_apply_only_within_their_scopes() { let red = group(&s, &[a], "FF0000"); let fill_slot: Vec<_> = red.targets.iter().filter(|t| t.fills).cloned().collect(); let stroke_slot: Vec<_> = red.targets.iter().filter(|t| !t.fills).cloned().collect(); - for (targets, variable) in [(&red.targets, "Line"), (&fill_slot, "Line"), (&stroke_slot, "Hidden")] { - assert!(s.execute("selection.bindColorVariable", &json!({"targets":targets,"variable":variable})).is_err()); - } - assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty()); + let doc = s.doc().unwrap(); + let offered = |targets: &[figcraft_engine::selection_colors::PaintTarget], v: &str| figcraft_engine::selection_colors::in_scope(doc, targets, v); + assert!(offered(&stroke_slot, "Line")); + assert!(!offered(&red.targets, "Line") && !offered(&fill_slot, "Line") && !offered(&stroke_slot, "Hidden")); run(&mut s, "selection.bindColorVariable", json!({"targets":stroke_slot,"variable":"Line"})); assert_eq!(s.doc().unwrap().node(a).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00"); assert_eq!(fill(&s, a), "FF0000"); + // As with `variable.bind`, the command itself binds outside the scopes. + run(&mut s, "selection.bindColorVariable", json!({"targets":fill_slot,"variable":"Line"})); + assert_eq!(fill(&s, a), "00FF00"); } diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index b7520ffc..316c15a4 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -100,7 +100,8 @@ fn polygons_and_stars_take_corner_smoothing() { s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap(); s.execute("select.set", &json!({"ids": [id]})).unwrap(); let mut u = Ui::new(s); - let t = named_button(&mut u, "Individual corners"); + // With no corners of their own, the button is named for what it opens. + let t = named_button(&mut u, "Corner smoothing"); click(&mut u, t); assert!((0..4).all(|k| u.ctx.read_response(Id::new(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields"); let smoothing = u.ctx.read_response(Id::new("smoothing").with("field")).expect("the smoothing field shows").rect.center(); diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 5f719dca..9b59015f 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -2220,10 +2220,10 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) let resp = ui.interact(area, ui.id().with("present_area"), Sense::click_and_drag()); let over = resp.contains_pointer(); if let Some(l) = layout(p, area, chrome) { - // The pane only takes the keyboard while the pointer is over it, and neither takes it - // while a link notice shows (Escape there cancels the notice). + // The pane only takes the keyboard while the pointer is over it. While a link notice shows + // the prototype takes neither keys nor the pointer (Escape there cancels the notice). let keys = chrome.keys && p.link_notice.is_none() && (!chrome.pane || over); - input(p, &ctx, &l, over, keys, chrome.pane, now); + input(p, &ctx, &l, over && p.link_notice.is_none(), keys, chrome.pane, now); } let Some(l) = layout(p, area, chrome) else { return action }; p.shown = Some((p.current, l.place, l.clip.intersect(area))); diff --git a/crates/ui-egui/src/present_links_tests.rs b/crates/ui-egui/src/present_links_tests.rs index 2fc816cc..31a6b2bc 100644 --- a/crates/ui-egui/src/present_links_tests.rs +++ b/crates/ui-egui/src/present_links_tests.rs @@ -191,3 +191,35 @@ fn a_prototype_cannot_open_a_script_or_handler_url() { assert!(opens_after_a_delay(url).is_empty(), "{url:?} must never reach the opener"); } } + +#[test] +fn the_prototype_takes_no_clicks_while_the_notice_shows() { + let mut s = Session::new(); + let a = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"}))); + let b = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 400, "name": "B"}))); + let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": a}))); + let link = json!({"type": "openUrl", "url": "https://example.com/a"}); + ex(&mut s, "proto.interaction.add", json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": link})); + let to_b = json!({"type": "navigate", "dest": b}); + ex(&mut s, "proto.interaction.add", json!({"id": a, "trigger": {"type": "onClick"}, "action": to_b, "transition": {"type": "instant"}})); + let mut u = Harness::new(s); + u.app.services = Box::new(Opened::default()); + u.run("proto.present", json!({"start": a})); + u.frames(40, Vec::new); + assert_eq!(notice(&u), "https://example.com/a"); + // On the frame, above the notice. + let at = egui::pos2(700.0, 290.0); + u.button(at, true); + u.button(at, false); + u.frames(5, Vec::new); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "the click behind the notice did not navigate"); + assert_eq!(notice(&u), "https://example.com/a", "the notice still waits"); + let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + crate::live_tests::text_center(&shapes, "Open link"); + u.key(egui::pos2(700.0, 450.0), egui::Key::Escape); + assert!(notice(&u).is_null()); + u.button(at, true); + u.button(at, false); + u.frames(5, Vec::new); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "with the notice gone the same click navigates"); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 0bbc2571..2243d11a 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -876,7 +876,9 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)); app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0))); let open = ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false); - if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tr("Individual corners")).clicked() { + // Polygons and stars have no corners of their own: the button opens only Corner smoothing. + let tip = if indep_ok { tr("Individual corners") } else { tr("Corner smoothing") }; + if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tip).clicked() { ui.data_mut(|d| d.insert_temp(Id::new("indep_corners"), !open)); } } diff --git a/crates/ui-egui/src/selection_colors.rs b/crates/ui-egui/src/selection_colors.rs index 5c90ec77..aaece00c 100644 --- a/crates/ui-egui/src/selection_colors.rs +++ b/crates/ui-egui/src/selection_colors.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use egui::{Id, Ui}; use figcraft_color::Color; use figcraft_doc::{Document, NodeId, StyleKind, VariableType}; -use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect}; +use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect, in_scope}; use serde_json::json; use crate::a11y::Tip; @@ -133,7 +133,11 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId .variables .variables .iter() - .filter(|v| v.kind == VariableType::Color && bind_refusal(doc, &group.targets, &v.id).is_none()) + .filter(|v| { + v.kind == VariableType::Color + && bind_refusal(doc, &group.targets, &v.id).is_none() + && in_scope(doc, &group.targets, &v.id) + }) .collect(); if !variables.is_empty() { ui.separator(); diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 60323a16..e63875ff 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -555,10 +555,10 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut } } let colors = if slot.paints() { - // Variables scoped to the selection's fill (or stroke), as the Design panel's pickers offer. - let scope = p.ids.first().and_then(|id| figcraft_engine::cmd::variables::field_scope(doc, *id, slot.key())); + // Variables scoped to every selected layer's fill (or stroke), as the Design panel's pickers offer. + let scopes: Vec<_> = p.ids.iter().filter_map(|id| figcraft_engine::cmd::variables::field_scope(doc, *id, slot.key())).collect(); crate::variable_ui::groups(doc, &libs, |_, v| { - v.kind == VariableType::Color && (q.is_empty() || v.name.to_lowercase().contains(&q)) && scope.is_none_or(|s| v.in_scope(s)) + v.kind == VariableType::Color && (q.is_empty() || v.name.to_lowercase().contains(&q)) && scopes.iter().all(|s| v.in_scope(s)) }) } else { Vec::new() diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 3dfeeec7..7244a1d2 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -633,4 +633,19 @@ mod scope_tests { check(&texts, shown, hidden, &format!("{slot:?}")); } } + + #[test] + fn a_mixed_selections_fill_picker_offers_variables_scoped_to_every_layer() { + let mut s = session(); + let r = s.selection()[0]; + let f = s.doc().unwrap().nodes().find(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame)).unwrap().id; + run(&mut s, "variable.create", json!({"collection": "Tokens", "name": "both fills", "type": "color", "values": {"Base": "#FFFF00"}})); + run(&mut s, "variable.update", json!({"variable": "both fills", "scopes": ["FRAME_FILL", "SHAPE_FILL"]})); + // The frame first: its scope alone would offer "frame fill" for the rectangle too. + run(&mut s, "select.set", json!({"ids": [f, r]})); + let mut u = TestUi::new(s); + crate::style_picker::open_for_selection(&mut u.app, StyleSlot::Fill, None); + u.frame(vec![]); + check(&drawn(&mut u), &["both fills"], &["frame fill", "shape fill"], "frame and rectangle"); + } } diff --git a/docs/gaps.md b/docs/gaps.md index a76df700..e76731e7 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 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (known gaps from the #1311 review in gaps 4, 14, 15 and 16; 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 @@ -152,7 +152,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ### 4. Interaction fidelity not compared tool by tool - **Missing:** a side-by-side pass of every tool and panel: cursors, modifier keys while dragging, - hover states, popovers, menus, snapping thresholds. + hover states, popovers, menus, snapping thresholds. Known: the Assets panel's export preview + follows a slice's first export setting, so a second setting with the other Ignore overlapping + layers value previews differently from what it exports. - **Landed (2026-10-11):** behaviour suites written from the public documentation for auto layout, selection and transform, text, and canvas navigation with rulers, guides, layout grids, outline mode and measuring, then (2026-10-12) comments and version history, and the layers and Assets @@ -342,7 +344,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe collection (a brand theme extending a core one) can be extended again, its values flowing down the chain as each file accepts updates, every level's overrides kept. - **Missing:** checked with synthetic files only; extending a collection of the same file isn't - offered (extending takes library collections). + offered (extending takes library collections). A paint style's color offers variables scoped to + any fill or stroke: FigCraft's own rule, since the help center doesn't say which scopes it takes. - **Estimate:** 0–1 h. ### 15. Prototyping: media triggers and actions, controllers off Windows @@ -357,6 +360,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe importer warns and skips them, `crates/format/src/rest/proto.rs`); controllers on macOS, Linux and FreeBSD (the usual Rust controller library links libudev on Linux; a pure-Rust reader per platform, or an owner decision on that dependency); PlayStation and Switch button labels. + `proto.linkNotice` from the Actions palette or the control channel changes the preference in + memory only; it reaches the preferences file with the next preferences save (the notice's own + "Don't show this again" saves at once). - **Estimate:** 2–5 h, the media part after video fills (gap 12). ### 16. Dev Mode: the tail after dev resources @@ -398,7 +404,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe the Pages list only opens pages). - **Missing:** 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. + and editors. The file name field in the left panel still renames the file in Dev Mode + (`file.rename`); whether Figma allows that in Dev Mode needs checking before it changes. - **Estimate:** 1–2 h. ### 17. Branching and merging @@ -480,6 +487,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours 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 | diff --git a/docs/mcp.md b/docs/mcp.md index fe137e55..9de0273a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1627,7 +1627,8 @@ Pass the returned target list to: to those solid fill and stroke slots, as `variable.bind` does with `fills//color` or `strokes//color`. A color style on the list and an earlier binding of the slot are removed. Text ranges with fills of their own take the variable's value in the layer's modes, without a - binding. Gradients, other variable types and slots outside the variable's scopes are refused. + binding. Gradients and other variable types are refused; scopes only filter what the Selection + colors menu offers, as for `variable.bind`. - `selection.detachColor {targets}` to unlink the color style and the color variable of those slots, keeping their values. From 283c6de2b160b9168f7509546a238dcb77201259 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:07:08 +0000 Subject: [PATCH 108/142] Keep significant digits in SVG matrix scale coefficients Found while testing crates/svg/src/lib.rs:97 (num, used by matrix at :129 for the image pattern's patternTransform at :243-254) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/svg/src/lib.rs | 17 ++++++++++++++++- crates/svg/src/tests.rs | 13 +++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 656307d9..449426d2 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -126,9 +126,24 @@ fn path_d(p: &BezPath) -> String { s } +/// A matrix scale/shear coefficient to 6 significant digits (unlike [`num`], small values survive). +fn coeff(v: f64) -> String { + if !v.is_finite() || v == 0.0 { + return "0".into(); + } + let digits = 5 - v.abs().log10().floor() as i32; + let p = 10f64.powi(digits); + let r = (v * p).round() / p; + format!("{r}") +} + fn matrix(a: Affine) -> String { let c = a.as_coeffs(); - format!("matrix({} {} {} {} {} {})", num(c[0]), num(c[1]), num(c[2]), num(c[3]), num(c[4]), num(c[5])) + // Snap float noise (e.g. 6e-17 from a 90 degree rotation) to zero, relative to the largest term. + let eps = c[..4].iter().fold(0.0f64, |m, v| m.max(v.abs())) * 1e-12; + let eps = eps.max(1e-12); + let k = |v: f64| if v.abs() <= eps { "0".to_string() } else { coeff(v) }; + format!("matrix({} {} {} {} {} {})", k(c[0]), k(c[1]), k(c[2]), k(c[3]), num(c[4]), num(c[5])) } /// A view of page area `b` at `k` device pixels per unit (fewer when that makes it larger than diff --git a/crates/svg/src/tests.rs b/crates/svg/src/tests.rs index dedeca3b..bfce1cf4 100644 --- a/crates/svg/src/tests.rs +++ b/crates/svg/src/tests.rs @@ -264,3 +264,16 @@ fn a_filled_open_curve_is_closed_for_its_fill() { }; assert_eq!(d.nodes().map(loops).sum::(), 1); } + +#[test] +fn matrix_keeps_small_scale_precision() { + assert_eq!(matrix(Affine::scale(0.0375)), "matrix(0.0375 0 0 0.0375 0 0)"); + assert_eq!(matrix(Affine::scale(0.0001)), "matrix(0.0001 0 0 0.0001 0 0)"); +} + +#[test] +fn matrix_snaps_rotation_noise_and_tiny_values() { + let r = Affine::rotate(std::f64::consts::FRAC_PI_2); + assert_eq!(matrix(r), "matrix(0 1 -1 0 0 0)"); + assert_eq!(matrix(Affine::scale(1e-320)), "matrix(0 0 0 0 0 0)"); +} From fc344de33c33e52baff87d348186fd468cafcdde Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 08:10:37 +0100 Subject: [PATCH 109/142] M0: correct the migration qualification reference --- docs/egui-037-migration-plan.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 6b8c5059..6cf1bb09 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -1189,8 +1189,9 @@ validation and generates the lockfile from its local dependency config. ## Historical local qualification — 2026-10-25 The following counts apply to the earlier `10702311` integration, not the later -`2a3b7d4f` qualification in the status update above. The intervening `9f788753` -refresh passed 3,247 executions; the runs listed below passed 3,242. +`2a3b7d4f` qualification documented in `docs/egui-037-local-followups.md`. The +status update above covers the subsequent `92682728` integration. The intervening +`9f788753` refresh passed 3,247 executions; the runs listed below passed 3,242. The migration preserves captured main `10702311f345393ee1d8868c3257679befcfa14d` (53 commits and 114 paths beyond the first integration). The final review commit records From 142676191e638d7991c2fca8eaa657e59cf02e22 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:08:47 +0100 Subject: [PATCH 110/142] Rename component parameters that are strict-mode reserved words Found while testing crates/engine/src/component_codegen.rs:~226 (RESERVED list used by ident at :346) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/component_codegen.rs | 5 +++++ crates/engine/src/component_codegen_tests.rs | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/engine/src/component_codegen.rs b/crates/engine/src/component_codegen.rs index a8217be1..11f5e7da 100644 --- a/crates/engine/src/component_codegen.rs +++ b/crates/engine/src/component_codegen.rs @@ -225,7 +225,9 @@ pub fn type_name(name: &str) -> String { /// Words no generated parameter may be called (keywords of TypeScript, Swift and Kotlin, and /// names the generated code uses). const RESERVED: &[&str] = &[ + "arguments", "as", + "await", "body", "break", "case", @@ -239,6 +241,7 @@ const RESERVED: &[&str] = &[ "do", "else", "enum", + "eval", "export", "extends", "extension", @@ -249,6 +252,7 @@ const RESERVED: &[&str] = &[ "function", "guard", "if", + "implements", "import", "in", "init", @@ -264,6 +268,7 @@ const RESERVED: &[&str] = &[ "operator", "package", "private", + "protected", "protocol", "public", "ref", diff --git a/crates/engine/src/component_codegen_tests.rs b/crates/engine/src/component_codegen_tests.rs index 4cd3d776..54ca80c1 100644 --- a/crates/engine/src/component_codegen_tests.rs +++ b/crates/engine/src/component_codegen_tests.rs @@ -43,6 +43,18 @@ fn button_set(s: &mut Session) -> (NodeId, NodeId, NodeId) { (set, variants[0], variants[1]) } +#[test] +fn strict_mode_reserved_words_are_not_parameter_names() { + let mut s = Session::new(); + let (c, title, _) = card(&mut s); + ex(&mut s, "component.addProperty", json!({"id": c, "name": "Protected", "type": "text", "layer": title})); + let react = code(&mut s, c, "react"); + assert!(react.contains("protectedValue"), "React renames the property:\n{react}"); + for bad in [" protected?", " protected =", "{protected}"] { + assert!(!react.contains(bad), "reserved word used as a binding ({bad:?}):\n{react}"); + } +} + #[test] fn components_take_their_properties_as_parameters() { let mut s = Session::new(); From a107e62bcc283d837109a20d9e40202acad97b7c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:13:55 +0100 Subject: [PATCH 111/142] Fix missing end arrowhead when a closed subpath follows an open one Found while testing crates/pathops/src/points.rs:89-92 (Styled::arrowheads) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/pathops/src/lib.rs | 23 +++++++++++++++++++++++ crates/pathops/src/points.rs | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/pathops/src/lib.rs b/crates/pathops/src/lib.rs index f4b30e10..1d6dd999 100644 --- a/crates/pathops/src/lib.rs +++ b/crates/pathops/src/lib.rs @@ -693,6 +693,29 @@ mod tests { assert_ne!(explicit, layer_only); } + #[test] + fn layer_end_arrow_is_drawn_when_a_closed_run_follows_the_open_one() { + let verts = [(0.0, 0.0), (50.0, 0.0), (0.0, 100.0), (50.0, 100.0), (25.0, 150.0)]; + let net = VectorNetwork { + vertices: verts + .iter() + .enumerate() + .map(|(i, &(x, y))| figcraft_doc::Vertex { cap: (i == 0).then_some(StrokeCap::Round), ..figcraft_doc::Vertex::new(x, y) }) + .collect(), + segments: vec![ + figcraft_doc::Segment::line(0, 1), + figcraft_doc::Segment::line(2, 3), + figcraft_doc::Segment::line(3, 4), + figcraft_doc::Segment::line(4, 2), + ], + ..Default::default() + }; + let path = BezPath::new(); + let plain = StrokeProps { weight: 2.0, ..Default::default() }; + let arrow = StrokeProps { cap_end: StrokeCap::TriangleArrow, ..plain.clone() }; + assert_ne!(stroke_outline_of(&path, &arrow, false, Some(&net)), stroke_outline_of(&path, &plain, false, Some(&net))); + } + #[test] fn equal_joins_do_not_break_closed_stroke_runs() { let path = sq(0.0, 0.0, 20.0); diff --git a/crates/pathops/src/points.rs b/crates/pathops/src/points.rs index 05f58216..650f309f 100644 --- a/crates/pathops/src/points.rs +++ b/crates/pathops/src/points.rs @@ -86,8 +86,8 @@ impl Styled { /// and last end (as without point styles). pub(crate) fn arrowheads(&self, weight: f64, props: &StrokeProps) -> BezPath { let mut out = BezPath::new(); - let last = self.runs.len().saturating_sub(1); - for (k, run) in self.runs.iter().enumerate().filter(|(_, r)| !r.closed) { + let last = self.runs.iter().filter(|r| !r.closed).count().saturating_sub(1); + for (k, run) in self.runs.iter().filter(|r| !r.closed).enumerate() { let start = self.cap_at(run.start, if k == 0 { props.cap_start } else { StrokeCap::None }); let end = self.cap_at(run.end, if k == last { props.cap_end } else { StrokeCap::None }); for (at_end, cap) in [(false, start), (true, end)] { From 8e8314e273a80129beddcf7a93af8ab9b73eb41c Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:35:08 +0000 Subject: [PATCH 112/142] Accept a unit typed after a space in number fields Found while testing crates/ui-egui/src/field_math.rs:105 (Parser::factor, unit loop; end() at :109) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/ui-egui/src/field_math.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/field_math.rs b/crates/ui-egui/src/field_math.rs index c8318309..f81500e6 100644 --- a/crates/ui-egui/src/field_math.rs +++ b/crates/ui-egui/src/field_math.rs @@ -102,7 +102,15 @@ impl Parser<'_> { } let v = digits.parse::().ok().filter(|v| v.is_finite())?; // The unit: letters (px, pt, ms, x), a degree sign, and a percent sign where that is one. - while self.chars.next_if(|c| c.is_ascii_alphabetic() || *c == '°' || (*c == '%' && self.percent_unit)).is_some() {} + // It may follow spaces ("3 px"). + let percent_unit = self.percent_unit; + let is_unit = |c: &char| c.is_ascii_alphabetic() || *c == '°' || (*c == '%' && percent_unit); + let mut ahead = self.chars.clone(); + while ahead.next_if(|c| c.is_whitespace()).is_some() {} + if ahead.peek().is_some_and(is_unit) { + self.space(); + } + while self.chars.next_if(is_unit).is_some() {} Some(v) } @@ -161,3 +169,17 @@ pub fn stepped(from: f64, steps: f64, step: f64) -> f64 { let v = from + steps * step; if v.is_finite() { (v * 1e6).round() / 1e6 } else { from } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unit_after_a_space() { + assert_eq!(parse("3px", true), Some(Typed::Value(3.0))); + assert_eq!(parse("3 px", true), Some(Typed::Value(3.0))); + assert_eq!(parse("10 °", true), Some(Typed::Value(10.0))); + assert_eq!(parse("50 %", true), Some(Typed::Value(50.0))); + assert_eq!(parse("3 px + 2 px", true), Some(Typed::Value(5.0))); + } +} From 32b836fbc5e27efcec4c566d36816e56f856bda3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 25 Oct 2026 09:04:17 -0400 Subject: [PATCH 113/142] Release 0.11.0 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fabd85f8..6a8f5e68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1465,7 +1465,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.10.0" +version = "0.11.0" dependencies = [ "arboard", "craft-window", @@ -1491,7 +1491,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.10.0" +version = "0.11.0" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1505,7 +1505,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.10.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "chrono", @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.10.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1544,7 +1544,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.10.0" +version = "0.11.0" dependencies = [ "kurbo", "serde", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.10.0" +version = "0.11.0" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.10.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.10.0" +version = "0.11.0" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1608,7 +1608,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.10.0" +version = "0.11.0" dependencies = [ "kurbo", "serde", @@ -1616,7 +1616,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.10.0" +version = "0.11.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1624,7 +1624,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.10.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.10.0" +version = "0.11.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1649,7 +1649,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.10.0" +version = "0.11.0" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1666,7 +1666,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.10.0" +version = "0.11.0" dependencies = [ "figcraft-color", "serde", @@ -1679,7 +1679,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.10.0" +version = "0.11.0" dependencies = [ "crc32fast", "figcraft-color", @@ -1697,7 +1697,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.10.0" +version = "0.11.0" dependencies = [ "crc32fast", "ctrlc", @@ -1721,7 +1721,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.10.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1737,7 +1737,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.10.0" +version = "0.11.0" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1751,7 +1751,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.10.0" +version = "0.11.0" dependencies = [ "base64 0.22.1", "chrono", @@ -1777,7 +1777,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.10.0" +version = "0.11.0" dependencies = [ "eframe", "egui", @@ -5881,7 +5881,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.10.0" +version = "0.11.0" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 65809315..318ecca8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.10.0" +version = "0.11.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 4b76c407d5a42d824524e7b95a7bcfbed47e8d16 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 25 Oct 2026 09:14:05 -0400 Subject: [PATCH 114/142] macOS signing: keep the temporary keychain until the build has signed with it import-cert.sh deleted its whole private temp directory on exit, and the keychain lives there, so codesign failed with "The specified item could not be found in the keychain" in the release build. Co-Authored-By: Claude Opus 5.5 --- packaging/macos/import-cert.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index aeb58fd4..978874d3 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -19,7 +19,9 @@ fi TMP_BASE="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" TMP="$(mktemp -d "${TMP_BASE}/figcraft-signing.XXXXXX")" -trap 'rm -rf "$TMP"' EXIT +# Remove only the decoded certificate: the keychain lives in the same directory and codesign +# needs it until the workflow's "Remove temporary keychain" step deletes it. +trap 'rm -f "${CERT:-}"' EXIT KEYCHAIN="$TMP/figcraft-signing.keychain-db" CERT="$TMP/figcraft-signing.p12" KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}" From 22c15e3206314a57b605125201ef89e7b9bc911d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 25 Oct 2026 09:14:22 -0400 Subject: [PATCH 115/142] Release 0.11.1 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a8f5e68..b8c6127b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1465,7 +1465,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.11.0" +version = "0.11.1" dependencies = [ "arboard", "craft-window", @@ -1491,7 +1491,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.11.0" +version = "0.11.1" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1505,7 +1505,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.11.0" +version = "0.11.1" dependencies = [ "base64 0.22.1", "chrono", @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.11.0" +version = "0.11.1" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1544,7 +1544,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.11.0" +version = "0.11.1" dependencies = [ "kurbo", "serde", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.11.0" +version = "0.11.1" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.11.0" +version = "0.11.1" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.11.0" +version = "0.11.1" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1608,7 +1608,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.11.0" +version = "0.11.1" dependencies = [ "kurbo", "serde", @@ -1616,7 +1616,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.11.0" +version = "0.11.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1624,7 +1624,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.11.0" +version = "0.11.1" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.11.0" +version = "0.11.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1649,7 +1649,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.11.0" +version = "0.11.1" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1666,7 +1666,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.11.0" +version = "0.11.1" dependencies = [ "figcraft-color", "serde", @@ -1679,7 +1679,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.11.0" +version = "0.11.1" dependencies = [ "crc32fast", "figcraft-color", @@ -1697,7 +1697,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.11.0" +version = "0.11.1" dependencies = [ "crc32fast", "ctrlc", @@ -1721,7 +1721,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.11.0" +version = "0.11.1" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1737,7 +1737,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.11.0" +version = "0.11.1" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1751,7 +1751,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.11.0" +version = "0.11.1" dependencies = [ "base64 0.22.1", "chrono", @@ -1777,7 +1777,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.11.0" +version = "0.11.1" dependencies = [ "eframe", "egui", @@ -5881,7 +5881,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.11.0" +version = "0.11.1" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 318ecca8..9403f16b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.11.0" +version = "0.11.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From 149f59cd14914f8c40392c80b7b688bc25aea7af Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 25 Oct 2026 09:44:13 -0400 Subject: [PATCH 116/142] Web: build the browser wasm with opt-level z to stay under the 24 MiB gate The 0.11.1 release's Web job failed: the wasm was 25,248,590 bytes, over packaging/web/package.sh's 25,165,824-byte limit. opt-level z brings it to 23,306,645 bytes (measured locally with the release's craft-fonts ref, wasm-bindgen 0.2.129 and wasm-opt -Oz). Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 9403f16b..c79312ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,7 +122,8 @@ codegen-units = 4 # wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there. [profile.web-release] inherits = "release" -opt-level = "s" +# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate. +opt-level = "z" lto = "fat" codegen-units = 1 debug = false From d4a303d4708602a9df1738960852d18b185a616b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 25 Oct 2026 09:44:44 -0400 Subject: [PATCH 117/142] Release 0.11.2 Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 42 +++++++++++++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b8c6127b..2d84daad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1465,7 +1465,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.11.1" +version = "0.11.2" dependencies = [ "arboard", "craft-window", @@ -1491,7 +1491,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1505,7 +1505,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.11.1" +version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.11.1" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1544,7 +1544,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.11.1" +version = "0.11.2" dependencies = [ "kurbo", "serde", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.11.1" +version = "0.11.2" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1608,7 +1608,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.11.1" +version = "0.11.2" dependencies = [ "kurbo", "serde", @@ -1616,7 +1616,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1624,7 +1624,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.11.1" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1649,7 +1649,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1666,7 +1666,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-color", "serde", @@ -1679,7 +1679,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.11.1" +version = "0.11.2" dependencies = [ "crc32fast", "figcraft-color", @@ -1697,7 +1697,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.11.1" +version = "0.11.2" dependencies = [ "crc32fast", "ctrlc", @@ -1721,7 +1721,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.11.1" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1737,7 +1737,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1751,7 +1751,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.11.1" +version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", @@ -1777,7 +1777,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.11.1" +version = "0.11.2" dependencies = [ "eframe", "egui", @@ -5881,7 +5881,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index c79312ad..39abeca3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.11.1" +version = "0.11.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" From b751271c2ac0588a13a67781e7bae8dfc1023202 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 14:52:12 +0100 Subject: [PATCH 118/142] M-egui037: document input lifecycle and UI regression contracts --- apps/figcraft-web/src/web.rs | 3 ++ apps/figcraft/src/control_server.rs | 3 ++ apps/figcraft/src/control_server_tests.rs | 9 ++++ apps/figcraft/src/main.rs | 3 ++ crates/collab-net/src/sessions_tests.rs | 3 ++ crates/engine/src/cmd/edit.rs | 4 ++ crates/engine/src/text_behaviour_tests.rs | 5 +++ crates/engine/src/textedit.rs | 32 +++++++++++++ crates/engine/src/textfit_tests.rs | 2 + crates/engine/src/tools.rs | 4 ++ crates/text/build.rs | 21 +++++++++ crates/ui-egui/src/a11y.rs | 2 + crates/ui-egui/src/a11y_tests.rs | 2 + crates/ui-egui/src/assets_panel.rs | 13 ++++++ crates/ui-egui/src/canvas.rs | 4 ++ crates/ui-egui/src/clipboard_fence_tests.rs | 16 +++++++ crates/ui-egui/src/colorpicker.rs | 9 ++++ crates/ui-egui/src/control.rs | 13 ++++++ crates/ui-egui/src/control_capture_tests.rs | 45 +++++++++++++++++++ crates/ui-egui/src/credits_tests.rs | 2 + crates/ui-egui/src/dialogs.rs | 4 ++ crates/ui-egui/src/field_tests.rs | 9 ++++ crates/ui-egui/src/frame_labels.rs | 3 ++ crates/ui-egui/src/frame_presets.rs | 2 + crates/ui-egui/src/guides.rs | 1 + crates/ui-egui/src/handles.rs | 1 + crates/ui-egui/src/home.rs | 4 ++ crates/ui-egui/src/idle_tests.rs | 7 +++ crates/ui-egui/src/ime_tests.rs | 42 +++++++++++++++++ crates/ui-egui/src/layers.rs | 8 ++++ crates/ui-egui/src/layers_behaviour_tests.rs | 3 ++ crates/ui-egui/src/layers_rename_tests.rs | 2 + crates/ui-egui/src/layout_panel.rs | 2 + crates/ui-egui/src/lib.rs | 10 +++++ crates/ui-egui/src/live_join_tests.rs | 1 + crates/ui-egui/src/live_server_tests.rs | 1 + crates/ui-egui/src/live_share_tests.rs | 5 +++ crates/ui-egui/src/live_tests.rs | 2 + crates/ui-egui/src/menu_aim_tests.rs | 3 ++ crates/ui-egui/src/menu_input_tests.rs | 2 + crates/ui-egui/src/menus.rs | 6 +++ crates/ui-egui/src/panel_remove_tests.rs | 5 +++ crates/ui-egui/src/panel_reorder_tests.rs | 2 + crates/ui-egui/src/perf_tests.rs | 3 ++ crates/ui-egui/src/plugin_panel_tests.rs | 3 ++ crates/ui-egui/src/plugins_ui.rs | 5 +++ crates/ui-egui/src/present_tests.rs | 2 + crates/ui-egui/src/present_view_tests.rs | 3 ++ crates/ui-egui/src/proto_canvas.rs | 3 ++ crates/ui-egui/src/proto_editor.rs | 1 + crates/ui-egui/src/proto_editor_tests.rs | 5 +++ crates/ui-egui/src/proto_panel.rs | 10 +++++ crates/ui-egui/src/proto_reorder_tests.rs | 1 + crates/ui-egui/src/right.rs | 18 ++++++++ crates/ui-egui/src/rtl_names_tests.rs | 11 +++++ crates/ui-egui/src/section_panel.rs | 3 ++ crates/ui-egui/src/select_all_tests.rs | 6 +++ crates/ui-egui/src/selection_colors.rs | 10 +++++ crates/ui-egui/src/selection_spacing.rs | 6 +++ crates/ui-egui/src/selection_spacing_tests.rs | 3 ++ crates/ui-egui/src/shortcuts.rs | 11 +++++ crates/ui-egui/src/style_picker_tests.rs | 2 + crates/ui-egui/src/tabs.rs | 2 + crates/ui-egui/src/theme.rs | 2 + crates/ui-egui/src/theme_startup_tests.rs | 2 + crates/ui-egui/src/titlebar_tests.rs | 2 + crates/ui-egui/src/toolbar.rs | 3 ++ crates/ui-egui/src/type_settings_tests.rs | 2 + crates/ui-egui/src/typography.rs | 5 +++ crates/ui-egui/src/typography_scrub_tests.rs | 3 ++ crates/ui-egui/src/variable_ui.rs | 1 + crates/ui-egui/src/variables_panel.rs | 9 ++++ crates/ui-egui/src/vector_panel.rs | 2 + crates/ui-egui/src/versions.rs | 2 + crates/ui-egui/src/widgets.rs | 3 ++ crates/ui-egui/src/windows_tests.rs | 14 ++++++ 76 files changed, 478 insertions(+) diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index 802d229a..40451d43 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -71,6 +71,9 @@ struct WebShell { impl eframe::App for WebShell { // eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep // autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it. + /// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs + /// still advance autosave, live and control deadlines without painting. `FigApp::ui` does + /// not repeat it. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx)); } diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index 2af5610d..ada44c0f 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -97,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut } } +/// Serve one control connection: read bounded JSON lines, reject oversized frames and a +/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input +/// answers with an error and closes the connection so HTTP body lines cannot become commands. fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> { out.set_read_timeout(Some(timeout))?; out.set_write_timeout(Some(timeout))?; diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 6144cdf3..f29a7773 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -28,6 +28,7 @@ fn reply(reader: &mut BufReader) -> Value { serde_json::from_str(&line).unwrap() } +/// Build a `ui.inspect` control request with a throwaway reply channel. fn request() -> ControlRequest { ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() } } @@ -115,6 +116,8 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() { worker.join().unwrap().unwrap(); } +/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's +/// timeout branch consumes that reply and leaves the state `Replied`. #[test] fn a_committed_reply_wins_over_the_transport_timeout() { let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id())); @@ -139,6 +142,7 @@ fn a_committed_reply_wins_over_the_transport_timeout() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate. #[test] fn a_timeout_cancels_an_open_request() { let gate = RequestGate::new(); @@ -147,6 +151,8 @@ fn a_timeout_cancels_an_open_request() { assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout"); } +/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not +/// cancelled. #[test] fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() { let gate = RequestGate::new(); @@ -268,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() { worker.join().unwrap().unwrap(); } +/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted +/// and answered, and a window that finally draws discards its expired command before +/// dispatch. #[test] fn timed_out_requests_release_a_full_deferred_ui_queue() { use figcraft_ui_egui::{FigApp, NoServices, control, windows}; diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index be380c60..4a1488ad 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -62,6 +62,9 @@ struct App { } impl eframe::App for App { + /// Native eframe logic callback: store the first context, drain control requests, run + /// guarded background work, poll macOS open events, handle close requests and title + /// changes, and schedule recovery repaints on the app clock. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { { let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/crates/collab-net/src/sessions_tests.rs b/crates/collab-net/src/sessions_tests.rs index 3c39e4d8..cf569a6d 100644 --- a/crates/collab-net/src/sessions_tests.rs +++ b/crates/collab-net/src/sessions_tests.rs @@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 { s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap() } +/// Asserts two files are hosted and joined independently: each has its own port, session and +/// edits, presence stays in its file's session, and closing one file's session leaves the +/// other live. #[test] fn two_files_are_hosted_and_joined_independently() { let mut host = session("Hana"); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 044ae884..38b24ea6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -601,6 +601,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option> { figcraft_format::fig::is_fig(&bytes).then_some(bytes) } +/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, +/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, +/// store them as a foreign-file clipboard source with a bumped layers generation, then +/// delegate to `paste` and append import warnings. fn paste_design_layers(s: &mut Session, p: &Value) -> Result { const CMD: &str = "edit.pasteDesignLayers"; let bytes = match (string(p, "html"), string(p, "data")) { diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index d88066cb..8cd8b145 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -134,6 +134,8 @@ fn ime_composition_with_cjk() { assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none()); } +/// Asserts `delete_surrounding_bytes` removes an exact UTF-8 byte range across CJK and a +/// supplementary emoji, leaves nothing past the start, and the whole edit is one undo step. #[test] fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() { // 0.37's `DeleteSurroundingBytes` counts UTF-8 bytes around the selection, before the paired @@ -156,6 +158,9 @@ fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() { assert_eq!(chars(&s, t), "ab\u{4F60}\u{597D}c\u{1F600}d", "one undo restores the whole session"); } +/// Asserts a byte range that only partly covers a character leaves it in place, while an +/// exact range deletes it, and a selection survives for the paired commit with its +/// direction preserved. #[test] fn ime_delete_surrounding_bytes_keeps_partial_characters_and_selection() { // The fork deletes the byte range exactly and leaves a character the range only partly diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 9c62c331..6551783c 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -69,6 +69,8 @@ pub struct ImeSurrounding { /// A mutex (rather than an atomic) also works on wasm32 targets without 64-bit atomics. static EDITING_SESSION: LazyLock> = LazyLock::new(|| Mutex::new(0)); +/// Return the next editing-session token from a mutex-protected counter (`wrapping_add`). +/// Zero is a valid token; the mutex also works on wasm32 without 64-bit atomics. fn next_editing_session() -> u64 { let mut session = match EDITING_SESSION.lock() { Ok(session) => session, @@ -143,6 +145,9 @@ fn range(te: &TextEdit) -> (usize, usize) { (te.caret.min(te.anchor), te.caret.max(te.anchor)) } +/// Apply an unconditional text-model mutation through `edit_if`, whose success clears the +/// composition, clamps caret and anchor to the character count, marks a `characters` override +/// when the node is source-backed, normalizes the document and bumps the revision. fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> { edit_if(s, |t, te| { f(t, te); @@ -230,6 +235,11 @@ pub fn insert_ime_commit(s: &mut Session, text: &str) -> Result { insert_model(s, text, true) } +/// Insert committed text, optionally as an IME commit: normalize line endings, drop control +/// characters except `\n`/`\t`, ignore a bare newline/CR IME commit, clear an active +/// composition for an empty or control-only IME commit, and turn a committed space after a +/// line-start `-`, `*` or `1.` into a bulleted/numbered list. Reports whether the insertion was +/// plain, transformed, or a no-op. fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result { // Stock `TextEdit` deliberately ignores these two commits (they are not a mutation); only a // real commit may do so, since typing Enter goes through `insert`. @@ -894,6 +904,9 @@ mod tests { e } + /// Asserts double-clicking a text layer selects all text, a second double-click selects the + /// word without changing the editing session, and drag and Shift+click extend the + /// selection. #[test] fn pointer_double_click_drag_and_shift_click() { let (mut s, id) = setup("Hello world"); @@ -1132,6 +1145,8 @@ mod tests { } } + /// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed + /// selection reversed. #[test] fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() { let (mut s, id) = setup("café😀e\u{301}x"); @@ -1146,6 +1161,8 @@ mod tests { assert_eq!(ime.owner.document, s.ds().unwrap().uid); } + /// Asserts an active composition is excluded from surrounding text and an empty text layer + /// reports an available, empty payload. #[test] fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() { let (mut s, id) = setup("Hi"); @@ -1162,6 +1179,8 @@ mod tests { assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable"); } + /// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized + /// selection is describable, and one byte more is `SelectionTooWide`. #[test] fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() { let long = "a".repeat(5000); @@ -1182,6 +1201,8 @@ mod tests { assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide)); } + /// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new + /// edit session changes the generation. #[test] fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() { let (mut s, id) = setup("Hello"); @@ -1197,6 +1218,8 @@ mod tests { assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch"); } + /// Asserts byte deletion around a reversed selection removes whole characters on both + /// sides, keeps the selected text and direction, and a final no-op leaves the text alone. #[test] fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() { let (mut s, id) = setup("ab你好cd"); @@ -1215,6 +1238,8 @@ mod tests { assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text"); } + /// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an + /// active composition and the revision untouched. #[test] fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() { let (mut s, id) = setup("ab你好cd"); @@ -1235,6 +1260,7 @@ mod tests { assert_eq!(s.ds().unwrap().revision, revision); } + /// Asserts a text layer in another document has a different IME owner. #[test] fn ime_owner_identity_changes_with_the_document() { let (mut s, id) = setup("Hi"); @@ -1250,6 +1276,8 @@ mod tests { assert_eq!(second.node, other); } + /// Asserts a space committed after a line-start `-` becomes a bulleted list and reports + /// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`. #[test] fn ime_commit_reports_list_autoformat_as_an_application_transformation() { let (mut s, id) = setup("-"); @@ -1261,6 +1289,8 @@ mod tests { assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); } + /// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`, + /// and bare newline/CR commits are ignored while a live composition remains. #[test] fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() { let (mut s, id) = setup("Hi"); @@ -1283,6 +1313,8 @@ mod tests { assert_eq!(data(&s, id).characters, "Hia\nb\n"); } + /// Asserts an empty or control-only IME commit ends a live composition (bumping the revision + /// once), inserts nothing, and is a harmless no-op when no composition is active. #[test] fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { let (mut s, id) = setup("Hello"); diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs index f9508942..e35f810c 100644 --- a/crates/engine/src/textfit_tests.rs +++ b/crates/engine/src/textfit_tests.rs @@ -64,6 +64,8 @@ fn a_one_word_last_line_is_avoided() { assert!(l.lines.len() == 1 || words.split_whitespace().count() > 1 || words.contains('\u{a0}'), "{words:?}"); } +/// Asserts `data.fill` keeps a short copy's font size, shrinks a long copy, and leaves the +/// source text layer untouched. #[test] fn data_fill_sizes_each_copys_text() { figcraft_text::scan_system_fonts(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index e6776d2c..e65afe2c 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -2010,6 +2010,10 @@ impl Session { Ok(()) } + /// Handle a double-click gesture: finish a pen path, rename or click labels/widgets, + /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text + /// editing (select-all on entry, word-select inside the running edit), enter vector editing + /// for selected shapes, and step groups/frames one level deeper. fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); diff --git a/crates/text/build.rs b/crates/text/build.rs index ecca6a45..ba087983 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -6,6 +6,9 @@ use std::fmt::Write as _; use std::path::PathBuf; +/// Font build script: emit `craft_fonts.rs` from `input_fonts`, passing the +/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target and `FIGCRAFT_EMBED_CJK` inputs; +/// report generation and write failures with `cargo::error`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); @@ -77,6 +80,10 @@ struct Catalog { faces: Vec<(String, String)>, } +/// Parse a font manifest of `family | style | file | scripts` rows into `CraftFont` +/// entries: skip Adobe-authored families, skip CJK unless requested, keep the web catalog +/// to its budgeted families, canonicalize and watch each face, and emit `include_bytes!` +/// items. fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result { let manifest = dir.join(manifest_rel); watch(&manifest); @@ -121,6 +128,8 @@ mod tests { struct Fixture(PathBuf); impl Fixture { + /// Test fixture: create a unique temporary directory, write a synthetic face file and + /// install the default Georgian manifest. fn new() -> Self { let dir = std::env::temp_dir().join(format!("fig-font-input-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); std::fs::create_dir_all(dir.join("fonts")).unwrap(); @@ -131,6 +140,7 @@ mod tests { fixture } + /// Test fixture: write manifest rows that all point at the fixture's `fonts/face.ttf` face. fn manifest(&self, name: &str, faces: &[(&str, &str)]) { let text = faces.iter().map(|(family, style)| format!("{family} | {style} | fonts/face.ttf | Latn\n")).collect::(); std::fs::write(self.0.join("fonts").join(name), text).unwrap(); @@ -138,11 +148,15 @@ mod tests { } impl Drop for Fixture { + /// Remove the fixture's temporary directory, ignoring errors. fn drop(&mut self) { let _ = std::fs::remove_dir_all(&self.0); } } + /// Asserts the native Latin catalog keeps its 43 permitted presentation faces, the web + /// catalog keeps only the four Inter faces, Adobe-authored families are absent, and + /// permitted native faces remain. #[test] fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() { let f = Fixture::new(); @@ -160,6 +174,7 @@ mod tests { assert!(input_fonts(Some(&f.0), true, true, false).is_ok()); } + /// Asserts required web input rejects a catalog missing Inter Regular or Inter SemiBold. #[test] fn required_web_rejects_core_only_and_partial_latin_catalogs() { let f = Fixture::new(); @@ -168,6 +183,8 @@ mod tests { assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold")); } + /// Asserts required web input embeds the Inter and Georgian catalogs (three entries) and + /// rejects a manifest without Noto Sans Georgian Regular. #[test] fn required_web_embeds_both_catalogs_and_requires_georgian() { let f = Fixture::new(); @@ -180,6 +197,8 @@ mod tests { assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular")); } + /// Asserts absent font input is empty when optional and an error when required, while present + /// input includes Noto Sans Georgian. #[test] fn optional_and_native_inputs_keep_their_existing_policy() { let f = Fixture::new(); @@ -189,6 +208,8 @@ mod tests { assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian")); } + /// Asserts required web input rejects a manifest whose face file is missing, even when every + /// face row is listed. #[test] fn required_web_rejects_missing_asset_even_with_all_face_rows() { let f = Fixture::new(); diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 79a28118..3c5fafb6 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -253,6 +253,8 @@ struct Shared { name: String, } +/// Run `f` on the per-context temporary `Shared` accessibility state keyed by +/// `figcraft_a11y`. fn shared(ctx: &Context, f: impl FnOnce(&mut Shared) -> R) -> R { ctx.data_mut(|d| f(d.get_temp_mut_or_default::(Id::unique("figcraft_a11y")))) } diff --git a/crates/ui-egui/src/a11y_tests.rs b/crates/ui-egui/src/a11y_tests.rs index a2c54cbe..14be637d 100644 --- a/crates/ui-egui/src/a11y_tests.rs +++ b/crates/ui-egui/src/a11y_tests.rs @@ -404,6 +404,8 @@ fn the_align_buttons_are_off_for_a_layer_alone_on_the_page() { assert!(!align_left(&tree(&mut u)), "one in a frame aligns to it"); } +/// Asserts side-panel and Pages edges are not focusable, and Tab never stops on the +/// side-panel edges. #[test] fn the_custom_panel_edges_never_enter_the_keyboard_or_focus_order() { // FigCraft's panels are fixed, so egui makes no `panel.__resize` widgets; the draggable edges diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index e9f0ffce..db0cc0f7 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -164,6 +164,7 @@ enum Act { Hide(NodeId, bool), } +/// Stable egui id for the assets drag payload. fn drag_id() -> Id { Id::unique("asset_drag") } @@ -581,6 +582,10 @@ fn render_thumbs(ui: &Ui, w: f32, thumbs: &mut Thumbs, wanted: &[(Source, NodeId } } +/// Draw the Assets panel: refresh libraries, switch to the libraries view when asked, +/// otherwise cache a listing keyed by revision, query and library state, render local and +/// enabled-library assets with reused thumbnails, and dispatch place, style, hide, toggle +/// and drag actions. pub fn show(app: &mut FigApp, ui: &mut Ui) { refresh_libraries(app, ui); if app.ui.libraries { @@ -773,6 +778,8 @@ fn drag_feedback(app: &mut FigApp, ui: &mut Ui, doc: &Document, libs: &HashMap) { let t = Tokens::get(ui.ctx()); let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("asset_ghost"))); @@ -831,6 +838,10 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) { }); } +/// Draw the publish form: status, publishable components/styles/collections, description and +/// publish buttons. Run `library.publish` locally or publish to the server in the +/// background, reset the form on success, and report when the file is unsaved so other files +/// cannot yet use it. fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let form_id = Id::unique("asset_publish_form"); let mut form: PublishForm = ui.data(|d| d.get_temp(form_id)).unwrap_or_default(); @@ -1010,6 +1021,8 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { ui.add_space(8.0); } +/// Draw the libraries enabled in this file with version/count/status, remove and swap +/// actions, add-file and pasted-link controls, and background fetch/cancel handling. fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let t = Tokens::get(ui.ctx()); title(ui, w, tr("Libraries in this file")); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 4211b3cc..a6770823 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -163,6 +163,10 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers } } +/// Run one canvas pass: update the viewport size and first-fit zoom; handle pointer/key input +/// and accessibility; draw the drop overlay, guides, live pointer and preedit; request/poll +/// renders and upload the texture; draw the page background and last texture; then draw overlays, +/// labels, handles, comments, versions/branch/dev/live UI and the cursor. pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let rect = ui.max_rect(); app.canvas.rect = rect; diff --git a/crates/ui-egui/src/clipboard_fence_tests.rs b/crates/ui-egui/src/clipboard_fence_tests.rs index 9211a4d4..76ddf81c 100644 --- a/crates/ui-egui/src/clipboard_fence_tests.rs +++ b/crates/ui-egui/src/clipboard_fence_tests.rs @@ -25,14 +25,17 @@ struct Recorded { struct Clipboard(Arc>); impl Services for Clipboard { + /// Record a system-clipboard text write in the test clipboard service. fn clipboard_set_text(&mut self, s: &str) { let mut r = self.0.lock().unwrap(); r.text.push(s.to_string()); r.last_text = Some(s.to_string()); } + /// Return the test system clipboard's last text. fn clipboard_get_text(&mut self) -> Option { self.0.lock().unwrap().last_text.clone() } + /// Count a PNG clipboard write in the test clipboard service. fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) { self.0.lock().unwrap().pngs += 1; } @@ -46,6 +49,7 @@ struct Win { } impl Win { + /// Create a test window over a session containing one selected rectangle. fn new() -> Self { let mut s = Session::new(); s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); @@ -54,6 +58,7 @@ impl Win { win } + /// Build a test window around `s` with a recording clipboard service and run three frames. fn with_session(s: Session) -> Self { let recorded = Arc::new(Mutex::new(Recorded::default())); let app = FigApp::new(s, Box::new(Clipboard(recorded.clone()))); @@ -64,6 +69,7 @@ impl Win { win } + /// Run one production UI frame with `events` at the next 60 Hz test time. fn frame(&mut self, events: Vec) { self.time += 1.0 / 60.0; self.app.clock.set_test_now(Some(self.time)); @@ -74,12 +80,15 @@ impl Win { out.textures_delta.clear(); } + /// Return the recorded `(text writes, PNG writes)` count. fn writes(&self) -> (usize, usize) { let r = self.recorded.lock().unwrap(); (r.text.len(), r.pngs) } } +/// Asserts that a control-channel copy shortcut writes no system clipboard, still runs the +/// command and is acknowledged. #[test] fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() { let mut w = Win::new(); @@ -106,6 +115,8 @@ fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() { assert!(rx.try_recv().is_ok(), "the batch is acknowledged"); } +/// Asserts copy commands run under the synthetic-control fence write nothing, while the same +/// command afterwards writes once. #[test] fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() { let mut w = Win::new(); @@ -120,6 +131,7 @@ fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() { assert_eq!(w.writes(), (1, 0)); } +/// Return the characters of a text node in the test window. fn edit_text(w: &Win, id: NodeId) -> String { match &w.app.session.doc().unwrap().node(id).unwrap().kind { NodeKind::Text(t) => t.characters.clone(), @@ -127,6 +139,8 @@ fn edit_text(w: &Win, id: NodeId) -> String { } } +/// Asserts a synthetic Cut deletes the selected text, keeps the edit session active, writes +/// no system clipboard, and ends its fence with the frame. #[test] fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone() { let mut s = Session::new(); @@ -147,6 +161,8 @@ fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone() assert!(!w.app.control_synthetic, "the fence ends with the frame"); } +/// Asserts a programmatic copy leaves the system clipboard alone and invalidates the stale +/// mirror, so a later user paste uses the system text rather than the programmatic layers. #[test] fn a_programmatic_copy_does_not_let_a_stale_mirror_hide_the_users_paste() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 90d00c3f..f1cde02f 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -339,6 +339,8 @@ fn hue_color(ctx: &egui::Context, h: f32) -> Color32 { doc_c32(ctx, Hsb { h, s: 1.0, b: 1.0 }.to_color()) } +/// Draw the saturation/value square and its knob, updating `hsb.s` and `hsb.b` on click or +/// drag; returns `(changed, done)`. fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { // White → hue horizontally, then transparent → black vertically. let mut m = Mesh::default(); @@ -377,6 +379,8 @@ fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { (changed, resp.drag_stopped() || resp.clicked()) } +/// Draw the hue gradient and knob, updating `hsb.h` clamped below 360; returns +/// `(changed, done)`. fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { let n = 12; let mut m = Mesh::default(); @@ -417,6 +421,8 @@ fn slider_knob(ui: &mut Ui, rect: Rect, t: f32, id: Id) -> Option<(f32, bool)> { None } +/// Draw the checkerboard and transparent-to-opaque alpha gradient with a knob; returns the +/// new alpha and done state, or `None` when the slider is not interacting. fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { widgets::checker(&ui.painter().with_clip_rect(rect), rect, rect.height() / 2.0); let mut m = Mesh::default(); @@ -441,6 +447,9 @@ pub fn window_id() -> Id { Id::unique("color_picker") } +/// Draw the colour picker for the current target: close it when there is no target or the +/// target's selection no longer matches, otherwise apply eyedropper, paint-kind, colour and +/// gradient-stop edits live, positioned beside a plugin field when applicable. pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(target) = app.ui.picker.clone() else { // A colour sampled for a picker that has closed has no paint to go to. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4c3be3de..97e62d1f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -70,18 +70,22 @@ pub struct RequestGate { } impl RequestGate { + /// Create a request gate in its default `Open` state. pub fn new() -> Self { Self::default() } + /// Return a copy of the gate's current request state. pub fn state(&self) -> RequestState { *self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } + /// Whether the gate is `Cancelled`. pub fn is_cancelled(&self) -> bool { self.state() == RequestState::Cancelled } + /// Whether the gate is `Replied`. pub fn is_replied(&self) -> bool { self.state() == RequestState::Replied } @@ -121,6 +125,7 @@ pub struct ControlRequest { } impl ControlRequest { + /// Whether this request's gate is `Cancelled`. pub fn is_cancelled(&self) -> bool { self.state.is_cancelled() } @@ -147,6 +152,7 @@ struct SlotInner { } impl CaptureSlot { + /// Create an empty capture slot for `viewport`, holding a clone of the UI context. fn new(ctx: &Context, viewport: egui::ViewportId) -> Arc { Arc::new(Self { inner: Mutex::new(SlotInner::default()), ctx: ctx.clone(), viewport }) } @@ -161,6 +167,8 @@ impl CaptureSlot { }) } + /// Store the first delivered image only while the slot is open and untaken, ignoring later + /// or closed deliveries, then wake the viewport after releasing the lock. pub(crate) fn complete(&self, image: Arc) { { let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -214,6 +222,7 @@ pub struct CaptureSpec { const SHOT_PNG_OVERHEAD: usize = 64 * 1024; impl CaptureSpec { + /// Return the checked pixel byte count (`width × height × 4`), or `None` on overflow. fn bytes(&self) -> Option { self.size_px[0].checked_mul(self.size_px[1])?.checked_mul(4) } @@ -272,6 +281,7 @@ pub struct ShotRequest { } impl ShotRequest { + /// Close and drop the capture slot, if any, and zero the reserved byte count. fn release(&mut self) { if let Some(slot) = self.slot.take() { slot.close(); @@ -327,6 +337,7 @@ fn current_spec(ctx: &Context, viewport: egui::ViewportId) -> Option) { let _ = r.reply.send(ControlReply { id: r.id.clone(), result }); } @@ -819,6 +830,8 @@ enum EncodedShot { } impl EncodedShot { + /// Deliver the encoded shot: for `File`, write the PNG through `file_access` and return its + /// path and dimensions; for `Data`, return the prebuilt result. fn finish(self) -> Result { match self { Self::File { path, png, width, height } => { diff --git a/crates/ui-egui/src/control_capture_tests.rs b/crates/ui-egui/src/control_capture_tests.rs index ce4df1aa..6068ce52 100644 --- a/crates/ui-egui/src/control_capture_tests.rs +++ b/crates/ui-egui/src/control_capture_tests.rs @@ -12,6 +12,7 @@ use serde_json::json; use crate::control::{CaptureSpec, ControlReply, ControlRequest}; use crate::layers_drag_tests::Ui; +/// Queue a `ui.screenshot` request in the test app and return its reply receiver. fn push(u: &mut Ui, id: u64, params: serde_json::Value) -> std::sync::mpsc::Receiver { let (tx, rx) = std::sync::mpsc::channel(); u.app.control_queue.push(ControlRequest { id: json!(id), method: "ui.screenshot".into(), params, reply: tx, state: Default::default() }); @@ -38,10 +39,13 @@ fn resized_frame(u: &mut Ui, size: egui::Vec2) -> egui::FullOutput { out } +/// Whether any viewport in the frame output issues a `Screenshot` command. fn has_screenshot(out: &egui::FullOutput) -> bool { out.viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Screenshot(_)))) } +/// Asserts reverse capture completion replies only to the matching request, then drains both +/// requests. #[test] fn two_captures_correlate_by_request_across_reverse_completion() { let mut u = Ui::new(Session::empty()); @@ -67,6 +71,7 @@ fn two_captures_correlate_by_request_across_reverse_completion() { assert!(u.app.capture.shots.is_empty(), "no retained requests"); } +/// Asserts a cancelled capture gets no reply and its slot is released. #[test] fn a_cancelled_capture_never_replies_or_writes() { let mut u = Ui::new(Session::empty()); @@ -89,6 +94,7 @@ fn commit_dir(tag: &str) -> std::path::PathBuf { dir } +/// Asserts cancellation after encoding still prevents the final file write and reply. #[test] fn a_cancellation_after_encoding_prevents_the_final_write() { let dir = commit_dir("cancelled"); @@ -107,6 +113,7 @@ fn a_cancellation_after_encoding_prevents_the_final_write() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a capture that arrives past its deadline reports a timeout and writes no PNG. #[test] fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() { let mut u = Ui::new(Session::empty()); @@ -129,6 +136,8 @@ fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts an expiry during encoding reports the timeout under the gate, marks the request +/// replied and writes no file. #[test] fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() { let dir = commit_dir("expired"); @@ -149,6 +158,8 @@ fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a commit before the deadline writes the file and queues one reply, and a second +/// commit is refused. #[test] fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() { let dir = commit_dir("committed"); @@ -172,6 +183,8 @@ fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a capture past its deadline sends one timeout reply, releases its slot, and late +/// pixels are ignored. #[test] fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() { let mut u = Ui::new(Session::empty()); @@ -191,6 +204,8 @@ fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() { assert!(rx.try_recv().is_err(), "a late capture is inert"); } +/// Asserts the snapshot count budget rejects an over-budget request before issuing, and every +/// admitted capture still completes under its own id. #[test] fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() { let mut u = Ui::new(Session::empty()); @@ -213,6 +228,7 @@ fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() { rxs.clear(); } +/// Asserts an undersized readback is not accepted but reissued with one retry and no reply. #[test] fn an_undersized_readback_is_reissued_instead_of_accepted() { let mut u = Ui::new(Session::empty()); @@ -230,6 +246,7 @@ fn an_undersized_readback_is_reissued_instead_of_accepted() { assert!(!r.issued, "waits for the settled geometry again"); } +/// Asserts a one-pixel rounding difference is accepted as the same surface. #[test] fn a_one_pixel_rounding_difference_is_still_the_same_surface() { let mut u = Ui::new(Session::empty()); @@ -245,6 +262,7 @@ fn a_one_pixel_rounding_difference_is_still_the_same_surface() { assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts an A→B→A tab switch invalidates the admitted capture with "document changed". #[test] fn a_document_a_to_b_to_a_switch_is_not_the_admitted_capture() { let mut s = Session::empty(); @@ -268,6 +286,8 @@ fn a_document_a_to_b_to_a_switch_is_not_the_admitted_capture() { assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts a geometry change bumps the capture epoch and reissues instead of accepting old +/// pixels. #[test] fn a_geometry_change_reissues_instead_of_accepting_old_pixels() { let mut u = Ui::new(Session::empty()); @@ -288,6 +308,8 @@ fn a_geometry_change_reissues_instead_of_accepting_old_pixels() { assert!(!r.issued, "waits for the settled geometry again"); } +/// Asserts a translucent pixel round-trips through the PNG as straight RGBA without +/// darkening. #[test] fn a_translucent_pixel_round_trips_straight_rgba() { let img = egui::ColorImage::from_rgba_unmultiplied([1, 1], &[128, 64, 32, 128]); @@ -298,6 +320,8 @@ fn a_translucent_pixel_round_trips_straight_rgba() { assert_eq!(decoded.get_pixel(0, 0).0, [128, 64, 32, 128], "straight alpha is not darkened"); } +/// Asserts a capture is not issued before three completed real UI frames; the following +/// completed frame issues it. #[test] fn a_capture_is_not_issued_before_three_completed_real_ui_frames() { let mut u = Ui::new(Session::empty()); @@ -313,6 +337,8 @@ fn a_capture_is_not_issued_before_three_completed_real_ui_frames() { assert!(u.app.capture.shots[0].issued, "issued once three frames have presented their layout"); } +/// Asserts a discarded sizing pass does not count toward capture settling, and the next +/// completed frame issues it. #[test] fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() { let mut u = Ui::new(Session::empty()); @@ -327,6 +353,8 @@ fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() { assert!(u.app.capture.shots[0].issued, "the next completed frame issues the capture"); } +/// Asserts pixels delivered while hidden force a draining paint and are answered rather than +/// timed out. #[test] fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() { let mut u = Ui::new(Session::empty()); @@ -354,6 +382,8 @@ fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() { assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts a capture for a window with unknown visibility is parked with its deadline and a +/// paint request, then times out once. #[test] fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline() { let mut u = Ui::new(Session::empty()); @@ -386,6 +416,8 @@ fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline() assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts a completed capture is rejected when current geometry is unavailable, failing +/// closed. #[test] fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() { let mut u = Ui::new(Session::empty()); @@ -412,6 +444,7 @@ fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() { assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts a tab switch while a capture settles invalidates it with "document changed". #[test] fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrives() { let mut s = Session::empty(); @@ -435,6 +468,8 @@ fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrive assert!(u.app.capture.shots.is_empty(), "released"); } +/// Asserts the capture budget covers the encoding and reply peaks, not just pixels, and an +/// over-budget resize is rejected before readback. #[test] fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() { let fits = CaptureSpec { viewport: egui::ViewportId::ROOT, size_px: [4096, 4096], pixels_per_point: 1.0, zoom: 1.0 }; @@ -462,6 +497,8 @@ fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() { assert!(!has_screenshot(&out), "over-budget growth is rejected before any readback command"); } +/// Asserts resizing settling captures rechecks aggregate reserved bytes, rejects the request +/// that no longer fits, and makes the survivor reserve its real cost. #[test] fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() { let mut u = Ui::new(Session::empty()); @@ -481,6 +518,8 @@ fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() { assert!(!has_screenshot(&out), "neither request was read back using the old small reservation"); } +/// Asserts an allowed resize restarts settling, issues only after three new-size frames, and +/// reserves the current cost. #[test] fn an_allowed_resize_restarts_settling_and_issues_with_the_current_reservation() { let mut u = Ui::new(Session::empty()); @@ -518,6 +557,8 @@ fn push_input(u: &mut Ui, id: u64) -> std::sync::mpsc::Receiver { rx } +/// Asserts injected input is queued, applied by the presenting pass, and acknowledged once +/// after that pass. #[test] fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() { let mut u = Ui::new(Session::empty()); @@ -534,6 +575,8 @@ fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() { assert!(!u.app.control_synthetic, "the fence ends with the frame"); } +/// Asserts a discarded pass neither acknowledges nor replays an injected batch; the presented +/// pass acknowledges once. #[test] fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() { let mut u = Ui::new(Session::empty()); @@ -565,6 +608,8 @@ fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() { assert!(app.input_acks.is_empty() && app.applied_acks.is_empty()); } +/// Asserts injected text is applied once across a discarded sizing pass and acknowledged +/// once. #[test] fn injected_text_lands_once_across_a_discarded_sizing_pass() { // egui reruns a discarded pass with the same `RawInput`. The custom editor applied the text in diff --git a/crates/ui-egui/src/credits_tests.rs b/crates/ui-egui/src/credits_tests.rs index 8b10a622..fb3993ad 100644 --- a/crates/ui-egui/src/credits_tests.rs +++ b/crates/ui-egui/src/credits_tests.rs @@ -51,10 +51,12 @@ impl Ui { u } + /// Store the About table's current view in test context data. fn set_view(&self, v: View) { self.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("credits_view"), v)); } + /// Read the stored About table view, defaulting when unset. fn view(&self) -> View { self.ctx.data(|d| d.get_temp::(egui::Id::unique("credits_view"))).unwrap_or_default() } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3ec562a2..8d4d8fdb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -179,6 +179,10 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { } } +/// Draw the app's dialogs and cards: pending opens/imports/missing fonts, About, +/// Preferences, shortcuts, find/replace, batch rename, menu input, variables, dev compare, +/// branch, export preview, colour profile, plugin, live join/publish, online dialogs and +/// close confirmation. pub fn show(app: &mut FigApp, ctx: &Context) { // Dropped files waiting to open: their card, then (once it has shown) the files. crate::pending_open::pending_opens(app, ctx); diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 4e724553..341e7cca 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -467,6 +467,8 @@ fn lead(u: &Ui, id: Id) -> Pos2 { u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() } +/// Asserts scrubbing the colour picker's R and alpha fields updates the colour live, each +/// scrub adding one undo step. #[test] fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { let (mut u, a, _) = setup(); @@ -490,6 +492,8 @@ fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { assert_eq!(undo_steps(&u), steps + 2, "alpha"); } +/// Asserts scrubbing a prototype delay sets the value once when released and adds one undo +/// step. #[test] fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { let mut s = Session::new(); @@ -514,6 +518,7 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { assert_eq!((ms, undo_steps(&u)), (800, steps + 1)); } +/// Asserts scrubbing the scale panel's width scales the layer live in one undo step. #[test] fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() { let (mut u, a, _) = setup(); @@ -584,6 +589,8 @@ fn the_intensity_label_fits_whole_in_every_language() { out.textures_delta.clear(); } +/// Asserts a label too long for its number field is elided before the value, while the value +/// stays whole and both remain inside the field. #[test] fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { let ctx = egui::Context::default(); @@ -780,6 +787,8 @@ fn a_deferred_field_save_keeps_its_document_when_the_next_input_switches_tabs() } } +/// Asserts a deferred field save does not retarget a closed, moved or foreign owner: the other +/// file stays untouched, Save as is not opened, and the stale request is consumed once. #[test] fn a_deferred_field_save_does_not_retarget_a_closed_moved_or_foreign_owner() { for change in ["closed", "moved", "foreign"] { diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 4c4b0de1..a87fc9f4 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -58,6 +58,9 @@ pub fn turn_since(painter: &Painter, from: ShapeIdx, pivot: Pos2, angle: f32) { }); } +/// Recursively rotate shape geometry by `rot`/`angle` about `pivot`: leaves +/// no-op/callback/band shapes alone, rotates points and centers, adds the angle to +/// ellipses/rects/text, and rotates meshes. fn turn(shape: &mut Shape, rot: Rot2, angle: f32, pivot: Pos2) { let at = |p: &mut Pos2| *p = pivot + rot * (*p - pivot); match shape { diff --git a/crates/ui-egui/src/frame_presets.rs b/crates/ui-egui/src/frame_presets.rs index 0d1c4b91..9eb28162 100644 --- a/crates/ui-egui/src/frame_presets.rs +++ b/crates/ui-egui/src/frame_presets.rs @@ -10,6 +10,8 @@ use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, PAD}; +/// Draw the expandable frame-preset categories; choosing a preset runs `frame.create`, +/// switches to the Move tool and zooms to the new selection. pub fn panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); widgets::section_header(ui, tr("Frame"), &[], false); diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs index 827597ac..3200ca42 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -49,6 +49,7 @@ struct Placed { line: Option<[Point; 2]>, } +/// Stable egui id for guide dragging. fn drag_id() -> Id { Id::unique("guide_drag") } diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index b7a575fa..d29d59ee 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -67,6 +67,7 @@ fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Po ]) } +/// Stable egui id for handle dragging. fn drag_id() -> Id { Id::unique("figcraft_handle_drag") } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index ebea682e..9c809b99 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -188,6 +188,8 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { } } +/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it) +/// and section tabs; choosing a section clears the file query. fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { let mut y = left.min.y + 20.0; crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0))); @@ -475,6 +477,8 @@ fn matches(template: &Template, query: &str, kind: Option) -> bool { || has(template.size)) } +/// Draw template search and kind filter, show no-match guidance and a "Show all templates" +/// button that clears the query and kind, and lay matching templates out in a grid. fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { heading(ui, t, tr("Templates")); ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index cc19d6a9..ee25124a 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -248,6 +248,8 @@ fn a_finished_background_render_wakes_the_ui() { assert!(worker.poll().is_some(), "the woken frame finds the image"); } +/// Asserts a control request wakes an idle UI and is answered, and screenshots keep frames +/// coming until delivered before the UI rests again. #[test] fn control_requests_and_screenshots_wake_an_idle_ui() { let (mut h, _) = document(); @@ -341,6 +343,8 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL); } +/// Asserts a hidden window answers edits and inspection at once, while a screenshot is +/// parked with a deadline and times out once instead of waiting for a frame. #[test] fn a_hidden_window_answers_at_once() { // #269, #720: minimised or covered, the window draws no frames, but the app's logic still runs: @@ -424,6 +428,8 @@ fn auto_save_writes_a_changed_file_then_rests() { let _ = std::fs::remove_dir_all(&dir); } +/// Asserts a hidden logic tick runs background work and advances the autosave deadline +/// without counting a UI frame, and is idempotent at the same time. #[test] fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() { let (mut h, _) = document(); @@ -444,6 +450,7 @@ fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() { assert_eq!(h.app.ui.next_autosave, deadline, "idempotent at the same monotonic time"); } +/// Asserts a discarded sizing pass counts one drawn frame, not one per pass. #[test] fn a_discarded_sizing_pass_sets_up_and_counts_one_frame_only() { let (mut h, _) = document(); diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index e699dda5..e6c2f698 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -48,6 +48,7 @@ fn ime_descriptor(u: &mut Ui, events: Vec) -> IMEOutput { u.frame_raw(RawInput { events, ..Default::default() }).platform_output.ime.expect("the IME stays on while editing") } +/// Return the available surrounding-text payload of an IME output, panicking when unavailable. fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload { match &ime.surrounding_text { IMESurroundingText::Available(payload) => payload, @@ -55,6 +56,7 @@ fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload { } } +/// Create a text layer with `text` and return its node id. fn text_layer(s: &mut Session, text: &str) -> NodeId { NodeId(s.execute("text.create", &json!({"text": text, "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()) } @@ -103,6 +105,8 @@ fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() { assert_eq!(text(&u, t), "Hi你好\n!"); } +/// Asserts an empty IME commit ends the composition, inserts nothing, keeps the editor +/// focused, and the next pass observes the committed text. #[test] fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { // Safari's egui web integration sends `Commit("")` (not `Preedit("")`) when the last @@ -126,6 +130,8 @@ fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { assert_eq!(text(&u, t), "Hello"); } +/// Asserts byte deletion before the paired commit replaces the exact UTF-8 range, and one undo +/// restores the pre-IME text. #[test] fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() { // 0.37 sends `DeleteSurroundingBytes` immediately before the commit that replaces it. The @@ -146,6 +152,8 @@ fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() { assert_eq!(text(&u, t), "ab\u{4F60}\u{597D}cd", "one undo restores the pre-IME text"); } +/// Asserts a caret click invalidates the whole stale IME batch (both byte and character +/// deletions) while a later direct commit still works. #[test] fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() { figcraft_text::scan_system_fonts(); @@ -185,6 +193,8 @@ fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() { } } +/// Asserts a control-channel document switch rejects the old composition's delete and commit +/// batch, while a direct commit to the new target remains valid. #[test] fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() { figcraft_text::scan_system_fonts(); @@ -214,6 +224,8 @@ fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() { assert_eq!(text(&u, b), "BravoX", "a direct commit to the new noncomposing target remains valid"); } +/// Asserts the first observation and ordinary typing are `Other`, while an accepted commit is +/// `InputMethod` with UTF-8 offsets, the same owner and generation. #[test] fn ime_cause_is_input_method_only_for_an_accepted_commit() { figcraft_text::scan_system_fonts(); @@ -240,6 +252,8 @@ fn ime_cause_is_input_method_only_for_an_accepted_commit() { assert_eq!(text(&u, t), "Hi你好x"); } +/// Asserts a frame mixing IME and application edits is `Other`, and a later pure commit is +/// `InputMethod` without lingering state. #[test] fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() { figcraft_text::scan_system_fonts(); @@ -267,6 +281,7 @@ fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() { assert_eq!(text(&u, t), "Hiあbうcえおdか"); } +/// Asserts a control-channel text edit is an application change (`Other`). #[test] fn a_control_channel_text_edit_is_an_application_change() { figcraft_text::scan_system_fonts(); @@ -283,6 +298,8 @@ fn a_control_channel_text_edit_is_an_application_change() { assert_eq!(available(&out).text, "Hiz"); } +/// Asserts a byte delete followed by a commit is `InputMethod` and describes the +/// post-mutation text and caret. #[test] fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text() { figcraft_text::scan_system_fonts(); @@ -302,6 +319,8 @@ fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text() assert_eq!(text(&u, t), "abXd"); } +/// Asserts preedit text is excluded from surrounding text and earns no receipt, while the +/// following commit is `InputMethod`. #[test] fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method() { figcraft_text::scan_system_fonts(); @@ -322,6 +341,8 @@ fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method() assert_eq!(available(&committed).cursor, 8); } +/// Asserts surrounding text is logical UTF-8 with byte offsets and keeps a reversed +/// selection. #[test] fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() { figcraft_text::scan_system_fonts(); @@ -339,6 +360,8 @@ fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() { assert!(payload.cursor < payload.anchor, "the reversed selection is not sorted"); } +/// Asserts empty text is available while a selection wider than the 4000-byte budget is +/// explicitly unavailable but still has a nonzero IME context generation. #[test] fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable() { figcraft_text::scan_system_fonts(); @@ -361,6 +384,7 @@ fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable( assert!(wide.context_generation > 0, "the owner stays known when only the text is unavailable"); } +/// Asserts replacing a too-wide selection with a commit is still an `InputMethod` receipt. #[test] fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() { figcraft_text::scan_system_fonts(); @@ -382,6 +406,8 @@ fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() { assert_eq!((available(&out).cursor, available(&out).anchor), (1, 1)); } +/// Asserts another text node is another owner, and re-entering the same node changes the +/// context generation while keeping the owner key. #[test] fn a_new_edit_session_and_another_text_node_are_new_ime_owners() { figcraft_text::scan_system_fonts(); @@ -405,6 +431,8 @@ fn a_new_edit_session_and_another_text_node_are_new_ime_owners() { assert_ne!(again.context_generation, first.context_generation, "re-entering is a replaced target"); } +/// Asserts an accepted commit survives a discarded rerun without inserting twice and keeps +/// its `InputMethod` cause, while a mixed rerun is `Other`. #[test] fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its_cause() { figcraft_text::scan_system_fonts(); @@ -428,6 +456,9 @@ fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its assert_eq!(mixed.change_cause, ImeChangeCause::Other); } +/// Asserts IME context state is scoped per viewport: each viewport has its own generation, +/// the root keeps its context across an aux pass, and a closed aux viewport does not inherit +/// the old generation. #[test] fn ime_context_state_is_scoped_per_viewport() { figcraft_text::scan_system_fonts(); @@ -480,6 +511,7 @@ fn ime_context_state_is_scoped_per_viewport() { assert_ne!(aux_again.context_generation, aux_ime.context_generation); } +/// Asserts the engine's IME surrounding-text byte limit equals egui's wire limit. #[test] fn the_engine_surrounding_limit_matches_the_wire_limit() { // A divergence would truncate or overrun the bounded payload silently. @@ -488,6 +520,8 @@ fn the_engine_surrounding_limit_matches_the_wire_limit() { assert_eq!(engine, wire); } +/// Asserts a frame observes the IME exactly once in both the editor pass and the Home +/// early-return pass. #[test] fn a_frame_observes_the_ime_once_even_when_it_ends_early() { figcraft_text::scan_system_fonts(); @@ -506,6 +540,8 @@ fn a_frame_observes_the_ime_once_even_when_it_ends_early() { assert_eq!(u.app.ime_observations - before, 1, "one idle observation on the home screen"); } +/// Asserts a double-click inside an existing edit selects the word without changing the IME +/// owner or generation. #[test] fn a_double_click_inside_the_edit_keeps_the_ime_generation() { figcraft_text::scan_system_fonts(); @@ -536,6 +572,8 @@ fn a_double_click_inside_the_edit_keeps_the_ime_generation() { assert_eq!(available(&after).text, "Hello world"); } +/// Asserts a `window.new` that moves the edited file leaves the root window emitting no IME +/// descriptor. #[test] fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() { figcraft_text::scan_system_fonts(); @@ -560,6 +598,8 @@ fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() { assert!(out.platform_output.ime.is_none(), "the root must not emit a descriptor for a file it no longer shows"); } +/// Asserts a CRLF-normalized commit is application-origin (`Other`), and bare-newline and +/// control-only commits insert nothing. #[test] fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing() { figcraft_text::scan_system_fonts(); @@ -582,6 +622,8 @@ fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing() assert!(matches!(control.surrounding_text, IMESurroundingText::Available(_))); } +/// Asserts character deletion keeps the selection for the paired commit, which replaces it +/// and reports `InputMethod`. #[test] fn ime_delete_surrounding_chars_keeps_the_selection_for_the_paired_commit() { figcraft_text::scan_system_fonts(); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 42a9569e..0ce4fa7a 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -134,6 +134,8 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the left panel: versions when open, otherwise the file/Assets header with in-place +/// file rename and collapse control, followed by Pages and Layers or the Assets panel. pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { if app.ui.versions.open { crate::versions::panel(app, ui); @@ -173,6 +175,8 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the page list with open/search/add controls, filtering, selection, rename, +/// reorder/drag and layer drop-to-page; Dev Mode allows opening pages but blocks mutations. fn pages(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // Dev Mode only inspects: rows open pages, but don't add, rename, reorder, duplicate or delete. @@ -444,6 +448,7 @@ struct Sweep { ids: Vec, } +/// Stable egui id for the eye/padlock sweep state. fn sweep_id() -> Id { Id::unique("layer_toggle_sweep") } @@ -654,6 +659,7 @@ fn rename_layer(app: &mut FigApp, id: NodeId) { app.ui.layers_seen_sel = app.session.selection(); } +/// Stable egui id for a layer row's rename field. fn rename_id(id: NodeId) -> Id { Id::unique(("rename", id)) } @@ -722,6 +728,8 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut } } +/// Draw the layer tree with search, cached rows, reveal-selection expansion and scrolling, +/// rename/Tab navigation, selection and drag/drop, sweep toggles and Dev Mode restrictions. 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. diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index e801ad00..0f312499 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -312,6 +312,7 @@ fn pages_down_to(u: &mut Ui, to: f32) -> Vec { opened } +/// Asserts a 60-page list scrolls with the wheel to its last page, past its first pages. #[test] fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { let mut s = Session::new(); @@ -383,6 +384,8 @@ fn double_click_checking_focus(u: &mut Ui, p: Pos2) { } } +/// Asserts double-clicking a page selects and renames it, with the keyboard in its name +/// field for the screen-reader tree. #[test] fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index 6ffa9696..c1c229eb 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -144,6 +144,8 @@ fn rename_without_a_name_renames_one_row_or_opens_the_dialog() { assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc)); } +/// Asserts a layer rename continues after its row is scrolled out of view and commits on +/// Enter. #[test] fn a_rename_scrolled_out_of_view_goes_on() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index eef37755..0874da2f 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -283,6 +283,8 @@ fn add_slot(ui: &mut Ui, label: &str, tip: &str, width: f32) -> egui::Response { resp } +/// Draw the 3×3 main/cross alignment grid, auto-gap modes, grid column/row gaps and the +/// horizontal baseline checkbox. fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let t = Tokens::get(ui.ctx()); let grid_mode = l.mode == LayoutMode::Grid; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d3c17453..6f0effd8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -488,6 +488,7 @@ pub struct FrameClock { } impl Default for FrameClock { + /// Start the frame clock at the current web time with no injected test time. fn default() -> Self { Self { origin: web_time::Instant::now(), test_now: None } } @@ -722,6 +723,9 @@ pub struct UiState { } impl Default for UiState { + /// Seed the default UI state (panel widths, tabs, pages, Home, presenters, pickers and + /// dialogs off, and the rest of the transient UI fields); `FigApp::new` overrides panel + /// widths and the pages height from preferences. fn default() -> Self { Self { left_width: PANEL_DEFAULT, @@ -915,6 +919,9 @@ pub struct FigApp { pub type Done = Box; impl FigApp { + /// Construct `FigApp` from a session and services, taking panel widths and pages height + /// from preferences and initializing UI, canvas, clocks, capture/IME and transient channel + /// state. pub fn new(session: Session, services: Box) -> Self { // The main window's panels open as wide as they were left. let (left_width, right_width) = session.prefs.panel_widths; @@ -2044,6 +2051,9 @@ impl FigApp { } } + /// Draw the toast: start its lifetime on the frame **after** the first drawn frame, request + /// that frame and the frame that hides it, expire and remove it at the deadline, and place it + /// at the bottom centre. fn toast_ui(&mut self, ctx: &egui::Context) { let Some((msg, clock)) = &mut self.ui.toast else { return }; let now = ctx.input(|i| i.time); diff --git a/crates/ui-egui/src/live_join_tests.rs b/crates/ui-egui/src/live_join_tests.rs index 81eddc76..bc1db8ad 100644 --- a/crates/ui-egui/src/live_join_tests.rs +++ b/crates/ui-egui/src/live_join_tests.rs @@ -14,6 +14,7 @@ fn escape(h: &mut Harness) { } } +/// Whether the Join link field currently has focus. fn link_focused(h: &Harness) -> bool { h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_link"))) } diff --git a/crates/ui-egui/src/live_server_tests.rs b/crates/ui-egui/src/live_server_tests.rs index 0bf1e631..014be152 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -20,6 +20,7 @@ fn key(h: &mut Harness, key: Key) { } } +/// Whether the widget with the given id currently has focus. fn focused(h: &Harness, id: &str) -> bool { h.ctx.memory(|m| m.has_focus(egui::Id::unique(id))) } diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index 06d902bd..d7fc61c8 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -385,6 +385,9 @@ fn without_a_code_the_link_is_shared_instead() { assert_eq!(script.lock().unwrap().copied.last().map(String::as_str), Some(PLAIN)); } +/// Asserts the join field accepts a typed code or invite link, joins on Enter, clears after +/// joining, shows failure/retry, rejects an invalid code with an error, and accepts local +/// codes and links. #[test] fn the_join_field_takes_a_code_or_a_link_and_enter_joins() { let (mut h, script) = harness(); @@ -450,6 +453,8 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { assert_eq!(h.app.session.docs.len(), 2); } +/// Asserts the join dialog uses the same shared join field, focuses it, and closes once the +/// join starts. #[test] fn the_join_dialog_is_the_same_field() { let (mut h, script) = harness(); diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index eaff7354..63c3c42a 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -290,6 +290,8 @@ fn cursors_follow_pages_in_a_shared_document() { assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Hana left")); } +/// Asserts clicking a peer's avatar follows that peer, and Escape or scrolling the canvas +/// stops following. #[test] fn clicking_an_avatar_follows_and_escape_stops() { let (mut h, _) = Harness::new(); diff --git a/crates/ui-egui/src/menu_aim_tests.rs b/crates/ui-egui/src/menu_aim_tests.rs index ed167e12..6f7c4b8a 100644 --- a/crates/ui-egui/src/menu_aim_tests.rs +++ b/crates/ui-egui/src/menu_aim_tests.rs @@ -87,6 +87,7 @@ impl Ui { const TOPS: [&str; 6] = ["File", "Edit", "View", "Object", "Text", "Arrange"]; +/// Stable id for a top-level menu in the menu-aim test harness. fn top(name: &str) -> Id { Id::unique(("menu", name)) } @@ -145,6 +146,8 @@ fn a_reopened_main_menu_shows_no_stale_submenu() { assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown()); } +/// Asserts leaving a top menu and returning to it does not restore the submenu that was open +/// before. #[test] fn a_submenu_one_level_down_reopens_closed() { let mut u = Ui::new(); diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs index a50173bc..6fc65533 100644 --- a/crates/ui-egui/src/menu_input_tests.rs +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -69,6 +69,8 @@ fn batch_rename_opens_its_existing_dialog() { assert!(ui.app.ui.last_error.is_none()); } +/// Asserts `file.rename` opens the left panel's inline file-name editor (not a dialog), +/// focuses it, and commits the typed name on Enter. #[test] fn rename_file_edits_the_file_name_in_place() { let mut ui = Ui::new(Session::new()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e73d242d..e5ddb899 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -478,10 +478,12 @@ struct At { path: String, } +/// Scoped egui id for the current menu level. fn at_key(ui: &Ui) -> Id { ui.scope_id().with("menu_level") } +/// Return the current menu root/path, defaulting to the main menu root. fn level_at(ui: &Ui) -> At { ui.data(|d| d.get_temp::(at_key(ui))).unwrap_or(At { root: Id::unique("menus"), path: String::new() }) } @@ -817,6 +819,9 @@ const TOP_MENUS: &[&str] = &[ tr_noop("Help"), ]; +/// Draw the main and context menu trees: forget closed/stale submenu state, apply +/// control-channel Select-layer open/close, close on outside click or Escape, then draw the +/// colour picker and palette popups. pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); // A closed menu forgets its open submenus (and a context menu opened somewhere else starts @@ -1020,6 +1025,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { /// The context menu's Select layer submenu (its key in the context menu's tree). const SELECT_LAYER: &str = "Select layer"; +/// Stable egui id for the context menu's pending Select-layer open request. fn select_layer_ask_id() -> Id { Id::unique("ctx_select_layer_ask") } diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index a2e8d3ee..1608902d 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -64,10 +64,13 @@ fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 { .unwrap_or_else(|| panic!("no Remove button in {list} row {index} {row:?} {all:?}")) } +/// Read a temporary boolean open-settings flag keyed by `salt` and row index. fn flag(u: &Ui, salt: &'static str, i: usize) -> bool { u.ctx.data(|d| d.get_temp::(Id::unique((salt, i)))).unwrap_or(false) } +/// Asserts removing a fill keeps the open picker on the surviving paint (with its index +/// shifted), and removing the paint being edited closes the picker. #[test] fn removing_a_fill_keeps_the_picker_on_its_paint() { let mut s = Session::new(); @@ -93,6 +96,8 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { assert_eq!(u.app.ui.picker, None); } +/// Asserts removing effects or grids moves open settings and an open colour picker to the +/// surviving row, or closes them when their own row is removed. #[test] fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/panel_reorder_tests.rs b/crates/ui-egui/src/panel_reorder_tests.rs index 54caa687..ffeb948e 100644 --- a/crates/ui-egui/src/panel_reorder_tests.rs +++ b/crates/ui-egui/src/panel_reorder_tests.rs @@ -73,6 +73,8 @@ fn escape_cancels_a_row_drag() { assert_eq!(u.app.session.ds().unwrap().selection, [r], "the Escape only cancels the drag"); } +/// Asserts effect and layout-grid rows reorder by drag, while a mixed fill list exposes no +/// drag handle. #[test] fn effects_and_grids_drag_too_and_mixed_lists_dont() { let (mut u, r) = setup(); diff --git a/crates/ui-egui/src/perf_tests.rs b/crates/ui-egui/src/perf_tests.rs index 7b5d1212..eba5e68c 100644 --- a/crates/ui-egui/src/perf_tests.rs +++ b/crates/ui-egui/src/perf_tests.rs @@ -68,6 +68,8 @@ impl Bench { } } +/// Build and print a synthetic UI benchmark report for the given layer count; it makes no +/// assertions. fn report(layers: usize) { let mut b = Bench::new(layers); let mut rows: Vec<(&str, f64)> = Vec::new(); @@ -180,6 +182,7 @@ fn design_system(icons: usize, instances: usize) -> (Document, figcraft_engine:: figcraft_engine::synthetic_ds::design_system_with_instances(&spec, instances).unwrap() } +/// Build a test mouse-wheel event for the given vertical delta. fn wheel(d: f32) -> Event { Event::MouseWheel { unit: egui::MouseWheelUnit::Point, diff --git a/crates/ui-egui/src/plugin_panel_tests.rs b/crates/ui-egui/src/plugin_panel_tests.rs index 3b0f2746..53b1ba56 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -89,6 +89,9 @@ fn an_open_plugin_panel_draws_no_frames_while_idle() { Harness::new(session()).assert_idle("a plugin panel"); } +/// Asserts a plugin colour field's swatch opens the picker on that field, a picked colour +/// reaches the plugin, and a second plugin's panel opens while the first remains (two panels +/// present). #[test] fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() { let mut u = Ui::new(session()); diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 83295359..26479c4d 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -245,6 +245,9 @@ const INSTALL: &str = "plugin.directory.install"; const MANAGE_W: f32 = 400.0; const ROW_TEXT_W: f32 = 300.0; +/// Draw the Plugins window with installed and browse tabs, dispatching remove, import, +/// browse, confirm and stop actions; closing the window cancels its directory requests and +/// clears confirmation. fn manage_window(app: &mut FigApp, ctx: &Context) { let t = Tokens::get(ctx); let mut open = true; @@ -509,6 +512,8 @@ fn summary(p: &InstalledPlugin) -> String { parts.join(" · ") } +/// Draw a plugin command's parameter form: close it when the plugin or command disappeared, +/// edit values and colour picks, run `plugin.run` on Run, and close on Cancel. fn form_window(app: &mut FigApp, ctx: &Context) { let Some(mut form) = app.ui.plugin_form.clone() else { return }; let Some((name, command)) = app diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index 4679c15d..93729922 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -137,6 +137,8 @@ fn preview_pane_opens_follows_edits_and_the_selection() { assert!(u.app.ui.preview.is_none()); } +/// Asserts the preview's device viewport and scroll range, wheel and drag scrolling, the +/// end clamp with spring-back, and that navigation resets scroll to the top. #[test] fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { let (mut u, a, b, _) = setup(); diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 97fbba03..361a4caf 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -135,6 +135,8 @@ fn num(v: &Value) -> f64 { v.as_f64().unwrap() } +/// Asserts presenter fit/fit-width/fill/actual scaling, pan ranges, wheel and drag panning, +/// option carry-over to the next presentation, and R restarting the flow. #[test] fn presenter_scales_by_its_options_and_pans_what_overflows() { let (mut u, big) = big(); @@ -272,6 +274,7 @@ fn scrolling_keeps_its_momentum_and_springs_back_from_the_ends() { assert_eq!(u.preview()["flinging"], json!(false)); } +/// Asserts wheel momentum continues after input stops and then comes to rest. #[test] fn the_wheel_coasts_after_it_stops() { let (mut u, a, ..) = setup(); diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 1faba513..b5d402b4 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -20,6 +20,7 @@ struct Drag { pos: Pos2, } +/// Stable egui id for prototype connection dragging. fn drag_id() -> Id { Id::unique("proto_canvas_drag") } @@ -59,6 +60,8 @@ struct Sources { ids: Arc>, } +/// Return the cached subtree nodes with interactions for `page`, recomputing only when the +/// document pointer or page changes. pub(crate) fn sources(ctx: &egui::Context, doc: &Arc, page: NodeId) -> Arc> { ctx.data_mut(|d| { let c = d.get_temp_mut_or_default::(Id::unique("proto_canvas_sources")); diff --git a/crates/ui-egui/src/proto_editor.rs b/crates/ui-egui/src/proto_editor.rs index cc3c352b..7163807e 100644 --- a/crates/ui-egui/src/proto_editor.rs +++ b/crates/ui-egui/src/proto_editor.rs @@ -129,6 +129,7 @@ fn unavailable(label: &str) -> &'static str { } } +/// Stable egui id for a node interaction's open action path. fn open_id(cx: &Cx) -> Id { Id::unique(("proto_open_action", cx.node, cx.k)) } diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index e6838a0a..63c152e1 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -78,6 +78,9 @@ fn condition(u: &Ui, btn: NodeId, block: usize) -> String { blocks[block].condition.as_ref().map(|c| c.display(&doc.variables)).unwrap_or_default() } +/// Asserts the interaction editor shows conditional blocks and nested actions, restarts a +/// condition when a variable of another type is picked, ignores a right-hand-side variable pick +/// for a complex condition, and one undo restores the previous condition. #[test] fn the_interaction_editor_shows_conditionals_and_picks_their_variables() { let (mut s, _, b, btn) = setup(); @@ -154,6 +157,8 @@ fn the_builder_takes_variable_operator_value_conditions() { } } +/// Asserts invalid typed conditions keep their draft and show errors without saving, a valid +/// one clears the error and saves, and Enter does not leak to shortcuts. #[test] fn typed_conditions_are_checked_and_errors_show_until_fixed() { let (mut s, _, b, btn) = setup(); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 972f068b..1b2e2df9 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -426,6 +426,8 @@ fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } } +/// Draw prototype settings: device and rotation, device colour, custom size, responsive +/// toggle and page background, running `proto.settings`. fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { if widgets::section_header(ui, tr("Prototype settings"), &[(Icon::Preview, tr("Open prototype preview"))], false).is_some() { app.run("proto.preview", json!({})); @@ -552,6 +554,7 @@ fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { }); } +/// Show, create or preview the flow starting at `frame` and edit its description. fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, frame: NodeId) { let flows = flows_of(doc, page); let mine = flows.iter().find(|f| f.start == frame).cloned(); @@ -579,6 +582,8 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra } } +/// List a node's interactions, add a default click navigation (or Back), open the editor +/// popover and remove interactions. fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node: &Node) { let t = Tokens::get(ui.ctx()); let id = node.id; @@ -696,6 +701,7 @@ fn animates(a: &Action) -> bool { ) } +/// Stable egui id for the prototype key-capture flag. fn capture_id() -> Id { Id::unique("proto_key_capture") } @@ -704,6 +710,8 @@ fn capturing(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(capture_id())).unwrap_or(false) } +/// Draw an interaction's trigger controls: trigger picker, mouse/delay fields, key capture +/// and controller-button selection, patching the interaction. fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Trigger"), None); @@ -1021,6 +1029,8 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: } } +/// Draw an interaction's animation controls: transition, direction, easing, duration and +/// custom easing fields, plus an animated curve preview. fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Animation"), None); diff --git a/crates/ui-egui/src/proto_reorder_tests.rs b/crates/ui-egui/src/proto_reorder_tests.rs index 1f70f48d..d69be4f6 100644 --- a/crates/ui-egui/src/proto_reorder_tests.rs +++ b/crates/ui-egui/src/proto_reorder_tests.rs @@ -44,6 +44,7 @@ fn setup() -> (Ui, NodeId) { (u, btn) } +/// Stable id for a prototype node's open action path in the reorder tests. fn open_id(btn: NodeId) -> egui::Id { egui::Id::unique(("proto_open_action", btn, 0usize)) } diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index e2dca4a6..a084b9d0 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -78,6 +78,8 @@ impl FigApp { } } +/// Draw the right panel: people, present and share controls, Design/Prototype tabs and zoom +/// menu, then route the scroll area to comments, frame presets, design, prototype or inspect. pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); @@ -393,6 +395,8 @@ fn design(app: &mut FigApp, ui: &mut Ui) { crate::export_panel::section(app, ui, &doc, &sel); } +/// Draw the page section: background and colour profile, local variables/styles, and the +/// page export section. fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let t = Tokens::get(ui.ctx()); let Ok(page) = app.session.page() else { return }; @@ -455,6 +459,8 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { crate::export_panel::page_section(app, ui, doc, page); } +/// Draw the selection header: type name (or Mixed), frame/group conversion, orientation +/// swap and frame presets, component/mask/boolean quick actions, and instance details. fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let t = Tokens::get(ui.ctx()); let label = match common(doc, sel, |n| n.kind.label()) { @@ -589,6 +595,8 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui.add_space(8.0); } +/// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the +/// absolute-position toggle and frame constraints. fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let col = widgets::col_width(ui.available_width()); @@ -807,6 +815,8 @@ fn pretty(s: &str) -> String { } } +/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and +/// the blend-mode row. fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let visible = common(doc, sel, |n| n.visible).unwrap_or(true); @@ -1080,6 +1090,8 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: }); } +/// Draw a selected-text range fill, or the fill/stroke paint rows with add, style, reorder, +/// stroke align/weight, per-side weights and advanced stroke settings. fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) { if fills && let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session) @@ -1641,6 +1653,8 @@ fn paint_label(p: &Paint) -> String { } } +/// Draw the effect list with add/style/type/toggle/remove/reorder, open settings, and keep an +/// open picker and settings flags aligned when rows move or are removed. fn effects(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let list = common(doc, sel, |n| n.effects.clone()); @@ -1723,6 +1737,8 @@ fn fx_field(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, prop: &str, crate::variable_ui::number_field(app, ui, ids, &format!("effects/{i}/{prop}"), Id::unique(("fx", prop, i)), lead, Some(value), "", width) } +/// Draw type-specific effect fields for shadows, blurs, noise, texture and glass, using live +/// scrub edits that stay one undo step and bindable colour fields. fn effect_settings(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect) { let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; let set = |app: &mut FigApp, e2: Effect, scrub: Option| { @@ -1959,6 +1975,8 @@ fn fx_color(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Effect, } } +/// Draw the layout-grid list with add/style/type/toggle/remove/reorder, per-grid settings, +/// and keep open-settings flags aligned when rows move or are removed. fn grids(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let list = common(doc, sel, |n| n.grids.clone()); diff --git a/crates/ui-egui/src/rtl_names_tests.rs b/crates/ui-egui/src/rtl_names_tests.rs index c88ab267..22f59817 100644 --- a/crates/ui-egui/src/rtl_names_tests.rs +++ b/crates/ui-egui/src/rtl_names_tests.rs @@ -34,6 +34,7 @@ fn caret_x(ctx: &egui::Context, text: &str, cursor: CCursor) -> f32 { ctx.fonts_mut(|f| f.layout_no_wrap(text.to_string(), regular(11.0), Color32::WHITE).pos_from_cursor(cursor).min.x) } +/// Read the primary character cursor of a test text field. fn cursor(ctx: &egui::Context, id: Id) -> CCursor { egui::TextEdit::load_state(ctx, id).unwrap().cursor.char_range().unwrap().primary } @@ -75,6 +76,8 @@ fn fonts() -> Option { Some(ctx) } +/// Asserts names keep their logical text while glyphs paint in visual order, including +/// ellipsis placement at the overflow end and mixed-direction rows. #[test] fn names_keep_logical_text_and_paint_in_visual_order() { let Some(ctx) = fonts() else { return }; @@ -137,6 +140,8 @@ fn names_keep_logical_text_and_paint_in_visual_order() { }); } +/// Asserts Arabic letters take joined contextual forms and truncation puts the ellipsis at +/// the visual end of a right-to-left row. #[test] fn arabic_joining_and_truncation_use_the_visual_end() { let Some(ctx) = fonts() else { return }; @@ -177,6 +182,8 @@ fn arabic_joining_and_truncation_use_the_visual_end() { }); } +/// Asserts the shaper mirrors right-to-left parentheses in both glyph order and the atlas +/// shapes chosen for them. #[test] fn rtl_brackets_are_mirrored_by_the_shaper() { let Some(ctx) = fonts() else { return }; @@ -211,6 +218,8 @@ fn rtl_brackets_are_mirrored_by_the_shaper() { }); } +/// Asserts Right/Left move the caret visually while logical offsets jump, Shift extends +/// visually, and typing inserts at the logical caret; Latin text keeps egui's own caret. #[test] fn rename_caret_moves_visually_with_logical_offsets() { let Some(ctx) = fonts() else { return }; @@ -266,6 +275,8 @@ fn rename_caret_moves_visually_with_logical_offsets() { assert_eq!(caret(&ctx, lid).0, 2); } +/// Asserts clicks map right-to-left visual positions to logical carets, so a click at the +/// visual left end leaves the caret at the logical end and typing appends there. #[test] fn clicks_place_the_logical_caret_by_what_is_shown() { let Some(ctx) = fonts() else { return }; diff --git a/crates/ui-egui/src/section_panel.rs b/crates/ui-egui/src/section_panel.rs index 586a5597..f3240d57 100644 --- a/crates/ui-egui/src/section_panel.rs +++ b/crates/ui-egui/src/section_panel.rs @@ -48,6 +48,9 @@ pub fn status_dropdown(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[Nod }); } +/// Draw section colour and hide-contents controls, Dev status, changed-reason Resolve, the +/// last-change note and Resize to fit; returns for an empty selection or one without a Dev +/// status. pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let sections = !sel.is_empty() && sel.iter().all(|i| is_section(doc, *i)); if sel.is_empty() || !sel.iter().all(|i| has_status(doc, *i)) { diff --git a/crates/ui-egui/src/select_all_tests.rs b/crates/ui-egui/src/select_all_tests.rs index a68a6854..d86de12d 100644 --- a/crates/ui-egui/src/select_all_tests.rs +++ b/crates/ui-egui/src/select_all_tests.rs @@ -45,6 +45,7 @@ fn selection(ctx: &egui::Context, te: Id) -> Option<(usize, usize)> { Some((a.min(b), a.max(b))) } +/// Asserts clicking a hex field focuses it and selects all six characters. #[test] fn a_click_into_a_hex_field_selects_it_all() { let ctx = egui::Context::default(); @@ -57,6 +58,7 @@ fn a_click_into_a_hex_field_selects_it_all() { assert_eq!(selection(&ctx, id.with("te")), Some((0, 6))); } +/// Asserts clicking an inline field selects it all and typing replaces the selection. #[test] fn a_click_into_an_inline_field_selects_it_all() { // A fill's hex in the Design panel, a variable's value, a rename. @@ -75,6 +77,7 @@ fn a_click_into_an_inline_field_selects_it_all() { assert_eq!(out.as_deref(), Some("red")); } +/// Asserts clicking a search box selects its whole query. #[test] fn a_click_into_a_search_box_selects_its_query() { let ctx = egui::Context::default(); @@ -87,6 +90,7 @@ fn a_click_into_a_search_box_selects_its_query() { assert_eq!(selection(&ctx, id), Some((0, 6))); } +/// Asserts Tab focus selects a field's whole value. #[test] fn tab_into_a_field_selects_it_all() { let ctx = egui::Context::default(); @@ -106,6 +110,7 @@ fn tab_into_a_field_selects_it_all() { assert_eq!(selection(&ctx, id), Some((0, 7))); } +/// Asserts a drag selects a non-empty proper subrange rather than the whole value. #[test] fn a_drag_selects_only_what_it_covers() { let ctx = egui::Context::default(); @@ -120,6 +125,7 @@ fn a_drag_selects_only_what_it_covers() { assert!(!(a == 0 && b == q.chars().count()), "not all of it: {a}..{b}"); } +/// Asserts re-clicking a field after focus was lost mid-edit selects all. #[test] fn a_field_left_mid_edit_selects_all_on_the_next_click() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/selection_colors.rs b/crates/ui-egui/src/selection_colors.rs index c699788b..682b37fe 100644 --- a/crates/ui-egui/src/selection_colors.rs +++ b/crates/ui-egui/src/selection_colors.rs @@ -24,6 +24,9 @@ struct Cache { colors: Arc>, } +/// Return the cached selection colours when the document and selection match. During an +/// interaction reuse an existing matching cache, or return an empty list without discovering +/// colours; otherwise discover and cache them. fn colors(ui: &Ui, document: u64, doc: &Arc, ids: &[NodeId], interacting: bool) -> Arc> { let key = Id::unique("selection_colors_cache"); if let Some(cache) = ui.data(|d| d.get_temp::(key)) @@ -49,6 +52,9 @@ pub(crate) fn open_picker(app: &mut FigApp, targets: Vec) { app.ui.picker = Some(PickerTarget::SelectionColor { targets, selection: ds.selection.clone(), document: ds.uid }); } +/// Draw the selection-colour groups (at least two): swatch opens the picker, hex and opacity +/// edits, style/variable detach/apply/bind, and “select layers with this color”; disabled +/// during an interaction. pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId]) { let Ok(ds) = app.session.ds() else { return }; let interacting = ds.interaction.is_some(); @@ -249,6 +255,8 @@ mod tests { assert!(!p.frame(vec![]).iter().any(|(s, _)| s == "Selection colors")); } + /// Asserts the panel reuses cached colours through a live interaction and refreshes the cache + /// after the commit. #[test] fn panel_keeps_cached_colors_during_interaction_and_refreshes_after_commit() { let (mut p, a, _) = Panel::new(); @@ -268,6 +276,8 @@ mod tests { assert!(!Arc::ptr_eq(&before.colors, &after.colors)); } + /// Asserts no cached colours are discovered during an interaction, and cancelling restores + /// the panel with the original colours. #[test] fn panel_defers_discovery_without_a_matching_cache_during_interaction() { let (mut p, a, _) = Panel::new(); diff --git a/crates/ui-egui/src/selection_spacing.rs b/crates/ui-egui/src/selection_spacing.rs index 7d1d27b4..414348bb 100644 --- a/crates/ui-egui/src/selection_spacing.rs +++ b/crates/ui-egui/src/selection_spacing.rs @@ -24,6 +24,8 @@ struct LayoutCache { reorder: Option>, } +/// Return one cached spacing/reorder calculation per document, page, selection and frame, +/// refreshing next frame after an edit and reusing an unchanged document snapshot. fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc { let Ok(ds) = app.session.ds() else { return Arc::new(SelectionSpacing::default()) }; let key = Id::unique("selection_spacing_layout"); @@ -52,11 +54,13 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc Option> { layout(app, ctx, ids); ctx.data(|d| d.get_temp::(Id::unique("selection_spacing_layout"))).and_then(|c| c.reorder) } +/// Stable egui id for spacing/reorder drag state. fn drag_id() -> Id { Id::unique("selection_spacing_drag") } @@ -107,6 +111,8 @@ fn start_drag(app: &mut FigApp, ctx: &egui::Context, ids: Vec, p: Pos2, } } +/// Draw horizontal and vertical spacing fields, running `arrange.spacing` with +/// `fromGesture`. pub fn panel(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId]) { let layout = layout(app, ui.ctx(), ids); if layout.horizontal.is_none() && layout.vertical.is_none() { diff --git a/crates/ui-egui/src/selection_spacing_tests.rs b/crates/ui-egui/src/selection_spacing_tests.rs index aaeaabc3..b4b384d0 100644 --- a/crates/ui-egui/src/selection_spacing_tests.rs +++ b/crates/ui-egui/src/selection_spacing_tests.rs @@ -43,6 +43,7 @@ fn drag(u: &mut Ui, p: Pos2, by: egui::Vec2, release: bool) { } } +/// Asserts typed and scrubbed spacing changes take effect with exactly one undo step. #[test] fn spacing_fields_type_scrub_and_keep_one_undo_step() { let (mut u, ids) = scene(true); @@ -88,6 +89,8 @@ fn canvas_gap_drag_keeps_selection_and_undo_redo_for_both_grid_axes() { } } +/// Asserts Escape rolls a spacing drag back, and releasing outside the window commits once +/// while keeping the selection. #[test] fn escape_rolls_back_and_an_outside_release_finishes_the_gesture() { let (mut u, ids) = scene(false); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2abb1cfc..d47d9607 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -205,6 +205,9 @@ pub struct AfterFieldSave { } impl AfterFieldSave { + /// Run a deferred field-save binding only if the originating window still exists and owns + /// the document; temporarily activate that document, run the binding, then restore the + /// previously front tab. fn run(self, app: &mut FigApp) { // A closed file or one moved to another window cannot turn this into a save of the // current tab. The UiState normally stays in its window's pane; validate that too. @@ -299,6 +302,7 @@ pub(crate) fn revalidate_field_save(app: &FigApp, ctx: &Context, save: Option egui::Id { egui::Id::unique(("field_save_cancelled", ctx.viewport_id(), editor)) } @@ -644,12 +648,16 @@ pub(crate) struct ImeInputBaseline { } impl ImeInputBaseline { + /// Capture an IME baseline only while a text edit and an active preedit exist, recording + /// the IME target and the caret/anchor. pub(crate) fn capture(app: &FigApp) -> Option { let te = app.session.ds().ok()?.text_edit.as_ref()?; te.preedit.as_ref()?; Some(Self { target: ime_target(app)?, caret: te.caret, anchor: te.anchor }) } + /// Whether the IME target changed, editing or the preedit ended, or the caret/anchor moved + /// since the baseline was captured. fn interrupted(&self, app: &FigApp) -> bool { ime_target(app) != Some(self.target) || app @@ -661,6 +669,9 @@ impl ImeInputBaseline { } } +/// Apply a batch of text, IME, paste, copy, cut and key events: reject all IME events for an +/// interrupted composition, swallow text produced by a key that ran a shortcut, then finish +/// with triple-click line selection and preedit sync. fn text_keys(app: &mut FigApp, ctx: &Context, events: &[Event], ime_input: Option) { // Validate once before the batch: an accepted DeleteSurrounding deliberately clears preedit, // but its following Commit still belongs to the same valid transaction. An interrupted diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/style_picker_tests.rs index f86ddd73..b732c11d 100644 --- a/crates/ui-egui/src/style_picker_tests.rs +++ b/crates/ui-egui/src/style_picker_tests.rs @@ -95,6 +95,8 @@ fn text_session() -> (Session, figcraft_doc::NodeId) { (s, t) } +/// Asserts hovering a text style previews it without an edit, leaving the popover restores +/// the value, and clicking applies one undo step and closes the picker. #[test] fn text_styles_preview_on_hover_and_apply_on_click() { let (s, t) = text_session(); diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs index 92ff9f63..6f17910a 100644 --- a/crates/ui-egui/src/tabs.rs +++ b/crates/ui-egui/src/tabs.rs @@ -97,6 +97,8 @@ fn tab_drop_position(visible: &[(usize, Rect)], from: usize, pointer_x: f32) -> } } +/// Draw the top tab bar: Home, document and presenter tabs, new-tab and window controls, +/// open/close/reorder animation and tab closing, pausing the presenter when going Home. pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::top("tab_bar").exact_size(HEIGHT).frame(egui::Frame::NONE.fill(t.tab_bar)).show(ui, |ui| { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 80d1399c..9a384294 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -173,6 +173,7 @@ impl Tokens { } } +/// Stable id for temporary theme tokens. fn tokens_id() -> egui::Id { egui::Id::unique("figcraft_tokens") } @@ -436,6 +437,7 @@ pub fn c32(c: figcraft_color::Color) -> Color32 { Color32::from_rgba_unmultiplied(r, g, b, a) } +/// Stable id for the current document-to-screen colour conversion. fn screen_conversion_id() -> egui::Id { egui::Id::unique("figcraft_screen_conversion") } diff --git a/crates/ui-egui/src/theme_startup_tests.rs b/crates/ui-egui/src/theme_startup_tests.rs index 1ae40f58..c128a597 100644 --- a/crates/ui-egui/src/theme_startup_tests.rs +++ b/crates/ui-egui/src/theme_startup_tests.rs @@ -84,6 +84,8 @@ fn unavailable_system_theme_uses_the_existing_light_fallback() { assert_eq!(Tokens::get(&ctx), Tokens::light()); } +/// Checks that the test graph enables egui's `default_fonts` and `monochrome_emoji_fonts` +/// features by asserting both a Latin and an emoji glyph are available. #[test] fn tests_have_the_explicit_default_and_monochrome_emoji_font_features() { // 0.37 egui has no default features, and kittest's own dev-features do not propagate to this diff --git a/crates/ui-egui/src/titlebar_tests.rs b/crates/ui-egui/src/titlebar_tests.rs index 8fa4ad47..fff303ef 100644 --- a/crates/ui-egui/src/titlebar_tests.rs +++ b/crates/ui-egui/src/titlebar_tests.rs @@ -81,6 +81,8 @@ fn window_buttons_minimise_maximise_and_close() { assert!(u.frame(vec![]).contains(&ViewportCommand::Close)); } +/// Asserts closing the custom titlebar over a presenter with unsaved changes asks with the +/// unsaved-changes dialog instead of emitting `Close`. #[test] fn titlebar_close_asks_over_the_presenter_when_a_file_is_unsaved() { let mut u = Ui::new(TitleBar::Custom); diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 7af3356a..8f8f6cc1 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -91,6 +91,7 @@ const BOARD_GROUPS: &[Group] = &[ Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), ]; +/// Return the last tool index remembered for a toolbar group, falling back to `default`. fn remembered(ui: &Ui, key: &str, default: ToolId) -> ToolId { ui.data(|d| d.get_temp::(egui::Id::unique(("tb_last", key)))).and_then(|i| ToolId::ALL.get(i as usize).copied()).unwrap_or(default) } @@ -535,6 +536,8 @@ fn options_flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::P } } +/// Draw a tool group's flyout above its button, with keyboard/hover highlighting, remembered +/// selection when a tool is clicked, and Escape to close. fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { let t = Tokens::get(ui.ctx()); let item_h = 32.0; diff --git a/crates/ui-egui/src/type_settings_tests.rs b/crates/ui-egui/src/type_settings_tests.rs index cbde6cc1..421c1ce2 100644 --- a/crates/ui-egui/src/type_settings_tests.rs +++ b/crates/ui-egui/src/type_settings_tests.rs @@ -2,6 +2,8 @@ //! de paragraphe") stays inside the popover, which otherwise widened a little every frame. use egui::{Area, Id, Pos2, RawInput, Rect, vec2}; +/// Asserts a long type-settings row label does not widen the popover beyond 264 pt across +/// frames. #[test] fn a_long_label_does_not_widen_the_type_settings_popover() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 33c0d5a0..f61aa183 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -1167,10 +1167,13 @@ const ROW: f32 = 26.0; /// The font list's tallest (it scrolls past that). const LIST_HEIGHT: f32 = 380.0; +/// Return the persisted list of recently used font families. fn recent_families(ctx: &egui::Context) -> Vec { ctx.data_mut(|d| d.get_persisted::>(Id::unique(RECENT_KEY))).unwrap_or_default() } +/// Remember a font family at the front of the recent list, removing an earlier +/// case-insensitive match and keeping at most eight entries. fn remember_family(ctx: &egui::Context, family: &str) { ctx.data_mut(|d| { let v = d.get_persisted_mut_or_default::>(Id::unique(RECENT_KEY)); @@ -1568,6 +1571,8 @@ mod variable_tests { assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error); } + /// Asserts bound font family and style chips replace the dropdowns, a selected text range + /// offers no variable binding, and detaching restores the last values. #[test] fn bound_font_family_and_style_show_their_variables_and_detach() { let mut p = Panel::new(); diff --git a/crates/ui-egui/src/typography_scrub_tests.rs b/crates/ui-egui/src/typography_scrub_tests.rs index 1e629acf..9b95b8e5 100644 --- a/crates/ui-egui/src/typography_scrub_tests.rs +++ b/crates/ui-egui/src/typography_scrub_tests.rs @@ -44,6 +44,7 @@ impl Fields { out.textures_delta.clear(); out.platform_output.cursor_icon } + /// Screen position of a typography field's label/lead, used to start a scrub. fn lead(&self, key: &str) -> Pos2 { let rect = self.ctx.read_response(Id::unique(key).with("click")).unwrap().rect; Pos2::new(rect.min.x + 12.0, rect.center().y) @@ -157,6 +158,8 @@ fn typography_icon_scrub_changes_only_the_selected_characters_without_ending_tex assert_eq!(f.value("ty_ls"), 0.0); } +/// Asserts typography spacing fields accept typed percent units, commit on Enter, and +/// Escape restores the previous value without adding an undo step. #[test] fn typography_spacing_fields_still_accept_typed_units_and_escape_cancels() { let mut f = Fields::new(); diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 776d8aae..fe822a1f 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -40,6 +40,7 @@ struct Picker { at: Pos2, } +/// Stable egui id for the variable picker. fn picker_id() -> Id { Id::unique("variable_picker") } diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 2a9e3c04..372e3f04 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -173,6 +173,8 @@ pub fn show(app: &mut FigApp, ctx: &Context) { app.ui.variables = open.then_some(st); } +/// Draw the variables header: the title at the left, and the import/export/Close controls at the +/// right. fn header(app: &mut FigApp, ui: &mut Ui, open: &mut bool) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::hover()); @@ -253,6 +255,7 @@ fn extend(app: &mut FigApp, lib: &str, c: &str, st: &mut Editor) { } } +/// Stable egui id for the rename field of a `Rename` target. fn rename_id(target: &Rename) -> Id { Id::unique(("var_rename", target)) } @@ -281,6 +284,8 @@ fn unique(base: &str, taken: impl Fn(&str) -> bool) -> String { (1..10_000).map(|k| if k == 1 { base.to_string() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or_else(|| base.to_string()) } +/// Draw the collections sidebar with library headings, collection rows and a create-collection +/// button. fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st: &mut Editor) { let t = Tokens::get(ui.ctx()); let (hr, _) = ui.allocate_exact_size(vec2(SIDEBAR_W, 40.0), Sense::hover()); @@ -505,6 +510,8 @@ fn group_of(name: &str) -> &str { name.rsplit_once('/').map(|(g, _)| g).unwrap_or("") } +/// Draw the variables table for the selected collection: search, library/extended notes, +/// grouped rows, mode columns and the create-variable row when editable. fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { let t = Tokens::get(ui.ctx()); // A sidebar click this frame picked another source: shown next frame. @@ -594,6 +601,8 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { }); } +/// Draw a collection's mode header row with rename, duplicate, reorder and delete actions +/// and an add-mode button when the collection is editable and not extended. fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: &mut Editor, width: f32) { let t = Tokens::get(ui.ctx()); // An extended collection has the modes of the one it extends. diff --git a/crates/ui-egui/src/vector_panel.rs b/crates/ui-egui/src/vector_panel.rs index 29ae8726..07dda3ff 100644 --- a/crates/ui-egui/src/vector_panel.rs +++ b/crates/ui-egui/src/vector_panel.rs @@ -72,6 +72,8 @@ fn width_point(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { ui.add_space(8.0); } +/// Draw the selected points' position, dimensions, corner radius, mirroring and per-point +/// stroke cap/join controls; the width tool delegates to its own panel. pub fn points(app: &mut FigApp, ui: &mut Ui, doc: &Document, ve: &VectorEdit) { if ve.tool == vectoredit::VTool::Width { width_point(app, ui, doc, ve); diff --git a/crates/ui-egui/src/versions.rs b/crates/ui-egui/src/versions.rs index 3519b610..19eb6a04 100644 --- a/crates/ui-egui/src/versions.rs +++ b/crates/ui-egui/src/versions.rs @@ -403,6 +403,8 @@ fn current_row(ui: &mut Ui, more: bool, active: bool) -> bool { !active && resp.on_hover_text(tr("Back to the current version")).clicked() } +/// Draw one version row with timeline, name, time, up to three description lines and a +/// more/context menu; returns the chosen preview, restore, edit, duplicate or delete action. fn version_row(ui: &mut Ui, row: &Row, last: bool, previewing: bool) -> Option { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index f356b797..a5dd2c7b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -223,6 +223,9 @@ fn cancel_retargeted_field_save(ui: &mut Ui, id: Id) { ui.memory_mut(|m| m.surrender_focus(editor)); } +/// Implement the shared number field: label/icon lead, scrubbing, keyboard stepping and +/// typing, select-all when a click starts editing, bindable values and deferred-save +/// cancellation; returns an edit and/or bind event. fn number_field_impl( ui: &mut Ui, id: Id, diff --git a/crates/ui-egui/src/windows_tests.rs b/crates/ui-egui/src/windows_tests.rs index 55a7babd..0f5884dd 100644 --- a/crates/ui-egui/src/windows_tests.rs +++ b/crates/ui-egui/src/windows_tests.rs @@ -45,6 +45,8 @@ fn names(app: &FigApp) -> Vec { app.session.docs.iter().map(|d| d.doc.name.clone()).collect() } +/// Asserts closing a window in the presenter shows the unsaved-changes dialog and does not +/// dismiss the preview. #[test] fn closing_a_window_in_the_presenter_shows_the_unsaved_changes_dialog() { let mut session = Session::new(); @@ -104,6 +106,8 @@ fn windows_hold_tabs_and_close_with_their_files() { assert_eq!(app.session.ds().unwrap().doc.name, "A"); } +/// Asserts a hidden other window answers edits and inspection in its own file while the main +/// window keeps its file, and a capture parks with a deadline and times out once. #[test] fn another_hidden_window_answers_requests_that_need_no_frame_in_its_own_file() { let mut s = Session::empty(); @@ -180,6 +184,8 @@ fn a_request_for_a_window_on_screen_wakes_it_while_the_main_window_is_hidden() { assert!(woken.lock().unwrap().contains(&other), "the other window is asked to draw: {:?}", woken.lock().unwrap()); } +/// Asserts every direct UI frame clears the logic-tick marker so background work runs each +/// frame. #[test] fn every_direct_ui_frame_runs_background_work() { // Direct `App::ui` harnesses never call `App::logic`: the tick marker the UI pass consumes @@ -192,6 +198,8 @@ fn every_direct_ui_frame_runs_background_work() { } } +/// Asserts synthetic input for a child window reaches the pane that draws it, leaving the +/// main window's pane untouched. #[test] fn synthetic_input_for_a_child_window_reaches_the_pane_that_draws_it() { let mut s = Session::empty(); @@ -214,6 +222,8 @@ fn synthetic_input_for_a_child_window_reaches_the_pane_that_draws_it() { assert_eq!(crate::windows::take_input(&mut app, other), events, "the child's next frame takes its batch"); } +/// Asserts a child raw-input hook with no batch does not error or drain the root window's +/// pending acknowledgement. #[test] fn a_frame_without_a_batch_does_not_error_another_windows_acknowledgement() { let mut s = Session::empty(); @@ -238,6 +248,8 @@ fn a_frame_without_a_batch_does_not_error_another_windows_acknowledgement() { assert_eq!(app.input_acks[0].reply.id, json!(5)); } +/// Asserts a child window runs `viewport_setup` and its pane keeps the dark theme after the +/// swap back. #[test] fn a_child_window_runs_the_ui_setup_the_main_window_does() { // eframe calls `App::ui` only for the root window, so a child that draws while the root does @@ -263,6 +275,8 @@ fn a_child_window_runs_the_ui_setup_the_main_window_does() { assert_eq!(again, Some(json!(true))); } +/// Asserts a closed and later reused viewport does not inherit its old IME context +/// generation. #[test] fn an_emptied_window_forgets_its_ime_context() { // `windows::begin` ends the viewport when a window loses its last file; a later window that From 000c0e3d4e8f33c0e5e9c0b96aff73336b9a03c5 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:22:29 +0000 Subject: [PATCH 119/142] Scale per-point corner radius of vector layers in transform.scale Found while testing crates/engine/src/cmd/arrange.rs:~560 (scale_node, the Vector branch calling VectorNetwork::scale); crates/doc/src/vector.rs:631 (VectorNetwork::scale) Co-Authored-By: Claude Haiku 5.5 Co-Authored-By: Claude Sonnet 5.5 --- crates/engine/src/cmd/arrange.rs | 3 +++ crates/engine/src/scale_tool_tests.rs | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs index 10ad13e5..7ee1f2c1 100644 --- a/crates/engine/src/cmd/arrange.rs +++ b/crates/engine/src/cmd/arrange.rs @@ -497,6 +497,9 @@ pub fn scale_node(doc: &mut Document, id: NodeId, k: f64) -> Result<()> { } if let NodeKind::Vector { network } = &mut n.kind { network.scale(k, k); + for v in &mut network.vertices { + v.radius *= k; + } } n.width *= k; n.height *= k; diff --git a/crates/engine/src/scale_tool_tests.rs b/crates/engine/src/scale_tool_tests.rs index 647a7ce1..f61eba93 100644 --- a/crates/engine/src/scale_tool_tests.rs +++ b/crates/engine/src/scale_tool_tests.rs @@ -240,3 +240,17 @@ fn the_scale_tool_only_acts_on_handles() { assert_rect(bounds(&s, r), [10.0, 10.0, 110.0, 60.0]); assert!(near(s.doc().unwrap().node(r).unwrap().width, 100.0)); } + +#[test] +fn scale_node_scales_vector_point_corner_radius() { + let mut s = Session::new(); + let v = id_of(&ex(&mut s, "vector.create", json!({"points": [[0, 0], [100, 0], [100, 100], [0, 100]], "closed": true}))["id"]).unwrap(); + ex(&mut s, "node.set", json!({"id": v, "props": {"cornerRadius": 10}})); + ex(&mut s, "transform.scale", json!({"ids": [v], "factor": 2})); + let d = s.doc().unwrap(); + let NodeKind::Vector { network } = &d.node(v).unwrap().kind else { panic!("not a vector") }; + assert!(!network.vertices.is_empty()); + for vx in &network.vertices { + assert_eq!(vx.radius, 20.0, "corner radius scaled with the layer"); + } +} From 4eb5224f023a7baf70df5f3df9b785480324f6f8 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 17:13:00 +0100 Subject: [PATCH 120/142] M0: correct egui 0.37 status docs and stabilize join-code regression --- crates/collab-net/src/code_tests.rs | 5 ++++- crates/ui-egui/src/windows.rs | 2 +- docs/egui-037-migration-plan.md | 8 ++++---- docs/gaps.md | 8 +++++--- docs/hardware-parity.md | 7 ++++--- docs/ui-parity.md | 5 +++-- 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index 7fe766ec..182f3782 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -103,7 +103,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() { assert!(host.live().peers.is_empty()); // A typing mistake is caught before anything connects. let mut typo: Vec = local.code().chars().collect(); - typo[7] = if typo[7] == 'A' { 'B' } else { 'A' }; + // The last symbol contains only checksum bits. Changing payload bits can collide with + // the eight-bit checksum, so it does not guarantee a malformed code. + let last = typo.last_mut().unwrap(); + *last = if *last == 'A' { 'B' } else { 'A' }; let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::()})).unwrap_err().to_string(); assert!(e.contains("typing mistake"), "{e}"); // A view-only code doesn't exist here, and says where it does. diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index c9c9fdce..95ec721d 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -248,7 +248,7 @@ fn exchange(app: &mut FigApp, p: &mut Pane) { /// file, at screen point `at` (else beside this window). Returns its id. With one window (the /// browser) or a screen reader running, a file opens as a tab here instead (and the error says so). pub fn open(app: &mut FigApp, ctx: &Context, index: Option, path: Option<&str>, at: Option) -> Result { - // shortcut: eframe 0.36 gives only the main window an AccessKit adapter, so other windows have + // shortcut: eframe 0.37 gives only the main window an AccessKit adapter, so other windows have // no screen-reader tree; while a screen reader runs files stay tabs here. Upgrade when eframe // adapts every viewport. let one = if ctx.embed_viewports() { diff --git a/docs/egui-037-migration-plan.md b/docs/egui-037-migration-plan.md index 6cf1bb09..93fd815b 100644 --- a/docs/egui-037-migration-plan.md +++ b/docs/egui-037-migration-plan.md @@ -1088,10 +1088,10 @@ agent/model/tool signature; contributor credit remains username-only (`@skylarta real evidence exists. 12. **Lockfile resolution:** one egui family, one winit, one wgpu, one craft-window. If Cargo chooses two versions, stop and fix the pins rather than accepting a duplicate. -13. **AccessKit per-viewport:** 0.37's eframe integrations initialize adapters per created viewport - in the inspected source; this may remove the 0.36 “main window only” limitation, but it has not - been exercised with a screen reader here. Keep the tab fallback until a real test says - otherwise. +13. **AccessKit per-viewport:** the pinned 0.37 fork initializes the native AccessKit adapter only + for the root viewport; creating a secondary viewport does not initialize an adapter. The + “main window only” limitation remains, and no real screen-reader qualification is claimed. + Keep the tab fallback until secondary-window accessibility is implemented and tested. 14. **Automation screenshot thread:** the callback must not write files outside `file_access` confinement. The shared completion-slot design in 6.3 is required, not optional. diff --git a/docs/gaps.md b/docs/gaps.md index e76731e7..280b04c5 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-25 · **Change:** minor (known gaps from the #1311 review in gaps 4, 14, 15 and 16; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; 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 @@ -439,8 +439,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe needs to; closing one asks about unsaved changes; `window.new`, `window.list`, `window.focus` and `window` on any control request. - **Missing:** wide-gamut colour on the canvas (egui draws sRGB, so P3 colours outside sRGB clip on - screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.36 - gives only the main window one); window positions restored at the next start. + screen; exports keep them); a screen-reader tree in windows other than the main one (eframe 0.37 + gives only the main window one; verified in the fork's viewport setup); window positions restored at + the next start. - **Estimate:** 4–8 h. ### 19. AI features @@ -487,6 +488,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | egui/eframe 0.37 verified for gap 18: the remaining limitations are unchanged. egui-wgpu 0.37 still picks an 8-bit sRGB surface with no colour-space option, so P3 colours outside sRGB still clip on screen; eframe 0.37 still gives only the main window an AccessKit adapter, so other windows have no screen-reader tree. Numbers unchanged | | 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours 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 | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 1b2536dc..61282d53 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -15,10 +15,10 @@ since 2025). FigCraft renders the canvas on the CPU and presents it through wgpu | Canvas rendering | GPU | CPU (tiny-skia), banded across cores, retained (re-renders only what an edit or pan touches), levels of detail below 50% zoom; when renders from scratch are slow, a new zoom shows the whole view coarsely first, then refines it (#312); shown through wgpu (WebGPU or WebGL2 in the browser) | 30–60 for a GPU path | | Effects (blurs, glass, noise) | GPU | CPU | in the above | | HiDPI / Retina | yes | yes (all); a canvas image rendered at the screen's density is placed on whole device pixels, so fractional pans, zooms and display scales don't blur it (#359; geometry tests, the snapping not judged by eye) | — | -| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.36 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 | +| Wide-gamut display (Display P3) | yes, on capable displays | colours stored and exported in P3; the screen shows sRGB: eframe/egui-wgpu 0.37 configure an 8-bit sRGB surface with no colour space option, although wgpu 30 can present Display P3 or extended sRGB on Metal, DX12, Vulkan and WebGPU (see gaps #18). The colour picker warns about colours outside sRGB and View ▸ Soft-proof sRGB stripes them on the canvas | 2–5 | | Trackpad pinch zoom and two-finger pan | yes | yes (egui zoom delta, smooth scroll) | — | | Pen tablet pressure (pencil, Figma Draw brushes) | yes (forum reports of regressions in 2025) | Windows pens (winit pointer input) and browser styluses that send touch events with a force (an Apple Pencil in Safari); the Pencil records it as the stroke's width profile. A mouse, macOS and Linux tablets and pens sending only pointer events in a browser report none: constant width | 1–3 | -| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes; a minimised or covered window still answers control and MCP requests, while screenshots with hidden or unknown visibility request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame (#313, #332, #720). Browser and Wayland hidden-paint behavior remain unqualified. Only the main window has a screen-reader tree (eframe 0.36), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 | +| Several windows / multi-monitor | yes (a window per file; presenter in its own window) | desktop: File ▸ New window, a tab's Open in new window or a tab dragged off the strip; each window has its own tabs, panels and canvas, draws only when it needs to and asks about unsaved changes when it closes; a minimised or covered window still answers control and MCP requests, while screenshots with hidden or unknown visibility request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame (#313, #332, #720). Browser and Wayland hidden-paint behavior remain unqualified. Only the main window has a screen-reader tree (eframe 0.37), so files stay tabs while a screen reader runs; positions aren't restored at the next start; the presenter stays inside its window; one window in the browser | 1–2 | | Screen readers | partial | AccessKit tree on desktop (names, roles, states), never tested with a real screen reader; none in the browser | 4–8 + human QA | | Keyboard layouts (non-US) | yes | keymap uses logical keys; not checked on AZERTY/QWERTZ/JIS | 1–2 | @@ -50,6 +50,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | egui/eframe 0.37 dependency migration: the workspace pins the egui family at 0.37, and the wide-gamut and several-windows rows now cite 0.37. The limitations are unchanged: egui-wgpu 0.37 offers no surface colour-space option, so the screen still shows sRGB; eframe 0.37 still gives only the main window an AccessKit adapter, so files stay tabs while a screen reader runs. Numbers unchanged | | 2026-10-25 | minor | Hidden or unknown-visibility screenshots request a hidden paint and expire at their 60-second admission deadline if unfinished; injected pointer/key/text input waits for a real UI frame. Browser and Wayland hidden-paint behavior remain unqualified. Numbers unchanged. | | 2026-10-15 | minor | Community fixes (#290, #306, #312, #313, #328, #332, #346, #474): coarse zoom previews when renders from scratch are slow; a minimised or covered window answers control and MCP requests (screenshots and input fail at once while it is hidden); `.figcraft` files open from Finder on macOS; a message box when the window can't open on Windows; on Linux the window id matches the desktop entry, the RPM requires glibc 2.35 and user fonts under `XDG_DATA_HOME` are found. Numbers unchanged | | 2026-10-18 | minor | macOS quit crash (#781): on Macs with a Touch Bar the app could crash as it quit (an exception in AppKit's Touch Bar support while the window's content view, which the accessibility adapter subclasses, was torn down). Quitting now keeps everything (preferences, this run's recovery copies, live sessions ended) and then ends the process before that teardown. Quit from the menu bar ends the app without the unsaved-changes question, so unsaved files' recovery copies now stay and reopen at the next start instead of being removed. Numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e1c219c6..74b056d8 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -20,7 +20,7 @@ interaction feel not yet compared. | Minimize UI (Cmd+\\) | done | | | Resizable panels | done | each panel's edge drags its width (left 200–480, right 240–480, 240 by default; a double-click on the edge or View ▸ Panels ▸ Reset panel widths puts the default back); the widths are each window's own, the main window's are kept in the preferences when a drag ends and come back at the next start, a new window opens with those of the window it comes from; Minimize UI hides the panels and brings them back as they were; the view keeps its centre and zoom while a panel is resized; in a window too small for them the panels give way so the canvas keeps 160 px (the left one first), down to the smallest window (800×500) (`panel_width_tests`). Not compared with Figma's widths; no snap points | | Actions palette (Cmd+K, Cmd+/) | done | tabs: All (every command, grouped by menu, or Other), Assets (components and styles) and Plugins & widgets; results ranked prefix first, then shortest, at most 60 rows; Up/Down move a highlight and Enter runs it; Recents list what was run from the palette (matching translated labels); one open/toggle path from every entry point (`palette_tests`). Ranking and the Assets results not compared with Figma's | -| Context menus | done | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key isn't delivered by the windowing layer (egui 0.36 has no key for it) | +| Context menus | partial | canvas and layers; submenus as in the main menu below, and a right-click in a new spot starts a fresh menu (Select layer closed); Shift+F10 opens them from the keyboard (at the focused layer row or below the canvas selection) and the menu takes the keyboard; the Menu key reaches egui as `Key::Menu` (egui-winit 0.37 maps the native key), but FigCraft's keymap has no binding for `Menu`, so it does nothing yet | | Home screen: Recents, Online and Templates | done (ours) | The sidebar switches between the three (`ui.set {homeSection}`). Online: your online files (see the collaboration checklist). Recents: new file, open, join with a code, open files, recent files (cards with the picture each file carries on its page colour, the file's icon, name and "Edited ... ago" or "File not found", full path on hover; or a Name, Last modified and Created table with sortable headers; sorted by name, creation or last change either way; grid or list and the sort kept in the preferences, `ui.set {homeRecentsView, homeRecentsSort, homeRecentsOrder}`; a click selects, a double-click or Enter opens, arrow keys move the selection; Open, Show in folder and Remove from recents on right-click), a Getting started card (six tips with their shortcuts from the keymap; its close button is remembered in the preferences and Help ▸ Getting started brings it back) and, while there are no recent files, four templates to start from. Templates: the eight starter templates as cards (thumbnail rendered from the document, cached per theme; name, description, kind and size), a search field and a kind filter, an empty state; Tab reaches every card and Enter opens it; cards and tabs are named for screen readers. A first run opens here. No Community files or drafts folder | | Main menu from the logo | done | generated from `catalog.rs`; popups sized to content. Submenus open after 0.15 s on their row (a click or the keyboard opens one at once) and close at once when another row is hovered; while the pointer heads for an open submenu inside the triangle to its near edge, the rows it crosses don't take over, and resting 0.3 s on one hands over; closing the menu, however it closes, clears which submenus were open, so it reopens with none (`menu_aim_tests`, 10 tests; timings tuned by feel, not measured against Figma). Menu and palette items that need a value ask for it first: Create link, Offset path and Move to page in a dialog of the shared controls, the language, user name, server and nudge items in Preferences, Open recent on the home screen's Recents; File ▸ Rename edits the file name in place | | Keyboard shortcuts panel | done | closes when the home screen shows (#548); docked, tabbed by group like the documentation, searchable, per-platform keycaps; held keys, gestures and presentation keys listed beside the bindings; a label too long for its column ends with "…" and shows in full on hover | @@ -172,6 +172,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Context menus: the Menu key is no longer an egui limitation at 0.37 (egui-winit maps the native Menu/ContextMenu key to egui's `Key::Menu`), but FigCraft normalizes no binding for `Menu`, so it does not open a context menu yet; Shift+F10 still does, with the same behaviour. The row moves from done to partial. Numbers unchanged | | 2026-10-24 | minor | Selection colors: a row's Styles menu also lists the document's color variables, and picking one binds it to every fill and stroke the row stands for, replacing a color style there (`selection.bindColorVariable`; a text range with fills of its own takes the variable's value); a row linked to a style or variable has a Detach button that unlinks it and keeps the color (`selection.detachColor`). Still partial: no library styles or variables, no new style or variable from a row | | 2026-10-24 | minor | The Text on a path tool (`tool.textOnPath`, in the Text flyout): a click on a shape's or vector's path puts new text on it, starting at the click, with the path's fill (when one shows) and effects, as the help center's Guide to text describes; the path layer is removed, and an edit left empty puts it back with no undo step (`draw_tools_tests`, `cursor_tests`) | | 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 | From d7da3f1bb15baf8a26074ffc03953f789b171543 Mon Sep 17 00:00:00 2001 From: NetterSilver <133535268+Silvertree2010@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:01:22 +0000 Subject: [PATCH 121/142] Scale down images past 16384 px a side instead of drawing nothing Fixes #1336 Co-Authored-By: Claude Opus 5.5 --- crates/render/src/image_budget_tests.rs | 22 ++++++++++++++++++---- crates/render/src/images.rs | 19 +++++++++++-------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/crates/render/src/image_budget_tests.rs b/crates/render/src/image_budget_tests.rs index 6228efc5..9a51f405 100644 --- a/crates/render/src/image_budget_tests.rs +++ b/crates/render/src/image_budget_tests.rs @@ -48,9 +48,11 @@ fn an_image_within_the_limits_decodes() { } #[test] -fn an_image_wider_or_taller_than_the_limit_is_refused() { - assert!(images::decode_within(&png(30, 20), ColorProfile::Srgb, 29, u64::MAX).is_none()); - assert!(images::decode_within(&png(20, 30), ColorProfile::Srgb, 29, u64::MAX).is_none()); +fn an_image_wider_or_taller_than_the_limit_is_scaled_down_to_fit() { + let (p, _) = images::decode_within(&png(30, 20), ColorProfile::Srgb, 15, u64::MAX).unwrap(); + assert_eq!((p.width(), p.height()), (15, 10)); + let (p, _) = images::decode_within(&png(20, 30), ColorProfile::Srgb, 15, u64::MAX).unwrap(); + assert_eq!((p.width(), p.height()), (10, 15)); } #[test] @@ -63,10 +65,22 @@ fn a_huge_image_is_refused_from_its_header_alone() { // 16 000 × 16 000 is within the side limit; its pixels (1 GB) aren't within a 1 MB budget, and // the file has none to decode: only the header is read. assert!(images::decode_within(&png_header_only(16_000, 16_000), ColorProfile::Srgb, 16_384, 1 << 20).is_none()); - // Past the side limit, the shipped decode refuses it the same way. + // Past the side limit, the shipped decode reads its header and finds no pixels to scale down. assert!(images::decode(&png_header_only(20_000, 10)).is_none()); } +#[test] +fn an_image_past_the_side_limit_is_drawn_downscaled() { + // #1336: one side over 16 384 px drew nothing. + let p = images::decode(&png(16_385, 4)).unwrap(); + assert_eq!(p.width(), 16_384); + assert!(p.height() >= 1 && p.height() <= 4); + assert!(p.pixels().iter().all(|c| c.alpha() == 255)); + let p = images::decode(&png(4, 18_078)).unwrap(); + assert_eq!(p.height(), 16_384); + assert!(p.width() >= 1 && p.width() <= 4); +} + fn key(name: &str) -> images::Key { (name.to_string(), [0; 7], ColorProfile::Srgb) } diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index f4f9c5d9..586c2dac 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -15,7 +15,7 @@ use tiny_skia::Pixmap; pub(crate) type Key = (String, [i32; 7], ColorProfile); -/// The largest image side decoded. +/// The largest side of a decoded image: larger images are scaled down to fit. const MAX_SIDE: u32 = 16_384; /// The most bytes one image's decoding may allocate: the image crate's own default limit (a /// larger image is refused from its header). @@ -83,26 +83,25 @@ fn fkey(f: &ImageFilters) -> [i32; 7] { [q(f.exposure), q(f.contrast), q(f.saturation), q(f.temperature), q(f.tint), q(f.highlights), q(f.shadows)] } -/// Decode encoded image bytes into a premultiplied sRGB pixmap (max 16k per side). +/// Decode encoded image bytes into a premultiplied sRGB pixmap (scaled down to 16k per side). pub fn decode(bytes: &[u8]) -> Option { decode_in(bytes, ColorProfile::Srgb).map(|(p, _)| p) } -/// Decode encoded image bytes into a premultiplied pixmap (max 16k per side): converted from the +/// Decode encoded image bytes into a premultiplied pixmap (scaled down to 16k per side): converted from the /// image's own profile into `target` (`false`), or as sRGB when it has none (`true`: the sRGB → /// `target` conversion is still to do). fn decode_in(bytes: &[u8], target: ColorProfile) -> Option<(Pixmap, bool)> { decode_within(bytes, target, MAX_SIDE, MAX_DECODED_BYTES) } -/// [`decode_in`] for images at most `max_side` wide and tall whose decoded pixels take at most -/// `max_bytes`; larger ones are refused from their header, before any pixels are decoded. +/// [`decode_in`] for images whose decoded pixels take at most `max_bytes` (larger ones are refused +/// from their header, before any pixels are decoded), scaled down to at most `max_side` wide and +/// tall. pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, max_bytes: u64) -> Option<(Pixmap, bool)> { use image::ImageDecoder; let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; let mut limits = image::Limits::default(); - limits.max_image_width = Some(max_side); - limits.max_image_height = Some(max_side); limits.max_alloc = Some(max_bytes); reader.limits(limits); let mut dec = reader.into_decoder().ok()?; @@ -110,7 +109,11 @@ pub(crate) fn decode_within(bytes: &[u8], target: ColorProfile, max_side: u32, m return None; } let source = crate::image_profile::Source::read(bytes, &mut dec); - let img = image::DynamicImage::from_decoder(dec).ok()?; + let mut img = image::DynamicImage::from_decoder(dec).ok()?; + if img.width() > max_side || img.height() > max_side { + // `thumbnail` keeps the aspect ratio and works in integers (no float copy of the pixels). + img = img.thumbnail(max_side, max_side); + } match source.convert(&img, target) { Some(rgba) => Some((premultiplied(&rgba)?, false)), None => Some((premultiplied(&img.to_rgba8())?, true)), From 35cea3ef8c7ecbf7f5d08c76f608992a9348a16e Mon Sep 17 00:00:00 2001 From: Davit Matchakhelidze Date: Sun, 25 Oct 2026 22:13:52 +0400 Subject: [PATCH 122/142] i18n(ka): shorter wording for labels that were cut off Decided in the native-speaker review of the screens (all-t01). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YMTAbGNRxnMEzRvYnQGphF --- crates/ui-egui/locales/ka.toml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index eefb2dd3..aebbe528 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -21,7 +21,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "ამ ფაილის კოლექცია ამ რეჟიმებითა და ცვლადებით: შეცვალეთ მათი მნიშვნელობები თითოეული რეჟიმისთვის და დაამატეთ საკუთარი ცვლადები" "A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "ამ სახელით .figcraft ფაილი უკვე არსებობს. ჩანაცვლების დასადასტურებლად აირჩიეთ მისი სრული სახელი." "A layer on its own aligns to its frame: select more layers, or one in a frame" = "ცალკე შრე თავის ჩარჩოსთან სწორდება: მონიშნეთ მეტი შრე ან ჩარჩოში მყოფი შრე" -"A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი: მთავარი ბლოკი, შესაძლებლობები, ციფრები, ციტატა და ქვედა ზოლი" +"A responsive product page: hero, features, figures, a quote and a footer" = "პროდუქტის ადაპტური გვერდი ყველა ძირითადი ბლოკით" "A spring plays until it settles: its mass, stiffness and damping set how long" = "ზამბარა მოძრაობს, სანამ არ დამშვიდდება: მისი მასა, სიხისტე და მილევა განსაზღვრავს ხანგრძლივობას" "A title, an agenda, a big figure, a chart and a closing slide" = "სათაური, დღის წესრიგი, დიდი ციფრი, დიაგრამა და დასკვნითი სლაიდი" "A to Z" = "ა-დან ჰ-მდე" @@ -104,7 +104,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Alpha" = "ალფა" "Alphabetical" = "ანბანის მიხედვით" "Alt+drag" = "Alt+გადათრევა" -"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკის ეკრანი ბადეზე: ციფრები, ვექტორებით დახატული დიაგრამები და ცხრილი" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "ანალიტიკა ბადეზე: ციფრები, დიაგრამები და ცხრილი" "An invite link that opens FigCraft and joins" = "მოსაწვევი ბმული, რომელიც ხსნის FigCraft-ს და უერთდება სესიას" "Anchor" = "საყრდენი" "Android" = "Android" @@ -215,7 +215,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Clear from this list" = "ამ სიიდან ამოღება" "Code or invite link" = "კოდი ან მოსაწვევი ბმული" "Color profile: {name}" = "ფერის პროფილი: {name}" -"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "ფერისა და ტექსტის სტილები, ღია და მუქი ცვლადები, ღილაკები, შეყვანის ველები და ბარათები" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "სტილები, ღია და მუქი ცვლადები, ღილაკები და ბარათები" "Connector" = "შემაერთებელი" "Convert to frame" = "ჩარჩოდ გარდაქმნა" "Convert to section" = "სექციად გარდაქმნა" @@ -480,7 +480,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Expand" = "გაშლა" "Export" = "ექსპორტირება" "Export all" = "ყველაფრის ექსპორტირება" -"Export frames and layers as PNG, JPG, SVG or PDF" = "ჩარჩოებისა და შრეების ექსპორტირება PNG, JPG, SVG ან PDF ფორმატში" +"Export frames and layers as PNG, JPG, SVG or PDF" = "ჩარჩოები და შრეები PNG, JPG, SVG ან PDF ფორმატში" "Export page as PNG" = "გვერდის PNG ფაილად ექსპორტირება" "Export settings" = "ექსპორტის პარამეტრები" "Export variables" = "ცვლადების ექსპორტირება" @@ -553,7 +553,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Font size" = "შრიფტის ზომა" "Font style" = "შრიფტის სტილი" "Forget on this computer" = "ამ კომპიუტერზე დავიწყება" -"Four screens of a trail app with components and a prototype flow" = "ლაშქრობის აპის ოთხი ეკრანი კომპონენტებითა და პროტოტიპის ნაკადით" +"Four screens of a trail app with components and a prototype flow" = "ლაშქრობის აპის ოთხი ეკრანი და პროტოტიპის ნაკადი" "Fractions" = "წილადები" "Frame" = "ჩარჩო" "Frame selection" = "მონიშნულის ჩარჩოში მოქცევა" @@ -576,7 +576,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Gradient" = "გრადიენტი" "Gray" = "ნაცრისფერი" "Green" = "მწვანე" -"Greyscale components and two example screens to sketch a flow with" = "ნაცრისფერი კომპონენტები და ორი სანიმუშო ეკრანი ნაკადის ჩანახატისთვის" +"Greyscale components and two example screens to sketch a flow with" = "ნაცრისფერი კომპონენტები ნაკადის ჩანახატისთვის" "Grid" = "ბადე" "Grid ({size})" = "ბადე ({size})" "Grid styles" = "ბადის სტილები" @@ -623,7 +623,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Hue" = "ფერის ტონი" "Hug" = "შიგთავსზე მორგება" "Hug contents" = "შიგთავსზე მორგება" -"Icon components on a 24 px grid with keylines and a size preview" = "ხატულების კომპონენტები 24 px ბადეზე საორიენტაციო ხაზებითა და ზომების გადახედვით" +"Icon components on a 24 px grid with keylines and a size preview" = "ხატულები 24 px ბადეზე, საორიენტაციო ხაზებით" "Icon set starter" = "ხატულების ნაკრების საწყისი" "If it keeps happening, please report it at {link} with this message." = "თუ ეს განმეორდება, გთხოვთ, ეს შეტყობინება გამოგვიგზავნეთ აქ: {link}" "If" = "თუ" @@ -745,7 +745,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Link" = "ბმული" "Link copied" = "ბმული დაკოპირდა" "Link docs, code or tickets for developers." = "მიაბით დოკუმენტაცია, კოდი ან ტიკეტები დეველოპერებისთვის." -"Link frames on the Prototype tab, then present the flow" = "დააკავშირეთ ჩარჩოები „პროტოტიპის“ ჩანართზე, შემდეგ წარადგინეთ ნაკადი" +"Link frames on the Prototype tab, then present the flow" = "დააკავშირეთ ჩარჩოები და წარადგინეთ ნაკადი" "Link this component to its code component: instances then show your real snippet with their values." = "დააკავშირეთ ეს კომპონენტი მის კოდის კომპონენტთან: ინსტანსები მაშინ თქვენს რეალურ ფრაგმენტს აჩვენებს მათი მნიშვნელობებით." "List spacing" = "სიის დაშორება" "List style" = "სიის სტილი" @@ -921,7 +921,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "On click" = "დაწკაპუნებით" "On drag" = "გადათრევისას" "On this page" = "ამ გვერდზე" -"One announcement in five common sizes: square, portrait, story, link and banner" = "ერთი განცხადება ხუთ ხშირად გამოყენებულ ზომაში: კვადრატი, ვერტიკალური, სთორი, ბმული და ბანერი" +"One announcement in five common sizes: square, portrait, story, link and banner" = "ერთი განცხადება ხუთ პოპულარულ ზომაში" "Online" = "ონლაინ" "Online files" = "ონლაინ ფაილები" "Online sharing is off: files are shared on the local network only." = "ონლაინ გაზიარება გამორთულია: ფაილები მხოლოდ ლოკალურ ქსელში ზიარდება." @@ -1003,7 +1003,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Pick color" = "ფერის არჩევა" "Pick color (I)" = "ფერის აღება (I)" "Pick one font family first" = "ჯერ აირჩიეთ ერთი შრიფტის ოჯახი" -"Pick the Frame tool, then drag on the canvas or choose a device size" = "აირჩიეთ ჩარჩოს ხელსაწყო, შემდეგ გადაათრიეთ ტილოზე ან აირჩიეთ მოწყობილობის ზომა" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "ჩარჩოს ხელსაწყოთი გადაათრიეთ ან აირჩიეთ ზომა" "Pin a property" = "თვისების მიმაგრება" "Pinch" = "მოჭყლეტა" "Pixel grid" = "პიქსელური ბადე" @@ -1182,7 +1182,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Save files automatically" = "ფაილების ავტომატურად შენახვა" "Search templates" = "შაბლონების ძიება" "See all templates" = "ყველა შაბლონის ნახვა" -"Select layers and they space themselves as their content changes" = "მონიშნეთ შრეები და შიგთავსის ცვლილებისას ისინი ავტომატურად დაშორდება ერთმანეთს" +"Select layers and they space themselves as their content changes" = "შრეები შიგთავსის ცვლილებისას თავად დაშორდება" "SHA-256: {sha}…" = "SHA-256: {sha}…" "Same as layer" = "შრის მსგავსად" "Saturation" = "გაჯერება" @@ -1330,7 +1330,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Start on path" = "დასაწყისი ტრაექტორიაზე" "Start point" = "საწყისი წერტილი" "Start sharing" = "გაზიარების დაწყება" -"Start sharing with the Share button, then pass on the code it gives" = "გაზიარება დაიწყეთ ღილაკით „გაზიარება“, შემდეგ გადაეცით მის მიერ მოცემული კოდი" +"Start sharing with the Share button, then pass on the code it gives" = "დააჭირეთ „გაზიარებას“ და გადაეცით მიღებული კოდი" "Starting to share…" = "გაზიარების დაწყება…" "Status" = "სტატუსი" "Sticky" = "მიმაგრებული" @@ -1451,7 +1451,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Try another search or filter." = "სცადეთ სხვა ძიება ან ფილტრი." "Try another search, or browse templates." = "სცადეთ სხვა ძიება ან დაათვალიერეთ შაბლონები." "Try another word, or look at every kind." = "სცადეთ სხვა სიტყვა ან ნახეთ ყველა სახეობა." -"Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად, გამოიყენეთ ხელახლა და შეცვალეთ ყველა ასლი ერთდროულად" +"Turn a layer into a component, reuse it, and change every copy at once" = "შრე აქციეთ კომპონენტად და შეცვალეთ ყველა ასლი" "Two digits" = "ორი ციფრი" "Type" = "ტიპი" "Type settings" = "ტექსტის პარამეტრები" From 236f3185d68512e0f4af0eefe26d24c2e1c4ddd7 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sun, 25 Oct 2026 13:04:39 -0600 Subject: [PATCH 123/142] Migrate FigCraft to egui 0.37 (#1312) * E21: require release font coverage and stabilize capture deadline tests * E21: separate capture, IME and field-save regression phases * M0: correct the migration qualification reference * M-egui037: document input lifecycle and UI regression contracts * M0: correct egui 0.37 status docs and stabilize join-code regression --------- Co-authored-by: Dylan M. Taylor Co-authored-by: Codebuff --- .github/workflows/ci.yml | 9 +- .github/workflows/freebsd.yml | 2 +- .github/workflows/release.yml | 9 +- ATTRIBUTION.md | 13 +- Cargo.lock | 191 +-- Cargo.toml | 32 +- NOTICE | 17 + ROADMAP.md | 4 +- apps/figcraft-web/src/web.rs | 10 +- apps/figcraft/Cargo.toml | 2 +- apps/figcraft/src/control_server.rs | 45 +- apps/figcraft/src/control_server_tests.rs | 54 +- apps/figcraft/src/main.rs | 17 +- crates/collab-net/src/code_tests.rs | 5 +- crates/collab-net/src/sessions_tests.rs | 7 + crates/engine/src/cmd/edit.rs | 19 +- crates/engine/src/lib.rs | 15 + crates/engine/src/text_behaviour_tests.rs | 57 + crates/engine/src/textedit.rs | 538 ++++++- crates/engine/src/textfit_tests.rs | 4 + crates/engine/src/tools.rs | 10 +- crates/text/build.rs | 187 ++- crates/text/tests/build_input.rs | 4 + crates/text/tests/fixtures/latin-families.txt | 52 + crates/ui-egui/Cargo.toml | 5 +- crates/ui-egui/src/a11y.rs | 20 +- crates/ui-egui/src/a11y_tests.rs | 28 + crates/ui-egui/src/assets_panel.rs | 57 +- crates/ui-egui/src/bidi.rs | 274 +--- crates/ui-egui/src/board_bar.rs | 8 +- crates/ui-egui/src/board_ui_tests.rs | 4 +- crates/ui-egui/src/branch_ui.rs | 4 +- crates/ui-egui/src/canvas.rs | 6 +- crates/ui-egui/src/clipboard_fence_tests.rs | 200 +++ crates/ui-egui/src/colorpicker.rs | 51 +- crates/ui-egui/src/comments.rs | 26 +- crates/ui-egui/src/component_panel.rs | 15 +- crates/ui-egui/src/control.rs | 919 ++++++++++-- crates/ui-egui/src/control_capture_tests.rs | 657 +++++++++ crates/ui-egui/src/corner_individual_tests.rs | 6 +- crates/ui-egui/src/credits.rs | 4 +- crates/ui-egui/src/credits_tests.rs | 6 +- crates/ui-egui/src/cursors.rs | 2 +- crates/ui-egui/src/dev_compare.rs | 8 +- crates/ui-egui/src/dev_mode.rs | 42 +- crates/ui-egui/src/dev_mode_tests.rs | 9 +- crates/ui-egui/src/dialogs.rs | 20 +- crates/ui-egui/src/export_panel.rs | 12 +- crates/ui-egui/src/export_preview_tests.rs | 4 +- crates/ui-egui/src/field_tests.rs | 364 ++++- crates/ui-egui/src/font_preview.rs | 2 +- crates/ui-egui/src/frame_labels.rs | 8 +- crates/ui-egui/src/frame_presets.rs | 4 +- crates/ui-egui/src/guides.rs | 5 +- crates/ui-egui/src/handles.rs | 3 +- crates/ui-egui/src/home.rs | 29 +- crates/ui-egui/src/home_recents.rs | 4 +- crates/ui-egui/src/icons.rs | 4 +- crates/ui-egui/src/idle_tests.rs | 89 +- crates/ui-egui/src/ime_tests.rs | 559 ++++++++ crates/ui-egui/src/layers.rs | 59 +- crates/ui-egui/src/layers_behaviour_tests.rs | 8 +- crates/ui-egui/src/layers_drag_tests.rs | 27 + crates/ui-egui/src/layers_rename_tests.rs | 3 + crates/ui-egui/src/layout_panel.rs | 43 +- crates/ui-egui/src/lib.rs | 359 ++++- crates/ui-egui/src/list_drag.rs | 28 +- crates/ui-egui/src/live.rs | 40 +- crates/ui-egui/src/live_join_tests.rs | 3 +- crates/ui-egui/src/live_server_tests.rs | 3 +- crates/ui-egui/src/live_share_tests.rs | 9 +- crates/ui-egui/src/live_tests.rs | 3 + crates/ui-egui/src/menu_aim_tests.rs | 7 +- crates/ui-egui/src/menu_input_tests.rs | 4 +- crates/ui-egui/src/menus.rs | 81 +- crates/ui-egui/src/online.rs | 15 +- .../ui-egui/src/padding_individual_tests.rs | 6 +- crates/ui-egui/src/panel_remove_tests.rs | 14 +- crates/ui-egui/src/panel_reorder_tests.rs | 3 + crates/ui-egui/src/pending_open.rs | 2 +- crates/ui-egui/src/perf_tests.rs | 12 +- crates/ui-egui/src/plugin_panel_tests.rs | 8 +- crates/ui-egui/src/plugins_ui.rs | 26 +- crates/ui-egui/src/present.rs | 144 +- crates/ui-egui/src/present_tests.rs | 4 + crates/ui-egui/src/present_view_tests.rs | 5 + crates/ui-egui/src/proto_canvas.rs | 7 +- crates/ui-egui/src/proto_editor.rs | 5 +- crates/ui-egui/src/proto_editor_tests.rs | 9 +- crates/ui-egui/src/proto_panel.rs | 59 +- crates/ui-egui/src/proto_reorder_tests.rs | 3 +- crates/ui-egui/src/right.rs | 189 ++- crates/ui-egui/src/rtl_names_tests.rs | 275 +++- crates/ui-egui/src/scale_panel.rs | 6 +- crates/ui-egui/src/section_panel.rs | 5 +- crates/ui-egui/src/select_all_tests.rs | 18 +- crates/ui-egui/src/selection_colors.rs | 20 +- crates/ui-egui/src/selection_spacing.rs | 14 +- crates/ui-egui/src/selection_spacing_tests.rs | 7 +- crates/ui-egui/src/shortcuts.rs | 625 +++++++-- crates/ui-egui/src/style_picker.rs | 22 +- crates/ui-egui/src/style_picker_tests.rs | 6 +- crates/ui-egui/src/tabs.rs | 30 +- crates/ui-egui/src/theme.rs | 17 +- crates/ui-egui/src/theme_startup_tests.rs | 17 + crates/ui-egui/src/titlebar_tests.rs | 4 +- crates/ui-egui/src/toolbar.rs | 29 +- crates/ui-egui/src/type_settings_tests.rs | 4 +- crates/ui-egui/src/typography.rs | 57 +- crates/ui-egui/src/typography_scrub_tests.rs | 13 +- crates/ui-egui/src/variable_ui.rs | 17 +- crates/ui-egui/src/variables_panel.rs | 33 +- crates/ui-egui/src/vector_panel.rs | 19 +- crates/ui-egui/src/versions.rs | 8 +- crates/ui-egui/src/widgets.rs | 55 +- crates/ui-egui/src/windows.rs | 124 +- crates/ui-egui/src/windows_tests.rs | 153 +- crates/ui-egui/tests/it/recent_file_names.rs | 2 +- .../tests/type_settings_long_labels.rs | 2 +- docs/control-protocol.md | 33 +- docs/egui-037-local-followups.md | 214 +++ docs/egui-037-migration-plan.md | 1230 +++++++++++++++++ docs/gaps.md | 8 +- docs/hardware-parity.md | 8 +- docs/plugins.md | 5 +- docs/releasing.md | 13 +- docs/ui-parity.md | 5 +- .../linux/flatpak/ai.storyteller.figcraft.yml | 1 + packaging/linux/package.sh | 1 - packaging/macos/package.sh | 1 + packaging/windows/figcraft.wxs | 12 + packaging/windows/package.ps1 | 16 + plugins/Cargo.toml | 3 +- xtask/src/main.rs | 13 +- 134 files changed, 7698 insertions(+), 1396 deletions(-) create mode 100644 crates/text/tests/build_input.rs create mode 100644 crates/text/tests/fixtures/latin-families.txt create mode 100644 crates/ui-egui/src/clipboard_fence_tests.rs create mode 100644 crates/ui-egui/src/control_capture_tests.rs create mode 100644 docs/egui-037-local-followups.md create mode 100644 docs/egui-037-migration-plan.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1968076f..b79af502 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,11 +21,10 @@ jobs: # Tracks @main so suite-wide CI fixes reach every app without a PR in each repository. uses: storytold/craft-common-actions/.github/workflows/gate.yml@main with: - # The craft-fonts commit release.yml builds with. Needs fonts/latin-manifest.txt (Inter): - # crates/text/build.rs fails a build that requires craft-fonts without it. Only craft-fonts' - # pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. Re-pin - # once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 + require-craft-fonts: true packages: >- libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev libgtk-3-dev diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 3b03a99c..80adf16a 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -32,4 +32,4 @@ jobs: uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main with: run-package-check: true - craft-fonts-ref: a71c20e30af613287c504f7e685ca8fad77752bc + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd2aefdd..f93219fa 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,13 +24,12 @@ jobs: with: app-name: figcraft app-title: FigCraft - # Needs fonts/latin-manifest.txt (Inter): crates/text/build.rs fails a release without it. Only - # craft-fonts' pr/latin-presentation-fonts has it; its main has Noto Sans Georgian but no Inter. - # Re-pin once one commit has both (storytold/craft-fonts#42). - craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + # Combined Latin presentation and core UI catalog. Required web builds enforce + # Inter Regular/SemiBold and Noto Sans Georgian Regular after asset-policy filtering. + craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 has-server: true # The browser has no system fonts: without craft-fonts the web build can't draw text (#885). - # crates/text/build.rs keeps the web build to Inter. + # crates/text/build.rs keeps the web allowlist small (Inter and Georgian). web-craft-fonts: true version: ${{ inputs.version }} secrets: diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 96dff6a9..46b789a8 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -26,8 +26,17 @@ Release builds embed fonts from the optional craft-fonts build input |---|---|---| | Inter | SIL Open Font License 1.1 | https://github.com/rsms/inter | | Archivo | SIL Open Font License 1.1 | [Google Fonts / Archivo](https://github.com/google/fonts/tree/6c70c829f09ea345d3590406693220ea35c6553f/ofl/archivo) | -| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Source Sans 3, Source Serif 4, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | +| Roboto, Montserrat, Lato, Open Sans, Poppins, Nunito Sans, Merriweather, Playfair Display, Carlito, Caladea | SIL Open Font License 1.1 | Google Fonts / upstream repositories (see craft-fonts) | | Liberation Sans, Serif, Mono | SIL Open Font License 1.1 | https://github.com/liberationfonts | -| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | +| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic, Noto Sans Georgian | SIL Open Font License 1.1 | Google Fonts (see craft-fonts) | + +The central catalog also contains Adobe-authored Source Sans 3 and Source Serif 4. +FigCraft filters both families from every build in accordance with its asset policy. egui's default fonts (compiled into the `egui`/`epaint` crates) keep their own licences (OFL / Apache-2.0 / Ubuntu Font Licence); see those crates. + +## egui dependency font icons + +| Asset | Author | Source | Licence | Use | +|---|---|---|---|---| +| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. | diff --git a/Cargo.lock b/Cargo.lock index 2d84daad..37affd4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,21 +4,21 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", "phf", "serde", @@ -27,43 +27,37 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.36.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", -] - -[[package]] -name = "accesskit_consumer" -version = "0.38.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -71,9 +65,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -89,13 +83,13 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -907,9 +902,8 @@ source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09 [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1f805378d454dac48b5c214029d254055fdf961b#1f805378d454dac48b5c214029d254055fdf961b" +source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" dependencies = [ - "egui", "wayland-client", ] @@ -1125,9 +1119,8 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "ecolor" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "emath", @@ -1135,9 +1128,8 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1159,6 +1151,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1170,9 +1163,8 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit", "ahash", @@ -1190,9 +1182,8 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1210,30 +1201,33 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", + "image", "log", "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", + "parking_lot", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_extras" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "egui", @@ -1245,9 +1239,8 @@ dependencies = [ [[package]] name = "egui_glow" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", "egui", @@ -1265,9 +1258,8 @@ checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "bytemuck", ] @@ -1292,22 +1284,22 @@ checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] name = "enum-map" -version = "2.7.3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" dependencies = [ "enum-map-derive", ] [[package]] name = "enum-map-derive" -version = "0.17.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -1356,9 +1348,8 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" dependencies = [ "ahash", "bytemuck", @@ -1374,16 +1365,17 @@ dependencies = [ "self_cell", "skrifa 0.44.0", "smallvec", + "unicode-bidi", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" [[package]] name = "equivalent" @@ -2061,9 +2053,9 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", "foldhash 0.2.0", @@ -3364,9 +3356,9 @@ dependencies = [ [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -3375,9 +3367,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -3385,9 +3377,9 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", @@ -3398,9 +3390,9 @@ dependencies = [ [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -4071,6 +4063,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -4876,9 +4874,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -4891,9 +4889,9 @@ dependencies = [ [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -5693,8 +5691,7 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winit" version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +source = "git+https://github.com/storytold/winit.git?rev=a517b3846ee8457d84ee4975110bfda6797c44dc#a517b3846ee8457d84ee4975110bfda6797c44dc" dependencies = [ "ahash", "android-activity", @@ -6190,3 +6187,23 @@ dependencies = [ "syn 3.0.9", "winnow", ] + +[[patch.unused]] +name = "egui_demo_lib" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_inspection" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_kittest" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" + +[[patch.unused]] +name = "egui_system_font_provider" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" diff --git a/Cargo.toml b/Cargo.toml index 39abeca3..5395ff97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,8 +35,11 @@ figcraft-plugin = { path = "crates/plugin" } # Which paths stored in a document may be followed (craft-libs). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the -# same native titlebar per desktop (craft-libs). The `egui` feature adds `decorate`/`decorations`. -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "1f805378d454dac48b5c214029d254055fdf961b", features = ["egui"] } +# same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it +# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would +# pull a second egui family version). The rev is the last merged one; the released 0.37 pin is +# supplied by the migration's reviewed craft-libs revision. +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -79,9 +82,9 @@ wasmi = { version = "2", default-features = false, features = ["std", "stable", wat = "1.261" # ui -egui = "0.36" -egui_extras = { version = "0.36", default-features = false } -eframe = { version = "0.36", default-features = false } +egui = "0.37" +egui_extras = { version = "0.37", default-features = false } +eframe = { version = "0.37", default-features = false } rfd = "0.17" arboard = { version = "3.6", default-features = false } webbrowser = "1" @@ -130,7 +133,24 @@ debug = false strip = true panic = "abort" +# Keep the complete egui family and native event bridge on reviewed fork commits. +[patch.crates-io] +ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } +winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" } + # craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a # panic while drawing unwinds without aborting. -[patch.crates-io] krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" } diff --git a/NOTICE b/NOTICE index a0baaa27..9970631d 100644 --- a/NOTICE +++ b/NOTICE @@ -51,3 +51,20 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +egui icon font subset (dependency asset) +---------------------------------------- +epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of +emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback +uses the same MIT license. Upstream license text follows unchanged. + +MIT License + +Copyright (c) 2014 John Slegers + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/ROADMAP.md b/ROADMAP.md index 64ba8ffc..0e0ae25b 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 (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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-25** · egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. - **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. - **2026-10-24** · 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)). @@ -334,6 +335,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | egui 0.37 capture lifecycle: screenshots with hidden or unknown visibility queue, request a hidden paint, and expire after 60 seconds if no capture arrives (headless-tested contract). Logic-only edits, saves and inspection continue while hidden. Native visible GUI and forced hidden-window paint, browser and Wayland behavior remain unqualified; parity percentages are unchanged. | | 2026-10-24 | minor | 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 | diff --git a/apps/figcraft-web/src/web.rs b/apps/figcraft-web/src/web.rs index ddb876b0..40451d43 100644 --- a/apps/figcraft-web/src/web.rs +++ b/apps/figcraft-web/src/web.rs @@ -69,7 +69,15 @@ struct WebShell { } impl eframe::App for WebShell { - // `FigApp::ui` runs its own per-frame logic first (fonts, theme), like the desktop app. + // eframe runs `logic` for visible tabs and, with only `run_logic`, for hidden ones: keep + // autosave/live/control deadlines moving without a paint. `FigApp::ui` does not repeat it. + /// Run the browser shell's eframe logic callback: guard `background_tick` so hidden tabs + /// still advance autosave, live and control deadlines without painting. `FigApp::ui` does + /// not repeat it. + fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + figcraft_ui_egui::windows::guard(&mut self.fig, ctx, |fig| fig.background_tick(ctx)); + } + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.fig.ui(ui); } diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index 6196d08d..141b9517 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -21,7 +21,7 @@ figcraft-text = { workspace = true } # craft-libs#217 branch and must be repointed to a merged `rev` before this PR is mergeable. craft-window = { workspace = true } egui = { workspace = true } -eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] } +eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] } serde_json = { workspace = true } log = { workspace = true } rfd = { workspace = true } diff --git a/apps/figcraft/src/control_server.rs b/apps/figcraft/src/control_server.rs index ff9c2fbe..ada44c0f 100644 --- a/apps/figcraft/src/control_server.rs +++ b/apps/figcraft/src/control_server.rs @@ -6,7 +6,7 @@ use std::net::{TcpListener, TcpStream}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use figcraft_ui_egui::ControlRequest; +use figcraft_ui_egui::{ControlReply, ControlRequest, RequestGate, RequestState}; use serde_json::{Value, json}; type Queue = Arc>>; @@ -21,6 +21,34 @@ const TIMEOUT: Duration = Duration::from_secs(60); /// core busy until it recovers. const ACCEPT_RETRY: Duration = Duration::from_millis(100); +/// Why a request's transport wait ended without a control reply. +#[derive(Debug, PartialEq, Eq)] +enum WaitFailure { + Timeout, + /// The UI side dropped its sender before replying (the app is closing). + Disconnected, +} + +/// Wait for the UI's reply, and settle a timeout under the same gate the UI's final commit uses. +/// A commit that got the gate first has already queued its reply: consume that reply instead of +/// reporting a timeout. If the gate is still open, cancel it so the UI writes nothing. +fn wait_for_reply(rx: &std::sync::mpsc::Receiver, gate: &RequestGate, timeout: Duration) -> Result { + match rx.recv_timeout(timeout) { + Ok(reply) => Ok(reply), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => after_timeout(rx, gate), + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(WaitFailure::Disconnected), + } +} + +/// The timeout branch of [`wait_for_reply`]. Kept separate so it can be tested deterministically +/// against a gate whose commit already happened. +fn after_timeout(rx: &std::sync::mpsc::Receiver, gate: &RequestGate) -> Result { + match gate.cancel_if_open() { + RequestState::Replied => rx.try_recv().map_err(|_| WaitFailure::Disconnected), + RequestState::Open | RequestState::Cancelled => Err(WaitFailure::Timeout), + } +} + /// 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) { target.retain(|request| !request.is_cancelled()); @@ -69,6 +97,9 @@ fn spawn_connection(stream: TcpStream, queue: Queue, ctx: CtxSlot, workers: &mut } } +/// Serve one control connection: read bounded JSON lines, reject oversized frames and a +/// full queue, queue one request, wait under its gate, and write one reply. Non-JSON input +/// answers with an error and closes the connection so HTTP body lines cannot become commands. fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> std::io::Result<()> { out.set_read_timeout(Some(timeout))?; out.set_write_timeout(Some(timeout))?; @@ -96,7 +127,7 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s let method = v.get("method").and_then(Value::as_str).unwrap_or("").to_string(); let params = v.get("params").cloned().unwrap_or(json!({})); let (tx, rx) = std::sync::mpsc::channel(); - let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let gate = RequestGate::new(); let mut pending = queue.lock().unwrap_or_else(std::sync::PoisonError::into_inner); pending.retain(|request| !request.is_cancelled()); if pending.len() >= MAX_QUEUED_REQUESTS { @@ -104,20 +135,22 @@ fn serve(mut out: TcpStream, queue: Queue, ctx: CtxSlot, timeout: Duration) -> s writeln!(out, "{}", json!({"id": id, "ok": false, "error": "control request queue is full"}))?; continue; } - pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, cancelled: cancelled.clone() }); + pending.push(ControlRequest { id: id.clone(), method, params, reply: tx, state: gate.clone() }); drop(pending); if let Some(c) = ctx.lock().unwrap_or_else(std::sync::PoisonError::into_inner).as_ref() { c.request_repaint(); } - match rx.recv_timeout(timeout) { + match wait_for_reply(&rx, &gate, timeout) { Ok(r) => match r.result { Ok(res) => json!({"id": r.id, "ok": true, "result": res}), Err(e) => json!({"id": r.id, "ok": false, "error": e}), }, - Err(_) => { - cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + Err(WaitFailure::Timeout) => { json!({"id": id, "ok": false, "error": "timed out waiting for the UI: screenshots and input need the FigCraft window on screen (is it minimised or covered?)"}) } + Err(WaitFailure::Disconnected) => { + json!({"id": id, "ok": false, "error": "the FigCraft window closed before answering"}) + } } } Err(e) => { diff --git a/apps/figcraft/src/control_server_tests.rs b/apps/figcraft/src/control_server_tests.rs index 011b0613..f29a7773 100644 --- a/apps/figcraft/src/control_server_tests.rs +++ b/apps/figcraft/src/control_server_tests.rs @@ -28,8 +28,9 @@ fn reply(reader: &mut BufReader) -> Value { serde_json::from_str(&line).unwrap() } +/// Build a `ui.inspect` control request with a throwaway reply channel. fn request() -> ControlRequest { - ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, cancelled: Default::default() } + ControlRequest { id: json!(0), method: "ui.inspect".into(), params: json!({}), reply: mpsc::channel().0, state: Default::default() } } #[test] @@ -115,6 +116,52 @@ fn full_queue_rejects_before_dispatch_and_ui_queue_stays_bounded() { worker.join().unwrap().unwrap(); } +/// Asserts a commit that writes the file and queues its reply wins the gate: the transport's +/// timeout branch consumes that reply and leaves the state `Replied`. +#[test] +fn a_committed_reply_wins_over_the_transport_timeout() { + let dir = std::env::temp_dir().join(format!("figcraft-control-commit-{}", std::process::id())); + let path = dir.join("shot.png"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let gate = RequestGate::new(); + let (tx, rx) = mpsc::channel(); + // The UI's final commit gets the gate first: the file and reply are both committed before the + // transport's timeout branch can look at the gate. + let committed = gate.commit(|| { + std::fs::write(&path, b"png").unwrap(); + tx.send(ControlReply { id: json!(7), result: Ok(json!({"path": path.to_string_lossy()})) }).unwrap(); + }); + assert!(committed.is_some()); + assert!(path.is_file()); + let reply = after_timeout(&rx, &gate).expect("the queued commit is consumed"); + assert_eq!(reply.id, json!(7)); + assert!(reply.result.is_ok()); + assert_eq!(gate.state(), RequestState::Replied, "the timeout must not overwrite a commit"); + assert!(path.is_file(), "a winning commit keeps its file"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a zero-duration wait reports `Timeout` and cancels the open gate. +#[test] +fn a_timeout_cancels_an_open_request() { + let gate = RequestGate::new(); + let (_tx, rx) = mpsc::channel::(); + assert!(matches!(wait_for_reply(&rx, &gate, Duration::ZERO), Err(WaitFailure::Timeout))); + assert_eq!(gate.state(), RequestState::Cancelled, "nothing may write after the timeout"); +} + +/// Asserts a closed reply channel reports `Disconnected` and leaves the gate `Open`, not +/// cancelled. +#[test] +fn a_disconnected_reply_channel_is_not_reported_as_a_timeout() { + let gate = RequestGate::new(); + let (tx, rx) = mpsc::channel::(); + drop(tx); + assert!(matches!(wait_for_reply(&rx, &gate, Duration::from_secs(1)), Err(WaitFailure::Disconnected))); + assert_eq!(gate.state(), RequestState::Open, "a closed app is not a cancel decision"); +} + #[test] fn idle_and_partial_frame_connections_expire() { for bytes in [b"".as_slice(), b"{\"id\":"] { @@ -227,6 +274,9 @@ fn a_limit_sized_frame_is_accepted_at_eof() { worker.join().unwrap().unwrap(); } +/// Asserts expired requests stop occupying the UI queue cap, a later request is admitted +/// and answered, and a window that finally draws discards its expired command before +/// dispatch. #[test] fn timed_out_requests_release_a_full_deferred_ui_queue() { use figcraft_ui_egui::{FigApp, NoServices, control, windows}; @@ -262,7 +312,7 @@ fn timed_out_requests_release_a_full_deferred_ui_queue() { // Include expired requests in the socket queue, too: admission must reclaim their slots. pending.lock().unwrap().extend((0..MAX_QUEUED_REQUESTS).map(|_| { let r = request(); - r.cancelled.store(true, std::sync::atomic::Ordering::Relaxed); + r.state.cancel_if_open(); r })); let (mut client, server) = pair(); diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0b71149a..4a1488ad 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -62,6 +62,9 @@ struct App { } impl eframe::App for App { + /// Native eframe logic callback: store the first context, drain control requests, run + /// guarded background work, poll macOS open events, handle close requests and title + /// changes, and schedule recovery repaints on the app clock. fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { { let mut slot = self.ctx_slot.lock().unwrap_or_else(std::sync::PoisonError::into_inner); @@ -71,7 +74,9 @@ impl eframe::App for App { } let title = with_app(|fig| { control_server::drain_queue(&self.queue, &mut fig.control_queue); - figcraft_ui_egui::control::drain_hidden(fig, ctx); + // Phase-safe background work: control queues, autosave/recovery deadlines, live polling. + // It runs on logic-only callbacks too, so it never reads UI input. + figcraft_ui_egui::windows::guard(fig, ctx, |fig| fig.background_tick(ctx)); #[cfg(target_os = "macos")] apple_events::poll(&self.apple_events, fig, ctx); // Closing the window asks about unsaved changes (and keeps the app running while other @@ -86,8 +91,14 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); self.title = title; } - let now = ctx.input(|i| i.time); - if let Some(wait) = with_app(|fig| recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now))).flatten() { + // Recovery deadlines use the same monotonic clock as the app's background work: a + // logic-only tick leaves `ctx.input(i.time)` stale. + if let Some(wait) = with_app(|fig| { + let now = fig.now_secs(); + recovery::lock(&self.recovery).as_mut().and_then(|r| r.tick(&fig.session, now)) + }) + .flatten() + { ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); } } diff --git a/crates/collab-net/src/code_tests.rs b/crates/collab-net/src/code_tests.rs index c2745e9a..4cf10f6a 100644 --- a/crates/collab-net/src/code_tests.rs +++ b/crates/collab-net/src/code_tests.rs @@ -106,7 +106,10 @@ fn a_local_code_with_the_wrong_secret_or_a_typo_doesnt_join() { assert!(host.live().peers.is_empty()); // A typing mistake is caught before anything connects. let mut typo: Vec = local.code().chars().collect(); - typo[7] = if typo[7] == 'A' { 'B' } else { 'A' }; + // The last symbol contains only checksum bits. Changing payload bits can collide with + // the eight-bit checksum, so it does not guarantee a malformed code. + let last = typo.last_mut().unwrap(); + *last = if *last == 'A' { 'B' } else { 'A' }; let e = guest.execute("collab.join", &json!({"code": typo.iter().collect::()})).unwrap_err().to_string(); assert!(e.contains("typing mistake"), "{e}"); // A view-only code doesn't exist here, and says where it does. diff --git a/crates/collab-net/src/sessions_tests.rs b/crates/collab-net/src/sessions_tests.rs index 404244fa..9e76e54e 100644 --- a/crates/collab-net/src/sessions_tests.rs +++ b/crates/collab-net/src/sessions_tests.rs @@ -89,6 +89,9 @@ fn rect(s: &mut Session) -> u64 { s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap()["id"].as_u64().unwrap() } +/// Asserts two files are hosted and joined independently: each has its own port, session and +/// edits, presence stays in its file's session, and closing one file's session leaves the +/// other live. #[test] fn two_files_are_hosted_and_joined_independently() { let mut host = session("Hana"); @@ -128,6 +131,10 @@ fn two_files_are_hosted_and_joined_independently() { assert_eq!(guest.ds().unwrap().uid, guest.docs[2].uid, "the file that arrived last is in front"); guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap(); // An edit in A reaches the guest's copy of A only, while B is in front at the guest. + // Concurrent joins may complete in either order. Put B in front explicitly so the + // following assertions exercise A's background session independent of network timing. + guest.execute("doc.switch", &json!({"index": guest.docs.iter().position(|d| d.uid == gb).unwrap()})).unwrap(); + assert_eq!(guest.ds().unwrap().uid, gb, "B is in front while A receives edits"); let in_a = rect(&mut host); host.execute("doc.switch", &json!({"index": 1})).unwrap(); let in_b = rect(&mut host); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 8969f7c4..38b24ea6 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -541,6 +541,7 @@ pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<( s.clipboard.nodes = nodes; s.clipboard.images = images; s.clipboard.bounds = bounds; + s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1); Ok(()) } @@ -600,6 +601,10 @@ pub fn design_clipboard_buffer(html: &str) -> Option> { figcraft_format::fig::is_fig(&bytes).then_some(bytes) } +/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, +/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, +/// store them as a foreign-file clipboard source with a bumped layers generation, then +/// delegate to `paste` and append import warnings. fn paste_design_layers(s: &mut Session, p: &Value) -> Result { const CMD: &str = "edit.pasteDesignLayers"; let bytes = match (string(p, "html"), string(p, "data")) { @@ -616,8 +621,18 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result { let (nodes, images, bounds) = copied(&doc, &tops); let props = s.clipboard.props.take(); // No open file has id 0. So paste treats this copy as a copy from a different file. - s.clipboard = - crate::Clipboard { nodes, images, props, bounds, source_doc: 0, source: Some(doc), source_path: None, copied_components: BTreeSet::new() }; + let layers_generation = s.clipboard.layers_generation.wrapping_add(1); + s.clipboard = crate::Clipboard { + nodes, + images, + props, + bounds, + source_doc: 0, + source: Some(doc), + source_path: None, + copied_components: BTreeSet::new(), + layers_generation, + }; let mut out = paste(s, p)?; if !other.issues.is_empty() { out["warnings"] = json!(other.issues); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 99b0f6f2..60ae4aa9 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -329,6 +329,17 @@ pub struct TextEdit { pub anchor: usize, /// The node was created by this edit (deleted if left empty). pub created: bool, + /// Identity of this editing session (`textedit::begin` assigns a fresh one). + /// + /// The IME owner is `(document uid, node)`, which stays the same through ordinary edits + /// and caret moves; this token changes only when editing is started again, so an IME + /// context is never silently inherited by a replaced target that reuses the same node. + /// + /// It is deliberately not serialized: current consumers only inspect a live session. A + /// serialized `TextEdit` restored from data would get zero here, so it must not be restored + /// into a live editor without minting (or otherwise invalidating) a new session. + #[serde(default, skip_serializing)] + pub editing_session: u64, /// An IME composition in progress, shown inline at the caret; the document gets it on commit. #[serde(default, skip_serializing_if = "Option::is_none")] pub preedit: Option, @@ -774,6 +785,10 @@ pub struct Clipboard { pub source_doc: u64, /// Copied component/set roots, excluding cuts and originals discarded by undo (ids are reused). pub copied_components: std::collections::BTreeSet, + /// Monotonic generation of the copied layers (contents, images, bounds and source). A UI + /// clipboard mirror records it, so a later programmatic copy that never touched the system + /// clipboard is not mistaken for the user's own copy just because the text still matches. + pub layers_generation: u64, /// The document copied from (pastes into other files follow its mains) and its file path. pub source: Option>, pub source_path: Option, diff --git a/crates/engine/src/text_behaviour_tests.rs b/crates/engine/src/text_behaviour_tests.rs index ceaad8a7..f15817e8 100644 --- a/crates/engine/src/text_behaviour_tests.rs +++ b/crates/engine/src/text_behaviour_tests.rs @@ -137,6 +137,63 @@ fn ime_composition_with_cjk() { assert!(s.ds().unwrap().text_edit.as_ref().unwrap().preedit.is_none()); } +/// Asserts `delete_surrounding_bytes` removes an exact UTF-8 byte range across CJK and a +/// supplementary emoji, leaves nothing past the start, and the whole edit is one undo step. +#[test] +fn ime_delete_surrounding_bytes_is_exact_and_one_undo_step() { + // 0.37's `DeleteSurroundingBytes` counts UTF-8 bytes around the selection, before the paired + // commit. Multibyte CJK and a supplementary emoji must be removed exactly, not as tofu or + // extended to grapheme clusters, and the whole edit remains one undo step. + let mut s = Session::new(); + let t = text(&mut s, "ab\u{4F60}\u{597D}c\u{1F600}d", json!({"fontSize": 24})); + begin(&mut s, t, Some(4), false).unwrap(); + assert!(crate::textedit::delete_surrounding_bytes(&mut s, 6, 1).unwrap(), "你好 before and c after"); + assert_eq!(chars(&s, t), "ab\u{1F600}d"); + assert_eq!(caret(&s), (2, 2)); + crate::textedit::set_caret(&mut s, 3, false).unwrap(); + assert!(crate::textedit::delete_surrounding_bytes(&mut s, 4, 0).unwrap(), "the emoji is four bytes"); + assert_eq!(chars(&s, t), "abd"); + assert_eq!(caret(&s), (2, 2)); + // Past the start of the text: nothing more to delete, not an error. + assert!(!crate::textedit::delete_surrounding_bytes(&mut s, 0, 0).unwrap()); + crate::textedit::end(&mut s).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(chars(&s, t), "ab\u{4F60}\u{597D}c\u{1F600}d", "one undo restores the whole session"); +} + +/// Asserts a byte range that only partly covers a character leaves it in place, while an +/// exact range deletes it, and a selection survives for the paired commit with its +/// direction preserved. +#[test] +fn ime_delete_surrounding_bytes_keeps_partial_characters_and_selection() { + // The fork deletes the byte range exactly and leaves a character the range only partly + // covers. FigCraft's editor counts characters with the same rule (mirroring egui's + // `surrounding_bytes_to_chars`), including around a selection. + let mut s = Session::new(); + let t = text(&mut s, "\u{e9}x\u{1F600}", json!({"fontSize": 24})); + let del = |s: &mut Session, b: usize, a: usize| crate::textedit::delete_surrounding_bytes(s, b, a).unwrap(); + begin(&mut s, t, Some(1), false).unwrap(); + assert!(!del(&mut s, 1, 0), "one byte ends inside 'é' (two bytes): leave it"); + assert_eq!(chars(&s, t), "\u{e9}x\u{1F600}"); + crate::textedit::set_caret(&mut s, 2, false).unwrap(); + assert!(!del(&mut s, 0, 2), "two bytes start inside the four-byte emoji: leave it"); + assert_eq!(chars(&s, t), "\u{e9}x\u{1F600}"); + assert!(del(&mut s, 0, 4), "its four bytes cover it exactly"); + assert_eq!(chars(&s, t), "\u{e9}x"); + assert_eq!(caret(&s), (2, 2)); + // Complete bytes before the selection go; a character the range only partly covers stays, + // and the selection itself is kept for the paired commit. + crate::textedit::set_caret(&mut s, 1, false).unwrap(); + crate::textedit::set_caret(&mut s, 1, true).unwrap(); + crate::textedit::set_caret(&mut s, 2, true).unwrap(); + assert!(del(&mut s, 2, 2), "é before (two bytes); two bytes start inside the four-byte emoji"); + // The selected 'x' is kept for the paired commit, as in stock `TextEdit`; the caret moves left + // with the deleted prefix and the selection direction is preserved. + assert_eq!(chars(&s, t), "x"); + assert_eq!(caret(&s), (0, 1)); + crate::textedit::end(&mut s).unwrap(); +} + #[test] fn vertical_trim_through_node_set() { let mut s = Session::new(); diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 47dfdf5b..e236e322 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -1,9 +1,85 @@ //! On-canvas text editing: caret, selection, typing. A whole editing session is one undo step. +use std::sync::{LazyLock, Mutex}; + use figcraft_doc::{Document, ListStyle, NodeId, NodeKind, TextData}; use crate::{EngineError, Preedit, Result, Session, TextEdit}; +/// Most committed UTF-8 bytes an outgoing IME snapshot may describe. +/// +/// This mirrors the fork's `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; the engine keeps its +/// own constant so this layer has no egui dependency. The query below returns only this bounded +/// window around the selection, never a copy of the whole document. +pub const MAX_IME_SURROUNDING_BYTES: usize = 4000; + +/// Stable identity of the on-canvas text edit an IME context belongs to. +/// +/// `document` and `node` do not change through ordinary text or caret edits. `editing_session` +/// changes when [`begin`] starts a new session for an equal `(document, node)` pair (leaving and +/// re-entering the same layer), so a replaced target never inherits an old composition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ImeEditOwner { + /// The document tab's uid. + pub document: u64, + /// The edited text node. + pub node: NodeId, + /// [`TextEdit::editing_session`] of the current edit. + pub editing_session: u64, +} + +/// Why the committed text around the IME caret cannot be described. +/// +/// These are the only reasons this custom editor reports +/// `egui::output::IMESurroundingText::Unavailable`; an empty text layer is available with empty +/// text and a collapsed selection. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImeSurroundingUnavailable { + /// No text layer is being edited. + NotEditing, + /// The editing target is not a text node any more. + NotText, + /// The selection alone is wider than [`MAX_IME_SURROUNDING_BYTES`], so no bounded snapshot + /// can contain both selection ends. + SelectionTooWide, + /// Defensive: the computed window was not a character-boundary slice. The rounding above + /// makes this unreachable for a well-formed text model. + InvalidWindow, +} + +/// One bounded, committed logical-text observation for the outgoing IME snapshot. +/// +/// `text` is never display/bidi order and never contains the pre-edit (which is UI-only in this +/// editor). `cursor` and `anchor` are UTF-8 byte offsets into `text`; `anchor > cursor` is a +/// valid reversed selection. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ImeSurrounding { + /// Identity of the edit this observation belongs to. + pub owner: ImeEditOwner, + /// At most [`MAX_IME_SURROUNDING_BYTES`] bytes of committed text around the selection. + pub text: String, + /// UTF-8 byte offset of the primary caret in [`Self::text`]. + pub cursor: usize, + /// UTF-8 byte offset of the selection anchor in [`Self::text`]. + pub anchor: usize, +} + +/// Every editing session gets its own token; zero is valid and never reused deliberately. +/// +/// A mutex (rather than an atomic) also works on wasm32 targets without 64-bit atomics. +static EDITING_SESSION: LazyLock> = LazyLock::new(|| Mutex::new(0)); + +/// Return the next editing-session token from a mutex-protected counter (`wrapping_add`). +/// Zero is a valid token; the mutex also works on wasm32 without 64-bit atomics. +fn next_editing_session() -> u64 { + let mut session = match EDITING_SESSION.lock() { + Ok(session) => session, + Err(poisoned) => poisoned.into_inner(), + }; + *session = session.wrapping_add(1); + *session +} + fn text_of(s: &Session, id: NodeId) -> Result { match &s.doc()?.get(id)?.kind { NodeKind::Text(t) => Ok(t.clone()), @@ -23,7 +99,7 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - Some(c) => (c.min(n), c.min(n)), None => (n, 0), }; - ds.text_edit = Some(TextEdit { id, caret, anchor, created, preedit: None }); + ds.text_edit = Some(TextEdit { id, caret, anchor, created, editing_session: next_editing_session(), preedit: None }); ds.selection = vec![id]; ds.revision += 1; s.live_text_notice(id); @@ -69,14 +145,33 @@ fn range(te: &TextEdit) -> (usize, usize) { (te.caret.min(te.anchor), te.caret.max(te.anchor)) } +/// Apply an unconditional text-model mutation through `edit_if`, whose success clears the +/// composition, clamps caret and anchor to the character count, marks a `characters` override +/// when the node is source-backed, normalizes the document and bumps the revision. fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result<()> { + edit_if(s, |t, te| { + f(t, te); + true + }) + .map(|_| ()) +} + +/// [`edit`] for a mutation that can still turn out to be a no-op. +/// +/// The closure returns whether it changed the model; return `false` only before mutating (the +/// text is handed over directly). A no-op leaves the composition (`preedit`), the revision and the +/// document untouched, so an IME deletion that finds nothing to delete does not cancel an +/// in-progress composition. +fn edit_if(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit) -> bool) -> Result { let ds = s.ds_mut()?; let Some(mut te) = ds.text_edit.clone() else { return Err(EngineError::Other("not editing text".into())) }; let before = ds.doc.clone(); let doc = ds.doc_mut(); let n = doc.get_mut(te.id)?; let NodeKind::Text(t) = &mut n.kind else { return Err(EngineError::Other("not a text layer".into())) }; - f(t, &mut te); + if !f(t, &mut te) { + return Ok(false); + } // Any edit ends a composition (a commit replaces it with the final text). te.preedit = None; let len = t.char_count(); @@ -88,16 +183,83 @@ fn edit(s: &mut Session, f: impl FnOnce(&mut TextData, &mut TextEdit)) -> Result crate::normalize(doc, &before); ds.text_edit = Some(te); ds.revision += 1; - Ok(()) + Ok(true) +} + +/// What an accepted IME `Commit` did to the committed model. +/// +/// An IME receipt may label the snapshot `InputMethod` only for [`Self::Plain`]. AutoFormat +/// (typing "- " at a line start turns the paragraph into a list) is an application +/// transformation in the same command, so the frame's change must be reported as `Other`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImeInsertion { + /// Nothing was inserted (an empty, control-only, or bare-newline commit). + Nothing, + /// The committed text replaced the selection exactly. + Plain, + /// The editor transformed the commit before applying it: paragraph AutoFormat, or line-ending + /// normalisation/control-character removal. The frame's change is application-origin. + Transformed, } /// Type (or paste) `text` replacing the selection. Line endings are normalised and other control /// characters dropped, so pasted plain text from any platform lands cleanly. pub fn insert(s: &mut Session, text: &str) -> Result<()> { - let text: String = crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect(); - if text.is_empty() { + insert_model(s, text, false).map(|_| ()) +} + +/// End the composition without changing the document, for an empty IME commit. +/// +/// Returns without touching the session when no composition is active (or nothing is being +/// edited): an empty commit is otherwise a no-op, never an error. +fn clear_composition(s: &mut Session) -> Result<()> { + let composing = s.ds().ok().and_then(|d| d.text_edit.as_ref()).is_some_and(|te| te.preedit.is_some()); + if !composing { return Ok(()); } + let ds = s.ds_mut()?; + if let Some(te) = ds.text_edit.as_mut() { + te.preedit = None; + } + ds.revision += 1; + Ok(()) +} + +/// Apply an IME `Commit`, reporting whether it was a plain insertion. +/// +/// The UI calls this instead of [`insert`] so only a commit that the editor applied as a plain +/// insertion can earn an `InputMethod` receipt. AutoFormat, line-ending normalisation and +/// control-character removal are application transformations and are reported as +/// [`ImeInsertion::Transformed`]. Like stock `TextEdit`, a bare `"\n"`/`"\r"` commit is ignored. +pub fn insert_ime_commit(s: &mut Session, text: &str) -> Result { + insert_model(s, text, true) +} + +/// Insert committed text, optionally as an IME commit: normalize line endings, drop control +/// characters except `\n`/`\t`, ignore a bare newline/CR IME commit, clear an active +/// composition for an empty or control-only IME commit, and turn a committed space after a +/// line-start `-`, `*` or `1.` into a bulleted/numbered list. Reports whether the insertion was +/// plain, transformed, or a no-op. +fn insert_model(s: &mut Session, text: &str, ime_commit: bool) -> Result { + // Stock `TextEdit` deliberately ignores these two commits (they are not a mutation); only a + // real commit may do so, since typing Enter goes through `insert`. + if ime_commit && matches!(text, "\n" | "\r") { + return Ok(ImeInsertion::Nothing); + } + let normalized: String = + crate::cmd::props::normalize_line_endings(text).chars().filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect(); + if normalized.is_empty() { + // An empty commit is how some integrations (Safari's egui web integration when the last + // composition character is deleted) end the composition. It inserts nothing, but it must + // still clear the pre-edit; stock `TextEdit` does the same in its non-ignored `Commit` arm. + if ime_commit { + clear_composition(s)?; + } + return Ok(ImeInsertion::Nothing); + } + // A commit the editor had to transform is an application edit, not a plain insertion. + let mut outcome = if normalized == text { ImeInsertion::Plain } else { ImeInsertion::Transformed }; + let text = normalized; edit(s, |t, te| { let (a, b) = range(te); // "- ", "* " or "1. " typed at the start of a plain paragraph starts a list. @@ -114,13 +276,15 @@ pub fn insert(s: &mut Session, text: &str) -> Result<()> { t.replace_range(start, a, ""); t.set_para_lists(start, start, |pl| pl.list = list); (te.caret, te.anchor) = (start, start); + outcome = ImeInsertion::Transformed; return; } } t.replace_range(a, b, &text); te.caret = a + text.chars().count(); te.anchor = te.caret; - }) + })?; + Ok(outcome) } /// List keys: Tab nests the selected list items a level deeper; Shift+Tab, Backspace at an item's @@ -345,6 +509,160 @@ fn select(s: &mut Session, anchor: usize, caret: usize) -> Result<()> { Ok(()) } +/// Delete the text surrounding the caret/selection that an IME reports with +/// [`egui::ImeEvent::DeleteSurroundingBytes`]: `before_bytes` UTF-8 bytes end where the selection +/// starts and `after_bytes` start where it ends. As in stock `TextEdit`, the selected text itself +/// is kept for the paired `Commit`; only whole characters covered by the byte counts beyond the +/// selection are removed (a character the range only partly covers stays). Returns whether +/// anything was deleted. +pub fn delete_surrounding_bytes(s: &mut Session, before_bytes: usize, after_bytes: usize) -> Result { + let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) }; + let t = text_of(s, te.id)?; + let (a, b) = range(&te); + let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes); + if before == 0 && after == 0 { + return Ok(false); + } + edit_if(s, |t, te| { + let (a, b) = range(te); + let (before, after) = surrounding_chars(&t.characters, a, b, before_bytes, after_bytes); + if before == 0 && after == 0 { + return false; + } + // Delete after the selection first so the before range's indices stay valid. + if after > 0 { + t.replace_range(b, b + after, ""); + } + if before > 0 { + t.replace_range(a - before, a, ""); + } + // Keep the selection: both ends move left by the deleted prefix, reversed or not. + te.caret = te.caret.saturating_sub(before); + te.anchor = te.anchor.saturating_sub(before); + true + }) +} + +/// Delete surrounding text counted in characters (the older `DeleteSurrounding` event). The +/// selection is kept, as in stock `TextEdit`; only text before and after it is removed. +pub fn delete_surrounding_chars(s: &mut Session, before_chars: usize, after_chars: usize) -> Result { + let Some(te) = s.ds()?.text_edit.clone() else { return Ok(false) }; + let t = text_of(s, te.id)?; + let (a, b) = range(&te); + let (_, b_byte) = char_byte_offsets(&t.characters, a, b); + let before = before_chars.min(a); + let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count()); + if before == 0 && after == 0 { + return Ok(false); + } + edit_if(s, |t, te| { + let (a, b) = range(te); + let (_, b_byte) = char_byte_offsets(&t.characters, a, b); + let before = before_chars.min(a); + let after = t.characters.get(b_byte..).map_or(0, |rest| rest.chars().take(after_chars).count()); + if before == 0 && after == 0 { + return false; + } + if after > 0 { + t.replace_range(b, b + after, ""); + } + if before > 0 { + t.replace_range(a - before, a, ""); + } + te.caret = te.caret.saturating_sub(before); + te.anchor = te.anchor.saturating_sub(before); + true + }) +} + +/// The UTF-8 byte offsets of character indices `a` and `b` in one forward pass. +/// +/// An index at or past the end maps to `text.len()`. Walking once (instead of twice) matters on +/// every IME event over a large text layer; the walk is still O(max(a, b)) characters, and callers +/// that need the end still pay O(n). See [`ime_surrounding`]. +fn char_byte_offsets(text: &str, a: usize, b: usize) -> (usize, usize) { + let (mut a_byte, mut b_byte) = (None, None); + for (i, (byte, _)) in text.char_indices().enumerate() { + if a_byte.is_none() && i == a { + a_byte = Some(byte); + } + if b_byte.is_none() && i == b { + b_byte = Some(byte); + } + if a_byte.is_some() && b_byte.is_some() { + break; + } + } + (a_byte.unwrap_or(text.len()), b_byte.unwrap_or(text.len())) +} + +/// `(before, after)` characters covered by the IME's byte counts around the selection `[a, b)` +/// (character indices): mirror of egui's own `surrounding_bytes_to_chars`. +fn surrounding_chars(text: &str, a: usize, b: usize, before_bytes: usize, after_bytes: usize) -> (usize, usize) { + let (a_byte, b_byte) = char_byte_offsets(text, a, b); + let from = a_byte.saturating_sub(before_bytes); + let before = text[..a_byte].char_indices().rev().take_while(|(i, _)| *i >= from).count(); + let after = text[b_byte..].char_indices().take_while(|(i, c)| i + c.len_utf8() <= after_bytes).count(); + (before, after) +} + +/// The committed text around the IME caret for the outgoing surrounding-text snapshot. +/// +/// This is the edited node's logical model text (never the bidi display order), with the +/// pre-edit excluded because a composition is UI-only in this editor until it is committed. +/// Character caret/anchor indices become independent UTF-8 byte offsets, so a reversed +/// selection stays reversed. At most [`MAX_IME_SURROUNDING_BYTES`] bytes are copied: the window +/// is centred on the selection and rounded inward to character boundaries. An empty text layer +/// is `Ok` with `text == ""` and both offsets zero; the result is [`ImeSurroundingUnavailable`] +/// only when there is no text edit, the target is not a text node, or the selection alone is +/// wider than the budget. +/// +/// Cost: one character walk up to the later of the two cursor indices (to turn char indices into +/// UTF-8 bytes) plus the bounded copy. The caret is a character index, so reaching a caret near +/// the end of a very large layer is still O(n); the UI calls this once per pass and twice around +/// each text-affecting event (`ime_stamp`), so such a layer pays O(caret/anchor) per event. The +/// document is never cloned here. +pub fn ime_surrounding(s: &Session) -> std::result::Result { + let ds = s.ds().map_err(|_| ImeSurroundingUnavailable::NotEditing)?; + let te = ds.text_edit.as_ref().ok_or(ImeSurroundingUnavailable::NotEditing)?; + let node = ds.doc.node(te.id).ok_or(ImeSurroundingUnavailable::NotText)?; + let NodeKind::Text(t) = &node.kind else { return Err(ImeSurroundingUnavailable::NotText) }; + let text = t.characters.as_str(); + // One pass for both offsets (the caret and anchor are already clamped by `edit`/`select`). + let (cursor, anchor) = char_byte_offsets(text, te.caret, te.anchor); + let (selection_start, selection_end) = (cursor.min(anchor), cursor.max(anchor)); + let selection_len = selection_end - selection_start; + if selection_len > MAX_IME_SURROUNDING_BYTES { + return Err(ImeSurroundingUnavailable::SelectionTooWide); + } + // Spend the budget on both sides, giving room unused on one side to the other. + let budget = MAX_IME_SURROUNDING_BYTES - selection_len; + let before_available = selection_start; + let after_available = text.len() - selection_end; + let mut before = (budget / 2).min(before_available); + let mut after = (budget - before).min(after_available); + before = (budget - after).min(before_available); + after = (budget - before).min(after_available); + let mut window_start = selection_start - before; + let mut window_end = selection_end + after; + // Round inward so the window stays within the byte budget and on character boundaries. + while window_start < window_end && !text.is_char_boundary(window_start) { + window_start += 1; + } + while window_end > window_start && !text.is_char_boundary(window_end) { + window_end -= 1; + } + let Some(window) = text.get(window_start..window_end) else { + return Err(ImeSurroundingUnavailable::InvalidWindow); + }; + Ok(ImeSurrounding { + owner: ImeEditOwner { document: ds.uid, node: te.id, editing_session: te.editing_session }, + text: window.to_owned(), + cursor: cursor.saturating_sub(window_start), + anchor: anchor.saturating_sub(window_start), + }) +} + /// Show an IME composition inline at the caret (`cursor` = the IME's cursor inside it, in chars; /// default its end). The document doesn't change until the composition is committed with /// [`insert`]; an empty `text` cancels it. Starting to compose over a selection deletes it, as @@ -596,6 +914,9 @@ mod tests { e } + /// Asserts double-clicking a text layer selects all text, a second double-click selects the + /// word without changing the editing session, and drag and Shift+click extend the + /// selection. #[test] fn pointer_double_click_drag_and_shift_click() { let (mut s, id) = setup("Hello world"); @@ -612,10 +933,16 @@ mod tests { s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); assert_eq!(sel(&s), (0, 11)); + let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session; s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); assert_eq!(sel(&s), (6, 11)); + assert_eq!( + s.ds().unwrap().text_edit.as_ref().unwrap().editing_session, + epoch, + "a double-click inside the edit is an ordinary edit: same IME replacement epoch" + ); // Drag-select from the start of "Hello" to the middle. s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap(); assert_eq!(sel(&s), (0, 0)); @@ -827,4 +1154,203 @@ mod tests { } } } + + /// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed + /// selection reversed. + #[test] + fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() { + let (mut s, id) = setup("café😀e\u{301}x"); + begin(&mut s, id, Some(8), false).unwrap(); + set_caret(&mut s, 8, false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text, "café😀e\u{301}x"); + assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes"); + assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed"); + assert_eq!(ime.owner.node, id); + assert_eq!(ime.owner.document, s.ds().unwrap().uid); + } + + /// Asserts an active composition is excluded from surrounding text and an empty text layer + /// reports an available, empty payload. + #[test] + fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + preedit(&mut s, "にほ", Some(1)).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text, "Hi", "the composition is not committed text"); + assert_eq!((ime.cursor, ime.anchor), (2, 2)); + end(&mut s).unwrap(); + + let (mut s, id) = setup(""); + begin(&mut s, id, Some(0), false).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable"); + } + + /// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized + /// selection is describable, and one byte more is `SelectionTooWide`. + #[test] + fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() { + let long = "a".repeat(5000); + let (mut s, id) = setup(&long); + begin(&mut s, id, Some(5000), false).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES); + assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES)); + + // Exactly the budget is still describable; one more byte is not. + set_caret(&mut s, 1000, false).unwrap(); + set_caret(&mut s, 5000, true).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text.len(), 4000); + assert_eq!((ime.cursor, ime.anchor), (4000, 0)); + set_caret(&mut s, 999, false).unwrap(); + set_caret(&mut s, 5000, true).unwrap(); + assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide)); + } + + /// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new + /// edit session changes the generation. + #[test] + fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() { + let (mut s, id) = setup("Hello"); + begin(&mut s, id, Some(0), false).unwrap(); + let first = ime_surrounding(&s).unwrap().owner; + insert(&mut s, "x").unwrap(); + set_caret(&mut s, 2, false).unwrap(); + assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner"); + end(&mut s).unwrap(); + begin(&mut s, id, Some(0), false).unwrap(); + let replaced = ime_surrounding(&s).unwrap().owner; + assert_eq!(replaced.node, id); + assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch"); + } + + /// Asserts byte deletion around a reversed selection removes whole characters on both + /// sides, keeps the selected text and direction, and a final no-op leaves the text alone. + #[test] + fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() { + let (mut s, id) = setup("ab你好cd"); + begin(&mut s, id, Some(4), false).unwrap(); + set_caret(&mut s, 4, false).unwrap(); + set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好 + assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); + // "b" (whole character before the selection) and "c" (whole character after it) go; the + // selected "你好" stays for the paired commit, and the reversed direction is preserved. + assert_eq!(data(&s, id).characters, "a你好d"); + assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed + assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); + assert_eq!(data(&s, id).characters, "你好"); + assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed + assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection"); + assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text"); + } + + /// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an + /// active composition and the revision untouched. + #[test] + fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() { + let (mut s, id) = setup("ab你好cd"); + begin(&mut s, id, Some(2), false).unwrap(); + set_caret(&mut s, 4, true).unwrap(); // "你好" + assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap()); + assert_eq!(data(&s, id).characters, "a你好d"); + assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix"); + set_caret(&mut s, 0, false).unwrap(); + // A composition in progress survives a delete that finds nothing to remove. + preedit(&mut s, "x", None).unwrap(); + let revision = s.ds().unwrap().revision; + assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap()); + assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); + assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision"); + assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap()); + assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); + assert_eq!(s.ds().unwrap().revision, revision); + } + + /// Asserts a text layer in another document has a different IME owner. + #[test] + fn ime_owner_identity_changes_with_the_document() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + let first = ime_surrounding(&s).unwrap().owner; + end(&mut s).unwrap(); + s.execute("file.new", &json!({"name": "Second"})).unwrap(); + let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap(); + let other = crate::params::id_of(&r["id"]).unwrap(); + begin(&mut s, other, Some(5), false).unwrap(); + let second = ime_surrounding(&s).unwrap().owner; + assert_ne!(second.document, first.document, "another document is another IME owner"); + assert_eq!(second.node, other); + } + + /// Asserts a space committed after a line-start `-` becomes a bulleted list and reports + /// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`. + #[test] + fn ime_commit_reports_list_autoformat_as_an_application_transformation() { + let (mut s, id) = setup("-"); + begin(&mut s, id, Some(1), false).unwrap(); + assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed); + assert_eq!(data(&s, id).characters, ""); + assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted); + assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain); + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + } + + /// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`, + /// and bare newline/CR commits are ignored while a live composition remains. + #[test] + fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + // CRLF is inserted as LF, but the editor changed the commit: application-origin. + assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed); + assert_eq!(data(&s, id).characters, "Hia\nb"); + // Control characters are dropped; the commit itself is a no-op. + assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); + assert_eq!(data(&s, id).characters, "Hia\nb"); + // Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one. + // (It also leaves a live composition alone, unlike the non-ignored commit below.) + preedit(&mut s, "x", None).unwrap(); + assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing); + assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing); + assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into())); + preedit(&mut s, "", None).unwrap(); + assert_eq!(data(&s, id).characters, "Hia\nb"); + insert(&mut s, "\n").unwrap(); + assert_eq!(data(&s, id).characters, "Hia\nb\n"); + } + + /// Asserts an empty or control-only IME commit ends a live composition (bumping the revision + /// once), inserts nothing, and is a harmless no-op when no composition is active. + #[test] + fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { + let (mut s, id) = setup("Hello"); + begin(&mut s, id, Some(5), false).unwrap(); + preedit(&mut s, "にほ", None).unwrap(); + assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into())); + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision"); + assert!(preedit_state(&s).is_none(), "an empty commit ends the composition"); + assert!(preedit_doc(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing"); + + // A control-only commit normalises to empty and ends the composition too. + preedit(&mut s, "x", None).unwrap(); + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision"); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello"); + + // With no composition (and not even while editing) an empty commit stays a harmless no-op. + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision); + end(&mut s).unwrap(); + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + } } diff --git a/crates/engine/src/textfit_tests.rs b/crates/engine/src/textfit_tests.rs index d9ffd5fd..e35f810c 100644 --- a/crates/engine/src/textfit_tests.rs +++ b/crates/engine/src/textfit_tests.rs @@ -8,6 +8,7 @@ use serde_json::json; /// A fixed-width (auto-height) headline of `width` px at 64px. Returns its id. fn headline(s: &mut Session, text: &str, width: f64) -> NodeId { + figcraft_text::scan_system_fonts(); let t = idv(&ex(s, "text.create", json!({"text": text, "x": 0, "y": 0, "props": {"name": "Headline", "fontSize": 64}}))); ex(s, "node.set", json!({"id": t, "props": {"width": width, "textAutoResize": "height"}})); t @@ -63,8 +64,11 @@ fn a_one_word_last_line_is_avoided() { assert!(l.lines.len() == 1 || words.split_whitespace().count() > 1 || words.contains('\u{a0}'), "{words:?}"); } +/// Asserts `data.fill` keeps a short copy's font size, shrinks a long copy, and leaves the +/// source text layer untouched. #[test] fn data_fill_sizes_each_copys_text() { + figcraft_text::scan_system_fonts(); let mut s = Session::new(); let f = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 1080, "height": 1350, "name": "Post"}))); let t = idv(&ex(&mut s, "text.create", json!({"text": "Curto", "x": 80, "y": 80, "parent": f, "props": {"name": "Headline", "fontSize": 96}}))); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index cc52caec..b6a6d101 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -2020,6 +2020,10 @@ impl Session { Ok(()) } + /// Handle a double-click gesture: finish a pen path, rename or click labels/widgets, + /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text + /// editing (select-all on entry, word-select inside the running edit), enter vector editing + /// for selected shapes, and step groups/frames one level deeper. fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { let ds = self.ds()?; let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); @@ -2069,13 +2073,15 @@ impl Session { && doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) { - // Entering a text selects all of it; in the text being edited, the word. + // Entering a text selects all of it; in the text being edited, the word. A + // double-click inside the running edit is an ordinary edit: starting a new session + // here would replace the IME owner's epoch and drop a composition on a plain gesture. let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target); let idx = self.text_index_at(target, ev.p())?; - textedit::begin(self, target, Some(idx), false)?; if editing { textedit::select_word(self, idx)?; } else { + textedit::begin(self, target, Some(idx), false)?; textedit::key(self, "SelectAll", false, false, false)?; } return Ok(()); diff --git a/crates/text/build.rs b/crates/text/build.rs index 05dfc653..ba087983 100644 --- a/crates/text/build.rs +++ b/crates/text/build.rs @@ -1,32 +1,26 @@ //! Embeds the optional craft-fonts build input (`CRAFT_FONTS_DIR`): both `fonts/manifest.txt` and the //! opt-in Latin presentation fonts in `fonts/latin-manifest.txt` (Inter, Roboto, …). Unset or missing, -//! `CRAFT_FONTS` is empty and the app uses system fonts. Recipe: craft-fonts `docs/integration.md`. +//! `CRAFT_FONTS` is empty and native apps use system fonts. Required web releases enforce Inter +//! Regular/SemiBold and Noto Sans Georgian Regular after filtering. Recipe: craft-fonts `docs/integration.md`. //! Noto Sans CJK only with `FIGCRAFT_EMBED_CJK` set (the web build's only way to show CJK UI text). use std::fmt::Write as _; use std::path::PathBuf; +/// Font build script: emit `craft_fonts.rs` from `input_fonts`, passing the +/// `CRAFT_FONTS_DIR`, `CRAFT_FONTS_REQUIRED`, wasm target and `FIGCRAFT_EMBED_CJK` inputs; +/// report generation and write failures with `cargo::error`. fn main() { println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR"); println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED"); println!("cargo::rerun-if-env-changed=FIGCRAFT_EMBED_CJK"); let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n"); - if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) { - let mut ok = false; - for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] { - match craft_fonts(&dir, manifest) { - Ok(entries) => { - ok = true; - src.push_str(&entries); - } - Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => { - println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display()); - } - Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()), - } - } - if !ok { - println!("cargo::warning=building without craft-fonts (once it is there, `cargo clean -p figcraft-text` makes the next build use it)"); - } + let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from); + let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some(); + let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); + let embed_cjk = std::env::var_os("FIGCRAFT_EMBED_CJK").is_some(); + match input_fonts(dir.as_deref(), wasm, required, embed_cjk) { + Ok(entries) => src.push_str(&entries), + Err(e) => println!("cargo::error={e}"), } src.push_str("];\n"); let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs"); @@ -35,13 +29,45 @@ fn main() { } } +/// Collect the effective catalog and enforce the required web release faces after filtering. +fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool, embed_cjk: bool) -> Result { + let Some(dir) = dir else { + return if required { Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()) } else { Ok(String::new()) }; + }; + let mut out = String::new(); + let mut faces = Vec::new(); + for manifest in ["fonts/latin-manifest.txt", "fonts/manifest.txt"] { + let path = dir.join(manifest); + if manifest == "fonts/latin-manifest.txt" && !path.exists() { + watch(&path); + continue; + } + match craft_fonts(dir, manifest, wasm, embed_cjk) { + Ok(catalog) => { + out.push_str(&catalog.entries); + faces.extend(catalog.faces); + } + Err(e) if required => return Err(format!("CRAFT_FONTS_DIR={}: {e}", dir.display())), + Err(e) => println!("cargo::warning=craft-fonts {manifest} skipped: CRAFT_FONTS_DIR={}: {e}", dir.display()), + } + } + if required && wasm { + for (family, style) in [("Inter", "Regular"), ("Inter", "SemiBold"), ("Noto Sans Georgian", "Regular")] { + if !faces.iter().any(|(f, s)| f == family && s == style) { + return Err(format!("required web font missing: {family} {style}; CRAFT_FONTS_DIR must include both Latin and core release faces")); + } + } + } + Ok(out) +} + /// Rebuild when `manifest` changes or, while it is missing, when it is added to its folder. /// /// Cargo takes a watched path that doesn't exist as changed on every build, and then rebuilds this /// crate and every crate that depends on it each time. So a missing manifest is never watched /// itself: its folder is, when there is one (a craft-fonts checkout without the Latin manifest). /// With no craft-fonts at all there is nothing to watch, so the build can't notice it arriving: -/// the warning in `main` says how to pick it up. +/// rebuild this crate after adding a previously absent checkout. fn watch(manifest: &std::path::Path) { let watched = if manifest.exists() { Some(manifest) } else { manifest.parent().filter(|folder| folder.is_dir()) }; if let Some(path) = watched { @@ -49,20 +75,33 @@ fn watch(manifest: &std::path::Path) { } } -fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result { +struct Catalog { + entries: String, + faces: Vec<(String, String)>, +} + +/// Parse a font manifest of `family | style | file | scripts` rows into `CraftFont` +/// entries: skip Adobe-authored families, skip CJK unless requested, keep the web catalog +/// to its budgeted families, canonicalize and watch each face, and emit `include_bytes!` +/// items. +fn craft_fonts(dir: &std::path::Path, manifest_rel: &str, wasm: bool, embed_cjk: bool) -> Result { let manifest = dir.join(manifest_rel); watch(&manifest); let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?; - let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32"); let mut out = String::new(); + let mut faces = Vec::new(); for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { return Err(format!("malformed manifest line: {line}")); }; + // FigCraft's asset policy excludes Adobe-authored visual assets on every target. + if matches!(*family, "Source Sans 3" | "Source Serif 4") { + continue; + } // CJK fonts are large; the desktop app finds system CJK fonts instead unless asked. let cjk = scripts.contains("Hans"); - if cjk && std::env::var_os("FIGCRAFT_EMBED_CJK").is_none() { + if cjk && !embed_cjk { continue; } // Web builds have a size budget: Inter, Noto Sans Georgian (small, for the Georgian UI), only the @@ -73,7 +112,109 @@ fn craft_fonts(dir: &std::path::Path, manifest_rel: &str) -> Result Self { + let dir = std::env::temp_dir().join(format!("fig-font-input-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(dir.join("fonts")).unwrap(); + // The build script validates manifest/file presence; font bytes are checked centrally. + std::fs::write(dir.join("fonts/face.ttf"), b"synthetic build-input fixture").unwrap(); + let fixture = Self(dir); + fixture.manifest("manifest.txt", &[("Noto Sans Georgian", "Regular")]); + fixture + } + + /// Test fixture: write manifest rows that all point at the fixture's `fonts/face.ttf` face. + fn manifest(&self, name: &str, faces: &[(&str, &str)]) { + let text = faces.iter().map(|(family, style)| format!("{family} | {style} | fonts/face.ttf | Latn\n")).collect::(); + std::fs::write(self.0.join("fonts").join(name), text).unwrap(); + } + } + + impl Drop for Fixture { + /// Remove the fixture's temporary directory, ignoring errors. + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// Asserts the native Latin catalog keeps its 43 permitted presentation faces, the web + /// catalog keeps only the four Inter faces, Adobe-authored families are absent, and + /// permitted native faces remain. + #[test] + fn restored_latin_catalog_preserves_permitted_native_faces_and_web_budget() { + let f = Fixture::new(); + std::fs::write(f.0.join("fonts/latin-manifest.txt"), include_str!("tests/fixtures/latin-families.txt")).unwrap(); + let native = craft_fonts(&f.0, "fonts/latin-manifest.txt", false, false).unwrap(); + assert_eq!(native.faces.len(), 43, "all prior permitted presentation faces remain available"); + let web = craft_fonts(&f.0, "fonts/latin-manifest.txt", true, false).unwrap(); + assert_eq!(web.faces.len(), 4, "the web catalog keeps only the four Inter faces"); + for catalog in [&native, &web] { + assert!(!catalog.entries.contains("Source Sans 3")); + assert!(!catalog.entries.contains("Source Serif 4")); + } + assert!(native.faces.iter().any(|(family, _)| family == "Carlito")); + assert!(native.faces.iter().any(|(family, _)| family == "Liberation Serif")); + assert!(input_fonts(Some(&f.0), true, true, false).is_ok()); + } + + /// Asserts required web input rejects a catalog missing Inter Regular or Inter SemiBold. + #[test] + fn required_web_rejects_core_only_and_partial_latin_catalogs() { + let f = Fixture::new(); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter Regular")); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Unrelated", "SemiBold")]); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Inter SemiBold")); + } + + /// Asserts required web input embeds the Inter and Georgian catalogs (three entries) and + /// rejects a manifest without Noto Sans Georgian Regular. + #[test] + fn required_web_embeds_both_catalogs_and_requires_georgian() { + let f = Fixture::new(); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]); + let entries = input_fonts(Some(&f.0), true, true, false).unwrap(); + assert_eq!(entries.matches("CraftFont {").count(), 3); + assert!(entries.contains("family: \"Inter\"")); + assert!(entries.contains("family: \"Noto Sans Georgian\"")); + f.manifest("manifest.txt", &[("Other", "Regular")]); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("Noto Sans Georgian Regular")); + } + + /// Asserts absent font input is empty when optional and an error when required, while present + /// input includes Noto Sans Georgian. + #[test] + fn optional_and_native_inputs_keep_their_existing_policy() { + let f = Fixture::new(); + assert!(input_fonts(None, true, false, false).unwrap().is_empty()); + assert!(input_fonts(None, true, true, false).unwrap_err().contains("CRAFT_FONTS_DIR is missing")); + assert!(input_fonts(Some(&f.0), true, false, false).unwrap().contains("Noto Sans Georgian")); + assert!(input_fonts(Some(&f.0), false, true, false).unwrap().contains("Noto Sans Georgian")); + } + + /// Asserts required web input rejects a manifest whose face file is missing, even when every + /// face row is listed. + #[test] + fn required_web_rejects_missing_asset_even_with_all_face_rows() { + let f = Fixture::new(); + f.manifest("latin-manifest.txt", &[("Inter", "Regular"), ("Inter", "SemiBold")]); + std::fs::remove_file(f.0.join("fonts/face.ttf")).unwrap(); + assert!(input_fonts(Some(&f.0), true, true, false).unwrap_err().contains("fonts/face.ttf")); + } } diff --git a/crates/text/tests/build_input.rs b/crates/text/tests/build_input.rs new file mode 100644 index 00000000..6f87ac1f --- /dev/null +++ b/crates/text/tests/build_input.rs @@ -0,0 +1,4 @@ +//! Exercise the actual build-script catalog policy without changing process environment. +#[allow(dead_code)] +#[path = "../build.rs"] +mod build_script; diff --git a/crates/text/tests/fixtures/latin-families.txt b/crates/text/tests/fixtures/latin-families.txt new file mode 100644 index 00000000..e9db6f41 --- /dev/null +++ b/crates/text/tests/fixtures/latin-families.txt @@ -0,0 +1,52 @@ +# Synthetic paths; family/style inventory from craft-fonts 76ba5a0496503c0e2841abd626295038cbe07cde. +Inter | Regular | fonts/face.ttf | Latn +Inter | Italic | fonts/face.ttf | Latn +Inter | SemiBold | fonts/face.ttf | Latn +Inter | Bold | fonts/face.ttf | Latn +Carlito | Regular | fonts/face.ttf | Latn +Carlito | Bold | fonts/face.ttf | Latn +Carlito | Italic | fonts/face.ttf | Latn +Carlito | Bold Italic | fonts/face.ttf | Latn +Caladea | Regular | fonts/face.ttf | Latn +Caladea | Bold | fonts/face.ttf | Latn +Caladea | Italic | fonts/face.ttf | Latn +Caladea | Bold Italic | fonts/face.ttf | Latn +Liberation Sans | Regular | fonts/face.ttf | Latn +Liberation Sans | Bold | fonts/face.ttf | Latn +Liberation Sans | Italic | fonts/face.ttf | Latn +Liberation Sans | Bold Italic | fonts/face.ttf | Latn +Liberation Serif | Regular | fonts/face.ttf | Latn +Liberation Serif | Bold | fonts/face.ttf | Latn +Liberation Serif | Italic | fonts/face.ttf | Latn +Liberation Serif | Bold Italic | fonts/face.ttf | Latn +Liberation Mono | Regular | fonts/face.ttf | Latn +Liberation Mono | Bold | fonts/face.ttf | Latn +Liberation Mono | Italic | fonts/face.ttf | Latn +Liberation Mono | Bold Italic | fonts/face.ttf | Latn +Source Sans 3 | Regular | fonts/face.ttf | Latn +Source Sans 3 | Italic | fonts/face.ttf | Latn +Source Sans 3 | Semibold | fonts/face.ttf | Latn +Source Sans 3 | Bold | fonts/face.ttf | Latn +Source Serif 4 | Regular | fonts/face.ttf | Latn +Source Serif 4 | Italic | fonts/face.ttf | Latn +Source Serif 4 | Semibold | fonts/face.ttf | Latn +Source Serif 4 | Bold | fonts/face.ttf | Latn +Montserrat | Variable | fonts/face.ttf | Latn +Montserrat | Italic Variable | fonts/face.ttf | Latn +Lato | Regular | fonts/face.ttf | Latn +Lato | Bold | fonts/face.ttf | Latn +Lato | Italic | fonts/face.ttf | Latn +Open Sans | Variable | fonts/face.ttf | Latn +Open Sans | Italic Variable | fonts/face.ttf | Latn +Roboto | Variable | fonts/face.ttf | Latn +Roboto | Italic Variable | fonts/face.ttf | Latn +Merriweather | Regular | fonts/face.ttf | Latn +Merriweather | Bold | fonts/face.ttf | Latn +Merriweather | Italic | fonts/face.ttf | Latn +Playfair Display | Variable | fonts/face.ttf | Latn +Playfair Display | Italic Variable | fonts/face.ttf | Latn +Poppins | Regular | fonts/face.ttf | Latn +Poppins | Bold | fonts/face.ttf | Latn +Poppins | Italic | fonts/face.ttf | Latn +Nunito Sans | Variable | fonts/face.ttf | Latn +Nunito Sans | Italic Variable | fonts/face.ttf | Latn diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 09cd2226..d72b1416 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,7 +18,7 @@ figcraft-render = { workspace = true } figcraft-pathops = { workspace = true } figcraft-engine = { workspace = true } figcraft-format = { workspace = true } -egui = { workspace = true } +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } @@ -34,3 +34,6 @@ toml = "1" [dev-dependencies] wat = { workspace = true } +# Keep the test font features explicit; production uses the same bundled fallback faces. +# Dependency dev-features do not propagate into app release builds. +egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 5fc098a4..3c5fafb6 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -239,6 +239,10 @@ pub fn scroll_focus(nav: &mut Nav, ui: &Ui) { #[derive(Clone, Default)] struct Shared { canvas: Option, + /// The root `Ui::scope_id` of the frame. A resizable panel's real `__resize` widget is + /// `root_scope.with().with("__resize")`; FigCraft's panels are fixed, so its own + /// `panel_edge` and `pages_edge` IDs are the ones to exclude today. + root_scope: Option, keyboard: bool, /// The focused widget showed its focus itself this frame (no ring needed). ring: Option, @@ -249,8 +253,10 @@ struct Shared { name: String, } +/// Run `f` on the per-context temporary `Shared` accessibility state keyed by +/// `figcraft_a11y`. fn shared(ctx: &Context, f: impl FnOnce(&mut Shared) -> R) -> R { - ctx.data_mut(|d| f(d.get_temp_mut_or_default::(Id::new("figcraft_a11y")))) + ctx.data_mut(|d| f(d.get_temp_mut_or_default::(Id::unique("figcraft_a11y")))) } /// Whether a widget other than the canvas has the keyboard (a field, a focused button): the @@ -284,8 +290,10 @@ pub fn keyboard(ctx: &Context) -> bool { /// Before the panels draw: pointer presses, the focused control's region, and the navigation /// keys (F6, Tab, arrows, Escape), which are taken out of the input when they move the keyboard. -pub fn begin(app: &mut FigApp, ctx: &Context) { +pub fn begin(app: &mut FigApp, ctx: &Context, root_scope: Id) { app.ui.nav.last = std::mem::take(&mut app.ui.nav.next); + // Before the keys: a panel edge derived from the root scope must already be excluded. + shared(ctx, |s| s.root_scope = Some(root_scope)); keys(app, ctx); let keyboard = app.ui.nav.keyboard; shared(ctx, |s| { @@ -646,7 +654,13 @@ fn step_within(app: &mut FigApp, ctx: &Context, region: Region, step: i32) { /// them, are left out). fn focusables(ctx: &Context, nav: &Nav, region: Region) -> Vec<(Id, Rect)> { let canvas = nav.last.canvas; - let edges = [Id::new("left_panel").with("__resize"), Id::new("right_panel").with("__resize")]; + // Both the real resize widgets (should a panel ever become resizable) and FigCraft's own + // custom panel and pages-list edges, which must never enter the F6/Tab order. + let mut edges = vec![Id::unique(("panel_edge", "left")), Id::unique(("panel_edge", "right")), Id::unique("pages_edge")]; + if let Some(root) = shared(ctx, |s| s.root_scope) { + edges.push(root.with("left_panel").with("__resize")); + edges.push(root.with("right_panel").with("__resize")); + } ctx.viewport(|v| { v.prev_pass .widgets diff --git a/crates/ui-egui/src/a11y_tests.rs b/crates/ui-egui/src/a11y_tests.rs index d041a5b3..14be637d 100644 --- a/crates/ui-egui/src/a11y_tests.rs +++ b/crates/ui-egui/src/a11y_tests.rs @@ -403,3 +403,31 @@ fn the_align_buttons_are_off_for_a_layer_alone_on_the_page() { u.app.run("select.set", json!({"ids": [inner]})); assert!(!align_left(&tree(&mut u)), "one in a frame aligns to it"); } + +/// Asserts side-panel and Pages edges are not focusable, and Tab never stops on the +/// side-panel edges. +#[test] +fn the_custom_panel_edges_never_enter_the_keyboard_or_focus_order() { + // FigCraft's panels are fixed, so egui makes no `panel.__resize` widgets; the draggable edges + // are `Id::unique(("panel_edge", ...))`. They sense the pointer but must not be focusable, or + // Tab/F6 would stop on an unnamed control. + let (mut u, _, _) = setup(); + let (left, right) = (egui::Id::unique(("panel_edge", "left")), egui::Id::unique(("panel_edge", "right"))); + // The Pages list's edge has the same contract as the side panels' edges. + let pages = egui::Id::unique("pages_edge"); + for f in [left, right, pages] { + let focusable = u.ctx.viewport(|v| v.prev_pass.widgets.get(f).map(|w| w.sense.is_focusable())); + assert_eq!(focusable, Some(false), "the panel/pages edge still senses clicks/drags but is not focusable"); + } + let mut seen = Vec::new(); + for back in [false, true] { + f6(&mut u, back); + for _ in 0..30 { + press(&mut u, Key::Tab, Modifiers::NONE); + if let Some(id) = u.ctx.memory(|m| m.focused()) { + seen.push(id); + } + } + } + assert!(!seen.contains(&left) && !seen.contains(&right), "Tab never stops on a panel edge: {seen:?}"); +} diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 3f1fcc77..db0cc0f7 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -164,8 +164,9 @@ enum Act { Hide(NodeId, bool), } +/// Stable egui id for the assets drag payload. fn drag_id() -> Id { - Id::new("asset_drag") + Id::unique("asset_drag") } /// Whether component `id` has the same layers in two versions of a document (layers an edit @@ -276,7 +277,7 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) { return; } let paths: Vec = doc.libraries.enabled.iter().map(|l| l.path.clone()).collect(); - let id = Id::new("asset_libraries_checked"); + let id = Id::unique("asset_libraries_checked"); let now = ui.input(|i| i.time); let last: Option = ui.data(|d| d.get_temp(id)); if last.is_none_or(|l| now - l >= LIBRARY_REFRESH) { @@ -285,7 +286,7 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) { } ui.data_mut(|d| d.insert_temp(id, now)); // Team libraries: in the background, at once for one not fetched yet. - let sid = Id::new("asset_server_libraries_checked"); + let sid = Id::unique("asset_server_libraries_checked"); let fetched: Option = ui.data(|d| d.get_temp(sid)); let unread = paths.iter().any(|p| is_server_link(p) && app.session.libraries.cached(p).is_none()); if paths.iter().any(|p| is_server_link(p)) && (unread || fetched.is_none_or(|l| now - l >= SERVER_REFRESH)) { @@ -299,7 +300,7 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) { /// The search field with the libraries button; returns the lowercase query. fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { let t = Tokens::get(ui.ctx()); - let filter_id = Id::new("asset_filter"); + let filter_id = Id::unique("asset_filter"); let mut filter: String = ui.data(|d| d.get_temp(filter_id)).unwrap_or_default(); let (fr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let field = Rect::from_min_max(pos2(fr.min.x + 12.0, fr.min.y + 8.0), pos2(fr.max.x - 44.0, fr.max.y - 4.0)); @@ -309,7 +310,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(te_rect)); let r = child.add( egui::TextEdit::singleline(&mut filter) - .id(Id::new("asset_filter_field")) + .id(Id::unique("asset_filter_field")) .frame(egui::Frame::NONE) .font(regular(11.0)) .hint_text(tr("Search assets")) @@ -324,7 +325,7 @@ fn search_row(app: &mut FigApp, ui: &mut Ui, w: f32) -> String { } ui.data_mut(|d| d.insert_temp(filter_id, filter.clone())); let br = Rect::from_center_size(pos2(fr.max.x - 26.0, field.center().y), vec2(24.0, 24.0)); - let bresp = ui.interact(br, Id::new("asset_libraries_btn"), Sense::click()).tip(tr("Libraries")); + let bresp = ui.interact(br, Id::unique("asset_libraries_btn"), Sense::click()).tip(tr("Libraries")); if bresp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), t.hover); } @@ -342,7 +343,7 @@ fn updates_banner(app: &mut FigApp, ui: &mut Ui, w: f32, count: usize) { let r = Rect::from_min_max(pos2(row.min.x + 12.0, row.min.y + 4.0), pos2(row.max.x - 12.0, row.max.y - 4.0)); ui.painter().rect_filled(r, CornerRadius::same(6), t.selected_row); let what = trn("{n} components and styles have newer versions", count, &[]); - ui.interact(r, Id::new("asset_updates"), Sense::hover()).on_hover_text(what); + ui.interact(r, Id::unique("asset_updates"), Sense::hover()).on_hover_text(what); ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, tr("Library updates available"), semibold(11.0), t.text); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(r.max.x - 70.0, r.min.y + 6.0), pos2(r.max.x - 6.0, r.max.y)))); @@ -438,7 +439,7 @@ fn tiles(ui: &mut Ui, w: f32, src: &Source, list: &[Tile], thumbs: &Thumbs, want let r = Rect::from_min_size(pos2(x, y), vec2(tile, tile)); let resp = ui.interact( r.union(Rect::from_min_size(r.left_bottom(), vec2(tile, TILE_LABEL))), - Id::new(("asset", src, item.id.0)), + Id::unique(("asset", src, item.id.0)), Sense::click_and_drag(), ); ui.painter().rect_filled(r, CornerRadius::same(6), t.field); @@ -581,6 +582,10 @@ fn render_thumbs(ui: &Ui, w: f32, thumbs: &mut Thumbs, wanted: &[(Source, NodeId } } +/// Draw the Assets panel: refresh libraries, switch to the libraries view when asked, +/// otherwise cache a listing keyed by revision, query and library state, render local and +/// enabled-library assets with reused thumbnails, and dispatch place, style, hide, toggle +/// and drag actions. pub fn show(app: &mut FigApp, ui: &mut Ui) { refresh_libraries(app, ui); if app.ui.libraries { @@ -593,7 +598,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { let revision = ds.revision; let q = search_row(app, ui, w); let key = (revision, q.clone(), libraries_key(app, &doc)); - let listing_id = Id::new("asset_listing"); + let listing_id = Id::unique("asset_listing"); let listing = match ui.data(|d| d.get_temp::>(listing_id)) { Some(l) if l.key == key => l, _ => { @@ -605,12 +610,12 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { if listing.updates > 0 { updates_banner(app, ui, w, listing.updates); } - let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::new("asset_thumbs"))).unwrap_or_default(); + let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::unique("asset_thumbs"))).unwrap_or_default(); let mut sources: Vec<(Source, Arc)> = vec![(Source::Local, doc.clone())]; sources.extend(listing.libraries.iter().filter_map(|l| l.doc.clone().map(|d| (Source::Library(l.id.clone()), d)))); invalidate_thumbs(ui, &mut thumbs, &sources); let mut wanted = Vec::new(); - let collapsed_id = Id::new("asset_collapsed_libraries"); + let collapsed_id = Id::unique("asset_collapsed_libraries"); let collapsed: HashSet = ui.data(|d| d.get_temp(collapsed_id)).unwrap_or_default(); let mut act = None; egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { @@ -657,7 +662,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { render_thumbs(ui, w, &mut thumbs, &wanted); let drag = ui.data(|d| d.get_temp::(drag_id())); let tex = drag.as_ref().and_then(|d| thumbs.map.get(&(d.src.clone(), d.component)).cloned().flatten()); - ui.data_mut(|d| d.insert_temp(Id::new("asset_thumbs"), thumbs)); + ui.data_mut(|d| d.insert_temp(Id::unique("asset_thumbs"), thumbs)); match act { Some(Act::Drag(d)) => ui.data_mut(|m| { m.insert_temp(drag_id(), d); @@ -773,9 +778,11 @@ fn drag_feedback(app: &mut FigApp, ui: &mut Ui, doc: &Document, libs: &HashMap) { let t = Tokens::get(ui.ctx()); - let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::new("asset_ghost"))); + let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, Id::unique("asset_ghost"))); let r = Rect::from_center_size(p, vec2(64.0, 64.0)); painter.rect_filled(r, CornerRadius::same(6), t.panel.gamma_multiply(0.9)); painter.rect_stroke(r, CornerRadius::same(6), Stroke::new(1.0, COMPONENT), egui::StrokeKind::Inside); @@ -812,7 +819,7 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) { let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let back = Rect::from_center_size(pos2(hr.min.x + 24.0, hr.center().y), vec2(24.0, 24.0)); - let bresp = ui.interact(back, Id::new("asset_libraries_back"), Sense::click()).tip(tr("Back to assets")); + let bresp = ui.interact(back, Id::unique("asset_libraries_back"), Sense::click()).tip(tr("Back to assets")); if bresp.hovered() { ui.painter().rect_filled(back, CornerRadius::same(5), t.hover); } @@ -831,8 +838,12 @@ fn libraries_view(app: &mut FigApp, ui: &mut Ui) { }); } +/// Draw the publish form: status, publishable components/styles/collections, description and +/// publish buttons. Run `library.publish` locally or publish to the server in the +/// background, reset the form on success, and report when the file is unsaved so other files +/// cannot yet use it. fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { - let form_id = Id::new("asset_publish_form"); + let form_id = Id::unique("asset_publish_form"); let mut form: PublishForm = ui.data(|d| d.get_temp(form_id)).unwrap_or_default(); title(ui, w, tr("This file")); let m = doc.published.as_ref().map(|p| &p.manifest); @@ -901,7 +912,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { ui.horizontal(|ui| { ui.add_space(PAD); if let Some(d) = - crate::widgets::text_field(ui, Id::new("asset_publish_description"), &description, w - PAD * 2.0, tr("Description (optional)")) + crate::widgets::text_field(ui, Id::unique("asset_publish_description"), &description, w - PAD * 2.0, tr("Description (optional)")) { form.description = Some(d); } @@ -965,7 +976,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { /// How other files use this file's library on its server (asked for with a button: it is a /// request to the server). fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { - let usage_id = Id::new(("asset_library_usage", link)); + let usage_id = Id::unique(("asset_library_usage", link)); let loading = app.session.waiting_for(USAGE); let (mut ask, mut stop) = (false, false); ui.horizontal(|ui| { @@ -1010,6 +1021,8 @@ fn usage(app: &mut FigApp, ui: &mut Ui, w: f32, link: &str) { ui.add_space(8.0); } +/// Draw the libraries enabled in this file with version/count/status, remove and swap +/// actions, add-file and pasted-link controls, and background fetch/cancel handling. fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let t = Tokens::get(ui.ctx()); title(ui, w, tr("Libraries in this file")); @@ -1050,9 +1063,9 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let clip = Rect::from_min_size(pos2(r.min.x + PAD, r.min.y), vec2(text_w, r.height())); ui.painter().with_clip_rect(clip).galley(pos2(r.min.x + PAD, r.min.y + 6.0), name, t.text); ui.painter().with_clip_rect(clip).galley(pos2(r.min.x + PAD, r.min.y + 23.0), line, t.text_secondary); - ui.interact(clip, Id::new(("asset_lib_row", &l.id)), Sense::hover()).on_hover_text(shown_path(&l.path)); + ui.interact(clip, Id::unique(("asset_lib_row", &l.id)), Sense::hover()).on_hover_text(shown_path(&l.path)); let x = Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0)); - let xr = ui.interact(x, Id::new(("asset_lib_remove", &l.id)), Sense::click()).tip(tr("Remove library from this file")); + let xr = ui.interact(x, Id::unique(("asset_lib_remove", &l.id)), Sense::click()).tip(tr("Remove library from this file")); if xr.hovered() { ui.painter().rect_filled(x, CornerRadius::same(5), t.hover); } @@ -1063,7 +1076,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { if swappable { let sw = Rect::from_center_size(pos2(r.max.x - 52.0, r.center().y), vec2(24.0, 24.0)); let sr = ui - .interact(sw, Id::new(("asset_lib_swap", &l.id)), Sense::click()) + .interact(sw, Id::unique(("asset_lib_swap", &l.id)), Sense::click()) .tip(tr("Swap library: use another library's assets of the same names")); if sr.hovered() { ui.painter().rect_filled(sw, CornerRadius::same(5), t.hover); @@ -1089,7 +1102,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { let pasted = ui .horizontal(|ui| { ui.add_space(PAD); - crate::widgets::text_field(ui, Id::new("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)")) + crate::widgets::text_field(ui, Id::unique("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)")) }) .inner; if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) @@ -1140,7 +1153,7 @@ fn added(app: &mut FigApp, r: &Value) { /// Tests: the components whose thumbnails are cached (for this file's assets). #[cfg(test)] pub(crate) fn cached_thumbnails(ctx: &egui::Context) -> Vec { - let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::new("asset_thumbs"))).unwrap_or_default(); + let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::unique("asset_thumbs"))).unwrap_or_default(); let mut out: Vec = thumbs.map.keys().filter(|(s, _)| *s == Source::Local).map(|(_, id)| *id).collect(); out.sort(); out diff --git a/crates/ui-egui/src/bidi.rs b/crates/ui-egui/src/bidi.rs index 58c5df93..0c7bf99e 100644 --- a/crates/ui-egui/src/bidi.rs +++ b/crates/ui-egui/src/bidi.rs @@ -1,69 +1,23 @@ //! Names in right-to-left and mixed-direction scripts (Hebrew, Arabic…) in the panels: layer, page, -//! style, component and file names. egui has no bidi: it shapes each run of one font on its own, -//! reversing right-to-left runs but leaving the words and runs of a name in logical order. So -//! names go through [`figcraft_text::display`] (visual order, Arabic letters joined, mirrored -//! brackets) and every shown cluster is laid out as its own section, which egui's shaper leaves as -//! it is. Names are elided at their end (the left end of a right-to-left name) and edited by -//! [`single_line`], whose caret, clicks and selection follow the shown text (the arrow keys move -//! visually). Left-to-right names take egui's own path unchanged. +//! style, component and file names. egui 0.37's epaint shapes the logical (typing-order) text with +//! the Unicode bidirectional algorithm (UAX #9), lays every run out in visual order, joins Arabic +//! letters through the font's OpenType features, mirrors brackets, and keeps one glyph per char so +//! that caret, selection and click offsets stay logical. Names are therefore passed to egui in +//! logical order and elided at their visual end. The rename editor is egui's own single-line +//! `TextEdit`, whose arrow keys move the caret on screen while its state remains a logical range. -use std::collections::HashMap; use std::sync::Arc; -use egui::text::{ByteIndex, CCursor, CCursorRange, LayoutJob, LayoutSection, TextFormat, TextWrapping}; -use egui::{Color32, FontId, Galley, Id, Painter, Pos2, Rect, Response, Stroke, Ui, pos2, vec2}; -use figcraft_text::display::{self, VisualLine}; - -/// Shown (visual, elided) lines by name, font and width: elision measures many candidates, so the -/// result is kept between frames (cleared when it grows large). -#[derive(Clone, Default)] -struct Elided(Arc>>); - -/// A name, its font's size and family (proportional or not) and the width it was cut to (bits). -type ElidedKey = (String, u32, bool, u32); - -const ELIDED_MAX: usize = 4096; +use egui::text::{LayoutJob, TextWrapping}; +use egui::{Color32, FontId, Galley, Id, Painter, Pos2, Rect, Response, Ui, pos2, vec2}; /// `name` on one line in `font`, at most `max_w` wide (ending in an ellipsis when cut), ready to -/// paint: in visual order with Arabic letters joined when it has right-to-left text. +/// paint. The text stays in logical order; egui shapes, joins and reorders it, and puts the +/// ellipsis at the row's visual end (the left end of a right-to-left name). pub fn name_galley(painter: &Painter, name: &str, font: FontId, color: Color32, max_w: f32) -> Arc { let max_w = max_w.max(1.0); - if !display::has_rtl(name) { - let mut job = LayoutJob::simple_singleline(name.to_string(), font, color); - job.wrap = TextWrapping::truncate_at_width(max_w); - return painter.layout_job(job); - } - let key = (name.to_string(), font.size.to_bits(), matches!(font.family, egui::FontFamily::Proportional), max_w.to_bits()); - let cache = painter.ctx().data_mut(|d| d.get_temp_mut_or_default::(Id::new("bidi_elided")).clone()); - let hit = cache.0.lock().ok().and_then(|m| m.get(&key).cloned()); - let shown = hit.unwrap_or_else(|| { - let fits = |v: &VisualLine| visual_galley(painter, v, &font, color).size().x <= max_w; - let v = display::elide(name, fits); - if let Ok(mut m) = cache.0.lock() { - if m.len() >= ELIDED_MAX { - m.clear(); - } - m.insert(key, v.clone()); - } - v - }); - visual_galley(painter, &shown, &font, color) -} - -/// A line in display order laid out with each cluster as its own section, so egui's shaper (which -/// would reverse a right-to-left run once more) gets one ordered, joined cluster at a time. -fn visual_galley(painter: &Painter, line: &VisualLine, font: &FontId, color: Color32) -> Arc { - let chars: Vec = line.text.chars().collect(); - let format = TextFormat::simple(font.clone(), color); - let mut job = LayoutJob::default(); - for c in &line.clusters { - let start = job.text.len(); - job.text.extend(chars.get(c.shown.clone()).unwrap_or_default()); - // Pushed as is: `LayoutJob::append` would merge it with the section before. - if job.text.len() > start { - job.sections.push(LayoutSection { leading_space: 0.0, byte_range: ByteIndex(start)..ByteIndex(job.text.len()), format: format.clone() }); - } - } + let mut job = LayoutJob::simple_singleline(name.to_string(), font, color); + job.wrap = TextWrapping::truncate_at_width(max_w); painter.layout_job(job) } @@ -76,209 +30,29 @@ pub fn paint_name(painter: &Painter, left_center: Pos2, name: &str, font: FontId rect } -/// Each shown cluster's left and right x in a galley of `v.text` laid out from x = 0. -fn edges(v: &VisualLine, galley: &Galley) -> Vec<(f64, f64)> { - let chars: Vec<(f64, f64)> = galley - .rows - .first() - .map(|r| r.row.glyphs.iter().map(|g| (f64::from(g.pos.x), f64::from(g.pos.x + g.advance_width))).collect()) - .unwrap_or_default(); - v.cluster_edges(&chars) -} - -/// The shown text of a field being edited, laid out once per frame. -struct Shown { - line: VisualLine, - galley: Arc, - edges: Vec<(f64, f64)>, -} - -impl Shown { - fn new(ui: &Ui, text: &str, font: &FontId, color: Color32) -> Shown { - let line = display::visual_line(text); - let galley = visual_galley(ui.painter(), &line, font, color); - let edges = edges(&line, &galley); - Shown { line, galley, edges } - } - - fn caret_x(&self, i: usize) -> f32 { - self.line.caret_x(i, &self.edges) as f32 - } - - fn hit(&self, x: f32) -> usize { - self.line.hit(f64::from(x), &self.edges) - } -} - -fn scroll_id(id: Id) -> Id { - id.with("bidi_scroll") -} - -fn anchor_id(id: Id) -> Id { - id.with("bidi_anchor") -} - -/// A frameless one-line text editor for a name (a [`egui::TextEdit`] with id `id`). Text with -/// right-to-left characters shows in visual order, and the caret, the selection, clicks, drags and -/// the left and right arrow keys follow what's on screen. +/// A frameless one-line text editor for a name (a [`egui::TextEdit`] with id `id`). The editor's +/// text is logical; egui's galley paints it in visual order, and its caret, selection, clicks, +/// drags and left and right arrows follow what is on screen while reporting logical char offsets. pub fn single_line(ui: &mut Ui, id: Id, text: &mut String, font: FontId, width: f32) -> Response { - let rtl = display::has_rtl(text); - let focused = ui.memory(|m| m.has_focus(id)); - let color = ui.visuals().text_color(); - if rtl && focused { - visual_arrows(ui, id, text, &font, color); - } // Centred in the height it's given (a rename row), like the name it replaces. let height = ui.available_height().max(0.0); - let edit = egui::TextEdit::singleline(text) + egui::TextEdit::singleline(text) .id(id) .frame(egui::Frame::NONE) - .font(font.clone()) + .font(font) .desired_width(width) - .vertical_align(egui::Align::Center) - .min_size(vec2(0.0, height)); - if !rtl { - ui.data_mut(|d| d.remove::(scroll_id(id))); - return edit.show(ui).response.response; - } - // egui would draw the chars in logical order: hide its text, caret and selection and draw the - // shown text over it. - let out = ui - .scope(|ui| { - let v = ui.visuals_mut(); - v.selection.bg_fill = Color32::TRANSPARENT; - v.selection.stroke = Stroke::new(0.0, Color32::TRANSPARENT); - v.text_cursor.stroke = Stroke::NONE; - edit.text_color(Color32::TRANSPARENT).show(ui) - }) - .inner; - let response = out.response.response; - let shown = Shown::new(ui, text, &font, color); - let clip = out.text_clip_rect; - let mut scroll: f32 = ui.data(|d| d.get_temp(scroll_id(id))).unwrap_or(0.0); - let origin = |scroll: f32| pos2(clip.min.x - scroll, out.galley_pos.y); - // Clicks and drags place the caret by what's on screen. - let mut range = out.state.cursor.char_range(); - if let Some(p) = response.interact_pointer_pos() { - let at = shown.hit(p.x - origin(scroll).x); - if response.double_clicked() { - let (a, b) = word_at(text, at); - range = Some(CCursorRange::two(CCursor::new(a), CCursor::new(b))); - } else if response.clicked() || (response.is_pointer_button_down_on() && !response.dragged()) { - // Pressed (not yet dragging): the caret and the drag's anchor go here. - ui.data_mut(|d| d.insert_temp(anchor_id(id), at)); - range = Some(CCursorRange::one(CCursor::new(at))); - } else if response.dragged() { - let anchor = ui.data(|d| d.get_temp::(anchor_id(id))).unwrap_or(at); - range = Some(CCursorRange { primary: CCursor::new(at), secondary: CCursor::new(anchor), h_pos: None }); - } - if range != out.state.cursor.char_range() { - let mut state = out.state.clone(); - state.cursor.set_char_range(range); - state.store(ui.ctx(), id); - } - } - // Keep the caret in view. - let caret = range.map_or(0, |r| r.primary.index.0); - let cx = shown.caret_x(caret); - let w = clip.width().max(1.0); - if cx - scroll > w - 1.0 { - scroll = cx - w + 1.0; - } - if cx - scroll < 0.0 { - scroll = cx; - } - scroll = scroll.clamp(0.0, (shown.galley.size().x - w + 1.0).max(0.0)); - ui.data_mut(|d| d.insert_temp(scroll_id(id), scroll)); - let o = origin(scroll); - let painter = ui.painter().with_clip_rect(clip.expand2(vec2(1.0, 0.0))); - let h = shown.galley.size().y; - if focused && let Some(r) = range { - let (a, b) = (r.primary.index.0, r.secondary.index.0); - for (x0, x1) in shown.line.selection(a, b, &shown.edges) { - let sel = Rect::from_min_max(pos2(o.x + x0 as f32, o.y), pos2(o.x + x1 as f32, o.y + h)); - painter.rect_filled(sel, 0.0, ui.visuals().selection.bg_fill); - } - } - painter.galley(o, shown.galley.clone(), color); - if focused { - let x = (o.x + cx).round() + 0.5; - painter.line_segment([pos2(x, o.y), pos2(x, o.y + h)], ui.visuals().text_cursor.stroke); - ui.ctx().request_repaint_after(std::time::Duration::from_millis(500)); - } - response -} - -/// Plain and Shift-held left and right arrows move the caret across the shown text (consumed so -/// the editor doesn't move it logically). Word and line jumps stay logical. -fn visual_arrows(ui: &mut Ui, id: Id, text: &str, font: &FontId, color: Color32) { - let moves: Vec<(bool, bool)> = ui.input_mut(|i| { - let mut out = Vec::new(); - i.events.retain(|e| match e { - egui::Event::Key { key: key @ (egui::Key::ArrowLeft | egui::Key::ArrowRight), pressed: true, modifiers, .. } - if !modifiers.alt && !modifiers.command && !modifiers.ctrl => - { - out.push((*key == egui::Key::ArrowRight, modifiers.shift)); - false - } - _ => true, - }); - out - }); - if moves.is_empty() { - return; - } - let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), id) else { return }; - let shown = Shown::new(ui, text, font, color); - let mut r = state.cursor.char_range().unwrap_or_default(); - for (right, shift) in moves { - let (lo, hi) = (r.primary.index.0.min(r.secondary.index.0), r.primary.index.0.max(r.secondary.index.0)); - let next = if !shift && lo != hi { - // A selection collapses to its end on the side of the arrow. - let (xa, xb) = (shown.caret_x(lo), shown.caret_x(hi)); - if (xa < xb) == right { hi } else { lo } - } else { - shown.line.step(r.primary.index.0, right, &shown.edges) - }; - r = if shift { - CCursorRange { primary: CCursor::new(next), secondary: r.secondary, h_pos: None } - } else { - CCursorRange::one(CCursor::new(next)) - }; - } - state.cursor.set_char_range(Some(r)); - state.store(ui.ctx(), id); -} - -/// The word around char `i` (letters, digits and marks; else the single char). -fn word_at(text: &str, i: usize) -> (usize, usize) { - let chars: Vec = text.chars().collect(); - let is_word = - |k: usize| chars.get(k).is_some_and(|c| c.is_alphanumeric() || matches!(*c as u32, 0x0300..=0x036F | 0x0591..=0x05C7 | 0x064B..=0x065F)); - let i = i.min(chars.len()); - let at = if is_word(i) { - i - } else if i > 0 && is_word(i - 1) { - i - 1 - } else { - return (i, (i + 1).min(chars.len())); - }; - let mut a = at; - while a > 0 && is_word(a - 1) { - a -= 1; - } - let mut b = at; - while is_word(b) { - b += 1; - } - (a, b) + .align(egui::Align2::LEFT_CENTER) + .min_size(vec2(0.0, height)) + .show(ui) + .response + .response } /// Faces the UI adds for scripts its own fonts lack (desktop: from the system), as egui font data; /// `taken` as for [`crate::theme::system_fallbacks`]. pub fn script_fallbacks(have: &dyn Fn(char) -> bool, taken: &mut Vec>) -> Vec<(String, egui::FontData)> { - // Hebrew, Arabic letters, Arabic presentation forms (what joined names are drawn with). - const SAMPLES: [char; 3] = ['\u{05D0}', '\u{0628}', '\u{FE91}']; + // Hebrew and Arabic letters; egui's shaper selects the joined contextual forms itself. + const SAMPLES: [char; 2] = ['\u{05D0}', '\u{0628}']; const FAMILIES: [&str; 11] = [ "Segoe UI", "Arial", diff --git a/crates/ui-egui/src/board_bar.rs b/crates/ui-egui/src/board_bar.rs index c312ddb9..6ddd1cb8 100644 --- a/crates/ui-egui/src/board_bar.rs +++ b/crates/ui-egui/src/board_bar.rs @@ -170,7 +170,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let size = vec2(h.width(), 40.0); let at = place(sel, size, app.canvas.rect); let t = Tokens::get(ctx); - egui::Area::new(egui::Id::new("board_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("board_bar")).fixed_pos(at).order(egui::Order::Middle).show(ctx, |ui| { let (rect, _) = ui.allocate_exact_size(size, Sense::click()); a11y::container_bounds(ui, rect); let p = ui.painter(); @@ -198,7 +198,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { for (i, (name, c)) in BOARD_COLORS.iter().enumerate() { let r = Rect::from_center_size(pos2(x + SWATCH / 2.0, cy), vec2(SWATCH, SWATCH)); - let resp = ui.interact(r, ui.id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name))); + let resp = ui.interact(r, ui.scope_id().with(("bb_color", i)), Sense::click()).on_hover_text(tr(color_label(name))); a11y::button(&resp, tr(color_label(name))); if resp.hovered() { ui.painter().rect_filled(r.expand(2.0), CornerRadius::same(6), t.hover); @@ -215,7 +215,7 @@ fn color_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> /// The shape menu at `at` (its left edge and middle), showing `shape`; where the next part starts. fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &Tokens) -> f32 { let r = Rect::from_min_size(pos2(at.x, at.y - CELL / 2.0), vec2(CELL + 12.0, CELL)); - let resp = ui.interact(r, ui.id().with("bb_shape"), Sense::click()).on_hover_text(tr("Shape")); + let resp = ui.interact(r, ui.scope_id().with("bb_shape"), Sense::click()).on_hover_text(tr("Shape")); a11y::menu_button(&resp, tr("Shape"), a11y::menu_open(&resp)); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); @@ -240,7 +240,7 @@ fn shape_part(app: &mut FigApp, ui: &mut Ui, shape: BoardShape, at: Pos2, t: &To fn size_part(app: &mut FigApp, ui: &mut Ui, mut x: f32, cy: f32, t: &Tokens) -> f32 { for (label, size) in TEXT_SIZES { let r = Rect::from_center_size(pos2(x + CELL / 2.0, cy), vec2(CELL, CELL)); - let resp = ui.interact(r, ui.id().with(("bb_size", label)), Sense::click()).on_hover_text(tr("Text size")); + let resp = ui.interact(r, ui.scope_id().with(("bb_size", label)), Sense::click()).on_hover_text(tr("Text size")); a11y::button(&resp, &format!("{} {label}", tr("Text size"))); if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index 4ffb3cf4..d184c934 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -56,12 +56,12 @@ fn pick_option(u: &mut Ui, i: usize, n: usize) { /// Whether the bar over the selection showed in the last frame. fn bar_shown(ctx: &egui::Context) -> bool { - ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, egui::Id::new("board_bar")))) + ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, egui::Id::unique("board_bar")))) } /// The bar over the selection, when it shows. fn bar(u: &Ui) -> Option { - bar_shown(&u.ctx).then(|| u.ctx.memory(|m| m.area_rect(egui::Id::new("board_bar")))).flatten() + bar_shown(&u.ctx).then(|| u.ctx.memory(|m| m.area_rect(egui::Id::unique("board_bar")))).flatten() } #[test] diff --git a/crates/ui-egui/src/branch_ui.rs b/crates/ui-egui/src/branch_ui.rs index 1e3c558c..c52fc216 100644 --- a/crates/ui-egui/src/branch_ui.rs +++ b/crates/ui-egui/src/branch_ui.rs @@ -75,7 +75,7 @@ pub fn banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { let Some(b) = m.branches.iter().find(|b| b.id == id) else { return }; let (file, name, active) = (m.doc.name.clone(), b.name.clone(), b.state == BranchState::Active); let t = Tokens::get(ctx); - let review = widgets::canvas_banner(ctx, Id::new("branch_banner"), canvas, |ui| { + let review = widgets::canvas_banner(ctx, Id::unique("branch_banner"), canvas, |ui| { ui.label(egui::RichText::new(format!("“{name}”")).font(semibold(11.0)).color(t.text)); let state = if active { trf("Branch of {file}", &[("file", &file)]) } else { trf("Branch of {file} · merged or archived", &[("file", &file)]) }; @@ -146,7 +146,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut open = true; let mut action = None; egui::Window::new("Review and merge") - .id(Id::new("branch_review_window")) + .id(Id::unique("branch_review_window")) .title_bar(false) .collapsible(false) .resizable(false) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ca46b4c8..de38ceb8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -163,12 +163,16 @@ fn pointer_event(app: &FigApp, kind: PointerKind, pos: Pos2, m: &egui::Modifiers } } +/// Run one canvas pass: update the viewport size and first-fit zoom; handle pointer/key input +/// and accessibility; draw the drop overlay, guides, live pointer and preedit; request/poll +/// renders and upload the texture; draw the page background and last texture; then draw overlays, +/// labels, handles, comments, versions/branch/dev/live UI and the cursor. pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { let rect = ui.max_rect(); app.canvas.rect = rect; let ctx = ui.ctx().clone(); let ppp = ctx.pixels_per_point(); - let resp = ui.interact(rect, ui.id().with("canvas"), Sense::click_and_drag()); + let resp = ui.interact(rect, ui.scope_id().with("canvas"), Sense::click_and_drag()); let key_box = app.ui.key_box; crate::a11y::canvas(&mut app.ui.nav, &resp, || key_box.map_or_else(|| describe(&app.session), describe_box)); if let Ok(ds) = app.session.ds_mut() diff --git a/crates/ui-egui/src/clipboard_fence_tests.rs b/crates/ui-egui/src/clipboard_fence_tests.rs new file mode 100644 index 00000000..76ddf81c --- /dev/null +++ b/crates/ui-egui/src/clipboard_fence_tests.rs @@ -0,0 +1,200 @@ +//! Programmatic gestures never replace the user's system clipboard: keys and clicks injected +//! through the control channel (`ui.input`, `ui.menu.invoke`, …) may copy inside FigCraft but the +//! OS clipboard is the user's. Every copy command goes through `FigApp::clipboard_set_text` / +//! `FigApp::clipboard_set_png`, which drop the write while the frame's input is synthetic. + +use std::sync::{Arc, Mutex}; + +use egui::{Event, Pos2, RawInput, Rect, vec2}; +use figcraft_doc::{NodeId, NodeKind}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::control::ControlRequest; +use crate::{FigApp, Services}; + +#[derive(Default)] +struct Recorded { + text: Vec, + pngs: usize, + /// What the system clipboard holds now (the last text written). + last_text: Option, +} + +/// The system clipboard of the test machine. +struct Clipboard(Arc>); + +impl Services for Clipboard { + /// Record a system-clipboard text write in the test clipboard service. + fn clipboard_set_text(&mut self, s: &str) { + let mut r = self.0.lock().unwrap(); + r.text.push(s.to_string()); + r.last_text = Some(s.to_string()); + } + /// Return the test system clipboard's last text. + fn clipboard_get_text(&mut self) -> Option { + self.0.lock().unwrap().last_text.clone() + } + /// Count a PNG clipboard write in the test clipboard service. + fn clipboard_set_png(&mut self, _w: u32, _h: u32, _rgba: &[u8]) { + self.0.lock().unwrap().pngs += 1; + } +} + +struct Win { + ctx: egui::Context, + app: FigApp, + time: f64, + recorded: Arc>, +} + +impl Win { + /// Create a test window over a session containing one selected rectangle. + fn new() -> Self { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 100, "height": 50})).unwrap(); + let mut win = Self::with_session(s); + let _ = win.app.run("select.all", json!({})); + win + } + + /// Build a test window around `s` with a recording clipboard service and run three frames. + fn with_session(s: Session) -> Self { + let recorded = Arc::new(Mutex::new(Recorded::default())); + let app = FigApp::new(s, Box::new(Clipboard(recorded.clone()))); + let mut win = Self { ctx: egui::Context::default(), app, time: 0.0, recorded }; + for _ in 0..3 { + win.frame(vec![]); + } + win + } + + /// Run one production UI frame with `events` at the next 60 Hz test time. + fn frame(&mut self, events: Vec) { + self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); + 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; + let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + } + + /// Return the recorded `(text writes, PNG writes)` count. + fn writes(&self) -> (usize, usize) { + let r = self.recorded.lock().unwrap(); + (r.text.len(), r.pngs) + } +} + +/// Asserts that a control-channel copy shortcut writes no system clipboard, still runs the +/// command and is acknowledged. +#[test] +fn a_synthetic_copy_shortcut_leaves_the_system_clipboard_alone() { + let mut w = Win::new(); + assert!(!w.app.session.selection().is_empty(), "something to copy"); + let (tx, rx) = std::sync::mpsc::channel(); + w.app.control_queue.push(ControlRequest { + id: json!(1), + method: "ui.input".into(), + // Shift+Cmd+C: `edit.copyAsPng`, which wrote the OS clipboard directly before the fence. + params: json!({"events": [{"key": "C", "shift": true, "cmd": true}]}), + reply: tx, + state: Default::default(), + }); + w.frame(vec![]); + let events = crate::windows::take_input(&mut w.app, egui::ViewportId::ROOT); + assert!(!events.is_empty(), "the injected shortcut"); + w.frame(events); + assert_eq!(w.writes(), (0, 0), "a control-channel gesture never replaces the user's clipboard"); + assert!( + w.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Copied")), + "the command itself ran (only its OS clipboard write is fenced): {:?}", + w.app.ui.toast + ); + assert!(rx.try_recv().is_ok(), "the batch is acknowledged"); +} + +/// Asserts copy commands run under the synthetic-control fence write nothing, while the same +/// command afterwards writes once. +#[test] +fn a_copy_command_invoked_for_a_synthetic_click_still_keeps_the_clipboard() { + let mut w = Win::new(); + // What a synthetic pointer click on Copy runs, fenced as `control::drain`/`background` fence it. + w.app.control_synthetic = true; + crate::menus::invoke(&mut w.app, "edit.copyAsCss"); + crate::menus::invoke(&mut w.app, "edit.copyAsText"); + assert_eq!(w.writes(), (0, 0)); + w.app.control_synthetic = false; + // A user's own copy still writes. + crate::menus::invoke(&mut w.app, "edit.copyAsCss"); + assert_eq!(w.writes(), (1, 0)); +} + +/// Return the characters of a text node in the test window. +fn edit_text(w: &Win, id: NodeId) -> String { + match &w.app.session.doc().unwrap().node(id).unwrap().kind { + NodeKind::Text(t) => t.characters.clone(), + _ => panic!("not text"), + } +} + +/// Asserts a synthetic Cut deletes the selected text, keeps the edit session active, writes +/// no system clipboard, and ends its fence with the frame. +#[test] +fn a_synthetic_cut_deletes_the_selection_but_leaves_the_users_clipboard_alone() { + let mut s = Session::new(); + let id = NodeId(s.execute("text.create", &json!({"text": "abc", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()); + let mut w = Win::with_session(s); + figcraft_engine::textedit::begin(&mut w.app.session, id, Some(3), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut w.app.session, 0, true).unwrap(); + + // A control-channel gesture is fenced from the OS clipboard, but its Cut still edits the + // document: the selection is deleted exactly as a user's Cut would delete it. + w.app.push_control_input(vec![Event::Cut]); + let events = crate::windows::take_input(&mut w.app, egui::ViewportId::ROOT); + assert_eq!(events, [Event::Cut], "the production input path owns the synthetic frame"); + w.frame(events); + assert_eq!(w.writes(), (0, 0), "a synthetic cut never writes the user's clipboard"); + assert_eq!(edit_text(&w, id), "", "the selected text is deleted"); + assert!(w.app.session.ds().unwrap().text_edit.is_some(), "the edit session stays active"); + assert!(!w.app.control_synthetic, "the fence ends with the frame"); +} + +/// Asserts a programmatic copy leaves the system clipboard alone and invalidates the stale +/// mirror, so a later user paste uses the system text rather than the programmatic layers. +#[test] +fn a_programmatic_copy_does_not_let_a_stale_mirror_hide_the_users_paste() { + let mut s = Session::new(); + let hello = figcraft_doc::NodeId(s.execute("text.create", &json!({"text": "hello", "x": 10, "y": 10})).unwrap()["id"].as_u64().unwrap()); + let other = figcraft_doc::NodeId(s.execute("text.create", &json!({"text": "other", "x": 10, "y": 80})).unwrap()["id"].as_u64().unwrap()); + s.execute("select.set", &json!({"ids": [hello]})).unwrap(); + let mut w = Win::with_session(s); + + // The user copies the "hello" text layer: the OS clipboard and `last_copy` become "hello". + crate::menus::invoke(&mut w.app, "edit.copy"); + assert_eq!(w.app.ui.last_copy.as_deref(), Some("hello")); + assert_eq!(w.writes().0, 1, "the user's copy reaches the system clipboard"); + + // A control-channel copy changes the engine clipboard to "other" but never writes the system + // clipboard. The stale "hello" mirror must not claim the programmatic selection is ours. + let _ = w.app.run("select.set", json!({"ids": [other]})); + w.app.control_synthetic = true; + crate::menus::invoke(&mut w.app, "edit.copy"); + w.app.control_synthetic = false; + assert_eq!(w.writes().0, 1, "the programmatic copy leaves the system clipboard alone"); + assert_eq!(w.app.ui.last_copy, None, "the old system-clipboard mirror is invalidated"); + + // The user pastes: the system clipboard's own text wins over the programmatic layers. + crate::menus::paste(&mut w.app, Some("hello"), None); + let doc = w.app.session.doc().unwrap(); + let texts: Vec = doc + .pages + .iter() + .filter_map(|p| doc.node(*p)) + .flat_map(|p| p.children.iter().copied()) + .filter_map(|id| doc.node(id).and_then(|n| n.kind.text().map(|t| t.characters.clone()))) + .collect(); + assert_eq!(texts.iter().filter(|t| t.as_str() == "hello").count(), 2, "the user's text was pasted"); + assert_eq!(texts.iter().filter(|t| t.as_str() == "other").count(), 1, "the programmatic selection was not pasted"); +} diff --git a/crates/ui-egui/src/colorpicker.rs b/crates/ui-egui/src/colorpicker.rs index 9d37477a..f1cde02f 100644 --- a/crates/ui-egui/src/colorpicker.rs +++ b/crates/ui-egui/src/colorpicker.rs @@ -199,7 +199,7 @@ fn finish(app: &mut FigApp) { /// The selected gradient stop in the picker (shared with the on-canvas stops). pub fn stop_id() -> Id { - Id::new("cp_stop") + Id::unique("cp_stop") } /// Apply a swatch colour: a solid paint's colour, or the selected gradient stop's. @@ -224,7 +224,7 @@ fn set_color(app: &mut FigApp, target: &PickerTarget, paint: &mut Paint, c: Colo fn page_colors(app: &FigApp, ui: &Ui) -> Vec { let Ok(ds) = app.session.ds() else { return vec![] }; let key = (std::sync::Arc::as_ptr(&ds.doc) as usize, ds.page); - let cache_id = Id::new("cp_doc_colors"); + let cache_id = Id::unique("cp_doc_colors"); if let Some((k, colors)) = ui.data(|d| d.get_temp::<((usize, figcraft_doc::NodeId), Vec)>(cache_id)) && k == key { @@ -241,7 +241,7 @@ const MODELS: [&str; 5] = ["Hex", "RGB", "HSL", "HSB", "CSS"]; /// The model dropdown with its fields and the alpha field. Returns a new colour and whether the /// edit is done (false while a field is being scrubbed). fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { - let model_id = Id::new("cp_model"); + let model_id = Id::unique("cp_model"); let model: usize = ui.data(|d| d.get_temp(model_id)).unwrap_or(0).min(MODELS.len() - 1); let mut out: Option = None; let mut done = true; @@ -259,7 +259,7 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { }); let num = |ui: &mut Ui, id: &str, v: f64, w: f32| match widgets::number_field( ui, - Id::new(("cp_f", id)), + Id::unique(("cp_f", id)), widgets::Lead::None, Some(v.round()), "", @@ -309,19 +309,19 @@ fn model_fields(ui: &mut Ui, c: Color) -> Option<(Color, bool)> { } } "CSS" => { - if let Some(text) = widgets::text_field(ui, Id::new("cp_css"), &c.to_css(), 152.0, "") { + if let Some(text) = widgets::text_field(ui, Id::unique("cp_css"), &c.to_css(), 152.0, "") { out = Color::parse_css(&text); } } _ => { - if let Some(h) = widgets::text_field(ui, Id::new("cp_hex"), &c.to_hex(), 101.0, "") { + if let Some(h) = widgets::text_field(ui, Id::unique("cp_hex"), &c.to_hex(), 101.0, "") { out = Color::from_hex_or_name(&h).map(|h| h.with_alpha(if h.a < 1.0 { h.a } else { c.a })); } } } if MODELS.get(model) != Some(&"CSS") { let alpha = Some((f64::from(out.unwrap_or(c).a) * 100.0).round()); - let edit = match widgets::number_field(ui, Id::new("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) { + let edit = match widgets::number_field(ui, Id::unique("cp_a"), widgets::Lead::None, alpha, "%", 44.0, 1.0) { Some(widgets::FieldEdit::Set(v)) => Some((v, true)), Some(widgets::FieldEdit::Scrub(v, end)) => Some((v, end)), None => None, @@ -339,6 +339,8 @@ fn hue_color(ctx: &egui::Context, h: f32) -> Color32 { doc_c32(ctx, Hsb { h, s: 1.0, b: 1.0 }.to_color()) } +/// Draw the saturation/value square and its knob, updating `hsb.s` and `hsb.b` on click or +/// drag; returns `(changed, done)`. fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { // White → hue horizontally, then transparent → black vertically. let mut m = Mesh::default(); @@ -362,7 +364,7 @@ fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { m2.add_triangle(0, 1, 2); m2.add_triangle(0, 2, 3); ui.painter().add(m2); - let resp = ui.interact(rect, Id::new("cp_sv"), Sense::click_and_drag()); + let resp = ui.interact(rect, Id::unique("cp_sv"), Sense::click_and_drag()); let mut changed = false; if (resp.dragged() || resp.clicked() || resp.drag_started()) && let Some(p) = resp.interact_pointer_pos() @@ -377,6 +379,8 @@ fn sv_square(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { (changed, resp.drag_stopped() || resp.clicked()) } +/// Draw the hue gradient and knob, updating `hsb.h` clamped below 360; returns +/// `(changed, done)`. fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { let n = 12; let mut m = Mesh::default(); @@ -393,7 +397,7 @@ fn hue_slider(ui: &mut Ui, rect: Rect, hsb: &mut Hsb) -> (bool, bool) { } } ui.painter().add(m); - slider_knob(ui, rect, hsb.h / 360.0, Id::new("cp_hue")) + slider_knob(ui, rect, hsb.h / 360.0, Id::unique("cp_hue")) .map(|(t, done)| { hsb.h = (t * 360.0).clamp(0.0, 359.9); (true, done) @@ -417,6 +421,8 @@ fn slider_knob(ui: &mut Ui, rect: Rect, t: f32, id: Id) -> Option<(f32, bool)> { None } +/// Draw the checkerboard and transparent-to-opaque alpha gradient with a knob; returns the +/// new alpha and done state, or `None` when the slider is not interacting. fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { widgets::checker(&ui.painter().with_clip_rect(rect), rect, rect.height() / 2.0); let mut m = Mesh::default(); @@ -432,15 +438,18 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> { m.add_triangle(0, 1, 2); m.add_triangle(0, 2, 3); ui.painter().add(m); - slider_knob(ui, rect, color.a, Id::new("cp_alpha")) + slider_knob(ui, rect, color.a, Id::unique("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. +/// `Id::unique(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") + Id::unique("color_picker") } +/// Draw the colour picker for the current target: close it when there is no target or the +/// target's selection no longer matches, otherwise apply eyedropper, paint-kind, colour and +/// gradient-stop edits live, positioned beside a plugin field when applicable. pub fn show(app: &mut FigApp, ctx: &egui::Context) { let Some(target) = app.ui.picker.clone() else { // A colour sampled for a picker that has closed has no paint to go to. @@ -477,7 +486,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { 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))`). + // (`Window::new` alone would key the area by `Id::unique(Some(title))`). .id(window_id()) .title_bar(false) .fixed_pos(pos) @@ -494,7 +503,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { 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); let cr = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)); - let cresp = ui.interact(cr, Id::new("cp_close"), Sense::click()); + let cresp = ui.interact(cr, Id::unique("cp_close"), Sense::click()); icons::paint(ui.painter(), cr.shrink(6.0), Icon::Close, t.icon); if cresp.clicked() { close_now = true; @@ -605,11 +614,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut changed = false; let mut done = false; // The bar first, so the stops on it take their own clicks. - let bresp = ui.interact(bar, Id::new("cp_bar"), Sense::click()); + let bresp = ui.interact(bar, Id::unique("cp_bar"), Sense::click()); for (i, s) in g.stops.clone().iter().enumerate() { let x = bar.min.x + s.position * bar.width(); let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0)); - let resp = ui.interact(r, Id::new(("cp_stopk", i)), Sense::click_and_drag()); + let resp = ui.interact(r, Id::unique(("cp_stopk", i)), Sense::click_and_drag()); ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0))); ui.painter().rect_stroke( r, @@ -773,7 +782,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } if let Some(color) = edit_color { let mut hsb = ui - .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::new("cp_hsb"))) + .data(|d| d.get_temp::<(f32, f32, f32, [u8; 4])>(Id::unique("cp_hsb"))) .filter(|(_, _, _, rgba)| *rgba == color.to_rgba8()) .map(|(h, s, b, _)| Hsb { h, s, b }) .unwrap_or_else(|| Hsb::from_color(color)); @@ -817,7 +826,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { changed = true; done |= d; } - ui.data_mut(|d| d.insert_temp(Id::new("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); + ui.data_mut(|d| d.insert_temp(Id::unique("cp_hsb"), (hsb.h, hsb.s, hsb.b, nc.to_rgba8()))); // A Display P3 colour sRGB can't show: the canvas (sRGB) shows the nearest one. if app.session.doc().is_ok_and(|d| d.color_profile.conversion_to(figcraft_color::ColorProfile::Srgb).clips(nc)) { gamut_warning(ui, &t); @@ -983,7 +992,7 @@ fn pattern_controls(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: ui.add_space(16.0); let field = |ui: &mut Ui, key: &str, lead: Lead, value: f64, suffix: &str| match widgets::number_field( ui, - Id::new(key), + Id::unique(key), lead, Some(value), suffix, @@ -1029,7 +1038,7 @@ fn saved_preset_row(ui: &mut Ui, name: &str) -> Option { let w = ui.available_width().max(180.0); let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click()); let del = Rect::from_center_size(pos2(r.max.x - 14.0, r.center().y), vec2(20.0, 20.0)); - let del_resp = ui.interact(del, ui.id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset")); + let del_resp = ui.interact(del, ui.scope_id().with(("cp_preset_del", name)), Sense::click()).tip(tr("Delete preset")); let hot = resp.hovered() || del_resp.hovered(); if hot { ui.painter().rect_filled(r, CornerRadius::same(5), t.menu_hover); @@ -1061,7 +1070,7 @@ fn image_presets(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &m .find(|(_, f)| *f == filters) .map(|(name, _)| name) .unwrap_or_else(|| if filters.is_identity() { tr("No preset").into() } else { tr("Custom").into() }); - let name_id = Id::new("cp_preset_name"); + let name_id = Id::unique("cp_preset_name"); let mut naming: Option = ui.data(|d| d.get_temp(name_id)); let mut action = None; ui.horizontal(|ui| { diff --git a/crates/ui-egui/src/comments.rs b/crates/ui-egui/src/comments.rs index d33c81ee..468836de 100644 --- a/crates/ui-egui/src/comments.rs +++ b/crates/ui-egui/src/comments.rs @@ -476,7 +476,7 @@ pub fn start_draft(app: &mut FigApp, x: f64, y: f64) { /// The reply field's id for a thread. fn reply_id(thread: u64) -> Id { - Id::new(("comment_reply", thread)) + Id::unique(("comment_reply", thread)) } fn set_cursor(ctx: &egui::Context, id: Id, at: usize) { @@ -650,7 +650,7 @@ fn message( let t = Tokens::get(ui.ctx()); let ctx = ui.ctx().clone(); let id = cx.c.id; - let hover_key = Id::new(("comment_msg_hover", id, i)); + let hover_key = Id::unique(("comment_msg_hover", id, i)); let hovered = ctx.data(|d| d.get_temp::(hover_key)).unwrap_or(false); let indent = 36.0; let block = ui.scope(|ui| { @@ -663,10 +663,10 @@ fn message( ui.label(egui::RichText::new(when).font(regular(11.0)).color(t.text_tertiary)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if m.author == cx.me { - let menu = Id::new(("comment_msg_more_menu", id, i)); + let menu = Id::unique(("comment_msg_more_menu", id, i)); let more = hover_button( ui, - Id::new(("comment_msg_more", id, i)), + Id::unique(("comment_msg_more", id, i)), Icon::Dots, hovered || egui::Popup::is_id_open(&ctx, menu), tr("More"), @@ -682,10 +682,10 @@ fn message( } }); } - let menu = Id::new(("comment_msg_react_menu", id, i)); + let menu = Id::unique(("comment_msg_react_menu", id, i)); let react = hover_button( ui, - Id::new(("comment_msg_react", id, i)), + Id::unique(("comment_msg_react", id, i)), Icon::Reaction, hovered || egui::Popup::is_id_open(&ctx, menu), tr("Add reaction"), @@ -715,7 +715,7 @@ fn message( ui.horizontal(|ui| { ui.add_space(indent); ui.vertical(|ui| { - if composer(ui, Id::new(("comment_edit", id, i)), text, tr("Edit comment"), cx.people, cx.me, pick) { + if composer(ui, Id::unique(("comment_edit", id, i)), text, tr("Edit comment"), cx.people, cx.me, pick) { let mut p = message_params(id, i); p["text"] = json!(text.trim()); *act = Some(Act::Run("comment.edit", p)); @@ -828,12 +828,12 @@ pub fn popovers(app: &mut FigApp, ctx: &egui::Context) { let me = me(app); let mut pick = std::mem::take(&mut app.ui.comments.mention); let mut post = false; - egui::Area::new(Id::new("comment_draft")).order(egui::Order::Foreground).fixed_pos(popover_pos(app.to_screen(x, y), canvas)).show( + egui::Area::new(Id::unique("comment_draft")).order(egui::Order::Foreground).fixed_pos(popover_pos(app.to_screen(x, y), canvas)).show( ctx, |ui| { popover_frame(&t).show(ui, |ui| { ui.set_width(POPOVER_W - 24.0); - post = composer(ui, Id::new("comment_draft_text"), &mut text, tr("Add a comment"), &people, &me, &mut pick); + post = composer(ui, Id::unique("comment_draft_text"), &mut text, tr("Add a comment"), &people, &me, &mut pick); }); }, ); @@ -877,7 +877,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun let mut pick = std::mem::take(&mut app.ui.comments.mention); let mut act: Option = None; let cx = ThreadCtx { c: &c, me: &me, people: &people }; - egui::Area::new(Id::new("comment_thread")).order(egui::Order::Foreground).fixed_pos(popover_pos(anchor, bounds)).show(ctx, |ui| { + egui::Area::new(Id::unique("comment_thread")).order(egui::Order::Foreground).fixed_pos(popover_pos(anchor, bounds)).show(ctx, |ui| { popover_frame(&t).show(ui, |ui| { ui.set_width(POPOVER_W - 24.0); // Header: resolve, more, close. @@ -934,7 +934,7 @@ pub fn thread(app: &mut FigApp, ctx: &egui::Context, id: u64, anchor: Pos2, boun } Some(Act::CopyLink) => { if let Some(link) = app.run("comment.link", json!({"id": id})).and_then(|v| v.get("link").and_then(Value::as_str).map(str::to_string)) { - app.services.clipboard_set_text(&link); + app.clipboard_set_text(&link); app.toast(tr("Link copied")); } } @@ -1051,7 +1051,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { ui.horizontal(|ui| { ui.add_space(16.0); let w = ui.available_width() - 16.0; - if let Some(s) = crate::widgets::text_field(ui, Id::new("comment_search"), &app.ui.comments.search, w, tr("Search comments")) { + if let Some(s) = crate::widgets::text_field(ui, Id::unique("comment_search"), &app.ui.comments.search, w, tr("Search comments")) { app.ui.comments.search = s; } }); @@ -1178,7 +1178,7 @@ pub fn presenter(app: &mut FigApp, ctx: &egui::Context, frame: NodeId, place: cr let mut clicked = None; for p in &pins { let r = pin_rect(p.at); - egui::Area::new(Id::new(("present_comment_pin", p.id))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| { + egui::Area::new(Id::unique(("present_comment_pin", p.id))).order(egui::Order::Foreground).fixed_pos(r.min).show(ctx, |ui| { let (rr, resp) = ui.allocate_exact_size(r.size(), Sense::click()); paint_pin(ui.painter(), rr, p, open == Some(p.id) || resp.hovered()); if resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() { diff --git a/crates/ui-egui/src/component_panel.rs b/crates/ui-egui/src/component_panel.rs index 6caab6d5..06a0385c 100644 --- a/crates/ui-egui/src/component_panel.rs +++ b/crates/ui-egui/src/component_panel.rs @@ -79,7 +79,8 @@ fn combine_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) { widgets::separator(ui); widgets::sublabels(ui, tr("Description"), None); - if let Some(new) = widgets::multiline_field(ui, Id::new(("comp_desc", id)), description, tr("Describe how and when to use this component"), 3) { + if let Some(new) = widgets::multiline_field(ui, Id::unique(("comp_desc", id)), description, tr("Describe how and when to use this component"), 3) + { app.run("node.set", json!({"id": id, "props": {"description": new.chars().take(10_000).collect::()}})); } ui.add_space(8.0); @@ -91,7 +92,7 @@ fn description_ui(app: &mut FigApp, ui: &mut Ui, id: NodeId, description: &str) 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"); + let id = Id::unique("component_panel_local_components"); if let Some((k, list)) = ui.data(|d| d.get_temp::(id)) && k == key { @@ -267,7 +268,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { let header = row_rect(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, Icon::ComponentSet, COMPONENT); - if let Some(nn) = widgets::text_field(ui, Id::new(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) { + if let Some(nn) = widgets::text_field(ui, Id::unique(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } let def = defaults.get(k).or(vals.first()).cloned().unwrap_or_default(); @@ -292,7 +293,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { row(ui, 30.0, |ui| { ui.add_space(22.0); for v in &chunk { - if let Some(nv) = widgets::text_field(ui, Id::new(("vprop_val", owner, k.as_str(), v.as_str())), v, 62.0, "") + if let Some(nv) = widgets::text_field(ui, Id::unique(("vprop_val", owner, k.as_str(), v.as_str())), v, 62.0, "") && nv != *v { app.run("component.editProperty", json!({"id": owner, "name": k, "value": v, "newValue": nv})); @@ -320,7 +321,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { row(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, type_icon(d), COMPONENT); - if let Some(nn) = widgets::text_field(ui, Id::new(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) { + if let Some(nn) = widgets::text_field(ui, Id::unique(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } match d { @@ -330,7 +331,7 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } } PropDef::Text { default } => { - if let Some(v) = widgets::text_field(ui, Id::new(("prop_def", owner, k.as_str())), default, 84.0, tr("Default")) { + if let Some(v) = widgets::text_field(ui, Id::unique(("prop_def", owner, k.as_str())), default, 84.0, tr("Default")) { app.run("component.editProperty", json!({"id": owner, "name": k, "default": v})); } } @@ -701,7 +702,7 @@ fn instance_props(app: &mut FigApp, ui: &mut Ui, doc: &Document, n: &Node, comps PropDef::Text { .. } => { crate::variable_ui::bindable(app, ui, &[id], &bind_field, VariableType::String, w, |app, ui| { let cur = v.as_str().unwrap_or_default().to_string(); - if let Some(ns) = widgets::text_field(ui, Id::new(("iprop", id, k.as_str())), &cur, w, "") + if let Some(ns) = widgets::text_field(ui, Id::unique(("iprop", id, k.as_str())), &cur, w, "") && ns != cur { app.run("instance.setProperties", json!({"ids": [id], "properties": {k.clone(): ns}})); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4629b22c..97e62d1f 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1,6 +1,7 @@ //! The control channel's UI-thread side. The app crate accepts JSON lines on a localhost socket -//! and queues [`ControlRequest`]s; [`drain`] runs them at the start of a frame so replies reflect -//! the real UI. Methods: +//! and queues [`ControlRequest`]s. [`background`] runs the phase-safe part from every logic +//! callback; [`drain`] admits and answers the UI-only work inside a real frame so replies reflect +//! the drawn UI. Methods: //! //! - `engine.execute {command, params}` — run any command (no dialogs open) //! - `engine.commands {}` — every command with its params doc @@ -16,8 +17,9 @@ //! prototype playing (presenter or preview pane) //! - `ui.menu.invoke {id}` — a menu item exactly like clicking it (may open a dialog) //! - `ui.set {rightTab?, leftTab?, theme?, home?, homeSection?, homeSearch?, homeRecentsView?, homeRecentsSort?, homeRecentsOrder?, palette?, focusRegion?, preview?, presentScroll?, …}` -//! - `ui.screenshot {path?}` — PNG of the window (needs a visible window), written to `path` or -//! returned as base64 `data` +//! - `ui.screenshot {path?}` — PNG of the window, written to `path` or returned as base64 `data`; +//! a hidden window is asked to paint (`RequestPaintWhileHidden`) and the request expires once at +//! its deadline (native; the shared web runner does not implement hidden paint yet) //! - `ui.render {path?, scale?, ids?}` — PNG of the document (no UI chrome; works without a //! window), written to `path` or returned as base64 `data` //! - `window.new {path?, index?}` → `{window}` / `window.list {}` / `window.focus {id}` — several @@ -27,6 +29,8 @@ //! Any method takes `window` (an id from `window.list`) to run in that window, with its tabs and //! panels; without it, requests go to the window last in front. +use std::sync::{Arc, Mutex}; + use egui::Context; use figcraft_engine::cmd::comment::{Filter, Sort}; use figcraft_engine::{PointerEvent, PointerKind}; @@ -39,125 +43,436 @@ pub struct ControlReply { pub result: Result, } +/// Which side has finished a control request. +/// +/// The transport and the UI race to settle one request: while it is `Open`, either may cancel it +/// (the transport's timeout) or commit it (the UI's final reply). Once settled, neither side may +/// change it. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RequestState { + #[default] + Open, + /// The transport stopped waiting: no file may be written and no reply is wanted. + Cancelled, + /// The UI wrote the file (if any) and queued the reply before releasing the gate. + Replied, +} + +/// The gate the transport's timeout and the UI's final reply share for one request. +/// +/// Encoding happens outside it, so a slow PNG never blocks the timeout. The final file write and +/// reply enqueue happen under it, after rechecking cancellation and the deadline, so a timeout +/// cannot overtake a committed file and a cancellation cannot slip between the check and the +/// write. It is never held across socket I/O. +#[derive(Clone, Default)] +pub struct RequestGate { + state: Arc>, +} + +impl RequestGate { + /// Create a request gate in its default `Open` state. + pub fn new() -> Self { + Self::default() + } + + /// Return a copy of the gate's current request state. + pub fn state(&self) -> RequestState { + *self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// Whether the gate is `Cancelled`. + pub fn is_cancelled(&self) -> bool { + self.state() == RequestState::Cancelled + } + + /// Whether the gate is `Replied`. + pub fn is_replied(&self) -> bool { + self.state() == RequestState::Replied + } + + /// Cancel an open request, as the transport's timeout does. Returns the resulting state so a + /// timeout that lost the race knows to consume the already-queued reply. + pub fn cancel_if_open(&self) -> RequestState { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if *state == RequestState::Open { + *state = RequestState::Cancelled; + } + *state + } + + /// Run the final commit (file write and reply enqueue) only while the request is still open, + /// then mark it replied before releasing the gate. `None` means the transport cancelled it or + /// it was already committed; the closure must not have run then. + pub fn commit(&self, finish: impl FnOnce() -> T) -> Option { + let mut state = self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if *state != RequestState::Open { + return None; + } + let value = finish(); + *state = RequestState::Replied; + Some(value) + } +} + pub struct ControlRequest { pub id: Value, pub method: String, pub params: Value, pub reply: std::sync::mpsc::Sender, - /// The transport stops waiting after its timeout; discard requests still queued then. - pub cancelled: std::sync::Arc, + /// The transport stops waiting after its timeout; discard requests still queued then. The UI + /// passes the same gate to its screenshot's final commit. + pub state: RequestGate, } impl ControlRequest { + /// Whether this request's gate is `Cancelled`. pub fn is_cancelled(&self) -> bool { - self.cancelled.load(std::sync::atomic::Ordering::Relaxed) + self.state.is_cancelled() } } -/// Pending screenshot replies. -pub struct PendingShot { +/// One screenshot's completion slot. +/// +/// The renderer's one-shot callback only stores the raw image here and wakes the captured +/// viewport; it never encodes or writes a file, and it holds only a [`std::sync::Weak`] reference, so a late +/// capture after the request (or the app) is gone is a no-op. The live request owns the only +/// strong reference, together with the viewport `Context` clone used for the wakeup. +pub struct CaptureSlot { + inner: Mutex, + ctx: Context, + viewport: egui::ViewportId, +} + +#[derive(Default)] +struct SlotInner { + image: Option>, + closed: bool, + /// The one image was already handed to a frame (a second callback cannot store another). + taken: bool, +} + +impl CaptureSlot { + /// Create an empty capture slot for `viewport`, holding a clone of the UI context. + fn new(ctx: &Context, viewport: egui::ViewportId) -> Arc { + Arc::new(Self { inner: Mutex::new(SlotInner::default()), ctx: ctx.clone(), viewport }) + } + + /// The one-shot callback for `ViewportCommand::Screenshot`. + fn callback(slot: &Arc) -> egui::ScreenshotCallback { + let weak = Arc::downgrade(slot); + egui::ScreenshotCallback::new(move |image| { + if let Some(slot) = weak.upgrade() { + slot.complete(image); + } + }) + } + + /// Store the first delivered image only while the slot is open and untaken, ignoring later + /// or closed deliveries, then wake the viewport after releasing the lock. + pub(crate) fn complete(&self, image: Arc) { + { + let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if inner.closed || inner.image.is_some() || inner.taken { + return; + } + inner.image = Some(image); + } + // The lock is released before waking: the wakeup may draw the frame that drains this slot. + self.ctx.request_repaint_of(self.viewport); + } + + /// The captured image, once: a second callback or drain finds the slot taken. + fn take(&self) -> Option> { + let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if inner.taken { + return None; + } + let image = inner.image.take(); + inner.taken = image.is_some(); + image + } + + /// Whether the renderer delivered the image. A hidden window's frame must be asked to paint + /// again to drain it: the capture command's own paint was the one that produced it. + fn has_result(&self) -> bool { + self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner).image.is_some() + } + + /// Forget a cancelled, timed-out or superseded request: a late callback finds it closed. + fn close(&self) { + let mut inner = self.inner.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + inner.closed = true; + inner.image = None; + } +} + +/// A screenshot's capture-time geometry (the viewport surface and the scale it was admitted at). +/// Pixels are derived from these dimensions, never from a stale `ViewportInfo::inner_rect` or a +/// previous pass's viewport rectangle. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureSpec { + pub viewport: egui::ViewportId, + /// Expected readback pixels. + pub size_px: [usize; 2], + pub pixels_per_point: f32, + pub zoom: f32, +} + +/// Slack allowed for PNG headers and chunk framing past the raw pixel bytes. +const SHOT_PNG_OVERHEAD: usize = 64 * 1024; + +impl CaptureSpec { + /// Return the checked pixel byte count (`width × height × 4`), or `None` on overflow. + fn bytes(&self) -> Option { + self.size_px[0].checked_mul(self.size_px[1])?.checked_mul(4) + } + + /// The bytes this capture may hold at its peak. + /// + /// `shot_reply` first holds the readback image, the straight-RGBA copy handed to the encoder + /// and the PNG being built (two raw buffers plus the PNG), then drops the copy and builds the + /// base64 reply from the PNG (readback plus PNG plus four thirds of the PNG). The budget is the + /// larger of those phases, never a sum of all four buffers at once. The reservation is a bound, + /// not an estimate of the encoded size; `shot_reply` enforces the reply side of it and must + /// drop the straight-RGBA copy before encoding base64. + fn cost(&self) -> Option { + let raw = self.bytes()?; + let png = raw.checked_add(SHOT_PNG_OVERHEAD)?; + let encode_phase = raw.checked_mul(2)?.checked_add(png)?; + let reply = png.checked_div(3)?.checked_add(1)?.checked_mul(4)?; + let reply_phase = raw.checked_add(png)?.checked_add(reply)?; + Some(encode_phase.max(reply_phase)) + } +} + +/// One admitted screenshot: its deadline, capture identity and completion slot. +pub struct ShotRequest { pub id: Value, /// Where to write the PNG; `None`: return it as data. pub path: Option, pub reply: std::sync::mpsc::Sender, - /// When it was asked for (UI clock, seconds). - pub since: f64, - /// The window it captures. + /// The transport's timeout and this request's final write share this gate. + pub state: RequestGate, + /// Monotonic seconds ([`crate::FrameClock`]); covers admission, settling, retries and arrival. + pub deadline: f64, pub viewport: egui::ViewportId, + /// The window whose document generation the capture belongs to. + pub window: u64, + /// The document UID shown when the request was admitted. + pub document: u64, + /// That presentation's monotonic generation (the UID alone cannot tell A→B→A apart). + pub presentation: u64, + pub spec: Option, + /// The geometry epoch at issuance; a resize/DPI/zoom change rejects the pixels. + pub epoch: u64, + /// Completed, presented UI frames since admission (hidden logic ticks never count; a + /// discarded sizing pass inside a frame does not count separately from that frame's final + /// presented pass). + pub settle: u8, + /// The last completed-frame number counted into `settle`; `None` until the first observed + /// pass anchors the count. A frame is counted only once it has completed, so its first pass + /// is never treated as settled before the frame has presented a layout. + pub settle_frame: Option, + pub issued: bool, + pub slot: Option>, + /// Image bytes reserved for this request (in flight or completed but undrained). + pub reserved: usize, + pub retries: u8, +} + +impl ShotRequest { + /// Close and drop the capture slot, if any, and zero the reserved byte count. + fn release(&mut self) { + if let Some(slot) = self.slot.take() { + slot.close(); + } + self.reserved = 0; + } +} + +/// Screenshot state owned by the app: pending requests and the geometry epoch they were admitted +/// against. Pending and completed-but-undrained images both count toward the budget. +#[derive(Default)] +pub struct CaptureState { + pub(crate) shots: Vec, + /// The last UI geometry of each viewport and the epoch it defines (a resize, DPI change or + /// zoom change — even back — bumps it). Per viewport: one window's frames must not invalidate + /// another's captures. + pub(crate) geometry: std::collections::HashMap, + pub(crate) epoch: u64, } /// How long a screenshot waits for the window to deliver it (the control server gives up then too). -const SHOT_TIMEOUT: f64 = 60.0; +pub(crate) const SHOT_TIMEOUT: f64 = 60.0; -fn pending(ctx: &Context) -> std::sync::Arc>> { - ctx.data_mut(|d| d.get_temp_mut_or_insert_with(egui::Id::new("pending_shots"), || std::sync::Arc::new(std::sync::Mutex::new(Vec::new()))).clone()) +/// Completed real UI frames a capture waits for before it is issued, so fonts, layout and the +/// renderer have settled (hidden logic ticks do not count; a frame counts once, after presenting +/// its final pass). +const SHOT_SETTLE_PASSES: u8 = 3; + +/// The suite's capture budget, counted over in-flight and completed-but-undrained images. +pub(crate) const MAX_SHOTS: usize = 4; +const MAX_SHOT_IMAGE_BYTES: usize = 128 * 1024 * 1024; +const MAX_SHOT_TOTAL_BYTES: usize = 256 * 1024 * 1024; +/// A PNG larger than this is refused before its base64 reply is built (a PNG cannot normally grow +/// past its raw pixels, so only pathological content reaches it). +const MAX_SHOT_REPLY_BYTES: usize = 192 * 1024 * 1024; + +/// The capture geometry of the UI pass drawing now, if the surface size is known. The screenshot +/// is the whole viewport surface (a notch's area is still read back), and pixel bounds are rounded +/// up: a fractional DPI must not turn a valid surface into a one-pixel mismatch. +fn current_spec(ctx: &Context, viewport: egui::ViewportId) -> Option { + let surface = ctx.viewport_rect(); + let rect = if surface.width() >= 1.0 && surface.height() >= 1.0 { surface } else { ctx.content_rect() }; + let ppp = ctx.pixels_per_point(); + let zoom = ctx.zoom_factor(); + if !(rect.width().is_finite() && rect.height().is_finite() && ppp.is_finite() && zoom.is_finite()) { + return None; + } + let w = (rect.width() * ppp).ceil(); + let h = (rect.height() * ppp).ceil(); + if w < 1.0 || h < 1.0 || w > 32768.0 || h > 32768.0 { + return None; + } + Some(CaptureSpec { viewport, size_px: [w as usize, h as usize], pixels_per_point: ppp, zoom }) } -pub fn drain(app: &mut FigApp, ctx: &Context) { - let now = ctx.input(|i| i.time); - let list = pending(ctx); - // Finish screenshots that arrived (a window's capture can arrive in any window's input). - let shots: Vec<(egui::ViewportId, std::sync::Arc)> = ctx.input(|i| { - i.events - .iter() - .filter_map(|e| match e { - egui::Event::Screenshot { viewport_id, image, .. } => Some((*viewport_id, image.clone())), - _ => None, - }) - .collect() - }); - for (vp, img) in &shots { - list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).retain(|p| { - if p.viewport != *vp { - return true; - } - let res = shot_reply(img, p.path.as_deref()); - let _ = p.reply.send(ControlReply { id: p.id.clone(), result: res }); - false - }); +/// Send one reply carrying the request id, ignoring a disconnected receiver. +fn reply(r: &mut ShotRequest, result: Result) { + let _ = r.reply.send(ControlReply { id: r.id.clone(), result }); +} + +/// Admit a screenshot request. Geometry is filled in on the next UI pass; until then the request +/// is only counted, so a hidden window's capture can wait for its forced paint without inventing +/// geometry from a stale pass. +fn admit(app: &mut FigApp, r: ControlRequest, window: u64, now: f64) { + let canceled = r.is_cancelled(); + let ControlRequest { id, method: _, params, reply: tx, state } = r; + if app.capture.shots.len() >= MAX_SHOTS { + if !canceled { + let _ = tx.send(ControlReply { id, result: Err(format!("too many screenshots are pending (at most {MAX_SHOTS})")) }); + } + return; } - let reqs = std::mem::take(&mut app.control_queue); - for r in reqs { - if r.is_cancelled() { - continue; - } - // Requests for another window wait for its frame. - let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); - if target != app.windows.current { - if app.windows.exists(target) { - ctx.request_repaint_of(crate::windows::viewport(target)); - app.control_queue.push(r); - } else { - let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); - } - continue; - } - if r.method == "ui.screenshot" { - let path = r.params.get("path").and_then(Value::as_str).map(str::to_string); - let viewport = ctx.viewport_id(); - list.lock().unwrap_or_else(std::sync::PoisonError::into_inner).push(PendingShot { id: r.id, path, reply: r.reply, since: now, viewport }); - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::default())); - continue; - } - let res = handle(app, ctx, &r.method, &r.params); - let _ = r.reply.send(ControlReply { id: r.id, result: res }); - ctx.request_repaint(); - } - // The window delivers a screenshot some frames after it was asked for (once the GPU has copied - // it back), and only while frames are drawn: keep drawing until it arrives (a hidden window - // never delivers; its requests expire). - let mut list = list.lock().unwrap_or_else(std::sync::PoisonError::into_inner); - list.retain(|p| { - let live = now - p.since < SHOT_TIMEOUT; - if !live { - let _ = p.reply.send(ControlReply { id: p.id.clone(), result: Err("the window didn't deliver a screenshot (is it visible?)".into()) }); - } - live + let path = params.get("path").and_then(Value::as_str).map(str::to_string); + let (document, presentation) = app.windows.presentation(&app.session, window); + app.capture.shots.push(ShotRequest { + id, + path, + reply: tx, + state, + deadline: now + SHOT_TIMEOUT, + viewport: crate::windows::viewport(window), + window, + document, + presentation, + spec: None, + epoch: app.capture.epoch, + settle: 0, + settle_frame: None, + issued: false, + slot: None, + reserved: 0, + retries: 0, }); - if !list.is_empty() { - ctx.request_repaint(); +} + +/// Expire requests whose whole lifetime (admission, settling, retries, arrival) is over, exactly +/// once, and release their image reservation. +fn expire(app: &mut FigApp, ctx: &Context, now: f64) { + let mut i = 0; + while i < app.capture.shots.len() { + let (deadline, cancelled, window) = { + let r = &app.capture.shots[i]; + (r.deadline, r.state.is_cancelled(), r.window) + }; + let closed = !app.windows.exists(window); + if now >= deadline || cancelled || closed { + let mut r = app.capture.shots.remove(i); + r.release(); + if !cancelled { + let why = if closed { + "the window closed before the screenshot was captured" + } else { + "the window didn't deliver a screenshot (is it visible?)" + }; + reply(&mut r, Err(why.into())); + } + ctx.request_repaint(); + } else { + i += 1; + } } } -/// Methods only a drawn frame can answer: a capture of the window, raw window input and a new -/// window (the main window's frame opens it). Everything else acts on the app's state, which a -/// hidden window answers without a frame (#269, #720). -const NEEDS_FRAME: &[&str] = &["ui.screenshot", "ui.input", "window.new"]; +/// Whether the platform reports window `id` as visible; `None` means it has not said (a +/// minimised window can report no `visible` flag at all). +fn visibility(ctx: &Context, id: u64) -> Option { + ctx.input(|i| { + let info = i.raw.viewports.get(&crate::windows::viewport(id))?; + // Some platforms report `visible: true` for a minimised window; `minimized` is the more + // specific signal there. No information at all stays `None` (unknown), never visible. + if info.minimized == Some(true) { + return Some(false); + } + info.visible() + }) +} /// Whether window `id` draws no frames now: minimised, or covered by other windows (on macOS also /// on another Space). fn hidden(ctx: &Context, id: u64) -> bool { - ctx.input(|i| i.raw.viewports.get(&crate::windows::viewport(id)).and_then(egui::ViewportInfo::visible)) == Some(false) + visibility(ctx, id) == Some(false) } -/// A hidden window (minimised, or covered by other windows) draws no frames, but the desktop app's -/// `logic` still runs, for the main window even while every window is hidden: it answers the -/// requests for any hidden window, in that window's file, so an attached agent can edit, inspect, -/// type and save while the app is out of sight. What needs a frame ([`NEEDS_FRAME`]) is an error at -/// once instead of waiting for the window to show. A request for a window on screen waits for its -/// frame; another window on screen is asked to draw, which the main window's frame would do but -/// doesn't while it is hidden. A request for a window that doesn't exist is an error at once. -pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { +/// Send `RequestPaintWhileHidden` for work that needs a real UI pass (a queued capture or +/// synthetic input), and schedule a wakeup for the next capture deadline. Native eframe honours +/// this command; the shared web runner does not implement it yet, so a hidden browser tab remains +/// an explicit, recorded qualification gap (see `docs/egui-037-local-followups.md`). +fn request_paint(app: &FigApp, ctx: &Context) { + for r in &app.capture.shots { + if !app.windows.exists(r.window) { + continue; + } + // A capture that is still settling needs the real UI pass its geometry comes from; one + // whose pixels arrived while the window was hidden needs the pass that drains the slot. + // The capture command's own paint was drained when it produced the image. + if !r.issued || r.slot.as_ref().is_some_and(|s| s.has_result()) { + ctx.send_viewport_cmd_to(r.viewport, egui::ViewportCommand::RequestPaintWhileHidden); + } + } + for r in &app.control_queue { + if r.method == "ui.input" { + let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); + if hidden(ctx, target) { + ctx.send_viewport_cmd_to(crate::windows::viewport(target), egui::ViewportCommand::RequestPaintWhileHidden); + } + } + } + // A batch queued for a frame that has not drawn it yet also keeps asking. During a child + // window's pass `app.control_input` is that window's live pane; from the main window's logic + // it is the root's, so the viewport drawing now is the one to wake. + if !app.control_input.is_empty() { + ctx.send_viewport_cmd_to(ctx.viewport_id(), egui::ViewportCommand::RequestPaintWhileHidden); + } + for viewport in app.windows.pending_input_viewports() { + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::RequestPaintWhileHidden); + } + // A batch already handed to a hidden window's raw input (a logic-only pass takes it, egui + // saves it for the next pass) still needs the real pass that applies and acknowledges it. + if let Some(viewport) = app.applied_frame { + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::RequestPaintWhileHidden); + } + if let Some(d) = app.capture.shots.iter().map(|r| r.deadline).reduce(f64::min) { + crate::repaint_in(ctx, (d - app.clock.now()).clamp(0.0, SHOT_TIMEOUT)); + } +} + +/// Work a logic-only callback can do safely: expire deadlines, admit or park screenshot requests, +/// and answer state-only control requests for hidden windows. Input/geometry are never read here. +pub fn background(app: &mut FigApp, ctx: &Context) { + let now = app.clock.now(); + expire(app, ctx, now); for r in std::mem::take(&mut app.control_queue) { if r.is_cancelled() { continue; @@ -167,18 +482,32 @@ pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); continue; } - if !hidden(ctx, target) { - // Another window on screen draws only when asked: while the main window is hidden no - // frame of it does that (`drain`), so ask here. - if target != app.windows.current { - ctx.request_repaint_of(crate::windows::viewport(target)); - } + let is_hidden = hidden(ctx, target); + if r.method == "ui.screenshot" && visibility(ctx, target) != Some(true) { + // Hidden, minimised, or with visibility the platform has not reported: admit it now + // so the request gets its deadline and a forced paint. Waiting for a frame that may + // never draw would leave it queued past the transport's own timeout with no reply. + admit(app, r, target, now); + continue; + } + if !is_hidden && target == app.windows.current { + // The window's frame admits and issues it against real geometry. app.control_queue.push(r); continue; } - if NEEDS_FRAME.contains(&r.method.as_str()) { - let error = - format!("window {target} is minimised or hidden: `{}` needs it on screen (`ui.render` draws the document without it)", r.method); + if !is_hidden { + // Another visible window draws only when asked. + ctx.request_repaint_of(crate::windows::viewport(target)); + app.control_queue.push(r); + continue; + } + if r.method == "ui.input" { + // Keep one synthetic batch until a real UI pass consumes it. + app.control_queue.push(r); + continue; + } + if r.method == "window.new" { + let error = format!("window {target} is minimised or hidden: `window.new` needs it on screen"); let _ = r.reply.send(ControlReply { id: r.id, result: Err(error) }); continue; } @@ -186,37 +515,400 @@ pub fn drain_hidden(app: &mut FigApp, ctx: &Context) { // ends in recovery copies and leaves every window its own state. let res = crate::windows::as_window(app, target, |app| { let mut res = Err("internal error".to_string()); + // Every control-channel gesture is fenced from the user's system clipboard. + let before = app.control_synthetic; + app.control_synthetic = true; crate::windows::guard(app, ctx, |app| res = handle(app, ctx, &r.method, &r.params)); + app.control_synthetic = before; res }); let _ = r.reply.send(ControlReply { id: r.id, result: res.unwrap_or_else(|| Err(format!("no window {target}"))) }); } + request_paint(app, ctx); +} + +/// The UI phase's control work: drain completed captures, admit and settle queued screenshots +/// against this pass's geometry, and run state-only requests for the window drawing now. +pub fn drain(app: &mut FigApp, ctx: &Context) { + let now = app.clock.now(); + let viewport = ctx.viewport_id(); + // This pass's geometry defines the epoch: a resize, DPI change or zoom change (back or not) + // invalidates captures issued under the old one. + let (spec, epoch) = pass_geometry(app, ctx, viewport); + expire(app, ctx, now); + + // Drain completed captures first, so their budget is free before new requests are admitted. + drain_completed(app, ctx, viewport, now, epoch); + + // Admit queued captures for the window drawing now; other windows keep waiting for their frame. + admit_queued(app, ctx, viewport, now); + + // Fill in this pass's geometry, enforce the byte budget (a post-callback check alone would + // not bound peak memory), and release anything over it before issuing. + enforce_capture_budget(app, viewport, spec); + + // Settle on completed UI frames only, then issue once. (The screenshot command itself forces + // the paint that delivers it.) The request's document and window were fixed at admission and + // are never rewritten here: a tab switched during settling is an error, not a capture of the + // new one. + // + // `cumulative_frame_nr` counts frames whose final (presented) pass has finished. A frame whose + // first pass calls `request_discard` is therefore counted once, after it presents its layout, + // not when its discarded sizing pass runs. Passes after the first (`current_pass_index > 0`) + // belong to such a frame and never add another count. + settle_and_issue(app, ctx, viewport, now, epoch, spec); + request_paint(app, ctx); +} + +/// Establish this pass's screenshot geometry and epoch: a resize, DPI change or zoom change (even +/// back) bumps the epoch captures issued under the old one were admitted against. +fn pass_geometry(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId) -> (Option, u64) { + let spec = current_spec(ctx, viewport); + if let Some(spec) = spec { + let changed = app.capture.geometry.get(&viewport).is_none_or(|(old, _)| *old != spec); + if changed { + app.capture.epoch = app.capture.epoch.wrapping_add(1); + app.capture.geometry.insert(viewport, (spec, app.capture.epoch)); + } + } + let epoch = app.capture.geometry.get(&viewport).map_or(0, |(_, e)| *e); + (spec, epoch) +} + +/// Take the image delivery for the shot at `index`; `None` when no image has arrived yet. +fn arrived_shot(app: &mut FigApp, index: usize) -> Option<(ShotRequest, Arc)> { + let slot = app.capture.shots.get(index)?.slot.clone()?; + let image = slot.take()?; + let mut r = app.capture.shots.remove(index); + r.release(); + Some((r, image)) +} + +/// Drain images delivered for this viewport: reply, re-queue for a geometry retry, or drop a +/// cancelled capture. Each viewport drains its own captures: their geometry and document differ. +fn drain_completed(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64) { + let mut i = 0; + while i < app.capture.shots.len() { + if app.capture.shots[i].viewport != viewport { + i += 1; + continue; + } + let Some((r, image)) = arrived_shot(app, i) else { + i += 1; + continue; + }; + settle_arrival(app, ctx, viewport, now, epoch, r, &image); + ctx.request_repaint(); + } +} + +/// Whether the pass geometry differs from the one the capture was issued at (even back to an old +/// size): the pixels cannot be accepted for the admitted surface. +fn geometry_moved(r: &ShotRequest, current: CaptureSpec, epoch: u64) -> bool { + r.epoch != epoch || r.spec.is_none_or(|s| s.size_px != current.size_px || s.pixels_per_point != current.pixels_per_point) +} + +/// The admitted surface is in whole UI points; the readback is in physical pixels. Up to one +/// pixel of rounding either way is the same surface, not a scale change. The difference is +/// absolute: an undersized readback is a scale mismatch too. +fn scale_mismatch(image: &egui::ColorImage, spec: CaptureSpec) -> bool { + image.size[0].abs_diff(spec.size_px[0]) > 1 || image.size[1].abs_diff(spec.size_px[1]) > 1 +} + +/// Re-queue a capture against the new pass, or hand it back when its retries are exhausted. +fn requeue_capture(app: &mut FigApp, mut r: ShotRequest, current: CaptureSpec, epoch: u64) -> Option { + if r.retries >= 2 { + return Some(r); + } + r.retries += 1; + r.spec = Some(current); + r.epoch = epoch; + r.reserved = r.spec.and_then(|s| s.cost()).unwrap_or(0); + r.settle = 0; + r.settle_frame = None; + r.issued = false; + app.capture.shots.push(r); + None +} + +/// Settle an arrived image: reply with it, retry against the new pass on a geometry/scale change, +/// or drop it when the transport cancelled or the window/document moved on. +fn settle_arrival(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, mut r: ShotRequest, image: &egui::ColorImage) { + let cancelled = r.state.is_cancelled(); + let current = current_spec(ctx, viewport); + let (document, presentation) = app.windows.presentation(&app.session, r.window); + if cancelled { + // The transport stopped waiting: no reply, no file. + return; + } + if now >= r.deadline { + reply(&mut r, Err("the window didn't deliver a screenshot in time (is it visible?)".into())); + return; + } + if r.window != app.windows.current || r.document != document || r.presentation != presentation { + reply(&mut r, Err("the document changed while the screenshot was being captured".into())); + return; + } + let Some(current) = current else { + // Fail closed: with no representable current geometry the pixels cannot be proven to + // belong to the surface that was admitted, so they are never the capture's reply. + reply(&mut r, Err("the window's current geometry is unavailable (is it minimised?)".into())); + return; + }; + if geometry_moved(&r, current, epoch) { + // Geometry moved between issuance and arrival (even back): retry against the new pass, + // never accept the old pixels. Document changes are errors above. + if let Some(mut r) = requeue_capture(app, r, current, epoch) { + reply(&mut r, Err("the window kept changing size while the screenshot was captured".into())); + } + return; + } + if let Some(spec) = r.spec + && scale_mismatch(image, spec) + { + if let Some(mut r) = requeue_capture(app, r, current, epoch) { + reply( + &mut r, + Err(format!( + "the screenshot is {}x{} but the window is {}x{} (a scale mismatch)", + image.size[0], image.size[1], spec.size_px[0], spec.size_px[1] + )), + ); + } + return; + } + // Encode outside the shared gate: a slow PNG must not hold the transport's timeout. The + // fresh clock read and final write happen under the gate, so a timeout that arrives while the + // image is being encoded either wins (no file, no reply) or finds the committed reply. + let encoded = encode_shot(image, r.path.as_deref()); + commit_shot(&r.state, &r.id, &r.reply, r.deadline, &app.clock, encoded); +} + +/// Admit queued captures for the window drawing now; other windows keep waiting for their frame. +fn admit_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64) { + for r in std::mem::take(&mut app.control_queue) { + route_queued(app, ctx, viewport, now, r); + } +} + +/// Route one queued control request: retarget another window, admit a capture, or run a +/// state-only request for the window drawing now. +fn route_queued(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, r: ControlRequest) { + if r.is_cancelled() { + return; + } + let target = r.params.get("window").and_then(Value::as_u64).unwrap_or(app.windows.focused); + if target != app.windows.current { + if app.windows.exists(target) { + ctx.request_repaint_of(crate::windows::viewport(target)); + app.control_queue.push(r); + } else { + let _ = r.reply.send(ControlReply { id: r.id, result: Err(format!("no window {target}")) }); + } + return; + } + if r.method == "ui.screenshot" { + admit(app, r, target, now); + return; + } + // Every control-channel gesture is fenced from the user's system clipboard (the flag is + // restored so a batch already in a frame's raw input keeps its own provenance). + let before = app.control_synthetic; + app.control_synthetic = true; + let res = handle(app, ctx, &r.method, &r.params); + app.control_synthetic = before; + if r.method == "ui.input" && std::mem::take(&mut app.control_input_queued) { + // Acknowledge once the pass that applies the batch is presented, never from a sizing + // pass whose input is thrown away. + app.input_acks.push(crate::PendingAck { viewport, tx: r.reply, reply: ControlReply { id: r.id, result: res } }); + } else { + let _ = r.reply.send(ControlReply { id: r.id, result: res }); + } + ctx.request_repaint(); +} + +/// Reserve this request's actual surface against the capture budget; `true` when it must be +/// removed for exceeding it. +fn reserve_for_spec(r: &mut ShotRequest, spec: Option, total: usize) -> bool { + if !r.issued && r.spec != spec { + // A request can resize during its settling frames. Reserve and validate this + // actual surface before issuance, never the old (possibly much smaller) one. + r.spec = spec; + r.settle = 0; + r.settle_frame = None; + if spec.is_none() { + r.reserved = 0; + } + } + let Some(spec) = r.spec else { return false }; + let raw = spec.bytes().unwrap_or(usize::MAX); + let cost = spec.cost().unwrap_or(usize::MAX); + if raw > MAX_SHOT_IMAGE_BYTES || total.saturating_sub(r.reserved).saturating_add(cost) > MAX_SHOT_TOTAL_BYTES { + reply(r, Err(format!("the screenshot needs {cost} bytes, over the capture budget"))); + return true; + } + if r.reserved != cost { + r.reserved = cost; + } + false +} + +/// Fill in this pass's geometry and enforce the byte budget (a post-callback check alone would +/// not bound peak memory), releasing anything over it before issuing. +fn enforce_capture_budget(app: &mut FigApp, viewport: egui::ViewportId, spec: Option) { + let mut i = 0; + while i < app.capture.shots.len() { + let total: usize = app.capture.shots.iter().map(|s| s.reserved).sum(); + let remove = { + let r = &mut app.capture.shots[i]; + if r.viewport != viewport { + i += 1; + continue; + } + reserve_for_spec(r, spec, total) + }; + if remove { + let mut r = app.capture.shots.remove(i); + r.release(); + } else { + i += 1; + } + } +} + +/// Count one completed frame into the capture's settling, reporting whether it may now be issued. +fn settled_for_issue(r: &mut ShotRequest, frame_nr: u64) -> bool { + match r.settle_frame { + None => { + // Anchor the count at the first pass that sees this request's geometry; this + // frame has not completed yet. + r.settle_frame = Some(frame_nr); + false + } + Some(seen) if frame_nr > seen => { + r.settle_frame = Some(frame_nr); + r.settle = r.settle.saturating_add(1); + r.settle >= SHOT_SETTLE_PASSES + } + _ => false, + } +} + +/// Settle on completed UI frames only, then issue once (against this pass's surface). +fn settle_and_issue(app: &mut FigApp, ctx: &Context, viewport: egui::ViewportId, now: f64, epoch: u64, spec: Option) { + let first_pass = ctx.current_pass_index() == 0; + let frame_nr = ctx.cumulative_frame_nr_for(viewport); + for r in &mut app.capture.shots { + if !first_pass || r.viewport != viewport || r.issued || r.spec.is_none() { + continue; + } + if r.state.is_cancelled() || now >= r.deadline { + continue; + } + if !settled_for_issue(r, frame_nr) { + continue; + } + let slot = CaptureSlot::new(ctx, viewport); + r.slot = Some(slot.clone()); + r.epoch = epoch; + // Issued against this pass's surface: the size the readback is expected at. + if let Some(spec) = spec { + r.spec = Some(spec); + } + r.issued = true; + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::Screenshot(CaptureSlot::callback(&slot))); + } +} + +/// An encoded screenshot that has not been written or returned yet. Encoding is the expensive +/// part, so it is done before the request's shared commit gate is taken. +enum EncodedShot { + /// The PNG for a `path` request; writing it is part of the final commit. + File { path: String, png: Vec, width: usize, height: usize }, + /// The complete base64 reply for a request without a path. + Data(Value), +} + +impl EncodedShot { + /// Deliver the encoded shot: for `File`, write the PNG through `file_access` and return its + /// path and dimensions; for `Data`, return the prebuilt result. + fn finish(self) -> Result { + match self { + Self::File { path, png, width, height } => { + figcraft_engine::file_access::write(&path, &png).map_err(|e| e.to_string())?; + Ok(json!({"path": path, "width": width, "height": height})) + } + Self::Data(result) => Ok(result), + } + } } /// A screenshot's reply: the PNG written to `path` (through the automation roots, when the app -/// has them), or returned as base64 `data`. +/// has them), or returned as base64 `data`. Control requests split this into [`encode_shot`] and +/// [`commit_shot`]; encoding and automation-root tests use the whole operation. +#[cfg(test)] fn shot_reply(img: &egui::ColorImage, path: Option<&str>) -> Result { + encode_shot(img, path)?.finish() +} + +/// Encode the straight-alpha PNG and the data reply. No request gate is held here. +fn encode_shot(img: &egui::ColorImage, path: Option<&str>) -> Result { let [w, h] = img.size; - let mut rgba = Vec::with_capacity(w * h * 4); + let raw = w.checked_mul(h).and_then(|n| n.checked_mul(4)).ok_or("bad screenshot size")?; + if raw > MAX_SHOT_IMAGE_BYTES { + return Err(format!("the screenshot is {raw} bytes, over the capture budget")); + } + let mut rgba = Vec::with_capacity(raw); for p in &img.pixels { rgba.extend_from_slice(&p.to_srgba_unmultiplied()); } let buf = image::RgbaImage::from_raw(w as u32, h as u32, rgba).ok_or("bad screenshot buffer")?; let mut png = std::io::Cursor::new(Vec::new()); buf.write_to(&mut png, image::ImageFormat::Png).map_err(|e| e.to_string())?; + // The encoder copy is not needed once the PNG exists. Drop it before the (possibly bigger) + // base64 reply is built so the peak is the larger phase accounted by `CaptureSpec::cost`, + // not the sum of all four buffers. + drop(buf); let png = png.into_inner(); match path { - Some(path) => { - figcraft_engine::file_access::write(path, &png).map_err(|e| e.to_string())?; - Ok(json!({"path": path, "width": w, "height": h})) - } + Some(path) => Ok(EncodedShot::File { path: path.to_string(), png, width: w, height: h }), None => { + if png.len() > MAX_SHOT_REPLY_BYTES { + // Refuse before the base64 string (4/3 of the PNG) is allocated. + return Err(format!("the encoded screenshot is {} bytes, over the reply budget", png.len())); + } use base64::Engine as _; - Ok(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&png)})) + Ok(EncodedShot::Data(json!({"format": "png", "width": w, "height": h, "data": base64::engine::general_purpose::STANDARD.encode(&png)}))) } } } +/// Commit an encoded screenshot's file and reply while holding the request's shared gate. The +/// cancellation and a fresh deadline are rechecked under the gate: if the transport gave up while +/// the image was being encoded, no file is written; a commit that gets there first queues its +/// reply before the timeout can mark the request cancelled. +fn commit_shot( + gate: &RequestGate, + id: &Value, + reply: &std::sync::mpsc::Sender, + deadline: f64, + clock: &crate::FrameClock, + encoded: Result, +) -> bool { + gate.commit(|| { + // Read the clock after the gate is held: a request whose deadline passed while it waited + // for the gate must not write its file. + let result = if clock.now() < deadline { + encoded.and_then(EncodedShot::finish) + } else { + Err("the window didn't deliver a screenshot in time (is it visible?)".into()) + }; + // Enqueue before the gate is released and before `Replied` is visible to the transport. + let _ = reply.send(ControlReply { id: id.clone(), result }); + }) + .is_some() +} + /// A controller button given as its standard-layout index, its key trigger name ("Gamepad A") or /// its short name ("A", "LB", "Up"). fn pad_name(v: &Value) -> Option<&'static str> { @@ -238,11 +930,16 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul "engine.execute" => { let cmd = p.get("command").and_then(Value::as_str).ok_or("`command` required")?; let params = p.get("params").cloned().unwrap_or(json!({})); + let layers_generation = app.session.clipboard.layers_generation; let r = app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()); app.clear_comment_draft_after_tool_set(cmd, r.is_ok()); + app.invalidate_synthetic_copy(layers_generation); // Programmatic calls never open dialogs. app.session.ui_requests.retain(|u| !u.kind.starts_with("dialog.")); app.handle_ui_requests(); + // A programmatic `doc.switch` changes the presented file just like a tab click: the + // capture generation must see it even though this path bypasses `FigApp::run`. + app.observe_presentation(); r } "engine.commands" => Ok(Value::Array( @@ -320,8 +1017,9 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul app.session.execute(cmd, ¶ms).map_err(|e| e.to_string()) } _ => { - // Number keys: opacity (two in quick succession make an exact percentage). - let now = ctx.input(|i| i.time); + // Number keys: opacity (two in quick succession make an exact percentage). A + // hidden window's logic tick has a stale `ctx.input(i.time)`. + let now = app.clock.now(); match egui::Key::from_name(name) { Some(k) if m.is_none() && crate::shortcuts::opacity_digit(app, k, now) => Ok(json!({"opacity": true})), _ => Err(format!("no shortcut `{key}`")), @@ -367,6 +1065,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul // Content moves by `d` points (negative y scrolls down, like a wheel turned towards you). events.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Wheel, delta: d.to_vec2(), phase: egui::TouchPhase::Move, modifiers: mods, @@ -397,7 +1096,7 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul (None, None) => return Err("controller buttons go to a prototype that is playing: run proto.present or proto.preview".into()), } } - app.control_input.extend(events); + app.control_input_queued = app.push_control_input(events); ctx.request_repaint(); Ok(json!({"queued": list.len().min(256)})) } @@ -764,3 +1463,7 @@ fn pointer(app: &mut FigApp, p: &Value) -> Result { #[cfg(test)] #[path = "control_roots_tests.rs"] mod roots_tests; + +#[cfg(test)] +#[path = "control_capture_tests.rs"] +mod capture_tests; diff --git a/crates/ui-egui/src/control_capture_tests.rs b/crates/ui-egui/src/control_capture_tests.rs new file mode 100644 index 00000000..6068ce52 --- /dev/null +++ b/crates/ui-egui/src/control_capture_tests.rs @@ -0,0 +1,657 @@ +//! Screenshot capture (the local completion-slot adapter): per-request identity and reverse +//! completion, cancellation/late delivery, budget admission, straight-alpha round-trips and zero +//! retained bytes after draining. Driven headless through the real UI pass, so geometry comes from +//! the frame that issues the capture, not from a stale logic tick. + +use std::sync::Arc; + +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::control::{CaptureSpec, ControlReply, ControlRequest}; +use crate::layers_drag_tests::Ui; + +/// Queue a `ui.screenshot` request in the test app and return its reply receiver. +fn push(u: &mut Ui, id: u64, params: serde_json::Value) -> std::sync::mpsc::Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(ControlRequest { id: json!(id), method: "ui.screenshot".into(), params, reply: tx, state: Default::default() }); + rx +} + +/// Run UI passes until every admitted capture has been issued (settled on real UI passes). +fn settle_to_issue(u: &mut Ui, n: usize) { + for _ in 0..12 { + u.frame(vec![]); + if u.app.capture.shots.len() == n && u.app.capture.shots.iter().all(|r| r.issued) { + return; + } + } + panic!("{n} captures were not issued: {} admitted", u.app.capture.shots.len()); +} + +/// A real changed viewport size, without allocating a readback image of that size. +fn resized_frame(u: &mut Ui, size: egui::Vec2) -> egui::FullOutput { + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }; + let app = &mut u.app; + let mut out = u.ctx.run_ui(input, |ui| app.ui(ui)); + out.textures_delta.clear(); + out +} + +/// Whether any viewport in the frame output issues a `Screenshot` command. +fn has_screenshot(out: &egui::FullOutput) -> bool { + out.viewport_output.values().any(|v| v.commands.iter().any(|c| matches!(c, egui::ViewportCommand::Screenshot(_)))) +} + +/// Asserts reverse capture completion replies only to the matching request, then drains both +/// requests. +#[test] +fn two_captures_correlate_by_request_across_reverse_completion() { + let mut u = Ui::new(Session::empty()); + let first = push(&mut u, 1, json!({})); + let second = push(&mut u, 2, json!({})); + settle_to_issue(&mut u, 2); + let [a, b] = [0, 1].map(|i| u.app.capture.shots[i].spec.expect("geometry")); + assert_eq!(a.viewport, b.viewport); + assert!(a.size_px[0] > 0 && a.size_px[1] > 0); + // The second capture arrives first; only its own request is completed. + let slot_b = u.app.capture.shots[1].slot.clone().expect("issued"); + slot_b.complete(Arc::new(egui::ColorImage::filled(b.size_px, egui::Color32::GREEN))); + u.frame(vec![]); + let got = second.try_recv().expect("the second capture's reply"); + assert_eq!(got.id, json!(2)); + assert!(first.try_recv().is_err(), "the first is still in flight"); + assert_eq!(u.app.capture.shots.len(), 1); + let slot_a = u.app.capture.shots[0].slot.clone().expect("issued"); + slot_a.complete(Arc::new(egui::ColorImage::filled(a.size_px, egui::Color32::RED))); + u.frame(vec![]); + let got = first.try_recv().expect("the first capture's reply"); + assert_eq!(got.id, json!(1)); + assert!(u.app.capture.shots.is_empty(), "no retained requests"); +} + +/// Asserts a cancelled capture gets no reply and its slot is released. +#[test] +fn a_cancelled_capture_never_replies_or_writes() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 3, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + u.app.capture.shots[0].state.cancel_if_open(); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::BLUE))); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "a cancelled transport gets no reply"); + assert!(u.app.capture.shots.is_empty(), "the slot is released"); +} + +/// A unique directory for a commit test; the caller removes it. +fn commit_dir(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("figcraft-capture-commit-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + dir +} + +/// Asserts cancellation after encoding still prevents the final file write and reply. +#[test] +fn a_cancellation_after_encoding_prevents_the_final_write() { + let dir = commit_dir("cancelled"); + let path = dir.join("shot.png"); + let gate = crate::control::RequestGate::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let clock = crate::FrameClock::default(); + let img = egui::ColorImage::filled([2, 2], egui::Color32::RED); + // Encoding is complete before the transport cancels: the final gate must still refuse the file. + let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded"); + assert!(!path.exists(), "encoding alone does not write"); + assert_eq!(gate.cancel_if_open(), crate::control::RequestState::Cancelled); + assert!(!super::commit_shot(&gate, &json!(40), &tx, 100.0, &clock, Ok(encoded)), "a cancelled request does not commit"); + assert!(!path.exists(), "the cancellation landed before the final write"); + assert!(rx.try_recv().is_err(), "a cancelled transport gets no reply"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a capture that arrives past its deadline reports a timeout and writes no PNG. +#[test] +fn a_real_arrival_that_crosses_the_deadline_does_not_write_its_png() { + let mut u = Ui::new(Session::empty()); + let dir = commit_dir("settle-expired"); + let path = dir.join("shot.png"); + let rx = push(&mut u, 60, json!({"path": path.to_string_lossy()})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + let deadline = u.app.capture.shots[0].deadline; + let epoch = u.app.capture.shots[0].epoch; + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + // A frame's saved time may precede encoding; the final write must use a fresh clock. + let stale_now = u.app.clock.now(); + u.app.clock.set_test_now(Some(deadline + 1.0)); + super::drain_completed(&mut u.app, &u.ctx, egui::ViewportId::ROOT, stale_now, epoch); + let reply = rx.try_recv().expect("the late commit reports a timeout"); + assert!(reply.result.unwrap_err().contains("didn't deliver")); + assert!(!path.exists(), "the final deadline check must precede the PNG write"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts an expiry during encoding reports the timeout under the gate, marks the request +/// replied and writes no file. +#[test] +fn an_expiry_during_encoding_prevents_the_final_write_and_reports_timeout() { + let dir = commit_dir("expired"); + let path = dir.join("shot.png"); + let gate = crate::control::RequestGate::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut clock = crate::FrameClock::default(); + clock.set_test_now(Some(5.0)); + let img = egui::ColorImage::filled([2, 2], egui::Color32::RED); + let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded"); + // The gate is open, but the fresh clock read under it is already at the deadline. + assert!(super::commit_shot(&gate, &json!(41), &tx, 5.0, &clock, Ok(encoded)), "the open request is settled once"); + let reply = rx.try_recv().expect("a timeout reply"); + assert_eq!(reply.id, json!(41)); + assert!(reply.result.unwrap_err().contains("didn't deliver"), "the late commit reports the same timeout"); + assert!(!path.exists(), "an expired request never writes its file"); + assert!(gate.is_replied(), "the timeout reply was committed under the gate"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a commit before the deadline writes the file and queues one reply, and a second +/// commit is refused. +#[test] +fn a_commit_before_the_deadline_writes_and_queues_its_reply_once() { + let dir = commit_dir("committed"); + let path = dir.join("shot.png"); + let gate = crate::control::RequestGate::new(); + let (tx, rx) = std::sync::mpsc::channel(); + let mut clock = crate::FrameClock::default(); + clock.set_test_now(Some(1.0)); + let img = egui::ColorImage::filled([2, 2], egui::Color32::GREEN); + let encoded = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded"); + assert!(super::commit_shot(&gate, &json!(42), &tx, 10.0, &clock, Ok(encoded)), "an open request commits"); + let reply = rx.try_recv().expect("the committed reply"); + assert_eq!(reply.id, json!(42)); + assert_eq!(reply.result.expect("written")["path"], json!(path.to_string_lossy())); + assert!(path.is_file(), "the file is written under the gate"); + assert!(gate.is_replied(), "a timeout now consumes the committed reply"); + // A second commit is refused: the gate is no longer open. + let again = super::encode_shot(&img, Some(path.to_str().expect("utf-8 path"))).expect("encoded"); + assert!(!super::commit_shot(&gate, &json!(43), &tx, 10.0, &clock, Ok(again))); + assert!(rx.try_recv().is_err(), "no duplicate reply"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Asserts a capture past its deadline sends one timeout reply, releases its slot, and late +/// pixels are ignored. +#[test] +fn a_capture_after_its_deadline_replies_once_and_late_pixels_are_inert() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 4, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + u.advance_clock(crate::control::SHOT_TIMEOUT + 1.0); + u.frame(vec![]); + let reply = rx.try_recv().expect("timed out"); + assert_eq!(reply.id, json!(4)); + assert!(reply.result.unwrap_err().contains("didn't deliver"), "one timeout reply"); + assert!(u.app.capture.shots.is_empty(), "released"); + // The renderer finishing afterwards finds a closed slot: no reply, no write, no panic. + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "a late capture is inert"); +} + +/// Asserts the snapshot count budget rejects an over-budget request before issuing, and every +/// admitted capture still completes under its own id. +#[test] +fn a_snapshot_batch_is_capped_before_issuing_over_the_count_budget() { + let mut u = Ui::new(Session::empty()); + let mut rxs: Vec<_> = (0..crate::control::MAX_SHOTS as u64).map(|i| push(&mut u, 10 + i, json!({}))).collect(); + settle_to_issue(&mut u, crate::control::MAX_SHOTS); + let over = push(&mut u, 99, json!({})); + u.frame(vec![]); + let reply = over.try_recv().expect("rejected at admission"); + assert!(reply.result.unwrap_err().contains("too many screenshots"), "budget error"); + assert_eq!(u.app.capture.shots.len(), crate::control::MAX_SHOTS); + // Every admitted capture still completes with its own identity. Each drain removes its + // request, so always complete the first remaining one: the vector shifts under the loop. + for (i, rx) in rxs.iter_mut().enumerate() { + let spec = u.app.capture.shots.first().expect("one left").spec.expect("geometry"); + let slot = u.app.capture.shots.first().and_then(|r| r.slot.clone()).expect("issued"); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + u.frame(vec![]); + assert_eq!(rx.try_recv().expect("answered").id, json!(10 + i as u64), "request {i} keeps its identity"); + } + rxs.clear(); +} + +/// Asserts an undersized readback is not accepted but reissued with one retry and no reply. +#[test] +fn an_undersized_readback_is_reissued_instead_of_accepted() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 30, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + assert!(spec.size_px[0] > 2, "room for a two-pixel undersized readback"); + // Smaller by two pixels on one axis: a scale mismatch, not the admitted surface. + slot.complete(Arc::new(egui::ColorImage::filled([spec.size_px[0] - 2, spec.size_px[1]], egui::Color32::RED))); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "the undersized pixels are not accepted"); + let r = u.app.capture.shots.first().expect("reissued"); + assert_eq!(r.retries, 1, "one retry, not an error"); + assert!(!r.issued, "waits for the settled geometry again"); +} + +/// Asserts a one-pixel rounding difference is accepted as the same surface. +#[test] +fn a_one_pixel_rounding_difference_is_still_the_same_surface() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 31, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + slot.complete(Arc::new(egui::ColorImage::filled([spec.size_px[0] - 1, spec.size_px[1] + 1], egui::Color32::RED))); + u.frame(vec![]); + let reply = rx.try_recv().expect("a pixel of rounding is accepted"); + assert_eq!(reply.id, json!(31)); + assert!(reply.result.is_ok()); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +/// Asserts an A→B→A tab switch invalidates the admitted capture with "document changed". +#[test] +fn a_document_a_to_b_to_a_switch_is_not_the_admitted_capture() { + let mut s = Session::empty(); + s.execute("file.new", &json!({"name": "A"})).unwrap(); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let (a, b) = (s.docs.iter().position(|d| d.doc.name == "A").expect("file A"), s.docs.iter().position(|d| d.doc.name == "B").expect("file B")); + let mut u = Ui::new(s); + u.app.session.active = a; + let rx = push(&mut u, 32, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + // A→B→A before the callback is drained: the final UID is A again, but its pixels were never + // what this capture asked for. + assert!(u.app.run("doc.switch", json!({"index": b})).is_some()); + assert!(u.app.run("doc.switch", json!({"index": a})).is_some()); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + u.frame(vec![]); + let reply = rx.try_recv().expect("answered"); + assert!(reply.result.unwrap_err().contains("document changed"), "the A→B→A switch invalidates the capture"); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +/// Asserts a geometry change bumps the capture epoch and reissues instead of accepting old +/// pixels. +#[test] +fn a_geometry_change_reissues_instead_of_accepting_old_pixels() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 5, json!({})); + settle_to_issue(&mut u, 1); + let old = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + // A resize between issuance and arrival bumps the epoch. + u.app + .capture + .geometry + .insert(egui::ViewportId::ROOT, (CaptureSpec { size_px: [old.size_px[0] + 1, old.size_px[1]], ..old }, u.app.capture.epoch.wrapping_add(1))); + slot.complete(Arc::new(egui::ColorImage::filled(old.size_px, egui::Color32::RED))); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "the old pixels are not accepted"); + let r = u.app.capture.shots.first().expect("reissued"); + assert_eq!(r.retries, 1, "one retry, not an error"); + assert!(!r.issued, "waits for the settled geometry again"); +} + +/// Asserts a translucent pixel round-trips through the PNG as straight RGBA without +/// darkening. +#[test] +fn a_translucent_pixel_round_trips_straight_rgba() { + let img = egui::ColorImage::from_rgba_unmultiplied([1, 1], &[128, 64, 32, 128]); + let reply = super::shot_reply(&img, None).expect("encoded"); + use base64::Engine as _; + let png = base64::engine::general_purpose::STANDARD.decode(reply["data"].as_str().expect("data")).expect("base64"); + let decoded = image::load_from_memory(&png).expect("png").to_rgba8(); + assert_eq!(decoded.get_pixel(0, 0).0, [128, 64, 32, 128], "straight alpha is not darkened"); +} + +/// Asserts a capture is not issued before three completed real UI frames; the following +/// completed frame issues it. +#[test] +fn a_capture_is_not_issued_before_three_completed_real_ui_frames() { + let mut u = Ui::new(Session::empty()); + let _rx = push(&mut u, 6, json!({})); + u.frame(vec![]); + assert_eq!(u.app.capture.shots.len(), 1, "admitted"); + assert!(!u.app.capture.shots[0].issued, "not on the frame that anchors the count"); + u.frame(vec![]); + assert!(!u.app.capture.shots[0].issued, "not on the second frame"); + u.frame(vec![]); + assert!(!u.app.capture.shots[0].issued, "the third frame has not completed when its pass 0 runs"); + u.frame(vec![]); + assert!(u.app.capture.shots[0].issued, "issued once three frames have presented their layout"); +} + +/// Asserts a discarded sizing pass does not count toward capture settling, and the next +/// completed frame issues it. +#[test] +fn a_discarded_sizing_pass_does_not_count_as_a_settled_capture_frame() { + let mut u = Ui::new(Session::empty()); + let _rx = push(&mut u, 33, json!({})); + // Each `frame_raw_with_discard` presents a layout after rerunning a discarded first pass. + // Only that completed frame counts; the discarded pass must not make the capture look settled. + u.frame_raw_with_discard(egui::RawInput::default()); + u.frame_raw_with_discard(egui::RawInput::default()); + u.frame_raw_with_discard(egui::RawInput::default()); + assert!(!u.app.capture.shots[0].issued, "three multipass frames present only two prior layouts here; the third pass 0 is not settled"); + u.frame(vec![]); + assert!(u.app.capture.shots[0].issued, "the next completed frame issues the capture"); +} + +/// Asserts pixels delivered while hidden force a draining paint and are answered rather than +/// timed out. +#[test] +fn a_hidden_window_that_delivers_pixels_is_asked_to_paint_the_draining_pass() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 7, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + // The renderer completes the readback while the window is hidden; the screenshot command that + // produced it is drained, so without a new forced paint the request would only time out. + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + let mut input = egui::RawInput::default(); + input.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); + let ctx = u.ctx.clone(); + let out = ctx.run_logic(&input, |ctx| crate::control::background(&mut u.app, ctx)); + let asked = out + .viewport_commands + .get(&egui::ViewportId::ROOT) + .is_some_and(|cmds| cmds.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); + assert!(asked, "the completed pixels keep the hidden window painting"); + // The forced paint drains the slot; the deadline is untouched. + u.frame(vec![]); + let reply = rx.try_recv().expect("the completed capture is answered, not timed out"); + assert_eq!(reply.id, json!(7)); + assert!(reply.result.unwrap()["width"].as_u64().is_some(), "written or returned"); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +/// Asserts a capture for a window with unknown visibility is parked with its deadline and a +/// paint request, then times out once. +#[test] +fn a_capture_for_a_window_with_unknown_visibility_is_admitted_with_a_deadline() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 40, json!({})); + // A minimised window may report no `visible` flag at all. The request must still be parked + // with its deadline rather than left in `control_queue` waiting for a frame that never draws. + let mut input = egui::RawInput::default(); + // Neither `visible` nor `minimized` is reported: the platform has not said whether the + // window draws, so waiting for its frame would leave the request queued indefinitely. + input.viewports.insert(egui::ViewportId::ROOT, egui::ViewportInfo::default()); + let ctx = u.ctx.clone(); + let out = ctx.run_logic(&input, |ctx| crate::control::background(&mut u.app, ctx)); + assert_eq!(u.app.capture.shots.len(), 1, "admitted, not left queued"); + assert!(u.app.control_queue.is_empty(), "the parked request no longer waits in the input queue"); + let r = &u.app.capture.shots[0]; + assert!(!r.issued); + let now = u.app.clock.now(); + assert!((r.deadline - (now + crate::control::SHOT_TIMEOUT)).abs() < 1e-6, "the hidden request has its deadline"); + let asked = out + .viewport_commands + .get(&egui::ViewportId::ROOT) + .is_some_and(|cmds| cmds.iter().any(|c| matches!(c, egui::ViewportCommand::RequestPaintWhileHidden))); + assert!(asked, "the parked capture asks the window to paint"); + // The deadline still answers the transport when no frame draws. + u.advance_clock(crate::control::SHOT_TIMEOUT + 2.0); + u.frame(vec![]); + let reply = rx.try_recv().expect("the parked capture times out"); + assert_eq!(reply.id, json!(40)); + assert!(reply.result.unwrap_err().contains("didn't deliver")); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +/// Asserts a completed capture is rejected when current geometry is unavailable, failing +/// closed. +#[test] +fn a_completed_capture_is_rejected_when_current_geometry_is_unavailable() { + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 41, json!({})); + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + // A surface that cannot represent geometry at all (smaller than one point here). The pixels + // may still match the old admitted spec, but they cannot be proven current: fail closed. + u.advance_clock(1.0); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(0.0, 0.0))), + time: Some(1.0), + ..Default::default() + }; + let app = &mut u.app; + let ctx = u.ctx.clone(); + let mut out = ctx.run_ui(input, |ui| crate::control::drain(app, ui.ctx())); + out.textures_delta.clear(); + let reply = rx.try_recv().expect("answered instead of accepted"); + assert_eq!(reply.id, json!(41)); + assert!(reply.result.unwrap_err().contains("geometry is unavailable"), "fail closed"); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +/// Asserts a tab switch while a capture settles invalidates it with "document changed". +#[test] +fn a_capture_belongs_to_the_document_admitted_not_the_one_showing_when_it_arrives() { + let mut s = Session::empty(); + s.execute("file.new", &json!({"name": "A"})).unwrap(); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let (a, b) = (s.docs.iter().position(|d| d.doc.name == "A").expect("file A"), s.docs.iter().position(|d| d.doc.name == "B").expect("file B")); + let mut u = Ui::new(s); + u.app.session.active = a; + let rx = push(&mut u, 8, json!({})); + u.frame(vec![]); + assert_eq!(u.app.capture.shots.len(), 1, "admitted against A"); + // The tab switches while the capture settles: the pixels that arrive are B's, not A's. + u.app.session.active = b; + settle_to_issue(&mut u, 1); + let spec = u.app.capture.shots[0].spec.expect("geometry"); + let slot = u.app.capture.shots[0].slot.clone().expect("issued"); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + u.frame(vec![]); + let reply = rx.try_recv().expect("answered"); + assert!(reply.result.unwrap_err().contains("document changed"), "a tab switch invalidates the capture"); + assert!(u.app.capture.shots.is_empty(), "released"); +} + +/// Asserts the capture budget covers the encoding and reply peaks, not just pixels, and an +/// over-budget resize is rejected before readback. +#[test] +fn the_capture_budget_counts_the_encoding_peak_not_only_the_pixels() { + let fits = CaptureSpec { viewport: egui::ViewportId::ROOT, size_px: [4096, 4096], pixels_per_point: 1.0, zoom: 1.0 }; + let over = CaptureSpec { viewport: egui::ViewportId::ROOT, size_px: [5000, 5000], pixels_per_point: 1.0, zoom: 1.0 }; + // The encode phase holds the readback and the straight-RGBA copy while the PNG is built; the + // reply phase holds the readback and PNG plus the base64 string after that copy is dropped. + let raw = fits.bytes().expect("pixels"); + let png = raw + super::SHOT_PNG_OVERHEAD; + let encode_phase = raw * 2 + png; + let reply_phase = raw + png + (png / 3 + 1) * 4; + assert_eq!(fits.cost(), Some(encode_phase.max(reply_phase)), "cost covers both live encoding phases"); + // 5000² × 4 is under the per-image pixel budget but its PNG copy and base64 reply are not. + assert!(over.bytes().expect("pixels") <= super::MAX_SHOT_IMAGE_BYTES); + assert!(over.cost().expect("cost") > super::MAX_SHOT_TOTAL_BYTES, "the reply is part of the bound"); + assert!(fits.cost().expect("cost") <= super::MAX_SHOT_TOTAL_BYTES); + // The real resize path enforces that bound, even after admission at a smaller size. + let mut u = Ui::new(Session::empty()); + let rx = push(&mut u, 9, json!({})); + u.frame(vec![]); + assert_eq!(u.app.capture.shots.len(), 1, "admitted"); + let out = resized_frame(&mut u, egui::vec2(5000.0, 5000.0)); + let reply = rx.try_recv().expect("rejected"); + assert!(reply.result.unwrap_err().contains("capture budget"), "the encoding peak is refused"); + assert!(u.app.capture.shots.is_empty(), "nothing retained"); + assert!(!has_screenshot(&out), "over-budget growth is rejected before any readback command"); +} + +/// Asserts resizing settling captures rechecks aggregate reserved bytes, rejects the request +/// that no longer fits, and makes the survivor reserve its real cost. +#[test] +fn resizing_settling_captures_rechecks_the_aggregate_reserved_bytes() { + let mut u = Ui::new(Session::empty()); + let a = push(&mut u, 81, json!({})); + let b = push(&mut u, 82, json!({})); + u.frame(vec![]); + assert_eq!(u.app.capture.shots.len(), 2, "both fit at the original size"); + let out = resized_frame(&mut u, egui::vec2(3200.0, 3200.0)); + let replies: Vec<_> = [a.try_recv(), b.try_recv()].into_iter().filter_map(Result::ok).collect(); + assert_eq!(replies.len(), 1, "each resized request fits alone, but not both together"); + assert!(replies[0].result.as_ref().unwrap_err().contains("capture budget")); + assert_eq!(u.app.capture.shots.len(), 1); + let remaining = &u.app.capture.shots[0]; + assert_eq!(remaining.spec.unwrap().size_px, [3200, 3200]); + assert_eq!(remaining.reserved, remaining.spec.unwrap().cost().unwrap(), "the survivor reserves its real current cost"); + assert!(!remaining.issued); + assert!(!has_screenshot(&out), "neither request was read back using the old small reservation"); +} + +/// Asserts an allowed resize restarts settling, issues only after three new-size frames, and +/// reserves the current cost. +#[test] +fn an_allowed_resize_restarts_settling_and_issues_with_the_current_reservation() { + let mut u = Ui::new(Session::empty()); + let _rx = push(&mut u, 83, json!({})); + u.frame(vec![]); + u.frame(vec![]); + u.frame(vec![]); + assert_eq!(u.app.capture.shots[0].settle, 2); + let size = egui::vec2(2000.0, 1200.0); + let out = resized_frame(&mut u, size); + assert!(!has_screenshot(&out), "the third old-size frame cannot issue a newly resized surface"); + assert_eq!(u.app.capture.shots[0].settle, 0); + for _ in 0..2 { + assert!(!has_screenshot(&resized_frame(&mut u, size)), "wait for three completed new-size frames"); + } + let out = resized_frame(&mut u, size); + assert!(has_screenshot(&out), "a valid settled capture still issues"); + let request = &u.app.capture.shots[0]; + assert!(request.issued); + assert_eq!(request.spec.unwrap().size_px, [2000, 1200]); + assert_eq!(request.reserved, request.spec.unwrap().cost().unwrap()); +} + +/// Queue an injected-input request (the desktop app's `raw_input_hook` is simulated by +/// `windows::take_input` in these tests). +fn push_input(u: &mut Ui, id: u64) -> std::sync::mpsc::Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(ControlRequest { + id: json!(id), + method: "ui.input".into(), + params: json!({"events": [{"move": [10.0, 10.0]}]}), + reply: tx, + state: Default::default(), + }); + rx +} + +/// Asserts injected input is queued, applied by the presenting pass, and acknowledged once +/// after that pass. +#[test] +fn injected_input_is_acknowledged_only_after_the_pass_that_applies_it() { + let mut u = Ui::new(Session::empty()); + let rx = push_input(&mut u, 20); + u.frame(vec![]); + assert!(rx.try_recv().is_err(), "queued, not applied yet"); + let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT); + assert!(!events.is_empty(), "the batch the window draws with"); + assert!(u.app.control_synthetic, "the applying frame is fenced from the system clipboard"); + u.frame(vec![]); + let reply = rx.try_recv().expect("acknowledged by the presented pass"); + assert_eq!(reply.id, json!(20)); + assert!(reply.result.is_ok()); + assert!(!u.app.control_synthetic, "the fence ends with the frame"); +} + +/// Asserts a discarded pass neither acknowledges nor replays an injected batch; the presented +/// pass acknowledges once. +#[test] +fn a_discarded_pass_does_not_acknowledge_or_lose_an_injected_batch() { + let mut u = Ui::new(Session::empty()); + let rx = push_input(&mut u, 21); + u.frame(vec![]); + let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT); + assert!(!events.is_empty()); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + time: Some(1.0), + ..Default::default() + }; + let app = &mut u.app; + let ctx = u.ctx.clone(); + let mut passes = 0; + let mut out = ctx.run_ui(input, |ui| { + passes += 1; + app.ui(ui); + if passes == 1 { + ui.ctx().request_discard("test sizing pass"); + } + }); + out.textures_delta.clear(); + assert_eq!(passes, 2); + // The batch's side effects already happened; it is never replayed. Its acknowledgement waits + // for the pass whose output is actually presented. + assert_eq!(rx.try_recv().expect("acknowledged by the presented pass").id, json!(21)); + assert!(app.control_input.is_empty(), "the batch is not replayed"); + assert!(app.input_acks.is_empty() && app.applied_acks.is_empty()); +} + +/// Asserts injected text is applied once across a discarded sizing pass and acknowledged +/// once. +#[test] +fn injected_text_lands_once_across_a_discarded_sizing_pass() { + // egui reruns a discarded pass with the same `RawInput`. The custom editor applied the text in + // the first pass; the rerun must not insert it a second time. + let mut s = Session::new(); + let id = NodeId(s.execute("text.create", &json!({"text": "X", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, id, Some(1), false).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(ControlRequest { + id: json!(22), + method: "ui.input".into(), + params: json!({"events": [{"text": "abc"}]}), + reply: tx, + state: Default::default(), + }); + u.frame(vec![]); + let events = crate::windows::take_input(&mut u.app, egui::ViewportId::ROOT); + assert!(!events.is_empty(), "the applying frame's batch"); + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + time: Some(1.0), + events, + ..Default::default() + }; + let app = &mut u.app; + let ctx = u.ctx.clone(); + let mut passes = 0; + let mut out = ctx.run_ui(input, |ui| { + passes += 1; + app.ui(ui); + if passes == 1 { + ui.ctx().request_discard("a grid-like sizing pass"); + } + }); + out.textures_delta.clear(); + assert_eq!(passes, 2, "egui reran the frame"); + let typed = match &app.session.doc().expect("a file").node(id).expect("the text layer").kind { + figcraft_doc::NodeKind::Text(t) => t.characters.clone(), + _ => panic!("not text"), + }; + assert_eq!(typed, "Xabc", "the discarded pass's text was not applied twice"); + assert_eq!(rx.try_recv().expect("acknowledged once").id, json!(22)); + assert!(app.input_acks.is_empty() && app.applied_acks.is_empty()); +} diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index 316c15a4..c87f4f33 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -52,7 +52,7 @@ fn named_button(u: &mut Ui, label: &str) -> Pos2 { /// 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 + u.ctx.read_response(Id::unique(("corner", k)).with("field")).expect("corner field shows").rect } /// The iOS button next to Corner smoothing sets 60% in one undo step, as the help page's "click @@ -103,8 +103,8 @@ fn polygons_and_stars_take_corner_smoothing() { // With no corners of their own, the button is named for what it opens. let t = named_button(&mut u, "Corner smoothing"); click(&mut u, t); - assert!((0..4).all(|k| u.ctx.read_response(Id::new(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields"); - let smoothing = u.ctx.read_response(Id::new("smoothing").with("field")).expect("the smoothing field shows").rect.center(); + assert!((0..4).all(|k| u.ctx.read_response(Id::unique(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields"); + let smoothing = u.ctx.read_response(Id::unique("smoothing").with("field")).expect("the smoothing field shows").rect.center(); let history = u.app.session.undo_depth().0; click(&mut u, smoothing); u.frame(vec![Event::Text("60".into())]); diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 3fc8b7a2..bce5822c 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -256,7 +256,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { /// [`contributors_ui`] over a given list (tests pass their own). fn contributors_in(ui: &mut egui::Ui, contributors: &[Contributor], total_commits: u64) { let t = Tokens::get(ui.ctx()); - let id = egui::Id::new("credits_view"); + let id = egui::Id::unique("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(RichText::new(tr("Show")).color(t.text_secondary)); @@ -446,7 +446,7 @@ fn cell(ui: &mut egui::Ui, numeric: bool, text: &str) { fn header_cell(ui: &mut egui::Ui, t: &Tokens, label: &str, numeric: bool, sort: Option>) -> egui::Response { let rect = ui.max_rect(); let resp = ui.allocate_rect(rect, if sort.is_some() { egui::Sense::click() } else { egui::Sense::hover() }); - let kind = if sort.is_some() { egui::WidgetType::Button } else { egui::WidgetType::Label }; + let kind = if sort.is_some() { egui::Role::Button } else { egui::Role::Label }; resp.widget_info(|| egui::WidgetInfo::labeled(kind, true, label)); let resp = if sort.is_some() { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } else { resp }; let current = sort.flatten(); diff --git a/crates/ui-egui/src/credits_tests.rs b/crates/ui-egui/src/credits_tests.rs index eebce841..fb3993ad 100644 --- a/crates/ui-egui/src/credits_tests.rs +++ b/crates/ui-egui/src/credits_tests.rs @@ -51,12 +51,14 @@ impl Ui { u } + /// Store the About table's current view in test context data. fn set_view(&self, v: View) { - self.ctx.data_mut(|d| d.insert_temp(egui::Id::new("credits_view"), v)); + self.ctx.data_mut(|d| d.insert_temp(egui::Id::unique("credits_view"), v)); } + /// Read the stored About table view, defaulting when unset. fn view(&self) -> View { - self.ctx.data(|d| d.get_temp::(egui::Id::new("credits_view"))).unwrap_or_default() + self.ctx.data(|d| d.get_temp::(egui::Id::unique("credits_view"))).unwrap_or_default() } /// One frame; returns the rect `draw` filled. diff --git a/crates/ui-egui/src/cursors.rs b/crates/ui-egui/src/cursors.rs index 1c4e4ad0..dec2a1b3 100644 --- a/crates/ui-egui/src/cursors.rs +++ b/crates/ui-egui/src/cursors.rs @@ -121,7 +121,7 @@ impl Shown { pub fn show(self, ctx: &egui::Context, at: Pos2) { ctx.set_cursor_icon(self.icon()); if let Shown::Drawn(g) = self { - paint(&ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("canvas_cursor"))), at, g); + paint(&ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("canvas_cursor"))), at, g); } } } diff --git a/crates/ui-egui/src/dev_compare.rs b/crates/ui-egui/src/dev_compare.rs index 0fd57aa7..a8c11354 100644 --- a/crates/ui-egui/src/dev_compare.rs +++ b/crates/ui-egui/src/dev_compare.rs @@ -179,7 +179,7 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { let mut open = true; let mut action = None; egui::Window::new("Compare changes") - .id(Id::new("dev_compare_window")) + .id(Id::unique("dev_compare_window")) .title_bar(false) .collapsible(false) .resizable(false) @@ -277,7 +277,7 @@ pub(crate) fn header(ui: &mut Ui, t: &Tokens, title: &str, name: &str, mode: &mu for (m, label) in modes.iter().rev() { let g = ui.painter().layout_no_wrap((*label).into(), regular(11.0), t.text); let cell = Rect::from_min_max(pos2(x - g.size().x - 20.0, r.center().y - 12.0), pos2(x, r.center().y + 12.0)); - let resp = ui.interact(cell, Id::new(("compare_mode", label)), Sense::click()); + let resp = ui.interact(cell, Id::unique(("compare_mode", label)), Sense::click()); let on = *mode == *m; crate::a11y::tab(&resp, label, on); if on { @@ -294,7 +294,7 @@ pub(crate) fn header(ui: &mut Ui, t: &Tokens, title: &str, name: &str, mode: &mu } if widgets::icon_button_at( ui, - Id::new("compare_close"), + Id::unique("compare_close"), Rect::from_center_size(pos2(r.max.x - 24.0, r.center().y), vec2(24.0, 24.0)), Icon::Close, tr("Close"), @@ -344,7 +344,7 @@ pub(crate) fn images(ui: &mut Ui, t: &Tokens, r: Rect, shots: [(&Shot, &str); 2] p.text(pos2(img.min.x, label_y), Align2::LEFT_CENTER, before_label, semibold(11.0), t.text_secondary); p.text(pos2(img.max.x, label_y), Align2::RIGHT_CENTER, after_label, semibold(11.0), t.text_secondary); // The slider: a line with a knob, dragged (or clicked) anywhere over the images. - let resp = ui.interact(img, Id::new("compare_split"), Sense::click_and_drag()); + let resp = ui.interact(img, Id::unique("compare_split"), Sense::click_and_drag()); if let Some(pos) = resp.interact_pointer_pos().filter(|_| resp.dragged() || resp.clicked()) { *split = ((pos.x - img.min.x) / img.width().max(1.0)).clamp(0.0, 1.0); } diff --git a/crates/ui-egui/src/dev_mode.rs b/crates/ui-egui/src/dev_mode.rs index 8783b0dd..87ce06bd 100644 --- a/crates/ui-egui/src/dev_mode.rs +++ b/crates/ui-egui/src/dev_mode.rs @@ -291,7 +291,7 @@ impl Callout { /// Where the template being edited in Connect to code keeps its draft. fn template_id(owner: NodeId, lang: CodeLang) -> Id { - Id::new(("code_connect_template", owner, lang.id())) + Id::unique(("code_connect_template", owner, lang.id())) } /// Layer `id`'s code in `lang`, generated again only when the document or the choice change (a @@ -390,7 +390,7 @@ pub fn playground_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: Nod "text" => row(ui, 32.0, |ui| { widgets::label(ui, name, t.text_secondary); let cur = value.as_str().unwrap_or(""); - let field = Id::new(("playground_text", id, name)); + let field = Id::unique(("playground_text", id, name)); if let Some(new) = widgets::text_field_named(ui, field, cur, ui.available_width().max(60.0), name) && new != cur { @@ -471,10 +471,10 @@ pub fn connect_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId let col = widgets::col_width(ui.available_width()); row(ui, 32.0, |ui| { ui.spacing_mut().item_spacing.x = 8.0; - if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_import", owner, lang.id())), &link.import_path, col, tr("Import path")) { + if let Some(v) = widgets::text_field(ui, Id::unique(("code_connect_import", owner, lang.id())), &link.import_path, col, tr("Import path")) { edit = Some(("importPath", v)); } - if let Some(v) = widgets::text_field(ui, Id::new(("code_connect_name", owner, lang.id())), &link.name, col, tr("Component name")) { + if let Some(v) = widgets::text_field(ui, Id::unique(("code_connect_name", owner, lang.id())), &link.name, col, tr("Component name")) { edit = Some(("name", v)); } }); @@ -585,13 +585,13 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No t.text_secondary, ); let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0)); - if widgets::icon_button_at(ui, Id::new(("annot_del", id, k)), del, Icon::Minus, tr("Remove annotation")) { + if widgets::icon_button_at(ui, Id::unique(("annot_del", id, k)), del, Icon::Minus, tr("Remove annotation")) { app.run("annotation.remove", json!({"id": id, "index": k})); } - if let Some(cat) = category_menu(ui, doc, Id::new(("annot_cat", id, k)), a) { + if let Some(cat) = category_menu(ui, doc, Id::unique(("annot_cat", id, k)), a) { app.run("annotation.edit", json!({"id": id, "index": k, "category": cat})); } - if let Some(label) = widgets::multiline_field(ui, Id::new(("annot_note", id, k)), &a.label, tr("Add a note for developers"), 2) { + if let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_note", id, k)), &a.label, tr("Add a note for developers"), 2) { if label.trim().is_empty() && a.properties.is_empty() { app.run("annotation.remove", json!({"id": id, "index": k})); } else { @@ -602,7 +602,7 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No for (p, v) in annotations::pinned(doc, id, a) { prop_row(app, ui, &v.unwrap_or_else(|| Prop { label: p.label.to_string(), value: "—".into(), color: None, token: None })); } - if let Some(p) = pin_menu(ui, Id::new(("annot_pin", id, k)), §ions, &a.properties) { + if let Some(p) = pin_menu(ui, Id::unique(("annot_pin", id, k)), §ions, &a.properties) { let mut props = a.properties.clone(); match props.iter().position(|q| q == p.id) { Some(i) => { @@ -620,7 +620,7 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No } // A new annotation starts as a note; properties are pinned to it afterwards. if n.annotations.len() < annotations::MAX_PER_LAYER - && let Some(label) = widgets::multiline_field(ui, Id::new(("annot_new", id)), "", tr("Add an annotation for developers"), 1) + && let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_new", id)), "", tr("Add an annotation for developers"), 1) && !label.trim().is_empty() { app.run("annotation.add", json!({"id": id, "label": label})); @@ -636,7 +636,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: let Some(n) = doc.node(id).filter(|n| !n.is_page()) else { return }; let list = dev_resources::list(doc, id); let can_add = !components::inside_instance(doc, id) && n.dev_resources.len() < dev_resources::MAX_PER_LAYER; - let (adding_id, editing_id) = (Id::new(("devres_adding", id)), Id::new(("devres_editing", id))); + let (adding_id, editing_id) = (Id::unique(("devres_adding", id)), Id::unique(("devres_editing", id))); let mut adding = can_add && ui.data(|d| d.get_temp::(adding_id)).unwrap_or(false); let mut editing = ui.data(|d| d.get_temp::(editing_id)).filter(|i| *i < n.dev_resources.len()); widgets::separator(ui); @@ -652,10 +652,10 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: let mut edit = None; row(ui, 32.0, |ui| { ui.spacing_mut().item_spacing.x = 8.0; - if let Some(v) = widgets::text_field(ui, Id::new(("devres_name", id, i)), name, col, tr("Name")) { + if let Some(v) = widgets::text_field(ui, Id::unique(("devres_name", id, i)), name, col, tr("Name")) { edit = Some(json!({"id": id, "index": i, "name": v})); } - if let Some(v) = widgets::text_field(ui, Id::new(("devres_url", id, i)), url, col, "https://…") { + if let Some(v) = widgets::text_field(ui, Id::unique(("devres_url", id, i)), url, col, "https://…") { edit = Some(json!({"id": id, "index": i, "url": v})); } if widgets::icon_button(ui, Icon::Check, tr("Done")) { @@ -692,10 +692,10 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: if let Some(i) = item.index { let del = Rect::from_center_size(pos2(r.max.x - PAD - 4.0, r.center().y), vec2(22.0, 22.0)); let pen = del.translate(vec2(-26.0, 0.0)); - if widgets::icon_button_at(ui, Id::new(("devres_edit", id, i)), pen, Icon::Pencil, tr("Edit dev resource")) { + if widgets::icon_button_at(ui, Id::unique(("devres_edit", id, i)), pen, Icon::Pencil, tr("Edit dev resource")) { editing = Some(i); } - if widgets::icon_button_at(ui, Id::new(("devres_del", id, i)), del, Icon::Minus, tr("Remove dev resource")) { + if widgets::icon_button_at(ui, Id::unique(("devres_del", id, i)), del, Icon::Minus, tr("Remove dev resource")) { app.run("devResource.remove", json!({"id": id, "index": i})); editing = None; } @@ -704,7 +704,7 @@ pub fn dev_resources_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: if adding { let w = ui.available_width() - PAD * 2.0; let mut added = None; - row(ui, 32.0, |ui| added = widgets::text_field(ui, Id::new(("devres_new", id)), "", w, tr("Paste a link (https://…)"))); + row(ui, 32.0, |ui| added = widgets::text_field(ui, Id::unique(("devres_new", id)), "", w, tr("Paste a link (https://…)"))); if let Some(url) = added.filter(|u| !u.trim().is_empty()) && app.run("devResource.add", json!({"id": id, "url": url})).is_some() { @@ -810,7 +810,7 @@ fn measurement_row(ui: &mut Ui, t: &Tokens, doc: &Document, m: &Placed) -> Optio 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")) { + if widgets::icon_button_at(ui, Id::unique(("measure_del", m.from, m.index)), del, Icon::Minus, tr("Remove measurement")) { action = Some(("devMode.removeMeasurement", json!({"from": m.from, "index": m.index}))); } action @@ -886,7 +886,7 @@ pub fn categories_section(app: &mut FigApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::hover()); // The colour: click for the palette. let sw = Rect::from_center_size(pos2(r.min.x + PAD + 8.0, r.center().y), vec2(16.0, 16.0)); - let sw_resp = ui.interact(sw, Id::new(("cat_color", &c.id)), Sense::click()).tip(tr("Colour")); + let sw_resp = ui.interact(sw, Id::unique(("cat_color", &c.id)), Sense::click()).tip(tr("Colour")); crate::a11y::color(&sw_resp, &c.name, &c.color.to_css_hex()); ui.painter().rect_filled(sw, CornerRadius::same(4), doc_c32(ui.ctx(), c.color)); if sw_resp.hovered() { @@ -909,14 +909,14 @@ pub fn categories_section(app: &mut FigApp, ui: &mut Ui) { }); }); let name_r = Rect::from_min_max(pos2(sw.max.x + 8.0, r.min.y + 3.0), pos2(r.max.x - PAD - 28.0, r.max.y - 3.0)); - if let Some(name) = widgets::text_field_inline(ui, Id::new(("cat_name", &c.id)), &c.name, name_r, regular(11.0)) + if let Some(name) = widgets::text_field_inline(ui, Id::unique(("cat_name", &c.id)), &c.name, name_r, regular(11.0)) && name.trim() != c.name && !name.trim().is_empty() { action = Some(("annotation.editCategory", json!({"id": c.id, "name": name.trim()}))); } let del = Rect::from_center_size(pos2(r.max.x - PAD - 8.0, r.center().y), vec2(22.0, 22.0)); - if widgets::icon_button_at(ui, Id::new(("cat_del", &c.id)), del, Icon::Minus, tr("Delete category (its annotations stay)")) { + if widgets::icon_button_at(ui, Id::unique(("cat_del", &c.id)), del, Icon::Minus, tr("Delete category (its annotations stay)")) { action = Some(("annotation.removeCategory", json!({"id": c.id}))); } } @@ -1014,7 +1014,7 @@ pub fn ready_list(app: &mut FigApp, ui: &mut Ui) { CornerRadius::same(5), t.hover, ); - if widgets::icon_button_at(ui, Id::new(("ready_focus", item.id)), focus_r, Icon::Frame, tr("Open in focus view")) { + if widgets::icon_button_at(ui, Id::unique(("ready_focus", item.id)), focus_r, Icon::Frame, tr("Open in focus view")) { focus = Some(item.id); } } @@ -1058,7 +1058,7 @@ pub fn focus_bar(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { // Somewhere to go: another ready frame, or into the list from outside it. let can_step = list.len() > usize::from(at.is_some()); let t = Tokens::get(ctx); - let action = widgets::canvas_banner(ctx, Id::new("dev_focus_bar"), canvas, |ui| { + let action = widgets::canvas_banner(ctx, Id::unique("dev_focus_bar"), canvas, |ui| { let mut out = None; let icon = crate::layers::node_icon(&doc, id); icons::paint(ui.painter(), Rect::from_center_size(ui.cursor().min + vec2(7.0, 12.0), vec2(14.0, 14.0)), icon, t.icon_secondary); diff --git a/crates/ui-egui/src/dev_mode_tests.rs b/crates/ui-egui/src/dev_mode_tests.rs index 3e2ea9ba..1a8de0e2 100644 --- a/crates/ui-egui/src/dev_mode_tests.rs +++ b/crates/ui-egui/src/dev_mode_tests.rs @@ -126,8 +126,13 @@ fn playground_combo(u: &mut crate::layers_drag_tests::Ui, name: &str) -> Option< 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() }; + let wheel = Event::MouseWheel { + unit: MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + 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![]); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 85d8ca15..8d4d8fdb 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -105,7 +105,7 @@ pub(crate) fn tab_strip(ui: &mut egui::Ui, t: &Tokens, tabs let active = *current == tab; let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), if active { t.text } else { t.text_secondary }); let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 16.0, 24.0), Sense::click()); - resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, label)); + resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, active, label)); if active || resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } @@ -179,6 +179,10 @@ pub fn keys(app: &mut FigApp, ctx: &Context) { } } +/// Draw the app's dialogs and cards: pending opens/imports/missing fonts, About, +/// Preferences, shortcuts, find/replace, batch rename, menu input, variables, dev compare, +/// branch, export preview, colour profile, plugin, live join/publish, online dialogs and +/// close confirmation. pub fn show(app: &mut FigApp, ctx: &Context) { // Dropped files waiting to open: their card, then (once it has shown) the files. crate::pending_open::pending_opens(app, ctx); @@ -246,7 +250,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { ui.add_space(8.0); ui.horizontal(|ui| { ui.label(tr("Your name in comments")); - if let Some(name) = crate::widgets::text_field(ui, egui::Id::new("pref_user_name"), &prefs.user_name, 160.0, tr("You")) { + if let Some(name) = crate::widgets::text_field(ui, egui::Id::unique("pref_user_name"), &prefs.user_name, 160.0, tr("You")) { app.run("pref.userName", json!({"name": name})); } }); @@ -256,7 +260,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { // Where Share ▸ Online keeps files and join codes are asked; no key here // (preferences are saved as text). let name = tr("Server for online sharing"); - if let Some(url) = crate::widgets::text_field_named(ui, egui::Id::new("pref_publish_server"), &prefs.publish_server, 220.0, name) { + if let Some(url) = crate::widgets::text_field_named(ui, egui::Id::unique("pref_publish_server"), &prefs.publish_server, 220.0, name) { app.run("pref.publishServer", json!({"url": url})); } let usual = prefs.publish_server == figcraft_engine::live::DEFAULT_SERVER; @@ -329,7 +333,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { let mut open = true; let mut action = None; egui::Window::new(tr("Find and replace")) - .id(egui::Id::new("Find and replace")) + .id(egui::Id::unique("Find and replace")) .open(&mut open) .anchor(Align2::LEFT_TOP, vec2(260.0, 120.0)) .resizable(false) @@ -396,7 +400,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { match &mut input { MenuInput::Link(url) => { ui.horizontal(|ui| { - let field = crate::widgets::text_input(ui, egui::Id::new("menu_input_link"), url, 250.0, "https://"); + let field = crate::widgets::text_input(ui, egui::Id::unique("menu_input_link"), url, 250.0, "https://"); let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); let ok = !url.trim().is_empty(); if (crate::widgets::button(ui, tr("Done"), true, ok).clicked() || enter) && ok { @@ -406,7 +410,7 @@ fn menu_input_dialog(app: &mut FigApp, ctx: &Context) { } MenuInput::Offset(offset) => { ui.horizontal(|ui| { - let id = egui::Id::new("menu_input_offset"); + let id = egui::Id::unique("menu_input_offset"); if let Some(crate::widgets::FieldEdit::Set(v) | crate::widgets::FieldEdit::Scrub(v, _)) = crate::widgets::number_field(ui, id, crate::widgets::Lead::None, Some(*offset), "", 120.0, 1.0) { @@ -573,7 +577,7 @@ fn batch_rename_dialog(app: &mut FigApp, ctx: &Context) { let mut open = true; let mut cancel = false; let mut apply = false; - window(tr("Rename layers"), ctx).id(egui::Id::new("Rename layers")).open(&mut open).show(ctx, |ui| { + window(tr("Rename layers"), ctx).id(egui::Id::unique("Rename layers")).open(&mut open).show(ctx, |ui| { ui.set_width(320.0); ui.label(RichText::new(trn("{n} layers", n, &[])).color(t.text_secondary)); ui.add_space(6.0); @@ -802,7 +806,7 @@ fn export_dialog(app: &mut FigApp, ctx: &Context) { // The PDF preset's print options. ui.add_space(6.0); caption(ui, tr("PDF print options")); - if let Some(next) = crate::export_panel::print_options(ui, egui::Id::new("export_dialog_print"), &print) { + if let Some(next) = crate::export_panel::print_options(ui, egui::Id::unique("export_dialog_print"), &print) { app.ui.pdf_print = next; } divider(ui); diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 5e05cc19..63d34f55 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -74,13 +74,13 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo if let Some(size_w) = size_w { let presets: Vec = ExportSize::SCALES.iter().map(|value| ExportSize::Scale { value: *value }.label()).chain(["512w".into(), "512h".into()]).collect(); - if let Some(s) = widgets::combo_field(ui, Id::new(("export_size", i)), &e.size.label(), size_w, &presets) + if let Some(s) = widgets::combo_field(ui, Id::unique(("export_size", i)), &e.size.label(), size_w, &presets) && ExportSize::parse(&s) != Some(e.size) { app.run("export.update", json!({"ids": ids, "index": i, "size": s})); } } - if let Some(sfx) = widgets::text_field(ui, Id::new(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix")) + if let Some(sfx) = widgets::text_field(ui, Id::unique(("export_suffix", i)), &e.suffix, suffix_w, tr("Suffix")) && sfx != e.suffix { app.run("export.update", json!({"ids": ids, "index": i, "suffix": sfx})); @@ -96,7 +96,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo }); let more = icons::button(ui, Icon::Dots, button, false, tr("Export settings")); egui::Popup::from_toggle_button_response(&more) - .id(Id::new(("export_more", i))) + .id(Id::unique(("export_more", i))) .width(216.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { @@ -142,7 +142,7 @@ fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintSettings) { let t = Tokens::get(ui.ctx()); row(ui, 26.0, |ui| { - let open_id = Id::new(("export_print", i)); + let open_id = Id::unique(("export_print", i)); let open = egui::Popup::is_id_open(ui.ctx(), open_id); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), FIELD_H), Sense::click()); if resp.hovered() || open { @@ -156,7 +156,7 @@ fn print_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, p: &PrintS .width(216.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { - if let Some(next) = print_options(ui, Id::new(("export_print_fields", i)), p) { + if let Some(next) = print_options(ui, Id::unique(("export_print_fields", i)), p) { let mut params = print_params(&next); params["ids"] = json!(ids); params["index"] = json!(i); @@ -264,7 +264,7 @@ fn preview(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId) { } let revision = app.session.ds().map(|d| d.revision).unwrap_or(0); let now = ui.input(|i| i.time); - let cache_id = Id::new("export_preview_tex"); + let cache_id = Id::unique("export_preview_tex"); let cached: Option = ui.data(|d| d.get_temp(cache_id)); let stale = cached.as_ref().is_none_or(|c| c.id != id || c.revision != revision); let due = cached.as_ref().is_none_or(|c| c.id != id || now - c.at >= PREVIEW_REFRESH); diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index f888ab47..4433356f 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -77,7 +77,7 @@ fn export_rows_offer_a_size_for_images_only() { s.execute("select.set", &json!({"ids": [r]})).unwrap(); s.execute("export.add", &json!({"ids": [r]})).unwrap(); let mut u = crate::layers_drag_tests::Ui::new(s); - let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::new(("export_size", 0)).with("menu")).is_some(); + let size_menu = |u: &crate::layers_drag_tests::Ui| u.ctx.read_response(egui::Id::unique(("export_size", 0)).with("menu")).is_some(); u.frame(vec![]); assert!(size_menu(&u), "PNG has sizes"); for format in ["svg", "pdf"] { @@ -108,7 +108,7 @@ fn advanced_options_offer_the_bounding_box_for_text_layers() { let options = |u: &mut crate::layers_drag_tests::Ui, id: NodeId| { u.app.run("select.set", json!({"ids": [id]})); u.frame(vec![]); - egui::Popup::open_id(&u.ctx, egui::Id::new(("export_more", 0))); + egui::Popup::open_id(&u.ctx, egui::Id::unique(("export_more", 0))); u.frame(vec![]); crate::import_report_tests::shown_texts(u.frame_output(vec![]).shapes) }; diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index dd63988a..341e7cca 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -467,29 +467,33 @@ fn lead(u: &Ui, id: Id) -> Pos2 { u.ctx.read_response(id.with("lead")).unwrap_or_else(|| panic!("no field {id:?}")).rect.center() } +/// Asserts scrubbing the colour picker's R and alpha fields updates the colour live, each +/// scrub adding one undo step. #[test] fn scrubbing_colour_picker_fields_is_live_and_one_undo_step() { let (mut u, a, _) = setup(); let rgba = |u: &Ui| u.app.session.doc().unwrap().node(a).unwrap().fills[0].solid_color().unwrap().to_rgba8(); let [r0, .., a0] = rgba(&u); - u.ctx.data_mut(|d| d.insert_temp(Id::new("cp_model"), 1usize)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique("cp_model"), 1usize)); u.app.ui.picker = Some(crate::colorpicker::PickerTarget::Paint { fills: true, index: 0, ids: vec![a] }); // The picker window takes a frame to size itself. for _ in 0..3 { u.frame(vec![]); } let steps = undo_steps(&u); - let from = lead(&u, Id::new(("cp_f", "r"))); + let from = lead(&u, Id::unique(("cp_f", "r"))); drag(&mut u, from, -20.0, Modifiers::NONE); assert_eq!((rgba(&u)[0], undo_steps(&u)), (r0 - 20, steps + 1), "R"); // The alpha field: a scrub moves it too. - let from = lead(&u, Id::new("cp_a")); + let from = lead(&u, Id::unique("cp_a")); drag(&mut u, from, -30.0, Modifiers::NONE); let [.., alpha] = rgba(&u); assert!(alpha < a0, "alpha {a0} → {alpha}"); assert_eq!(undo_steps(&u), steps + 2, "alpha"); } +/// Asserts scrubbing a prototype delay sets the value once when released and adds one undo +/// step. #[test] fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { let mut s = Session::new(); @@ -505,7 +509,7 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { u.frame(vec![]); } let steps = undo_steps(&u); - let from = lead(&u, Id::new(("proto_delay", f1, 0usize))); + let from = lead(&u, Id::unique(("proto_delay", f1, 0usize))); drag(&mut u, from, 30.0, Modifiers::NONE); let ms = match u.app.session.doc().unwrap().node(f1).unwrap().interactions[0].trigger { figcraft_doc::proto::Trigger::AfterDelay { ms } => ms, @@ -514,13 +518,14 @@ fn scrubbing_a_prototype_delay_sets_it_once_when_let_go() { assert_eq!((ms, undo_steps(&u)), (800, steps + 1)); } +/// Asserts scrubbing the scale panel's width scales the layer live in one undo step. #[test] fn scrubbing_the_scale_panel_width_scales_live_in_one_undo_step() { let (mut u, a, _) = setup(); u.app.run("tool.set", json!({"tool": "scale"})); u.frame(vec![]); let steps = undo_steps(&u); - let from = lead(&u, Id::new("scale_w")); + let from = lead(&u, Id::unique("scale_w")); drag(&mut u, from, 20.0, Modifiers::NONE); let (_, _, w, h) = geom(&u, a); assert_eq!((w, h, undo_steps(&u)), (60.0, 45.0, steps + 1), "40×30 scaled to a width of 60"); @@ -550,14 +555,14 @@ fn the_glass_light_intensity_field_has_a_label() { s.execute("select.set", &json!({"ids": [r]})).unwrap(); s.execute("effect.add", &json!({"type": "glass"})).unwrap(); let mut u = Ui::new(s); - u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 0usize)), true)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("fx_open", 0usize)), true)); u.frame(vec![]); let shapes = u.frame_output(vec![]).shapes; - let lead = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect; + let lead = u.ctx.read_response(Id::unique(("fx_gi", 0usize)).with("lead")).expect("the intensity field is shown").rect; let labels: Vec<(String, bool)> = painted_texts(&shapes).into_iter().filter(|(_, _, at)| lead.contains(at.center())).map(|(text, cut, _)| (text, cut)).collect(); assert_eq!(labels, [("Intensity".to_string(), false)], "labelled, and not cut short"); - let field = u.ctx.read_response(Id::new(("fx_gi", 0usize)).with("field")).expect("its value").rect; + let field = u.ctx.read_response(Id::unique(("fx_gi", 0usize)).with("field")).expect("its value").rect; assert_eq!(field.max.x - lead.min.x, GLASS_FIELD_W, "the width the next test measures against"); } @@ -584,13 +589,15 @@ fn the_intensity_label_fits_whole_in_every_language() { out.textures_delta.clear(); } +/// Asserts a label too long for its number field is elided before the value, while the value +/// stays whole and both remain inside the field. #[test] fn a_label_too_long_for_its_field_ends_in_an_ellipsis_before_the_value() { let ctx = egui::Context::default(); let mut field = egui::Rect::NOTHING; let mut out = ctx.run_ui(Default::default(), |ui| { let lead = crate::widgets::Lead::Text("A label much wider than its field"); - let _ = crate::widgets::number_field(ui, Id::new("long"), lead, Some(80.0), "%", 60.0, 1.0); + let _ = crate::widgets::number_field(ui, Id::unique("long"), lead, Some(80.0), "%", 60.0, 1.0); field = ui.min_rect(); }); out.textures_delta.clear(); @@ -623,7 +630,7 @@ fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) { s.execute("node.set", &json!({"ids": [f], "props": {"grids": [grid]}})).unwrap(); s.execute("select.set", &json!({"ids": [f]})).unwrap(); let mut u = Ui::new(s); - u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 0usize)), true)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("grid_open", 0usize)), true)); u.frame(vec![]); (u, f) } @@ -633,6 +640,7 @@ fn grid_ui(grid: serde_json::Value) -> (Ui, NodeId) { fn scroll_to(u: &mut Ui, id: Id) { let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -300.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, @@ -655,7 +663,7 @@ fn scroll_to(u: &mut Ui, id: Id) { /// placeholder said 0 everywhere, which turned the gap's mode off. #[test] fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { - let count_id = Id::new(("grid_field", "count", 0usize)); + let count_id = Id::unique(("grid_field", "count", 0usize)); let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "min", "count": 3, "size": 8, "gutter": 10})); let count = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().grids[0].count; scroll_to(&mut u, count_id); @@ -665,7 +673,7 @@ fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { assert_eq!(count(&u), 4, "and a number is that count"); // A stretched grid works its width out from the frame: the field shows Auto and takes no edit. let (mut u, f) = grid_ui(json!({"kind": "columns", "align": "stretch", "count": 3, "size": 0})); - let size_id = Id::new(("grid_field", "size", 0usize)); + let size_id = Id::unique(("grid_field", "size", 0usize)); scroll_to(&mut u, size_id); type_at(&mut u, size_id, "40"); assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids[0].size, 0.0, "a stretched grid's width isn't set"); @@ -683,6 +691,336 @@ fn typing_auto_is_zero_only_where_zero_is_the_automatic_value() { u.frame(vec![]); let gap = |u: &Ui| u.app.session.doc().unwrap().node(f).unwrap().layout.as_ref().unwrap().gap; let before = gap(&u); - type_at(&mut u, Id::new("al_gap"), "Auto"); + type_at(&mut u, Id::unique("al_gap"), "Auto"); assert_eq!(gap(&u), before, "the word isn't a value where Auto is a mode"); } + +/// Save starts after the field commits, even if the next raw input releases a tab click first. +#[test] +fn a_deferred_field_save_keeps_its_document_when_the_next_input_switches_tabs() { + for (save_as, same_batch, discard) in + [(false, false, false), (true, false, false), (false, true, false), (true, true, false), (false, true, true), (true, true, true)] + { + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-deferred-field-tab-{}-{save_as}-{same_batch}-{discard}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, copy, other) = (dir.join("a.figcraft"), dir.join("copy.figcraft"), dir.join("b.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let owner = u.app.session.ds().unwrap().uid; + u.app.session.execute("file.new", &json!({"name": "Deferred B"})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 30, "height": 30})).unwrap(); + u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 10, "height": 10})).unwrap(); + let other_uid = u.app.session.ds().unwrap().uid; + let other_name = u.app.session.doc().unwrap().name.clone(); + let other_bytes = std::fs::read(&other).unwrap(); + let index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": index})).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() }); + u.frame(vec![]); + let output = u.frame_output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let tab = nodes + .iter() + .map(|(_, node)| node) + .find(|node| node.role() == Role::Tab && node.label() == Some(other_name.as_str())) + .expect("the actual B tab"); + let bounds = tab.bounds().unwrap(); + let at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let (field_at, _) = field(&mut u, "X"); + click(&mut u, field_at); + assert!(crate::a11y::field_focused(&u.ctx)); + assert!(u.ctx.text_edit_focused(), "the actual field editor owns raw text"); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te")), "X owns the keyboard"); + if !same_batch { + u.frame(vec![Event::Text("77".into())]); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into()), "raw text reached X's draft"); + } + assert_eq!(geom(&u, a).0, 10.0, "the field has not committed before the shortcut"); + let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + if save_as { + modifiers = modifiers.plus(Modifiers::SHIFT); + } + let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + // Pressing the tab relinquishes the real field; its release switches documents in the + // next frame, before shortcuts::handle consumes the deferred request. + let mut events = vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + Event::PointerMoved(at), + button(true), + ]; + if same_batch { + events.insert(0, Event::Text("77".into())); + } + if discard { + events.insert(0, Event::ModifiersChanged(modifiers)); + u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() }); + } else { + u.frame_with(events, modifiers); + } + assert!(u.app.ui.after_field.is_some(), "the real shortcut deferred a save"); + if !discard { + assert!( + !u.ctx.input(|i| i.events.iter().any(|e| matches!(e, Event::Key { key: Key::S, pressed: true, .. }))), + "the save press was consumed once" + ); + } + // A discarded presented pass sees the original RawInput but does not dispatch it again; + // pending/picker/write assertions below also prove that pass did not duplicate the save. + assert_eq!(u.app.session.ds().unwrap().uid, owner, "press alone did not switch tabs"); + assert_eq!(geom(&u, a).0, 77.0, "the original field committed before delivery"); + u.frame(vec![button(false)]); + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the real tab release switched to B"); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "saving A restores the front tab B"); + assert!(u.app.session.ds().unwrap().is_dirty(), "B was not saved"); + assert_eq!(std::fs::read(&other).unwrap(), other_bytes, "B's file was not overwritten"); + assert_eq!(asked.get(), usize::from(save_as), "only the original Save as asks for a path"); + assert_eq!(saved_x(if save_as { © } else { &path }, a), 77.0, "A's typed field is saved"); + assert_eq!(saved_x(&path, a), if save_as { 10.0 } else { 77.0 }); + assert!(u.app.ui.after_field.is_none()); + std::fs::remove_dir_all(&dir).ok(); + } +} + +/// Asserts a deferred field save does not retarget a closed, moved or foreign owner: the other +/// file stays untouched, Save as is not opened, and the stale request is consumed once. +#[test] +fn a_deferred_field_save_does_not_retarget_a_closed_moved_or_foreign_owner() { + for change in ["closed", "moved", "foreign"] { + for save_as in [false, true] { + let (mut u, a, _) = setup(); + u.ctx.set_embed_viewports(false); + let dir = std::env::temp_dir().join(format!("figcraft-deferred-field-invalid-{}-{change}-{save_as}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, other, copy) = (dir.join("a.figcraft"), dir.join("b.figcraft"), dir.join("copy.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let owner = u.app.session.ds().unwrap().uid; + u.app.session.execute("file.new", &json!({"name": "Deferred B"})).unwrap(); + u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 10, "height": 10})).unwrap(); + let other_uid = u.app.session.ds().unwrap().uid; + let other_bytes = std::fs::read(&other).unwrap(); + let index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": index})).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() }); + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert!(crate::a11y::field_focused(&u.ctx)); + assert!(u.ctx.text_edit_focused(), "the actual field editor owns raw text"); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te")), "X owns the keyboard"); + u.frame(vec![Event::Text("77".into())]); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into()), "raw text reached X's draft"); + assert_eq!(geom(&u, a).0, 10.0, "typed field text is not committed before Save"); + let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + if save_as { + modifiers = modifiers.plus(Modifiers::SHIFT); + } + u.frame_with( + vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ], + modifiers, + ); + assert!(u.app.ui.after_field.is_some()); + assert_eq!(geom(&u, a).0, 77.0, "field commit is still before the save"); + let owner_index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + if change == "closed" { + u.app.session.execute("file.close", &json!({"index": owner_index, "force": true})).unwrap(); + } else if change == "moved" { + let window = crate::windows::open(&mut u.app, &u.ctx, Some(owner_index), None, None).unwrap(); + assert_ne!(window, 0); + assert_eq!(u.app.windows.of(owner), window); + } else { + // An impossible/misrouted pane must fail closed too; no real window has this id. + u.app.windows.current = u64::MAX; + } + let other_index = u.app.session.docs.iter().position(|d| d.uid == other_uid).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": other_index})).unwrap(); + if change == "foreign" { + // Exercise the actual consumer without begin() resetting this deliberate invalid + // scope; the valid input/field producer above was the real app path. + // Match this direct pass and the harness clock; egui's default predicted_dt is f32. + let time = u.ctx.input(|i| i.time) + 1.0 / 60.0; + u.advance_clock(time); + u.ctx + .run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| { + crate::shortcuts::handle(&mut u.app, ui.ctx(), false, Default::default()) + }) + .textures_delta + .clear(); + u.app.windows.current = 0; + } else { + u.frame(vec![]); + } + for _ in 0..2 { + u.frame(vec![]); + } + assert_eq!(u.app.session.ds().unwrap().uid, other_uid); + assert!(u.app.session.ds().unwrap().is_dirty(), "stale delivery must not save B"); + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + assert_eq!(saved_x(&path, a), 10.0, "a cancelled owner is not saved"); + assert_eq!(asked.get(), 0, "a stale Save as must not open for B"); + assert!(!copy.exists()); + assert!(u.app.ui.after_field.is_none(), "consume stale requests once"); + std::fs::remove_dir_all(&dir).ok(); + } + } +} + +/// A save prepared for A must not force its cached X editor into B when routing changes the +/// presented document before the fields draw, even though both panels use the same widget ID. +#[test] +fn a_prepared_field_save_cannot_commit_into_a_same_pass_replacement_document() { + for route in ["control", "tab"] { + for save_as in [false, true] { + for discard in [false, true] { + prepared_save_cannot_enter_a_replacement(route, save_as, discard); + } + } + } +} + +/// One route/save-as/discard combination of the same-pass replacement test: a save prepared for A +/// must not commit A's cached X editor into B when routing presents B before the fields draw, +/// even though both panels use the same widget ID. +fn prepared_save_cannot_enter_a_replacement(route: &str, save_as: bool, discard: bool) { + let (mut u, a, _) = setup(); + let dir = std::env::temp_dir().join(format!("figcraft-field-save-owner-{}-{route}-{save_as}-{discard}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (path, other, copy) = (dir.join("a.figcraft"), dir.join("b.figcraft"), dir.join("copy.figcraft")); + u.app.session.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let owner = u.app.session.ds().unwrap().uid; + let owner_bytes = std::fs::read(&path).unwrap(); + u.app.session.execute("file.new", &json!({"name": "Same-pass B"})).unwrap(); + let b = id(u.app.session.execute("shape.rectangle", &json!({"x": 200, "y": 40, "width": 30, "height": 30})).unwrap()); + u.app.session.execute("file.saveAs", &json!({"path": other.to_string_lossy()})).unwrap(); + u.app.session.execute("shape.rectangle", &json!({"x": 300, "y": 0, "width": 10, "height": 10})).unwrap(); + u.app.session.execute("select.set", &json!({"ids": [b]})).unwrap(); + let other_index = u.app.session.active; + let other_uid = u.app.session.ds().unwrap().uid; + let other_name = u.app.session.doc().unwrap().name.clone(); + let other_model = u.app.session.ds().unwrap().doc.clone(); + let before_switch_revision = u.app.session.ds().unwrap().revision; + let other_history = u.app.session.undo_depth(); + let other_selection = u.app.session.ds().unwrap().selection.clone(); + let other_dirty = u.app.session.ds().unwrap().is_dirty(); + let other_bytes = std::fs::read(&other).unwrap(); + assert!(other_dirty, "B has independent unsaved content"); + let owner_index = u.app.session.docs.iter().position(|d| d.uid == owner).unwrap(); + u.app.session.execute("doc.switch", &json!({"index": owner_index})).unwrap(); + // Every command bumps the outgoing document's revision, even a nonediting switch. + let other_revision = u.app.session.docs.iter().find(|d| d.uid == other_uid).unwrap().revision; + assert_eq!(other_revision, before_switch_revision + 1, "the setup switch advances only B's command revision"); + let asked = std::rc::Rc::new(std::cell::Cell::new(0)); + u.app.services = Box::new(SaveAsPicker { path: Some(copy.clone()), asked: asked.clone() }); + let output = u.frame_output(vec![]); + let nodes = output.platform_output.accesskit_update.unwrap().nodes; + let tab = nodes + .iter() + .map(|(_, node)| node) + .find(|node| node.role() == Role::Tab && node.label() == Some(other_name.as_str())) + .expect("the actual B tab"); + let bounds = tab.bounds().unwrap(); + let tab_at = pos2(((bounds.x0 + bounds.x1) / 2.0) as f32, ((bounds.y0 + bounds.y1) / 2.0) as f32); + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); + assert!(u.ctx.text_edit_focused()); + u.frame(vec![Event::Text("77".into())]); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("77".into())); + assert_eq!(geom(&u, a).0, 10.0, "A's raw draft is not committed yet"); + assert_eq!(u.app.session.ds().unwrap().uid, owner, "the save begins with A presented"); + let reply = if route == "control" { + let (reply, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(crate::ControlRequest { + id: json!("same-pass-switch"), + method: "engine.execute".into(), + params: json!({"command": "doc.switch", "params": {"index": other_index}, "window": 0}), + reply, + state: Default::default(), + }); + Some(rx) + } else { + None + }; + let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) }; + if save_as { + modifiers = modifiers.plus(Modifiers::SHIFT); + } + let mut events = vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ]; + if route == "tab" { + events.push(Event::PointerMoved(tab_at)); + for pressed in [true, false] { + events.push(Event::PointerButton { pos: tab_at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + } + } + if discard { + events.insert(0, Event::ModifiersChanged(modifiers)); + u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() }); + } else { + u.frame_with(events, modifiers); + } + if let Some(reply) = reply { + let reply = reply.try_recv().expect("real control::drain acknowledged doc.switch in this pass"); + assert!(reply.result.is_ok(), "{:?}", reply.result); + } + let unchanged = |u: &Ui| { + assert_eq!(u.app.session.ds().unwrap().uid, other_uid, "the real same-pass route presents B"); + assert_eq!(geom(u, b), (200.0, 40.0, 30.0, 30.0), "A's draft cannot enter B's field: route={route}, save_as={save_as}, discard={discard}"); + assert!(std::sync::Arc::ptr_eq(&u.app.session.ds().unwrap().doc, &other_model), "B's model is untouched"); + assert_eq!(u.app.session.ds().unwrap().revision, other_revision); + assert_eq!(u.app.session.undo_depth(), other_history, "B's undo/redo history is unchanged"); + assert_eq!(u.app.session.ds().unwrap().selection, other_selection); + assert_eq!(u.app.session.ds().unwrap().is_dirty(), other_dirty); + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + assert_eq!(std::fs::read(&path).unwrap(), owner_bytes); + assert_eq!(u.app.session.docs.iter().find(|d| d.uid == owner).unwrap().doc.world_bounds(a).unwrap().x0, 10.0); + assert_eq!(asked.get(), 0, "the cancelled request never asks for a path"); + assert!(!copy.exists()); + assert!(u.app.ui.after_field.is_none(), "cancel before late fallback or queueing"); + }; + unchanged(&u); + for _ in 0..3 { + u.frame(vec![]); + unchanged(&u); + } + // A cancelled draft is not a permanent veto: B accepts a later real field edit + // and saves its own value through the same focused-field shortcut path. + let (at, _) = field(&mut u, "X"); + click(&mut u, at); + assert_eq!(u.ctx.memory(|m| m.focused()), Some(Id::unique("pos_x").with("te"))); + u.frame(vec![Event::Text("88".into())]); + assert_eq!(geom(&u, b).0, 200.0, "fresh B text still waits for a normal commit"); + assert_eq!(u.ctx.data(|d| d.get_temp::(Id::unique("pos_x").with("editing"))), Some("88".into())); + u.frame_with( + vec![ + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers }, + Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers }, + ], + modifiers, + ); + assert_eq!(geom(&u, b).0, 88.0, "a fresh B field commits normally"); + assert!(u.app.ui.after_field.is_some()); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(u.app.session.ds().unwrap().uid, other_uid); + assert_eq!(saved_x(if save_as { © } else { &other }, b), 88.0, "a later valid save belongs to B"); + if save_as { + assert_eq!(std::fs::read(&other).unwrap(), other_bytes); + } + assert_eq!(std::fs::read(&path).unwrap(), owner_bytes, "A stays unsaved"); + assert_eq!(asked.get(), usize::from(save_as)); + assert!(u.app.ui.after_field.is_none()); + std::fs::remove_dir_all(&dir).ok(); +} diff --git a/crates/ui-egui/src/font_preview.rs b/crates/ui-egui/src/font_preview.rs index a16177dc..ad05645d 100644 --- a/crates/ui-egui/src/font_preview.rs +++ b/crates/ui-egui/src/font_preview.rs @@ -32,7 +32,7 @@ fn key(style: &TextStyle, text: &str, ppp: f32) -> u64 { pub fn texture(ctx: &egui::Context, style: &TextStyle, text: &str, budget: &mut usize) -> Option { let ppp = ctx.pixels_per_point(); let k = key(style, text, ppp); - let cache = ctx.data_mut(|d| d.get_temp_mut_or_default::(Id::new("font_previews")).clone()); + let cache = ctx.data_mut(|d| d.get_temp_mut_or_default::(Id::unique("font_previews")).clone()); if let Some(t) = cache.0.lock().ok()?.get(&k) { return t.clone(); } diff --git a/crates/ui-egui/src/frame_labels.rs b/crates/ui-egui/src/frame_labels.rs index 9e8b2e7a..a87fc9f4 100644 --- a/crates/ui-egui/src/frame_labels.rs +++ b/crates/ui-egui/src/frame_labels.rs @@ -58,10 +58,16 @@ pub fn turn_since(painter: &Painter, from: ShapeIdx, pivot: Pos2, angle: f32) { }); } +/// Recursively rotate shape geometry by `rot`/`angle` about `pivot`: leaves +/// no-op/callback/band shapes alone, rotates points and centers, adds the angle to +/// ellipses/rects/text, and rotates meshes. fn turn(shape: &mut Shape, rot: Rot2, angle: f32, pivot: Pos2) { let at = |p: &mut Pos2| *p = pivot + rot * (*p - pivot); match shape { Shape::Noop | Shape::Callback(_) => {} + // Bands are not drawn by this UI; if one ever arrives, rotate it via its own `angle` + // rather than approximating the samples as points. + Shape::Band(_) => {} Shape::Vec(v) => v.iter_mut().for_each(|s| turn(s, rot, angle, pivot)), Shape::Circle(c) => at(&mut c.center), Shape::Ellipse(e) => { @@ -166,7 +172,7 @@ pub fn rename_field(app: &mut FigApp, ui: &mut egui::Ui) { let font = regular(11.0); let text_w = ui.painter().layout_no_wrap(name.clone(), font.clone(), Color32::WHITE).size().x; let rect = Rect::from_min_size(pos2(area.min.x, area.max.y - 16.0), vec2((text_w + 24.0).max(area.width()).clamp(80.0, 480.0), 16.0)); - let (done, open) = crate::widgets::rename_field(ui, egui::Id::new(("frame_label_rename", r.id)), &name, rect, font, r.start); + let (done, open) = crate::widgets::rename_field(ui, egui::Id::unique(("frame_label_rename", r.id)), &name, rect, font, r.start); app.ui.rename_label = open.then_some(crate::layers::Rename { id: r.id, start: false }); if let Some(new) = done { app.run("object.rename", serde_json::json!({"ids": [r.id], "name": new})); diff --git a/crates/ui-egui/src/frame_presets.rs b/crates/ui-egui/src/frame_presets.rs index 51662d3d..9eb28162 100644 --- a/crates/ui-egui/src/frame_presets.rs +++ b/crates/ui-egui/src/frame_presets.rs @@ -10,6 +10,8 @@ use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular, semibold}; use crate::widgets::{self, PAD}; +/// Draw the expandable frame-preset categories; choosing a preset runs `frame.create`, +/// switches to the Move tool and zooms to the new selection. pub fn panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); widgets::section_header(ui, tr("Frame"), &[], false); @@ -22,7 +24,7 @@ pub fn panel(app: &mut FigApp, ui: &mut Ui) { } let mut chosen = None; for cat in cats { - let open_id = Id::new(("preset_cat", cat)); + let open_id = Id::unique(("preset_cat", cat)); // The first category starts open. let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(cat == "Phone"); let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), Sense::click()); diff --git a/crates/ui-egui/src/guides.rs b/crates/ui-egui/src/guides.rs index befc9b53..c3bb6589 100644 --- a/crates/ui-egui/src/guides.rs +++ b/crates/ui-egui/src/guides.rs @@ -49,13 +49,14 @@ struct Placed { line: Option<[Point; 2]>, } +/// Stable egui id for guide dragging. fn drag_id() -> Id { - Id::new("guide_drag") + Id::unique("guide_drag") } /// The dragged guide as it would land. fn preview_id() -> Id { - Id::new("guide_preview") + Id::unique("guide_preview") } /// The page's guides and those of its top-level frames (frames last, so they're found first). diff --git a/crates/ui-egui/src/handles.rs b/crates/ui-egui/src/handles.rs index ec489bf8..d29d59ee 100644 --- a/crates/ui-egui/src/handles.rs +++ b/crates/ui-egui/src/handles.rs @@ -67,8 +67,9 @@ fn crop_corners(app: &FigApp, id: NodeId, r: (f64, f64, f64, f64)) -> Option<[Po ]) } +/// Stable egui id for handle dragging. fn drag_id() -> Id { - Id::new("figcraft_handle_drag") + Id::unique("figcraft_handle_drag") } /// The gradient being edited (single node, picker open on a gradient paint). diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index c3f7ee3a..9c809b99 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -188,6 +188,8 @@ fn dropped(app: &mut FigApp, ctx: &egui::Context) { } } +/// Draw the Home sidebar: app icon and wordmark, file/template search (Escape clears it) +/// and section tabs; choosing a section clears the file query. fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { let mut y = left.min.y + 20.0; crate::theme::paint_app_icon(ui, Rect::from_min_size(pos2(left.min.x + 12.0, y - 4.0), vec2(32.0, 32.0))); @@ -195,8 +197,13 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { y += 52.0; let search = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, widgets::FIELD_H)); ui.scope_builder(egui::UiBuilder::new().max_rect(search), |ui| { - let response = - widgets::text_input(ui, Id::new("home_file_search"), &mut app.ui.home_view.file_query, search.width(), tr("Search files and templates")); + let response = widgets::text_input( + ui, + Id::unique("home_file_search"), + &mut app.ui.home_view.file_query, + search.width(), + tr("Search files and templates"), + ); a11y::text(&response, tr("Search files and templates"), &app.ui.home_view.file_query); if (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)) { app.ui.home_view.file_query.clear(); @@ -205,7 +212,7 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, t: &Tokens, left: Rect) { y += widgets::FIELD_H + 12.0; for (section, icon, label) in Section::ALL { let r = Rect::from_min_size(pos2(left.min.x + 8.0, y), vec2(SIDEBAR - 16.0, 32.0)); - let resp = ui.interact(r, ui.id().with(("home_section", label)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("home_section", label)), Sense::click()); let current = app.ui.home_view.file_query.trim().is_empty() && app.ui.home_view.section == section; a11y::tab(&resp, tr(label), current); if current || resp.hovered() { @@ -250,7 +257,7 @@ fn search_results(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { detail_size: 11.0, detail_ink: t.text_secondary, }; - if listed(ui, t, ui.id().with(("search_online", &f.server, &f.file)), 36.0, &row) { + if listed(ui, t, ui.scope_id().with(("search_online", &f.server, &f.file)), 36.0, &row) { open_online = Some(f.clone()); } } @@ -385,7 +392,7 @@ fn open_files(app: &mut FigApp, ui: &mut Ui, t: &Tokens, query: &str) -> usize { detail_size: 11.0, detail_ink: t.text_secondary, }; - if listed(ui, t, ui.id().with(("open", d.uid)), 36.0, &row) { + if listed(ui, t, ui.scope_id().with(("open", d.uid)), 36.0, &row) { switch = Some(i); } } @@ -423,7 +430,7 @@ fn getting_started(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { let close = Rect::from_min_size(pos2(rect.max.x - 36.0, rect.min.y + 12.0), vec2(24.0, 24.0)); if widgets::icon_button_at( ui, - ui.id().with("tips_close"), + ui.scope_id().with("tips_close"), close, Icon::Close, tr("Hide these tips (Getting started in the Help menu shows them again)"), @@ -470,10 +477,12 @@ fn matches(template: &Template, query: &str, kind: Option) -> bool { || has(template.size)) } +/// Draw template search and kind filter, show no-match guidance and a "Show all templates" +/// button that clears the query and kind, and lay matching templates out in a grid. fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { heading(ui, t, tr("Templates")); ui.horizontal(|ui| { - let search = widgets::text_input(ui, Id::new("home_template_search"), &mut app.ui.home_view.query, 260.0, tr("Search templates")); + let search = widgets::text_input(ui, Id::unique("home_template_search"), &mut app.ui.home_view.query, 260.0, tr("Search templates")); a11y::text(&search, tr("Search templates"), &app.ui.home_view.query); let labels: Vec<&str> = std::iter::once(tr("All")).chain(Kind::ALL.iter().map(|k| tr(k.label()))).collect(); let active = app.ui.home_view.kind.and_then(|k| Kind::ALL.iter().position(|x| *x == k)).map_or(0, |i| i + 1); @@ -506,7 +515,7 @@ fn templates(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { fn template_grid(app: &mut FigApp, ui: &mut Ui, t: &Tokens, list: &[&Template]) { let columns = (((ui.available_width() + GAP) / (CARD_W + GAP)).floor() as usize).max(1); let dark = ui.visuals().dark_mode; - let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::new("template_thumbs"))).unwrap_or_default(); + let mut thumbs: Thumbs = ui.data(|d| d.get_temp(Id::unique("template_thumbs"))).unwrap_or_default(); let mut chosen = None; let mut rendered = false; for row in list.chunks(columns) { @@ -529,7 +538,7 @@ fn template_grid(app: &mut FigApp, ui: &mut Ui, t: &Tokens, list: &[&Template]) }); ui.add_space(GAP); } - ui.data_mut(|d| d.insert_temp(Id::new("template_thumbs"), thumbs)); + ui.data_mut(|d| d.insert_temp(Id::unique("template_thumbs"), thumbs)); if let Some(template) = chosen && app.run("file.newFromTemplate", json!({"template": template.id, "name": tr(template.name)})).is_some() { @@ -601,6 +610,6 @@ fn thumbnail(ctx: &egui::Context, template: &Template, background: figcraft_colo /// Tests: the templates whose pictures are cached for the light or dark theme. #[cfg(test)] pub(crate) fn cached_thumbnails(ctx: &egui::Context, dark: bool) -> Vec<&'static str> { - let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::new("template_thumbs"))).unwrap_or_default(); + let thumbs: Thumbs = ctx.data(|d| d.get_temp(Id::unique("template_thumbs"))).unwrap_or_default(); thumbs.0.iter().filter(|((_, d), thumb)| *d == dark && thumb.is_some()).map(|((id, _), _)| *id).collect() } diff --git a/crates/ui-egui/src/home_recents.rs b/crates/ui-egui/src/home_recents.rs index 2d39c6fe..0e151da2 100644 --- a/crates/ui-egui/src/home_recents.rs +++ b/crates/ui-egui/src/home_recents.rs @@ -685,7 +685,7 @@ fn heading(ui: &mut Ui, t: &Tokens, text: &str) { /// The id of `path`'s card or row (stable, so the keyboard stays on it as the order changes). fn card_id(path: &str) -> Id { - Id::new(("home_recent_file", path)) + Id::unique(("home_recent_file", path)) } /// Clicks, keys and the context menu on a card or row: a click selects, a double-click or Enter @@ -909,7 +909,7 @@ fn list(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6 let x0 = if i == 0 { header.min.x } else { header.min.x + date_x(i) }; let x1 = if i + 1 < columns.len() { header.min.x + date_x(i + 1) } else { header.max.x }; let cell = Rect::from_min_max(pos2(x0, header.min.y), pos2(x1, header.max.y)); - let resp = ui.interact(cell, Id::new(("home_recents_header", key.id())), Sense::click()); + let resp = ui.interact(cell, Id::unique(("home_recents_header", key.id())), Sense::click()); a11y::button(&resp, &trf("Sort by {column}", &[("column", label)])); let on = sort.key == *key; if resp.hovered() { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index aeb35096..a94cdec8 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -1283,7 +1283,7 @@ pub fn paint_outlines(painter: &Painter, rect: Rect, size: f64, parts: &[(&figcr /// Every icon at the sizes the UI uses, labelled: for reviewing the set (`ui.set {iconGallery}`). pub fn gallery(ctx: &egui::Context) { let t = crate::theme::Tokens::get(ctx); - egui::Area::new(egui::Id::new("icon_gallery")).order(egui::Order::Foreground).fixed_pos(Pos2::ZERO).show(ctx, |ui| { + egui::Area::new(egui::Id::unique("icon_gallery")).order(egui::Order::Foreground).fixed_pos(Pos2::ZERO).show(ctx, |ui| { let screen = ctx.content_rect(); let (r, _) = ui.allocate_exact_size(screen.size(), egui::Sense::click()); ui.painter().rect_filled(r, 0.0, t.panel); @@ -1381,7 +1381,7 @@ mod tests { let mut u = crate::layers_drag_tests::Ui::new(s); u.frame(vec![]); let shapes = u.frame_output(vec![]).shapes; - let lead = |name: &str| u.ctx.read_response(egui::Id::new(name).with("lead")).map(|r| r.rect).unwrap_or_else(|| panic!("{name} is shown")); + let lead = |name: &str| u.ctx.read_response(egui::Id::unique(name).with("lead")).map(|r| r.rect).unwrap_or_else(|| panic!("{name} is shown")); let (rotation, radius) = (marks(&shapes, lead("pos_rot")), marks(&shapes, lead("ap_rad"))); assert!(!rotation.is_empty() && !radius.is_empty(), "both draw an icon"); assert_ne!(rotation, radius); diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 192073b3..ee25124a 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -36,6 +36,7 @@ impl Harness { /// One frame with `events`, 1/60 s after the last; returns the repaint delay it asked for. fn frame(&mut self, events: Vec) -> Duration { self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); 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; @@ -247,6 +248,8 @@ fn a_finished_background_render_wakes_the_ui() { assert!(worker.poll().is_some(), "the woken frame finds the image"); } +/// Asserts a control request wakes an idle UI and is answered, and screenshots keep frames +/// coming until delivered before the UI rests again. #[test] fn control_requests_and_screenshots_wake_an_idle_ui() { let (mut h, _) = document(); @@ -258,7 +261,7 @@ fn control_requests_and_screenshots_wake_an_idle_ui() { method: "ui.inspect".into(), params: json!({}), reply: tx.clone(), - cancelled: Default::default(), + state: Default::default(), }); assert_eq!(h.frame(vec![]), Duration::ZERO); assert!(rx.try_recv().unwrap().result.is_ok()); @@ -266,15 +269,20 @@ fn control_requests_and_screenshots_wake_an_idle_ui() { // A screenshot keeps frames coming until the window delivers it, then the UI rests again. let path = std::env::temp_dir().join(format!("figcraft-idle-shot-{}.png", std::process::id())); let params = json!({"path": path.to_string_lossy()}); - h.app.control_queue.push(ControlRequest { id: json!(2), method: "ui.screenshot".into(), params, reply: tx, cancelled: Default::default() }); + h.app.control_queue.push(ControlRequest { id: json!(2), method: "ui.screenshot".into(), params, reply: tx, state: Default::default() }); for _ in 0..5 { - assert_eq!(h.frame(vec![]), Duration::ZERO, "waiting for the screenshot"); + let delay = h.frame(vec![]); + assert!(delay <= Duration::from_secs_f64(crate::control::SHOT_TIMEOUT), "waiting for the screenshot"); } assert!(rx.try_recv().is_err(), "not delivered yet"); - let image = Arc::new(egui::ColorImage::filled([4, 4], egui::Color32::RED)); - h.frame(vec![Event::Screenshot { viewport_id: ViewportId::ROOT, user_data: egui::UserData::default(), image }]); + // The settled UI issued the viewport capture command; the renderer completes its one-shot + // slot, and the next UI pass writes the reply. + let spec = h.app.capture.shots.first().and_then(|r| r.spec).expect("admitted against real geometry"); + let slot = h.app.capture.shots.first().and_then(|r| r.slot.clone()).expect("issued after settling"); + slot.complete(Arc::new(egui::ColorImage::filled(spec.size_px, egui::Color32::RED))); + h.frame(vec![]); let reply = rx.try_recv().unwrap(); - assert_eq!(reply.result.unwrap()["width"], json!(4)); + assert_eq!(reply.result.unwrap()["width"], json!(spec.size_px[0])); let _ = std::fs::remove_file(&path); h.assert_idle("after a screenshot"); } @@ -335,41 +343,52 @@ fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL); } +/// Asserts a hidden window answers edits and inspection at once, while a screenshot is +/// parked with a deadline and times out once instead of waiting for a frame. #[test] fn a_hidden_window_answers_at_once() { // #269, #720: minimised or covered, the window draws no frames, but the app's logic still runs: // it answers edits, inspection, saves and the interface's state; what needs a frame (a capture - // of the window) is an error at once instead of waiting for the window to show. + // of the window) queues for a forced hidden paint and otherwise expires at its deadline. let dir = std::env::temp_dir().join(format!("figcraft-hidden-save-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("hidden.figcraft"); let (mut h, _) = document(); h.settle(); + // Exercise admission well after startup, independently of settling speed. + h.time += 120.0; + h.app.clock.set_test_now(Some(h.time)); let (tx, rx) = std::sync::mpsc::channel(); let request = |id: u64, method: &str, params: Value| ControlRequest { id: json!(id), method: method.into(), params, reply: tx.clone(), - cancelled: Default::default(), + state: Default::default(), }; h.app.control_queue.push(request(1, "engine.execute", json!({"command": "file.save", "params": {"path": path.to_string_lossy()}}))); h.app.control_queue.push(request(2, "ui.inspect", json!({}))); h.app.control_queue.push(request(3, "ui.screenshot", json!({"path": dir.join("shot.png").to_string_lossy()}))); let mut hidden = RawInput::default(); hidden.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); - let app = &mut h.app; - let _ = h.ctx.run_logic(&hidden, |ctx| crate::control::drain_hidden(app, ctx)); + let ctx = h.ctx.clone(); + let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx)); let saved = rx.try_recv().unwrap(); assert_eq!(saved.id, json!(1)); assert!(saved.result.is_ok() && path.exists(), "saved while hidden: {:?}", saved.result); let inspected = rx.try_recv().unwrap(); assert_eq!(inspected.id, json!(2)); assert!(inspected.result.is_ok(), "the interface's state: {:?}", inspected.result); - let shot = rx.try_recv().unwrap(); - assert_eq!(shot.id, json!(3)); - assert!(shot.result.unwrap_err().contains("minimised or hidden"), "a capture needs the window on screen"); + assert!(rx.try_recv().is_err(), "a capture queues for a forced hidden paint"); + assert_eq!(h.app.capture.shots.len(), 1, "one admitted capture"); assert!(h.app.control_queue.is_empty(), "nothing waits for the window"); + // Its deadline expires it once, with the same error the transport used to get. + h.app.clock.set_test_now(Some(h.time + crate::control::SHOT_TIMEOUT + 1.0)); + let _ = ctx.run_logic(&hidden, |ctx| crate::control::background(&mut h.app, ctx)); + let shot = rx.try_recv().expect("expired"); + assert_eq!(shot.id, json!(3)); + assert!(shot.result.unwrap_err().contains("didn't deliver a screenshot"), "a capture needs paint"); + assert!(h.app.capture.shots.is_empty(), "released after its deadline"); let _ = std::fs::remove_dir_all(&dir); } @@ -408,3 +427,47 @@ fn auto_save_writes_a_changed_file_then_rests() { h.assert_idle("an auto-saved file"); let _ = std::fs::remove_dir_all(&dir); } + +/// Asserts a hidden logic tick runs background work and advances the autosave deadline +/// without counting a UI frame, and is idempotent at the same time. +#[test] +fn a_hidden_logic_tick_runs_background_work_but_counts_no_ui_frame() { + let (mut h, _) = document(); + h.settle(); + let frames = h.app.frame_stats.frames; + let deadline_before = h.app.ui.next_autosave; + h.time += crate::AUTOSAVE_SECS + 1.0; + h.app.clock.set_test_now(Some(h.time)); + let mut input = RawInput::default(); + input.viewports.insert(ViewportId::ROOT, egui::ViewportInfo { minimized: Some(true), ..Default::default() }); + let ctx = h.ctx.clone(); + let _ = ctx.run_logic(&input, |ctx| h.app.background_tick(ctx)); + assert_eq!(h.app.frame_stats.frames, frames, "a logic-only tick is not a UI frame"); + let deadline = h.app.ui.next_autosave; + assert!(deadline > deadline_before + crate::AUTOSAVE_SECS - 1.0, "the autosave deadline advanced"); + // Repeating the same due instant does not advance it again (consumed/deadline-driven work). + let _ = ctx.run_logic(&input, |ctx| h.app.background_tick(ctx)); + assert_eq!(h.app.ui.next_autosave, deadline, "idempotent at the same monotonic time"); +} + +/// Asserts a discarded sizing pass counts one drawn frame, not one per pass. +#[test] +fn a_discarded_sizing_pass_sets_up_and_counts_one_frame_only() { + let (mut h, _) = document(); + h.settle(); + let frames = h.app.frame_stats.frames; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(h.time + 1.0 / 60.0), ..Default::default() }; + let mut passes = 0; + let app = &mut h.app; + let mut out = h.ctx.run_ui(input, |ui| { + passes += 1; + app.ui(ui); + if passes == 1 { + ui.ctx().request_discard("test sizing pass"); + } + }); + out.textures_delta.clear(); + assert_eq!(passes, 2, "egui reran the frame"); + assert_eq!(app.frame_stats.frames, frames + 1, "one frame was drawn, not one per pass"); +} diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index a6db0cbd..e6c2f698 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -3,6 +3,7 @@ //! edit (an idle frame too) with the candidate window at the caret, the composition shows without //! entering the document, keys pressed while composing are the IME's, and the commit lands once. +use egui::output::{IMEOutput, IMESurroundingText, IMESurroundingTextPayload, ImeChangeCause}; use egui::{Event, ImeEvent, Key, Modifiers, RawInput, Rect}; use figcraft_doc::{NodeId, NodeKind}; use figcraft_engine::Session; @@ -42,6 +43,24 @@ fn composing(u: &Ui) -> Option { u.app.session.ds().unwrap().text_edit.as_ref().expect("still editing").preedit.as_ref().map(|p| p.text.clone()) } +/// The whole IME descriptor one frame leaves for the platform. +fn ime_descriptor(u: &mut Ui, events: Vec) -> IMEOutput { + u.frame_raw(RawInput { events, ..Default::default() }).platform_output.ime.expect("the IME stays on while editing") +} + +/// Return the available surrounding-text payload of an IME output, panicking when unavailable. +fn available(ime: &IMEOutput) -> &IMESurroundingTextPayload { + match &ime.surrounding_text { + IMESurroundingText::Available(payload) => payload, + IMESurroundingText::Unavailable => panic!("expected available surrounding text"), + } +} + +/// Create a text layer with `text` and return its node id. +fn text_layer(s: &mut Session, text: &str) -> NodeId { + NodeId(s.execute("text.create", &json!({"text": text, "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()) +} + #[test] fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() { // The text is measured with a font: without craft-fonts a system one, scanned here rather than @@ -85,3 +104,543 @@ fn pinyin_composes_inline_and_commits_once_with_the_ime_on_every_frame() { ime(&mut u, vec![key(Key::Enter, true), key(Key::Enter, false), Event::Text("!".into())]); assert_eq!(text(&u, t), "Hi你好\n!"); } + +/// Asserts an empty IME commit ends the composition, inserts nothing, keeps the editor +/// focused, and the next pass observes the committed text. +#[test] +fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { + // Safari's egui web integration sends `Commit("")` (not `Preedit("")`) when the last + // composition character is deleted. It ends the composition; it must not leave the pre-edit + // rendering or insert anything. + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hello"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(5), false).unwrap(); + ime(&mut u, vec![preedit("にほ")]); + assert_eq!(composing(&u).as_deref(), Some("にほ")); + let after = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit(String::new()))]); + assert_eq!(composing(&u), None, "the empty commit ends the composition"); + assert_eq!(text(&u, t), "Hello", "and inserts nothing"); + assert!(matches!(after.surrounding_text, IMESurroundingText::Available(_))); + // The editor is still focused, and the next pass observes committed text only. + let next = ime_descriptor(&mut u, vec![]); + assert_eq!(composing(&u), None); + assert_eq!(available(&next).text, "Hello"); + assert_eq!(text(&u, t), "Hello"); +} + +/// Asserts byte deletion before the paired commit replaces the exact UTF-8 range, and one undo +/// restores the pre-IME text. +#[test] +fn an_ime_replacing_text_deletes_in_bytes_before_the_paired_commit() { + // 0.37 sends `DeleteSurroundingBytes` immediately before the commit that replaces it. The + // custom engine editor deletes the exact UTF-8 range (never display-bidi text) and keeps the + // whole edit as one undo step. + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = NodeId(s.execute("text.create", &json!({"text": "ab\u{4F60}\u{597D}cd", "x": 40, "y": 40})).unwrap()["id"].as_u64().unwrap()); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(4), false).unwrap(); + ime(&mut u, vec![]); + // Compose, then replace "你好c" with "X". + ime(&mut u, vec![preedit("x")]); + ime(&mut u, vec![Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 6, after_bytes: 1 }), Event::Ime(ImeEvent::Commit("X".into()))]); + assert_eq!(text(&u, t), "abXd"); + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + u.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&u, t), "ab\u{4F60}\u{597D}cd", "one undo restores the pre-IME text"); +} + +/// Asserts a caret click invalidates the whole stale IME batch (both byte and character +/// deletions) while a later direct commit still works. +#[test] +fn a_caret_click_rejects_the_stale_delete_and_commit_in_the_same_input_batch() { + figcraft_text::scan_system_fonts(); + for delete in + [ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 1 }, ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 1 }] + { + let mut s = Session::new(); + let t = text_layer(&mut s, "abcdef"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(6), false).unwrap(); + ime(&mut u, vec![preedit("z")]); + let p = u.app.to_screen(42.0, 45.0); + // Real canvas pointer handling moves the caret and cancels preedit before text_keys. + ime( + &mut u, + vec![ + Event::PointerMoved(p), + Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::Ime(delete), + Event::Ime(ImeEvent::Commit("Q".into())), + ], + ); + let edit = u.app.session.ds().unwrap().text_edit.as_ref().unwrap(); + assert!(edit.caret < 6, "the production canvas click actually moved the caret"); + assert!(edit.preedit.is_none(), "the click ended the composition"); + assert_eq!(text(&u, t), "abcdef", "neither stale deletion nor its Commit may mutate the clicked caret"); + u.frame(vec![Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }]); + // A later direct commit without an active composition is legitimate. + let caret = u.app.session.ds().unwrap().text_edit.as_ref().unwrap().caret; + ime(&mut u, vec![Event::Ime(ImeEvent::Commit("X".into()))]); + let mut expected = "abcdef".to_string(); + expected.insert(caret, 'X'); + assert_eq!(text(&u, t), expected, "the interrupted batch does not poison a later direct commit"); + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + u.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&u, t), "abcdef", "the valid edit retains one-session undo"); + } +} + +/// Asserts a control-channel document switch rejects the old composition's delete and commit +/// batch, while a direct commit to the new target remains valid. +#[test] +fn a_document_switch_rejects_the_old_compositions_delete_and_commit_batch() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let a = text_layer(&mut s, "Alpha"); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let b = text_layer(&mut s, "Bravo"); + figcraft_engine::textedit::begin(&mut s, b, Some(5), false).unwrap(); + s.execute("doc.switch", &json!({"index": 0})).unwrap(); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, a, Some(5), false).unwrap(); + ime(&mut u, vec![preedit("z")]); + let (tx, rx) = std::sync::mpsc::channel(); + u.app.control_queue.push(crate::control::ControlRequest { + id: json!(91), + method: "engine.execute".into(), + params: json!({"window": 0, "command": "doc.switch", "params": {"index": 1}}), + reply: tx, + state: Default::default(), + }); + ime(&mut u, vec![Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), Event::Ime(ImeEvent::Commit("Q".into()))]); + assert!(rx.try_recv().unwrap().result.is_ok(), "production control drain switched the document"); + assert_eq!(u.app.session.active, 1); + assert_eq!(text(&u, b), "Bravo", "the new target cannot inherit the old composition's mutations"); + assert_eq!(u.app.session.docs[0].doc.node(a).unwrap().kind.text().unwrap().characters, "Alpha"); + ime(&mut u, vec![Event::Ime(ImeEvent::Commit("X".into()))]); + assert_eq!(text(&u, b), "BravoX", "a direct commit to the new noncomposing target remains valid"); +} + +/// Asserts the first observation and ordinary typing are `Other`, while an accepted commit is +/// `InputMethod` with UTF-8 offsets, the same owner and generation. +#[test] +fn ime_cause_is_input_method_only_for_an_accepted_commit() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + + let first = ime_descriptor(&mut u, vec![]); + assert_eq!(first.change_cause, ImeChangeCause::Other, "the first focused observation starts a context"); + assert_eq!(available(&first).text, "Hi"); + assert_eq!((available(&first).cursor, available(&first).anchor), (2, 2)); + + let commit = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("你好".into()))]); + assert_eq!(commit.change_cause, ImeChangeCause::InputMethod); + assert_eq!(available(&commit).text, "Hi你好"); + assert_eq!(available(&commit).cursor, 8, "UTF-8 bytes, not characters"); + assert_eq!(commit.context_generation, first.context_generation, "a commit is not a context change"); + assert_eq!(commit.owner, first.owner); + + let typed = ime_descriptor(&mut u, vec![Event::Text("x".into())]); + assert_eq!(typed.change_cause, ImeChangeCause::Other, "ordinary typing is an application edit"); + assert_eq!(available(&typed).text, "Hi你好x"); + assert_eq!(text(&u, t), "Hi你好x"); +} + +/// Asserts a frame mixing IME and application edits is `Other`, and a later pure commit is +/// `InputMethod` without lingering state. +#[test] +fn ime_cause_reports_mixed_application_and_ime_mutations_as_other() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + ime(&mut u, vec![]); + + let mixed = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("あ".into())), Event::Text("b".into())]); + assert_eq!(mixed.change_cause, ImeChangeCause::Other, "an application edit in the same frame dominates"); + assert_eq!(available(&mixed).text, "Hiあb"); + + // The next frame is only an accepted commit: the frame-scoped `Other` does not linger. + let pure = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("う".into()))]); + assert_eq!(pure.change_cause, ImeChangeCause::InputMethod); + assert_eq!(available(&pure).text, "Hiあbう"); + + // An application edit before the IME receipt, and IME → application → IME, are both Other. + let before = ime_descriptor(&mut u, vec![Event::Text("c".into()), Event::Ime(ImeEvent::Commit("え".into()))]); + assert_eq!(before.change_cause, ImeChangeCause::Other); + let sandwiched = + ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("お".into())), Event::Text("d".into()), Event::Ime(ImeEvent::Commit("か".into()))]); + assert_eq!(sandwiched.change_cause, ImeChangeCause::Other); + assert_eq!(text(&u, t), "Hiあbうcえおdか"); +} + +/// Asserts a control-channel text edit is an application change (`Other`). +#[test] +fn a_control_channel_text_edit_is_an_application_change() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + ime(&mut u, vec![]); + + // A command from the control channel (not an ImeEvent) changes committed text. + u.app.session.execute("text.edit.insert", &json!({"text": "z"})).unwrap(); + let out = ime_descriptor(&mut u, vec![]); + assert_eq!(out.change_cause, ImeChangeCause::Other); + assert_eq!(available(&out).text, "Hiz"); +} + +/// Asserts a byte delete followed by a commit is `InputMethod` and describes the +/// post-mutation text and caret. +#[test] +fn ime_delete_then_commit_is_input_method_and_describes_the_post_mutation_text() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "ab\u{4F60}\u{597D}cd"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(4), false).unwrap(); + ime(&mut u, vec![]); + + let out = ime_descriptor( + &mut u, + vec![Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 6, after_bytes: 1 }), Event::Ime(ImeEvent::Commit("X".into()))], + ); + assert_eq!(out.change_cause, ImeChangeCause::InputMethod); + assert_eq!(available(&out).text, "abXd", "the snapshot is observed after both mutations"); + assert_eq!(available(&out).cursor, 3); + assert_eq!(text(&u, t), "abXd"); +} + +/// Asserts preedit text is excluded from surrounding text and earns no receipt, while the +/// following commit is `InputMethod`. +#[test] +fn preedit_is_not_surrounding_text_but_the_commit_that_follows_is_input_method() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + ime(&mut u, vec![]); + + let composing = ime_descriptor(&mut u, vec![preedit("にほ")]); + assert_eq!(available(&composing).text, "Hi", "the composition is excluded"); + assert_eq!((available(&composing).cursor, available(&composing).anchor), (2, 2)); + assert_eq!(composing.change_cause, ImeChangeCause::Other, "preedit alone earns no receipt"); + + let committed = ime_descriptor(&mut u, vec![preedit(""), Event::Ime(ImeEvent::Commit("にほ".into()))]); + assert_eq!(committed.change_cause, ImeChangeCause::InputMethod); + assert_eq!(available(&committed).text, "Hiにほ"); + assert_eq!(available(&committed).cursor, 8); +} + +/// Asserts surrounding text is logical UTF-8 with byte offsets and keeps a reversed +/// selection. +#[test] +fn ime_surrounding_text_is_logical_utf8_and_keeps_a_reversed_selection() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "café😀e\u{301}x"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(8), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut u.app.session, 8, false).unwrap(); + figcraft_engine::textedit::set_caret(&mut u.app.session, 5, true).unwrap(); + + let out = ime_descriptor(&mut u, vec![]); + let payload = available(&out); + assert_eq!(payload.text, "café😀e\u{301}x"); + assert_eq!((payload.cursor, payload.anchor), (9, 13)); + assert!(payload.cursor < payload.anchor, "the reversed selection is not sorted"); +} + +/// Asserts empty text is available while a selection wider than the 4000-byte budget is +/// explicitly unavailable but still has a nonzero IME context generation. +#[test] +fn empty_text_is_available_while_a_too_wide_selection_is_explicitly_unavailable() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, ""); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(0), false).unwrap(); + let empty = ime_descriptor(&mut u, vec![]); + let payload = available(&empty); + assert_eq!((payload.text.as_str(), payload.cursor, payload.anchor), ("", 0, 0)); + + // The same editor over a selection wider than the 4000-byte budget cannot describe it. + let mut s = Session::new(); + let long = "a".repeat(5000); + let t = text_layer(&mut s, &long); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, None, false).unwrap(); + let wide = ime_descriptor(&mut u, vec![]); + assert_eq!(wide.surrounding_text, IMESurroundingText::Unavailable); + assert!(wide.context_generation > 0, "the owner stays known when only the text is unavailable"); +} + +/// Asserts replacing a too-wide selection with a commit is still an `InputMethod` receipt. +#[test] +fn a_commit_replacing_a_too_wide_selection_is_still_an_input_method_receipt() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let long = "a".repeat(5000); + let t = text_layer(&mut s, &long); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, None, false).unwrap(); + let wide = ime_descriptor(&mut u, vec![]); + assert_eq!(wide.surrounding_text, IMESurroundingText::Unavailable); + + // The unavailable baseline and the pre-mutation stamp carry the engine's document revision, + // so replacing the over-wide selection with a normal commit is a provable IME receipt rather + // than an unproven `Other` change. + let out = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("X".into()))]); + assert_eq!(out.change_cause, ImeChangeCause::InputMethod); + assert_eq!(text(&u, t), "X"); + assert_eq!(available(&out).text, "X"); + assert_eq!((available(&out).cursor, available(&out).anchor), (1, 1)); +} + +/// Asserts another text node is another owner, and re-entering the same node changes the +/// context generation while keeping the owner key. +#[test] +fn a_new_edit_session_and_another_text_node_are_new_ime_owners() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let a = text_layer(&mut s, "A"); + let b = text_layer(&mut s, "B"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, a, Some(1), false).unwrap(); + let first = ime_descriptor(&mut u, vec![]); + + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + figcraft_engine::textedit::begin(&mut u.app.session, b, Some(1), false).unwrap(); + let other = ime_descriptor(&mut u, vec![]); + assert_ne!(other.owner, first.owner, "another text node is another owner"); + assert_ne!(other.context_generation, first.context_generation); + + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + figcraft_engine::textedit::begin(&mut u.app.session, a, Some(1), false).unwrap(); + let again = ime_descriptor(&mut u, vec![]); + assert_eq!(again.owner, first.owner, "the owner key is the document and node"); + assert_ne!(again.context_generation, first.context_generation, "re-entering is a replaced target"); +} + +/// Asserts an accepted commit survives a discarded rerun without inserting twice and keeps +/// its `InputMethod` cause, while a mixed rerun is `Other`. +#[test] +fn an_accepted_commit_survives_a_discarded_pass_without_reapplying_or_losing_its_cause() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + let first = ime_descriptor(&mut u, vec![]); + + let out = u.frame_raw_with_discard(RawInput { events: vec![Event::Ime(ImeEvent::Commit("あ".into()))], ..Default::default() }); + let ime = out.platform_output.ime.expect("the presented pass keeps the IME on"); + assert_eq!(text(&u, t), "Hiあ", "the discarded rerun must not insert twice"); + assert_eq!(available(&ime).text, "Hiあ"); + assert_eq!(ime.change_cause, ImeChangeCause::InputMethod, "the unchanged second pass keeps the first pass's cause"); + assert_eq!(ime.context_generation, first.context_generation); + + let mixed = u + .frame_raw_with_discard(RawInput { events: vec![Event::Ime(ImeEvent::Commit("い".into())), Event::Text("x".into())], ..Default::default() }); + let mixed = mixed.platform_output.ime.expect("still editing"); + assert_eq!(text(&u, t), "Hiあいx"); + assert_eq!(mixed.change_cause, ImeChangeCause::Other); +} + +/// Asserts IME context state is scoped per viewport: each viewport has its own generation, +/// the root keeps its context across an aux pass, and a closed aux viewport does not inherit +/// the old generation. +#[test] +fn ime_context_state_is_scoped_per_viewport() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + figcraft_engine::textedit::begin(&mut s, t, Some(2), false).unwrap(); + let mut app = crate::FigApp::new(s, Box::new(crate::NoServices)); + // No window drew here, so give the canvas a finite rect for the caret geometry. + app.canvas.rect = Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + let aux = egui::ViewportId::from_hash_of("figcraft-ime-aux"); + + let mut raw = RawInput { viewport_id: egui::ViewportId::ROOT, ..Default::default() }; + raw.viewports.entry(egui::ViewportId::ROOT).or_default(); + let root_out = ctx.run_ui(raw, |ui| { + ui.ctx().show_viewport_deferred(aux, egui::ViewportBuilder::default(), |_, _| {}); + crate::shortcuts::ime_output(&mut app, ui.ctx()); + }); + let root = root_out.platform_output.ime.clone().expect("root descriptor"); + root_out.drop_without_applying_deltas(); + + let mut raw = RawInput { viewport_id: aux, ..Default::default() }; + raw.viewports.entry(aux).or_default(); + let aux_out = ctx.run_ui(raw, |ui| crate::shortcuts::ime_output(&mut app, ui.ctx())); + let aux_ime = aux_out.platform_output.ime.clone().expect("aux descriptor"); + aux_out.drop_without_applying_deltas(); + assert_eq!(aux_ime.owner, root.owner, "the same edit is the same owner"); + assert_ne!(aux_ime.context_generation, root.context_generation, "each viewport gets its own generation"); + assert_eq!(available(&aux_ime).text, "Hi"); + + // The root's next consecutive pass keeps its context even though the aux pass ran between. + let mut raw = RawInput { viewport_id: egui::ViewportId::ROOT, ..Default::default() }; + raw.viewports.entry(egui::ViewportId::ROOT).or_default(); + let root_out = ctx.run_ui(raw, |ui| { + ui.ctx().show_viewport_deferred(aux, egui::ViewportBuilder::default(), |_, _| {}); + crate::shortcuts::ime_output(&mut app, ui.ctx()); + }); + let root_again = root_out.platform_output.ime.clone().expect("root descriptor"); + root_out.drop_without_applying_deltas(); + assert_eq!(root_again.context_generation, root.context_generation); + + // Closing the aux viewport releases its state; it never inherits the old context. + app.ime.end_viewport(aux); + let mut raw = RawInput { viewport_id: aux, ..Default::default() }; + raw.viewports.entry(aux).or_default(); + let aux_out = ctx.run_ui(raw, |ui| crate::shortcuts::ime_output(&mut app, ui.ctx())); + let aux_again = aux_out.platform_output.ime.clone().expect("aux descriptor"); + aux_out.drop_without_applying_deltas(); + assert_ne!(aux_again.context_generation, aux_ime.context_generation); +} + +/// Asserts the engine's IME surrounding-text byte limit equals egui's wire limit. +#[test] +fn the_engine_surrounding_limit_matches_the_wire_limit() { + // A divergence would truncate or overrun the bounded payload silently. + let engine = figcraft_engine::textedit::MAX_IME_SURROUNDING_BYTES; + let wire = egui::output::MAX_IME_SURROUNDING_TEXT_BYTES; + assert_eq!(engine, wire); +} + +/// Asserts a frame observes the IME exactly once in both the editor pass and the Home +/// early-return pass. +#[test] +fn a_frame_observes_the_ime_once_even_when_it_ends_early() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + let before = u.app.ime_observations; + u.frame(vec![]); + assert_eq!(u.app.ime_observations - before, 1, "one final observation for the editor pass"); + + // The home/present early returns make the same single idle observation. + u.app.ui.home = true; + let before = u.app.ime_observations; + u.frame(vec![]); + assert_eq!(u.app.ime_observations - before, 1, "one idle observation on the home screen"); +} + +/// Asserts a double-click inside an existing edit selects the word without changing the IME +/// owner or generation. +#[test] +fn a_double_click_inside_the_edit_keeps_the_ime_generation() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hello world"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + let (p, zoom) = { + let doc = u.app.session.doc().unwrap(); + let node = doc.node(t).unwrap(); + let Some((layout, dy)) = figcraft_text::layout_node(node) else { return }; + if layout.glyphs.is_empty() || layout.lines.is_empty() { + return; // no fonts + } + let (x0, x1) = (layout.caret(7).0, layout.caret(8).0); + let line = &layout.lines[0]; + let world = doc.world_transform(t); + let p = world * figcraft_geom::Point::new((x0 + x1) / 2.0, line.top + line.height / 2.0 + dy); + (p, u.app.session.ds().unwrap().view.zoom) + }; + let first = ime_descriptor(&mut u, vec![]); + let ev = figcraft_engine::PointerEvent::at(figcraft_engine::PointerKind::DoubleClick, p.x, p.y, zoom); + u.app.session.pointer(&ev).unwrap(); + + let after = ime_descriptor(&mut u, vec![]); + assert_eq!(after.owner, first.owner, "the same text node is the same owner"); + assert_eq!(after.context_generation, first.context_generation, "a word-select inside the edit is not a replaced target"); + assert_eq!(available(&after).text, "Hello world"); +} + +/// Asserts a `window.new` that moves the edited file leaves the root window emitting no IME +/// descriptor. +#[test] +fn a_window_new_that_moves_the_edited_file_leaves_the_root_ime_idle() { + figcraft_text::scan_system_fonts(); + let mut s = Session::empty(); + s.execute("file.new", &json!({"name": "A"})).unwrap(); + let uid = s.ds().unwrap().uid; + let t = text_layer(&mut s, "Hi"); + figcraft_engine::textedit::begin(&mut s, t, Some(2), false).unwrap(); + s.execute("file.new", &json!({"name": "B"})).unwrap(); + let a = s.docs.iter().position(|d| d.uid == uid).unwrap(); + s.execute("doc.switch", &json!({"index": a})).unwrap(); + let mut u = Ui::new(s); + u.ctx.set_embed_viewports(false); + let first = ime_descriptor(&mut u, vec![]); + assert_eq!(available(&first).text, "Hi"); + + // A `window.new` queued before the pass is applied after `windows::begin` picked the file + // for the root: the root no longer shows the edited file when the pass ends. + u.app.window_requests.push(figcraft_engine::UiRequest { kind: "window.new".into(), data: json!({"index": a}) }); + let out = u.frame_raw(RawInput::default()); + assert_ne!(u.app.windows.of(uid), 0, "the file moved to its own window"); + assert!(out.platform_output.ime.is_none(), "the root must not emit a descriptor for a file it no longer shows"); +} + +/// Asserts a CRLF-normalized commit is application-origin (`Other`), and bare-newline and +/// control-only commits insert nothing. +#[test] +fn a_transformed_commit_is_application_origin_and_a_bare_newline_does_nothing() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "Hi"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + ime(&mut u, vec![]); + + let normalized = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("a\r\nb".into()))]); + assert_eq!(normalized.change_cause, ImeChangeCause::Other, "CRLF normalisation is an application transformation"); + assert_eq!(text(&u, t), "Hia\nb"); + assert_eq!(available(&normalized).text, "Hia\nb"); + + let newline = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("\n".into()))]); + assert_eq!(text(&u, t), "Hia\nb", "stock TextEdit ignores a bare newline commit"); + assert!(matches!(newline.surrounding_text, IMESurroundingText::Available(_))); + let control = ime_descriptor(&mut u, vec![Event::Ime(ImeEvent::Commit("\u{0}\u{7}".into()))]); + assert_eq!(text(&u, t), "Hia\nb", "a control-only commit inserts nothing"); + assert!(matches!(control.surrounding_text, IMESurroundingText::Available(_))); +} + +/// Asserts character deletion keeps the selection for the paired commit, which replaces it +/// and reports `InputMethod`. +#[test] +fn ime_delete_surrounding_chars_keeps_the_selection_for_the_paired_commit() { + figcraft_text::scan_system_fonts(); + let mut s = Session::new(); + let t = text_layer(&mut s, "ab\u{4F60}\u{597D}cd"); + let mut u = Ui::new(s); + figcraft_engine::textedit::begin(&mut u.app.session, t, Some(2), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut u.app.session, 4, true).unwrap(); // "你好" + ime(&mut u, vec![]); + + let out = ime_descriptor( + &mut u, + vec![Event::Ime(ImeEvent::DeleteSurrounding { before_chars: 1, after_chars: 1 }), Event::Ime(ImeEvent::Commit("X".into()))], + ); + // The delete removes "b" and "c"; the selected "你好" stays for the commit, which replaces it. + assert_eq!(text(&u, t), "aXd"); + assert_eq!(available(&out).text, "aXd"); + assert_eq!(available(&out).cursor, 2); + assert_eq!(out.change_cause, ImeChangeCause::InputMethod); +} diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index c84db661..fcc0bbc6 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -81,7 +81,7 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { let mut y = r.min.y + 8.0; // Main menu (logo). let lr = Rect::from_center_size(pos2(r.center().x, y + 16.0), vec2(36.0, 32.0)); - let lresp = ui.interact(lr, ui.id().with("rail_menu"), Sense::click()).tip(tr("Main menu")); + let lresp = ui.interact(lr, ui.scope_id().with("rail_menu"), Sense::click()).tip(tr("Main menu")); if lresp.hovered() || app.ui.main_menu { ui.painter().rect_filled(lr, CornerRadius::same(6), t.hover); } @@ -100,7 +100,7 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { for (icon, label, tab) in items { let ir = Rect::from_center_size(pos2(r.center().x, y + 14.0), vec2(36.0, 28.0)); let hit = Rect::from_min_max(pos2(r.min.x, y), pos2(r.max.x, y + 48.0)); - let resp = ui.interact(hit, ui.id().with(("rail", label)), Sense::click()).tip(label); + let resp = ui.interact(hit, ui.scope_id().with(("rail", label)), Sense::click()).tip(label); let active = tab.is_some_and(|tb| tb == app.ui.left_tab && !app.ui.left_collapsed && !app.ui.versions.open); if tab.is_some() { a11y::tab(&resp, label, active); @@ -134,6 +134,8 @@ pub fn rail(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the left panel: versions when open, otherwise the file/Assets header with in-place +/// file rename and collapse control, followed by Pages and Layers or the Assets panel. pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { if app.ui.versions.open { crate::versions::panel(app, ui); @@ -149,13 +151,13 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default(); let nr = Rect::from_min_max(pos2(head.min.x + 14.0, head.min.y + 14.0), pos2(head.max.x - 48.0, head.min.y + 34.0)); let start = std::mem::take(&mut app.ui.rename_file); - if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new("filename"), &name, nr, semibold(13.0), start) { + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::unique("filename"), &name, nr, semibold(13.0), start) { app.run("file.rename", json!({"name": new})); } ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 46.0), Align2::LEFT_CENTER, tr("Drafts"), regular(11.0), t.text_secondary); } let hr = Rect::from_center_size(pos2(head.max.x - 24.0, head.min.y + 24.0), vec2(24.0, 24.0)); - let hresp = ui.interact(hr, ui.id().with("hideui"), Sense::click()).tip(tr("Collapse panel")); + let hresp = ui.interact(hr, ui.scope_id().with("hideui"), Sense::click()).tip(tr("Collapse panel")); if hresp.hovered() { ui.painter().rect_filled(hr, CornerRadius::same(5), t.hover); } @@ -173,6 +175,8 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { } } +/// Draw the page list with open/search/add controls, filtering, selection, rename, +/// reorder/drag and layer drop-to-page; Dev Mode allows opening pages but blocks mutations. fn pages(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); // Dev Mode only inspects: rows open pages, but don't add, rename, reorder, duplicate or delete. @@ -182,19 +186,19 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); - let tresp = ui.interact(Rect::from_min_size(hr.min, vec2(w - 40.0, 40.0)), ui.id().with("pages_toggle"), Sense::click()); + let tresp = ui.interact(Rect::from_min_size(hr.min, vec2(w - 40.0, 40.0)), ui.scope_id().with("pages_toggle"), Sense::click()); a11y::toggle(&tresp, tr("Pages"), app.ui.pages_open); ui.painter().text(pos2(hr.min.x + 16.0, hr.center().y), Align2::LEFT_CENTER, tr("Pages"), semibold(11.0), t.text); if tresp.clicked() { app.ui.pages_open = !app.ui.pages_open; } let sr = Rect::from_center_size(pos2(hr.max.x - 50.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("lsearch"), Sense::click()).tip(tr("Search pages")); + let sresp = ui.interact(sr, ui.scope_id().with("lsearch"), Sense::click()).tip(tr("Search pages")); if sresp.hovered() || app.ui.page_search { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); - let search_id = Id::new("page_search_field"); + let search_id = Id::unique("page_search_field"); if sresp.clicked() { app.ui.page_search = !app.ui.page_search; app.ui.page_filter.clear(); @@ -203,7 +207,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } } let ar = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); - let aresp = ui.interact(ar, ui.id().with("addpage"), Sense::click()).tip(tr("Add new page")); + let aresp = ui.interact(ar, ui.scope_id().with("addpage"), Sense::click()).tip(tr("Add new page")); if aresp.hovered() { ui.painter().rect_filled(ar, CornerRadius::same(5), t.hover); } @@ -285,7 +289,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { let tr = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 4.0), pos2(r.max.x - 4.0, r.max.y - 4.0)); let start = ui.data_mut(|d| d.remove_temp::(page_rename_start_id())).unwrap_or(false); let font = if id == cur { semibold(11.0) } else { regular(11.0) }; - if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new(("pagename", id)), &name, tr, font, start) { + if let (Some(new), _) = crate::widgets::rename_field(ui, Id::unique(("pagename", id)), &name, tr, font, start) { rename_done = Some((id, new)); } } else if !divider { @@ -391,8 +395,9 @@ fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) { let w = ui.available_width(); let (line, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover()); let grab = ui.style().interaction.resize_grab_radius_side; - let id = Id::new("pages_edge"); - let resp = ui.interact(line.expand2(vec2(0.0, grab)), id, Sense::click_and_drag()); + let id = Id::unique("pages_edge"); + // Not focusable, like the side panels' edges: Tab/F6 must not stop on an unnamed drag handle. + let resp = ui.interact(line.expand2(vec2(0.0, grab)), id, Sense::CLICK | Sense::DRAG); let active = resp.hovered() || resp.dragged(); if active { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); @@ -425,12 +430,12 @@ fn pages_edge(app: &mut FigApp, ui: &mut Ui, shown: f32) { /// A page rename just asked for by a double click: its field takes the focus when drawn (temp data). fn page_rename_start_id() -> Id { - Id::new("page_rename_start") + Id::unique("page_rename_start") } /// The layer row being dragged in the panel (temp data). fn layer_drag_id() -> Id { - Id::new("layer_drag") + Id::unique("layer_drag") } /// A press on a row's eye or padlock dragged across other rows: every row passed gets the state the @@ -443,8 +448,9 @@ struct Sweep { ids: Vec, } +/// Stable egui id for the eye/padlock sweep state. fn sweep_id() -> Id { - Id::new("layer_toggle_sweep") + Id::unique("layer_toggle_sweep") } /// What a drag of row `dragging` moves: the selection when the row is in it, else the row. @@ -489,7 +495,7 @@ fn hash_expanded(expanded: &BTreeSet) -> u64 { pub(crate) fn rows_for(ui: &Ui, doc: &Arc, page: NodeId, expanded: &BTreeSet, filter: &str) -> Arc> { let ex = if filter.is_empty() { hash_expanded(expanded) } else { 0 }; ui.data_mut(|d| { - let c = d.get_temp_mut_or_default::(Id::new("layer_rows")); + let c = d.get_temp_mut_or_default::(Id::unique("layer_rows")); let fresh = c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter; if !fresh { let rows = listed_rows(doc, page, expanded, filter); @@ -653,8 +659,9 @@ fn rename_layer(app: &mut FigApp, id: NodeId) { app.ui.layers_seen_sel = app.session.selection(); } +/// Stable egui id for a layer row's rename field. fn rename_id(id: NodeId) -> Id { - Id::new(("rename", id)) + Id::unique(("rename", id)) } /// Left edge of a row's contents (disclosure triangle) in the row's inner rect `rr`. @@ -704,7 +711,7 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut .frame(egui::Frame::NONE) .font(regular(11.0)) .hint_text(hint) - .vertical_align(egui::Align::Center) + .align(egui::Align2::LEFT_CENTER) .desired_width(te_rect.width()), ); // After the field: the click that opened it ends elsewhere, which would take the focus back. @@ -721,6 +728,8 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut } } +/// Draw the layer tree with search, cached rows, reveal-selection expansion and scrolling, +/// rename/Tab navigation, selection and drag/drop, sweep toggles and Dev Mode restrictions. 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. @@ -732,12 +741,12 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { 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); let sr = Rect::from_center_size(pos2(hr.max.x - 22.0, hr.center().y), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("layer_search_btn"), Sense::click()).tip(tr("Search layers")); + let sresp = ui.interact(sr, ui.scope_id().with("layer_search_btn"), Sense::click()).tip(tr("Search layers")); if sresp.hovered() || app.ui.layer_search { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } icons::paint(ui.painter(), sr.shrink(5.0), Icon::Search, t.icon); - let search_id = Id::new("layer_search_field"); + let search_id = Id::unique("layer_search_field"); if sresp.clicked() { app.ui.layer_search = !app.ui.layer_search; app.ui.layer_filter.clear(); @@ -889,7 +898,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Expand triangle. if row.has_kids { let tri = Rect::from_center_size(pos2(x0 + 6.0, r.center().y), vec2(14.0, 14.0)); - let tresp = ui.interact(tri, ui.id().with(("tri", row.id)), Sense::click()); + let tresp = ui.interact(tri, ui.scope_id().with(("tri", row.id)), Sense::click()); let open = app.ui.expanded.contains(&row.id); a11y::button(&tresp, if open { tr("Collapse") } else { tr("Expand") }); icons::paint(ui.painter(), tri.shrink(1.0), if open { Icon::Chevron } else { Icon::ChevronRight }, t.icon_secondary); @@ -956,7 +965,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { 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()); + let eresp = ui.interact(er, ui.scope_id().with(("eye", row.id)), Sense::click_and_drag()); a11y::toggle(&eresp, tr("Hidden"), hidden); if eresp.clicked() { vis = Some(row.id); @@ -970,7 +979,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { 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()); + let lresp = ui.interact(lr, ui.scope_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); @@ -1025,7 +1034,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside); // Holding over a collapsed group or frame opens it, to drop deeper inside. if row.has_kids && !app.ui.expanded.contains(&row.id) { - let hold_id = Id::new("layer_drag_hold"); + let hold_id = Id::unique("layer_drag_hold"); let now = ui.input(|i| i.time); match ui.data(|d| d.get_temp::<(NodeId, f64)>(hold_id)) { Some((id, since)) if id == row.id => { @@ -1060,7 +1069,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { // Empty space below clears selection. let rest = Rect::from_x_y_ranges(band.x_range(), (top + total)..=(top + total.max(viewport.max.y))); // A click target for the pointer only (no stop for the keyboard). - if rest.height() > 0.0 && ui.interact(rest, Id::new("layers_empty_space"), Sense::CLICK).clicked() { + if rest.height() > 0.0 && ui.interact(rest, Id::unique("layers_empty_space"), Sense::CLICK).clicked() { app.run("select.none", json!({})); } }); @@ -1096,7 +1105,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { if ui.input(|i| i.pointer.any_released()) { ui.data_mut(|d| { d.remove::(drag_id); - d.remove::<(NodeId, f64)>(Id::new("layer_drag_hold")); + d.remove::<(NodeId, f64)>(Id::unique("layer_drag_hold")); }); // A drag started in the design can end after a switch to Dev Mode. if let (Some(_), Some((target, zone))) = (dragging, drop) diff --git a/crates/ui-egui/src/layers_behaviour_tests.rs b/crates/ui-egui/src/layers_behaviour_tests.rs index 4d987e66..0f312499 100644 --- a/crates/ui-egui/src/layers_behaviour_tests.rs +++ b/crates/ui-egui/src/layers_behaviour_tests.rs @@ -312,6 +312,7 @@ fn pages_down_to(u: &mut Ui, to: f32) -> Vec { opened } +/// Asserts a 60-page list scrolls with the wheel to its last page, past its first pages. #[test] fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { let mut s = Session::new(); @@ -330,6 +331,7 @@ fn a_long_pages_list_scrolls_and_leaves_room_for_the_layers() { // The wheel over the pages list scrolls it to its last page. let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -400.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -382,6 +384,8 @@ fn double_click_checking_focus(u: &mut Ui, p: Pos2) { } } +/// Asserts double-clicking a page selects and renames it, with the keyboard in its name +/// field for the screen-reader tree. #[test] fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() { let mut s = Session::new(); @@ -394,10 +398,10 @@ fn double_clicking_a_page_renames_it_with_the_focus_in_the_screen_reader_tree() double_click_checking_focus(&mut u, r2); assert_eq!(u.app.session.page().unwrap(), p2); assert_eq!(u.app.ui.rename_page.as_ref().map(|r| r.0), Some(p2), "a double click renames the page"); - assert!(u.ctx.memory(|m| m.has_focus(Id::new(("pagename", p2)).with("te"))), "with the keyboard in its name"); + assert!(u.ctx.memory(|m| m.has_focus(Id::unique(("pagename", p2)).with("te"))), "with the keyboard in its name"); // The page that's open. u.app.ui.rename_page = None; - u.ctx.memory_mut(|m| m.surrender_focus(Id::new(("pagename", p2)).with("te"))); + u.ctx.memory_mut(|m| m.surrender_focus(Id::unique(("pagename", p2)).with("te"))); u.app.session.execute("page.select", &json!({"id": p1})).unwrap(); for _ in 0..30 { u.frame(vec![]); diff --git a/crates/ui-egui/src/layers_drag_tests.rs b/crates/ui-egui/src/layers_drag_tests.rs index fb74e155..978a3b23 100644 --- a/crates/ui-egui/src/layers_drag_tests.rs +++ b/crates/ui-egui/src/layers_drag_tests.rs @@ -43,6 +43,7 @@ impl Ui { /// screen and clock. pub(crate) fn frame_raw(&mut self, raw: RawInput) -> egui::FullOutput { self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), ..raw }; let app = &mut self.app; let mut out = self.ctx.run_ui(input, |ui| app.ui(ui)); @@ -50,6 +51,32 @@ impl Ui { out } + /// One frame that egui reruns as a discarded pass (the returned output is the presented pass). + /// + /// The rerun sees the same `RawInput`; the custom text editor must not apply its events twice, + /// and the IME descriptor of the presented pass must still describe the first pass's result. + pub(crate) fn frame_raw_with_discard(&mut self, raw: RawInput) -> egui::FullOutput { + self.time += 1.0 / 60.0; + self.app.clock.set_test_now(Some(self.time)); + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), ..raw }; + let Ui { ctx, app, .. } = self; + let mut out = ctx.run_ui(input, |ui| { + app.ui(ui); + if ui.ctx().current_pass_index() == 0 { + ui.ctx().request_discard("ime test: exercise a discarded pass"); + } + }); + out.textures_delta.clear(); + out + } + + /// Move the harness time (and the app's monotonic clock) forward, e.g. past a deadline; the + /// next frame continues from here. + pub(crate) fn advance_clock(&mut self, secs: f64) { + self.time = secs; + self.app.clock.set_test_now(Some(secs)); + } + /// The middle of `id`'s row in the layers panel (found by hovering down the panel). pub(crate) fn row_at(&mut self, id: NodeId) -> Pos2 { let mut hits = Vec::new(); diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index c4b9211c..c1c229eb 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -144,6 +144,8 @@ fn rename_without_a_name_renames_one_row_or_opens_the_dialog() { assert!(std::sync::Arc::ptr_eq(&doc, &s.ds().unwrap().doc)); } +/// Asserts a layer rename continues after its row is scrolled out of view and commits on +/// Enter. #[test] fn a_rename_scrolled_out_of_view_goes_on() { let mut s = Session::new(); @@ -158,6 +160,7 @@ fn a_rename_scrolled_out_of_view_goes_on() { let p = egui::pos2(150.0, 400.0); let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -400.0), phase: egui::TouchPhase::Move, modifiers: Modifiers::NONE, diff --git a/crates/ui-egui/src/layout_panel.rs b/crates/ui-egui/src/layout_panel.rs index b5ff1d45..0874da2f 100644 --- a/crates/ui-egui/src/layout_panel.rs +++ b/crates/ui-egui/src/layout_panel.rs @@ -156,9 +156,9 @@ fn dimensions(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], has let col = widgets::col_width(ui.available_width()); widgets::sublabels(ui, tr("Dimensions"), None); row(ui, 32.0, |ui| { - let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "width", Id::new("lay_w"), Lead::Text("W"), w, "", col); + let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "width", Id::unique("lay_w"), Lead::Text("W"), w, "", col); app.field_of(&ids, "width", e, w_of, |v| json!(v.max(0.0))); - let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "height", Id::new("lay_h"), Lead::Text("H"), h, "", col); + let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "height", Id::unique("lay_h"), Lead::Text("H"), h, "", col); app.field_of(&ids, "height", e, h_of, |v| json!(v.max(0.0))); let locked = common(doc, sel, |n| n.constrain_proportions).unwrap_or(false); let link = ui @@ -259,7 +259,7 @@ fn limit_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], hori let (lead, add) = if is_min { (tr(axis.min_lead), tr(axis.add_min)) } else { (tr(axis.max_lead), tr(axis.add_max)) }; if has(is_min) { let v = common(doc, sel, |n| limit(n, horizontal, is_min).map(|v| (v * 100.0).round() / 100.0)).flatten(); - let e = crate::variable_ui::number_field(app, ui, &ids, key, Id::new(("lay_limit", key)), Lead::Text(lead), v, "", col); + let e = crate::variable_ui::number_field(app, ui, &ids, key, Id::unique(("lay_limit", key)), Lead::Text(lead), v, "", col); app.field(&ids, key, e, |v| json!(v.max(0.0))); } else if add_slot(ui, lead, add, col).clicked() { app.set_props(&ids, json!({key: current})); @@ -283,6 +283,8 @@ fn add_slot(ui: &mut Ui, label: &str, tip: &str, width: f32) -> egui::Response { resp } +/// Draw the 3×3 main/cross alignment grid, auto-gap modes, grid column/row gaps and the +/// horizontal baseline checkbox. fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let t = Tokens::get(ui.ctx()); let grid_mode = l.mode == LayoutMode::Grid; @@ -309,7 +311,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { for xi in 0..3 { let c = pos2(grid.min.x + 12.0 + xi as f32 * 24.0, grid.min.y + 10.0 + yi as f32 * 22.0); let r = Rect::from_center_size(c, vec2(22.0, 20.0)); - let resp = ui.interact(r, Id::new(("al_grid", xi, yi)), Sense::click()); + let resp = ui.interact(r, Id::unique(("al_grid", xi, yi)), Sense::click()); let (main_i, cross_i) = if horizontal { (xi, yi) } else { (yi, xi) }; let on = (cur_main == main_i || cur_main == 9) && (cur_cross == cross_i || cur_cross == 9); if on { @@ -338,7 +340,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "columnGap", - Id::new("al_gap"), + Id::unique("al_gap"), Lead::Icon(Icon::Gap, tr("Column gap")), Some(l.gap), "", @@ -351,7 +353,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "rowGap", - Id::new("al_rowgap"), + Id::unique("al_rowgap"), Lead::Icon(Icon::DistributeV, tr("Row gap")), Some(l.cross_gap), "", @@ -363,7 +365,7 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let auto = l.main_align.is_auto_gap(); let gap_icon = if l.mode == LayoutMode::Vertical { Icon::DistributeV } else { Icon::Gap }; let (shown, lead) = ((!auto).then_some(l.gap), Lead::Icon(gap_icon, tr("Gap between items"))); - let e = crate::variable_ui::number_field_or(app, ui, ids, "gap", Id::new("al_gap"), shown, 96.0, tr("Auto"), lead, ""); + let e = crate::variable_ui::number_field_or(app, ui, ids, "gap", Id::unique("al_gap"), shown, 96.0, tr("Auto"), lead, ""); app.field(ids, "gap", e, |v| json!(v)); ui.add_space(8.0); let label = match l.main_align { @@ -406,10 +408,10 @@ fn alignment(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { let col = widgets::col_width(ui.available_width()); let [pt, pr, pb, pl] = l.padding; - let open = ui.data(|d| d.get_temp::(Id::new("indep_padding"))).unwrap_or(false); + let open = ui.data(|d| d.get_temp::(Id::unique("indep_padding"))).unwrap_or(false); let toggle = |ui: &mut Ui| { if icons::button(ui, Icon::IndependentPadding, 24.0, open, tr("Individual padding")).clicked() { - ui.data_mut(|d| d.insert_temp(Id::new("indep_padding"), !open)); + ui.data_mut(|d| d.insert_temp(Id::unique("indep_padding"), !open)); } }; if open { @@ -424,7 +426,8 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { for (r, pair) in sides.chunks(2).enumerate() { row(ui, 32.0, |ui| { for (key, name, icon, v, k) in pair { - let e = crate::variable_ui::number_field(app, ui, ids, key, Id::new(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col); + let e = + crate::variable_ui::number_field(app, ui, ids, key, Id::unique(("padding", *k)), Lead::Icon(*icon, name), Some(*v), "", col); app.field(ids, key, e, |v| json!(v.max(0.0))); } if r == 0 { @@ -439,7 +442,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "paddingHorizontal", - Id::new("al_padh"), + Id::unique("al_padh"), Lead::Icon(Icon::PaddingH, tr("Horizontal padding")), (pl == pr).then_some(pl), "", @@ -451,7 +454,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "paddingVertical", - Id::new("al_padv"), + Id::unique("al_padv"), Lead::Icon(Icon::PaddingV, tr("Vertical padding")), (pt == pb).then_some(pt), "", @@ -475,7 +478,7 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { ui, ids, "crossGap", - Id::new("al_crossgap"), + Id::unique("al_crossgap"), shown, col, tr("Auto"), @@ -500,9 +503,9 @@ fn spacing(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], l: &AutoLayout) { if l.mode == LayoutMode::Grid { widgets::sublabels(ui, tr("Columns"), Some(tr("Rows"))); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("al_cols"), Lead::None, Some(f64::from(l.grid_columns)), "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_cols"), Lead::None, Some(f64::from(l.grid_columns)), "", col, 1.0); app.field(ids, "gridColumns", e, |v| json!(v.max(1.0))); - let e = widgets::number_field(ui, Id::new("al_rows"), Lead::None, Some(f64::from(l.grid_rows)), "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_rows"), Lead::None, Some(f64::from(l.grid_rows)), "", col, 1.0); app.field(ids, "gridRows", e, |v| json!(v.max(1.0))); }); } @@ -571,7 +574,7 @@ fn tracks(app: &mut FigApp, ui: &mut Ui, doc: &Document, ids: &[NodeId]) { let (value, suffix) = if track.sizing == Sizing::Fill { (track.value, "fr") } else { (size, "") }; let e = widgets::number_field( ui, - Id::new(("al_track", axis, i)), + Id::unique(("al_track", axis, i)), Lead::Text(&label), Some(value), suffix, @@ -614,9 +617,9 @@ fn grid_cell(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { // Moving on one axis pins the other where it is, so the child doesn't jump. let at = |c: f64, r: f64| json!({"ids": [id], "props": {"gridColumn": (c - 1.0).max(0.0), "gridRow": (r - 1.0).max(0.0)}}); let (c0, r0) = ((cell.column + 1) as f64, (cell.row + 1) as f64); - let e = widgets::number_field(ui, Id::new("al_cell_col"), Lead::None, Some(c0), "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_cell_col"), Lead::None, Some(c0), "", col, 1.0); app.field_run(e, "node.set", |v| at(v, r0)); - let e = widgets::number_field(ui, Id::new("al_cell_row"), Lead::None, Some(r0), "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_cell_row"), Lead::None, Some(r0), "", col, 1.0); app.field_run(e, "node.set", |v| at(c0, v)); if anchored && icons::button(ui, Icon::Reset, 24.0, false, tr("Place automatically")).clicked() { app.set_props(&ids, json!({"gridColumn": null, "gridRow": null})); @@ -627,9 +630,9 @@ fn grid_cell(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let rs = common(doc, sel, |n| f64::from(n.child_layout.row_span())); widgets::sublabels(ui, tr("Column span"), Some(tr("Row span"))); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("al_span_c"), Lead::None, cs, "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_span_c"), Lead::None, cs, "", col, 1.0); app.field(&ids, "gridColumnSpan", e, |v| json!(v.max(1.0))); - let e = widgets::number_field(ui, Id::new("al_span_r"), Lead::None, rs, "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("al_span_r"), Lead::None, rs, "", col, 1.0); app.field(&ids, "gridRowSpan", e, |v| json!(v.max(1.0))); }); // Alignment in the cell; Auto follows the frame's alignment. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 23666252..eff9fa4c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -90,6 +90,8 @@ mod canvas_fixes_tests; #[cfg(test)] mod canvas_image_rect_tests; #[cfg(test)] +mod clipboard_fence_tests; +#[cfg(test)] mod colorpicker_eyedropper_tests; #[cfg(test)] mod colorpicker_selection_tests; @@ -254,7 +256,7 @@ use figcraft_doc::NodeId; use figcraft_engine::Session; use serde_json::{Value, json}; -pub use control::{ControlReply, ControlRequest}; +pub use control::{ControlReply, ControlRequest, RequestGate, RequestState}; /// Platform services the app crate provides (file dialogs, clipboard, URLs). pub trait Services { @@ -477,6 +479,35 @@ pub(crate) fn repaint_in(ctx: &egui::Context, secs: f64) { } } +/// A monotonic clock for background work (autosave, recovery, capture deadlines). +/// +/// `Context::input(|i| i.time)` is the UI pass's time and is stale in `run_logic`; wall-clock time +/// can jump. `web_time::Instant` is monotonic on native and in the browser. Tests drive deadlines +/// directly with [`FrameClock::set_test_now`]. +pub struct FrameClock { + origin: web_time::Instant, + test_now: Option, +} + +impl Default for FrameClock { + /// Start the frame clock at the current web time with no injected test time. + fn default() -> Self { + Self { origin: web_time::Instant::now(), test_now: None } + } +} + +impl FrameClock { + /// Seconds since the app started (or the injected test time). + pub fn now(&self) -> f64 { + self.test_now.unwrap_or_else(|| self.origin.elapsed().as_secs_f64()) + } + + /// Set a fake monotonic time (tests); `None` returns to the real clock. + pub fn set_test_now(&mut self, secs: Option) { + self.test_now = secs; + } +} + /// The side panels' widths: the least and most a drag of their edge gives, what a double-click on /// the edge puts back, and how much of the window they leave the canvas. pub const LEFT_PANEL: std::ops::RangeInclusive = 200.0..=480.0; @@ -645,7 +676,13 @@ pub struct UiState { /// What FigCraft last put on the system clipboard: when the clipboard still holds it, a paste /// uses our copied layers; anything else there (an image, SVG, text) came from another app. + /// `None` also means a programmatic copy changed the engine clipboard without writing the OS + /// clipboard, so the system text is no longer known to be ours. pub last_copy: Option, + /// The engine clipboard's layers generation when `last_copy` was mirrored. A programmatic + /// copy changes the engine clipboard without writing the OS clipboard; the generation then + /// no longer matches, so the stale mirror is not mistaken for the user's own copy. + pub last_copy_generation: u64, /// Since the last V key release: whether its press arrived, and whether a paste event did /// (`shortcuts::swallowed_paste`). pub v_pressed: bool, @@ -653,14 +690,14 @@ pub struct UiState { /// 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>, + /// commits what was typed) and the command runs next frame for that document/window. + pub after_field: Option, /// The modifiers held, as of the last modifier change in the raw events: a paste event doesn't /// carry them. pub held: egui::Modifiers, - /// When (UI clock, seconds) open documents next get an autosave checkpoint. + /// When (monotonic [`FrameClock`], seconds) open documents next get an autosave checkpoint. pub next_autosave: f64, - /// With auto-save on: when (UI clock, seconds) files with unsaved changes are next saved, and + /// With auto-save on: when (monotonic [`FrameClock`], seconds) files with unsaved changes are next saved, and /// the revisions of those files it waits on (a change restarts the wait). pub auto_save_at: Option<(f64, u64)>, /// Files being encoded for auto-save on threads of their own (tab `uid`). @@ -690,6 +727,9 @@ pub struct UiState { } impl Default for UiState { + /// Seed the default UI state (panel widths, tabs, pages, Home, presenters, pickers and + /// dialogs off, and the rest of the transient UI fields); `FigApp::new` overrides panel + /// widths and the pages height from preferences. fn default() -> Self { Self { left_width: PANEL_DEFAULT, @@ -778,6 +818,7 @@ impl Default for UiState { dev: dev_mode::DevUi::default(), last_copy: None, + last_copy_generation: 0, v_pressed: false, paste_event: false, refocused: false, @@ -800,6 +841,14 @@ impl Default for UiState { } } +/// A control reply waiting for the pass that presents an injected input batch. The viewport keeps +/// one window's batch from acknowledging (or erroring) another window's request. +pub(crate) struct PendingAck { + pub(crate) viewport: egui::ViewportId, + pub(crate) tx: std::sync::mpsc::Sender, + pub(crate) reply: ControlReply, +} + /// The whole FigCraft UI. pub struct FigApp { pub session: Session, @@ -838,12 +887,46 @@ pub struct FigApp { /// The automation roots the app was started with: every frame and control request runs /// confined to them ([`windows::guard`]). pub automation_roots: Option>, + /// The active-document id when synthetic input was queued: a batch is dropped when the file + /// changed before a UI pass applied it. + pub(crate) control_input_doc: Option, + /// This frame applies events injected through the control channel: no system clipboard + /// read or write may happen for them. + pub(crate) control_synthetic: bool, + /// `ui.input` replies waiting for the pass that actually presents the injected batch (a + /// discarded sizing pass must not acknowledge it). + pub(crate) input_acks: Vec, + /// Replies whose batch this frame took into its raw input: sent only if the pass is presented. + pub(crate) applied_acks: Vec, + /// The batch taken into this frame's raw input, kept so a discarded pass gives it back. + pub(crate) applied_input: Option>, + /// `handle("ui.input")` queued raw events whose acknowledgement waits for the applied pass + /// (controller-only input is applied at once and needs no fence). + pub(crate) control_input_queued: bool, + /// The viewport whose batch this frame took into its raw input (`applied_input`/`applied_acks`). + pub(crate) applied_frame: Option, + /// Monotonic time for background work and capture deadlines. + pub(crate) clock: FrameClock, + /// Screenshot requests admitted, settling and awaiting readback. + pub(crate) capture: control::CaptureState, + /// [`Self::background_tick`] ran for this pass (so [`Self::ui`] does not repeat it). + logic_ticked: bool, + /// The last frame whose UI setup ran (multi-pass frames set up once). + ui_frame_stamp: Option<(egui::ViewportId, u64)>, + /// IME owner/generation/change-cause state for the on-canvas text editor, per viewport. + pub(crate) ime: egui::ime_context::ImeContextTracker, + /// Test-only count of the one IME observation each viewport pass makes. + #[cfg(test)] + pub(crate) ime_observations: u32, } /// What to do with the result of a command run in the background. pub type Done = Box; impl FigApp { + /// Construct `FigApp` from a session and services, taking panel widths and pages height + /// from preferences and initializing UI, canvas, clocks, capture/IME and transient channel + /// state. pub fn new(session: Session, services: Box) -> Self { // The main window's panels open as wide as they were left. let (left_width, right_width) = session.prefs.panel_widths; @@ -869,6 +952,20 @@ impl FigApp { pending_dialog: None, ui_context: None, automation_roots: None, + control_input_doc: None, + control_synthetic: false, + input_acks: Vec::new(), + applied_acks: Vec::new(), + applied_input: None, + control_input_queued: false, + applied_frame: None, + clock: FrameClock::default(), + capture: control::CaptureState::default(), + logic_ticked: false, + ui_frame_stamp: None, + ime: egui::ime_context::ImeContextTracker::new(), + #[cfg(test)] + ime_observations: 0, } } @@ -881,12 +978,34 @@ impl FigApp { /// Run a command, surfacing errors as a toast. Returns the result. pub fn run(&mut self, id: &str, params: Value) -> Option { + let layers_generation = self.session.clipboard.layers_generation; let r = self.session.execute(id, ¶ms); self.clear_comment_draft_after_tool_set(id, r.is_ok()); self.handle_ui_requests(); + self.invalidate_synthetic_copy(layers_generation); + // A command may change the file the window shows (`doc.switch`, `doc.nextTab`, + // `file.open`/`file.close`). Record that presentation before returning, so an in-flight + // screenshot admitted on the old file cannot accept pixels from the new one. + self.observe_presentation(); r.map_err(|e| self.failed(id, &e)).ok() } + /// A programmatic command that changed the engine clipboard never wrote the system clipboard: + /// forget the old system mirror so a later user paste does not trust it as our own copy. + /// `before` is the layers generation captured before the command ran. + pub(crate) fn invalidate_synthetic_copy(&mut self, before: u64) { + if self.control_synthetic && self.session.clipboard.layers_generation != before { + self.ui.last_copy = None; + } + } + + /// Record the file the window drawing now shows, with a monotonic generation that changes on + /// every switch (an A→B→A switch is two generations, never the admitted one again). + pub(crate) fn observe_presentation(&mut self) { + let window = self.windows.current; + self.windows.presentation(&self.session, window); + } + /// [`run`](Self::run) for something the user did (a dialog, a drop, a paste, a recent file): /// a file it imports shows its import report. Programmatic calls use `run` and never open one. pub fn run_user(&mut self, id: &str, params: Value) -> Option { @@ -1009,6 +1128,83 @@ impl FigApp { } } + /// Finish the frame that applies an injected batch: acknowledge it once the pass is presented, + /// and stop fencing the system clipboard. Only the viewport whose batch this frame took acts. + pub(crate) fn end_input_frame(&mut self, ctx: &egui::Context) { + if self.applied_frame != Some(ctx.viewport_id()) { + return; + } + self.applied_input = None; + if ctx.will_discard() { + // Keep `applied_frame`/`applied_acks`: the rerun pass must present (and acknowledge) + // the same frame without replaying the input. + return; + } + self.applied_frame = None; + for ack in self.applied_acks.drain(..) { + let _ = ack.tx.send(ack.reply); + } + self.control_synthetic = false; + } + + /// The app's monotonic clock in seconds, also usable from logic-only callbacks (recovery, + /// control deadlines). Never `ctx.input(i.time)`, which is stale there. + pub fn now_secs(&self) -> f64 { + self.clock.now() + } + + /// Queue raw input injected through the control channel for the window drawing next, tagged + /// with the document it belongs to (a batch is dropped if another file is active by then). + pub(crate) fn push_control_input(&mut self, events: Vec) -> bool { + if events.is_empty() { + return false; + } + let doc = self.session.docs.get(self.session.active).map(|d| d.uid); + if !self.control_input.is_empty() && self.control_input_doc != doc { + // A queued batch belongs to the file that was open when it was injected; it cannot be + // merged with one for the file showing now. Its acknowledgement says so. + self.control_input.clear(); + let viewport = crate::windows::viewport(self.windows.current); + crate::windows::fail_input_acks(self, viewport); + } + // Always the live pane vector: while a child window draws, `windows::enter` has swapped + // its pane into the app, and `leave` swaps these events back into that pane. + self.control_input.extend(events); + self.control_input_doc = doc; + self.control_synthetic = true; + true + } + + /// The acknowledgements waiting for `viewport`'s input batch (other windows keep theirs). + pub(crate) fn take_input_acks(&mut self, viewport: egui::ViewportId) -> Vec { + let mut mine = Vec::new(); + for ack in std::mem::take(&mut self.input_acks) { + if ack.viewport == viewport { + mine.push(ack); + } else { + self.input_acks.push(ack); + } + } + mine + } + + /// Put `text` on the system clipboard. Input injected through the control channel never + /// replaces what the user copied: this is the one write path every copy command goes through. + pub(crate) fn clipboard_set_text(&mut self, text: &str) { + if self.control_synthetic { + return; + } + self.services.clipboard_set_text(text); + } + + /// [`Self::clipboard_set_text`] for an image (straight RGBA). + pub(crate) fn clipboard_set_png(&mut self, width: u32, height: u32, rgba: &[u8]) { + if self.control_synthetic { + return; + } + self.services.clipboard_set_png(width, height, rgba); + } + pub fn toast(&mut self, msg: impl Into) { self.ui.toast = Some((msg.into(), ToastClock::Raised)); } @@ -1174,8 +1370,10 @@ impl FigApp { fn panel_edge(ui: &mut egui::Ui, name: &str, x: f32, rows: egui::Rangef, width: f32, grows: f32) -> Option { let grab = ui.style().interaction.resize_grab_radius_side; let rect = egui::Rect::from_x_y_ranges(x - grab..=x + grab, rows); - let id = egui::Id::new(("panel_edge", name)); - let resp = ui.interact(rect, id, egui::Sense::click_and_drag()); + let id = egui::Id::unique(("panel_edge", name)); + // Not focusable: an edge is a pointer target, so it must not take Tab/F6 focus from the + // panels' controls (see `a11y::focusables`). + let resp = ui.interact(rect, id, egui::Sense::CLICK | egui::Sense::DRAG); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); ui.painter().vline(x, rows, egui::Stroke::new(2.0, theme::ACCENT)); @@ -1417,18 +1615,55 @@ impl FigApp { need } - /// Per-frame logic that doesn't draw (control requests, theme, language). - pub fn logic(&mut self, ctx: &egui::Context) { + /// Work safe from a logic-only callback (`App::logic`, which also runs while hidden) and from + /// a normal UI pass: consumed control work, live polling, background completions, autosave and + /// recovery deadlines. It never reads UI input, so it must not scan events, run shortcuts or + /// lay out anything. + pub fn background_tick(&mut self, ctx: &egui::Context) { + control::background(self, ctx); + // What happened in a live session (the network woke us for it). + if self.session.live_poll() { + if let Some(m) = self.session.message.take() { + self.toast(live::failure_text(&m)); + } + self.background_done(ctx); + } + // Files that arrived without a command (joined, recovered) take the kept View toggles too. + self.session.apply_view_prefs(); + let now = self.clock.now(); + self.autosave_tick(ctx, now); + self.auto_save_tick(ctx, now); + self.logic_ticked = true; + } + + /// UI-phase setup that needs a real current `Ui`: fonts/language/theme, interface zoom and the + /// render worker. Runs once per frame (pass 0) for the main window, and once per frame for + /// every child window (`windows::draw`), never on a logic-only tick. + fn ui_setup(&mut self, ctx: &egui::Context) { + self.viewport_setup(ctx); + } + + /// The app-global part of [`Self::ui_setup`] that every window's frame needs: language, fonts, + /// theme and `ui.dark`, the render worker, and the interface zoom plan. eframe only calls + /// `App::ui` for the root window, so a child must run this itself before it draws; a child that + /// only runs while the root is minimised still gets theme switches, CJK faces for its own + /// document, and the right `ui.dark` for its tabs and dialogs. + pub(crate) fn viewport_setup(&mut self, ctx: &egui::Context) { if self.language.as_ref() != Some(&self.session.prefs.language) { i18n::set(&self.session.prefs.language, self.services.system_language().as_deref()); self.language = Some(self.session.prefs.language.clone()); } + // Screen-reader and bidi language of the UI (fork E17). + ctx.set_language(i18n::current().map(|l| l.code.to_string())); if !self.fonts_installed { // Cmd/Ctrl+= / - / 0 zoom the canvas (view.zoomIn, view.zoomOut, view.zoom100): egui's // own keyboard zoom would scale the whole interface on the same keys. ctx.options_mut(|o| o.zoom_with_keyboard = false); theme::install_fonts(ctx, false, 0); self.fonts_installed = true; + // Installed fonts apply from the next frame; a child window draws on here, so ask for + // that frame (the root draws nothing on the frame it installs them). + ctx.request_repaint(); } else if figcraft_text::system_fonts_scanned() { // Once the system fonts are indexed: faces for names in scripts the UI fonts lack, and // for each CJK script once something shows it. @@ -1443,12 +1678,15 @@ impl FigApp { } // egui's zoom is for the whole app and multiplies each window's own system scale, so the // size is the same relative to every screen; the main window, whose size can hold a size - // back, sets it. The browser keeps its own zoom. - if ctx.viewport_id() == egui::ViewportId::ROOT { + // back, sets it. The browser keeps its own zoom. Every window records its own plan (the + // Preferences warning and `ui.inspect` read it), from its own size. + { let window_px = ctx.content_rect().size() * ctx.pixels_per_point(); let size = if cfg!(target_arch = "wasm32") { 1.0 } else { self.session.prefs.ui_scale }; let plan = ui_scale::plan(size, ctx.native_pixels_per_point(), Some((window_px.x, window_px.y))); - ctx.set_zoom_factor(plan.zoom); + if ctx.viewport_id() == egui::ViewportId::ROOT { + ctx.set_zoom_factor(plan.zoom); + } self.ui.ui_scale = plan; } let dark = match self.session.prefs.theme.as_str() { @@ -1469,8 +1707,7 @@ impl FigApp { /// Every [`AUTOSAVE_SECS`], each open document with unsaved changes since its last version /// gets an autosave checkpoint in its version history. - fn autosave_tick(&mut self, ctx: &egui::Context) { - let now = ctx.input(|i| i.time); + fn autosave_tick(&mut self, ctx: &egui::Context, now: f64) { if now < self.ui.next_autosave { // Wake up for it even if nothing else draws a frame by then. repaint_in(ctx, self.ui.next_autosave - now); @@ -1491,8 +1728,7 @@ impl FigApp { /// With auto-save on, files that have a path are saved [`AUTO_SAVE_SECS`] after they last /// changed (not during a gesture, a live preview or while a version is shown). The file is /// encoded on a thread of its own, so a big one doesn't hold up the window, and written here. - fn auto_save_tick(&mut self, ctx: &egui::Context) { - let now = ctx.input(|i| i.time); + fn auto_save_tick(&mut self, ctx: &egui::Context, now: f64) { let mut failed = self.auto_save_done(); // The browser has no files to save to. let on = !cfg!(target_arch = "wasm32") && self.session.prefs.auto_save; @@ -1567,25 +1803,36 @@ impl FigApp { let t0 = std::time::Instant::now(); let ctx = ui.ctx().clone(); self.ui_context = Some(ctx.clone()); - self.frame_stats.record(&ctx); + // `App::logic` already ticked this pass; direct harnesses do not call it. The marker only + // covers the tick that shares this pass: clearing it here keeps a direct `App::ui` harness + // ticking on every frame (it used to tick every other frame). + if !std::mem::take(&mut self.logic_ticked) { + self.background_tick(&ctx); + self.logic_ticked = false; + } + // Setup once per frame (pass 0), not once per pass and never on a logic-only tick. + let stamp = (ctx.viewport_id(), ctx.cumulative_frame_nr()); let first = !self.fonts_installed; - self.logic(&ctx); - self.autosave_tick(&ctx); - self.auto_save_tick(&ctx); - if first { - // New fonts apply from the next frame; draw nothing until then. + if ctx.current_pass_index() == 0 && self.ui_frame_stamp != Some(stamp) { + self.ui_frame_stamp = Some(stamp); + self.frame_stats.record(&ctx); + self.ui_setup(&ctx); + } + if first && self.fonts_installed { + // New fonts apply from the next frame; draw nothing until then. Anything taken into + // this frame's (undrawn) input waits for the first drawn frame. ctx.request_repaint(); + if let Some(events) = self.applied_input.take() { + self.push_control_input(events); + } + self.input_acks.append(&mut self.applied_acks); + self.applied_frame = None; + self.control_synthetic = false; + // The next (drawn) frame ticks for itself in a direct harness. + self.logic_ticked = false; + self.observe_ime(&ctx, false); return; } - // What happened in a live session (the network woke us for it). - if self.session.live_poll() { - if let Some(m) = self.session.message.take() { - self.toast(live::failure_text(&m)); - } - self.background_done(&ctx); - } - // Files that arrived without a command (joined, recovered) take the kept View toggles too. - self.session.apply_view_prefs(); windows::begin(self, &ctx); let (files, prefs) = (self.session.docs.len(), self.session.prefs.clone()); self.frame(ui); @@ -1594,6 +1841,10 @@ impl FigApp { windows::repaint_others(self, &ctx); } windows::show(self, &ctx); + // The injected batch was applied by the frame that took it. A discarded sizing pass keeps + // its acknowledgement for the presented pass but never replays the input (its side effects + // already happened and would otherwise duplicate). + self.end_input_frame(&ctx); // Plugins installed, removed or set up are kept at once: a crash doesn't lose them. // So are the owner tokens of files put online. if std::mem::take(&mut self.session.plugins.unsaved) | std::mem::take(&mut self.session.live.unsaved) { @@ -1618,11 +1869,17 @@ impl FigApp { /// One frame of the window drawing now. pub(crate) fn frame(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); + // Bind this input batch before controls or pointer input can replace its composing + // target/caret. Outgoing IME receipts run after mutation and cannot authorize it. + let ime_input = shortcuts::ImeInputBaseline::capture(self); let t = theme::Tokens::get(&ctx); // A field that had the keyboard when the frame began keeps its keys from the shortcuts // even if it lets go during the frame (Enter commits it), or as it began (egui drops the // focus on Escape before the frame, which cancels the field's edit and nothing else). let typing = std::mem::take(&mut self.field_focus) || a11y::field_focused(&ctx); + // Bind the originating field/file before tabs can switch. Its widget commits only after + // processing this batch; delivery remains deferred until the following frame. + let field_save = shortcuts::prepare_field_save(self, &ctx); let swallowed = shortcuts::swallowed_paste(self, &ctx); // A dialog that closed acts first, in the window it was opened from. self.poll_dialog(&ctx); @@ -1631,7 +1888,7 @@ impl FigApp { windows::requests(self, &ctx); selection_spacing::sync(self, &ctx); // Before any widget: the navigation keys (F6, Tab, arrows, Escape) move the keyboard. - a11y::begin(self, &ctx); + a11y::begin(self, &ctx, ui.scope_id()); if self.ui.title_bar == TitleBar::Custom { tabs::resize_edges(&ctx); } @@ -1644,6 +1901,9 @@ impl FigApp { self.end_pen_outside_design(); tabs::relink_presents(self); tabs::tab_bar(self, ui); + // Control/window work and this pass's physical tab click may have replaced the owner. + // A shared field ID in that replacement must never consume the old commit marker. + let field_save = shortcuts::revalidate_field_save(self, &ctx, field_save); if self.ui.present.is_some() { // A close request can arrive while the presenter fills the window. Let its unsaved // changes dialog take Escape and draw above the player just as it does in the editor. @@ -1653,6 +1913,8 @@ impl FigApp { // What went wrong while presenting (a widget's plugin failing) shows over it. self.toast_ui(&ctx); windows::requests(self, &ctx); + shortcuts::clear_field_save_marker(&ctx); + self.observe_ime(&ctx, false); return; } if !self.has_files() || self.ui.home { @@ -1664,6 +1926,8 @@ impl FigApp { dialogs::show(self, &ctx); self.toast_ui(&ctx); windows::requests(self, &ctx); + shortcuts::clear_field_save_marker(&ctx); + self.observe_ime(&ctx, false); return; } let hidden = self.session.ds().map(|d| d.view.ui_hidden).unwrap_or(false); @@ -1748,7 +2012,7 @@ impl FigApp { branch_ui::keys(self, &ctx); live::dialog_keys(self, &ctx); dialogs::keys(self, &ctx); - shortcuts::handle(self, &ctx, typing, swallowed); + shortcuts::handle_input(self, &ctx, typing, swallowed, ime_input, field_save); menus::popups(self, &ctx); variable_ui::picker(self, &ctx); style_picker::show(self, &ctx); @@ -1768,8 +2032,33 @@ impl FigApp { // changed), from the window with its file. let windows = &self.windows; self.session.live_sync_in(|uid| uid.map_or(0, |u| windows.of(u)) == windows.current); + // The canvas editor's IME context is observed exactly once per pass, after every command + // this pass ran (shortcuts, menus, dialogs, window requests and live sync). A focused panel + // field or an open palette owns the platform IME instead; the early `home`/present paths + // above make the same single idle observation. + self.observe_ime(&ctx, !typing && !self.field_focus && self.ui.palette.is_none()); } + /// The pass's one IME observation: the focused on-canvas editor, or an explicit idle. + /// + /// Calling `ImeContextTracker::observe` more than once in a pass would treat an idle followed + /// by a focused observation as a context change (dropping the frame's receipts) and bump the + /// generation. Keeping this in one place makes the fork contract structural. + fn observe_ime(&mut self, ctx: &egui::Context, focused: bool) { + #[cfg(test)] + { + self.ime_observations = self.ime_observations.saturating_add(1); + } + if focused { + shortcuts::ime_output(self, ctx); + } else { + let _ = self.ime.observe(ctx.viewport_id(), None); + } + } + + /// Draw the toast: start its lifetime on the frame **after** the first drawn frame, request + /// that frame and the frame that hides it, expire and remove it at the deadline, and place it + /// at the bottom centre. fn toast_ui(&mut self, ctx: &egui::Context) { let Some((msg, clock)) = &mut self.ui.toast else { return }; let now = ctx.input(|i| i.time); @@ -1798,7 +2087,7 @@ impl FigApp { repaint_in(ctx, left); } let screen = ctx.content_rect(); - egui::Area::new(egui::Id::new("toast")) + egui::Area::new(egui::Id::unique("toast")) .fixed_pos(egui::pos2(screen.center().x, screen.max.y - 96.0)) .pivot(egui::Align2::CENTER_BOTTOM) .order(egui::Order::Tooltip) @@ -1928,7 +2217,7 @@ pub(crate) fn drop_overlay(ctx: &egui::Context, rect: egui::Rect, label: &str) { return; } let t = theme::Tokens::get(ctx); - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("drop_overlay"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("drop_overlay"))); let area = rect.shrink(4.0); painter.rect_filled(area, egui::CornerRadius::same(8), t.accent.gamma_multiply(0.12)); painter.rect_stroke(area, egui::CornerRadius::same(8), egui::Stroke::new(2.0, theme::ACCENT), egui::StrokeKind::Inside); diff --git a/crates/ui-egui/src/list_drag.rs b/crates/ui-egui/src/list_drag.rs index b701a15a..08a391ae 100644 --- a/crates/ui-egui/src/list_drag.rs +++ b/crates/ui-egui/src/list_drag.rs @@ -6,15 +6,15 @@ //! lists bottom first; other lists (an interaction's actions, pages) show their items in model //! order. -use egui::{AsId, Id, Rect, Sense, Ui, pos2, vec2}; +use egui::{AsIdSalt, Id, Rect, Sense, Ui, pos2, vec2}; use crate::icons::{self, Icon}; use crate::theme::{Tokens, regular}; use crate::widgets::{self, PAD}; /// The id of list `list` (any hashable key, e.g. `"fills"`). -fn list_id(list: impl AsId) -> Id { - Id::new(("list_drag", list)) +fn list_id(list: impl AsIdSalt) -> Id { + Id::unique(("list_drag", list)) } /// The row of list `list` being dragged (its model index; temp data). @@ -24,7 +24,7 @@ fn drag_id(list: Id) -> Id { /// The grip of item `index` of list `list` (tests find rows by it). #[cfg(test)] -pub(crate) fn grip_id(list: impl AsId, index: usize) -> Id { +pub(crate) fn grip_id(list: impl AsIdSalt, index: usize) -> Id { grip_of(list_id(list), index) } @@ -49,12 +49,12 @@ pub struct ListDrag { impl ListDrag { /// A list shown topmost item (the model's last) first, as the Design panel shows paints. - pub fn new(ui: &Ui, list: impl AsId) -> Self { + pub fn new(ui: &Ui, list: impl AsIdSalt) -> Self { Self::with_order(ui, list_id(list), true) } /// A list shown in model order, first item on top (an interaction's actions). - pub fn in_order(ui: &Ui, list: impl AsId) -> Self { + pub fn in_order(ui: &Ui, list: impl AsIdSalt) -> Self { Self::with_order(ui, list_id(list), false) } @@ -164,34 +164,34 @@ fn drop_slot(rows: &[(usize, Rect)], from: usize, p: egui::Pos2, reversed: bool) Some((if reversed { n - 1 - at } else { at }, y)) } -/// Keep per-row flags (`Id::new((salt, index))`, e.g. an open settings section) with their rows +/// Keep per-row flags (`Id::unique((salt, index))`, e.g. an open settings section) with their rows /// when item `from` of a `len`-long list moves to `to`. pub fn move_flags(ui: &Ui, salt: &'static str, len: usize, from: usize, to: usize) { ui.data_mut(|d| { - let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::new((salt, i)))).collect(); + let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::unique((salt, i)))).collect(); for (i, f) in flags.into_iter().enumerate() { let j = figcraft_engine::cmd::lists::moved_index(i, from, to); match f { Some(v) => { - d.insert_temp(Id::new((salt, j)), v); + d.insert_temp(Id::unique((salt, j)), v); } - None => d.remove::(Id::new((salt, j))), + None => d.remove::(Id::unique((salt, j))), } } }); } -/// Keep per-row flags (`Id::new((salt, index))`) with their rows when item `removed` of a +/// Keep per-row flags (`Id::unique((salt, index))`) with their rows when item `removed` of a /// `len`-long list goes; its own flag goes with it. pub fn remove_flags(ui: &Ui, salt: &'static str, len: usize, removed: usize) { ui.data_mut(|d| { - let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::new((salt, i)))).collect(); + let flags: Vec> = (0..len).map(|i| d.get_temp::(Id::unique((salt, i)))).collect(); for i in 0..len { - d.remove::(Id::new((salt, i))); + d.remove::(Id::unique((salt, i))); } for (i, f) in flags.into_iter().enumerate() { if let (Some(j), Some(v)) = (figcraft_engine::cmd::lists::removed_index(i, removed), f) { - d.insert_temp(Id::new((salt, j)), v); + d.insert_temp(Id::unique((salt, j)), v); } } }); diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index a4baa303..7a1af420 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -354,7 +354,7 @@ pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) { let _ = app.session.live_chat(""); return; } - let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("live_chat"))); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::unique("live_chat"))); chat_bubble(&painter, tip, None, &chat.text, fill, alpha); app.ui.live.chat = Some(chat); return; @@ -366,7 +366,7 @@ pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) { } ctx.request_repaint_after(std::time::Duration::from_secs_f64(CHAT_HOLD + CHAT_FADE - idle)); let fg = text_on(fill); - let resp = egui::Area::new(egui::Id::new("live_chat_box")) + let resp = egui::Area::new(egui::Id::unique("live_chat_box")) .order(egui::Order::Foreground) .fixed_pos(tip + vec2(12.0, 16.0)) .show(ctx, |ui| { @@ -378,7 +378,7 @@ pub fn chat_box(app: &mut FigApp, ctx: &egui::Context) { let w = ui.painter().layout_no_wrap(chat.text.clone(), regular(12.0), fg).size().x + 8.0; ui.add( egui::TextEdit::singleline(&mut chat.text) - .id(egui::Id::new("live_chat_text")) + .id(egui::Id::unique("live_chat_text")) .frame(egui::Frame::NONE) .font(regular(12.0)) .text_color(fg) @@ -425,7 +425,7 @@ pub fn spotlight_banner(app: &mut FigApp, ctx: &egui::Context, canvas: Rect) { return; }; let t = Tokens::get(ctx); - let clicked = widgets::canvas_banner(ctx, egui::Id::new("live_spotlight"), canvas, |ui| { + let clicked = widgets::canvas_banner(ctx, egui::Id::unique("live_spotlight"), canvas, |ui| { ui.label(egui::RichText::new(text).font(regular(11.0)).color(t.text)); widgets::button(ui, button, true, true).clicked() }); @@ -473,7 +473,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let mut spotlight = false; if in_session { let c = pos2(x, cy); - let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.id().with("live_me"), Sense::click()); + let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.scope_id().with("live_me"), Sense::click()); a11y::menu_button(&resp, tr("You"), a11y::menu_open(&resp)); if spotlighting || resp.hovered() { ui.painter().circle_stroke(c, R + 2.5, Stroke::new(2.0, me_fill)); @@ -491,7 +491,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { let c = pos2(x, cy); let following = follow == Some(*id); let tip = if following { trf("Stop following {name}", &[("name", name)]) } else { trf("Follow {name}", &[("name", name)]) }; - let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.id().with(("live_avatar", id)), Sense::click()).tip(tip); + let resp = ui.interact(Rect::from_center_size(c, vec2(2.0 * R, 2.0 * R)), ui.scope_id().with(("live_avatar", id)), Sense::click()).tip(tip); ring(ui, c); avatar(ui.painter(), c, R, name, *fill); if following || resp.hovered() { @@ -517,7 +517,7 @@ pub fn people_bar(app: &mut FigApp, ui: &mut Ui, area: Rect) { ui.painter().text(c, Align2::CENTER_CENTER, format!("+{more}"), semibold(10.0), t.text); } let chev = Rect::from_center_size(pos2(x + R + 10.0, cy), vec2(16.0, 24.0)); - let cresp = ui.interact(chev, ui.id().with("live_people"), Sense::click()); + let cresp = ui.interact(chev, ui.scope_id().with("live_people"), Sense::click()); a11y::menu_button(&cresp, tr("People in this file"), a11y::menu_open(&cresp)); if cresp.hovered() { ui.painter().rect_filled(chev, CornerRadius::same(4), t.hover); @@ -665,7 +665,7 @@ fn online_note(app: &mut FigApp, ui: &mut Ui, t: &Tokens, server: &str) { /// home screen is closed. The session of the join that started, if one did. pub fn join_row(app: &mut FigApp, ui: &mut Ui, width: f32) -> Option { let mut typed = std::mem::take(&mut app.ui.live.code); - let joined = ui.horizontal(|ui| join_field(app, ui, &mut typed, egui::Id::new("live_join_code"), width, false)).inner; + let joined = ui.horizontal(|ui| join_field(app, ui, &mut typed, egui::Id::unique("live_join_code"), width, false)).inner; app.ui.live.code = typed; joined } @@ -714,7 +714,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { ui.horizontal(|ui| { widgets::label(ui, tr("Your name"), t.text); let name = app.session.prefs.user_name.clone(); - if let Some(n) = widgets::text_field(ui, egui::Id::new("live_name"), &name, 160.0, tr("Your name")) { + if let Some(n) = widgets::text_field(ui, egui::Id::unique("live_name"), &name, 160.0, tr("Your name")) { app.run("pref.userName", json!({"name": n})); } }); @@ -755,7 +755,7 @@ fn start_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { } ui.horizontal(|ui| { widgets::label(ui, tr("Port"), t.text); - if let Some(port) = widgets::text_field_named(ui, egui::Id::new("live_port"), &app.ui.live.port, 70.0, tr("Port")) { + if let Some(port) = widgets::text_field_named(ui, egui::Id::unique("live_port"), &app.ui.live.port, 70.0, tr("Port")) { // Digits only; empty: the usual port, or any free one. app.ui.live.port = port.trim().parse::().map(|p| p.to_string()).unwrap_or_default(); } @@ -885,11 +885,11 @@ fn share_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens, share: &Share) { }); ui.horizontal(|ui| { if widgets::button(ui, tr("Copy code"), true, true).clicked() { - app.services.clipboard_set_text(&code); + app.clipboard_set_text(&code); app.toast(tr("Join code copied")); } if widgets::button(ui, tr("Copy link"), false, true).on_hover_text(tr("An invite link that opens FigCraft and joins")).clicked() { - app.services.clipboard_set_text(&link); + app.clipboard_set_text(&link); app.toast(tr("Invite link copied")); } }); @@ -1007,7 +1007,7 @@ fn report_ui(app: &mut FigApp, ui: &mut Ui, t: &Tokens) { .hint_text(tr("Spam, stolen work, abuse…")) .char_limit(figcraft_engine::live::MAX_REPORT) .desired_width(ui.available_width()) - .id(egui::Id::new("live_report_reason")), + .id(egui::Id::unique("live_report_reason")), ); a11y::text(&resp, tr("Report"), &reason); crate::widgets::select_all_on_focus(ui, &resp, &reason); @@ -1042,7 +1042,7 @@ fn link_rows(app: &mut FigApp, ui: &mut Ui, rows: &[(&str, &String, &str)]) { ui.label(egui::RichText::new(widgets::elide(ui, value, w)).font(regular(10.5)).monospace()); }); if widgets::button(ui, tr("Copy"), false, true).on_hover_text(trf("Copy the {label}", &[("label", &label.to_lowercase())])).clicked() { - app.services.clipboard_set_text(value); + app.clipboard_set_text(value); app.toast(*what); } }); @@ -1085,11 +1085,11 @@ pub fn join_dialog(app: &mut FigApp, ctx: &egui::Context) { let mut cancel = false; let mut go = false; let focus = std::mem::take(&mut app.ui.live.focus_join); - crate::dialogs::window(tr("Join live session"), ctx).id(egui::Id::new("Join live session")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Join live session"), ctx).id(egui::Id::unique("Join live session")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); widgets::label(ui, tr("Type the join code you were given, or paste an invite link."), t.text); ui.horizontal(|ui| { - go = join_field(app, ui, &mut link, egui::Id::new("live_join_link"), 300.0, focus).is_some(); + go = join_field(app, ui, &mut link, egui::Id::unique("live_join_link"), 300.0, focus).is_some(); cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); }); widgets::label(ui, tr("You'll get the shared file as a new tab and edit it together."), t.text_secondary); @@ -1158,7 +1158,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { let open_server = info.as_ref().is_some_and(|v| v["open"] == true); // A first look at an address already filled in. ask |= focus && !address.is_empty() && !keyed && info.is_none(); - crate::dialogs::window(tr("Publish to server"), ctx).id(egui::Id::new("Publish to server")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Publish to server"), ctx).id(egui::Id::unique("Publish to server")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); widgets::label( ui, @@ -1170,7 +1170,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { egui::TextEdit::singleline(&mut form.server) .hint_text("wss://server/ or ws://server:47900/?key=…") .desired_width(380.0) - .id(egui::Id::new("live_publish_server")), + .id(egui::Id::unique("live_publish_server")), ); // The address may carry the server key: it isn't read out. a11y::text(&resp, tr("Server address"), ""); @@ -1202,7 +1202,7 @@ pub fn publish_dialog(app: &mut FigApp, ctx: &egui::Context) { ), } widgets::label(ui, tr("File name"), t.text_secondary); - let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::new("live_publish_name"))); + let nresp = ui.add(egui::TextEdit::singleline(&mut form.name).desired_width(380.0).id(egui::Id::unique("live_publish_name"))); a11y::text(&nresp, tr("File name"), &form.name); crate::widgets::select_all_on_focus(ui, &nresp, &form.name); go |= nresp.lost_focus() && enter(ui) && (keyed || open_server); @@ -1249,7 +1249,7 @@ fn published_dialog(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let mut open = true; let mut done = false; - crate::dialogs::window(tr("File published"), ctx).id(egui::Id::new("File published")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("File published"), ctx).id(egui::Id::unique("File published")).open(&mut open).show(ctx, |ui| { ui.set_width(400.0); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); widgets::label(ui, &trf("{name} is on the server.", &[("name", &p.name)]), t.text); diff --git a/crates/ui-egui/src/live_join_tests.rs b/crates/ui-egui/src/live_join_tests.rs index 61567ce0..bc1db8ad 100644 --- a/crates/ui-egui/src/live_join_tests.rs +++ b/crates/ui-egui/src/live_join_tests.rs @@ -14,8 +14,9 @@ fn escape(h: &mut Harness) { } } +/// Whether the Join link field currently has focus. fn link_focused(h: &Harness) -> bool { - h.ctx.memory(|m| m.has_focus(egui::Id::new("live_join_link"))) + h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_link"))) } #[test] diff --git a/crates/ui-egui/src/live_server_tests.rs b/crates/ui-egui/src/live_server_tests.rs index 4a92edf0..014be152 100644 --- a/crates/ui-egui/src/live_server_tests.rs +++ b/crates/ui-egui/src/live_server_tests.rs @@ -20,8 +20,9 @@ fn key(h: &mut Harness, key: Key) { } } +/// Whether the widget with the given id currently has focus. fn focused(h: &Harness, id: &str) -> bool { - h.ctx.memory(|m| m.has_focus(egui::Id::new(id))) + h.ctx.memory(|m| m.has_focus(egui::Id::unique(id))) } /// Joined through a server's link with `access`: the welcome and the document (a new tab). diff --git a/crates/ui-egui/src/live_share_tests.rs b/crates/ui-egui/src/live_share_tests.rs index 66750cac..d7fc61c8 100644 --- a/crates/ui-egui/src/live_share_tests.rs +++ b/crates/ui-egui/src/live_share_tests.rs @@ -385,13 +385,16 @@ fn without_a_code_the_link_is_shared_instead() { assert_eq!(script.lock().unwrap().copied.last().map(String::as_str), Some(PLAIN)); } +/// Asserts the join field accepts a typed code or invite link, joins on Enter, clears after +/// joining, shows failure/retry, rejects an invalid code with an error, and accepts local +/// codes and links. #[test] fn the_join_field_takes_a_code_or_a_link_and_enter_joins() { let (mut h, script) = harness(); let shapes = open(&mut h); // Click into the field (its hint), type a code the way people type it, press Enter. click(&mut h, &shapes, "Code or invite link"); - assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("live_join_code")))); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_code")))); type_text(&mut h, " k7m 2qx-9td "); assert_eq!(crate::live::inspect(&h.app)["joinField"], " k7m 2qx-9td "); key(&mut h, Key::Enter); @@ -450,13 +453,15 @@ fn the_home_screen_joins_with_a_code_and_opens_the_file() { assert_eq!(h.app.session.docs.len(), 2); } +/// Asserts the join dialog uses the same shared join field, focuses it, and closes once the +/// join starts. #[test] fn the_join_dialog_is_the_same_field() { let (mut h, script) = harness(); crate::menus::invoke(&mut h.app, "collab.join"); let shapes = h.frames(2); assert!(has_text(&shapes, "Type the join code you were given, or paste an invite link.") && has_text(&shapes, "Code or invite link")); - assert!(h.ctx.memory(|m| m.has_focus(egui::Id::new("live_join_link"))), "the field has the keyboard"); + assert!(h.ctx.memory(|m| m.has_focus(egui::Id::unique("live_join_link"))), "the field has the keyboard"); type_text(&mut h, "4fh j8r 2wn"); key(&mut h, Key::Enter); h.frames(1); diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index 331316b8..63c3c42a 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -290,6 +290,8 @@ fn cursors_follow_pages_in_a_shared_document() { assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Hana left")); } +/// Asserts clicking a peer's avatar follows that peer, and Escape or scrolling the canvas +/// stops following. #[test] fn clicking_an_avatar_follows_and_escape_stops() { let (mut h, _) = Harness::new(); @@ -317,6 +319,7 @@ fn clicking_an_avatar_follows_and_escape_stops() { h.frame(vec![Event::PointerMoved(pos2(700.0, 500.0))]); h.frame(vec![Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -40.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), diff --git a/crates/ui-egui/src/menu_aim_tests.rs b/crates/ui-egui/src/menu_aim_tests.rs index 5e2fe076..6f7c4b8a 100644 --- a/crates/ui-egui/src/menu_aim_tests.rs +++ b/crates/ui-egui/src/menu_aim_tests.rs @@ -87,8 +87,9 @@ impl Ui { const TOPS: [&str; 6] = ["File", "Edit", "View", "Object", "Text", "Arrange"]; +/// Stable id for a top-level menu in the menu-aim test harness. fn top(name: &str) -> Id { - Id::new(("menu", name)) + Id::unique(("menu", name)) } /// The submenus of top-level menu `menu` (their keys), from the menu catalog. @@ -145,6 +146,8 @@ fn a_reopened_main_menu_shows_no_stale_submenu() { assert!(u.tops_shown().is_empty(), "reopened with {:?} open", u.tops_shown()); } +/// Asserts leaving a top menu and returning to it does not restore the submenu that was open +/// before. #[test] fn a_submenu_one_level_down_reopens_closed() { let mut u = Ui::new(); @@ -152,7 +155,7 @@ fn a_submenu_one_level_down_reopens_closed() { u.settle(u.top_row(0).center()); let keys = submenu_keys("File"); assert!(!keys.is_empty(), "File has submenus"); - let sub_shown = |u: &Ui| keys.iter().find(|k| u.shown(Id::new(("submenu", k.as_str())))).cloned(); + let sub_shown = |u: &Ui| keys.iter().find(|k| u.shown(Id::unique(("submenu", k.as_str())))).cloned(); // Down the middle of File's panel until one of its submenus opens. let panel = u.rect(top("File")); let mut y = panel.min.y + 10.0; diff --git a/crates/ui-egui/src/menu_input_tests.rs b/crates/ui-egui/src/menu_input_tests.rs index f317b65b..6fc65533 100644 --- a/crates/ui-egui/src/menu_input_tests.rs +++ b/crates/ui-egui/src/menu_input_tests.rs @@ -69,6 +69,8 @@ fn batch_rename_opens_its_existing_dialog() { assert!(ui.app.ui.last_error.is_none()); } +/// Asserts `file.rename` opens the left panel's inline file-name editor (not a dialog), +/// focuses it, and commits the typed name on Enter. #[test] fn rename_file_edits_the_file_name_in_place() { let mut ui = Ui::new(Session::new()); @@ -79,7 +81,7 @@ fn rename_file_edits_the_file_name_in_place() { ui.frame(vec![]); ui.frame(vec![]); assert!(!ui.app.ui.rename_file, "the rename started"); - assert!(ui.ctx.memory(|m| m.has_focus(egui::Id::new("filename").with("te"))), "the file name's editor has the keyboard"); + assert!(ui.ctx.memory(|m| m.has_focus(egui::Id::unique("filename").with("te"))), "the file name's editor has the keyboard"); ui.frame(vec![egui::Event::Text("Brand kit".into())]); ui.frame(vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]); assert_eq!(ui.app.session.doc().unwrap().name, "Brand kit"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index fb4966ed..0da6ed1b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -37,6 +37,16 @@ pub const UI_COMMANDS: &[&str] = &[ /// middle of the view. /// In Dev Mode only a comment's link pastes (it opens the thread); everything else would edit. pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) { + // A gesture injected through the control channel pastes only our own copied layers: it must not + // read (and so expose) the user's system clipboard. + if app.control_synthetic { + // Synthetic input is user-like: Dev Mode refuses the paste here too, and only the engine's + // own copied layers are pasted (the system clipboard is never read). + if !dev_mode_refuses(app, "edit.paste") { + app.run("edit.paste", at.map_or(json!({}), |(x, y)| json!({"x": x, "y": y}))); + } + return; + } // Only HTML that holds readable design data takes this path; other HTML pastes as before. if let Some(html) = app.services.clipboard_get_html().filter(|h| figcraft_engine::cmd::edit::design_clipboard_buffer(h).is_some()) { if dev_mode_refuses(app, "edit.paste") { @@ -50,6 +60,7 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) if let Some(r) = app.run("edit.pasteDesignLayers", params) { // The pasted layers are now our copy: text another app puts on the clipboard later is newer. app.ui.last_copy = Some(app.services.clipboard_get_text().unwrap_or_default()); + app.ui.last_copy_generation = app.session.clipboard.layers_generation; let left_out = r["warnings"].as_array().map_or(0, Vec::len); if left_out > 0 { app.toast(trn("Pasted design layers; {n} kinds of content were left out", left_out, &[])); @@ -58,7 +69,11 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) return; } let sys_text = app.services.clipboard_get_text().or_else(|| event_text.map(str::to_string)).filter(|t| !t.is_empty()); - let ours = sys_text.is_some() && sys_text == app.ui.last_copy; + // The text matching `last_copy` means the system clipboard still holds what we mirrored only + // while the engine clipboard is the one we mirrored. A control-channel copy changes the engine + // clipboard without writing the system clipboard, so its generation no longer matches and the + // user's own text is pasted instead of the programmatic selection. + let ours = sys_text.is_some() && sys_text == app.ui.last_copy && app.ui.last_copy_generation == app.session.clipboard.layers_generation; let (cx, cy) = at.or_else(|| app.session.ds().ok().map(|d| (d.view.cx, d.view.cy))).unwrap_or((0.0, 0.0)); if !ours { if let Some((png, w, h)) = clipboard_png(app) { @@ -72,8 +87,10 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) app.run("file.import", json!({"data": b64, "name": "Pasted image.png", "x": x, "y": y})); return; } - // Text from elsewhere when it's newer than our copy, or when we have nothing to paste. - if let Some(text) = sys_text.filter(|_| app.ui.last_copy.is_some() || app.session.clipboard.nodes.is_empty()) { + // Not ours: any other text is newer (another app's text, SVG, a comment link) and wins. + // Our own marker for copied layers without a text/SVG mirror is never a text paste; when + // the engine copy moved on it falls through to `edit.paste` (the only layers we still have). + if let Some(text) = sys_text.filter(|t| !is_clipboard_marker(t)) { return paste_text(app, &text, (cx, cy), at.is_none()); } } @@ -82,6 +99,11 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) } } +/// The marker FigCraft puts on the system clipboard for copied layers with no text/SVG mirror. +fn is_clipboard_marker(text: &str) -> bool { + text.starts_with("figcraft://clipboard/") +} + /// Paste text from another app at `(cx, cy)` (`center`: centred there): a comment's link opens its /// thread, SVG imports, anything else becomes a text layer. fn paste_text(app: &mut FigApp, text: &str, (cx, cy): (f64, f64), center: bool) { @@ -183,14 +205,14 @@ pub fn invoke(app: &mut FigApp, id: &str) { let ids = app.session.selection(); if let (Some(first), Ok(doc)) = (ids.first(), app.session.doc()) { let css = figcraft_engine::codegen::code_for(doc, *first, figcraft_engine::codegen::CodeLang::Css); - app.services.clipboard_set_text(&css); + app.clipboard_set_text(&css); app.toast(tr("Copied as CSS")); } } "edit.copyAsSvg" => { let ids = app.session.selection(); if let Some(svg) = app.session.doc().ok().and_then(|d| figcraft_svg_export(d, &ids)) { - app.services.clipboard_set_text(&svg); + app.clipboard_set_text(&svg); app.toast(tr("Copied as SVG")); } } @@ -202,7 +224,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { // Clipboard images are read as sRGB. figcraft_render::to_srgb(&mut pix, profile); let rgba = figcraft_render::to_rgba8(&pix); - app.services.clipboard_set_png(pix.width(), pix.height(), &rgba); + app.clipboard_set_png(pix.width(), pix.height(), &rgba); app.toast(tr("Copied as PNG")); } } @@ -214,7 +236,7 @@ pub fn invoke(app: &mut FigApp, id: &str) { .iter() .filter_map(|i| doc.and_then(|d| d.node(*i)).and_then(|n| n.kind.text().map(|t| t.characters.clone()))) .collect(); - app.services.clipboard_set_text(&text.join("\n")); + app.clipboard_set_text(&text.join("\n")); app.toast(tr("Copied as text")); } "edit.copyLink" => { @@ -261,10 +283,15 @@ pub fn invoke(app: &mut FigApp, id: &str) { let mirror = doc.and_then(|d| text_layers_text(d, &ids).or_else(|| figcraft_svg_export(d, &ids))); app.run(id, json!({})); // Mirror to the system clipboard (text or SVG, else a marker) and remember it, so a - // later paste knows whether the clipboard still holds our layers. - let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len())); - app.services.clipboard_set_text(&text); - app.ui.last_copy = Some(text); + // later paste knows whether the clipboard still holds our layers. Control-channel + // gestures keep the engine's copy but never replace the user's clipboard; `run` then + // invalidates the old mirror because it no longer describes the engine clipboard. + if !app.control_synthetic { + let text = mirror.unwrap_or_else(|| format!("figcraft://clipboard/{}", ids.len())); + app.clipboard_set_text(&text); + app.ui.last_copy = Some(text); + app.ui.last_copy_generation = app.session.clipboard.layers_generation; + } } "edit.paste" => paste(app, None, None), "file.close" if app.session.ds().is_ok_and(|d| d.is_dirty() && d.branch_of.is_none()) => { @@ -393,7 +420,7 @@ fn recent_menu(app: &mut FigApp, ui: &mut Ui, prefix: &str, key: &str, label: &s /// it. `key` names the submenu (its menu path, unique in its menu tree). Returns what `add` /// returned. pub(crate) fn submenu(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option) -> Option { - submenu_at(ui, Id::new(("submenu", key)), key, label, 4.0, false, t, add) + submenu_at(ui, Id::unique(("submenu", key)), key, label, 4.0, false, t, add) } // --------------------------------------------------------------------------------------------- @@ -453,12 +480,14 @@ struct At { path: String, } +/// Scoped egui id for the current menu level. fn at_key(ui: &Ui) -> Id { - ui.id().with("menu_level") + ui.scope_id().with("menu_level") } +/// Return the current menu root/path, defaulting to the main menu root. fn level_at(ui: &Ui) -> At { - ui.data(|d| d.get_temp::(at_key(ui))).unwrap_or(At { root: Id::new("menus"), path: String::new() }) + ui.data(|d| d.get_temp::(at_key(ui))).unwrap_or(At { root: Id::unique("menus"), path: String::new() }) } fn with_level(ctx: &egui::Context, at: &At, f: impl FnOnce(&mut Level) -> R) -> R { @@ -702,7 +731,7 @@ const MENU_W: f32 = 240.0; /// Where a menu remembers the width its rows need (its frame's ui), per language. fn width_key(ui: &Ui) -> Id { - ui.id().with(("menu_w", i18n::current().map(|l| l.code))) + ui.scope_id().with(("menu_w", i18n::current().map(|l| l.code))) } /// A row needs `w`: the menu widens to it from the next frame on. @@ -765,12 +794,12 @@ pub fn open_main_menu(ctx: &egui::Context, top: &str) { /// The context menu's area (and menu tree). fn ctx_menu_id() -> Id { - Id::new("ctx_menu") + Id::unique("ctx_menu") } /// The main menu's area. pub fn main_menu_id() -> Id { - Id::new("main_menu") + Id::unique("main_menu") } /// Whether the pointer is over a menu (any foreground area). @@ -792,6 +821,9 @@ const TOP_MENUS: &[&str] = &[ tr_noop("Help"), ]; +/// Draw the main and context menu trees: forget closed/stale submenu state, apply +/// control-channel Select-layer open/close, close on outside click or Escape, then draw the +/// colour picker and palette popups. pub fn popups(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); // A closed menu forgets its open submenus (and a context menu opened somewhere else starts @@ -800,7 +832,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { forget_tree(ctx, main_menu_id()); } let ctx_at = app.ui.context_menu.as_ref().map(|(p, _)| *p); - let ctx_seen = Id::new("ctx_menu_at"); + let ctx_seen = Id::unique("ctx_menu_at"); if ctx.data(|d| d.get_temp::>(ctx_seen)) != Some(ctx_at) { ctx.data_mut(|d| d.insert_temp(ctx_seen, ctx_at)); forget_tree(ctx, ctx_menu_id()); @@ -834,7 +866,9 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { } ui.add_space(6.0); for top in TOP_MENUS { - if submenu_at(ui, Id::new(("menu", *top)), top, tr(top), 12.0, false, &t, |ui| menu_level(app, ui, top).then_some(())).is_some() { + if submenu_at(ui, Id::unique(("menu", *top)), top, tr(top), 12.0, false, &t, |ui| menu_level(app, ui, top).then_some(())) + .is_some() + { close = true; } } @@ -853,7 +887,7 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) { } if let Some((pos, ids)) = app.ui.context_menu.clone() { if let Some(f) = ctx.memory(|m| m.focused()) { - a11y::popup(&mut app.ui.nav, f, Id::new("ctx_menu")); + a11y::popup(&mut app.ui.nav, f, Id::unique("ctx_menu")); } let mut close = false; let area = egui::Area::new(ctx_menu_id()).fixed_pos(pos).order(egui::Order::Foreground).constrain(true).show(ctx, |ui| { @@ -1021,8 +1055,9 @@ const SELECT_LAYER: &str = "Select layer"; /// The context menu's Copy as submenu: its key in the tree, and the marker row in the entries. const COPY_AS: &str = "Copy as"; +/// Stable egui id for the context menu's pending Select-layer open request. fn select_layer_ask_id() -> Id { - Id::new("ctx_select_layer_ask") + Id::unique("ctx_select_layer_ask") } /// Open (or close) the context menu's Select layer submenu (applied when the menu next draws). @@ -1051,7 +1086,7 @@ fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) return false; } let sel = ds.selection.clone(); - let picked = submenu_at(ui, Id::new("ctx_select_layer"), SELECT_LAYER, tr("Select layer"), 4.0, true, t, |ui| { + let picked = submenu_at(ui, Id::unique("ctx_select_layer"), SELECT_LAYER, tr("Select layer"), 4.0, true, t, |ui| { let mut picked = None; for (id, depth, name) in &layers { if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) { @@ -1117,7 +1152,7 @@ fn palette(app: &mut FigApp, ctx: &egui::Context) { i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 }); - egui::Area::new(Id::new("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Area::new(Id::unique("palette")).fixed_pos(area.min).order(egui::Order::Foreground).show(ctx, |ui| { egui::Frame::NONE .fill(t.panel) .corner_radius(CornerRadius::same(13)) diff --git a/crates/ui-egui/src/online.rs b/crates/ui-egui/src/online.rs index 2f054986..61901fae 100644 --- a/crates/ui-egui/src/online.rs +++ b/crates/ui-egui/src/online.rs @@ -163,8 +163,9 @@ fn row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, f: &OnlineFile, busy: bool) { let shown = FileRow { mark: Some((glyph, tint)), name: &f.name, ink, detail: &details, detail_size: 11.0, detail_ink: t.text_secondary }; file_row(ui, t, r.with_max_x(more.min.x - 4.0), &resp, &shown); // The menu button takes its own clicks. - let mresp = - ui.interact(more, ui.id().with(("online_more", &f.server, &f.file)), Sense::click()).tip(trf("Actions for {name}", &[("name", &f.name)])); + let mresp = ui + .interact(more, ui.scope_id().with(("online_more", &f.server, &f.file)), Sense::click()) + .tip(trf("Actions for {name}", &[("name", &f.name)])); a11y::menu_button(&mresp, &trf("Actions for {name}", &[("name", &f.name)]), a11y::menu_open(&mresp)); if mresp.hovered() { ui.painter().rect_filled(more, CornerRadius::same(5), t.pressed); @@ -199,14 +200,14 @@ fn row(app: &mut FigApp, ui: &mut Ui, t: &Tokens, f: &OnlineFile, busy: bool) { if let Some(code) = shared.and_then(|s| s.edit.code.as_deref()) && item(ui, tr("Copy code"), true) { - app.services.clipboard_set_text(&figcraft_engine::figcraft_collab::code::grouped(code)); + app.clipboard_set_text(&figcraft_engine::figcraft_collab::code::grouped(code)); app.toast(tr("Join code copied")); ui.close(); } if let Some(s) = shared && item(ui, tr("Copy link"), true) { - app.services.clipboard_set_text(&s.edit.link); + app.clipboard_set_text(&s.edit.link); app.toast(tr("Invite link copied")); ui.close(); } @@ -257,7 +258,7 @@ pub fn dialogs(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); if let Some((server, file, name)) = app.ui.online.remove.take() { let (mut open, mut cancel, mut go) = (true, false, false); - crate::dialogs::window(tr("Remove from server"), ctx).id(egui::Id::new("Remove online file")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Remove from server"), ctx).id(egui::Id::unique("Remove online file")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); widgets::label(ui, &trf("Remove {name} from {server}?", &[("name", &name), ("server", &server_host(&server))]), t.text); @@ -282,11 +283,11 @@ pub fn dialogs(app: &mut FigApp, ctx: &egui::Context) { if let Some((server, file, mut name)) = app.ui.online.rename.take() { let (mut open, mut cancel, mut go) = (true, false, false); let focus = std::mem::take(&mut app.ui.online.focus_rename); - crate::dialogs::window(tr("Rename on the server"), ctx).id(egui::Id::new("Rename online file")).open(&mut open).show(ctx, |ui| { + crate::dialogs::window(tr("Rename on the server"), ctx).id(egui::Id::unique("Rename online file")).open(&mut open).show(ctx, |ui| { ui.set_width(380.0); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap); widgets::label(ui, tr("The name in your online files and on the server. The document inside keeps its own name."), t.text_secondary); - let resp = widgets::text_input(ui, egui::Id::new("online_rename"), &mut name, 380.0, tr("File name")); + let resp = widgets::text_input(ui, egui::Id::unique("online_rename"), &mut name, 380.0, tr("File name")); a11y::text(&resp, tr("File name"), &name); if focus { resp.request_focus(); diff --git a/crates/ui-egui/src/padding_individual_tests.rs b/crates/ui-egui/src/padding_individual_tests.rs index bc95886e..bf076d48 100644 --- a/crates/ui-egui/src/padding_individual_tests.rs +++ b/crates/ui-egui/src/padding_individual_tests.rs @@ -61,12 +61,12 @@ fn side_fields_show_only_once_the_toggle_opens_and_set_one_side() { let (mut u, f) = scene(); assert_eq!(padding(&u, f), [10.0; 4]); // Shut: no per-side fields. - assert!(u.ctx.read_response(Id::new(("padding", 0)).with("field")).is_none(), "top field hidden until toggled"); + assert!(u.ctx.read_response(Id::unique(("padding", 0)).with("field")).is_none(), "top field hidden until toggled"); // Click the toggle: the four sides appear. let t = named_button(&mut u, "Individual padding"); click(&mut u, t); - let top = u.ctx.read_response(Id::new(("padding", 0)).with("field")).expect("top field shows").rect.center(); - assert!(u.ctx.read_response(Id::new(("padding", 3)).with("field")).is_some(), "left field shows"); + let top = u.ctx.read_response(Id::unique(("padding", 0)).with("field")).expect("top field shows").rect.center(); + assert!(u.ctx.read_response(Id::unique(("padding", 3)).with("field")).is_some(), "left field shows"); // Typing in the top field sets only the top, in one undo step. let history = u.app.session.undo_depth().0; click(&mut u, top); diff --git a/crates/ui-egui/src/panel_remove_tests.rs b/crates/ui-egui/src/panel_remove_tests.rs index 3805dca3..1608902d 100644 --- a/crates/ui-egui/src/panel_remove_tests.rs +++ b/crates/ui-egui/src/panel_remove_tests.rs @@ -35,6 +35,7 @@ fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 { } let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -100.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -63,10 +64,13 @@ fn remove_button(u: &mut Ui, list: &str, index: usize) -> Pos2 { .unwrap_or_else(|| panic!("no Remove button in {list} row {index} {row:?} {all:?}")) } +/// Read a temporary boolean open-settings flag keyed by `salt` and row index. fn flag(u: &Ui, salt: &'static str, i: usize) -> bool { - u.ctx.data(|d| d.get_temp::(Id::new((salt, i)))).unwrap_or(false) + u.ctx.data(|d| d.get_temp::(Id::unique((salt, i)))).unwrap_or(false) } +/// Asserts removing a fill keeps the open picker on the surviving paint (with its index +/// shifted), and removing the paint being edited closes the picker. #[test] fn removing_a_fill_keeps_the_picker_on_its_paint() { let mut s = Session::new(); @@ -78,7 +82,7 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { let mut u = Ui::new(s); u.ctx.enable_accesskit(); // Green's swatch, then red's Remove: the picker follows green to index 0. - let sw = u.ctx.read_response(Id::new(("swatch", true, 1usize))).unwrap().rect.center(); + let sw = u.ctx.read_response(Id::unique(("swatch", true, 1usize))).unwrap().rect.center(); click(&mut u, sw); assert_eq!(u.app.ui.picker, Some(PickerTarget::Paint { fills: true, index: 1, ids: vec![r] })); let rm = remove_button(&mut u, "fills", 0); @@ -92,6 +96,8 @@ fn removing_a_fill_keeps_the_picker_on_its_paint() { assert_eq!(u.app.ui.picker, None); } +/// Asserts removing effects or grids moves open settings and an open colour picker to the +/// surviving row, or closes them when their own row is removed. #[test] fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut s = Session::new(); @@ -105,7 +111,7 @@ fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { let mut u = Ui::new(s); u.ctx.enable_accesskit(); // Effect 2's settings open and its colour in the picker; effect 0 goes. - u.ctx.data_mut(|d| d.insert_temp(Id::new(("fx_open", 2usize)), true)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("fx_open", 2usize)), true)); u.app.ui.picker = Some(PickerTarget::Effect { ids: vec![f], index: 2, secondary: false }); let rm = remove_button(&mut u, "effects", 0); click(&mut u, rm); @@ -118,7 +124,7 @@ fn removing_effects_and_grids_keeps_open_settings_with_their_rows() { assert!(!flag(&u, "fx_open", 0) && !flag(&u, "fx_open", 1)); assert_eq!(u.app.ui.picker, None); // Grid 1's settings open; grid 0 goes. - u.ctx.data_mut(|d| d.insert_temp(Id::new(("grid_open", 1usize)), true)); + u.ctx.data_mut(|d| d.insert_temp(Id::unique(("grid_open", 1usize)), true)); let rm = remove_button(&mut u, "grids", 0); click(&mut u, rm); assert_eq!(u.app.session.doc().unwrap().node(f).unwrap().grids.len(), 1); diff --git a/crates/ui-egui/src/panel_reorder_tests.rs b/crates/ui-egui/src/panel_reorder_tests.rs index 00f969b5..ffeb948e 100644 --- a/crates/ui-egui/src/panel_reorder_tests.rs +++ b/crates/ui-egui/src/panel_reorder_tests.rs @@ -73,6 +73,8 @@ fn escape_cancels_a_row_drag() { assert_eq!(u.app.session.ds().unwrap().selection, [r], "the Escape only cancels the drag"); } +/// Asserts effect and layout-grid rows reorder by drag, while a mixed fill list exposes no +/// drag handle. #[test] fn effects_and_grids_drag_too_and_mixed_lists_dont() { let (mut u, r) = setup(); @@ -94,6 +96,7 @@ fn effects_and_grids_drag_too_and_mixed_lists_dont() { // The layout grid section is below the fold: scroll the panel. let wheel = Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: egui::vec2(0.0, -400.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), diff --git a/crates/ui-egui/src/pending_open.rs b/crates/ui-egui/src/pending_open.rs index f5e978c6..c6ade598 100644 --- a/crates/ui-egui/src/pending_open.rs +++ b/crates/ui-egui/src/pending_open.rs @@ -175,7 +175,7 @@ pub fn pending_opens(app: &mut FigApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let pending = &app.ui.pending_opens; // Shown at once: the frame after this one may block the window while the file opens. - crate::dialogs::window("", ctx).id(egui::Id::new("pending_open")).title_bar(false).movable(false).interactable(false).fade_in(false).show( + crate::dialogs::window("", ctx).id(egui::Id::unique("pending_open")).title_bar(false).movable(false).interactable(false).fade_in(false).show( ctx, |ui| { ui.set_width(480.0); diff --git a/crates/ui-egui/src/perf_tests.rs b/crates/ui-egui/src/perf_tests.rs index f8f39e93..eba5e68c 100644 --- a/crates/ui-egui/src/perf_tests.rs +++ b/crates/ui-egui/src/perf_tests.rs @@ -68,6 +68,8 @@ impl Bench { } } +/// Build and print a synthetic UI benchmark report for the given layer count; it makes no +/// assertions. fn report(layers: usize) { let mut b = Bench::new(layers); let mut rows: Vec<(&str, f64)> = Vec::new(); @@ -77,6 +79,7 @@ fn report(layers: usize) { rows.push(("hover over the canvas", b.mean(60, |i| pts[i].clone()))); let wheel = |d: f32| Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, d), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -179,8 +182,15 @@ fn design_system(icons: usize, instances: usize) -> (Document, figcraft_engine:: figcraft_engine::synthetic_ds::design_system_with_instances(&spec, instances).unwrap() } +/// Build a test mouse-wheel event for the given vertical delta. fn wheel(d: f32) -> Event { - Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta: vec2(0.0, d), phase: egui::TouchPhase::Move, modifiers: Default::default() } + Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, + delta: vec2(0.0, d), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + } } /// UI frames with a large design system: the Assets panel listing its components and the Design diff --git a/crates/ui-egui/src/plugin_panel_tests.rs b/crates/ui-egui/src/plugin_panel_tests.rs index e4bc6d4f..53b1ba56 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -89,14 +89,18 @@ fn an_open_plugin_panel_draws_no_frames_while_idle() { Harness::new(session()).assert_idle("a plugin panel"); } +/// Asserts a plugin colour field's swatch opens the picker on that field, a picked colour +/// reaches the plugin, and a second plugin's panel opens while the first remains (two panels +/// present). #[test] fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() { let mut u = Ui::new(session()); u.ctx.enable_accesskit(); u.frame(vec![]); let nodes = |u: &mut Ui| u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; - // The swatch beside a colour field opens the colour picker on it. - let swatch = nodes(&mut u).iter().map(|(_, n)| n).find(|n| n.label() == Some("Color")).and_then(|n| n.bounds()).unwrap(); + // The swatch beside the accent field (named for that field, not just "Color") opens the + // colour picker on the plugin field, not on another colour control in the window. + let swatch = nodes(&mut u).iter().map(|(_, n)| n).find(|n| n.label() == Some("Accent Color")).and_then(|n| n.bounds()).unwrap(); let at = Pos2::new(((swatch.x0 + swatch.x1) / 2.0) as f32, ((swatch.y0 + swatch.y1) / 2.0) as f32); u.button(at, true); u.button(at, false); diff --git a/crates/ui-egui/src/plugins_ui.rs b/crates/ui-egui/src/plugins_ui.rs index 745cde88..26479c4d 100644 --- a/crates/ui-egui/src/plugins_ui.rs +++ b/crates/ui-egui/src/plugins_ui.rs @@ -81,8 +81,8 @@ pub(crate) fn field_value(app: &FigApp, plugin: &str, control: &str) -> Option Option { let id = match &app.ui.plugin_form { - Some(f) if f.id == plugin => Id::new("plugin_form"), - _ => Id::new(("plugin_panel", plugin)), + Some(f) if f.id == plugin => Id::unique("plugin_form"), + _ => Id::unique(("plugin_panel", plugin)), }; ctx.memory(|m| m.area_rect(id)) } @@ -206,7 +206,7 @@ fn panel_windows(app: &mut FigApp, ctx: &Context) { let mut used = None; // Side by side leftwards from the properties panel, three to a row. let at = egui::pos2(right - PANEL_STEP * (i % 3) as f32, 52.0 + 28.0 * (i / 3) as f32); - floating(open.title(), ctx).id(Id::new(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| { + floating(open.title(), ctx).id(Id::unique(("plugin_panel", id))).pivot(Align2::RIGHT_TOP).default_pos(at).open(&mut keep).show(ctx, |ui| { ui.set_width(320.0); used = controls(ui, id, &open.panel.controls, &open.values, &t); }); @@ -245,12 +245,15 @@ const INSTALL: &str = "plugin.directory.install"; const MANAGE_W: f32 = 400.0; const ROW_TEXT_W: f32 = 300.0; +/// Draw the Plugins window with installed and browse tabs, dispatching remove, import, +/// browse, confirm and stop actions; closing the window cancels its directory requests and +/// clears confirmation. fn manage_window(app: &mut FigApp, ctx: &Context) { let t = Tokens::get(ctx); let mut open = true; let mut asked = None; let mut tab = app.ui.plugins_manage.tab; - window(tr("Plugins"), ctx).id(Id::new("Plugins")).open(&mut open).show(ctx, |ui| { + window(tr("Plugins"), ctx).id(Id::unique("Plugins")).open(&mut open).show(ctx, |ui| { ui.set_width(MANAGE_W); tab_strip(ui, &t, &PluginsTab::ALL, PluginsTab::label, &mut tab); ui.add_space(12.0); @@ -326,7 +329,7 @@ fn browse_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> Option { // The field commits on Enter (which also browses) or when Browse takes the focus. let mut enter = false; if let Some(s) = - widgets::text_field(ui, Id::new("plugin_directory_server"), &server, MANAGE_W - 90.0, tr("Server address (ws://host:47900/)")) + widgets::text_field(ui, Id::unique("plugin_directory_server"), &server, MANAGE_W - 90.0, tr("Server address (ws://host:47900/)")) { enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); server = s.clone(); @@ -351,7 +354,7 @@ fn browse_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens) -> Option { return asked; }; ui.add_space(8.0); - if let Some(q) = widgets::text_field(ui, Id::new("plugin_directory_query"), &app.ui.plugins_manage.query, MANAGE_W, tr("Search plugins")) { + if let Some(q) = widgets::text_field(ui, Id::unique("plugin_directory_query"), &app.ui.plugins_manage.query, MANAGE_W, tr("Search plugins")) { app.ui.plugins_manage.query = q; } ui.add_space(8.0); @@ -432,7 +435,7 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) { let installing = app.session.waiting_for(INSTALL); let title = if update { tr("Update plugin") } else { tr("Install plugin") }; let (mut open, mut install, mut cancel) = (true, false, false); - window(title, ctx).id(Id::new("plugin_install_confirm")).open(&mut open).show(ctx, |ui| { + window(title, ctx).id(Id::unique("plugin_install_confirm")).open(&mut open).show(ctx, |ui| { ui.set_width(340.0); ui.label(RichText::new(format!("{} {}", e.name, e.version)).font(semibold(13.0)).color(t.text)); widgets::label(ui, &trf("From {server}", &[("server", &server)]), t.text_secondary); @@ -509,6 +512,8 @@ fn summary(p: &InstalledPlugin) -> String { parts.join(" · ") } +/// Draw a plugin command's parameter form: close it when the plugin or command disappeared, +/// edit values and colour picks, run `plugin.run` on Run, and close on Cancel. fn form_window(app: &mut FigApp, ctx: &Context) { let Some(mut form) = app.ui.plugin_form.clone() else { return }; let Some((name, command)) = app @@ -529,7 +534,7 @@ fn form_window(app: &mut FigApp, ctx: &Context) { let mut pick = None; let label = command.label.trim_end_matches(['…', '.']).trim(); let title = if label.contains(name.as_str()) { label.to_string() } else { format!("{name}: {label}") }; - window(&title, ctx).id(Id::new("plugin_form")).open(&mut open).show(ctx, |ui| { + window(&title, ctx).id(Id::unique("plugin_form")).open(&mut open).show(ctx, |ui| { ui.set_width(320.0); match controls(ui, &format!("{}/{}", form.id, form.command), &command.params, &form.values, &t) { Some(Used::Set(name, v)) => { @@ -586,7 +591,7 @@ fn controls(ui: &mut Ui, salt: &str, params: &[Param], values: &Map { let current = values.get(&p.name).cloned().unwrap_or_else(|| p.default_value()); - field(ui, Id::new(("plugin_control", salt, &p.name)), p, ¤t) + field(ui, Id::unique(("plugin_control", salt, &p.name)), p, ¤t) } }; if hit.is_some() { @@ -643,7 +648,8 @@ fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option { let (picked, typed) = ui .horizontal(|ui| { let color = figcraft_color::Color::from_hex(hex).unwrap_or(figcraft_color::Color::BLACK); - let picked = widgets::swatch(ui, &figcraft_color::Paint::solid(color), widgets::FIELD_H).clicked(); + let label = format!("{name} {}", tr("Color")); + let picked = widgets::swatch_named(ui, &figcraft_color::Paint::solid(color), widgets::FIELD_H, &label).clicked(); (picked, widgets::text_field_named(ui, id, hex, FIELD_W - widgets::FIELD_H - 8.0, name).and_then(|s| p.coerce(&json!(s)).ok())) }) .inner; diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index 9b59015f..bbaae4b6 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -1740,7 +1740,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { app.ui.comments.close(); } finish(app, &mut pres); - link_notice(app, &mut pres, &ctx, Id::new("present_link_notice")); + link_notice(app, &mut pres, &ctx, Id::unique("present_link_notice")); // The options carry over to the next presentation. app.ui.present_view = pres.view; if live && let Some(BarAction::Responsive(on)) = action { @@ -1779,78 +1779,82 @@ fn navigation_bar(p: &mut Presenter, ctx: &egui::Context, now: f64) -> Option Id { - Id::new("proto_preview_pane") + Id::unique("proto_preview_pane") } /// Show the preview pane while `app.ui.preview` is set (call once per frame, before shortcuts: @@ -2217,7 +2221,7 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) if p.step_fling(now) { ctx.request_repaint(); } - let resp = ui.interact(area, ui.id().with("present_area"), Sense::click_and_drag()); + let resp = ui.interact(area, ui.scope_id().with("present_area"), Sense::click_and_drag()); let over = resp.contains_pointer(); if let Some(l) = layout(p, area, chrome) { // The pane only takes the keyboard while the pointer is over it. While a link notice shows @@ -2317,7 +2321,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) let w = if chrome.pane { 24.0 } else { ui.painter().layout_no_wrap(shown.to_string(), regular(11.5), text).size().x + 16.0 }; let br = Rect::from_min_size(pos2(x - w, bar.center().y - 12.0), vec2(w, 24.0)); x = br.min.x - if chrome.pane { 2.0 } else { 4.0 }; - let mut resp = ui.interact(br, ui.id().with(("pbar", *label)), Sense::click()); + let mut resp = ui.interact(br, ui.scope_id().with(("pbar", *label)), Sense::click()); if chrome.pane { resp = resp.tip(shown); } @@ -2369,7 +2373,7 @@ fn top_bar(p: &mut Presenter, ui: &mut Ui, bar: Rect, now: f64, chrome: Chrome) let tip = playing.map(|f| f.description.trim()).filter(|d| !d.is_empty()).unwrap_or(tr("Flows")).to_string(); let galley = left.layout_no_wrap(label, semibold(11.5), text); let fr = Rect::from_min_size(pos2(lx, bar.center().y - 12.0), vec2(galley.size().x + 30.0, 24.0)); - let resp = ui.interact(fr.intersect(left.clip_rect()), ui.id().with("pflows"), Sense::click()).tip(tip); + let resp = ui.interact(fr.intersect(left.clip_rect()), ui.scope_id().with("pflows"), Sense::click()).tip(tip); if resp.hovered() { left.rect_filled(fr, CornerRadius::same(5), hover); } diff --git a/crates/ui-egui/src/present_tests.rs b/crates/ui-egui/src/present_tests.rs index 73bccaed..93729922 100644 --- a/crates/ui-egui/src/present_tests.rs +++ b/crates/ui-egui/src/present_tests.rs @@ -137,6 +137,8 @@ fn preview_pane_opens_follows_edits_and_the_selection() { assert!(u.app.ui.preview.is_none()); } +/// Asserts the preview's device viewport and scroll range, wheel and drag scrolling, the +/// end clamp with spring-back, and that navigation resets scroll to the top. #[test] fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { let (mut u, a, b, _) = setup(); @@ -150,6 +152,7 @@ fn the_frame_scrolls_on_the_device_screen_by_wheel_and_drag() { Event::PointerMoved(c), Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -60.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -203,6 +206,7 @@ fn a_top_level_frame_taller_than_the_phone_scrolls_without_an_overflow_setting() Event::PointerMoved(c), Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -60.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), diff --git a/crates/ui-egui/src/present_view_tests.rs b/crates/ui-egui/src/present_view_tests.rs index 42647dc0..361a4caf 100644 --- a/crates/ui-egui/src/present_view_tests.rs +++ b/crates/ui-egui/src/present_view_tests.rs @@ -135,6 +135,8 @@ fn num(v: &Value) -> f64 { v.as_f64().unwrap() } +/// Asserts presenter fit/fit-width/fill/actual scaling, pan ranges, wheel and drag panning, +/// option carry-over to the next presentation, and R restarting the flow. #[test] fn presenter_scales_by_its_options_and_pans_what_overflows() { let (mut u, big) = big(); @@ -165,6 +167,7 @@ fn presenter_scales_by_its_options_and_pans_what_overflows() { Event::PointerMoved(at), Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -80.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), @@ -271,6 +274,7 @@ fn scrolling_keeps_its_momentum_and_springs_back_from_the_ends() { assert_eq!(u.preview()["flinging"], json!(false)); } +/// Asserts wheel momentum continues after input stops and then comes to rest. #[test] fn the_wheel_coasts_after_it_stops() { let (mut u, a, ..) = setup(); @@ -281,6 +285,7 @@ fn the_wheel_coasts_after_it_stops() { Event::PointerMoved(c), Event::MouseWheel { unit: egui::MouseWheelUnit::Point, + source: egui::MouseWheelSource::Unknown, delta: vec2(0.0, -40.0), phase: egui::TouchPhase::Move, modifiers: Default::default(), diff --git a/crates/ui-egui/src/proto_canvas.rs b/crates/ui-egui/src/proto_canvas.rs index 956523ee..b5d402b4 100644 --- a/crates/ui-egui/src/proto_canvas.rs +++ b/crates/ui-egui/src/proto_canvas.rs @@ -20,8 +20,9 @@ struct Drag { pos: Pos2, } +/// Stable egui id for prototype connection dragging. fn drag_id() -> Id { - Id::new("proto_canvas_drag") + Id::unique("proto_canvas_drag") } /// One drawn connection. @@ -59,9 +60,11 @@ struct Sources { ids: Arc>, } +/// Return the cached subtree nodes with interactions for `page`, recomputing only when the +/// document pointer or page changes. pub(crate) fn sources(ctx: &egui::Context, doc: &Arc, page: NodeId) -> Arc> { ctx.data_mut(|d| { - let c = d.get_temp_mut_or_default::(Id::new("proto_canvas_sources")); + let c = d.get_temp_mut_or_default::(Id::unique("proto_canvas_sources")); if !(c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page)) { let ids = doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| !n.interactions.is_empty())).collect(); *c = Sources { doc: Some(doc.clone()), page: Some(page), ids: Arc::new(ids) }; diff --git a/crates/ui-egui/src/proto_editor.rs b/crates/ui-egui/src/proto_editor.rs index 76ee3044..7163807e 100644 --- a/crates/ui-egui/src/proto_editor.rs +++ b/crates/ui-egui/src/proto_editor.rs @@ -32,7 +32,7 @@ pub struct Slot { impl Slot { /// A widget id for `what` of this action. pub fn id(&self, what: &'static str) -> Id { - Id::new((what, self.node, self.k, &self.path)) + Id::unique((what, self.node, self.k, &self.path)) } } @@ -129,8 +129,9 @@ fn unavailable(label: &str) -> &'static str { } } +/// Stable egui id for a node interaction's open action path. fn open_id(cx: &Cx) -> Id { - Id::new(("proto_open_action", cx.node, cx.k)) + Id::unique(("proto_open_action", cx.node, cx.k)) } /// The open action (the conditionals around it are open too); the first one by default. diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index 80d49616..63c152e1 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -78,6 +78,9 @@ fn condition(u: &Ui, btn: NodeId, block: usize) -> String { blocks[block].condition.as_ref().map(|c| c.display(&doc.variables)).unwrap_or_default() } +/// Asserts the interaction editor shows conditional blocks and nested actions, restarts a +/// condition when a variable of another type is picked, ignores a right-hand-side variable pick +/// for a complex condition, and one undo restores the previous condition. #[test] fn the_interaction_editor_shows_conditionals_and_picks_their_variables() { let (mut s, _, b, btn) = setup(); @@ -95,7 +98,7 @@ fn the_interaction_editor_shows_conditionals_and_picks_their_variables() { u.app.ui.right_tab = crate::RightTab::Prototype; crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); // The conditional open, then its nested set variable, then the else-if as an expression. - let open = egui::Id::new(("proto_open_action", btn, 0usize)); + let open = egui::Id::unique(("proto_open_action", btn, 0usize)); for path in [vec![1usize], vec![1, 0, 1], vec![1, 2, 0]] { u.ctx.data_mut(|d| d.insert_temp(open, path)); u.frames(3, Vec::new); @@ -154,6 +157,8 @@ fn the_builder_takes_variable_operator_value_conditions() { } } +/// Asserts invalid typed conditions keep their draft and show errors without saving, a valid +/// one clears the error and saves, and Enter does not leak to shortcuts. #[test] fn typed_conditions_are_checked_and_errors_show_until_fixed() { let (mut s, _, b, btn) = setup(); @@ -169,7 +174,7 @@ fn typed_conditions_are_checked_and_errors_show_until_fixed() { let mut u = Ui::new(s); u.app.ui.right_tab = crate::RightTab::Prototype; crate::proto_panel::open_editor(&u.ctx, Some((btn, 0))); - u.ctx.data_mut(|d| d.insert_temp(egui::Id::new(("proto_open_action", btn, 0usize)), vec![1usize])); + u.ctx.data_mut(|d| d.insert_temp(egui::Id::unique(("proto_open_action", btn, 0usize)), vec![1usize])); u.frames(3, Vec::new); // The else if is complex: it shows as an expression field. let field = Slot { node: btn, k: 0, path: vec![1] }.id("proto_cond_expr").with(1usize); diff --git a/crates/ui-egui/src/proto_panel.rs b/crates/ui-egui/src/proto_panel.rs index 747dbaf6..1b2e2df9 100644 --- a/crates/ui-egui/src/proto_panel.rs +++ b/crates/ui-egui/src/proto_panel.rs @@ -22,7 +22,7 @@ use crate::widgets::{self, FIELD_H, FieldEdit, Lead, PAD}; /// The interaction whose editor popover is open: (layer, index). fn editor_id() -> Id { - Id::new("proto_editor") + Id::unique("proto_editor") } pub fn open_editor(ctx: &egui::Context, target: Option<(NodeId, usize)>) { @@ -378,17 +378,17 @@ fn flow_row(app: &mut FigApp, ui: &mut Ui, f: &proto::Flow) { let (r, _) = ui.allocate_exact_size(vec2(w, 32.0), Sense::hover()); let button = |dx: f32| Rect::from_center_size(pos2(dx, r.center().y), vec2(24.0, 24.0)); let play = button(r.min.x + PAD + 8.0); - if icon_button(ui, Id::new(("flow_play", f.start)), play, Icon::Play, tr("Present this flow")) { + if icon_button(ui, Id::unique(("flow_play", f.start)), play, Icon::Play, tr("Present this flow")) { app.run("proto.present", json!({"start": f.start})); } let name_rect = Rect::from_min_max(pos2(play.max.x + 6.0, r.center().y - 9.0), pos2(r.max.x - 64.0, r.center().y + 9.0)); - if let Some(n) = widgets::text_field_inline(ui, Id::new(("flow_name", f.start)), &f.name, name_rect, regular(11.0)) { + if let Some(n) = widgets::text_field_inline(ui, Id::unique(("flow_name", f.start)), &f.name, name_rect, regular(11.0)) { app.run("proto.flow.rename", json!({"start": f.start, "name": n})); } - if icon_button(ui, Id::new(("flow_preview", f.start)), button(r.max.x - 44.0), Icon::Preview, tr("Preview this flow")) { + if icon_button(ui, Id::unique(("flow_preview", f.start)), button(r.max.x - 44.0), Icon::Preview, tr("Preview this flow")) { app.run("proto.preview", json!({"start": f.start})); } - if icon_button(ui, Id::new(("flow_rm", f.start)), button(r.max.x - 20.0), Icon::Minus, tr("Remove starting point")) { + if icon_button(ui, Id::unique(("flow_rm", f.start)), button(r.max.x - 20.0), Icon::Minus, tr("Remove starting point")) { app.run("proto.flow.remove", json!({"start": f.start})); } } @@ -426,6 +426,8 @@ fn flows_overview(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } } +/// Draw prototype settings: device and rotation, device colour, custom size, responsive +/// toggle and page background, running `proto.settings`. fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { if widgets::section_header(ui, tr("Prototype settings"), &[(Icon::Preview, tr("Open prototype preview"))], false).is_some() { app.run("proto.preview", json!({})); @@ -479,7 +481,7 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { } row(ui, 32.0, |ui| { let clicked = widgets::checkbox(ui, settings.responsive, tr("Responsive")); - ui.interact(ui.min_rect(), Id::new("proto_responsive_tip"), Sense::hover()).on_hover_text(tr(RESPONSIVE_TIP)); + ui.interact(ui.min_rect(), Id::unique("proto_responsive_tip"), Sense::hover()).on_hover_text(tr(RESPONSIVE_TIP)); if clicked { app.run("proto.settings", json!({"responsive": !settings.responsive})); } @@ -487,7 +489,7 @@ fn settings(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId) { widgets::sublabels(ui, tr("Background"), None); row(ui, 32.0, |ui| { widgets::swatch(ui, &Paint::solid(bg), 16.0); - if let Some(h) = widgets::text_field(ui, Id::new("proto_bg"), &bg.to_hex(), 96.0, "") + if let Some(h) = widgets::text_field(ui, Id::unique("proto_bg"), &bg.to_hex(), 96.0, "") && let Some(c) = figcraft_color::Color::from_hex_or_name(&h) { app.run("proto.settings", json!({"background": c.to_css_hex()})); @@ -539,7 +541,7 @@ fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { row(ui, 32.0, |ui| { for (key, lead, v) in [("width", "W", d.width), ("height", "H", d.height)] { if let Some(FieldEdit::Set(x) | FieldEdit::Scrub(x, true)) = - widgets::number_field(ui, Id::new(("proto_custom", key)), Lead::Text(lead), Some(v), "", col, 1.0) + widgets::number_field(ui, Id::unique(("proto_custom", key)), Lead::Text(lead), Some(v), "", col, 1.0) && let Some(x) = proto::PrototypeSettings::custom_side(x) && x != v { @@ -552,6 +554,7 @@ fn custom_size(app: &mut FigApp, ui: &mut Ui, model: Option) { }); } +/// Show, create or preview the flow starting at `frame` and edit its description. fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, frame: NodeId) { let flows = flows_of(doc, page); let mine = flows.iter().find(|f| f.start == frame).cloned(); @@ -570,7 +573,7 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra if let Some(f) = mine { flow_row(app, ui, &f); row(ui, 32.0, |ui| { - if let Some(d) = widgets::text_field(ui, Id::new(("flow_desc", frame)), &f.description, ui.available_width(), tr("Add a description")) + if let Some(d) = widgets::text_field(ui, Id::unique(("flow_desc", frame)), &f.description, ui.available_width(), tr("Add a description")) && d != f.description { app.run("proto.flow.rename", json!({"start": frame, "description": d})); @@ -579,6 +582,8 @@ fn flow_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, fra } } +/// List a node's interactions, add a default click navigation (or Back), open the editor +/// popover and remove interactions. fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, node: &Node) { let t = Tokens::get(ui.ctx()); let id = node.id; @@ -597,7 +602,7 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, 44.0), Sense::hover()); let body = Rect::from_min_max(pos2(r.min.x + PAD - 6.0, r.min.y + 2.0), pos2(r.max.x - 40.0, r.max.y - 2.0)); - let resp = ui.interact(body, ui.id().with(("proto_it", id, i)), Sense::click()); + let resp = ui.interact(body, ui.scope_id().with(("proto_it", id, i)), Sense::click()); let active = open == Some((id, i)); if active { ui.painter().rect_filled(body, CornerRadius::same(5), t.selected_row); @@ -618,7 +623,7 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod open_editor(ui.ctx(), if active { None } else { Some((id, i)) }); } let rm = Rect::from_center_size(pos2(r.max.x - 20.0, r.center().y), vec2(24.0, 24.0)); - if icon_button(ui, ui.id().with(("proto_rm", id, i)), rm, Icon::Minus, tr("Remove interaction")) { + if icon_button(ui, ui.scope_id().with(("proto_rm", id, i)), rm, Icon::Minus, tr("Remove interaction")) { open_editor(ui.ctx(), None); app.run("proto.interaction.remove", json!({"ids": targets_for(app, id), "index": i})); return; @@ -644,7 +649,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N let screen = ctx.content_rect(); let pos = pos2(at.x - width, at.y.min(screen.max.y - 420.0).max(screen.min.y + 48.0)); let mut close = false; - egui::Area::new(Id::new("proto_editor_area")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| { + egui::Area::new(Id::unique("proto_editor_area")).fixed_pos(pos).order(egui::Order::Foreground).show(ctx, |ui| { egui::Frame::new() .fill(t.panel) .stroke(Stroke::new(1.0, t.border)) @@ -657,7 +662,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: N let (hr, _) = ui.allocate_exact_size(vec2(width, 40.0), Sense::hover()); ui.painter().text(pos2(hr.min.x + PAD, hr.center().y), Align2::LEFT_CENTER, tr("Interaction"), semibold(11.0), t.text); let x = Rect::from_center_size(pos2(hr.max.x - 20.0, hr.center().y), vec2(24.0, 24.0)); - let xr = ui.interact(x, ui.id().with("proto_editor_close"), Sense::click()).tip(tr("Close")); + let xr = ui.interact(x, ui.scope_id().with("proto_editor_close"), Sense::click()).tip(tr("Close")); if xr.hovered() { ui.painter().rect_filled(x, CornerRadius::same(5), t.hover); } @@ -696,14 +701,17 @@ fn animates(a: &Action) -> bool { ) } +/// Stable egui id for the prototype key-capture flag. fn capture_id() -> Id { - Id::new("proto_key_capture") + Id::unique("proto_key_capture") } fn capturing(ctx: &egui::Context) -> bool { ctx.data(|d| d.get_temp::(capture_id())).unwrap_or(false) } +/// Draw an interaction's trigger controls: trigger picker, mouse/delay fields, key capture +/// and controller-button selection, patching the interaction. fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Trigger"), None); @@ -729,7 +737,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte Trigger::MouseEnter | Trigger::MouseLeave | Trigger::MouseDown | Trigger::MouseUp => { row(ui, 32.0, |ui| { widgets::label(ui, tr("Delay"), t.text_secondary); - let id = Id::new(("proto_mouse_delay", node, k)); + let id = Id::unique(("proto_mouse_delay", node, k)); if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = widgets::number_field(ui, id, Lead::None, Some(f64::from(it.delay_ms)), "ms", 96.0, 10.0) && v.is_finite() @@ -742,7 +750,7 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte row(ui, 32.0, |ui| { widgets::label(ui, tr("Delay"), t.text_secondary); if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = - widgets::number_field(ui, Id::new(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) + widgets::number_field(ui, Id::unique(("proto_delay", node, k)), Lead::None, Some(f64::from(*ms)), "ms", 96.0, 10.0) && v.is_finite() { patch(app, node, k, json!({"trigger": {"type": "afterDelay", "ms": v.clamp(0.0, 600_000.0).round() as u32}})); @@ -1021,6 +1029,8 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: } } +/// Draw an interaction's animation controls: transition, direction, easing, duration and +/// custom easing fields, plus an animated curve preview. fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Interaction) { let t = Tokens::get(ui.ctx()); widgets::sublabels(ui, tr("Animation"), None); @@ -1082,7 +1092,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In ui.label(egui::RichText::new(ms).font(regular(11.0)).color(t.text_secondary)) .tip(tr("A spring plays until it settles: its mass, stiffness and damping set how long")); } else if let Some(e) = - widgets::number_field(ui, Id::new(("proto_dur", node, k)), Lead::None, Some(f64::from(it.duration_ms)), "ms", 72.0, 10.0) + widgets::number_field(ui, Id::unique(("proto_dur", node, k)), Lead::None, Some(f64::from(it.duration_ms)), "ms", 72.0, 10.0) { match e { FieldEdit::Set(v) => { @@ -1112,7 +1122,7 @@ fn animation_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &In let box_r = Rect::from_min_max(pos2(r.min.x + PAD, r.min.y + 4.0), pos2(r.max.x - PAD, r.max.y - 4.0)); ui.painter().rect_filled(box_r, CornerRadius::same(5), t.field); let period = it.easing.seconds(it.duration_ms).max(0.05) + CURVE_HOLD; - let elapsed = curve_play(ui, Id::new(("proto_curve", node, k)), Id::new((format!("{:?}", it.easing), it.duration_ms)), resp.hovered()); + let elapsed = curve_play(ui, Id::unique(("proto_curve", node, k)), Id::unique((format!("{:?}", it.easing), it.duration_ms)), resp.hovered()); let phase = if resp.hovered() || elapsed < period * CURVE_PLAYS { ui.ctx().request_repaint_after(std::time::Duration::from_millis(33)); ((elapsed % period) / (period - CURVE_HOLD)).min(1.0) @@ -1147,12 +1157,13 @@ fn custom_easing_fields(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, e for i in 0..count { let (Some(name), Some(v), Some(step)) = (names.get(i), vals.get(i), steps.get(i)) else { continue }; let lead = if count == 4 { Lead::Text(name) } else { Lead::None }; - let edit = widgets::number_field(ui, Id::new(("proto_ease", node, k, i)), lead, Some((v * 100.0).round() / 100.0), "", w, *step).map( - |e| match e { - FieldEdit::Set(x) => (x, true, false), - FieldEdit::Scrub(x, done) => (x, done, true), - }, - ); + let edit = + widgets::number_field(ui, Id::unique(("proto_ease", node, k, i)), lead, Some((v * 100.0).round() / 100.0), "", w, *step).map(|e| { + match e { + FieldEdit::Set(x) => (x, true, false), + FieldEdit::Scrub(x, done) => (x, done, true), + } + }); if let Some((x, done, scrub)) = edit && x.is_finite() { diff --git a/crates/ui-egui/src/proto_reorder_tests.rs b/crates/ui-egui/src/proto_reorder_tests.rs index 1a061399..d69be4f6 100644 --- a/crates/ui-egui/src/proto_reorder_tests.rs +++ b/crates/ui-egui/src/proto_reorder_tests.rs @@ -44,8 +44,9 @@ fn setup() -> (Ui, NodeId) { (u, btn) } +/// Stable id for a prototype node's open action path in the reorder tests. fn open_id(btn: NodeId) -> egui::Id { - egui::Id::new(("proto_open_action", btn, 0usize)) + egui::Id::unique(("proto_open_action", btn, 0usize)) } /// The kinds of the actions at `prefix` (the interaction's, or a block's). diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index 2243d11a..a084b9d0 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -78,6 +78,8 @@ impl FigApp { } } +/// Draw the right panel: people, present and share controls, Design/Prototype tabs and zoom +/// menu, then route the scroll area to comments, frame presets, design, prototype or inspect. pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); @@ -87,7 +89,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let label = ui.painter().layout_no_wrap(tr("Share").to_string(), semibold(11.5), t.accent_on); let sw = (label.size().x + 24.0).max(56.0); let share = Rect::from_min_size(pos2(top.max.x - 14.0 - sw, top.center().y - 16.0), vec2(sw, 32.0)); - let sresp = ui.interact(share, ui.id().with("share"), Sense::click()).tip(tr("Share: live session, export")); + let sresp = ui.interact(share, ui.scope_id().with("share"), Sense::click()).tip(tr("Share: live session, export")); ui.painter().rect_filled(share, CornerRadius::same(6), if sresp.hovered() { t.accent_hover } else { t.accent }); ui.painter().galley(share.center() - label.size() / 2.0, label, t.accent_on); crate::live::share_popover(app, &sresp); @@ -96,8 +98,8 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { let (tri_r, chev_r) = (Rect::from_min_max(play.min, pos2(play.max.x - 16.0, play.max.y)), Rect::from_min_max(pos2(play.max.x - 16.0, play.min.y), play.max)); let present_keys = crate::toolbar::pretty_shortcut(crate::shortcuts::shortcut("proto.present").unwrap_or("")); - let presp = ui.interact(tri_r, ui.id().with("play"), Sense::click()).tip(format!("{} {present_keys}", tr("Present"))); - let mresp = ui.interact(chev_r, ui.id().with("play_menu"), Sense::click()).tip(tr("Present or preview")); + let presp = ui.interact(tri_r, ui.scope_id().with("play"), Sense::click()).tip(format!("{} {present_keys}", tr("Present"))); + let mresp = ui.interact(chev_r, ui.scope_id().with("play_menu"), Sense::click()).tip(tr("Present or preview")); if presp.hovered() || mresp.hovered() { ui.painter().rect_filled(play, CornerRadius::same(5), t.hover); } @@ -127,7 +129,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { for (tab, label) in [(RightTab::Design, tr("Design")), (RightTab::Prototype, tr("Prototype"))] { let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), t.text); let r = Rect::from_min_size(pos2(x, tabs.center().y - 12.0), vec2(galley.size().x + 16.0, 24.0)); - let resp = ui.interact(r, ui.id().with(("rtab", label)), Sense::click()); + let resp = ui.interact(r, ui.scope_id().with(("rtab", label)), Sense::click()); let active = app.ui.right_tab == tab; a11y::tab(&resp, label, active); if active { @@ -145,7 +147,7 @@ pub fn right_panel(app: &mut FigApp, ui: &mut Ui) { // Zoom menu. let zoom = app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0); let zr = Rect::from_min_size(pos2(tabs.max.x - 64.0, tabs.center().y - 12.0), vec2(56.0, 24.0)); - let zresp = ui.interact(zr, ui.id().with("zoommenu"), Sense::click()); + let zresp = ui.interact(zr, ui.scope_id().with("zoommenu"), Sense::click()); // The value is only spelled out while a screen reader reads the tree. let value = if a11y::active(&zresp.ctx) { format!("{}%", (zoom * 100.0).round()) } else { String::new() }; a11y::combo(&zresp, tr("Zoom"), &value, a11y::menu_open(&zresp)); @@ -393,6 +395,8 @@ fn design(app: &mut FigApp, ui: &mut Ui) { crate::export_panel::section(app, ui, &doc, &sel); } +/// Draw the page section: background and colour profile, local variables/styles, and the +/// page export section. fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let t = Tokens::get(ui.ctx()); let Ok(page) = app.session.page() else { return }; @@ -408,7 +412,7 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { if widgets::swatch(ui, &p, 16.0).clicked() { open_picker = true; } - if let Some(h) = widgets::text_field(ui, Id::new("pagebg"), &bg.to_hex(), 96.0, "") { + if let Some(h) = widgets::text_field(ui, Id::unique("pagebg"), &bg.to_hex(), 96.0, "") { new_hex = Some(h); } widgets::label(ui, "100 %", t.text); @@ -455,6 +459,8 @@ fn page_section(app: &mut FigApp, ui: &mut Ui, doc: &Document) { crate::export_panel::page_section(app, ui, doc, page); } +/// Draw the selection header: type name (or Mixed), frame/group conversion, orientation +/// swap and frame presets, component/mask/boolean quick actions, and instance details. fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let t = Tokens::get(ui.ctx()); let label = match common(doc, sel, |n| n.kind.label()) { @@ -479,7 +485,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let w = ui.available_width(); let (r, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let lr = Rect::from_min_size(pos2(r.min.x + 8.0, r.center().y - 12.0), vec2(120.0, 24.0)); - let resp = ui.interact(lr, ui.id().with("typedd"), Sense::click()); + let resp = ui.interact(lr, ui.scope_id().with("typedd"), Sense::click()); if resp.hovered() { ui.painter().rect_filled(lr, CornerRadius::same(5), t.hover); } @@ -497,7 +503,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } if is_frame { let sr = Rect::from_min_size(pos2(lr.max.x + 4.0, r.center().y - 12.0), vec2(24.0, 24.0)); - let sresp = ui.interact(sr, ui.id().with("swap_orient"), Sense::click()).tip(tr("Swap orientation")); + let sresp = ui.interact(sr, ui.scope_id().with("swap_orient"), Sense::click()).tip(tr("Swap orientation")); if sresp.hovered() { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } @@ -536,7 +542,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { for (icon, label, cmd) in actions.into_iter().rev() { let br = Rect::from_min_size(pos2(x - 24.0, r.center().y - 12.0), vec2(24.0, 24.0)); let tip = if cmd == "bool.union" { label } else { crate::shortcuts::tip(label, cmd) }; - let bresp = ui.interact(br, ui.id().with(("qa", cmd)), Sense::click()).tip(tip); + let bresp = ui.interact(br, ui.scope_id().with(("qa", cmd)), Sense::click()).tip(tip); if bresp.hovered() { ui.painter().rect_filled(br, CornerRadius::same(5), t.hover); } @@ -589,6 +595,8 @@ fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui.add_space(8.0); } +/// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the +/// absolute-position toggle and frame constraints. fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let col = widgets::col_width(ui.available_width()); @@ -640,9 +648,9 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] let absolute = common(doc, sel, |n| n.child_layout.absolute); widgets::sublabels(ui, tr("Position"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field_mixed(ui, Id::new("pos_x"), Lead::Text("X"), x, "", col, 1.0); + let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), x, "", col, 1.0); app.field_of(&ids, "x", e, x_of, |v| json!(v)); - let e = widgets::number_field_mixed(ui, Id::new("pos_y"), Lead::Text("Y"), y, "", col, 1.0); + let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), y, "", col, 1.0); app.field_of(&ids, "y", e, y_of, |v| json!(v)); // Children of auto layout frames can ignore the layout (absolute position). if parent_al { @@ -662,7 +670,7 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId] }); widgets::sublabels(ui, tr("Rotation"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field_mixed(ui, Id::new("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0); + let e = widgets::number_field_mixed(ui, Id::unique("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0); app.field_of(&ids, "rotation", e, rot_of, |v| json!(v)); let items = [ (Icon::Rotate, tr("Rotate 90° right")), @@ -745,7 +753,7 @@ fn arc_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) -> widgets::section_header(ui, tr("Arc"), &[], false); let col = widgets::col_width(ui.available_width()); let field = |app: &mut FigApp, ui: &mut Ui, key: &str, icon: Icon, name: &str, value: Option, suffix: &str| { - crate::variable_ui::number_field(app, ui, &ids, key, Id::new(("arc", key)), Lead::Icon(icon, name), value, suffix, col) + crate::variable_ui::number_field(app, ui, &ids, key, Id::unique(("arc", key)), Lead::Icon(icon, name), value, suffix, col) }; widgets::sublabels(ui, tr("Start"), Some(tr("Sweep"))); row(ui, 32.0, |ui| { @@ -787,10 +795,10 @@ fn points_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) let col = widgets::col_width(ui.available_width()); widgets::sublabels(ui, tr("Count"), all_stars.then_some(tr("Ratio"))); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("pts_count"), Lead::Icon(Icon::Polygon, tr("Count")), count, "", col, 1.0); + let e = widgets::number_field(ui, Id::unique("pts_count"), Lead::Icon(Icon::Polygon, tr("Count")), count, "", col, 1.0); app.field(&ids, "pointCount", e, |v| json!(v.round().clamp(3.0, 120.0))); if all_stars { - let e = widgets::number_field(ui, Id::new("pts_ratio"), Lead::Icon(Icon::Star, tr("Ratio")), ratio, "%", col, 1.0); + let e = widgets::number_field(ui, Id::unique("pts_ratio"), Lead::Icon(Icon::Star, tr("Ratio")), ratio, "%", col, 1.0); app.field(&ids, "innerRadius", e, |v| json!((v / 100.0).clamp(0.001, 1.0))); } }); @@ -807,6 +815,8 @@ fn pretty(s: &str) -> String { } } +/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and +/// the blend-mode row. fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let ids = sel.to_vec(); let visible = common(doc, sel, |n| n.visible).unwrap_or(true); @@ -827,7 +837,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { crate::variable_ui::open_at_pointer(ui, &ids, "visible", VariableType::Boolean); } if hdr == Some(2) { - ui.data_mut(|d| d.insert_temp(Id::new("blend_open"), !d.get_temp::(Id::new("blend_open")).unwrap_or(false))); + ui.data_mut(|d| d.insert_temp(Id::unique("blend_open"), !d.get_temp::(Id::unique("blend_open")).unwrap_or(false))); } let col = widgets::col_width(ui.available_width()); crate::variable_ui::bound_row(app, ui, &ids, "visible", VariableType::Boolean, Lead::Icon(Icon::Eye, tr("Visible")), col * 2.0 + 8.0); @@ -858,7 +868,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius"))); row(ui, 32.0, |ui| { let lead = Lead::Icon(Icon::Opacity, tr("Opacity")); - let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "opacity", Id::new("ap_op"), lead, op, "%", col); + let e = crate::variable_ui::number_field_mixed(app, ui, &ids, "opacity", Id::unique("ap_op"), lead, op, "%", col); app.field_of(&ids, "opacity", e, op_of, |v| json!((v / 100.0).clamp(0.0, 1.0))); if radius_ok { let e = crate::variable_ui::number_field_mixed( @@ -866,7 +876,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui, &ids, "cornerRadius", - Id::new("ap_rad"), + Id::unique("ap_rad"), Lead::Icon(Icon::CornerRadius, tr("Corner radius")), r.flatten(), "", @@ -875,15 +885,15 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { // A layer with corners of its own starts from its largest. let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)); app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0))); - let open = ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false); + let open = ui.data(|d| d.get_temp::(Id::unique("indep_corners"))).unwrap_or(false); // Polygons and stars have no corners of their own: the button opens only Corner smoothing. let tip = if indep_ok { tr("Individual corners") } else { tr("Corner smoothing") }; if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tip).clicked() { - ui.data_mut(|d| d.insert_temp(Id::new("indep_corners"), !open)); + ui.data_mut(|d| d.insert_temp(Id::unique("indep_corners"), !open)); } } }); - if smooth_ok && ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false) { + if smooth_ok && ui.data(|d| d.get_temp::(Id::unique("indep_corners"))).unwrap_or(false) { let radii = common(doc, sel, |n| n.corners.radii); // `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. @@ -902,7 +912,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ui, &ids, key, - Id::new(("corner", *k)), + Id::unique(("corner", *k)), Lead::Icon(*icon, name), radii.map(|r| r[*k]), "", @@ -915,7 +925,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let sm = common(doc, sel, |n| (n.corners.smoothing * 100.0).round()); widgets::sublabels(ui, tr("Corner smoothing"), None); row(ui, 32.0, |ui| { - let e = widgets::number_field(ui, Id::new("smoothing"), Lead::None, sm, "%", col, 1.0); + let e = widgets::number_field(ui, Id::unique("smoothing"), Lead::None, sm, "%", col, 1.0); app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); if widgets::button(ui, tr("iOS"), false, true).on_hover_text(tr("Set corner smoothing to 60%")).clicked() { app.set_props(&ids, json!({"cornerSmoothing": 0.6})); @@ -923,7 +933,7 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { }); } let blend = common(doc, sel, |n| n.blend); - let show_blend = ui.data(|d| d.get_temp::(Id::new("blend_open"))).unwrap_or(false) + let show_blend = ui.data(|d| d.get_temp::(Id::unique("blend_open"))).unwrap_or(false) || blend.is_some_and(|b| !matches!(b, BlendMode::Normal | BlendMode::PassThrough)); if show_blend { widgets::sublabels(ui, tr("Blend mode"), None); @@ -992,7 +1002,7 @@ fn variable_modes(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) let name = widgets::elide(ui, &c.name, name_w); ui.painter().text(pos2(rect.min.x, rect.center().y), Align2::LEFT_CENTER, &name, regular(11.0), t.text); if name != c.name || r.library.is_some() { - ui.interact(rect, Id::new(("var_mode_name", &c.id)), Sense::hover()).on_hover_text(&hover); + ui.interact(rect, Id::unique(("var_mode_name", &c.id)), Sense::hover()).on_hover_text(&hover); } let resp = widgets::dropdown(ui, &label, pick_w, None); menu_popup(&resp).width(180.0).show(|ui| { @@ -1051,13 +1061,13 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: match fills.as_ref().and_then(|f| f.first()) { Some(p) => { widgets::paint_swatch(ui.painter(), sw, p); - if ui.interact(sw.expand(2.0), Id::new("range_swatch"), Sense::click()).clicked() { + if ui.interact(sw.expand(2.0), Id::unique("range_swatch"), Sense::click()).clicked() { app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end }); } if let Some(c) = p.solid_color() { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_r)); if let Some(h) = - widgets::text_field_inline(&mut child, Id::new("range_hex"), &c.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0)) + widgets::text_field_inline(&mut child, Id::unique("range_hex"), &c.to_hex(), text_r.shrink2(vec2(0.0, 4.0)), regular(11.0)) && let Some(nc) = figcraft_color::Color::from_hex_or_name(&h) { app.run("text.style", json!({"id": id, "start": start, "end": end, "textFills": [nc.to_css_hex()]})); @@ -1068,7 +1078,7 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: } None => { ui.painter().text(pos2(text_r.min.x, r.center().y), Align2::LEFT_CENTER, tr("Mixed"), regular(11.0), t.text_secondary); - if ui.interact(r, Id::new("range_mixed"), Sense::click()).clicked() { + if ui.interact(r, Id::unique("range_mixed"), Sense::click()).clicked() { app.ui.picker = Some(crate::colorpicker::PickerTarget::TextRange { id, start, end }); } } @@ -1080,6 +1090,8 @@ fn range_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: NodeId, start: }); } +/// Draw a selected-text range fill, or the fill/stroke paint rows with add, style, reorder, +/// stroke align/weight, per-side weights and advanced stroke settings. fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: bool) { if fills && let Some((id, a, b)) = figcraft_engine::textedit::selected_range(&app.session) @@ -1164,7 +1176,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: ui, &ids, "strokeWeight", - Id::new("stroke_w"), + Id::unique("stroke_w"), Lead::Icon(Icon::Menu, tr("Stroke weight")), weight, "", @@ -1190,7 +1202,7 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: } }); } - let adv_id = Id::new("stroke_adv"); + let adv_id = Id::unique("stroke_adv"); let open = ui.data(|d| d.get_temp::(adv_id)).unwrap_or(false); if icons::button(ui, Icon::Dots, 24.0, open, tr("Advanced stroke")).clicked() { ui.data_mut(|d| d.insert_temp(adv_id, !open)); @@ -1205,13 +1217,13 @@ fn paints(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], fills: row(ui, 32.0, |ui| { for (k, (label, field)) in fields.clone().enumerate().skip(first).take(2) { let v = sides.get(k).copied(); - let e = crate::variable_ui::number_field(app, ui, &ids, field, Id::new(("stroke_side", k)), Lead::Text(label), v, "", col); + let e = crate::variable_ui::number_field(app, ui, &ids, field, Id::unique(("stroke_side", k)), Lead::Text(label), v, "", col); app.field(&ids, field, e, |x| json!(x.max(0.0))); } }); } } - if ui.data(|d| d.get_temp::(Id::new("stroke_adv"))).unwrap_or(false) { + if ui.data(|d| d.get_temp::(Id::unique("stroke_adv"))).unwrap_or(false) { stroke_settings(app, ui, doc, sel); } } @@ -1369,16 +1381,16 @@ fn brush_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], brush: Option