diff --git a/Cargo.lock b/Cargo.lock index dd3e11b5..d38bc5d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1465,7 +1465,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.11.1" +version = "0.11.2" dependencies = [ "arboard", "craft-window", @@ -1491,7 +1491,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1505,7 +1505,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.11.1" +version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.11.1" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1544,7 +1544,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.11.1" +version = "0.11.2" dependencies = [ "kurbo", "serde", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.11.1" +version = "0.11.2" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-layout", @@ -1609,7 +1609,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.11.1" +version = "0.11.2" dependencies = [ "kurbo", "serde", @@ -1617,7 +1617,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1625,7 +1625,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.11.1" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1641,7 +1641,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1650,7 +1650,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1667,7 +1667,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-color", "serde", @@ -1680,7 +1680,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.11.1" +version = "0.11.2" dependencies = [ "crc32fast", "figcraft-color", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.11.1" +version = "0.11.2" dependencies = [ "crc32fast", "ctrlc", @@ -1722,7 +1722,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.11.1" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1738,7 +1738,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1752,7 +1752,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.11.1" +version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", @@ -1778,7 +1778,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.11.1" +version = "0.11.2" dependencies = [ "eframe", "egui", @@ -5882,7 +5882,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.11.1" +version = "0.11.2" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 9403f16b..39abeca3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.11.1" +version = "0.11.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -122,7 +122,8 @@ codegen-units = 4 # wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there. [profile.web-release] inherits = "release" -opt-level = "s" +# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate. +opt-level = "z" lto = "fat" codegen-units = 1 debug = false diff --git a/ROADMAP.md b/ROADMAP.md index 7c685a42..899d8d47 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -132,8 +132,9 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log -- **2026-10-25** · M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets and bounded stroke dash expansion guard untrusted input. Figma parity numbers unchanged. +- **2026-10-25** · M6: offline Penpot 2.x open/import/export through the editor, CLI and MCP using the existing document model. Unsupported layout settings retain saved positions and bounded source metadata with warnings; font binaries and toggle-overlay are reported, and provider fonts are never downloaded. Shared ZIP/JSON/token budgets and bounded stroke dash expansion guard untrusted input. Figma parity numbers unchanged. +- **2026-10-24** · Open, Save and the folder dialogs start in the folder of the last file or folder picked in the run (#1290): the Linux portal does not remember it between dialogs as the macOS and Windows panels do. A folder deleted since falls back to the system's choice. No numbers change. - **2026-10-24** · Dev Mode tail (M6, gap 16): Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Dev Mode 98% / 78%. - **2026-10-24** · Boards (M12): File ▸ New board makes a board, a file kind of its own saved in `.figcraft` and carried to people joining a live session. Sticky notes (8 colours, text that shrinks as the note grows, the author's name, Shift+Cmd/Ctrl+Enter for the next one), shapes with text (7 outlines), connectors that re-route when what they join moves (computed in the engine's normalize pass, so local edits, undo and others' edits agree), marker, highlighter and eraser, stamps with the placer's name on hover; a board toolbar, a contextual bar over the selection and the Design panel hidden until asked for; prototyping and Dev Mode refuse on boards. Design files and boards have their own marks (a pen nib, a note) on tabs, the home screen and the Open files list. Checked in the app, with two app instances in one live session, and through MCP; the web build compiles ([boards-parity](docs/boards-parity.md)). - **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. @@ -145,6 +146,10 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). - **2026-10-15** · Web clipboard (M1, #375): copy and paste in the browser go through the system clipboard. Numbers unchanged. - **2026-10-15** · Alt-drag copies snap to their original and Duplicate (Cmd+D) repeats the last copy's offset (M1, #283). Numbers unchanged. - **2026-10-22** · `fill` and `stroke` props accept `"none"` and `null` (M1, #1036): `node.create`, `node.set` and the other prop appliers map them to an empty fills/strokes list, as `fills: []` already did, so an outline-only shape is one call (agents write `"none"` for no paint; the empty-list form was undocumented). The parameter docs (`KEYS`, shown by `list_commands` and the CLI) and `docs/mcp.md` say so, and the error message names the accepted forms. `fill_none_tests` covers create, set, undo, text and the error. Numbers unchanged. +- **2026-10-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 3fe17e80..0c04b732 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -20,6 +20,10 @@ impl Args { let mut bad: Option = None; 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 ef9e244b..21252e5b 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -109,15 +109,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}"); @@ -128,13 +137,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 = @@ -142,7 +151,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())), @@ -150,19 +159,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())); } @@ -171,7 +180,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())); } @@ -186,7 +195,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()))?; @@ -196,10 +205,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/it/cli.rs b/apps/figcraft-cli/tests/it/cli.rs index c51cba3a..ae00ceb7 100644 --- a/apps/figcraft-cli/tests/it/cli.rs +++ b/apps/figcraft-cli/tests/it/cli.rs @@ -47,6 +47,38 @@ fn penpot_import_notes_reach_convert_export_and_render() { let _ = std::fs::remove_dir_all(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/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"); +} 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); diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index f042e23a..7ce1df5e 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 2216b6fa..6fedad83 100644 --- a/crates/color/src/tests.rs +++ b/crates/color/src/tests.rs @@ -115,6 +115,15 @@ fn infinite_channels_clamp() { 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"); +} + #[test] fn malformed_alpha_or_extra_args_rejected() { assert_eq!(Color::parse_css("rgba(0,0,0,abc)"), None); diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 928dca55..41e233c4 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -560,6 +560,16 @@ fn is_ancestor_holds_for_very_deep_chains() { 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)]); +} + #[test] fn many_small_vector_subpaths_build_and_stroke_without_rescans() { // Many closed subpaths whose last point repeats the start (#F-332), a long open chain (#F-334) 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), 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/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)); + } +} 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(); 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(); 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/tests.rs b/crates/engine/src/tests.rs index 7ab7a857..72236a55 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; 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 2dfc774c..47dfdf5b 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -249,7 +249,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 { @@ -580,6 +580,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/engine/src/tools.rs b/crates/engine/src/tools.rs index eb20de49..cc52caec 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -971,6 +971,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/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)"); +} 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/layout.rs b/crates/text/src/layout.rs index 54bee7e3..3c64d4ab 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/lib.rs b/crates/text/src/lib.rs index 548a7b66..26ffdb7a 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, 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); + } + } +} diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index a7dbfff0..2f6c63b2 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" @@ -178,6 +179,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" @@ -512,7 +514,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ń" @@ -646,6 +648,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" @@ -667,6 +670,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" @@ -683,7 +687,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ą" @@ -717,6 +721,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" @@ -727,9 +732,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" @@ -946,7 +951,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ść" @@ -969,6 +974,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" "Pink" = "Różowy" @@ -1083,6 +1089,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ę" @@ -1122,6 +1129,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" @@ -1157,6 +1165,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°" @@ -1208,6 +1217,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" @@ -1326,6 +1336,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" @@ -1404,6 +1415,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ść" @@ -1414,6 +1426,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." @@ -1437,6 +1450,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" = "Trójkąt" @@ -1486,6 +1500,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ść" @@ -1757,6 +1772,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)." @@ -1799,6 +1815,7 @@ status = "draft — needs native review" "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" @@ -1938,6 +1955,7 @@ status = "draft — needs native review" "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.newBoard" = "Nowa tablica" "file.newFromTemplate" = "Nowy z szablonu" @@ -2081,6 +2099,8 @@ status = "draft — needs native review" "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" @@ -2189,6 +2209,7 @@ status = "draft — needs native review" "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" @@ -2197,7 +2218,9 @@ status = "draft — needs native review" "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)" diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index cb8df52d..ca46b4c8 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -556,7 +556,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()) { @@ -654,7 +654,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 }; @@ -702,6 +702,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 { @@ -1484,7 +1485,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 }; @@ -1493,10 +1496,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/context_menu_tests.rs b/crates/ui-egui/src/context_menu_tests.rs new file mode 100644 index 00000000..61df3fdb --- /dev/null +++ b/crates/ui-egui/src/context_menu_tests.rs @@ -0,0 +1,69 @@ +//! 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}; + +use egui::Pos2; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +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. +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_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())); + 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); + // 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); + 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/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))); + } +} 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]); +} 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 ffb74aef..c84db661 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -991,6 +991,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() && !crate::menus::dev_mode_refuses(app, "node.reparent") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 900f18c3..bd2dd96f 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; @@ -528,6 +530,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. @@ -707,6 +711,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 c8d0159b..fb4966ed 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -218,8 +218,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 @@ -894,11 +896,13 @@ 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"), (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"), @@ -923,10 +927,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 { @@ -965,6 +965,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" { @@ -978,11 +1001,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); @@ -991,6 +1018,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") } 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"); +} diff --git a/docs/cli.md b/docs/cli.md index 930d4db4..58bbb439 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -8,6 +8,10 @@ commands, serve MCP and measure performance. Build it with `--help` or `-h` after a subcommand (`figcraft-cli mcp --help`). Bad arguments print usage and exit with code 2; failures exit with 1. The CLI never panics on input. +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 diff --git a/docs/translating.md b/docs/translating.md index 9dfca60c..0a8fcb62 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