Community batch 2: 20 reviewed fixes (#510)

Lands 20 reviewed community PRs with their own commits: #314 #337 #330 #297 #301 #282 #284 #254 #475 #384 #358 #472 #471 #339 #328 #288 #343 #344 #353 #290, plus a test fix on #297's branch so its retry tests run on Windows.
This commit is contained in:
BFlat
2026-10-15 18:23:50 +07:00
committed by GitHub
46 changed files with 1453 additions and 142 deletions
Generated
+12
View File
@@ -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"
+21
View File
@@ -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();
+7
View File
@@ -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 }
+57
View File
@@ -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<PathBuf>) {
for p in paths {
if fig.run("file.open", json!({"path": p.to_string_lossy()})).is_some() {
fig.ui.home = false;
}
}
}
+30
View File
@@ -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);
}
+29 -1
View File
@@ -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),
}))
}),
)
}
+49 -14
View File
@@ -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<u64, (u64, Option<JoinHandle<()>>)>,
/// What was copied of each document, and the write in flight.
written: HashMap<u64, Copy>,
/// 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<Waiting>,
}
/// 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<u64>,
/// The write in flight: the revision it copies, and whether it succeeded once it ends.
flight: Option<(u64, JoinHandle<bool>)>,
}
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<f64> {
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(&note, text),
None => std::fs::remove_file(&note),
};
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<u64> = 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);
+53
View File
@@ -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);
}
+4
View File
@@ -23,3 +23,7 @@ display_name = "tylerjenningsw"
[people.VanTan666]
display_name = "VanTan"
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false
+7 -2
View File
@@ -307,7 +307,8 @@ impl Client {
/// Queue `changes` as a new batch (already shown in the view).
fn queue(&mut self, changes: Vec<Change>, kind: BatchKind, step: Option<u64>) {
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 {
+43
View File
@@ -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());
}
+2
View File
@@ -123,6 +123,8 @@ impl Recompute for NoRecompute {
fn recompute(&self, _: &mut Document, _: &Document, _: &BTreeSet<NodeId>) {}
}
#[cfg(test)]
mod client_tests;
#[cfg(test)]
mod codec_tests;
#[cfg(test)]
+8 -1
View File
@@ -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::<u64>()
{
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> {
+10
View File
@@ -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;
+22
View File
@@ -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());
}
+18 -9
View File
@@ -253,7 +253,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<CommandSpec> {
}
),
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<Value> {
fn bump_name(n: &str) -> String {
if let Some((base, num)) = n.rsplit_once(' ')
&& let Ok(k) = num.parse::<u64>()
&& 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<f32> {
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<Value> {
}
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;
}
}
+128 -8
View File
@@ -219,7 +219,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
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<Vec<u8>> {
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"<svg/>").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"<svg/>");
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"<svg/>").unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through");
assert_eq!(std::fs::read(&path).unwrap(), b"<svg/>");
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);
}
}
+1 -1
View File
@@ -52,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
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"))?
}
};
+1 -1
View File
@@ -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), \
+3
View File
@@ -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}})));
+3
View File
@@ -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();
+21
View File
@@ -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}");
}
}
@@ -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<Paint> {
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<f64> = 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());
}
@@ -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<serde_json::Value> {
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);
}
+6 -1
View File
@@ -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");
+11 -3
View File
@@ -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;
@@ -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");
}
+71 -3
View File
@@ -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<Color> {
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<Paint> {
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)
}
+22
View File
@@ -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();
+46 -5
View File
@@ -654,14 +654,53 @@ fn read_branches<R: Read + Seek>(z: &mut zip::ZipArchive<R>, images: &BTreeMap<S
out
}
/// Save atomically: write a temp file next to `path`, then rename over it.
/// Save atomically: write a staging file next to `path`, then rename over it.
#[cfg(not(target_arch = "wasm32"))]
pub fn save_file(doc: &Document, path: &std::path::Path, thumbnail_png: Option<&[u8]>) -> 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 (`<path>.tmp-figcraft-<process id>-<counter>`) 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;
+125
View File
@@ -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);
}
+95
View File
@@ -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<NodeId>) {
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");
});
}
+29
View File
@@ -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<usize> = 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<Self> {
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;
+6 -62
View File
@@ -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<String, Value>) {
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);
}
}
}
+13 -4
View File
@@ -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;
+88
View File
@@ -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" = "ダーク"
@@ -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}");
}
+5 -2
View File
@@ -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![]);
+6
View File
@@ -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;
@@ -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<Event>, k: usize) -> Vec<egui::Shape> {
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<egui::Shape> {
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<String> = u.app.session.doc().unwrap().styles.iter().map(|s| s.name.clone()).collect();
assert_eq!(names, ["Theme/Primary", "Theme/Accent"]);
}
+5 -2
View File
@@ -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::<bool>(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();
}
});
+24 -18
View File
@@ -47,6 +47,8 @@ pub struct Editor {
collection: Option<String>,
query: String,
rename: Option<Rename>,
/// 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<String>,
/// 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<String> {
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();
}
@@ -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<Event>, 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<String> {
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);
}
+3 -1
View File
@@ -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}}}
+4 -4
View File
@@ -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
+4
View File
@@ -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)