From cabe2e3cfb14a26cf4d5049ece9d27b812781f66 Mon Sep 17 00:00:00 2001 From: Lucas Date: Tue, 20 Oct 2026 15:58:55 +0200 Subject: [PATCH 01/20] 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 02/20] 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 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 03/20] 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 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 04/20] 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 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 05/20] 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 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 06/20] 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 07/20] 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 0b3ce29eb333ee6c611649836f4d21606778fc8c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 24 Oct 2026 14:14:37 +0700 Subject: [PATCH 08/20] 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 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 09/20] 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 1cb5f403fc76625e83ed182dd05d2f884246f855 Mon Sep 17 00:00:00 2001 From: Ridzzz0Alam Date: Sat, 24 Oct 2026 11:52:57 +0200 Subject: [PATCH 10/20] 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 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 11/20] 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 09b28f9b2d69051e2d77c259578167782deebb0c Mon Sep 17 00:00:00 2001 From: watchakorn-18k Date: Sat, 24 Oct 2026 22:34:35 +0700 Subject: [PATCH 12/20] 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 13/20] 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 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 14/20] 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 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 15/20] 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 f4d3f796d66dd04ff9a2aa19a88d3aaf10641daa Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Sat, 24 Oct 2026 22:58:07 +0200 Subject: [PATCH 16/20] 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 6e86e26d143f7968a15f03ec5d59188809007afe Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 24 Oct 2026 19:00:42 -0700 Subject: [PATCH 17/20] 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 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 18/20] 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 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 19/20] 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 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 20/20] 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))); + } +}