mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
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:
Generated
+12
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(¬e, text),
|
||||
None => std::fs::remove_file(¬e),
|
||||
};
|
||||
true
|
||||
};
|
||||
// The revision counts as copied only once its write succeeded.
|
||||
if wait {
|
||||
job();
|
||||
entry.1 = None;
|
||||
if job() {
|
||||
entry.done = Some(d.revision);
|
||||
}
|
||||
} else {
|
||||
entry.1 = std::thread::Builder::new().name("recovery".into()).spawn(job).ok();
|
||||
entry.flight = std::thread::Builder::new().name("recovery".into()).spawn(job).ok().map(|h| (d.revision, h));
|
||||
}
|
||||
entry.0 = d.revision;
|
||||
}
|
||||
// Closed documents.
|
||||
let open: Vec<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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -23,3 +23,7 @@ display_name = "tylerjenningsw"
|
||||
|
||||
[people.VanTan666]
|
||||
display_name = "VanTan"
|
||||
|
||||
[people.Silvertree2010]
|
||||
display_name = "Netter"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))?
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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), \
|
||||
|
||||
@@ -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}})));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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![]);
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user