diff --git a/Cargo.lock b/Cargo.lock index 5e9c6f68..467e7a84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,6 +1418,7 @@ dependencies = [ "figcraft-format", "figcraft-text", "figcraft-ui-egui", + "fmv-macos-events", "image", "log", "rfd", @@ -1750,6 +1751,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "fmv-macos-events" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55" +dependencies = [ + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", +] + [[package]] name = "fnv" version = "1.0.7" diff --git a/apps/figcraft-cli/tests/cli.rs b/apps/figcraft-cli/tests/cli.rs index 390e6414..9dd52203 100644 --- a/apps/figcraft-cli/tests/cli.rs +++ b/apps/figcraft-cli/tests/cli.rs @@ -78,6 +78,27 @@ fn run_export_info_convert() { std::fs::remove_dir_all(&d).ok(); } +#[test] +fn run_takes_the_documented_gradient_shorthand() { + // #252: only MCP used to accept it; `run` said "`fills` must be paints or colours". + let d = tmp("gradient"); + let script = d.join("s.json"); + std::fs::write( + &script, + r##"[{"command":"shape.rectangle","params":{"x":0,"y":0,"width":200,"height":100}}, + {"command":"node.set","params":{"ids":[2],"fills":[{"type":"linearGradient","stops":["#ff0000","#0000ff"],"from":[0,0.5],"to":[1,0.5]}]}}]"##, + ) + .unwrap(); + let file = d.join("g.figcraft"); + let out = bin().arg("run").arg(&script).arg("--out").arg(&file).output().unwrap(); + assert!(out.status.success(), "{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + let json = d.join("g.json"); + assert!(bin().arg("convert").arg(&file).arg(&json).status().unwrap().success()); + let doc = std::fs::read_to_string(&json).unwrap(); + assert!(doc.contains("\"kind\":\"linear\"") || doc.contains("\"kind\": \"linear\""), "{doc}"); + std::fs::remove_dir_all(&d).ok(); +} + #[test] fn mcp_over_stdio() { let mut child = bin().arg("mcp").stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap(); diff --git a/apps/figcraft/Cargo.toml b/apps/figcraft/Cargo.toml index b64b68d8..bafb6da1 100644 --- a/apps/figcraft/Cargo.toml +++ b/apps/figcraft/Cargo.toml @@ -31,6 +31,13 @@ env_logger = { version = "0.11", default-features = false, features = ["auto-col [target.'cfg(windows)'.dependencies] windows = { version = "0.62", default-features = false, features = ["UI_ViewManagement", "System_UserProfile", "Foundation_Collections", "Gaming_Input"] } +# Finder double-clicks, Open With, Dock drops and `open -a FigCraft file.figcraft` arrive as a +# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This crate (also used by GridCraft, +# EffectCraft and PhotoCraft) registers the handler behind a safe API (Objective-C interop needs +# unsafe, which the workspace forbids). Pinned exactly: review the diff before bumping. +[target.'cfg(target_os = "macos")'.dependencies] +fmv-macos-events = "=0.1.0" + [dev-dependencies] wat = { workspace = true } diff --git a/apps/figcraft/src/apple_events.rs b/apps/figcraft/src/apple_events.rs new file mode 100644 index 00000000..6b792094 --- /dev/null +++ b/apps/figcraft/src/apple_events.rs @@ -0,0 +1,57 @@ +//! macOS open-documents and quit Apple events (#289). +//! +//! Finder double-clicks, Open With, drops on the Dock icon and `open -a FigCraft file.figcraft` +//! don't pass paths on the command line: LaunchServices sends the running (or just-launched) app a +//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the +//! `NSApplicationDelegate`, so AppKit answered "FigCraft cannot open files in the FigCraft Document +//! format". Handling it ourselves needs Objective-C class declarations, i.e. `unsafe`, which this +//! workspace forbids. The `fmv-macos-events` crate (also used by GridCraft, EffectCraft and +//! PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler registered before Finder's +//! launch event that leaves winit's delegate alone, behind a safe main-thread API. + +use std::path::PathBuf; + +use figcraft_ui_egui::FigApp; +use fmv_macos_events::{Event, Inbox, Registration}; +use serde_json::json; + +/// Keeps the Apple-event handlers registered; hold it until the event loop returns. +pub struct AppleEvents { + _registration: Registration, + inbox: Inbox, +} + +impl AppleEvents { + /// Register the handlers. Call on the main thread before the event loop starts, so the event + /// that launched the app (a Finder double-click) is caught too. + pub fn install() -> Self { + let (registration, inbox) = Registration::install(); + Self { _registration: registration, inbox } + } + + /// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`. + pub fn connect(&self, ctx: &egui::Context) -> Inbox { + let ctx = ctx.clone(); + self.inbox.set_wake(move || ctx.request_repaint()); + self.inbox.clone() + } +} + +/// Open the documents and act on the quit requests that arrived since the last frame. +pub fn poll(inbox: &Inbox, fig: &mut FigApp, ctx: &egui::Context) { + for e in inbox.drain() { + match e { + Event::Open(paths) => open_documents(fig, paths), + Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + } + } +} + +/// Open `paths` as File › Open does; a file that fails shows its error and the others still open. +pub fn open_documents(fig: &mut FigApp, paths: Vec) { + for p in paths { + if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() { + fig.ui.home = false; + } + } +} diff --git a/apps/figcraft/src/apple_events_tests.rs b/apps/figcraft/src/apple_events_tests.rs new file mode 100644 index 00000000..173f5385 --- /dev/null +++ b/apps/figcraft/src/apple_events_tests.rs @@ -0,0 +1,30 @@ +//! Documents macOS hands over in an open-documents Apple event (a Finder double-click, Open With, +//! a drop on the Dock icon) open as File › Open opens them (#289). + +use std::path::PathBuf; + +use figcraft_engine::Session; +use figcraft_ui_egui::{FigApp, NoServices}; +use serde_json::json; + +use crate::apple_events::open_documents; + +#[test] +fn documents_from_finder_open_and_leave_the_home_screen() { + let dir = std::env::temp_dir().join(format!("figcraft-apple-events-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("BLA-001.figcraft"); + let mut s = Session::new(); + s.execute("file.saveAs", &json!({"path": file.to_string_lossy()})).unwrap(); + + let mut app = FigApp::new(Session::empty(), Box::new(NoServices)); + app.ui.home = true; + // A file that can't be read is reported and doesn't keep the others from opening. + open_documents(&mut app, vec![dir.join("Missing.figcraft"), file.clone()]); + let paths: Vec<_> = app.session.docs.iter().map(|d| d.path.clone().map(PathBuf::from)).collect(); + assert_eq!(paths, [Some(file)]); + assert!(!app.ui.home, "the opened document shows"); + assert!(app.ui.last_error.as_deref().is_some_and(|e| e.contains("Missing.figcraft")), "{:?}", app.ui.last_error); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/apps/figcraft/src/main.rs b/apps/figcraft/src/main.rs index 0332f7d8..7b14d8e2 100644 --- a/apps/figcraft/src/main.rs +++ b/apps/figcraft/src/main.rs @@ -9,6 +9,8 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] +#[cfg(target_os = "macos")] +mod apple_events; mod args; mod control_server; mod gamepad; @@ -16,6 +18,8 @@ mod graphics; mod recovery; mod services; +#[cfg(all(test, target_os = "macos"))] +mod apple_events_tests; #[cfg(test)] mod args_tests; #[cfg(test)] @@ -43,6 +47,9 @@ struct App { title: String, /// Shared with the services, which write the copies after a panic. recovery: recovery::Shared, + /// Documents and quit requests from Finder, the Dock and Open With. + #[cfg(target_os = "macos")] + apple_events: fmv_macos_events::Inbox, } impl eframe::App for App { @@ -55,6 +62,8 @@ impl eframe::App for App { } let title = with_app(|fig| { control_server::drain_queue(&self.queue, &mut fig.control_queue); + #[cfg(target_os = "macos")] + apple_events::poll(&self.apple_events, fig, ctx); // Closing the window asks about unsaved changes (and keeps the app running while other // windows are open). figcraft_ui_egui::windows::on_close_request(fig, ctx); @@ -188,5 +197,24 @@ fn main() -> eframe::Result { let mut options = eframe::NativeOptions { viewport, renderer: eframe::Renderer::Wgpu, ..Default::default() }; graphics::configure(&mut options.wgpu_options); figcraft_ui_egui::windows::install(fig); - eframe::run_native("FigCraft", options, Box::new(move |_cc| Ok(Box::new(App { queue, ctx_slot, title: String::new(), recovery })))) + // Registered before the event loop starts, so it catches the Finder event that launched the app + // as well as later ones. Lives until the event loop returns; the app creator only borrows it. + #[cfg(target_os = "macos")] + let apple_events = apple_events::AppleEvents::install(); + #[cfg(target_os = "macos")] + let apple_events = &apple_events; + eframe::run_native( + "FigCraft", + options, + Box::new(move |_cc| { + Ok(Box::new(App { + queue, + ctx_slot, + title: String::new(), + recovery, + #[cfg(target_os = "macos")] + apple_events: apple_events.connect(&_cc.egui_ctx), + })) + }), + ) } diff --git a/apps/figcraft/src/recovery.rs b/apps/figcraft/src/recovery.rs index 93335394..0885c68e 100644 --- a/apps/figcraft/src/recovery.rs +++ b/apps/figcraft/src/recovery.rs @@ -41,14 +41,40 @@ pub struct Recovery { dir: PathBuf, /// Held while the app runs: a free lock means the run that wrote the folder is gone. _lock: File, - /// The revision each document was last copied at, and the write in flight. - written: HashMap>)>, + /// What was copied of each document, and the write in flight. + written: HashMap, /// When (UI clock) the next copies are due. next: f64, /// Copies of online files from earlier runs, kept until their outcome is known. waiting: Vec, } +/// The state of the copy of one document: only a write that succeeded counts as a copy. +#[derive(Default)] +struct Copy { + /// The revision whose copy was written. + done: Option, + /// The write in flight: the revision it copies, and whether it succeeded once it ends. + flight: Option<(u64, JoinHandle)>, +} + +impl Copy { + /// Take in the result of the write in flight once it ended; a failed one leaves its revision uncopied. + fn reap(&mut self) { + if self.flight.as_ref().is_some_and(|f| f.1.is_finished()) + && let Some((revision, h)) = self.flight.take() + && h.join().unwrap_or(false) + { + self.done = Some(revision); + } + } + + /// Whether `revision` has neither been copied nor is being copied. + fn pending(&self, revision: u64) -> bool { + self.done != Some(revision) && self.flight.as_ref().is_none_or(|f| f.0 != revision) + } +} + /// A copy of one of your online files whose file is being joined again as session `session`. pub struct Waiting { session: u64, @@ -86,15 +112,21 @@ impl Recovery { /// an idle window can sleep). pub fn tick(&mut self, s: &Session, now: f64) -> Option { self.settle(s); + self.written.values_mut().for_each(Copy::reap); // A join on its way settles within its timeouts: look again soon. let settling = (!self.waiting.is_empty()).then_some(0.5); - let pending = s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.0 != d.revision)); if now < self.next { - return pending.then_some(self.next - now).or(settling); + return self.pending(s).then_some(self.next - now).or(settling); } self.write(s, false); self.next = now + INTERVAL; - settling + // A copy that failed is tried again at the next turn, without another edit. + settling.or(self.pending(s).then_some(INTERVAL)) + } + + /// Whether a document with unsaved changes has a revision that is neither copied nor being copied. + fn pending(&self, s: &Session) -> bool { + s.docs.iter().any(|d| needs_copy(d) && self.written.get(&d.uid).is_none_or(|w| w.pending(d.revision))) } /// Remove earlier runs' copies whose outcome is known, once what they became is copied here. @@ -131,9 +163,10 @@ impl Recovery { } continue; } - let entry = self.written.entry(d.uid).or_insert((0, None)); - let busy = entry.1.as_ref().is_some_and(|h| !h.is_finished()); - if entry.0 == d.revision || busy { + let entry = self.written.entry(d.uid).or_default(); + entry.reap(); + // A write still in flight (for any revision) is waited for; a failed one is tried again. + if entry.done == Some(d.revision) || entry.flight.is_some() { continue; } let doc = d.doc.clone(); @@ -144,20 +177,22 @@ impl Recovery { let online = s.online_of(d.uid).map(|(server, file, at)| format!("{server}\n{file}\n{} {} {}", at.session, at.client_id, at.seq)); let job = move || { if !write_atomic(&path, &doc) { - return; + return false; } let _ = match online { Some(text) => std::fs::write(¬e, text), None => std::fs::remove_file(¬e), }; + true }; + // The revision counts as copied only once its write succeeded. if wait { - job(); - entry.1 = None; + if job() { + entry.done = Some(d.revision); + } } else { - entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok(); + entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (d.revision, h)); } - entry.0 = d.revision; } // Closed documents. let open: Vec = s.docs.iter().map(|d| d.uid).collect(); @@ -177,7 +212,7 @@ impl Recovery { pub fn finish(self) { let Self { dir, _lock, written, .. } = self; // Windows removes a folder only once nothing in it is open: copies being written first. - for h in written.into_values().filter_map(|w| w.1) { + for (_, h) in written.into_values().filter_map(|w| w.flight) { let _ = h.join(); } drop(_lock); diff --git a/apps/figcraft/src/recovery_tests.rs b/apps/figcraft/src/recovery_tests.rs index 95560319..bada7d98 100644 --- a/apps/figcraft/src/recovery_tests.rs +++ b/apps/figcraft/src/recovery_tests.rs @@ -62,3 +62,56 @@ fn copies_follow_saves_and_a_normal_quit_leaves_nothing() { assert!(!dir.exists(), "a normal quit removes the copies"); let _ = std::fs::remove_dir_all(&root); } + +/// The files named `*.figcraft` in the run folder under `root`. +fn copy_count(root: &std::path::Path) -> usize { + let Some(dir) = std::fs::read_dir(root).unwrap().flatten().next().map(|e| e.path()) else { return 0 }; + std::fs::read_dir(dir).unwrap().flatten().filter(|f| f.path().extension().is_some_and(|e| e == "figcraft")).count() +} + +/// A folder at the path of `s`'s first document's copy in the run folder under `root`. +fn blocked_copy(root: &std::path::Path, s: &Session) -> std::path::PathBuf { + let dir = std::fs::read_dir(root).unwrap().flatten().next().unwrap().path(); + let blocker = dir.join(format!("{}.figcraft", s.docs[0].uid)); + std::fs::create_dir(&blocker).unwrap(); + blocker +} + +#[test] +fn a_copy_that_failed_is_tried_again_without_another_edit() { + let root = root("retry"); + let s = edited(); + let mut r = Recovery::start(&root).unwrap(); + // A folder where the copy goes makes its write fail on every platform (renaming the run folder + // away fails on Windows, where the run's lock file keeps it open). + let blocker = blocked_copy(&root, &s); + r.write(&s, true); + std::fs::remove_dir(&blocker).unwrap(); + // Nothing was edited since: the same revision still has to be copied. + r.write(&s, true); + assert_eq!(copy_count(&root), 1, "the failed revision was copied again"); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_background_copy_that_failed_is_tried_again_without_another_edit() { + let root = root("retry-bg"); + let s = edited(); + let mut r = Recovery::start(&root).unwrap(); + let blocker = blocked_copy(&root, &s); + r.write(&s, false); + // Let the failing write end before the way is clear again. + std::thread::sleep(std::time::Duration::from_millis(300)); + std::fs::remove_dir(&blocker).unwrap(); + for _ in 0..50 { + r.write(&s, false); + if copy_count(&root) == 1 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + assert_eq!(copy_count(&root), 1, "the failed revision was copied again"); + r.finish(); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/contributors/people.toml b/contributors/people.toml index 06699f06..af5d0576 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -23,3 +23,7 @@ display_name = "tylerjenningsw" [people.VanTan666] display_name = "VanTan" + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false diff --git a/crates/collab/src/client.rs b/crates/collab/src/client.rs index a2c6e744..8f2a9841 100644 --- a/crates/collab/src/client.rs +++ b/crates/collab/src/client.rs @@ -307,7 +307,8 @@ impl Client { /// Queue `changes` as a new batch (already shown in the view). fn queue(&mut self, changes: Vec, kind: BatchKind, step: Option) { let batch_id = self.next_batch; - self.next_batch += 1; + // `next_batch` starts from the welcome's `last_batch`, the host's word: saturate. + self.next_batch = self.next_batch.saturating_add(1); self.pending.push_back(Pending { batch_id, base_seq: self.seq, changes, kind, step, rebased: false, sent: false }); } @@ -484,7 +485,11 @@ impl Client { /// A batch the server accepted (in order). Follow-ups it causes are queued. fn accepted(&mut self, acc: Accepted, recompute: &dyn Recompute) -> Result<(), Error> { - if acc.seq != self.seq + 1 { + // The host's sequence number is untrusted: at `u64::MAX` it can't step past it. + let Some(next) = self.seq.checked_add(1) else { + return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq))); + }; + if acc.seq != next { return Err(Error::Protocol(format!("sequence {} after {}", acc.seq, self.seq))); } if acc.author != self.id { diff --git a/crates/collab/src/client_tests.rs b/crates/collab/src/client_tests.rs new file mode 100644 index 00000000..67e1e7a1 --- /dev/null +++ b/crates/collab/src/client_tests.rs @@ -0,0 +1,43 @@ +//! A client's handling of the host's messages: sequence numbers and batch ids come from the host, +//! so they are treated as hostile input (they must never overflow). + +use figcraft_doc::NodeKind; + +use crate::message::{Accepted, ClientMsg, Hello, ServerMsg}; +use crate::sim_tests::base; +use crate::{Client, Limits, NoRecompute, Outgoing, Server}; + +fn hello() -> Hello { + let ClientMsg::Hello(h) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") }; + h +} + +/// A welcome whose sequence numbers are what a lying host would send. +fn hostile_welcome(seq: u64, last_batch: u64) -> ServerMsg { + let mut s = Server::new(base(), "tok", Limits::default()); + let (_, out) = s.join(hello()).unwrap(); + let mut welcome = out + .into_iter() + .find_map(|o| match o { + Outgoing::To(_, m) => Some(m), + _ => None, + }) + .unwrap(); + let ServerMsg::Welcome { seq: seq0, processed, .. } = &mut welcome else { panic!("no welcome") }; + *seq0 = seq; + processed.last_batch = last_batch; + welcome +} + +#[test] +fn hostile_sequence_numbers_do_not_overflow_the_client() { + let mut c = Client::join(hostile_welcome(u64::MAX, u64::MAX)).unwrap(); + // This client's own first batch must not overflow its batch counter. + let mut d = c.document().clone(); + let page = d.first_page().unwrap(); + d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 10.0, 10.0).unwrap(); + assert!(c.edit(&d).unwrap().is_some(), "the edit is batched"); + // A batch the host claims follows `u64::MAX` can't exist: refuse it, don't overflow. + let acc = Accepted { seq: 0, author: c.id().wrapping_add(1), batch_id: 0, changes: Vec::new() }; + assert!(c.receive(ServerMsg::Changes(acc), &NoRecompute).is_err()); +} diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 502d8a3e..2c693bc4 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -123,6 +123,8 @@ impl Recompute for NoRecompute { fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet) {} } +#[cfg(test)] +mod client_tests; #[cfg(test)] mod codec_tests; #[cfg(test)] diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 3ae8bbe8..513800e3 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -903,15 +903,22 @@ impl Document { /// A unique name like "Frame 3" for a new node of a kind. pub fn next_name(&self, base: &str) -> String { let mut max = 0u64; + let mut taken = std::collections::BTreeSet::new(); for n in self.nodes() { if let Some(rest) = n.name.strip_prefix(base) && let Some(num) = rest.strip_prefix(' ') && let Ok(k) = num.parse::() { max = max.max(k); + taken.insert(k); } } - format!("{base} {}", max + 1) + let next = match max.checked_add(1) { + Some(next) => next, + // The counter is spent: the lowest free number instead of wrapping onto a name. + None => (1u64..).find(|k| !taken.contains(k)).unwrap_or(1), + }; + format!("{base} {next}") } pub fn style(&self, id: &str) -> Option<&Style> { diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index ef2f37b9..d54bb29b 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -335,6 +335,16 @@ fn next_name_increments() { assert_eq!(d.next_name("Ellipse"), "Ellipse 1"); } +#[test] +fn next_name_never_overflows() { + let mut d = Document::new("x"); + let p = page(&d); + d.create(p, NodeKind::Frame, "Frame 18446744073709551615", 0.0, 0.0, 1.0, 1.0).unwrap(); + assert_eq!(d.next_name("Frame"), "Frame 1", "the counter can't step past u64::MAX"); + d.create(p, NodeKind::Frame, "Frame 1", 0.0, 0.0, 1.0, 1.0).unwrap(); + assert_eq!(d.next_name("Frame"), "Frame 2"); +} + #[test] fn custom_easings() { use crate::proto::Easing; diff --git a/crates/engine/src/batch_rollback_tests.rs b/crates/engine/src/batch_rollback_tests.rs index db0b7962..12d958cd 100644 --- a/crates/engine/src/batch_rollback_tests.rs +++ b/crates/engine/src/batch_rollback_tests.rs @@ -61,3 +61,25 @@ fn failed_plugin_run_restores_each_document_without_restoring_unrelated_state() assert_eq!(s.journal, before_journal); assert_eq!(s.message, before_message); } + +#[test] +fn failed_group_keeps_the_redo_steps() { + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.undo().unwrap()); + assert_eq!(s.undo_depth().1, 1); + + let result = s.execute( + "command.batch", + &json!({ + "commands": [ + {"command": "shape.rectangle", "params": {"x": 20, "y": 20, "width": 10, "height": 10}}, + {"command": "node.set", "params": {"id": 999999, "props": {"name": "must fail"}}} + ] + }), + ); + + assert!(result.is_err()); + assert_eq!(s.undo_depth().1, 1, "the redo step survives the failed batch"); + assert!(s.redo().unwrap()); +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index df6afc6d..3a0342cd 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -253,7 +253,7 @@ pub fn specs() -> Vec { ok() }), spec!("effect.set", "Set effect", "{ids?, index, effect {..}}", true, |s, p| { - let i = req_num("effect.set", p, "index")? as usize; + let i = req_index("effect.set", p, "index")?; let e: Effect = parse("effect.set", p, "effect")?.ok_or_else(|| bad("effect.set", "`effect` required"))?; for id in req_targets("effect.set", s, p)? { let n = s.doc_mut()?.get_mut(id)?; @@ -302,7 +302,7 @@ pub fn specs() -> Vec { true, |s, p| { let cmd = "export.update"; - let i = req_num(cmd, p, "index")? as usize; + let i = req_index(cmd, p, "index")?; super::file::export_setting(cmd, ExportSetting::default(), p)?; for id in req_targets(cmd, s, p)? { let n = s.doc_mut()?.get_mut(id)?; @@ -314,7 +314,7 @@ pub fn specs() -> Vec { } ), spec!("export.remove", "Remove export setting", "{ids?, index}", true, |s, p| { - let i = req_num("export.remove", p, "index")? as usize; + let i = req_index("export.remove", p, "index")?; for id in req_targets("export.remove", s, p)? { let n = s.doc_mut()?.get_mut(id)?; if i < n.exports.len() { @@ -488,8 +488,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { fn bump_name(n: &str) -> String { if let Some((base, num)) = n.rsplit_once(' ') && let Ok(k) = num.parse::() + && let Some(next) = k.checked_add(1) { - return format!("{base} {}", k + 1); + return format!("{base} {next}"); } format!("{n} 2") } @@ -845,7 +846,8 @@ pub fn opacity_from_digits(digits: &str) -> Option { let mut n = 0u32; let mut count = 0; for c in digits.chars() { - n = n * 10 + c.to_digit(10)?; + // Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps. + n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?; count += 1; } match count { @@ -1012,13 +1014,20 @@ fn find_replace(s: &mut Session, p: &Value) -> Result { } if let NodeKind::Text(t) = &mut n.kind { // Replace occurrence by occurrence so styles shift correctly. + // The search resumes after each inserted replacement, so a replacement that + // contains `find` neither loops forever nor stops the other occurrences. let mut guard = 0; - while let Some(pos) = t.characters.find(find.as_str()) { - let start = t.characters.get(..pos).map(|x| x.chars().count()).unwrap_or(0); - let len = find.chars().count(); + let mut cursor = 0; + let len = find.chars().count(); + let r_len = r.chars().count(); + loop { + let from = t.characters.char_indices().nth(cursor).map_or(t.characters.len(), |(b, _)| b); + let Some(pos) = t.characters.get(from..).and_then(|tail| tail.find(find.as_str())) else { break }; + let start = cursor + t.characters.get(from..from + pos).map_or(0, |x| x.chars().count()); t.replace_range(start, start + len, r); + cursor = start + r_len; guard += 1; - if guard > 10_000 || r.contains(find.as_str()) { + if guard > 10_000 { break; } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 781ed6b0..8122af42 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -219,7 +219,7 @@ pub fn specs() -> Vec { } ), spec!("doc.switch", "Switch document", "{index}", false, |s, p| { - let i = req_num("doc.switch", p, "index")? as usize; + let i = req_index("doc.switch", p, "index")?; if i >= s.docs.len() { return Err(bad("doc.switch", "no such document")); } @@ -437,15 +437,12 @@ pub(crate) fn read_capped(path: &str, max: u64) -> Result> { read(path) } +/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one +/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never +/// truncates `path`, and no file or symlink at a staging name is ever written through. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { - let tmp = format!("{path}.tmp-figcraft"); - std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{tmp}: {e}")))?; - std::fs::rename(&tmp, path).map_err(|e| { - // Leave nothing behind when the file can't take its place. - let _ = std::fs::remove_file(&tmp); - EngineError::Other(format!("{path}: {e}")) - }) + figcraft_format::write_file_atomic(std::path::Path::new(path), bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))) } #[cfg(target_arch = "wasm32")] @@ -1327,3 +1324,126 @@ mod rest_import_tests { let _ = std::fs::remove_dir_all(&dir); } } + +#[cfg(test)] +mod staging_write_tests { + use super::*; + + fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d + } + + /// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). + #[cfg(unix)] + fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::unix::fs::symlink(to, from).is_ok() + } + + #[cfg(windows)] + fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::windows::fs::symlink_file(to, from).is_ok() + } + + #[test] + fn an_existing_staging_file_is_never_written_through() { + let d = dir("existing"); + let path = d.join("shot.svg"); + // The old deterministic staging name, held by an unrelated file: the write must pick its + // own staging name instead of truncating this one. + let old = d.join("shot.svg.tmp-figcraft"); + std::fs::write(&old, b"not yours").unwrap(); + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b""); + assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); + // Just the file and the untouched staging file: no new staging leftovers. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_staging_symlink_is_never_followed() { + let d = dir("symlink"); + let path = d.join("shot.svg"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"innocent").unwrap(); + let old = d.join("shot.svg.tmp-figcraft"); + if !symlink(&old, &victim) { + return; // Making a symlink needs privileges on some Windows setups. + } + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); + assert_eq!(std::fs::read(&path).unwrap(), b""); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn concurrent_writers_of_one_file_never_interleave() { + let d = dir("concurrent"); + let path = d.join("shot.svg"); + for round in 0..5u8 { + let mut handles = Vec::new(); + for w in 0u8..4 { + let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]); + handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes))); + } + let mut wins = 0; + for h in handles { + // On Windows a rename over a file another writer just replaced can lose the race. + if h.join().unwrap().is_ok() { + wins += 1; + } + } + assert!(wins > 0, "round {round}: every writer lost"); + // Whatever landed is one writer's complete payload, never a mix. + let got = std::fs::read(&path).unwrap(); + assert_eq!(got.len(), 8192, "round {round}: a complete payload landed"); + assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix"); + } + // No staging file was left stuck (a failed writer removes its own). + let left = std::fs::read_dir(&d).unwrap().count(); + assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory"); + let _ = std::fs::remove_dir_all(&d); + } + + #[test] + fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { + let d = dir("failure"); + // The destination is a directory: the rename fails, our staging file is removed and the + // directory is untouched. + let taken = d.join("over-a-directory"); + std::fs::create_dir_all(&taken).unwrap(); + assert!(write(&taken.to_string_lossy(), b"payload").is_err()); + assert!(taken.is_dir()); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); + // A missing parent directory fails without creating anything anywhere. + assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err()); + assert!(!d.join("no").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // A read-only directory refuses the staging file, and the old destination stays. + let ro = d.join("read-only"); + std::fs::create_dir_all(&ro).unwrap(); + let dest = ro.join("keep.svg"); + std::fs::write(&dest, b"keep").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&ro, perms).unwrap(); + // Running as root would defeat the read-only directory: skip instead of failing. + let probe = ro.join(".probe"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + } else { + assert!(write(&dest.to_string_lossy(), b"payload").is_err()); + assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); + assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); + } + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(&ro, perms).unwrap(); + } + let _ = std::fs::remove_dir_all(&d); + } +} diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index d7f630b9..8fccd607 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -52,7 +52,7 @@ pub fn specs() -> Vec { let id = match id(p, "id") { Some(i) => i, None => { - let k = req_num("page.select", p, "index")? as usize; + let k = req_index("page.select", p, "index")?; *doc.pages.get(k).ok_or_else(|| bad("page.select", "no such page"))? } }; diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs index 257d1a12..dd2daa82 100644 --- a/crates/engine/src/cmd/props.rs +++ b/crates/engine/src/cmd/props.rs @@ -13,7 +13,7 @@ pub const STROKE_SIDES: [&str; 4] = ["strokeTopWeight", "strokeRightWeight", "st /// Keys `node.set` understands (documented for agents). pub const KEYS: &str = "name, visible, locked, opacity (0-1), blend, x, y (relative to the parent frame; groups don't count), width, height, rotation (degrees), \ -fills (paints or colours), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ +fills (paints or colours; gradients as {type: linearGradient|radialGradient|angularGradient|diamondGradient, stops: [\"#hex\", ...], from?, to?} too), fill (colour), strokes, stroke (colour), strokeWeight, strokeAlign (inside|center|outside), strokeSides [t,r,b,l], strokeTopWeight / strokeRightWeight / strokeBottomWeight / strokeLeftWeight (one side; turns on individual strokes), \ strokeCap / startCap / endCap, strokeJoin, miterAngle (degrees: sharper joins bevel; or miterLimit as a ratio), dashes [..], widthProfile (preset uniform|wedge|taper|eye|rounded|pinch, [[t, scale], ...] or null), brush (preset id, {preset, spacing, sizeJitter, rotationJitter, scatter, seed} or null), cornerRadius (n or [tl,tr,br,bl]), cornerSmoothing (0-1), effects, \ clip, isMask, layout (null|horizontal|vertical|grid|{...}), gap, crossGap, padding (n or [t,r,b,l]), paddingTop/Right/Bottom/Left, \ mainAlign (min|center|max, or the auto gaps spaceBetween|spaceAround|spaceEvenly), crossAlign (min|center|max|baseline: first text baselines, horizontal layouts), contentAlign (wrapped lines: auto = packed where crossAlign says | spaceBetween), wrap (rows of a horizontal layout, columns of a vertical one), strokesIncluded (the frame's inside stroke takes up space), reverseZ (canvas stacking: true = first on top), \ diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 39a18ce4..0975f3fa 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -58,6 +58,9 @@ fn close(a: f64, b: f64) -> bool { /// A square (100,100)–(200,200), selected, and the text "Hello" at (400, 100), not selected. fn scene() -> (Session, NodeId, NodeId) { + // The text is measured with a font: without craft-fonts that is a system one, scanned here + // rather than by whichever test happens to run first (#333). + figcraft_text::scan_system_fonts(); let mut s = Session::new(); let r = id(&ex(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 100, "height": 100}))); let t = id(&ex(&mut s, "text.create", json!({"text": "Hello", "x": 400, "y": 100, "props": {"fontSize": 40}}))); diff --git a/crates/engine/src/double_click_tests.rs b/crates/engine/src/double_click_tests.rs index e51cb7da..2ffa0083 100644 --- a/crates/engine/src/double_click_tests.rs +++ b/crates/engine/src/double_click_tests.rs @@ -15,6 +15,9 @@ fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { /// A frame holding a group of a rectangle and a text layer at (60, 60); returns (group, text). fn scene(s: &mut Session) -> (NodeId, NodeId) { + // The text is measured with a font: without craft-fonts that is a system one, scanned here + // rather than by whichever test happens to run first (#333). + figcraft_text::scan_system_fonts(); let f = id_of(&ex(s, "frame.create", json!({"x": 0, "y": 0, "width": 400, "height": 300}))["id"]).unwrap(); let r = id_of(&ex(s, "shape.rectangle", json!({"x": 40, "y": 40, "width": 200, "height": 60, "parent": f}))["id"]).unwrap(); let t = id_of(&ex(s, "text.create", json!({"text": "Hi there", "x": 60, "y": 60, "parent": f, "props": {"fontSize": 16}}))["id"]).unwrap(); diff --git a/crates/engine/src/effects_tests.rs b/crates/engine/src/effects_tests.rs index 5142c40d..be69fac2 100644 --- a/crates/engine/src/effects_tests.rs +++ b/crates/engine/src/effects_tests.rs @@ -85,3 +85,24 @@ fn inspect_and_css_show_the_new_effects() { let css = code_for(doc, r, CodeLang::Css); assert!(css.contains("backdrop-filter: blur(2px);"), "{css}"); } + +#[test] +fn negative_or_fractional_index_is_refused_not_item_zero() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("effect.add", &json!({"ids": [r], "type": "layerBlur"})).unwrap(); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + let before = s.doc().unwrap().node(r).unwrap().clone(); + assert_eq!(before.exports.len(), 2); + for index in [json!(-1), json!(-0.5), json!(1.5)] { + let set = json!({"ids": [r], "index": index, "effect": {"type": "dropShadow"}}); + assert!(s.execute("effect.set", &set).is_err(), "effect.set index {index}"); + let upd = json!({"ids": [r], "index": index, "format": "svg"}); + assert!(s.execute("export.update", &upd).is_err(), "export.update index {index}"); + assert!(s.execute("export.remove", &json!({"ids": [r], "index": index})).is_err(), "export.remove index {index}"); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!(n.effects, before.effects, "index {index}"); + assert_eq!(n.exports, before.exports, "index {index}"); + } +} diff --git a/crates/engine/src/gradient_shorthand_tests.rs b/crates/engine/src/gradient_shorthand_tests.rs new file mode 100644 index 00000000..3ea51a89 --- /dev/null +++ b/crates/engine/src/gradient_shorthand_tests.rs @@ -0,0 +1,107 @@ +//! The documented gradient shorthand (`{"type":"linearGradient","stops":["#hex",…]}`) works +//! through every route into the engine, not only MCP (#252): `figcraft-cli run` and the control +//! channel send params straight to `Session::execute`. + +use figcraft_color::Paint; +use figcraft_doc::NodeId; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::{id_of, paint_of}; + +/// The red→blue left-to-right gradient as written in the docs. +fn short() -> Value { + json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]}) +} + +/// The same gradient in the engine's own form. +fn long() -> Value { + json!({"type": "gradient", "kind": "linear", + "stops": [{"position": 0, "color": {"r": 1, "g": 0, "b": 0, "a": 1}}, + {"position": 1, "color": {"r": 0, "g": 0, "b": 1, "a": 1}}], + "from": [0, 0.5], "to": [1, 0.5], "width": [0, 1.5]}) +} + +fn rect(s: &mut Session) -> NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"]).unwrap() +} + +fn fills(s: &Session, id: NodeId) -> Vec { + s.doc().unwrap().node(id).unwrap().fills.clone() +} + +#[test] +fn the_shorthand_reads_as_the_long_form() { + assert_eq!(paint_of(&short()).expect("the documented shorthand is a paint"), paint_of(&long()).unwrap()); +} + +#[test] +fn node_set_takes_the_shorthand() { + let mut s = Session::new(); + let r = rect(&mut s); + s.execute("node.set", &json!({"ids": [r], "fills": [short()]})).unwrap(); + s.execute("node.set", &json!({"ids": [r], "strokes": [short()]})).unwrap(); + let want = paint_of(&long()).unwrap(); + assert_eq!(fills(&s, r), vec![want.clone()]); + assert_eq!(s.doc().unwrap().node(r).unwrap().strokes, vec![want]); +} + +#[test] +fn node_create_fill_add_and_fill_set_take_the_shorthand() { + let want = paint_of(&long()).unwrap(); + let mut s = Session::new(); + let v = s.execute("node.create", &json!({"type": "rectangle", "width": 200, "height": 100, "props": {"fills": [short()]}})).unwrap(); + let r = id_of(&v["id"]).unwrap(); + assert_eq!(fills(&s, r), vec![want.clone()]); + s.execute("fill.add", &json!({"ids": [r], "paint": short()})).unwrap(); + assert_eq!(fills(&s, r), vec![want.clone(), want.clone()]); + let r2 = rect(&mut s); + s.execute("fill.set", &json!({"ids": [r2], "index": 0, "paint": short()})).unwrap(); + assert_eq!(fills(&s, r2), vec![want]); +} + +#[test] +fn shorthand_defaults_match_the_mcp_server() { + // No handles: top to bottom, with a square width handle. + let p = paint_of(&json!({"type": "linearGradient", "stops": ["#000000", "#ffffff"]})).unwrap(); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!( + (v["kind"].clone(), v["from"].clone(), v["to"].clone(), v["width"].clone()), + (json!("linear"), json!([0.5, 0.0]), json!([0.5, 1.0]), json!([-0.5, 0.0])) + ); + // Plain colour stops are spread evenly. + let p = paint_of(&json!({"type": "radialGradient", "stops": ["#000000", "#808080", "#ffffff"]})).unwrap(); + let v = serde_json::to_value(&p).unwrap(); + assert_eq!(v["kind"], "radial"); + let pos: Vec = v["stops"].as_array().unwrap().iter().map(|s| s["position"].as_f64().unwrap()).collect(); + assert_eq!(pos, vec![0.0, 0.5, 1.0]); + for kind in ["angular", "diamond"] { + let p = paint_of(&json!({"type": format!("{kind}Gradient"), "stops": ["#000000", "#ffffff"]})).unwrap(); + assert_eq!(serde_json::to_value(&p).unwrap()["kind"], kind); + } + // A CSS colour inside a solid paint or a positioned stop is read too. + assert_eq!(paint_of(&json!({"type": "solid", "color": "#00ff00"})), paint_of(&json!("#00ff00"))); + let p = paint_of(&json!({"type": "gradient", "stops": [{"position": 0, "color": "#ff0000"}, {"position": 1, "color": "#0000ff"}], "from": [0, 0.5], "to": [1, 0.5]})).unwrap(); + assert_eq!(p, paint_of(&long()).unwrap()); +} + +#[test] +fn junk_is_still_refused() { + let mut s = Session::new(); + let r = rect(&mut s); + for junk in [ + json!({"type": "linearGradient", "stops": "red"}), + json!({"type": "linearGradient", "stops": [42]}), + json!({"type": "fooGradient", "stops": ["#ff0000"]}), + json!({"type": "linearGradient", "stops": ["#ff0000"], "from": "left"}), + ] { + let e = s.execute("node.set", &json!({"ids": [r], "fills": [junk.clone()]})).unwrap_err().to_string(); + assert!(e.contains("`fills` must be paints or colours"), "{junk}: {e}"); + } + // Deep nesting doesn't overflow the stack. + let mut deep = json!("#ff0000"); + for _ in 0..200 { + deep = json!({"type": "linearGradient", "stops": [{"position": 0, "color": deep}]}); + } + assert!(paint_of(&deep).is_none()); +} diff --git a/crates/engine/src/interaction_rollback_tests.rs b/crates/engine/src/interaction_rollback_tests.rs new file mode 100644 index 00000000..3a738017 --- /dev/null +++ b/crates/engine/src/interaction_rollback_tests.rs @@ -0,0 +1,67 @@ +//! A command that fails part-way (a property that applies, then one that doesn't) leaves nothing +//! behind — also during an interaction, whose last valid preview and own snapshot survive it. + +use serde_json::json; + +use crate::Session; +use crate::params::id_of; + +fn rect(s: &mut Session) -> crate::NodeId { + id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap() +} + +/// `width` applies (geometry keys go first), then `visible` fails validation. +fn partially_applicable(s: &mut Session, id: crate::NodeId) -> crate::Result { + s.execute("node.set", &json!({"id": id, "width": 123, "visible": "yes"})) +} + +#[test] +fn a_failed_command_during_an_interaction_keeps_the_prior_preview() { + let mut s = Session::new(); + let r = rect(&mut s); + s.begin_interaction("Move").unwrap(); + let props = serde_json::Map::from_iter([("x".to_string(), json!(20.0))]); + s.preview(|d, _| crate::cmd::props::apply("node.set", d, r, &props)).unwrap(); + let (previewed, selection, page) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.selection.clone(), ds.page) + }; + let before_journal = s.journal.clone(); + let before_history = s.ds().unwrap().history.undo.len(); + + assert!(partially_applicable(&mut s, r).is_err()); + + // The rejected edit is gone: the command-entry state (the valid preview) is back. + let ds = s.ds().unwrap(); + assert_eq!(*ds.doc, *previewed, "the document is what the preview showed"); + assert_eq!(ds.doc.node(r).unwrap().width, 80.0); + assert_eq!(ds.selection, selection); + assert_eq!(ds.page, page); + assert_eq!(ds.history.undo.len(), before_history); + assert_eq!(s.journal, before_journal); + + // The interaction is alive and commits the preview, not the rejected mutation. + s.commit_interaction().unwrap(); + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!((n.width, n.visible), (80.0, true)); + assert_eq!(n.transform.as_coeffs()[4], 20.0, "the previewed move is committed"); + assert_eq!(s.undo_depth(), (before_history + 1, 0)); + assert_eq!(s.undo_labels(1), vec!["Move".to_string()]); + s.undo().unwrap(); + assert_eq!(s.doc().unwrap().node(r).unwrap().transform.as_coeffs()[4], 0.0); +} + +#[test] +fn a_failed_command_without_an_interaction_still_rolls_back() { + let mut s = Session::new(); + let r = rect(&mut s); + let before_journal = s.journal.clone(); + let before_history = s.ds().unwrap().history.undo.len(); + + assert!(partially_applicable(&mut s, r).is_err()); + + let n = s.doc().unwrap().node(r).unwrap(); + assert_eq!((n.width, n.visible), (80.0, true)); + assert_eq!(s.ds().unwrap().history.undo.len(), before_history); + assert_eq!(s.journal, before_journal); +} diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs index 8f6f28c1..38775d3b 100644 --- a/crates/engine/src/keymap_tests.rs +++ b/crates/engine/src/keymap_tests.rs @@ -4,11 +4,11 @@ use serde_json::{Value, json}; -use crate::Session; use crate::cmd::edit::opacity_from_digits; use crate::keymap::{Context, GESTURES, Group, KEYS, Mods, Platform, display, lookup, matches, normalize, parse, primary}; use crate::params::id_of; use crate::tools::ToolId; +use crate::{EngineError, Session}; use figcraft_doc::NodeId; const PLATFORMS: [Platform; 2] = [Platform::Mac, Platform::Other]; @@ -186,6 +186,7 @@ fn opacity_digits() { assert_eq!(opacity_from_digits("00"), Some(0.0)); assert_eq!(opacity_from_digits(""), None); assert_eq!(opacity_from_digits("123"), None); + assert_eq!(opacity_from_digits("9999999999"), None, "ten digits must not overflow u32"); assert_eq!(opacity_from_digits("4x"), None); assert_eq!(opacity_from_digits("٣"), None, "ASCII digits only"); @@ -201,6 +202,10 @@ fn opacity_digits() { ex(&mut s, "edit.undo", json!({})); assert_eq!(op(&s, a), 0.3, "one undo step"); assert!(s.execute("object.opacityKeys", &json!({"digits": "123"})).is_err()); + assert!( + matches!(s.execute("object.opacityKeys", &json!({"digits": "9999999999"})), Err(EngineError::BadParams { .. })), + "an overflowing digit string is a parameter error, not a panic" + ); assert!(s.execute("object.opacityKeys", &json!({})).is_err()); ex(&mut s, "select.none", json!({})); assert!(s.execute("object.opacityKeys", &json!({"digits": "1"})).is_err(), "nothing selected"); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aadbbbd5..42423dbc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -846,10 +846,11 @@ impl Session { } } Err(_) => { - // Roll back any partial edit. + // Roll back any partial edit — also during an interaction, where `before` is the + // interaction's last valid preview: restore it and leave the gesture itself (its + // own snapshot) alone. if let Some((doc, sel, page, uid)) = before && let Some(ds) = self.docs.iter_mut().find(|d| d.uid == uid) - && ds.interaction.is_none() { ds.doc = doc; ds.selection = sel; @@ -1091,6 +1092,8 @@ impl Session { self.ds()?; let before: Vec<_> = self.docs.iter().map(|ds| (ds.uid, ds.doc.clone(), ds.selection.clone(), ds.page, ds.history.undo.len(), ds.history.limit)).collect(); + // An edit inside clears the redo steps; a failed group has to bring them back. + let redo_before: Vec<_> = self.docs.iter().map(|ds| ds.history.redo.clone()).collect(); let active = self.active; let journal_len = self.journal.len(); @@ -1108,8 +1111,9 @@ impl Session { if result.is_err() { self.docs.retain(|ds| before.iter().any(|(uid, _, _, _, _, _)| *uid == ds.uid)); - for (uid, doc, selection, page, undo_len, limit) in &before { + for ((uid, doc, selection, page, undo_len, limit), redo) in before.iter().zip(redo_before) { if let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == *uid) { + ds.history.redo = redo; ds.doc = doc.clone(); ds.selection = selection.clone(); ds.page = *page; @@ -1478,6 +1482,8 @@ mod fx_behaviour_tests; #[cfg(test)] mod gesture_preview_tests; #[cfg(test)] +mod gradient_shorthand_tests; +#[cfg(test)] mod hide_publishing_tests; #[cfg(test)] mod history_behaviour_tests; @@ -1488,6 +1494,8 @@ mod import_export_tests; #[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod interaction_rollback_tests; +#[cfg(test)] mod invite_links_tests; #[cfg(test)] mod keymap_tests; diff --git a/crates/engine/src/panels_behaviour_tests.rs b/crates/engine/src/panels_behaviour_tests.rs index f2802461..c1016c29 100644 --- a/crates/engine/src/panels_behaviour_tests.rs +++ b/crates/engine/src/panels_behaviour_tests.rs @@ -337,6 +337,23 @@ fn pages_by_the_documented_rules() { assert_ne!(s.page().unwrap(), div, "deleting the open page opens a page, never a divider"); } +#[test] +fn page_select_and_doc_switch_refuse_negative_or_fractional_indexes() { + let mut s = Session::new(); + let p2 = id(&ex(&mut s, "page.add", json!({}))); + for bad_index in [json!(-1), json!(0.5)] { + assert!(s.execute("page.select", &json!({"index": bad_index})).is_err(), "page.select index {bad_index} must fail"); + assert_eq!(s.page().unwrap(), p2, "page.select index {bad_index} leaves the open page alone"); + } + ex(&mut s, "file.new", json!({})); + let active = s.active; + assert_eq!(active, 1); + for bad_index in [json!(-1), json!(0.5)] { + assert!(s.execute("doc.switch", &json!({"index": bad_index})).is_err(), "doc.switch index {bad_index} must fail"); + assert_eq!(s.active, active, "doc.switch index {bad_index} leaves the active tab alone"); + } +} + #[test] fn assets_panel_lists_files_and_drops_by_the_documented_rules() { let mut s = Session::new(); @@ -386,3 +403,14 @@ fn assets_panel_lists_files_and_drops_by_the_documented_rules() { assert_eq!(kids(&s, row).first(), Some(&into), "an asset dropped in an auto layout frame joins the flow where it is dropped"); assert_eq!(s.doc().unwrap().page_of(into), Some(page)); } + +#[test] +fn duplicating_a_layer_named_past_the_counter_does_not_overflow() { + let mut s = Session::new(); + ex(&mut s, "pref.renameDuplicates", json!({"on": true})); + let r = rect(&mut s, "r", 30.0, 40.0, None); + ex(&mut s, "node.set", json!({"id": r, "props": {"name": "Layer 18446744073709551615"}})); + let dup = id_of(&ex(&mut s, "edit.duplicate", json!({"ids": [r]}))["ids"][0]).unwrap(); + assert_eq!(name(&s, dup), "Layer 18446744073709551615 2"); + assert!(s.doc().unwrap().validate().is_empty(), "the document still checks out"); +} diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs index 4eca1444..bbaa42a4 100644 --- a/crates/engine/src/params.rs +++ b/crates/engine/src/params.rs @@ -3,7 +3,7 @@ use figcraft_color::{BlendMode, Color, Paint}; use figcraft_doc::NodeId; -use serde_json::Value; +use serde_json::{Value, json}; use crate::{EngineError, Result, Session, bad}; @@ -135,12 +135,80 @@ pub fn color(p: &Value, key: &str) -> Option { get(p, key).and_then(color_of) } -/// A paint from JSON: a colour string/object (solid) or a full paint object. +/// Read the friendlier paint/effect JSON that the docs and MCP tools describe as the engine's own +/// form: any `"color"` given as a CSS string ("#RRGGBB[AA]", "rgb(…)") becomes {r,g,b,a}, +/// `{"type":"linearGradient"}` becomes `{"type":"gradient","kind":"linear"}`, gradients get default +/// handles (top to bottom) when `from`/`to`/`width` are missing, and stops given as plain colours +/// are spread evenly. Anything else is left for serde to refuse. +pub fn normalize_paint(v: &mut Value) { + normalize_paintish(v, 0); +} + +fn normalize_paintish(v: &mut Value, depth: usize) { + if depth > 16 { + return; + } + match v { + Value::Array(a) => { + for x in a { + normalize_paintish(x, depth + 1); + } + } + Value::Object(o) => { + if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(color_of) { + o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null)); + } + let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient")); + if is_gradient { + // Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}. + if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase) + && t != "gradient" + { + let kind = t.trim_end_matches("gradient").to_string(); + o.insert("type".into(), json!("gradient")); + o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind })); + } + o.entry("kind").or_insert(json!("linear")); + let linear = o.get("kind").and_then(Value::as_str) == Some("linear"); + let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) }; + o.entry("from").or_insert(json!(from)); + o.entry("to").or_insert(json!(to)); + // A linear gradient's width handle goes square to its axis (else its bands skew, + // or vanish when the handle lies on the axis). + let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + if linear + && !o.contains_key("width") + && let (Some(f), Some(t)) = (pt("from"), pt("to")) + { + o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)])); + } + o.entry("width").or_insert(json!(width)); + if let Some(Value::Array(stops)) = o.get_mut("stops") { + let n = stops.len(); + for (i, s) in stops.iter_mut().enumerate() { + if let Some(c) = color_of(s).filter(|_| s.is_string()) { + let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 }; + *s = json!({"position": pos, "color": c}); + } else { + normalize_paintish(s, depth + 1); + } + } + } + } + } + _ => {} + } +} + +/// A paint from JSON: a colour string/object (solid), a full paint object or its shorthand +/// ([`normalize_paint`]). pub fn paint_of(v: &Value) -> Option { if let Some(c) = color_of(v) { return Some(Paint::solid(c)); } - let mut p: Paint = serde_json::from_value(v.clone()).ok()?; + let mut v = v.clone(); + normalize_paint(&mut v); + let mut p: Paint = serde_json::from_value(v).ok()?; p.opacity = if p.opacity.is_finite() { p.opacity.clamp(0.0, 1.0) } else { 1.0 }; Some(p) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9ddefb9f..a43959d3 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -161,6 +161,28 @@ fn text_create_autosize_and_edit() { assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "Hello"); } +#[test] +fn find_replace_replaces_every_occurrence_when_replacement_contains_find() { + for (text, find, replace, want) in [ + ("cat and cat", "cat", "cats", "cats and cats"), + ("cat and cat", "cat", "category", "category and category"), + ("banana", "a", "aa", "baanaanaa"), + ] { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": text, "x": 0, "y": 0})).unwrap()); + s.execute("edit.findReplace", &json!({"find": find, "replace": replace})).unwrap(); + assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, want, "{text}: {find} -> {replace}"); + } +} + +#[test] +fn find_replace_rejects_empty_find() { + let mut s = sess(); + let t = id(&s.execute("text.create", &json!({"text": "cat", "x": 0, "y": 0})).unwrap()); + assert!(s.execute("edit.findReplace", &json!({"find": "", "replace": "x"})).is_err()); + assert_eq!(s.doc().unwrap().node(t).unwrap().kind.text().unwrap().characters, "cat"); +} + #[test] fn components_and_instances_sync() { let mut s = sess(); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index a989f3f6..56798d14 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -654,14 +654,53 @@ fn read_branches(z: &mut zip::ZipArchive, images: &BTreeMap) -> Result<(), FormatError> { let bytes = save_bytes(doc, thumbnail_png)?; - let tmp = path.with_extension("figcraft.tmp"); - std::fs::write(&tmp, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", tmp.display())))?; - std::fs::rename(&tmp, path).map_err(|e| FormatError::Io(format!("{}: {e}", path.display())))?; - Ok(()) + write_file_atomic(path, &bytes).map_err(|e| FormatError::Io(format!("{}: {e}", path.display()))) +} + +/// Write `bytes` to `path` through a staging file and one atomic rename, so a reader of `path` +/// sees either the previous file or the complete new one, never a partial write. The staging file +/// is a uniquely named sibling (`.tmp-figcraft--`) created exclusively +/// (`create_new`) and written through its own handle: a file or symlink that already exists at a +/// staging name is never truncated or followed, and separate writers (also of separate processes) +/// never share a staging file. On failure only the staging file this call created is removed, and +/// `path` is replaced only on success. The rename replaces an existing destination on every +/// platform (`std::fs::rename` uses `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING` on Windows). +/// (No `sync_all` yet: nothing around here promises durability across a power cut.) +#[cfg(not(target_arch = "wasm32"))] +pub fn write_file_atomic(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { + use std::sync::atomic::{AtomicU64, Ordering::Relaxed}; + + static STAGING: AtomicU64 = AtomicU64::new(0); + // Three tries at a fresh name: the counter is unique within this process, so only a leftover + // of a dead process that now shares our process id can collide, and the next counter won't. + for _ in 0..3 { + let mut name = path.as_os_str().to_os_string(); + name.push(format!(".tmp-figcraft-{}-{}", std::process::id(), STAGING.fetch_add(1, Relaxed))); + let tmp = std::path::PathBuf::from(name); + match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) { + Ok(mut file) => { + if let Err(e) = file.write_all(bytes) { + let _ = std::fs::remove_file(&tmp); + return Err(e); + } + return match std::fs::rename(&tmp, path) { + Ok(()) => Ok(()), + // Leave nothing behind when the file can't take its place. + Err(e) => { + let _ = std::fs::remove_file(&tmp); + Err(e) + } + }; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no free staging name next to the file")) } #[cfg(not(target_arch = "wasm32"))] @@ -677,6 +716,8 @@ mod image_tests; #[cfg(test)] mod large_tests; #[cfg(test)] +mod staging_tests; +#[cfg(test)] mod thumbnail_tests; #[cfg(test)] mod version_tests; diff --git a/crates/format/src/staging_tests.rs b/crates/format/src/staging_tests.rs new file mode 100644 index 00000000..1bc199e2 --- /dev/null +++ b/crates/format/src/staging_tests.rs @@ -0,0 +1,125 @@ +//! The staging write behind `save_file`: a pre-existing file or symlink at a staging name is never +//! truncated or followed, concurrent savers of one file never corrupt it, and a failed write +//! cleans up after itself and leaves the destination alone. + +use figcraft_doc::Document; + +use crate::{save_file, write_file_atomic}; + +fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-staging-tests-{name}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). +#[cfg(unix)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::unix::fs::symlink(to, from).is_ok() +} + +#[cfg(windows)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::windows::fs::symlink_file(to, from).is_ok() +} + +#[test] +fn an_existing_staging_file_is_never_written_through() { + let d = dir("existing"); + let path = d.join("a.figcraft"); + // The old deterministic staging name, held by an unrelated file: saving must pick its own + // staging name instead of truncating this one. + let old = d.join("a.figcraft.tmp"); + std::fs::write(&old, b"not yours").unwrap(); + save_file(&Document::new("Mine"), &path, None).unwrap(); + assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); + assert_eq!(crate::load_file(&path).unwrap().0.name, "Mine"); + // Just the file and the untouched staging file: no new staging leftovers. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_staging_symlink_is_never_followed() { + let d = dir("symlink"); + let path = d.join("a.figcraft"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"innocent").unwrap(); + let old = d.join("a.figcraft.tmp"); + if !symlink(&old, &victim) { + return; // Making a symlink needs privileges on some Windows setups. + } + save_file(&Document::new("Mine"), &path, None).unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); + assert_eq!(crate::load_file(&path).unwrap().0.name, "Mine"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn concurrent_savers_of_one_file_never_interleave() { + let d = dir("concurrent"); + let path = d.join("a.figcraft"); + let names = ["W0", "W1", "W2", "W3"]; + for round in 0..5 { + let mut handles = Vec::new(); + for name in names { + let (path, doc) = (path.clone(), Document::new(name)); + handles.push(std::thread::spawn(move || save_file(&doc, &path, None))); + } + for h in handles { + // On Windows a rename over a file another writer just replaced can lose the race. + if cfg!(windows) { + let _ = h.join().unwrap(); + } else { + h.join().unwrap().unwrap(); + } + } + // Whatever landed is one writer's complete file, never a mix: a torn archive wouldn't load. + let (doc, _) = crate::load_file(&path).unwrap(); + assert!(names.contains(&doc.name.as_str()), "round {round}: {:?}", doc.name); + } + // No staging file was left stuck. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { + let d = dir("failure"); + // The destination is a directory: the rename fails, our staging file is removed, the + // directory is untouched. + let taken = d.join("folder.figcraft"); + std::fs::create_dir_all(&taken).unwrap(); + assert!(save_file(&Document::new("Mine"), &taken, None).is_err()); + assert!(write_file_atomic(&taken, b"payload").is_err()); + assert!(taken.is_dir()); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); + // A missing parent directory fails without creating anything anywhere. + assert!(write_file_atomic(&d.join("no").join("such.figcraft"), b"payload").is_err()); + assert!(!d.join("no").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // A read-only directory refuses the staging file, and the old destination stays. + let ro = d.join("read-only"); + std::fs::create_dir_all(&ro).unwrap(); + let dest = ro.join("keep.figcraft"); + std::fs::write(&dest, b"keep").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&ro, perms).unwrap(); + // Running as root would defeat the read-only directory: skip instead of failing. + let probe = ro.join(".probe"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + } else { + assert!(write_file_atomic(&dest, b"payload").is_err()); + assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); + assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); + } + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(&ro, perms).unwrap(); + } + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/layout/src/depth_tests.rs b/crates/layout/src/depth_tests.rs new file mode 100644 index 00000000..f8b597a3 --- /dev/null +++ b/crates/layout/src/depth_tests.rs @@ -0,0 +1,95 @@ +//! Deeply nested layers and a resize (#210): constraints, group scaling and auto layout fill +//! follow a resize down to [`MAX_NESTING`] levels and stop there, instead of running out of stack. +//! Each case runs on a 1 MB stack, the main thread's on Windows; thousands of levels overflowed it. + +use super::*; +use figcraft_doc::{AutoLayout, Constraints, Document, HConstraint, NodeKind, VConstraint}; + +/// Far deeper than the nesting the layout follows. +const DEEP: usize = 5000; + +fn on_small_stack(f: impl FnOnce() + Send + 'static) { + std::thread::Builder::new().stack_size(1 << 20).spawn(f).unwrap().join().unwrap(); +} + +fn width(d: &Document, id: NodeId) -> f64 { + d.node(id).unwrap().width +} + +/// A 100 × 100 frame holding `depth` frames, each inside the last and stretching with it; the +/// root and the chain from the top. +fn stretching_frames(depth: usize) -> (Document, NodeId, Vec) { + let mut d = Document::new("deep"); + let page = d.first_page().unwrap(); + let root = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + let mut chain = Vec::new(); + let mut parent = root; + for _ in 0..depth { + let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(f).unwrap().constraints = Constraints { horizontal: HConstraint::LeftRight, vertical: VConstraint::TopBottom }; + chain.push(f); + parent = f; + } + (d, root, chain) +} + +#[test] +fn deeply_nested_frames_follow_their_constraints_without_running_out_of_stack() { + on_small_stack(|| { + let (mut d, root, chain) = stretching_frames(DEEP); + set_size(&mut d, root, 200.0, 200.0); + apply_constraints(&mut d, root, 100.0, 100.0); + assert_eq!(width(&d, chain[0]), 200.0); + assert_eq!(width(&d, chain[MAX_NESTING - 2]), 200.0); + assert_eq!(width(&d, chain[DEEP - 1]), 100.0, "past the nesting the layout follows, layers stay"); + }); +} + +#[test] +fn a_shallow_chain_follows_all_the_way_down() { + let (mut d, root, chain) = stretching_frames(20); + set_size(&mut d, root, 200.0, 200.0); + apply_constraints(&mut d, root, 100.0, 100.0); + assert!(chain.iter().all(|f| width(&d, *f) == 200.0)); +} + +#[test] +fn deeply_nested_groups_scale_without_running_out_of_stack() { + on_small_stack(|| { + let mut d = Document::new("deep"); + let mut parent = d.first_page().unwrap(); + let root = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + parent = root; + let mut chain = Vec::new(); + for _ in 0..DEEP { + parent = d.create(parent, NodeKind::Group, "G", 0.0, 0.0, 100.0, 100.0).unwrap(); + chain.push(parent); + } + scale_group_children(&mut d, root, 2.0, 2.0); + assert_eq!(width(&d, chain[0]), 200.0); + }); +} + +#[test] +fn deeply_nested_fill_frames_lay_out_without_running_out_of_stack() { + on_small_stack(|| { + let mut d = Document::new("deep"); + let page = d.first_page().unwrap(); + let root = d.create(page, NodeKind::Frame, "F", 0.0, 0.0, 100.0, 100.0).unwrap(); + d.get_mut(root).unwrap().layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() }); + let mut parent = root; + let mut chain = Vec::new(); + for _ in 0..DEEP { + let f = d.create(parent, NodeKind::Frame, "F", 0.0, 0.0, 10.0, 10.0).unwrap(); + let n = d.get_mut(f).unwrap(); + n.layout = Some(AutoLayout { padding: [0.0; 4], ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Fill; + chain.push(f); + parent = f; + } + d.get_mut(root).unwrap().width = 300.0; + layout_frame(&mut d, root, &NoMeasure); + assert_eq!(width(&d, chain[0]), 300.0); + assert_eq!(width(&d, chain[DEEP - 1]), 10.0, "past the nesting the layout follows, layers stay"); + }); +} diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index fd7ff00b..02e7bdaf 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -20,6 +20,30 @@ use figcraft_doc::{ }; use figcraft_geom::{Affine, Point, Rect}; +/// Most layout calls nested in one another (about one per level of layers): deeper layers don't +/// follow a resize, so a deeply nested document can't run the stack out (#210). +pub const MAX_NESTING: usize = 256; + +thread_local! { + static NESTING: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// One level of nested layout calls, released when dropped. +struct Nested; + +impl Nested { + /// Enter one more level; `None` when [`MAX_NESTING`] are in progress. + fn enter() -> Option { + NESTING.with(|n| (n.get() < MAX_NESTING).then(|| n.set(n.get() + 1)).map(|()| Self)) + } +} + +impl Drop for Nested { + fn drop(&mut self) { + NESTING.with(|n| n.set(n.get().saturating_sub(1))); + } +} + /// Measures text for a given width (auto-height text inside a Fill container). pub trait Measure { /// The height a text node needs at `width`. @@ -311,6 +335,7 @@ struct Item { /// Lay out one auto layout frame's children and size the frame if it hugs. pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) { + let Some(_nested) = Nested::enter() else { return }; let Some(f) = doc.node(frame) else { return }; let Some(al) = f.layout.clone() else { return }; let horizontal = matches!(al.mode, LayoutMode::Horizontal); @@ -893,6 +918,7 @@ fn layout_grid(doc: &mut Document, frame: NodeId, al: &AutoLayout, m: &dyn Measu /// (recursively for children frames that resize). In an auto layout frame only the children that /// ignore auto layout (absolute position) follow constraints; the layout places the others. pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { + let Some(_nested) = Nested::enter() else { return }; let Some(f) = doc.node(id) else { return }; let absolute_only = f.layout.is_some(); let (nw, nh) = (f.width, f.height); @@ -977,6 +1003,7 @@ pub fn apply_constraints(doc: &mut Document, id: NodeId, ow: f64, oh: f64) { /// Each child keeps its rotation and flip and scales along its own axes ([`axis_factors`]), its /// centre following the group's scale. pub fn scale_group_children(doc: &mut Document, id: NodeId, kx: f64, ky: f64) { + let Some(_nested) = Nested::enter() else { return }; let Some(n) = doc.node(id) else { return }; for c in n.children.clone() { let Some(cn) = doc.node(c) else { continue }; @@ -1101,6 +1128,8 @@ mod behaviour_tests; #[cfg(test)] mod content_align_tests; #[cfg(test)] +mod depth_tests; +#[cfg(test)] mod nested_tests; #[cfg(test)] mod tests; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b8d3f3ad..9f4d5b8f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -3,6 +3,7 @@ //! behave the same. use base64::Engine as _; +use figcraft_engine::params::normalize_paint; use serde_json::{Map, Value, json}; use crate::backend::{Backend, decode_data, page_children}; @@ -369,74 +370,17 @@ fn bounds_of(b: &mut dyn Backend, ids: &[u64]) -> Option<(f64, f64, f64, f64)> { acc } -/// Make paint/effect JSON friendlier than the engine's strict serde: any `"color"` given as a CSS -/// string ("#RRGGBB[AA]", "rgb(…)") becomes the engine's {r,g,b,a} object, gradient paints get -/// default handles (top-to-bottom) when `from`/`to`/`width` are missing, and gradient stops given -/// as plain colours are spread evenly. +/// Make paint/effect JSON friendlier than the engine's strict serde (see +/// [`figcraft_engine::params::normalize_paint`]). The engine reads paints this way itself; effects +/// still need it here. pub fn normalize_props(props: &mut Map) { for k in ["fills", "strokes", "effects"] { if let Some(v) = props.get_mut(k) { - normalize_paintish(v, 0); + normalize_paint(v); } } } -fn normalize_paintish(v: &mut Value, depth: usize) { - if depth > 16 { - return; - } - match v { - Value::Array(a) => { - for x in a { - normalize_paintish(x, depth + 1); - } - } - Value::Object(o) => { - if let Some(c) = o.get("color").filter(|c| c.is_string()).and_then(figcraft_engine::params::color_of) { - o.insert("color".into(), serde_json::to_value(c).unwrap_or(Value::Null)); - } - let is_gradient = o.get("type").and_then(Value::as_str).is_some_and(|t| t.to_ascii_lowercase().contains("gradient")); - if is_gradient { - // Accept {"type":"linearGradient"} as well as {"type":"gradient","kind":"linear"}. - if let Some(t) = o.get("type").and_then(Value::as_str).map(str::to_ascii_lowercase) - && t != "gradient" - { - let kind = t.trim_end_matches("gradient").to_string(); - o.insert("type".into(), json!("gradient")); - o.entry("kind").or_insert(json!(if kind.is_empty() { "linear".to_string() } else { kind })); - } - o.entry("kind").or_insert(json!("linear")); - let linear = o.get("kind").and_then(Value::as_str) == Some("linear"); - let (from, to, width) = if linear { ([0.5, 0.0], [0.5, 1.0], [-0.5, 0.0]) } else { ([0.5, 0.5], [0.5, 1.0], [1.0, 0.5]) }; - o.entry("from").or_insert(json!(from)); - o.entry("to").or_insert(json!(to)); - // A linear gradient's width handle goes square to its axis (else its bands skew, - // or vanish when the handle lies on the axis). - let pt = |k: &str| o.get(k).and_then(Value::as_array).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?))); - if linear - && !o.contains_key("width") - && let (Some(f), Some(t)) = (pt("from"), pt("to")) - { - o.insert("width".into(), json!([f.0 - (t.1 - f.1), f.1 + (t.0 - f.0)])); - } - o.entry("width").or_insert(json!(width)); - if let Some(Value::Array(stops)) = o.get_mut("stops") { - let n = stops.len(); - for (i, s) in stops.iter_mut().enumerate() { - if let Some(c) = figcraft_engine::params::color_of(s).filter(|_| s.is_string()) { - let pos = if n > 1 { i as f64 / (n - 1) as f64 } else { 0.0 }; - *s = json!({"position": pos, "color": c}); - } else { - normalize_paintish(s, depth + 1); - } - } - } - } - } - _ => {} - } -} - /// [`normalize_props`] for command params: `props`, flat node.set keys, `paint`, `effect`. fn friendly_params(p: &mut Value) { let Some(o) = p.as_object_mut() else { return }; @@ -446,7 +390,7 @@ fn friendly_params(p: &mut Value) { normalize_props(o); for k in ["paint", "effect"] { if let Some(v) = o.get_mut(k) { - normalize_paintish(v, 0); + normalize_paint(v); } } } diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs index 9f771b81..890b254d 100644 --- a/crates/text/src/tail_tests.rs +++ b/crates/text/src/tail_tests.rs @@ -130,14 +130,23 @@ fn hanging_punctuation_outside_the_aligned_edge() { t.auto_resize = TextAutoResize::Height; let Some(plain) = laid(&t) else { return }; let open = plain.glyphs[0].advance; + // Kerning may draw a glyph off its pen position (an x offset, e.g. Helvetica Neue's "re"), so + // each hanging line is checked against the same line set flush: every glyph moves by exactly + // the hanging mark's advance, which puts the letters' pen edge on the box edge. + let moved = |a: &TextLayout, b: &TextLayout, by: f64| { + a.glyphs.len() == b.glyphs.len() && a.glyphs.iter().zip(&b.glyphs).all(|(a, b)| (a.x - b.x - by).abs() < EPS) + }; t.hanging_punctuation = true; let l = laid(&t).unwrap(); - assert!((l.glyphs[0].x + open).abs() < EPS && l.glyphs[1].x.abs() < EPS, "left: the quote hangs, Q starts the line"); + assert!(plain.glyphs[0].x.abs() < EPS && moved(&l, &plain, -open), "left: the quote hangs, Q starts the line"); t.align_h = TextAlignH::Right; + t.hanging_punctuation = false; + let flush = laid(&t).unwrap(); + t.hanging_punctuation = true; let l = laid(&t).unwrap(); - let last = l.glyphs.last().unwrap(); - let before = &l.glyphs[l.glyphs.len() - 2]; - assert!((before.x + before.advance - 400.0).abs() < EPS && last.x >= 400.0 - EPS, "right: the full stop hangs past the edge"); + let stop = l.glyphs.last().unwrap().advance; + let edge = flush.lines[0].x + flush.lines[0].width; + assert!((edge - 400.0).abs() < EPS && moved(&l, &flush, stop), "right: the full stop hangs past the edge"); t.align_h = TextAlignH::Center; let (a, b) = (laid(&t).unwrap(), { t.hanging_punctuation = false; diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 23a0f5e2..fb81b042 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -20,7 +20,10 @@ status = "draft — needs native review" "@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}:PR {prs} 件、コミット {commits} 件、+{added} / −{deleted} 行(Δ {delta})、バイナリアセット +{bin_added} / −{bin_deleted}、{first} – {last}" "A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "このファイルのコレクションとそのモード・変数:モードごとに値を上書きし、独自の変数を追加できる" "A layer on its own aligns to its frame: select more layers, or one in a frame" = "単独のレイヤーはフレームに整列します:レイヤーを追加で選択するか、フレーム内のレイヤーを選択" +"A responsive product page: hero, features, figures, a quote and a footer" = "レスポンシブな製品ページ:ヒーロー、機能、数値、引用、フッター" "A spring plays until it settles: its mass, stiffness and damping set how long" = "スプリングは静止するまで再生。長さは質量・剛性・減衰で決まる" +"A title, an agenda, a big figure, a chart and a closing slide" = "タイトル、目次、大きな数値、グラフ、締めのスライド" +"A to Z" = "昇順(A→Z)" "About" = "情報" "About FigCraft" = "FigCraft について" "About this server" = "このサーバーについて" @@ -91,7 +94,9 @@ status = "draft — needs native review" "All supported properties" = "サポートされているすべてのプロパティ" "All variables" = "すべての変数" "Alpha" = "アルファ" +"Alphabetical" = "名前順" "Alt+drag" = "Alt+ドラッグ" +"An analytics screen on a grid: figures, charts drawn from vectors and a table" = "グリッドに沿った分析画面:数値、ベクターで描いたグラフ、表" "An invite link that opens FigCraft and joins" = "FigCraft を開いて参加する招待リンク" "Anchor" = "アンカー" "Android" = "Android" @@ -110,6 +115,8 @@ status = "draft — needs native review" "Apply variable" = "変数を適用" "Apply variable to text" = "テキストに変数を適用" "Apply variable to visibility" = "表示に変数を適用" +"Apps" = "アプリ" +"Apr" = "4月" "Arc" = "円弧" "Arc ratio" = "円弧の比率" "Arc start" = "円弧の開始" @@ -121,6 +128,7 @@ status = "draft — needs native review" "As typed" = "入力どおり" "Asking the server…" = "サーバーに問い合わせ中…" "Assets" = "アセット" +"Aug" = "8月" "Auto" = "自動" "Auto (space around)" = "自動(周囲に間隔)" "Auto (space between)" = "自動(間に間隔)" @@ -165,6 +173,7 @@ status = "draft — needs native review" "Bottom" = "下" "Bottom left radius" = "左下の半径" "Bottom right radius" = "右下の半径" +"Bound to {name}. Click to detach the variable." = "「{name}」を適用中。クリックで変数の割り当てを解除。" "Branch" = "ブランチ" "Branch archived" = "ブランチをアーカイブ済み" "Branch merged" = "ブランチをマージ済み" @@ -251,6 +260,7 @@ status = "draft — needs native review" "Color values in this file are in this color space." = "このファイルのカラー値はこのカラースペースで表される。" "Color variables" = "カラー変数" "Colour" = "カラー" +"Colour and text styles, Light and Dark variables, buttons, inputs and cards" = "カラー・テキストスタイル、ライト・ダークの変数、ボタン、入力欄、カード" "Column" = "列" "Column gap" = "列の間隔" "Column sizes" = "列のサイズ" @@ -317,6 +327,7 @@ status = "draft — needs native review" "Create property…" = "プロパティを作成…" "Create style" = "スタイルを作成" "Create style from the selected text" = "選択したテキストからスタイルを作成" +"Created" = "作成日" "Crop" = "切り抜き" "Crop marks" = "トンボ" "Ctrl+arrows" = "Ctrl+矢印キー" @@ -330,6 +341,9 @@ status = "draft — needs native review" "Dark" = "ダーク" "Darken" = "比較(暗)" "Dash" = "破線" +"Dashboard" = "ダッシュボード" +"Date created" = "作成日" +"Dec" = "12月" "Decoration style" = "装飾のスタイル" "Decoration" = "装飾" "Deep select" = "深い階層を選択" @@ -356,6 +370,7 @@ status = "draft — needs native review" "Description" = "説明" "Description (optional)" = "説明(任意)" "Design" = "デザイン" +"Design system starter" = "デザインシステムの雛形" "Desktop" = "デスクトップ" "Destination" = "遷移先" "Detach alias" = "エイリアスを解除" @@ -391,6 +406,7 @@ status = "draft — needs native review" "Drafts" = "下書き" "Drag the stroke to add a width point" = "線をドラッグして幅のポイントを追加" "Draw" = "描画" +"Draw a frame" = "フレームを描く" "Drawing tools" = "描画ツール" "Drop shadow" = "ドロップシャドウ" "Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "MIT または Apache-2.0 のデュアルライセンス。ArtCraft チームとコミュニティが制作。" @@ -409,6 +425,7 @@ status = "draft — needs native review" "Edit style" = "スタイルを編集" "Edit variable" = "変数を編集" "Edit with the builder" = "ビルダーで編集" +"Edited {when}" = "最終編集:{when}" "Effect settings" = "エフェクトの設定" "Effect styles" = "エフェクトスタイル" "Effects" = "エフェクト" @@ -439,6 +456,7 @@ status = "draft — needs native review" "Expand" = "展開" "Export" = "書き出し" "Export all" = "すべて書き出し" +"Export frames and layers as PNG, JPG, SVG or PDF" = "フレームやレイヤーを PNG、JPG、SVG、PDF で書き出し" "Export page as PNG" = "ページを PNG で書き出し" "Export settings" = "書き出し設定" "Export variables" = "変数を書き出し" @@ -458,9 +476,11 @@ status = "draft — needs native review" "F" = "F" "Fade" = "フェード" "False" = "false" +"Feb" = "2月" "Fetching from the server…" = "サーバーから取得中…" "File" = "ファイル" "File name" = "ファイル名" +"File not found" = "ファイルが見つからない" "File published" = "ファイルを公開済み" "Files aren't end-to-end encrypted: the server's operator can read them." = "ファイルはエンドツーエンドで暗号化されない:サーバーの運営者は内容を読める。" "Files nobody opens for {n} days are deleted." = { other = "{n} 日間誰も開かなかったファイルは削除される。" } @@ -507,6 +527,7 @@ status = "draft — needs native review" "Font size" = "フォントサイズ" "Font style" = "フォントスタイル" "Forget on this computer" = "このコンピューターで記録を消去" +"Four screens of a trail app with components and a prototype flow" = "コンポーネントとプロトタイプのフローを備えた登山アプリの4画面" "Fractions" = "分数" "Frame" = "フレーム" "Frame selection" = "選択範囲をフレームで囲む" @@ -520,6 +541,7 @@ status = "draft — needs native review" "Gap between columns" = "列の間隔" "Gap between items" = "アイテムの間隔" "Gap between rows" = "行の間隔" +"Getting started" = "使い始めるには" "Give the address of a server open to the public, or a server's address with its key (`figcraft-server key` prints it)." = "一般公開されているサーバーのアドレス、またはキー付きのサーバーのアドレスを入力(`figcraft-server key` で表示)。" "Glass" = "ガラス" "Go to main component" = "メインコンポーネントへ移動" @@ -528,6 +550,7 @@ status = "draft — needs native review" "Gradient" = "グラデーション" "Gray" = "グレー" "Green" = "緑" +"Greyscale components and two example screens to sketch a flow with" = "フローを考えるためのグレースケールのコンポーネントと2つの画面例" "Grid" = "グリッド" "Grid ({size})" = "グリッド({size})" "Grid styles" = "グリッドスタイル" @@ -549,6 +572,8 @@ status = "draft — needs native review" "Hide contents" = "コンテンツを非表示" "Hide from publishing" = "公開対象から除外" "Hide section contents" = "セクションのコンテンツを非表示" +"Hide these tips (Getting started in the Help menu shows them again)" = "ヒントを非表示(ヘルプメニューの「使い始めるには」で再表示)" +"Hide when publishing" = "公開時に非表示" "Highlight layers on hover" = "ホバー時にレイヤーをハイライト" "Highlights" = "ハイライト" "Hold Alt" = "Alt を押したまま" @@ -570,6 +595,8 @@ status = "draft — needs native review" "Hue" = "色相" "Hug" = "ハグ" "Hug contents" = "内容に合わせる" +"Icon components on a 24 px grid with keylines and a size preview" = "24 px グリッドのアイコンコンポーネント、キーライン、サイズのプレビュー" +"Icon set starter" = "アイコンセットの雛形" "If" = "条件" "If {condition}" = "条件:{condition}" "Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)" @@ -614,6 +641,7 @@ status = "draft — needs native review" "Invite link" = "招待リンク" "Invite link copied" = "招待リンクをコピー済み" "It asks permission to:" = "次の権限を求めている:" +"Jan" = "1月" "Join" = "参加" "Join code copied" = "参加コードをコピー済み" "Join code or invite link" = "参加コードまたは招待リンク" @@ -624,6 +652,8 @@ status = "draft — needs native review" "Joined {file}" = "{file} に参加済み" "Joining…" = "参加中…" "Join|stroke" = "角の形状" +"Jul" = "7月" +"Jun" = "6月" "Just now" = "たった今" "Justify" = "両端揃え" "Keep appearance" = "見た目を保持" @@ -633,11 +663,14 @@ status = "draft — needs native review" "Key/Gamepad" = "キー/ゲームパッド" "Key/Gamepad {key}" = "キー/ゲームパッド {key}" "Keyboard shortcuts" = "キーボードショートカット" +"Kits" = "キット" +"Landing page" = "ランディングページ" "Language" = "言語" "Lasso" = "なげなわ" "Last" = "最後" "Last change: {note}" = "最後の変更:{note}" "Last commit" = "最後のコミット" +"Last modified" = "更新日" "Last on top" = "最後を上に" "Layer blur" = "レイヤーのぼかし" "Layer name" = "レイヤー名" @@ -676,6 +709,7 @@ status = "draft — needs native review" "Link" = "リンク" "Link copied" = "リンクをコピー済み" "Link docs, code or tickets for developers." = "開発者向けにドキュメント、コード、チケットをリンク。" +"Link frames on the Prototype tab, then present the flow" = "プロトタイプタブでフレームをつなぎ、フローを再生" "Link this component to its code component: instances then show your real snippet with their values." = "このコンポーネントをコードコンポーネントにリンクすると、インスタンスに実際のスニペットがその値とともに表示される。" "List spacing" = "リストの間隔" "List style" = "リストのスタイル" @@ -693,7 +727,9 @@ status = "draft — needs native review" "Main" = "メイン" "Main component deleted" = "メインコンポーネントは削除済み" "Main menu" = "メインメニュー" +"Make a component" = "コンポーネントを作成" "Make slot" = "スロットにする" +"Mar" = "3月" "Margin" = "マージン" "Mark all as read" = "すべて既読にする" "Mark as resolved" = "解決済みにする" @@ -706,6 +742,7 @@ status = "draft — needs native review" "Max W" = "最大 W" "Max lines" = "最大行数" "Maximize" = "最大化" +"May" = "5月" "Measure to the selection" = "選択範囲までの距離を測定" "Merge into main" = "メインにマージ" "Merge regions" = "領域を結合" @@ -727,6 +764,7 @@ status = "draft — needs native review" "Mixed" = "混在" "Mixed fonts" = "混在フォント" "Mixed interactions: click + to replace them" = "混在したインタラクション:+ をクリックして置き換え" +"Mobile app" = "モバイルアプリ" "Mode" = "モード" "Model" = "モデル" "Models" = "モデル" @@ -756,6 +794,7 @@ status = "draft — needs native review" "Navigate to" = "移動先" "Nested instance" = "ネストされたインスタンス" "New design file" = "新規デザインファイル" +"Newest first" = "新しい順" "Next or previous control in the focused region" = "フォーカス中の領域で次または前のコントロール" "Next ready for dev" = "次の開発準備完了" "Next size option" = "次のサイズオプション" @@ -798,6 +837,7 @@ status = "draft — needs native review" "No string variables yet. Create them in Local variables." = "文字列変数はまだない。ローカル変数で作成。" "No styles yet." = "スタイルはまだない。" "No styles yet. Click + to create one from the selection." = "スタイルはまだない。+ をクリックして選択範囲から作成。" +"No templates match your search" = "検索に一致するテンプレートなし" "No text styles yet. Click + to create one from the selected text." = "テキストスタイルはまだない。+ をクリックして選択したテキストから作成。" "No variables fit here." = "ここに使える変数はない。" "No variables yet. Create them in Local variables." = "変数はまだない。ローカル変数で作成。" @@ -817,6 +857,7 @@ status = "draft — needs native review" "Nothing here yet. Set a frame's status to list it." = "まだ何もない。フレームのステータスを設定すると一覧に表示される。" "Nothing online yet. Use Share on a file to put it online and it is listed here." = "オンラインのファイルはまだない。ファイルの「共有」からオンラインにすると、ここに表示される。" "Nothing to export" = "書き出すものがない" +"Nov" = "11月" "Now" = "現在" "Nudge (Shift: big nudge)" = "微調整(Shift:大きく微調整)" "Nudge amount" = "微調整の量" @@ -825,13 +866,16 @@ status = "draft — needs native review" "Number ↓" = "番号 ↓" "Numbered list" = "番号付きリスト" "Object" = "オブジェクト" +"Oct" = "10月" "Off" = "オフ" "Offered first when filling the slot (any content is allowed)" = "スロットを埋めるときに最初に提示(どのコンテンツも使用可)" "Offered first when swapping the instance (any component is allowed)" = "インスタンスを入れ替えるときに最初に提示(どのコンポーネントも使用可)" "Offset" = "オフセット" +"Oldest first" = "古い順" "On click" = "クリック時" "On drag" = "ドラッグ時" "On this page" = "このページ" +"One announcement in five common sizes: square, portrait, story, link and banner" = "1つのお知らせを5種類の一般的なサイズで:正方形、縦長、ストーリー、リンク、バナー" "Online" = "オンライン" "Online files" = "オンラインファイル" "Online sharing is off: files are shared on the local network only." = "オンライン共有はオフ:ファイルはローカルネットワークでのみ共有される。" @@ -858,6 +902,7 @@ status = "draft — needs native review" "Open|comments" = "未解決" "Options" = "オプション" "Orange" = "オレンジ" +"Order" = "並び順" "Other apps may not open every .fig file exported here." = "ここで書き出した .fig ファイルは、他のアプリで開けない場合がある。" "Others lose access and keep their copy; the file stays on the server for you" = "他の人はアクセスできなくなり、各自のコピーは残る。ファイルはあなた用にサーバーに残る" "Others type this under Share ▸ Join with a code" = "他の人は「共有 ▸ コードで参加」でこれを入力" @@ -904,6 +949,7 @@ status = "draft — needs native review" "Pick color" = "色を抽出" "Pick color (I)" = "色を抽出(I)" "Pick one font family first" = "先にフォントファミリーを 1 つ選択" +"Pick the Frame tool, then drag on the canvas or choose a device size" = "フレームツールを選び、キャンバスをドラッグするか端末のサイズを選択" "Pin a property" = "プロパティを固定" "Pinch" = "すぼめる" "Pixel grid" = "ピクセルグリッド" @@ -912,6 +958,7 @@ status = "draft — needs native review" "Place a shape or frame (its tool chosen)" = "シェイプまたはフレームを配置(ツール選択中)" "Place automatically" = "自動で配置" "Place image" = "画像を配置" +"Playground" = "プレイグラウンド" "Plugins" = "プラグイン" "Plugins run in FigCraft's sandbox, without files, network or clock. Install only plugins you trust." = "プラグインは FigCraft のサンドボックス内で、ファイル、ネットワーク、時計なしで実行される。信頼できるプラグインのみをインストールすること。" "Plus darker" = "プラス(暗)" @@ -929,6 +976,7 @@ status = "draft — needs native review" "Present or preview" = "プレゼンテーションまたはプレビュー" "Present this flow" = "このフローをプレゼンテーション" "Presentation" = "プレゼンテーション" +"Presentation slides" = "プレゼンテーションのスライド" "Presenting: comments" = "プレゼンテーション中:コメント" "Presenting: cycle the size options" = "プレゼンテーション中:サイズオプションを切り替え" "Presenting: full screen" = "プレゼンテーション中:全画面表示" @@ -984,6 +1032,7 @@ status = "draft — needs native review" "Remove auto layout" = "オートレイアウトを解除" "Remove dev resource" = "開発リソースを削除" "Remove export setting" = "書き出し設定を削除" +"Remove from recents" = "最近使ったファイルから削除" "Remove from server" = "サーバーから削除" "Remove from server…" = "サーバーから削除…" "Remove guide" = "ガイドを削除" @@ -1093,15 +1142,18 @@ status = "draft — needs native review" "Search layers" = "レイヤーを検索" "Search plugins" = "プラグインを検索" "Search shortcuts" = "ショートカットを検索" +"Search templates" = "テンプレートを検索" "Section" = "セクション" "Section color" = "セクションの色" "Sections" = "セクション" +"See all templates" = "すべてのテンプレートを見る" "Select a frame and add a starting point to present it as a flow." = "フレームを選択して開始点を追加すると、フローとしてプレゼンテーションできる。" "Select a frame to preview it" = "フレームを選択するとプレビュー" "Select a layer to see its properties and code" = "レイヤーを選択するとプロパティとコードを表示" "Select all" = "すべて選択" "Select all with same" = "同じ項目をすべて選択" "Select layer" = "レイヤーを選択" +"Select layers and they space themselves as their content changes" = "レイヤーを選択すると、内容の変化に合わせて間隔を自動調整" "Select layers to apply the style to" = "スタイルを適用するレイヤーを選択" "Select overlapping shapes" = "重なったシェイプを選択" "Select points to edit their position, radius and mirroring" = "ポイントを選択すると位置、半径、対称を編集できる" @@ -1112,6 +1164,7 @@ status = "draft — needs native review" "Send backward" = "背面へ" "Send report" = "報告を送信" "Send to back" = "最背面へ" +"Sep" = "9月" "Server address" = "サーバーのアドレス" "Server address (ws://host:47900/)" = "サーバーのアドレス(ws://host:47900/)" "Server for online sharing" = "オンライン共有用のサーバー" @@ -1126,6 +1179,7 @@ status = "draft — needs native review" "Shape tools" = "シェイプツール" "Share" = "共有" "Share this file" = "このファイルを共有" +"Share with a code" = "コードで共有" "Share: live session, export" = "共有:ライブセッション、書き出し" "Shared" = "共有中" "Shared: copy its code from the file's menu" = "共有済み:コードはファイルのメニューからコピー" @@ -1135,6 +1189,7 @@ status = "draft — needs native review" "Sharing online · {people}" = "オンラインで共有中 · {people}" "Sharing stopped: the file stays on the server, open only to you" = "共有を停止:ファイルはサーバーに残り、開けるのはあなただけ" "Show" = "表示" +"Show all templates" = "すべてのテンプレートを表示" "Show as grid" = "グリッドで表示" "Show as list" = "リストで表示" "Show autosaves" = "自動保存を表示" @@ -1143,6 +1198,9 @@ status = "draft — needs native review" "Show device frame" = "デバイスフレームを表示" "Show dimensions on objects" = "オブジェクトに寸法を表示" "Show hotspot hints" = "ホットスポットのヒントを表示" +"Show in Explorer" = "エクスプローラーで表示" +"Show in Finder" = "Finder で表示" +"Show in folder" = "フォルダーで表示" "Show only this on the canvas" = "キャンバスにこれだけを表示" "Show open" = "未解決を表示" "Show section contents" = "セクションのコンテンツを表示" @@ -1167,13 +1225,17 @@ status = "draft — needs native review" "Small caps" = "スモールキャップ" "Smart animate" = "スマートアニメート" "Snap to pixel grid" = "ピクセルグリッドにスナップ" +"Social and slides" = "SNS・スライド" "Social media" = "ソーシャルメディア" +"Social post sizes" = "SNS投稿サイズ" "Soft light" = "ソフトライト" "Solid" = "実線" "Some variants have the same property values: give each one a unique combination" = "同じプロパティ値を持つバリアントがある:それぞれ固有の組み合わせにする" "Sort" = "並べ替え" +"Sort by" = "並べ替えの基準" "Sort by date" = "日付で並べ替え" "Sort by page" = "ページで並べ替え" +"Sort by {column}" = "{column}で並べ替え" "Space around" = "周囲に均等配置" "Space between" = "間に均等配置" "Space drag" = "Space ドラッグ" @@ -1192,9 +1254,11 @@ status = "draft — needs native review" "Start" = "開始" "Start ascending from / stop descending at" = "昇順の開始値 / 降順の終了値" "Start from a blank canvas" = "空白のキャンバスから始める" +"Start from a template" = "テンプレートから始める" "Start on path" = "パス上の開始位置" "Start point" = "始点" "Start sharing" = "共有を開始" +"Start sharing with the Share button, then pass on the code it gives" = "共有ボタンで共有を開始し、表示されたコードを相手に伝える" "Starting to share…" = "共有を開始中…" "Status" = "ステータス" "Sticky" = "固定" @@ -1243,6 +1307,7 @@ status = "draft — needs native review" "Tell the server's operator what is wrong with it" = "何が問題かをサーバーの運営者に伝える" "Temperature" = "色温度" "Template" = "テンプレート" +"Templates" = "テンプレート" "Terms" = "利用規約" "Text" = "テキスト" "Text align" = "テキストの配置" @@ -1300,6 +1365,8 @@ status = "draft — needs native review" "Truncate with an ellipsis" = "省略記号で切り詰める" "Try again" = "再試行" "Try another search or filter." = "別の検索語やフィルターを試す。" +"Try another word, or look at every kind." = "別の単語で検索するか、すべての種類を表示。" +"Turn a layer into a component, reuse it, and change every copy at once" = "レイヤーをコンポーネントに変換して再利用し、すべてのコピーを一括で変更" "Two digits" = "2 桁" "Type" = "種類" "Type settings" = "文字の設定" @@ -1335,6 +1402,7 @@ status = "draft — needs native review" "Use the layer's fill" = "レイヤーの塗りを使用" "Used in {files} · {instances}" = "{files}で使用 · {instances}" "Username" = "ユーザー名" +"Uses variables: each layer shows them in its own mode" = "変数を使用:各レイヤーに設定されたモードで表示" "Value" = "値" "Variable" = "変数" "Variable collections" = "変数コレクション" @@ -1372,12 +1440,14 @@ status = "draft — needs native review" "Web" = "Web" "WebSocket URL" = "WebSocket URL" "WebSocket URL copied" = "WebSocket URL をコピー済み" +"Websites" = "Webサイト" "Wedge" = "くさび" "Weight" = "太さ" "What changed since it was marked ready for dev" = "開発準備完了にしてからの変更内容" "What is wrong with this file? The server's operator gets your report." = "このファイルの何が問題ですか? 報告はサーバーの運営者に届く。" "What it's for" = "用途" "What this changed from isn't known: it changed before FigCraft kept a copy of ready-for-dev frames. Resolve the change (or mark it ready again) to compare from now on." = "変更前の状態は不明:FigCraft が開発準備完了フレームのコピーを保存するようになる前に変更された。変更を解決する(または再度準備完了にする)と、今後はそこから比較する。" +"What you try here changes this preview and the code below, not the file" = "ここでの操作はプレビューと下のコードに反映。ファイルは変更されない" "While hovering" = "ホバー中" "While pressing" = "押している間" "White" = "白" @@ -1387,6 +1457,7 @@ status = "draft — needs native review" "Width" = "幅" "Width point" = "幅のポイント" "Width profile" = "幅プロファイル" +"Wireframe kit" = "ワイヤーフレームキット" "With the layers' export settings" = "レイヤーの書き出し設定で" "Wrap" = "折り返し" "Wrap in new section" = "新しいセクションで囲む" @@ -1401,6 +1472,7 @@ status = "draft — needs native review" "Your name in comments" = "コメントに表示する名前" "Your system asks for reduced motion: animations stay off." = "システムで動きを減らす設定がオンのため、アニメーションはオフのまま。" "Z" = "Z" +"Z to A" = "降順(Z→A)" "Zoom" = "ズーム" "Zoom around the pointer" = "ポインターを中心にズーム" "Zoom device to fill window" = "デバイスをウィンドウいっぱいにズーム" @@ -1427,6 +1499,7 @@ status = "draft — needs native review" "from {server}" = "{server} から" "hidden" = "非表示" "iOS" = "iOS" +"just now" = "たった今" "locked" = "ロック中" "no bleed" = "裁ち落としなし" "nobody else here yet" = "まだ他に誰もいない" @@ -1438,9 +1511,11 @@ status = "draft — needs native review" "reads the document" = "ドキュメントを読み取る" "the plugin" = "このプラグイン" "this session only" = "このセッションのみ" +"yesterday" = "昨日" "{instances} in {files}" = "{files}内の{instances}" "{language}: draft translation, waiting for a native speaker's review." = "{language}:下書きの翻訳。ネイティブスピーカーによるレビュー待ち。" "{length} bleed" = "裁ち落とし {length}" +"{month} {day}, {year}" = "{year}年{month}{day}日" "{name}\nClick to apply to the selection" = "{name}\nクリックして選択範囲に適用" "{name}\nDrag onto the canvas, or click to place in the middle of the view.\nHold Alt (Option) while dropping onto an instance to swap it." = "{name}\nキャンバスにドラッグするか、クリックしてビューの中央に配置。\nインスタンスの上にドロップするときに Alt(Option)を押したままにすると入れ替え。" "{name} (from {library})" = "{name}({library} から)" @@ -1602,8 +1677,10 @@ status = "draft — needs native review" "component.editProperty" = "コンポーネントプロパティを編集" "component.exposeInstance" = "ネストされたインスタンスを公開" "component.goToMain" = "メインコンポーネントへ移動" +"component.hideWhenPublishing" = "公開時に非表示" "component.list" = "コンポーネントを一覧表示" "component.makeSlot" = "スロットにする" +"component.playground" = "コンポーネントを試す" "component.properties" = "コンポーネントプロパティ" "component.pushChanges" = "変更をメインコンポーネントにプッシュ" "component.removeSlot" = "スロットを削除" @@ -1661,6 +1738,7 @@ status = "draft — needs native review" "file.exportSvg" = "選択範囲を SVG で書き出し" "file.import" = "インポート" "file.new" = "新規デザインファイル" +"file.newFromTemplate" = "テンプレートから新規作成" "file.open" = "開く" "file.openRecent" = "最近使ったファイルを開く" "file.placeImage" = "画像を配置" @@ -1688,6 +1766,7 @@ status = "draft — needs native review" "guide.remove" = "ガイドを削除" "guide.select" = "ガイドを選択" "help.about" = "FigCraft について" +"help.gettingStarted" = "使い始めるには" "help.page" = "ヘルプページ" "help.shortcuts" = "キーボードショートカット" "image.adjust" = "画像を調整" @@ -1741,6 +1820,8 @@ status = "draft — needs native review" "node.reparent" = "レイヤーを中に移動" "node.set" = "プロパティを設定" "object.batchRename" = "一括で名前を変更" +"object.convertToFrame" = "フレームに変換" +"object.convertToGroup" = "グループに変換" "object.flipH" = "左右反転" "object.flipV" = "上下反転" "object.frameSelection" = "選択範囲をフレームで囲む" @@ -1856,6 +1937,7 @@ status = "draft — needs native review" "stroke.set" = "線を設定" "stroke.widthProfile" = "幅プロファイル" "style.apply" = "スタイルを適用" +"style.bind" = "スタイルに変数を割り当て" "style.create" = "スタイルを作成" "style.delete" = "スタイルを削除" "style.detach" = "スタイルを切り離す" @@ -1864,6 +1946,7 @@ status = "draft — needs native review" "style.move" = "スタイルを移動" "style.renameFolder" = "スタイルフォルダー名を変更" "style.update" = "スタイルを編集" +"template.list" = "テンプレートを一覧表示" "text.alignCenter" = "中央揃え" "text.alignJustify" = "両端揃え" "text.alignLeft" = "左揃え" @@ -2029,8 +2112,11 @@ status = "draft — needs native review" "view.nextPage" = "次のページ" "view.nextRegion" = "次の領域にフォーカス" "view.outlines" = "アウトライン" +"view.outlinesHiddenLayers" = "アウトラインに非表示のレイヤーを含める" +"view.outlinesObjectBounds" = "アウトラインにオブジェクトの境界を含める" "view.outlinesSelection" = "選択範囲のアウトライン" "view.pan" = "ビューをパン" +"view.panelWidths" = "パネルの幅をリセット" "view.pixelGrid" = "ピクセルグリッド" "view.pixelPreview" = "ピクセルプレビュー" "view.prevFrame" = "前のフレームにズーム" @@ -2088,6 +2174,8 @@ status = "draft — needs native review" "view.design" = "デザイン" "view.layers" = "レイヤー" "view.outlines" = "アウトラインを表示" +"view.outlinesHiddenLayers" = "非表示のレイヤーを含める" +"view.outlinesObjectBounds" = "オブジェクトの境界を含める" "view.outlinesSelection" = "選択範囲のアウトラインを表示" "view.prototype" = "プロトタイプ" "view.themeDark" = "ダーク" diff --git a/crates/ui-egui/src/gradient_shorthand_tests.rs b/crates/ui-egui/src/gradient_shorthand_tests.rs new file mode 100644 index 00000000..db368f8e --- /dev/null +++ b/crates/ui-egui/src/gradient_shorthand_tests.rs @@ -0,0 +1,21 @@ +//! The control channel takes the documented gradient shorthand, as MCP does (#252). + +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +#[test] +fn engine_execute_takes_the_gradient_shorthand() { + let mut u = Ui::new(Session::new()); + let exec = |u: &mut Ui, command: &str, params: serde_json::Value| { + crate::control::handle(&mut u.app, &u.ctx.clone(), "engine.execute", &json!({"command": command, "params": params})) + }; + let r = exec(&mut u, "shape.rectangle", json!({"x": 0, "y": 0, "width": 200, "height": 100})).unwrap()["id"].clone(); + let short = json!({"type": "linearGradient", "stops": ["#ff0000", "#0000ff"], "from": [0, 0.5], "to": [1, 0.5]}); + exec(&mut u, "node.set", json!({"ids": [r], "fills": [short]})).unwrap(); + let n = exec(&mut u, "node.get", json!({"id": r, "depth": 0})).unwrap(); + let f = &n["fills"][0]; + assert_eq!((f["type"].clone(), f["kind"].clone(), f["width"].clone()), (json!("gradient"), json!("linear"), json!([0.0, 1.5])), "{n}"); + assert_eq!(f["stops"].as_array().map(Vec::len), Some(2), "{n}"); +} diff --git a/crates/ui-egui/src/layers_rename_tests.rs b/crates/ui-egui/src/layers_rename_tests.rs index 652522d5..4f5c6af8 100644 --- a/crates/ui-egui/src/layers_rename_tests.rs +++ b/crates/ui-egui/src/layers_rename_tests.rs @@ -100,9 +100,12 @@ fn tab_on_the_last_row_commits_and_ends() { #[test] fn cmd_r_renames_the_selection_and_opens_its_parents() { - // Cmd+R on macOS; Ctrl+R elsewhere, where egui sets both `ctrl` and `command`. + // Cmd+R on macOS; Ctrl+R elsewhere, where egui sets both `ctrl` and `command`. On macOS Ctrl + // comes without `command` and Ctrl+R isn't a shortcut. + let cmd = Modifiers::MAC_CMD | Modifiers::COMMAND; let ctrl = Modifiers { ctrl: true, command: true, ..Default::default() }; - for (k, held) in [Modifiers::MAC_CMD | Modifiers::COMMAND, ctrl].into_iter().enumerate() { + let keys: &[Modifiers] = if cfg!(target_os = "macos") { &[cmd] } else { &[cmd, ctrl] }; + for (k, &held) in keys.iter().enumerate() { let (mut u, _, frame, child, _) = setup(); u.app.session.execute("select.set", &json!({"ids": [child]})).unwrap(); u.frame(vec![]); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index dfa5b4bb..9f166dfa 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -89,6 +89,8 @@ mod frame_label_tests; #[cfg(test)] mod gap_handle_tests; #[cfg(test)] +mod gradient_shorthand_tests; +#[cfg(test)] mod home_recents_tests; #[cfg(test)] mod home_tests; @@ -149,10 +151,14 @@ mod shortcut_keys_tests; #[cfg(test)] mod shortcuts_tests; #[cfg(test)] +mod style_folder_rename_tests; +#[cfg(test)] mod text_enter_tests; #[cfg(test)] mod titlebar_tests; #[cfg(test)] +mod variables_rename_tests; +#[cfg(test)] mod vector_tail_ui_tests; #[cfg(test)] mod web_clipboard_tests; diff --git a/crates/ui-egui/src/style_folder_rename_tests.rs b/crates/ui-egui/src/style_folder_rename_tests.rs new file mode 100644 index 00000000..624edfda --- /dev/null +++ b/crates/ui-egui/src/style_folder_rename_tests.rs @@ -0,0 +1,56 @@ +//! Renaming a folder of local styles, drawn headless with the screen reader tree on: Rename folder +//! in the folder's menu opens its name for editing. On every frame the focused node must be in the +//! AccessKit tree; the platform adapter (UI Automation on Windows) aborts the app when it isn't +//! (the same fault as #224). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; + +/// One frame with `events`, checking the focused node is in the screen reader tree. +fn checked_frame(u: &mut Ui, events: Vec, k: usize) -> Vec { + let out = u.frame_output(events); + let tree = out.platform_output.accesskit_update.expect("an AccessKit update"); + assert!(tree.nodes.iter().any(|(i, _)| *i == tree.focus), "frame {k}: the focused node {:?} isn't in the tree", tree.focus); + flat(&out.shapes) +} + +/// A click with `button` at `p` and the frames that follow, each checked. +fn click(u: &mut Ui, p: Pos2, button: PointerButton) -> Vec { + for _ in 0..5 { + u.frame(vec![Event::PointerMoved(p)]); + } + let mut shapes = Vec::new(); + for (k, pressed) in [Some(true), Some(false)].into_iter().chain(std::iter::repeat_n(None, 20)).enumerate() { + let events = pressed.map(|pressed| Event::PointerButton { pos: p, button, pressed, modifiers: Modifiers::NONE }); + shapes = checked_frame(u, events.into_iter().collect(), k); + } + shapes +} + +#[test] +fn renaming_a_style_folder_keeps_the_focus_in_the_screen_reader_tree() { + let mut s = Session::new(); + for name in ["Brand/Primary", "Brand/Accent"] { + s.execute("style.create", &json!({"type": "paint", "name": name, "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + } + let mut u = Ui::new(s); + u.ctx.enable_accesskit(); + let shapes = checked_frame(&mut u, vec![], 0); + // The Design panel lists the local styles with nothing selected; the folder's menu renames it. + let menu = click(&mut u, text_center(&shapes, "Brand"), PointerButton::Secondary); + click(&mut u, text_center(&menu, "Rename folder"), PointerButton::Primary); + checked_frame(&mut u, vec![Event::Key { key: Key::A, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::COMMAND }], 0); + checked_frame(&mut u, vec![Event::Text("Theme".into())], 1); + for pressed in [true, false] { + checked_frame(&mut u, vec![Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }], 2); + } + for k in 0..5 { + checked_frame(&mut u, vec![], k + 3); + } + let names: Vec = u.app.session.doc().unwrap().styles.iter().map(|s| s.name.clone()).collect(); + assert_eq!(names, ["Theme/Primary", "Theme/Accent"]); +} diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 3a4e7b11..6f0cb2cd 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -1042,7 +1042,10 @@ pub fn local_styles(app: &mut FigApp, ui: &mut Ui, doc: &Document) { let (r, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click()); let text_r = Rect::from_min_max(pos2(r.min.x + widgets::PAD, r.min.y + 2.0), pos2(r.max.x - widgets::PAD, r.max.y - 2.0)); if renaming.as_deref() == Some(*f) { - let (out, open) = widgets::rename_field(ui, folder_rename_id().with(*f), f, text_r, regular(11.0), false); + // Focused as it is first drawn: a focus asked for before its field is drawn + // isn't in the screen reader tree (UI Automation aborts the app, #224). + let start = ui.data_mut(|d| d.remove_temp::(folder_rename_id().with("start"))).unwrap_or(false); + let (out, open) = widgets::rename_field(ui, folder_rename_id().with(*f), f, text_r, regular(11.0), start); if let Some(name) = out { run = Some(("style.renameFolder", json!({"folder": f, "name": name}))); } @@ -1055,7 +1058,7 @@ pub fn local_styles(app: &mut FigApp, ui: &mut Ui, doc: &Document) { crate::right::context_popup(&resp).show(|ui| { if crate::right::menu_item(ui, tr("Rename folder"), "") { ui.data_mut(|d| d.insert_temp(folder_rename_id(), f.to_string())); - ui.memory_mut(|m| m.request_focus(folder_rename_id().with(*f).with("te"))); + ui.data_mut(|d| d.insert_temp(folder_rename_id().with("start"), true)); ui.close(); } }); diff --git a/crates/ui-egui/src/variables_panel.rs b/crates/ui-egui/src/variables_panel.rs index 240a51a5..20137482 100644 --- a/crates/ui-egui/src/variables_panel.rs +++ b/crates/ui-egui/src/variables_panel.rs @@ -47,6 +47,8 @@ pub struct Editor { collection: Option, query: String, rename: Option, + /// The rename just started: its field takes the focus in the frame it's first drawn. + rename_start: bool, /// The variable whose details (name, description, scopes) are open. details: Option, /// The group the table shows ("space" or "color/brand"); None = every variable. @@ -240,15 +242,19 @@ fn rename_id(target: &Rename) -> Id { Id::new(("var_rename", target)) } -fn start_rename(ui: &Ui, st: &mut Editor, target: Rename) { - ui.memory_mut(|m| m.request_focus(rename_id(&target).with("te"))); +/// Start renaming `target`. The field isn't drawn yet (a new collection, mode or variable shows next +/// frame), and focusing it now would put a node that isn't there in the screen reader tree, which +/// AccessKit aborts on (#224): the field takes the focus when drawn. +fn start_rename(st: &mut Editor, target: Rename) { st.rename = Some(target); + st.rename_start = true; } /// The inline editor of a rename in progress: the new name once committed. The rename ends when /// the editor closes. fn rename_field(ui: &mut Ui, st: &mut Editor, target: &Rename, value: &str, rect: Rect, font: egui::FontId) -> Option { - let (out, open) = widgets::rename_field(ui, rename_id(target), value, rect, font, false); + let start = std::mem::take(&mut st.rename_start); + let (out, open) = widgets::rename_field(ui, rename_id(target), value, rect, font, start); if !open { st.rename = None; } @@ -271,7 +277,7 @@ fn sidebar(app: &mut FigApp, ui: &mut Ui, views: &[View], local: &Variables, st: Icon::Plus, tr("Create collection"), ) { - create_collection(app, ui, local, st); + create_collection(app, local, st); } egui::ScrollArea::vertical().id_salt("vars_collections").auto_shrink([false, false]).show(ui, |ui| { for view in views { @@ -332,11 +338,11 @@ fn collection_row(app: &mut FigApp, ui: &mut Ui, view: &View, c: &Collection, st } if view.editable() { if resp.double_clicked() { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Rename"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if menu_item(ui, tr("Delete collection"), "") { @@ -421,11 +427,11 @@ fn group_rows(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: continue; } if resp.double_clicked() { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Rename group"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if menu_item(ui, tr("Delete group"), "") { @@ -468,14 +474,14 @@ fn rename_group(app: &mut FigApp, members: &[&Variable], g: &str, new: &str, st: } } -fn create_collection(app: &mut FigApp, ui: &Ui, vars: &Variables, st: &mut Editor) { +fn create_collection(app: &mut FigApp, vars: &Variables, st: &mut Editor) { let name = unique("Collection", |n| vars.collections.iter().any(|c| c.name == n)); if let Some(id) = app.run("variable.createCollection", json!({"name": name, "modes": ["Mode 1"]})).and_then(|r| r["id"].as_str().map(str::to_string)) { st.library = None; st.collection = Some(id.clone()); - start_rename(ui, st, Rename::Collection(id)); + start_rename(st, Rename::Collection(id)); } } @@ -503,7 +509,7 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { ); ui.add_space(12.0); if widgets::button(ui, tr("Create collection"), true, true).clicked() { - create_collection(app, ui, view.vars, st); + create_collection(app, view.vars, st); } }); }); @@ -599,18 +605,18 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: } ui.painter().with_clip_rect(cell).text(name_pos, Align2::LEFT_CENTER, &m.name, regular(11.0), t.text_secondary); if resp.double_clicked() { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Rename mode"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if menu_item(ui, tr("Duplicate mode"), "") { let name = unique(&format!("{} copy", m.name), |n| coll.modes.iter().any(|x| x.name == n)); let made = app.run("variable.addMode", json!({"collection": coll.id, "name": name, "from": m.id})); if let Some(id) = made.and_then(|r| r["id"].as_str().map(str::to_string)) { - start_rename(ui, st, Rename::Mode(coll.id.clone(), id)); + start_rename(st, Rename::Mode(coll.id.clone(), id)); } ui.close(); } @@ -645,7 +651,7 @@ fn header_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: let name = unique("Mode", |n| coll.modes.iter().any(|m| m.name == n)); if let Some(id) = app.run("variable.addMode", json!({"collection": coll.id, "name": name})).and_then(|r| r["id"].as_str().map(str::to_string)) { - start_rename(ui, st, Rename::Mode(coll.id.clone(), id)); + start_rename(st, Rename::Mode(coll.id.clone(), id)); } } ui.painter().line_segment([r.left_bottom(), r.right_bottom()], Stroke::new(1.0, t.border)); @@ -679,7 +685,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va } if editable { if !inherited && resp.double_clicked() && ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| p.x < name_r.max.x) { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); } crate::right::context_popup(&resp).show(|ui| { if menu_item(ui, tr("Edit variable"), "") { @@ -687,7 +693,7 @@ fn var_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, v: &Va ui.close(); } if !inherited && menu_item(ui, tr("Rename"), "") { - start_rename(ui, st, target.clone()); + start_rename(st, target.clone()); ui.close(); } if !v.overrides.is_empty() && menu_item(ui, tr("Reset overrides"), "") { @@ -894,7 +900,7 @@ fn create_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: let params = json!({"collection": coll.id, "name": name, "type": kind.id()}); if let Some(id) = app.run("variable.create", params).and_then(|r| r["id"].as_str().map(str::to_string)) { st.query.clear(); - start_rename(ui, st, Rename::Variable(id)); + start_rename(st, Rename::Variable(id)); } ui.close(); } diff --git a/crates/ui-egui/src/variables_rename_tests.rs b/crates/ui-egui/src/variables_rename_tests.rs new file mode 100644 index 00000000..66a5e4e2 --- /dev/null +++ b/crates/ui-egui/src/variables_rename_tests.rs @@ -0,0 +1,82 @@ +//! Renames in the variables window, drawn headless with the screen reader tree on: creating a +//! collection opens its name for editing, and so does a double click on its row. On every frame +//! the focused node must be in the AccessKit tree; the platform adapter (UI Automation on Windows) +//! aborts the app when it isn't (#224). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2}; +use figcraft_engine::Session; + +use crate::layers_drag_tests::Ui; +use crate::live_tests::{flat, text_center}; + +/// The variables window open on a document without collections, with AccessKit on. +fn variables_window() -> Ui { + let mut u = Ui::new(Session::new()); + u.app.ui.variables = Some(Default::default()); + u.ctx.enable_accesskit(); + for _ in 0..10 { + u.frame(vec![]); + } + u +} + +/// One frame with `events`, checking the focused node is in the screen reader tree. +fn checked_frame(u: &mut Ui, events: Vec, k: usize) { + let out = u.frame_output(events); + let tree = out.platform_output.accesskit_update.expect("an AccessKit update"); + assert!(tree.nodes.iter().any(|(i, _)| *i == tree.focus), "frame {k}: the focused node {:?} isn't in the tree", tree.focus); +} + +/// `clicks` clicks at `p` (two make a double click) and the frames that follow, each checked. +fn click_checking_focus(u: &mut Ui, p: Pos2, clicks: usize) { + for _ in 0..10 { + u.frame(vec![Event::PointerMoved(p)]); + } + let presses = [true, false].into_iter().cycle().take(clicks * 2).map(Some); + for (k, pressed) in presses.chain(std::iter::repeat_n(None, 20)).enumerate() { + let events = pressed.map(|pressed| Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + checked_frame(u, events.into_iter().collect(), k); + } +} + +/// Type `name` and press Enter (each frame checked). +fn type_name(u: &mut Ui, name: &str) { + checked_frame(u, vec![Event::Text(name.into())], 0); + for pressed in [true, false] { + checked_frame(u, vec![Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }], 1); + } + for k in 0..5 { + checked_frame(u, vec![], k + 2); + } +} + +fn collection_names(u: &Ui) -> Vec { + u.app.session.doc().unwrap().variables.collections.iter().map(|c| c.name.clone()).collect() +} + +#[test] +fn creating_a_collection_puts_its_name_in_edit_with_the_focus_in_the_screen_reader_tree() { + let mut u = variables_window(); + let shapes = flat(&u.frame_output(vec![]).shapes); + click_checking_focus(&mut u, text_center(&shapes, "Create collection"), 1); + assert_eq!(collection_names(&u), ["Collection"], "a click creates one collection"); + // The keyboard is in its name: typing renames it. + type_name(&mut u, "Brand"); + assert_eq!(collection_names(&u), ["Brand"]); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} + +#[test] +fn double_clicking_a_collection_renames_it_with_the_focus_in_the_screen_reader_tree() { + let mut u = variables_window(); + u.app.session.execute("variable.createCollection", &serde_json::json!({"name": "Spacing"})).unwrap(); + for _ in 0..10 { + u.frame(vec![]); + } + let shapes = flat(&u.frame_output(vec![]).shapes); + click_checking_focus(&mut u, text_center(&shapes, "Spacing"), 2); + // The row's text was replaced by the field, whose text is selected: typing replaces it. + type_name(&mut u, "Space"); + assert_eq!(collection_names(&u), ["Space"]); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9fc9937d..3a4d10ae 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -51,7 +51,9 @@ unknown id is an error. Run any command (the same ids as the menus, shortcuts, Actions palette, CLI and MCP). Programmatic calls never open dialogs: dialog requests are dropped. Returns the command's result (creation -commands return `{"id": N}`). +commands return `{"id": N}`). Paints (`fills`, `strokes`, `paint`) take a colour, the full paint +JSON or the gradient shorthand `{"type":"linearGradient","stops":["#hex","#hex"],"from"?,"to"?}` +(as in `docs/mcp.md`). ```json {"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}} diff --git a/docs/mcp.md b/docs/mcp.md index 054af8db..38d821f4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -128,10 +128,10 @@ Or in a project's `.mcp.json`: resolved `gridCell`. - **Every menu action is a command** (`list_commands`), and most commands act on `ids` when given, else on the selection. New layers become the selection. -- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills, strokes and effects passed through the MCP - tools, `color` may also be a hex string and gradients can be written - `{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; the server converts them to the - engine's paint JSON. +- Colours are `"#RRGGBB"` or `"#RRGGBBAA"`. Inside fills and strokes, `color` may also be a hex + string and gradients can be written `{"type": "linearGradient", "stops": ["#7048E8", "#1098AD"]}`; + the engine reads them as its paint JSON, so `run_command`, `figcraft-cli run` and the control + channel take them too. Effects passed through the MCP tools may use hex `color` strings as well. - **Blend modes**: `blend` on a layer (`node.set`) or a paint (`fill.set` / `stroke.set`) is a menu label or CSS keyword (`"Normal"`, `"pass through"`, `"multiply"`, `"plusDarker"`, ...; `normal` is Normal). Groups and frames start as pass-through: their children blend with what is diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index 69667a2b..0adbfb04 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -51,7 +51,11 @@ overrides: rpm: # Soname provides rather than package names, so the same requirements resolve on Fedora/RHEL # (libxkbcommon-x11, libX11-xcb, ...) and openSUSE (libxkbcommon-x11-0, libX11-xcb1, ...). + # The glibc floor matches the deb's libc6 (>= 2.35). It names libm because the binaries' + # GLIBC_2.35 symbols (hypot, hypotf) are libm's: RHEL 9's glibc 2.34 provides + # libc.so.6(GLIBC_2.35) through backports, but not this, so it refuses the install. depends: + - libm.so.6(GLIBC_2.35)(64bit) - libxkbcommon.so.0()(64bit) - libxkbcommon-x11.so.0()(64bit) - libwayland-client.so.0()(64bit)