Merge pull request #735 from storytold/community/batch-12

Community batch 12: engine checks, server limits, web join links, menus and more (13 PRs)
This commit is contained in:
BFlat
2026-10-17 17:52:50 +07:00
committed by GitHub
36 changed files with 1519 additions and 72 deletions
+3 -13
View File
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
+1 -1
View File
@@ -228,7 +228,7 @@ The desktop app also offers a loopback-only control channel (`figcraft --control
- **Runs in the browser:** the whole engine and UI compile to WebAssembly.
- **Accessible:** screen readers get names, roles, values and states for the tools, layer rows, fields, dropdowns, tabs and menus (AccessKit on desktop), F6 moves the keyboard between the toolbar, layers, canvas and properties (Tab within them, Escape back to the canvas), the system's reduce-motion setting is honoured, and panel text meets WCAG AA contrast in both themes.
- **Languages:** English, Russian and Turkish (reviewed by native speakers), and Spanish, French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Czech and Indonesian drafts, picked in Preferences or taken from the system or browser and switched live; adding a language is one catalog file ([`docs/translating.md`](docs/translating.md)).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and two example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Plugins:** sandboxed WebAssembly plugins (a metered, pure-Rust interpreter with a memory cap and no file or network access, in the browser too) that act through the same commands, each run one undo step, within the permissions their manifest asks for. A Rust SDK and three example plugins are in [`plugins/`](plugins); see [`docs/plugins.md`](docs/plugins.md).
- **Live sessions (preview):** share the open file from the app (Share ▸ Start sharing: online through a server, or on your local network), give the others the short join code it shows (or the invite link), and edit it together: everyone's changes sync live, with cursors, selections, avatars and follow, and undo takes back only your own changes. Others join from the desktop app (invite links open it) or from the browser build. See [`docs/collab-protocol.md`](docs/collab-protocol.md).
- **A server of your own (preview):** `figcraft-server` keeps a team's files, publishes them from the app (File ▸ Publish to server…, or Share ▸ Online with your server in Preferences) and hands out share links and join codes that let people edit or only view; files survive restarts and apps reconnect where they were. No accounts yet: links are the access. Releases ship it for Linux, and the desktop app joins over `wss://`; [hosting it on a rented Linux server](docs/server.md#deploy-on-a-linux-server) takes a systemd unit and Caddy or nginx (or Docker). See [`docs/server.md`](docs/server.md).
- **Never crashes:** no `unwrap`, `expect` or `panic` in shipped code, enforced by clippy; commands run behind a guard so a bug fails one action, not your session.
+83 -10
View File
@@ -333,17 +333,32 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe
}
None => Session::new(),
};
for (i, (cmd, params)) in steps.iter().enumerate() {
let r = s.execute(cmd, params);
s.ui_requests.clear();
match r {
Ok(v) => println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v})),
Err(e) => {
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string()}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
return Ok(ExitCode::from(1));
}
// One undo step and all-or-nothing, the way the same commands behave through `command.batch`
// (MCP, the control channel, plugins): `{"$step": n}` resolves against the steps before it, and
// a step that fails takes the whole script back instead of leaving the earlier steps applied.
// The result lines are held back until the script is through, so none of them reports work that
// was rolled back.
let mut results: Vec<Value> = Vec::with_capacity(steps.len());
let mut failed_at = 0;
let outcome = s.grouped(&format!("run {script}"), |s| {
for (i, (cmd, params)) in steps.iter().enumerate() {
failed_at = i;
let v = s.batch_step(&results, i, cmd, params)?;
s.ui_requests.clear();
results.push(v);
}
Ok(())
});
if let Err(e) = outcome {
let cmd = steps.get(failed_at).map_or("", |(cmd, _)| cmd.as_str());
let i = failed_at;
println!("{}", json!({"step": i, "command": cmd, "ok": false, "error": e.to_string(), "rolledBack": true}));
eprintln!("error: step {i} ({cmd}) failed: {e}");
eprintln!("the script's document changes were rolled back");
return Ok(ExitCode::from(1));
}
for (i, ((cmd, _), v)) in steps.iter().zip(&results).enumerate() {
println!("{}", json!({"step": i, "command": cmd, "ok": true, "result": v}));
}
if let Some(out) = out {
let ds = s.ds().map_err(|e| CliError::Failed(e.to_string()))?;
@@ -422,4 +437,62 @@ mod tests {
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2);
std::fs::remove_dir_all(dir).unwrap();
}
fn temp_dir(tag: &str) -> std::path::PathBuf {
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let dir = std::env::temp_dir().join(format!("figcraft-cli-{tag}-{}-{stamp}", std::process::id()));
std::fs::create_dir(&dir).unwrap();
dir
}
/// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step
/// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it
/// as "no parent" and the layer landed on the page — with every step reporting success.
#[test]
fn a_script_puts_a_layer_in_a_layer_an_earlier_step_made() {
let dir = temp_dir("run-step");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "node.create", "params": {"type": "rectangle", "name": "child", "parent": {"$step": 0}, "x": 10, "y": 10, "width": 20, "height": 20}}
]"#,
)
.unwrap();
let out = dir.join("out.figcraft");
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
let mut s = Session::empty();
s.execute("file.open", &json!({"path": out.to_str().unwrap()})).unwrap();
let doc = s.doc().unwrap();
let page = s.page().unwrap();
let top = doc.node(page).unwrap().children.clone();
assert_eq!(top.len(), 1, "only the frame is top level, not the rectangle too");
assert_eq!(doc.node(top[0]).unwrap().name, "Card");
let inner = doc.node(top[0]).unwrap().children.clone();
assert_eq!(inner.len(), 1, "the rectangle went inside the frame");
assert_eq!(doc.node(inner[0]).unwrap().name, "child");
std::fs::remove_dir_all(dir).unwrap();
}
/// A step that fails takes the whole script back: nothing is written, and the failure says so.
#[test]
fn a_failed_step_leaves_no_output() {
let dir = temp_dir("run-rollback");
let script = dir.join("script.json");
std::fs::write(
&script,
r#"[
{"command": "node.create", "params": {"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 200, "height": 100}},
{"command": "node.set", "params": {"ids": [99999], "props": {"name": "nope"}}}
]"#,
)
.unwrap();
let out = dir.join("out.figcraft");
// The failure is the script's, not the call's: the CLI reports it and exits non-zero.
run_script(script.to_str().unwrap(), None, Some(out.to_str().unwrap())).unwrap();
assert!(!out.exists(), "a script that failed writes nothing");
std::fs::remove_dir_all(dir).unwrap();
}
}
+29 -11
View File
@@ -135,9 +135,23 @@ impl Config {
/// abuse contact, or one whose clients would all count as its proxy).
pub fn warnings(&self) -> Vec<String> {
let mut out = Vec::new();
let client_max = NetLimits::default().max_server_message >> 20;
if usize::try_from(self.file_mb()).is_ok_and(|mb| mb >= client_max) {
out.push(format!(
"max_file_mb is {}: the app opens files of up to {} MiB, so a file that large can't be opened",
self.file_mb(),
client_max - 1
));
}
if !self.open_publish {
return out;
}
if self.file_mb() > 64 {
out.push(format!(
"open_publish is on with max_file_mb = {}: anyone may send messages that large, and the memory set aside for reading and queueing them grows to eight times it; keep it at 64 or less on a public server",
self.file_mb()
));
}
if self.operator_contact.trim().is_empty() {
out.push("open_publish is on but operator_contact is empty: people can't reach you about abuse; set it (and terms_url)".into());
}
@@ -253,8 +267,9 @@ impl Config {
}
}
/// Largest message a client may send when open to the public: a whole file (or plugin bundle)
/// and a margin, not the core's 64 MiB.
/// Largest message a client may send: a whole file (or plugin bundle) and a margin. Open to the
/// public this is also the cap (strangers' files stay small); a private server never goes below
/// the core's 64 MiB, and a `max_file_mb` above it raises the cap with it.
fn open_message(&self) -> usize {
usize::try_from(self.file_mb().max(self.max_plugin_mb).saturating_add(1).saturating_mul(1 << 20)).unwrap_or(usize::MAX)
}
@@ -265,9 +280,9 @@ impl Config {
fn core(&self) -> figcraft_collab::Limits {
let base = figcraft_collab::Limits { max_peers: self.max_people, ..figcraft_collab::Limits::default() };
if !self.open_publish {
return base;
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
}
let max = self.open_message().min(base.max_message_bytes);
let max = self.open_message();
let file = usize::try_from(self.file_mb().saturating_mul(1 << 20)).unwrap_or(usize::MAX);
figcraft_collab::Limits { max_message_bytes: max, max_log_bytes: (file / 4).max(256 << 10), ..base }
}
@@ -279,21 +294,24 @@ impl Config {
pub fn net(&self) -> NetLimits {
let base = NetLimits { max_connections: self.max_connections, trusted_proxy: self.proxy().ok().flatten(), ..NetLimits::default() };
if !self.open_publish {
return base;
let max = base.max_client_message.max(self.open_message());
return NetLimits { max_client_message: max, max_queued_bytes: base.max_queued_bytes.max(max.saturating_add(1 << 20)), ..base };
}
let max = self.open_message().min(base.max_client_message);
let max = self.open_message();
// Shared budgets: one block of addresses (an IPv4 address, an IPv6 /48) gets at most an
// eighth of each, and its biggest consumers are dropped first.
let (queued, reading) = (256usize << 20, 192usize << 20);
// eighth of each, and its biggest consumers are dropped first. Each still fits one whole
// message, or a file as large as `max_file_mb` could never arrive.
let one = max.saturating_add(1 << 20);
let (queued, reading) = ((256usize << 20).max(one.saturating_mul(8)), (192usize << 20).max(one.saturating_mul(8)));
NetLimits {
max_per_address: self.max_connections_per_ip,
max_client_message: max,
max_queued_bytes: max.saturating_add(1 << 20),
max_queued_bytes: one,
max_queued_block: queued / 8,
max_queued_total: queued,
max_reading_block: max.saturating_add(1 << 20).min(reading / 8),
max_reading_block: one.min(reading / 8),
max_reading_total: reading,
max_in_flight: 128 << 20,
max_in_flight: (128usize << 20).max(one),
reserve_for_sessions: self.max_connections / 8,
request_idle: std::time::Duration::from_secs(20),
..base
+4 -2
View File
@@ -8,8 +8,10 @@
//! length (u32 LE), CRC-32 (u32 LE), one [`Journal`] entry in the wire encoding.
//!
//! Compaction writes the next generation's snapshot, then an empty log of that generation. A log
//! whose generation isn't the snapshot's is from before a compaction that was cut short: what it
//! holds is in the snapshot already. A log ends at its first damaged record (a write cut short).
//! whose generation isn't the snapshot's is usually from before a compaction that was cut short
//! (what it holds is in the snapshot already), but no checksum covers either generation, so the
//! store replays such a log anyway and reports any change the snapshot lacked. A log ends at its
//! first damaged record (a write cut short).
use std::fs::{self, File};
use std::io::{self, Read, Write};
+32 -1
View File
@@ -413,7 +413,7 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() {
assert_eq!(open.net().request_idle, Duration::from_secs(20), "idle one-shot requests go quickly");
assert_eq!(open.net().reserve_for_sessions, 512 / 8);
assert_eq!(open.net().max_client_message, 17 << 20);
assert_eq!(private.net().max_client_message, NetLimits::default().max_client_message, "unchanged when private");
assert_eq!(private.net().max_client_message, 65 << 20, "private: a 64 MiB file and a margin");
let w = open.warnings().join("\n");
assert!(w.contains("operator_contact is empty") && w.contains("trusted_proxy"), "{w}");
let set = Config { operator_contact: "abuse@example.com".into(), trusted_proxy: Some("127.0.0.1".into()), max_file_mb: Some(8), ..open };
@@ -493,3 +493,34 @@ fn open_connections_never_reach_what_needs_the_key() {
assert!(!format!("{info:?}").contains(&k));
server.stop();
}
#[test]
fn files_above_64_mib_fit_every_limit() {
// `max_file_mb` may go up to 4096: every cap a whole file's message meets on its way in must
// follow it, or the upload is dropped by the transport before the server can say why.
for open_publish in [false, true] {
let cfg = Config { open_publish, max_file_mb: Some(256), public_url: Some("wss://figcraft.example.com/".into()), ..Config::default() };
let (net, core, file) = (cfg.net(), cfg.store().core, 256usize << 20);
assert!(net.max_client_message > file, "open={open_publish}: {}", net.max_client_message);
assert!(net.max_queued_bytes > file && net.max_reading_block > file && net.max_in_flight > file, "open={open_publish}: {net:?}");
assert!(net.max_reading_total >= net.max_reading_block && net.max_queued_total >= net.max_queued_block);
assert!(core.max_message_bytes > file, "open={open_publish}: {}", core.max_message_bytes);
}
// A client takes such a file too, but caps what any server may make it buffer at 1 GiB.
assert_eq!(NetLimits::default().max_server_message, 1 << 30);
}
#[test]
fn large_files_warn_the_operator() {
let public_url = Some("wss://figcraft.example.com/".into());
let warns = |open_publish, mb| {
Config { open_publish, max_file_mb: Some(mb), public_url: public_url.clone(), ..Config::default() }.warnings().join(
"
",
)
};
assert!(!warns(true, 64).contains("max_file_mb"), "{}", warns(true, 64));
assert!(warns(true, 65).contains("open_publish is on with max_file_mb = 65"), "{}", warns(true, 65));
assert!(!warns(false, 1023).contains("max_file_mb"), "{}", warns(false, 1023));
assert!(warns(false, 1024).contains("can't be opened"), "{}", warns(false, 1024));
}
+27 -3
View File
@@ -440,7 +440,7 @@ impl Store {
&& let Ok(log) = disk::read_log(&bytes)
&& log.generation != generation
{
log::info!("file at {}: its log predates the last compaction; it is ignored", path.display());
log::info!("file at {}: its log isn't of its snapshot's generation; it is replayed over it when the file opens", path.display());
}
let bytes = [SNAPSHOT, LOG].iter().filter_map(|n| fs::metadata(path.join(n)).ok()).map(|m| m.len()).sum();
Ok(Entry { meta, links, log: None, bytes, forced: 0 })
@@ -1126,8 +1126,32 @@ impl Store {
fresh_log = true;
}
}
// From before a compaction that was cut short: the snapshot has it all.
Ok(_) => {}
// From before a compaction that was cut short, the snapshot has it all; or a generation
// field damaged, which no checksum covers. Replaying tells them apart without trusting
// it: entries the snapshot holds are skipped, so only edits it lacks change anything.
Ok(log) => {
let before = server.seq();
let mut stopped = None;
for entry in log.entries {
if let Err(e) = server.replay(entry) {
stopped = Some(e);
break;
}
}
let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before));
if recovered > 0 {
issues.push(format!(
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \
{recovered} changes the snapshot didn't; they were recovered"
));
}
if let Some(e) = stopped {
issues.push(format!(
"file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \
making sense after {recovered} newer changes ({e}); the rest was dropped"
));
}
}
Err(why) => issues.push(format!("file {file}: {why}; starting a new log")),
}
for i in issues {
@@ -0,0 +1,139 @@
//! A generation field damaged after an abrupt stop must not make the server drop a log that still
//! holds confirmed edits (#408): the log is replayed over the snapshot, which only the changes the
//! snapshot lacks survive, and the damage is reported. A genuine leftover of a compaction cut short
//! still restores silently.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use figcraft_collab::message::{Access, ClientMsg};
use figcraft_collab::{Client, Outgoing, Replica, Server};
use figcraft_collab_net::hub::Backend;
use figcraft_doc::Document;
use figcraft_server::config::Config;
use figcraft_server::disk::{self, LOG, SNAPSHOT};
use figcraft_server::store::{FILES, Store};
use std::fs;
use std::path::{Path, PathBuf};
struct Fixture(PathBuf);
impl Fixture {
fn new() -> Self {
let path = std::env::temp_dir().join(format!("figcraft-header-recovery-{}", figcraft_collab_net::token::file_id().unwrap()));
fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn page_name(server: &Server) -> String {
let doc = server.document();
doc.node(doc.first_page().unwrap()).unwrap().name.clone()
}
/// A published file whose first page was renamed "Kept", flushed to its log (not compacted), then
/// the store dropped without stopping (an abrupt stop). Returns the file id and the final state.
fn renamed_and_stopped(data: &Path) -> (String, Server) {
let mut store = Store::open(data, Config::default().store()).unwrap();
let snapshot = Replica::new(Document::new("Fixture")).snapshot().unwrap();
let (info, _, _) = store.publish("Fixture", &snapshot).unwrap();
let id = info.id;
let mut server = store.open(&id).unwrap();
let ClientMsg::Hello(hello) = Client::hello("f", "", "Editor", "#FF8800") else { panic!("not a hello") };
let (_, messages) = server.join_as(hello, Access::Edit).unwrap();
let Some(Outgoing::To(_, welcome)) = messages.into_iter().next() else { panic!("no welcome") };
let mut client = Client::join(welcome).unwrap();
store.changed(&id, &mut server);
let mut edited = client.document().clone();
let page = edited.first_page().unwrap();
edited.node_mut(page).unwrap().name = "Kept".into();
let change = client.edit(&edited).unwrap().unwrap();
assert!(!server.receive(client.id(), change).is_empty());
store.changed(&id, &mut server);
assert!(store.flush().is_empty(), "the edit must reach disk");
assert_eq!(page_name(&server), "Kept");
let dir = data.join(FILES).join(&id);
assert!(!disk::read_log(&fs::read(dir.join(LOG)).unwrap()).unwrap().entries.is_empty());
assert_eq!(disk::decode_snapshot(&fs::read(dir.join(SNAPSHOT)).unwrap()).unwrap().1.seq, 0, "the rename is only in the log");
drop(store);
(id, server)
}
/// Reopen the data directory: the file's first page name and the problems reported.
fn reopen(data: &Path, id: &str) -> (String, Vec<String>) {
let mut store = Store::open(data, Config::default().store()).unwrap();
let server = store.open(id).unwrap();
(page_name(&server), store.problems().to_vec())
}
fn flip(path: &Path, at: usize) {
let mut bytes = fs::read(path).unwrap();
bytes[at] ^= 1;
fs::write(path, bytes).unwrap();
}
/// The generation field's first byte in both headers (after the magic and the format byte).
const GENERATION_AT: usize = 5;
#[test]
fn an_intact_log_restores_its_edits_without_a_report() {
let fixture = Fixture::new();
let (id, _) = renamed_and_stopped(&fixture.0);
let (name, problems) = reopen(&fixture.0, &id);
assert_eq!(name, "Kept");
assert!(problems.is_empty(), "{problems:?}");
}
#[test]
fn a_damaged_log_generation_keeps_the_edits_and_is_reported() {
let fixture = Fixture::new();
let (id, _) = renamed_and_stopped(&fixture.0);
let log = fixture.0.join(FILES).join(&id).join(LOG);
flip(&log, GENERATION_AT);
// The records themselves still read: only the header's generation changed.
let read = disk::read_log(&fs::read(&log).unwrap()).unwrap();
assert!(read.damaged.is_none() && !read.entries.is_empty());
let (name, problems) = reopen(&fixture.0, &id);
assert_eq!(name, "Kept", "the flushed rename must survive");
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
// And it stays: the recovered edit is now in the file's snapshot.
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
}
#[test]
fn a_damaged_snapshot_generation_keeps_the_edits_and_is_reported() {
let fixture = Fixture::new();
let (id, _) = renamed_and_stopped(&fixture.0);
let snapshot = fixture.0.join(FILES).join(&id).join(SNAPSHOT);
flip(&snapshot, GENERATION_AT);
assert!(disk::check_snapshot(&fs::read(&snapshot).unwrap()).is_ok(), "the generation is outside the checksum");
let (name, problems) = reopen(&fixture.0, &id);
assert_eq!(name, "Kept", "the flushed rename must survive");
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("generation")), "{problems:?}");
assert_eq!(reopen(&fixture.0, &id).0, "Kept");
}
#[test]
fn a_damaged_log_format_is_still_reported() {
let fixture = Fixture::new();
let (id, _) = renamed_and_stopped(&fixture.0);
flip(&fixture.0.join(FILES).join(&id).join(LOG), 4);
let (_, problems) = reopen(&fixture.0, &id);
assert!(problems.iter().any(|p| p.contains(&id) && p.contains("format")), "{problems:?}");
}
#[test]
fn a_log_left_by_a_compaction_cut_short_restores_without_a_report() {
let fixture = Fixture::new();
let (id, server) = renamed_and_stopped(&fixture.0);
// The next snapshot was written (it holds the rename); the old log wasn't replaced yet.
let bytes = disk::encode_snapshot(2, &server.state().unwrap()).unwrap();
fs::write(fixture.0.join(FILES).join(&id).join(SNAPSHOT), bytes).unwrap();
let (name, problems) = reopen(&fixture.0, &id);
assert_eq!(name, "Kept");
assert!(problems.is_empty(), "a leftover log is expected, not damage: {problems:?}");
}
+9
View File
@@ -14,7 +14,16 @@
//! join from other machines.
//!
//! URL query flag: `?webgl` forces the WebGL2 backend instead of WebGPU.
//!
//! URL fragment: `#join=<invite link or join code>` joins that live session once the page has
//! loaded, so a bookmark opens a shared file. `join` takes the rest of the fragment: put it last.
//! The link carries the session's secret; a fragment is never sent to the web host, but the address
//! bar and the browser history keep it, so the URL is as private as the link itself.
#[cfg(any(target_arch = "wasm32", test))]
mod query;
#[cfg(test)]
mod query_tests;
#[cfg(target_arch = "wasm32")]
mod web;
+15
View File
@@ -0,0 +1,15 @@
//! The page's URL: the invite link its fragment names.
/// The invite link in `#join=<link>` (`fragment` is `location.hash`, with or without its `#`), still
/// escaped. The fragment, unlike the query, never leaves the browser, so the link's secret stays out
/// of the web host's logs and `Referer` headers. `join` takes the rest of the fragment, so a pasted
/// `figcraft://join?host=…&file=…&token=…` keeps its own `&`s. Anything longer than an escaped invite
/// link can be is ignored; what is left is checked by `collab.join` like a link the desktop app is
/// opened with.
pub fn join_link(fragment: &str) -> Option<&str> {
// An invite link is at most 2 KiB; every byte of it escaped is three.
const MAX: usize = 3 * 2048;
let fragment = fragment.strip_prefix('#').unwrap_or(fragment);
let start = if fragment.starts_with("join=") { 0 } else { fragment.find("&join=")? + 1 };
fragment.get(start + "join=".len()..).filter(|link| !link.is_empty() && link.len() <= MAX)
}
+29
View File
@@ -0,0 +1,29 @@
//! `#join=` in the page's URL.
use crate::query::join_link;
#[test]
fn join_takes_the_rest_of_the_fragment() {
let link = "figcraft://join?host=192.168.1.20:47900&file=abc&token=AbCdEfGhIjKlMnOpQrStUv";
assert_eq!(join_link(&format!("#join={link}")), Some(link), "a pasted link keeps its own &s");
assert_eq!(join_link(&format!("#x=1&join={link}")), Some(link), "after other entries");
assert_eq!(join_link("#join=figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), Some("figcraft%3A%2F%2Fjoin%3Fhost%3Dh"), "escaped, decoded later");
assert_eq!(join_link("join=K7M-2QX-9TD"), Some("K7M-2QX-9TD"), "a join code, no leading #");
}
#[test]
fn no_join_without_the_entry() {
assert_eq!(join_link(""), None);
assert_eq!(join_link("#"), None);
assert_eq!(join_link("#join="), None);
assert_eq!(join_link("#rejoin=x"), None, "only the whole entry name");
assert_eq!(join_link("#xjoin=x&a=1"), None);
assert_eq!(join_link("?join=x"), None, "the query isn't read: it goes to the web host");
}
#[test]
fn an_overlong_link_is_ignored() {
let long = format!("#join=figcraft://join?host=h&file=f&token={}", "a".repeat(7000));
assert_eq!(join_link(&long), None);
assert_eq!(join_link(&format!("#join={}", "é".repeat(4000))), None, "length counts bytes");
}
+7 -1
View File
@@ -40,7 +40,13 @@ pub fn start() {
// on the socket wakes the page for a frame.
let ctx = cc.egui_ctx.clone();
session.live.install(Box::new(figcraft_collab_net::NetLink::new(std::sync::Arc::new(move || ctx.request_repaint()))));
let fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
let mut fig = FigApp::new(session, Box::new(WebServices { reduce_motion, ctx: cc.egui_ctx.clone() }));
// `#join=<link>`: join that session, as opening an invite link does on the desktop.
let hash = web_sys::window().and_then(|w| w.location().hash().ok()).unwrap_or_default();
if let Some(link) = crate::query::join_link(&hash) {
let link = js_sys::decode_uri_component(link).ok().and_then(|s| s.as_string()).unwrap_or_else(|| link.to_string());
figcraft_ui_egui::live::open_invite(&mut fig, &link);
}
Ok(Box::new(WebShell { fig }))
}),
)
+5 -1
View File
@@ -160,7 +160,11 @@ fn connection_limit_rejects_excess_clients_and_reuses_finished_slots() {
assert_eq!(workers.len(), 1);
client.write_all(b"invalid\n").unwrap();
assert_eq!(reply(&mut BufReader::new(client.try_clone().unwrap()))["ok"], false);
client.shutdown(Shutdown::Both).unwrap();
// The server closes this connection after answering an invalid request, and shutting down
// a socket whose peer has already closed is ENOTCONN on macOS and the BSDs where Linux
// returns Ok. Closing the client is what the worker is waiting for, so the result is moot:
// the other shutdowns in this file run while the peer is still open and stay checked.
let _ = client.shutdown(Shutdown::Both);
workers.pop().unwrap().join().unwrap();
}
+5
View File
@@ -44,3 +44,8 @@ sync_github_name = true
[people.volkovskey]
real_name = "Andrii Volkov"
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
+4 -2
View File
@@ -12,7 +12,9 @@ pub struct NetLimits {
/// before: its hello.
pub max_client_message: usize,
pub max_hello: usize,
/// Largest message a client accepts from the host (a welcome carries the whole document).
/// Largest message a client accepts from the host (a welcome carries the whole document): 1 GiB,
/// room for any file up to 1023 MiB and well past the servers' defaults, while a hostile server
/// (any link can name one) can't make the app or a browser tab buffer more than that.
pub max_server_message: usize,
/// Messages a client may send per second on average, and in a burst; beyond that it is
/// disconnected.
@@ -61,7 +63,7 @@ impl Default for NetLimits {
max_connections: 64,
max_client_message: figcraft_collab::Limits::default().max_message_bytes,
max_hello: 64 << 10,
max_server_message: figcraft_collab::Limits::default().max_message_bytes,
max_server_message: 1 << 30,
rate: 100.0,
burst: 400.0,
hello_timeout: Duration::from_secs(10),
+1 -1
View File
@@ -68,7 +68,7 @@ pub fn specs() -> Vec<CommandSpec> {
let cmd = "instance.create";
let c = req_id(cmd, p, "component")?;
let page = s.page()?;
let parent = id(p, "parent").unwrap_or(page);
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let doc = s.doc_mut()?;
let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?;
let (x, y) = match (num(p, "x"), num(p, "y")) {
+3 -3
View File
@@ -167,7 +167,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
let t = string(p, "type").ok_or_else(|| bad(cmd, "`type` is required"))?;
let (mut kind, base) = kind_for(&t).ok_or_else(|| bad(cmd, format!("unknown type `{t}`")))?;
let page = s.page()?;
let parent = id(p, "parent").unwrap_or(page);
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let doc = s.doc()?;
let pn = doc.get(parent)?;
if !pn.kind.is_container() {
@@ -255,7 +255,7 @@ pub fn create(s: &mut Session, p: &Value) -> Result<Value> {
node.width = tw;
node.height = th;
}
let index = num(p, "index").map(|i| i.max(0.0) as usize);
let index = crate::params::opt_index(cmd, p, "index")?;
doc.insert(node, parent, index)?;
if let Some(Value::Object(props)) = get(p, "props") {
super::props::apply(cmd, doc, nid, props)?;
@@ -340,7 +340,7 @@ pub fn place_image(s: &mut Session, p: &Value) -> Result<Value> {
_ => ImageScaleMode::Fill,
};
let page = s.page()?;
let parent = id(p, "parent").unwrap_or(page);
let parent = crate::params::opt_id(cmd, p, "parent")?.unwrap_or(page);
let ds = s.ds_mut()?;
let doc = ds.doc_mut();
let (hash, iw, ih) = figcraft_format::add_image(doc, bytes).map_err(|e| bad(cmd, e.to_string()))?;
+13 -6
View File
@@ -503,9 +503,14 @@ fn bump_name(n: &str) -> String {
}
/// The copied form of the selection.
pub fn copy_to_clipboard(s: &mut Session, ids: &[NodeId]) -> Result<()> {
pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> {
let ds = s.ds()?;
let (nodes, images, bounds) = copied(&ds.doc, ids);
// Pages are not layers: they can't be pasted anywhere.
let layers: Vec<NodeId> = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect();
if layers.is_empty() && !ids.is_empty() {
return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into()));
}
let (nodes, images, bounds) = copied(&ds.doc, &layers);
let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid);
s.clipboard.source = Some(source);
s.clipboard.source_path = source_path;
@@ -598,14 +603,16 @@ fn paste_design_layers(s: &mut Session, p: &Value) -> Result<Value> {
fn copy(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("edit.copy", s, p)?;
copy_to_clipboard(s, &ids)?;
copy_to_clipboard(s, "edit.copy", &ids)?;
Ok(json!({"copied": s.clipboard.nodes.len()}))
}
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
let ids = req_targets("edit.cut", s, p)?;
copy_to_clipboard(s, &ids)?;
delete(s, &json!({"ids": ids}))
copy_to_clipboard(s, "edit.cut", &ids)?;
let doc = s.doc()?;
let layers: Vec<NodeId> = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect();
delete(s, &json!({"ids": layers}))
}
/// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords).
@@ -1240,7 +1247,7 @@ fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result<Valu
fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result<Value> {
let cmd = if fills { "fill.set" } else { "stroke.set" };
let ids = req_targets(cmd, s, p)?;
let idx = num(p, "index").map(|x| x.max(0.0) as usize);
let idx = opt_index(cmd, p, "index")?;
let new_paint = match get(p, "paint") {
Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?),
None => None,
@@ -0,0 +1,40 @@
//! Commands never leave a layer with a non-finite position or size: such a file saves but can't be
//! reopened (JSON writes NaN and infinity as null).
use serde_json::json;
use crate::Session;
fn rect(s: &mut Session) -> crate::NodeId {
let r = s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 10, "width": 100, "height": 50})).unwrap();
crate::params::id_of(&r["id"]).unwrap()
}
fn assert_roundtrips(s: &Session) {
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
assert!(figcraft_format::load_bytes(&bytes).is_ok(), "the saved file reopens");
}
#[test]
fn huge_moves_are_refused_and_the_file_still_reopens() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 0})).ok();
let second = s.execute("transform.move", &json!({"ids": [r], "dx": 1e308, "dy": 1e308}));
assert!(second.is_err(), "a move past the f64 range is refused");
let n = s.doc().unwrap().node(r).unwrap();
assert!(n.transform.as_coeffs().iter().all(|c| c.is_finite()), "{:?}", n.transform);
assert_roundtrips(&s);
}
#[test]
fn huge_scale_is_refused_and_the_file_still_reopens() {
let mut s = Session::new();
let r = rect(&mut s);
s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300})).ok();
let second = s.execute("transform.scale", &json!({"ids": [r], "factor": 1e300}));
assert!(second.is_err(), "a size past the f64 range is refused");
let n = s.doc().unwrap().node(r).unwrap();
assert!(n.width.is_finite() && n.height.is_finite(), "{} x {}", n.width, n.height);
assert_roundtrips(&s);
}
@@ -0,0 +1,161 @@
//! Layer ids in params: naming something that isn't a layer is a mistake, and says so, rather than
//! quietly meaning "none".
//!
//! `node.create`'s `parent` was `id(p, "parent").unwrap_or(page)`, so any value that isn't an id —
//! a `{"$step": n}` nothing had resolved, a name, a negative number — became "no parent" and the
//! layer landed on the page while the call reported success. The same shape sat on `image.place`
//! and `instance.create`'s parents, on `index`, and on the `ids` of every command that targets
//! layers (an entry that wasn't an id was dropped and the rest ran).
use crate::params::id_of;
use crate::{EngineError, Session};
use figcraft_doc::*;
use serde_json::{Value, json};
/// The page's children, by name (bottom first).
fn top(s: &Session) -> Vec<String> {
let doc = s.doc().unwrap();
let page = s.page().unwrap();
doc.node(page).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.clone())).collect()
}
/// The message of a failure, or `None` when the call went through.
fn failed(r: std::result::Result<Value, EngineError>) -> Option<String> {
match r {
Ok(_) => None,
Err(e) => Some(e.to_string()),
}
}
fn rect(parent: Value) -> Value {
json!({"type": "rectangle", "name": "child", "parent": parent, "x": 0, "y": 0, "width": 10, "height": 10})
}
fn card(s: &mut Session) -> NodeId {
let v = s.execute("node.create", &json!({"type": "frame", "name": "Card", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap();
id_of(&v["id"]).unwrap()
}
#[test]
fn a_parent_that_is_not_an_id_is_refused_rather_than_becoming_the_page() {
let mut s = Session::new();
let card = card(&mut s);
for parent in [json!({"$step": 0}), json!("Card"), json!(-1), json!(true)] {
let msg = failed(s.execute("node.create", &rect(parent.clone()))).unwrap_or_else(|| panic!("{parent} must be refused"));
assert!(msg.contains("`parent` must be a node id"), "{parent}: {msg}");
}
assert_eq!(top(&s), ["Card"], "nothing landed on the page");
let doc = s.doc().unwrap();
assert!(doc.node(card).unwrap().children.is_empty(), "nor inside the frame");
}
#[test]
fn leaving_the_parent_out_or_null_still_means_the_page() {
let mut s = Session::new();
let card = card(&mut s);
for parent in [None, Some(json!(null))] {
let mut p = rect(json!(0));
match parent {
None => _ = p.as_object_mut().unwrap().remove("parent"),
Some(v) => p["parent"] = v,
}
s.execute("node.create", &p).unwrap();
}
assert_eq!(top(&s), ["Card", "child", "child"]);
let doc = s.doc().unwrap();
assert!(doc.node(card).unwrap().children.is_empty());
}
#[test]
fn a_parent_id_that_names_nothing_is_still_reported() {
let mut s = Session::new();
card(&mut s);
let msg = failed(s.execute("node.create", &rect(json!(99999)))).unwrap_or_else(|| panic!("a missing layer must fail"));
assert!(msg.contains("no such node 99999"), "{msg}");
}
#[test]
fn an_index_that_is_not_a_whole_non_negative_number_is_refused() {
let mut s = Session::new();
let card = card(&mut s);
// (A boolean is a number to `f64_of` — `true` reads as 1 — as for `x`, `width` and every
// other number, so it is deliberately not in this list.)
for index in [json!("last"), json!(1.5), json!(-1), json!(-0.5), json!(1e12)] {
let mut p = rect(json!(card));
p["index"] = index.clone();
let msg = failed(s.execute("node.create", &p)).unwrap_or_else(|| panic!("index {index} must be refused"));
assert!(msg.contains("`index`"), "{index}: {msg}");
}
let doc = s.doc().unwrap();
assert!(doc.node(card).unwrap().children.is_empty(), "nothing was inserted");
}
#[test]
fn a_whole_index_still_inserts_where_it_says() {
let mut s = Session::new();
let card = card(&mut s);
for name in ["a", "b"] {
s.execute("node.create", &json!({"type": "rectangle", "name": name, "parent": card, "x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
}
s.execute("node.create", &json!({"type": "rectangle", "name": "mid", "parent": card, "index": 1, "x": 0, "y": 0, "width": 10, "height": 10}))
.unwrap();
let doc = s.doc().unwrap();
let names: Vec<&str> = doc.node(card).unwrap().children.iter().filter_map(|c| doc.node(*c).map(|n| n.name.as_str())).collect();
assert_eq!(names, ["a", "mid", "b"]);
}
#[test]
fn ids_entries_that_are_not_ids_are_refused_instead_of_being_dropped() {
let mut s = Session::new();
let card = card(&mut s);
// The junk used to be dropped and the rest applied, so `["nope", card]` renamed the frame.
for ids in [json!(["nope"]), json!(["nope", card]), json!([{"$step": 0}, card]), json!([null])] {
let msg =
failed(s.execute("node.set", &json!({"ids": ids, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("{ids} must be refused"));
assert!(msg.contains("`ids`"), "{ids}: {msg}");
}
let doc = s.doc().unwrap();
assert_eq!(doc.node(card).unwrap().name, "Card", "nothing was renamed");
}
#[test]
fn an_ids_that_is_not_an_array_is_refused_but_null_means_the_selection() {
let mut s = Session::new();
let card = card(&mut s);
let msg =
failed(s.execute("node.set", &json!({"ids": "nope", "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a non-array ids must fail"));
assert!(msg.contains("`ids` must be an array"), "{msg}");
// An explicit null says the same as leaving it out: the selection.
s.execute("select.set", &json!({"ids": [card]})).unwrap();
s.execute("node.set", &json!({"ids": null, "props": {"name": "Selected"}})).unwrap();
assert_eq!(s.doc().unwrap().node(card).unwrap().name, "Selected");
}
#[test]
fn a_single_id_that_is_not_an_id_is_refused() {
let mut s = Session::new();
card(&mut s);
let msg =
failed(s.execute("node.set", &json!({"id": {"$step": 0}, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("a bad id must fail"));
assert!(msg.contains("`id` must be a node id"), "{msg}");
}
#[test]
fn an_image_and_an_instance_parent_are_checked_the_same_way() {
let mut s = Session::new();
card(&mut s);
let img = failed(s.execute("image.place", &json!({"data": "aGk=", "parent": {"$step": 0}})));
assert!(img.unwrap_or_default().contains("`parent` must be a node id"), "image.place");
let inst = failed(s.execute("instance.create", &json!({"component": 99999, "parent": {"$step": 0}})));
assert!(inst.is_some(), "instance.create refuses both a bad component and a bad parent");
}
#[test]
fn a_huge_bad_value_is_cut_short_in_the_error() {
let mut s = Session::new();
card(&mut s);
let junk = "é".repeat(10_000);
let msg = failed(s.execute("node.set", &json!({"ids": junk, "props": {"name": "renamed"}}))).unwrap_or_else(|| panic!("must fail"));
assert!(msg.contains("`ids` must be an array") && msg.ends_with("…)"), "{msg}");
assert!(msg.chars().count() < 200, "{} chars", msg.chars().count());
}
+37 -3
View File
@@ -905,7 +905,8 @@ impl Session {
let result = guard::run(id, || (spec.run)(self, params))
.and_then(|v| self.check_instance_structure(id, &before).map(|()| v))
.and_then(|v| self.check_preview(id, &before).map(|()| v))
.and_then(|v| self.check_live_access(id, &before).map(|()| v));
.and_then(|v| self.check_live_access(id, &before).map(|()| v))
.and_then(|v| self.check_finite_geometry(id, &before).map(|()| v));
match &result {
Ok(_) => {
self.after_command(spec, before);
@@ -947,6 +948,22 @@ impl Session {
Err(EngineError::Disabled(id.to_string(), PREVIEW_READ_ONLY.into()))
}
/// A layer's position and size stay finite: infinity and NaN are written as `null`, so a file
/// holding them saves but can't be reopened. Refuse such an edit (it is rolled back).
fn check_finite_geometry(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
let Some((bdoc, _, _, uid)) = before else { return Ok(()) };
let Some(ds) = self.docs.iter().find(|d| d.uid == *uid) else { return Ok(()) };
if Arc::ptr_eq(&ds.doc, bdoc) {
return Ok(());
}
let broken = |n: &Node| !(n.transform.as_coeffs().iter().all(|c| c.is_finite()) && n.width.is_finite() && n.height.is_finite());
let untouched = |n: &Node| bdoc.node_arc(n.id).zip(ds.doc.node_arc(n.id)).is_some_and(|(a, b)| Arc::ptr_eq(a, b));
match ds.doc.nodes().find(|n| broken(n) && !untouched(n)) {
Some(n) => Err(bad(id, format!("\"{}\" would get a position or size beyond the number range", n.name))),
None => Ok(()),
}
}
/// A live session's document you may only view (or that is on its way to a server) is
/// read-only: refuse an edit (it is rolled back).
fn check_live_access(&self, id: &str, before: &Option<(Arc<Document>, Vec<NodeId>, NodeId, u64)>) -> Result<()> {
@@ -1166,13 +1183,22 @@ impl Session {
self.grouped(label, |s| {
let mut results: Vec<Value> = Vec::with_capacity(cmds.len());
for (step, (id, p)) in cmds.iter().enumerate() {
let p = if id == "command.batch" { p.clone() } else { resolve_step_refs(p, &results, step, 0)? };
results.push(s.execute(id, &p)?);
let v = s.batch_step(&results, step, id, p)?;
results.push(v);
}
Ok(results)
})
}
/// One step of a batch: `{"$step": n}` in `params` resolves against `results` (the results of
/// the steps before it), then the command runs. [`Session::batch`] runs a list of these as one
/// undo step; a caller with its own list — the CLI's script runner — uses this so its steps
/// resolve `$step` the same way, inside its own [`Session::grouped`].
pub fn batch_step(&mut self, results: &[Value], step: usize, cmd: &str, params: &Value) -> Result<Value> {
let p = if cmd == "command.batch" { params.clone() } else { resolve_step_refs(params, results, step, 0)? };
self.execute(cmd, &p)
}
/// Run `f` as one undo step called `label`: the commands it executes push no steps of their
/// own, and when it fails the documents stay at their group-start state.
pub fn grouped<T>(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
@@ -1561,6 +1587,8 @@ mod fig_export_tests;
#[cfg(test)]
mod fig_import_tests;
#[cfg(test)]
mod finite_geometry_tests;
#[cfg(test)]
mod frame_guides_tests;
#[cfg(test)]
mod frame_label_tests;
@@ -1583,6 +1611,8 @@ mod html_codegen_tests;
#[cfg(test)]
mod huge_stroke_tests;
#[cfg(test)]
mod id_params_behaviour_tests;
#[cfg(test)]
mod import_export_tests;
#[cfg(test)]
mod import_journal_tests;
@@ -1627,10 +1657,14 @@ mod open_issues_tests;
#[cfg(test)]
mod outline_options_tests;
#[cfg(test)]
mod page_clipboard_tests;
#[cfg(test)]
mod page_copy_navigation_tests;
#[cfg(test)]
mod page_target_tests;
#[cfg(test)]
mod paint_index_tests;
#[cfg(test)]
mod paint_reorder_tests;
#[cfg(test)]
mod paint_tests;
+36
View File
@@ -0,0 +1,36 @@
//! `edit.copy` / `edit.cut` skip pages (they aren't layers and can't be pasted), and refuse a
//! selection of only pages under their own command id.
use serde_json::json;
use crate::{EngineError, Session};
#[test]
fn copy_and_cut_ignore_pages_so_paste_keeps_the_document_valid() {
for cmd in ["edit.copy", "edit.cut"] {
let mut s = Session::new();
let page = s.page().unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
match s.execute(cmd, &json!({"ids": [page.0]})) {
Err(EngineError::Disabled(id, _)) => assert_eq!(id, cmd),
r => panic!("{cmd} on a page: {r:?}"),
}
let _ = s.execute("edit.paste", &json!({}));
let doc = s.doc().unwrap();
assert!(doc.validate().is_empty(), "{cmd}: {:?}", doc.validate());
assert!(doc.node(page).is_some(), "{cmd} removed the page");
}
}
#[test]
fn cut_of_a_page_and_a_layer_moves_only_the_layer() {
let mut s = Session::new();
let page = s.page().unwrap();
let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
let rect = crate::params::id_of(&r["id"]).unwrap();
s.execute("edit.cut", &json!({"ids": [page.0, rect.0]})).unwrap();
assert!(s.doc().unwrap().node(page).is_some());
assert!(s.doc().unwrap().node(rect).is_none());
s.execute("edit.paste", &json!({})).unwrap();
assert!(s.doc().unwrap().validate().is_empty());
}
+39
View File
@@ -0,0 +1,39 @@
//! `fill.set` / `stroke.set` reject a negative, fractional or non-finite `index` instead of
//! clamping or truncating it onto another paint (like `fill.remove` / `stroke.remove`).
use figcraft_doc::NodeId;
use serde_json::{Value, json};
use crate::Session;
fn ex(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"))
}
fn paints(s: &Session, id: NodeId) -> String {
let n = s.doc().unwrap().node(id).unwrap();
format!("{:?} {:?}", n.fills, n.strokes)
}
#[test]
fn paint_set_rejects_bad_index_and_leaves_the_paints_alone() {
let mut s = Session::new();
let r = NodeId(ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))["id"].as_u64().unwrap());
for add in ["fill.add", "stroke.add"] {
for c in ["#0000FF", "#FFFF00"] {
ex(&mut s, add, json!({"ids": [r], "color": c}));
}
}
let before = paints(&s, r);
for cmd in ["fill.set", "stroke.set"] {
for bad in [json!(-1), json!(0.5), json!(1.9), json!("-1"), json!("1.5"), json!("NaN"), json!("inf"), json!("abc")] {
let res = s.execute(cmd, &json!({"ids": [r], "index": bad, "color": "#FF0000"}));
assert!(res.is_err(), "{cmd} index {bad} was accepted: {res:?}");
assert_eq!(paints(&s, r), before, "{cmd} index {bad} changed the document");
}
ex(&mut s, cmd, json!({"ids": [r], "index": 1, "color": "#FF0000"}));
assert_ne!(paints(&s, r), before, "{cmd} with a valid index still edits");
ex(&mut s, "edit.undo", json!({}));
assert_eq!(paints(&s, r), before);
}
}
+48 -1
View File
@@ -88,6 +88,36 @@ pub fn req_id(cmd: &str, p: &Value, key: &str) -> Result<NodeId> {
id(p, key).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id")))
}
/// The offending value for an error message, cut to a few dozen characters so a huge param
/// doesn't end up in the error whole.
fn shown(v: &Value) -> String {
const MAX: usize = 40;
let text = v.to_string();
match text.char_indices().nth(MAX) {
Some((cut, _)) => format!("{}…", &text[..cut]),
None => text,
}
}
/// `key` when it is there: a layer id, or an error naming it. Missing and `null` mean "none", so a
/// command can tell "no parent given" (use the page) from "a parent was given that isn't an id" —
/// which is a mistake, not the default. Without this, `{"$step": 0}` reaching a param that isn't
/// resolved yet silently becomes "no parent" and the layer lands on the page.
pub fn opt_id(cmd: &str, p: &Value, key: &str) -> Result<Option<NodeId>> {
match get(p, key) {
None | Some(Value::Null) => Ok(None),
Some(v) => id_of(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` must be a node id (got {})", shown(v)))),
}
}
/// `key` when it is there: a whole, non-negative number, or an error. Missing means "none".
pub fn opt_index(cmd: &str, p: &Value, key: &str) -> Result<Option<usize>> {
match get(p, key) {
None | Some(Value::Null) => Ok(None),
Some(_) => req_index(cmd, p, key).map(Some),
}
}
/// `ids` (array), `id` (single), or the current selection.
pub fn targets(s: &Session, p: &Value) -> Vec<NodeId> {
if let Some(Value::Array(a)) = get(p, "ids") {
@@ -99,8 +129,25 @@ pub fn targets(s: &Session, p: &Value) -> Vec<NodeId> {
s.selection()
}
/// Like [`targets`] but an empty result is an error.
/// Like [`targets`] but an unparseable `ids` entry, an unparseable `id`, or an empty result is an
/// error: naming something that isn't a layer is a mistake, not an empty selection. An explicit
/// `null` says the same as leaving the key out.
pub fn req_targets(cmd: &str, s: &Session, p: &Value) -> Result<Vec<NodeId>> {
match get(p, "ids") {
Some(Value::Array(a)) => {
if let Some(v) = a.iter().find(|v| id_of(v).is_none()) {
return Err(bad(cmd, format!("`ids`: every entry must be a node id (got {})", shown(v))));
}
}
Some(Value::Null) | None => {
if let Some(v) = get(p, "id").filter(|v| !v.is_null())
&& id_of(v).is_none()
{
return Err(bad(cmd, format!("`id` must be a node id (got {})", shown(v))));
}
}
Some(v) => return Err(bad(cmd, format!("`ids` must be an array (got {})", shown(v)))),
}
let t = targets(s, p);
if t.is_empty() {
return Err(EngineError::Disabled(cmd.to_string(), "nothing is selected (pass `ids`)".into()));
+1
View File
@@ -838,6 +838,7 @@ name = "English"
"No model credits were built into this copy." = "No model credits were built into this copy."
"No number variables fit here." = "No number variables fit here."
"No number variables yet. Create them in Local variables." = "No number variables yet. Create them in Local variables."
"No other pages" = "No other pages"
"No paints" = "No paints"
"No plugins on {server}." = "No plugins on {server}."
"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "No plugins yet. Import a development plugin from its figcraft-plugin.json."
+1
View File
@@ -839,6 +839,7 @@ status = "draft — needs native review"
"No model credits were built into this copy." = "Esta copia no incluye créditos de modelos."
"No number variables fit here." = "Ninguna variable numérica encaja aquí."
"No number variables yet. Create them in Local variables." = "Aún no hay variables numéricas. Créalas en Variables locales."
"No other pages" = "No hay otras páginas"
"No paints" = "Sin pinturas"
"No plugins on {server}." = "No hay plugins en {server}."
"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Aún no hay plugins. Importa un plugin de desarrollo desde su figcraft-plugin.json."
+1
View File
@@ -826,6 +826,7 @@ name = "Русский"
"No model credits were built into this copy." = "В этой сборке нет сведений об использованных моделях."
"No number variables fit here." = "Нет подходящих числовых переменных."
"No number variables yet. Create them in Local variables." = "Числовых переменных пока нет. Создайте их в разделе «Локальные переменные»."
"No other pages" = "Других страниц нет"
"No paints" = "Заливок нет"
"No plugins on {server}." = "На {server} нет плагинов."
"No plugins yet. Import a development plugin from its figcraft-plugin.json." = "Плагинов пока нет. Импортируйте плагин для разработки из его figcraft-plugin.json."
+489
View File
@@ -17,6 +17,7 @@ name = "Svenska"
"@{login}: {prs} PRs, {commits} commits, +{added} / −{deleted} lines (Δ {delta}), +{bin_added} / −{bin_deleted} binary assets, {first} – {last}" = "@{login}: {prs} PR:er, {commits} commits, +{added} / −{deleted} rader (Δ {delta}), +{bin_added} / −{bin_deleted} binära tillgångar, {first} – {last}"
"A collection of this file with these modes and variables: override their values per mode and add variables of its own" = "En samling i den här filen med dessa lägen och variabler: åsidosätt deras värden per läge och lägg till egna variabler"
"A .figcraft file with this name already exists. Choose its full name to confirm replacement." = "Det finns redan en .figcraft-fil med det här namnet. Välj dess fullständiga namn för att bekräfta att den ersätts."
"A layer on its own aligns to its frame: select more layers, or one in a frame" = "Ett ensamt lager justeras mot sin ram: markera fler lager, eller ett lager i en ram"
"A responsive product page: hero, features, figures, a quote and a footer" = "En responsiv produktsida: toppsektion, funktioner, figurer, ett citat och en sidfot"
"A spring plays until it settles: its mass, stiffness and damping set how long" = "En fjäder spelas tills den stannar: dess massa, styvhet och dämpning avgör hur länge"
"A title, an agenda, a big figure, a chart and a closing slide" = "En titel, en agenda, en stor figur, ett diagram och en avslutande bild"
@@ -66,6 +67,7 @@ name = "Svenska"
"Add variant with a new value" = "Lägg till variant med ett nytt värde"
"Added" = "Tillagd"
"Added the library \"{name}\"" = "Biblioteket ”{name}” har lagts till"
"Adding to {name}" = "Lägger till i {name}"
"Adjust" = "Justera"
"Adjustment presets" = "Justeringsförinställningar"
"Advanced" = "Avancerat"
@@ -311,7 +313,9 @@ name = "Svenska"
"Copy {value}" = "Kopiera {value}"
"Corner radius" = "Hörnradie"
"Corner smoothing" = "Hörnutjämning"
"Couldn't download the variables" = "Det gick inte att hämta variablerna"
"Couldn't reach {server}: showing what this computer remembers." = "Det gick inte att nå {server}: visar det som den här datorn minns."
"Couldn't save automatically: {error}" = "Det gick inte att spara automatiskt: {error}"
"Count" = "Antal"
"Create" = "Skapa"
"Create a variable collection first" = "Skapa en variabelsamling först"
@@ -406,6 +410,9 @@ name = "Svenska"
"Draw" = "Rita"
"Draw a frame" = "Rita en ram"
"Drawing tools" = "Ritverktyg"
"Drop a .figcraft, .fig, .json, .svg or image file." = "Släpp en .figcraft-, .fig-, .json- eller .svg-fil eller en bildfil."
"Drop to import" = "Släpp för att importera"
"Drop to open" = "Släpp för att öppna"
"Drop shadow" = "Släppskugga"
"Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community." = "Dubbellicensierad under MIT OR Apache-2.0. Skapad av ArtCraft-teamet och gemenskapen."
"Duo" = "Duo"
@@ -476,6 +483,7 @@ name = "Svenska"
"False" = "Falskt"
"Feb" = "feb"
"Fetching from the server…" = "Hämtar från servern…"
"FigCraft couldn't open its window: {error}" = "FigCraft kunde inte öppna sitt fönster: {error}"
"FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft stöder ännu inte alla funktioner i de här filerna. Anteckningarna visar vad som utelämnades eller ändrades i den importerade kopian; originalfilen är oförändrad."
"File" = "Fil"
"File name" = "Filnamn"
@@ -596,6 +604,7 @@ name = "Svenska"
"Hug contents" = "Omslut innehåll"
"Icon components on a 24 px grid with keylines and a size preview" = "Ikonkomponenter på ett 24 px-rutnät med hjälplinjer och en förhandsgranskning av storlekar"
"Icon set starter" = "Startpaket för ikonuppsättning"
"If it keeps happening, please report it at {link} with this message." = "Om det fortsätter att hända kan du rapportera det på {link} och bifoga det här meddelandet."
"If" = "Om"
"If {condition}" = "Om {condition}"
"Ignore auto layout (absolute position)" = "Ignorera automatisk layout (absolut position)"
@@ -903,6 +912,8 @@ name = "Svenska"
"Open variables ({count})" = "Öppna variabler ({count})"
"Opened as a new document" = "Öppnat som ett nytt dokument"
"Opened “{name}” in a new tab" = "Öppnade ”{name}” i en ny flik"
"Opening the file…" = "Öppnar filen…"
"Opening {n} files as new files" = { one = "Öppnar som en ny fil", other = "Öppnar {n} filer som nya filer" }
"Open|comments" = "Öppna"
"Options" = "Alternativ"
"Orange" = "Orange"
@@ -1056,6 +1067,7 @@ name = "Svenska"
"Remove the else and its actions" = "Ta bort else-grenen och dess åtgärder"
"Remove this condition and its actions" = "Ta bort det här villkoret och dess åtgärder"
"Remove this file from the server? It can't be shared or opened from there again. Your copy stays open here." = "Vill du ta bort den här filen från servern? Den kan inte längre delas eller öppnas därifrån. Din kopia förblir öppen här."
"Remove {name} from session" = "Ta bort {name} från sessionen"
"Remove {name} from {server}?" = "Vill du ta bort {name} från {server}?"
"Removed" = "Borttaget"
"Removed from the server" = "Borttagen från servern"
@@ -1123,6 +1135,7 @@ name = "Svenska"
"Rulers" = "Linjaler"
"Run" = "Kör"
"Run {label}" = "Kör {label}"
"Save files automatically" = "Spara filer automatiskt"
"Search templates" = "Sök mallar"
"See all templates" = "Se alla mallar"
"Select layers and they space themselves as their content changes" = "Markera lager så fördelar de avståndet själva när innehållet ändras"
@@ -1324,6 +1337,9 @@ name = "Svenska"
"Texts" = "Texter"
"Texture" = "Textur"
"The canvas previews colors in sRGB; exported PNG, JPG and WebP files are tagged Display P3." = "Arbetsytan förhandsgranskar färger i sRGB; exporterade PNG-, JPG- och WebP-filer märks med Display P3."
"The destination document is unavailable. Nothing was imported." = "Måldokumentet är inte tillgängligt. Ingenting importerades."
"The destination document was closed. Nothing was imported." = "Måldokumentet stängdes. Ingenting importerades."
"The destination page was removed. Nothing was imported." = "Målsidan togs bort. Ingenting importerades."
"The file is deleted from the server: nobody can open or join it from there again, and this can't be undone. Copies that are open stay with whoever has them." = "Filen raderas från servern: ingen kan längre öppna eller ansluta till den därifrån, och detta kan inte ångras. Kopior som är öppna finns kvar hos dem som har dem."
"The file now holds another library" = "Filen innehåller nu ett annat bibliotek"
"The file now holds another library (\"{name}\")" = "Filen innehåller nu ett annat bibliotek (\"{name}\")"
@@ -1399,6 +1415,8 @@ name = "Svenska"
"Update from selection" = "Uppdatera från markering"
"Updated {components} and {styles}" = "{components} och {styles} har uppdaterats"
"Updated {name}" = "{name} har uppdaterats"
"Updating the graphics driver often helps. To use OpenGL only, start FigCraft from a Command Prompt with:" = "Det hjälper ofta att uppdatera grafikdrivrutinen. Om du bara vill använda OpenGL startar du FigCraft från en kommandotolk med:"
"Updating the graphics driver often helps. WGPU_BACKEND (vulkan, gl, metal) names another graphics API to try." = "Det hjälper ofta att uppdatera grafikdrivrutinen. WGPU_BACKEND (vulkan, gl, metal) anger ett annat grafik-API att prova."
"Uploads this file to a FigCraft server and keeps this tab in sync with it. You get a link to edit and a link to view." = "Laddar upp filen till en FigCraft-server och håller fliken synkroniserad med den. Du får en länk för redigering och en för visning."
"Uppercase" = "Versaler"
"Use a fixed value" = "Använd ett fast värde"
@@ -1442,6 +1460,7 @@ name = "Svenska"
"Visibility" = "Synlighet"
"Visible" = "Synlig"
"Vivid" = "Livfull"
"WGPU_BACKEND is set and names the graphics API to use; unsetting it lets FigCraft choose." = "WGPU_BACKEND är satt och anger vilket grafik-API som ska användas; tar du bort den väljer FigCraft själv."
"Warm" = "Varm"
"Watch" = "Bevaka"
"Wavy" = "Vågig"
@@ -1664,6 +1683,127 @@ name = "Svenska"
[commands]
"align.bottom" = "Justera nederkant"
"align.hcenter" = "Justera horisontella mittpunkter"
"align.left" = "Justera vänster"
"align.right" = "Justera höger"
"align.top" = "Justera överkant"
"align.vcenter" = "Justera vertikala mittpunkter"
"annotation.add" = "Lägg till anteckning"
"annotation.addCategory" = "Lägg till anteckningskategori"
"annotation.categories" = "Anteckningskategorier"
"annotation.edit" = "Redigera anteckning"
"annotation.editCategory" = "Redigera anteckningskategori"
"annotation.list" = "Lista anteckningar"
"annotation.remove" = "Ta bort anteckning"
"annotation.removeCategory" = "Radera anteckningskategori"
"app.actions" = "Åtgärder"
"app.preferences" = "Inställningar"
"arrange.back" = "Flytta längst bak"
"arrange.backward" = "Flytta bakåt"
"arrange.forward" = "Flytta framåt"
"arrange.front" = "Flytta längst fram"
"arrange.packH" = "Packa horisontellt"
"arrange.packV" = "Packa vertikalt"
"arrange.roundToPixels" = "Avrunda till pixlar"
"arrange.tidy" = "Snygga till"
"bool.exclude" = "Uteslut markering"
"bool.flatten" = "Platta till boolesk form"
"bool.intersect" = "Skärning av markering"
"bool.subtract" = "Subtrahera markering"
"bool.union" = "Förena markering"
"branch.archive" = "Arkivera gren"
"branch.create" = "Skapa gren"
"branch.list" = "Lista grenar"
"branch.merge" = "Sammanfoga gren"
"branch.open" = "Öppna gren"
"branch.review" = "Granska gren"
"branch.updateFromMain" = "Uppdatera från main"
"codeConnect.export" = "Exportera kodkopplingar"
"codeConnect.import" = "Importera kodkopplingar"
"codeConnect.remove" = "Koppla från koden"
"codeConnect.set" = "Koppla till kod"
"collab.cursor" = "Visa markör vid"
"collab.cursorChat" = "Markörchatt"
"collab.diff" = "Visa synkändringar"
"collab.files" = "Dina onlinefiler"
"collab.follow" = "Följ"
"collab.forgetFile" = "Glöm onlinefil"
"collab.host" = "Starta livesession"
"collab.join" = "Gå med i livesession"
"collab.leave" = "Lämna livesession"
"collab.manageFile" = "Hantera publicerad fil"
"collab.openFile" = "Öppna onlinefil"
"collab.peers" = "Personer i den här filen"
"collab.publish" = "Publicera på server"
"collab.recoverFile" = "Återställ onlinefil"
"collab.registerLinks" = "Öppna inbjudningslänkar i FigCraft"
"collab.removePerson" = "Ta bort från session"
"collab.report" = "Rapportera filen till serveroperatören"
"collab.serverInfo" = "Serverinfo"
"collab.share" = "Dela med en anslutningskod"
"collab.spotlight" = "Rikta strålkastaren mot mig"
"collab.stop" = "Stoppa livesession"
"command.batch" = "Kör flera kommandon"
"comment.add" = "Lägg till kommentar"
"comment.delete" = "Ta bort kommentar"
"comment.edit" = "Redigera kommentar"
"comment.link" = "Kopiera länk till kommentar"
"comment.list" = "Lista kommentarer"
"comment.markRead" = "Markera kommentar som läst"
"comment.move" = "Flytta kommentar"
"comment.next" = "Nästa kommentar"
"comment.open" = "Öppna kommentar"
"comment.people" = "Personer i kommentarer"
"comment.react" = "Reagera på kommentar"
"comment.reply" = "Svara på kommentar"
"comment.resolve" = "Lös kommentar"
"comment.zoom" = "Zooma till kommentarer"
"component.addProperty" = "Lägg till komponentegenskap"
"component.addVariant" = "Lägg till variant"
"component.bindProperty" = "Koppla lager till egenskap"
"component.combineVariants" = "Kombinera som varianter"
"component.create" = "Skapa komponent"
"component.createMultiple" = "Skapa flera komponenter"
"component.deleteProperty" = "Radera komponentegenskap"
"component.detach" = "Koppla bort instans"
"component.editProperty" = "Redigera komponentegenskap"
"component.exposeInstance" = "Exponera kapslad instans"
"component.goToMain" = "Gå till huvudkomponent"
"component.hideWhenPublishing" = "Dölj vid publicering"
"component.list" = "Lista komponenter"
"component.makeSlot" = "Gör till plats"
"component.playground" = "Komponenttestyta"
"component.properties" = "Komponentegenskaper"
"component.pushChanges" = "Skicka ändringar till huvudkomponenten"
"component.removeSlot" = "Ta bort plats"
"component.resetInstance" = "Återställ alla ändringar"
"component.resetOverride" = "Återställ åsidosättning"
"component.restoreMain" = "Återställ huvudkomponent"
"dev.compare" = "Jämför ändringar"
"dev.focus" = "Fokusvy"
"dev.resolveChange" = "Lös ändring"
"dev.unfocus" = "Lämna fokusvy"
"devResource.add" = "Lägg till utvecklarresurs"
"devResource.edit" = "Redigera utvecklarresurs"
"devResource.list" = "Lista utvecklarresurser"
"devResource.remove" = "Ta bort utvecklarresurs"
"distribute.horizontal" = "Fördela horisontellt avstånd"
"distribute.vertical" = "Fördela vertikalt avstånd"
"doc.home" = "Start"
"doc.list" = "Lista dokument"
"doc.nextTab" = "Nästa flik"
"doc.previousTab" = "Föregående flik"
"doc.switch" = "Byt dokument"
"edit.copy" = "Kopiera"
"edit.copyAsCss" = "Kopiera som CSS"
"edit.copyAsPng" = "Kopiera som PNG"
"edit.copyAsSvg" = "Kopiera som SVG"
"edit.copyAsText" = "Kopiera som text"
"edit.copyLink" = "Kopiera länk till markering"
"edit.copyProperties" = "Kopiera egenskaper"
"edit.cut" = "Klipp ut"
"edit.delete" = "Radera"
"edit.duplicate" = "Duplicera"
"edit.findReplace" = "Sök och ersätt"
"edit.paste" = "Klistra in"
@@ -1785,6 +1925,355 @@ name = "Svenska"
"object.moveToPage" = "Flytta till sida"
"object.opacityKeys" = "Nyckelbildrutor för opacitet"
"object.rasterize" = "Rastrera"
"object.releaseMask" = "Frigör mask"
"object.removeFill" = "Ta bort fyllning"
"object.removeFrame" = "Ta bort ram"
"object.removeStroke" = "Ta bort kontur"
"object.rename" = "Byt namn"
"object.resizeToFit" = "Anpassa storlek"
"object.rotate180" = "Rotera 180°"
"object.rotateLeft" = "Rotera 90° åt vänster"
"object.rotateRight" = "Rotera 90° åt höger"
"object.setThumbnail" = "Ange som miniatyr"
"object.swapFillStroke" = "Byt plats på fyllning och kontur"
"object.toggleLock" = "Lås/lås upp markering"
"object.toggleVisible" = "Visa/dölj markering"
"object.ungroup" = "Dela upp markerade grupper"
"object.wrapInSection" = "Lägg i nytt avsnitt"
"page.add" = "Lägg till sida"
"page.delete" = "Radera sida"
"page.duplicate" = "Duplicera sida"
"page.list" = "Lista sidor"
"page.move" = "Flytta sida"
"page.rename" = "Byt namn på sida"
"page.select" = "Gå till sida"
"page.setBackground" = "Sidbakgrund"
"paint.documentColors" = "Dokumentfärger"
"plugin.close" = "Stäng plugin-panelen"
"plugin.directory.install" = "Installera plugin från katalogen"
"plugin.directory.list" = "Bläddra i plugin-katalogen"
"plugin.directory.publish" = "Publicera plugin i katalogen"
"plugin.directory.unpublish" = "Avpublicera plugin från katalogen"
"plugin.event" = "Använd kontroll i plugin-panelen"
"plugin.import" = "Importera plugin från manifest…"
"plugin.list" = "Lista plugin-program"
"plugin.remove" = "Ta bort plugin"
"plugin.run" = "Kör plugin"
"plugin.ui" = "Plugin-panel"
"plugins.manage" = "Hantera plugin-program…"
"plugins.runLast" = "Kör senaste plugin"
"pref.autoSave" = "Spara filer automatiskt"
"pref.highlightHover" = "Framhäv lager vid hovring"
"pref.keepTool" = "Behåll verktyget markerat efter användning"
"pref.language" = "Gränssnittsspråk"
"pref.nudge" = "Knuffavstånd"
"pref.numberKeysOpacity" = "Använd siffertangenterna för opacitet"
"pref.publishServer" = "Server för delning online"
"pref.reduceMotion" = "Minska rörelse"
"pref.renameDuplicates" = "Byt namn på duplicerade lager"
"pref.showDimensions" = "Visa dimensioner på objekt"
"pref.userName" = "Ditt namn i kommentarer"
"proto.action.add" = "Lägg till prototypåtgärd"
"proto.action.move" = "Ändra ordning på prototypåtgärd"
"proto.action.remove" = "Ta bort prototypåtgärd"
"proto.action.set" = "Redigera prototypåtgärd"
"proto.closePreview" = "Stäng prototypförhandsgranskning"
"proto.conditional.addBlock" = "Lägg till annars om / annars"
"proto.conditional.removeBlock" = "Ta bort om / annars om / annars"
"proto.conditional.setCondition" = "Redigera villkor"
"proto.evaluate" = "Beräkna prototyputtryck"
"proto.flow.add" = "Lägg till startpunkt för flöde"
"proto.flow.remove" = "Ta bort flöde"
"proto.flow.rename" = "Byt namn på flöde"
"proto.interaction.add" = "Lägg till interaktion"
"proto.interaction.clear" = "Ta bort alla interaktioner"
"proto.interaction.remove" = "Ta bort interaktion"
"proto.interaction.set" = "Redigera interaktion"
"proto.present" = "Presentera"
"proto.presentOptions" = "Presentationsalternativ"
"proto.preview" = "Öppna prototypförhandsgranskning"
"proto.settings" = "Prototypinställningar"
"section.toggleContents" = "Dölj/visa avsnittets innehåll"
"select.add" = "Lägg till i markering"
"select.all" = "Markera alla"
"select.area" = "Markera område"
"select.byName" = "Markera lager efter namn"
"select.children" = "Markera underordnade"
"select.inverse" = "Invertera markering"
"select.layersAt" = "Lager vid en punkt"
"select.matching" = "Markera matchande lager"
"select.next" = "Markera nästa syskon"
"select.none" = "Markera inga"
"select.parent" = "Markera överordnad"
"select.previous" = "Markera föregående syskon"
"select.remove" = "Ta bort från markering"
"select.sameEffect" = "Markera samma effekt"
"select.sameFill" = "Markera samma fyllning"
"select.sameFont" = "Markera samma teckensnitt"
"select.sameInstance" = "Markera samma instans"
"select.sameProperties" = "Markera samma egenskaper"
"select.sameStroke" = "Markera samma kontur"
"select.sameText" = "Markera samma textegenskaper"
"select.set" = "Markera"
"shape.arrow" = "Pil"
"shape.ellipse" = "Ellips"
"shape.line" = "Linje"
"shape.polygon" = "Polygon"
"shape.rectangle" = "Rektangel"
"shape.star" = "Stjärna"
"shapeBuilder.merge" = "Sammanfoga områden"
"shapeBuilder.mode" = "Formbyggarläge"
"shapeBuilder.regions" = "Formbyggarområden"
"shapeBuilder.remove" = "Ta bort områden"
"stroke.add" = "Lägg till kontur"
"stroke.brush" = "Pensel"
"stroke.move" = "Flytta kontur"
"stroke.remove" = "Ta bort kontur"
"stroke.set" = "Ange kontur"
"stroke.widthProfile" = "Breddprofil"
"style.apply" = "Använd stil"
"style.bind" = "Bind variabel i stil"
"style.create" = "Skapa stil"
"style.delete" = "Ta bort stil"
"style.detach" = "Koppla bort stil"
"style.duplicate" = "Duplicera stil"
"style.list" = "Lista stilar"
"style.move" = "Flytta stilar"
"style.renameFolder" = "Byt namn på stilmapp"
"style.update" = "Redigera stil"
"template.list" = "Lista mallar"
"text.alignCenter" = "Centrera"
"text.alignJustify" = "Marginaljustera"
"text.alignLeft" = "Justera vänster"
"text.alignRight" = "Justera höger"
"text.bold" = "Fet"
"text.bulletList" = "Punktlista"
"text.case" = "Skiftläge"
"text.caseLower" = "Gemener"
"text.caseOriginal" = "Ursprungligt skiftläge"
"text.caseSmallCaps" = "Kapitäler"
"text.caseTitle" = "Inledande versaler"
"text.caseUpper" = "Versaler"
"text.create" = "Text"
"text.decoration" = "Dekoration"
"text.edit.begin" = "Redigera text"
"text.edit.caret" = "Placera markören"
"text.edit.end" = "Sluta redigera text"
"text.edit.insert" = "Skriv text"
"text.edit.key" = "Texttangent"
"text.edit.preedit" = "Komponera text (IME)"
"text.edit.selectLine" = "Markera textrad"
"text.edit.selectWord" = "Markera ord"
"text.edit.selection" = "Textmarkering"
"text.endPreview" = "Avsluta förhandsgranskning av text"
"text.fonts" = "Lista teckensnitt"
"text.italic" = "Kursiv"
"text.lineHeightDown" = "Minska radhöjden"
"text.lineHeightUp" = "Öka radhöjden"
"text.link" = "Skapa länk"
"text.listIndent" = "Öka listindrag"
"text.listOutdent" = "Minska listindrag"
"text.listStyle" = "Liststil"
"text.maxLines" = "Max antal rader"
"text.numberList" = "Numrerad lista"
"text.onPath" = "Text på bana"
"text.paragraphIndent" = "Styckeindrag"
"text.paragraphSpacing" = "Styckeavstånd"
"text.pathOptions" = "Alternativ för textbana"
"text.preview" = "Förhandsgranska textändring"
"text.removeFromPath" = "Ta bort text från bana"
"text.set" = "Ange text"
"text.setFeatures" = "OpenType-funktioner"
"text.setVariations" = "Axlar för variabla teckensnitt"
"text.sizeDown" = "Minska teckenstorleken"
"text.sizeUp" = "Öka teckenstorleken"
"text.strikethrough" = "Genomstruken"
"text.style" = "Formatera textomfång"
"text.trackingDown" = "Minska teckenavståndet"
"text.trackingUp" = "Öka teckenavståndet"
"text.underline" = "Understrykning"
"text.weightDown" = "Minska teckenvikten"
"text.weightUp" = "Öka teckenvikten"
"tool.arrow" = "Pil"
"tool.comment" = "Kommentar"
"tool.ellipse" = "Ellips"
"tool.frame" = "Ram"
"tool.hand" = "Hand"
"tool.line" = "Linje"
"tool.move" = "Flytta"
"tool.pen" = "Penna"
"tool.pencil" = "Blyertspenna"
"tool.pencilBrush" = "Blyertspensel"
"tool.place" = "Placera med verktyget"
"tool.polygon" = "Polygon"
"tool.rectangle" = "Rektangel"
"tool.scale" = "Skala"
"tool.scaleAnchor" = "Skalningsankare"
"tool.section" = "Avsnitt"
"tool.set" = "Välj verktyg"
"tool.shapeBuilder" = "Formbyggare"
"tool.slice" = "Skiva"
"tool.star" = "Stjärna"
"tool.text" = "Text"
"transform.move" = "Flytta"
"transform.moveTo" = "Flytta till"
"transform.nudge" = "Knuffa"
"transform.nudgeSize" = "Ändra storlek genom att knuffa"
"transform.resize" = "Ändra storlek"
"transform.rotate" = "Rotera"
"transform.rotationOrigin" = "Flytta rotationspunkt"
"transform.scale" = "Skala"
"transform.setRotationOrigin" = "Ange rotationspunkt"
"variable.addMode" = "Lägg till läge"
"variable.bind" = "Bind variabel"
"variable.create" = "Skapa variabel"
"variable.createCollection" = "Skapa variabelsamling"
"variable.delete" = "Radera variabel"
"variable.deleteCollection" = "Radera samling"
"variable.duplicate" = "Duplicera variabel"
"variable.export" = "Exportera variabler"
"variable.import" = "Importera variabler"
"variable.list" = "Lista variabler"
"variable.moveMode" = "Flytta läge"
"variable.removeMode" = "Radera läge"
"variable.renameCollection" = "Byt namn på samling"
"variable.renameMode" = "Byt namn på läge"
"variable.resetOverride" = "Återställ åsidosatt variabel"
"variable.set" = "Ange variabelvärde"
"variable.setMode" = "Ange variabelläge"
"variable.update" = "Redigera variabel"
"vector.create" = "Vektor"
"vector.deleteHeal" = "Radera och läk markering"
"vector.edit.addVertex" = "Lägg till punkt"
"vector.edit.begin" = "Redigera vektor"
"vector.edit.bend" = "Böj segment"
"vector.edit.bounds" = "Markerade punkters gränser"
"vector.edit.connect" = "Koppla ihop punkter"
"vector.edit.cornerRadius" = "Hörnradie för punkt"
"vector.edit.cut" = "Dela bana"
"vector.edit.delete" = "Radera punkter"
"vector.edit.end" = "Klar med redigeringen"
"vector.edit.erase" = "Radera"
"vector.edit.lasso" = "Lassomarkering"
"vector.edit.mirroring" = "Handtagsspegling"
"vector.edit.moveHandles" = "Flytta handtag"
"vector.edit.moveVertices" = "Flytta punkter"
"vector.edit.nudge" = "Knuffa punkter"
"vector.edit.paint" = "Färghink"
"vector.edit.pen" = "Pennpunkt"
"vector.edit.penEnd" = "Avsluta pennbana"
"vector.edit.position" = "Punktposition"
"vector.edit.resize" = "Ändra storlek på punkter"
"vector.edit.rotate" = "Rotera punkter"
"vector.edit.select" = "Markera punkter"
"vector.edit.selectAll" = "Markera alla punkter"
"vector.edit.setHandle" = "Ange handtag"
"vector.edit.smooth" = "Gör punkter mjuka eller skarpa"
"vector.edit.state" = "Tillstånd för vektorredigering"
"vector.edit.strokeCap" = "Konturände för punkt"
"vector.edit.strokeJoin" = "Konturhörn för punkt"
"vector.edit.tool" = "Vektorredigeringsverktyg"
"vector.edit.transform" = "Transformera punkter"
"vector.edit.widthPoint" = "Breddpunkt"
"vector.flatten" = "Platta till"
"vector.join" = "Sammanfoga markering"
"vector.offset" = "Förskjut bana"
"vector.outlineStroke" = "Konvertera kontur till form"
"vector.reverse" = "Vänd banans riktning"
"vector.simplify" = "Förenkla vektor"
"vector.smoothJoin" = "Sammanfoga markering mjukt"
"version.autosave" = "Autospara version"
"version.delete" = "Radera version"
"version.duplicate" = "Duplicera version"
"version.exitPreview" = "Tillbaka till aktuell version"
"version.list" = "Lista versioner"
"version.preview" = "Visa version"
"version.rename" = "Byt namn på version"
"version.restore" = "Återställ version"
"view.assets" = "Panelen Tillgångar"
"view.comments" = "Kommentarer"
"view.contextMenu" = "Visa snabbmeny"
"view.design" = "Panelen Design"
"view.designPrototype" = "Växla mellan Design och Prototyp"
"view.devMode" = "Utvecklarläge"
"view.frameOutlines" = "Konturlinjer för ramar"
"view.fullscreen" = "Helskärm"
"view.get" = "Hämta vy"
"view.layers" = "Panelen Lager"
"view.layoutGrids" = "Layoutrutnät"
"view.leftSidebar" = "Visa/dölj vänster sidofält"
"view.maskOutlines" = "Konturlinjer för masker"
"view.multiplayerCursors" = "Samarbetsmarkörer"
"view.nextFrame" = "Zooma till nästa ram"
"view.nextPage" = "Nästa sida"
"view.nextRegion" = "Fokusera nästa region"
"view.outlines" = "Konturlinjer"
"view.outlinesHiddenLayers" = "Inkludera dolda lager i konturlinjer"
"view.outlinesObjectBounds" = "Inkludera objektgränser i konturlinjer"
"view.outlinesSelection" = "Konturlinjer för markering"
"view.pan" = "Panorera vyn"
"view.panelWidths" = "Återställ panelbredder"
"view.pixelGrid" = "Pixelrutnät"
"view.pixelPreview" = "Pixelförhandsgranskning"
"view.prevFrame" = "Zooma till föregående ram"
"view.prevPage" = "Föregående sida"
"view.previousRegion" = "Fokusera föregående region"
"view.proofSrgb" = "Skärmkorrektur i sRGB"
"view.prototype" = "Panelen Prototyp"
"view.rulers" = "Linjaler"
"view.set" = "Ange vy"
"view.slices" = "Skivor"
"view.snapGeometry" = "Fäst mot geometri"
"view.snapObjects" = "Fäst mot objekt"
"view.snapPixel" = "Fäst mot pixelrutnät"
"view.themeDark" = "Mörkt tema"
"view.themeLight" = "Ljust tema"
"view.themeSystem" = "Följ systemets tema"
"view.toggleUi" = "Visa/dölj gränssnitt"
"view.zoom100" = "Zooma till 100 %"
"view.zoom200" = "Zooma till 200 %"
"view.zoom50" = "Zooma till 50 %"
"view.zoomAt" = "Zoomverktyg"
"view.zoomFit" = "Zooma för att anpassa"
"view.zoomIn" = "Zooma in"
"view.zoomOut" = "Zooma ut"
"view.zoomSelection" = "Zooma till markering"
"widget.click" = "Klicka på widget"
"widget.insert" = "Infoga widget"
"widget.list" = "Lista widgetar"
"window.focus" = "Placera fönstret längst fram"
"window.new" = "Nytt fönster"
[menu]
"bool.exclude" = "Uteslut"
"bool.flatten" = "Platta till"
"bool.intersect" = "Skärning"
"bool.subtract" = "Subtrahera"
"bool.union" = "Förena"
"file.duplicate" = "Duplicera"
"file.import" = "Importera från fil…"
"file.rename" = "Byt namn"
"libraries.open" = "Öppna bibliotek"
"select.sameEffect" = "Effekt"
"select.sameFill" = "Fyllning"
"select.sameFont" = "Teckensnitt"
"select.sameInstance" = "Instans"
"select.sameProperties" = "Egenskaper"
"select.sameStroke" = "Kontur"
"select.sameText" = "Textegenskaper"
"text.alignCenter" = "Centrera text"
"text.alignJustify" = "Marginaljustera text"
"text.alignLeft" = "Vänsterjustera text"
"text.alignRight" = "Högerjustera text"
"view.assets" = "Tillgångar"
"view.design" = "Design"
"view.layers" = "Lager"
"view.outlines" = "Visa konturlinjer"
"view.outlinesHiddenLayers" = "Inkludera dolda lager"
"view.outlinesObjectBounds" = "Inkludera objektgränser"
"view.outlinesSelection" = "Visa konturlinjer för markering"
"view.prototype" = "Prototyp"
"view.themeDark" = "Mörk"
"view.themeLight" = "Ljus"
"view.themeSystem" = "Följ systemet"
+101 -6
View File
@@ -73,6 +73,14 @@ pub enum AboutTab {
Models,
}
/// Values collected by a menu or Actions command before dispatching it to the engine.
#[derive(Clone, Debug, PartialEq)]
pub enum MenuInput {
Link(String),
Offset(f64),
MoveToPage,
}
impl AboutTab {
pub const ALL: [AboutTab; 3] = [AboutTab::About, AboutTab::Contributors, AboutTab::Models];
@@ -139,6 +147,7 @@ fn about_body(ui: &mut egui::Ui, t: &Tokens) {
pub fn keys(app: &mut FigApp, ctx: &Context) {
let u = &mut app.ui;
let open = u.color_profile.is_some()
|| u.menu_input.is_some()
|| u.find.is_some()
|| u.batch_rename.is_some()
|| u.import_report.is_some()
@@ -150,7 +159,12 @@ pub fn keys(app: &mut FigApp, ctx: &Context) {
if !open || !ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
return;
}
if u.color_profile.take().is_some() || u.find.take().is_some() || u.batch_rename.take().is_some() || u.import_report.take().is_some() {
if u.menu_input.take().is_some()
|| u.color_profile.take().is_some()
|| u.find.take().is_some()
|| u.batch_rename.take().is_some()
|| u.import_report.take().is_some()
{
return;
}
for flag in [&mut u.confirm_close, &mut u.export_preview, &mut u.shortcuts_panel, &mut u.preferences, &mut u.about] {
@@ -268,12 +282,26 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
ui.horizontal(|ui| {
ui.label(tr("Language"));
// Each language by its own name; System follows the OS (the browser on the web).
let langs = i18n::languages().iter().filter(|l| crate::cjk::can_show(l.code)).map(|l| (l.code, l.name));
for (code, name) in [("system", tr("System"))].into_iter().chain(langs) {
if ui.selectable_label(app.session.prefs.language.eq_ignore_ascii_case(code), name).clicked() {
app.run("pref.language", json!({"language": code}));
ui.ctx().request_repaint();
let langs = || {
[("system", tr("System"))]
.into_iter()
.chain(i18n::languages().iter().filter(|l| crate::cjk::can_show(l.code)).map(|l| (l.code, l.name)))
};
let on = |code: &str| app.session.prefs.language.eq_ignore_ascii_case(code);
let selected = langs().find(|(code, _)| on(code)).map_or(tr("System"), |(_, name)| name);
let resp = crate::widgets::dropdown(ui, selected, 220.0, None);
let mut pick = None;
crate::right::menu_popup(&resp).show(|ui| {
for (code, name) in langs() {
if crate::right::menu_check(ui, on(code), name, "") {
pick = Some(code);
ui.close();
}
}
});
if let Some(code) = pick {
app.run("pref.language", json!({"language": code}));
ui.ctx().request_repaint();
}
});
if let Some(lang) = i18n::current().filter(|l| l.draft) {
@@ -325,6 +353,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
app.ui.find = if open { Some((find, replace)) } else { None };
}
batch_rename_dialog(app, ctx);
menu_input_dialog(app, ctx);
crate::variables_panel::show(app, ctx);
crate::dev_compare::show(app, ctx);
crate::branch_ui::show(app, ctx);
@@ -339,6 +368,72 @@ pub fn show(app: &mut FigApp, ctx: &Context) {
crate::windows::confirm_close(app, ctx);
}
/// Menus and Actions cannot pass a value themselves. Gather it here, then run the regular
/// command so the same validation and undo behaviour apply as for programmatic callers.
fn menu_input_dialog(app: &mut FigApp, ctx: &Context) {
let Some(mut input) = app.ui.menu_input.take() else { return };
let t = Tokens::get(ctx);
let title = match &input {
MenuInput::Link(_) => i18n::command("text.link", "Create link"),
MenuInput::Offset(_) => i18n::command("vector.offset", "Offset path"),
MenuInput::MoveToPage => i18n::command("object.moveToPage", "Move to page"),
};
let mut open = true;
let mut action: Option<(&str, serde_json::Value)> = None;
window(&title, ctx).open(&mut open).show(ctx, |ui| {
ui.set_width(320.0);
match &mut input {
MenuInput::Link(url) => {
ui.horizontal(|ui| {
let field = crate::widgets::text_input(ui, egui::Id::new("menu_input_link"), url, 250.0, "https://");
let enter = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let ok = !url.trim().is_empty();
if (crate::widgets::button(ui, tr("Done"), true, ok).clicked() || enter) && ok {
action = Some(("text.link", json!({"url": url.trim()})));
}
});
}
MenuInput::Offset(offset) => {
ui.horizontal(|ui| {
let id = egui::Id::new("menu_input_offset");
if let Some(crate::widgets::FieldEdit::Set(v) | crate::widgets::FieldEdit::Scrub(v, _)) =
crate::widgets::number_field(ui, id, crate::widgets::Lead::None, Some(*offset), "", 120.0, 1.0)
{
*offset = v.clamp(-100_000.0, 100_000.0);
}
if crate::widgets::button(ui, tr("Offset"), true, true).clicked() {
action = Some(("vector.offset", json!({"offset": *offset})));
}
});
}
MenuInput::MoveToPage => {
let mut choices = 0;
if let Ok(ds) = app.session.ds() {
for &page in &ds.doc.pages {
if page == ds.page || ds.doc.is_divider(page) {
continue;
}
if let Some(node) = ds.doc.node(page)
&& crate::widgets::button(ui, &node.name, false, true).clicked()
{
action = Some(("object.moveToPage", json!({"page": page})));
}
choices += 1;
}
}
if choices == 0 {
crate::widgets::label(ui, tr("No other pages"), t.text_secondary);
}
}
}
});
if let Some((id, params)) = action {
app.run(id, params);
} else if open {
app.ui.menu_input = Some(input);
}
}
/// The Rename layers dialog's fields.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BatchRenameUi {
+2 -1
View File
@@ -148,7 +148,8 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) {
} else {
let name = app.session.doc().map(|d| d.name.clone()).unwrap_or_default();
let nr = Rect::from_min_max(pos2(head.min.x + 14.0, head.min.y + 14.0), pos2(head.max.x - 48.0, head.min.y + 34.0));
if let Some(new) = crate::widgets::text_field_inline(ui, Id::new("filename"), &name, nr, semibold(13.0)) {
let start = std::mem::take(&mut app.ui.rename_file);
if let (Some(new), _) = crate::widgets::rename_field(ui, Id::new("filename"), &name, nr, semibold(13.0), start) {
app.run("file.rename", json!({"name": new}));
}
ui.painter().text(pos2(head.min.x + 16.0, head.min.y + 46.0), Align2::LEFT_CENTER, tr("Drafts"), regular(11.0), t.text_secondary);
+8
View File
@@ -137,6 +137,8 @@ mod live_tests;
#[cfg(test)]
mod live_text_tests;
#[cfg(test)]
mod menu_input_tests;
#[cfg(test)]
mod online_tests;
#[cfg(test)]
mod open_issues_tests;
@@ -434,6 +436,10 @@ pub struct UiState {
/// The tab the About window shows.
pub about_tab: dialogs::AboutTab,
pub preferences: bool,
/// A menu command that needs a value before the engine can run it.
pub menu_input: Option<dialogs::MenuInput>,
/// File ▸ Rename: the file name in the left panel starts its inline rename on the next frame.
pub rename_file: bool,
pub shortcuts_panel: bool,
/// The Keyboard shortcuts panel's tab and search text.
pub shortcuts_tab: figcraft_engine::keymap::Group,
@@ -576,6 +582,8 @@ impl Default for UiState {
about: false,
about_tab: dialogs::AboutTab::About,
preferences: false,
menu_input: None,
rename_file: false,
shortcuts_panel: false,
shortcuts_tab: figcraft_engine::keymap::Group::Essential,
shortcuts_query: String::new(),
+87
View File
@@ -0,0 +1,87 @@
//! Menu and Actions entries that need an input must collect it in the UI, not send `{}` to the
//! engine (which would show a parameter error or silently use a default).
use figcraft_engine::Session;
use serde_json::json;
use crate::dialogs::MenuInput;
use crate::layers_drag_tests::Ui;
use crate::menus;
#[test]
fn preference_actions_open_preferences_without_changing_values() {
let mut ui = Ui::new(Session::new());
ui.app.session.prefs.language = "en".into();
ui.app.session.prefs.publish_server = "wss://example.test/".into();
let before = ui.app.session.prefs.clone();
for id in ["pref.language", "pref.userName", "pref.publishServer", "pref.nudge"] {
ui.app.ui.preferences = false;
menus::invoke(&mut ui.app, id);
assert!(ui.app.ui.preferences, "{id} did not open Preferences");
assert!(ui.app.ui.last_error.is_none(), "{id} showed an error");
assert_eq!(ui.app.session.prefs.language, before.language);
assert_eq!(ui.app.session.prefs.publish_server, before.publish_server);
assert_eq!(ui.app.session.prefs.nudge_small, before.nudge_small);
assert_eq!(ui.app.session.prefs.nudge_big, before.nudge_big);
}
// Programmatic callers still need the actual argument.
assert!(ui.app.session.execute("pref.language", &json!({})).is_err());
}
#[test]
fn menu_entries_collect_required_arguments_before_running() {
let mut ui = Ui::new(Session::new());
for (id, expected) in
[("text.link", MenuInput::Link(String::new())), ("vector.offset", MenuInput::Offset(0.0)), ("object.moveToPage", MenuInput::MoveToPage)]
{
ui.app.ui.menu_input = None;
menus::invoke(&mut ui.app, id);
assert_eq!(ui.app.ui.menu_input, Some(expected), "{id}");
assert!(ui.app.ui.last_error.is_none(), "{id} showed an error");
}
ui.frame(vec![]);
assert!(ui.app.ui.last_error.is_none());
}
#[test]
fn palette_entries_with_existing_pickers_show_them() {
let mut ui = Ui::new(Session::new());
menus::invoke(&mut ui.app, "tool.set");
assert_eq!(ui.app.ui.tool_flyout, Some("move"));
ui.app.ui.left_collapsed = true;
menus::invoke(&mut ui.app, "page.select");
assert!(!ui.app.ui.left_collapsed);
assert!(ui.app.ui.pages_open);
menus::invoke(&mut ui.app, "file.openRecent");
assert!(ui.app.ui.home);
assert_eq!(ui.app.ui.home_view.section, crate::home::Section::Recents);
assert!(ui.app.ui.last_error.is_none());
}
#[test]
fn batch_rename_opens_its_existing_dialog() {
let mut session = Session::new();
let id = session.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 20})).unwrap()["id"].clone();
session.execute("select.set", &json!({"ids": [id]})).unwrap();
let mut ui = Ui::new(session);
menus::invoke(&mut ui.app, "object.batchRename");
assert_eq!(ui.app.ui.batch_rename.as_ref().map(|d| d.ids.len()), Some(1));
assert!(ui.app.ui.last_error.is_none());
}
#[test]
fn rename_file_edits_the_file_name_in_place() {
let mut ui = Ui::new(Session::new());
ui.app.ui.left_collapsed = true;
menus::invoke(&mut ui.app, "file.rename");
assert!(!ui.app.ui.left_collapsed, "the left panel shows the file name");
assert!(ui.app.ui.menu_input.is_none(), "no dialog");
ui.frame(vec![]);
ui.frame(vec![]);
assert!(!ui.app.ui.rename_file, "the rename started");
assert!(ui.ctx.memory(|m| m.has_focus(egui::Id::new("filename").with("te"))), "the file name's editor has the keyboard");
ui.frame(vec![egui::Event::Text("Brand kit".into())]);
ui.frame(vec![egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }]);
assert_eq!(ui.app.session.doc().unwrap().name, "Brand kit");
assert!(ui.app.ui.last_error.is_none());
}
+30
View File
@@ -120,6 +120,10 @@ pub fn invoke(app: &mut FigApp, id: &str) {
"file.placeImage" => app.import_dialog(true),
"file.save" => app.save(),
"file.saveAs" => app.save_as_dialog(),
"file.openRecent" => {
app.ui.home = true;
app.ui.home_view.section = crate::home::Section::Recents;
}
"file.saveVersion" => crate::versions::start_save(app),
"branch.create" => crate::versions::start_branch(app),
"file.export" | "file.exportSvg" => app.ui.export_preview = true,
@@ -167,6 +171,32 @@ pub fn invoke(app: &mut FigApp, id: &str) {
app.services.clipboard_set_text(&format!("figcraft://node/{}", ids.first().map(|i| i.0).unwrap_or(0)));
}
"edit.findReplace" => app.ui.find = Some((String::new(), String::new())),
// The Actions palette invokes commands without parameters. These values are edited in
// Preferences; sending an empty object would either fail or silently reset a preference.
"pref.language" | "pref.userName" | "pref.publishServer" | "pref.nudge" => {
app.run("app.preferences", json!({}));
}
// The file name in the left panel's header turns into its editor, the name selected.
"file.rename" => {
app.ui.left_tab = crate::LeftTab::File;
app.ui.left_collapsed = false;
app.ui.rename_file = true;
}
"text.link" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Link(String::new())),
"vector.offset" => app.ui.menu_input = Some(crate::dialogs::MenuInput::Offset(0.0)),
"object.moveToPage" => app.ui.menu_input = Some(crate::dialogs::MenuInput::MoveToPage),
"object.batchRename" => {
let ids = app.session.selection();
if !ids.is_empty() {
crate::dialogs::open_batch_rename(app, &ids);
}
}
"tool.set" => app.ui.tool_flyout = Some("move"),
"page.select" => {
app.ui.left_tab = crate::LeftTab::File;
app.ui.left_collapsed = false;
app.ui.pages_open = true;
}
// From a menu the link is pasted into the Join dialog (`collab.join {link}` joins directly).
"collab.join" => crate::live::open_join(app, ""),
// So is the server address for publishing (it carries the server key).
+23 -1
View File
@@ -103,7 +103,29 @@ Runs commands in a headless session (a new document, or `--in`), printing one JS
{"command":"frame.create","ok":true,"result":{"id":2},"step":0}
```
A failing step prints `"ok": false` with the error and exits with code 1 (nothing is saved).
The whole script is **one undo step**, and it is **all-or-nothing**, exactly like running the same
commands through `command.batch` (the MCP tool `batch`, the control channel, plugins): `{"$step": n}`
in a step's params is the `id` in an earlier step's result, so a step can target a layer an earlier
step of the same script created:
```json
[
{"command": "node.create", "params": {"type": "frame", "name": "Card", "width": 200, "height": 100}},
{"command": "node.create", "params": {"type": "text", "parent": {"$step": 0}, "text": "Hi"}}
]
```
The result lines are printed once the script is through, so none of them reports work a later step
took back.
A failing step prints one line with `"ok": false`, the error and `"rolledBack": true`, writes
nothing (`--out` is not saved, and the session's documents go back to how the script found them),
and exits with code 1:
```text
{"step":1,"command":"node.set","ok":false,"error":"no such node 99999","rolledBack":true}
```
`--out` saves the result by extension like `convert`.
Script formats: a JSON array of `{"command": id, "params": {...}}`, an object
+5 -4
View File
@@ -282,7 +282,7 @@ are errors):
| `data` | `figcraft-data` | The data directory |
| `public_url` | (the listen address) | How links name the server, when people reach it another way (a reverse proxy: `wss://figcraft.example.com/`) |
| `max_files` | 1000 | Most files kept |
| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, and messages are at most 64 MiB |
| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server). The app opens files of up to 1023 MiB; above 64 with `open_publish` the server warns |
| `max_links` | 100 | Most share links per file |
| `max_connections` | 512 | Most open connections |
| `max_people` | 256 | Most people in one file at once |
@@ -337,8 +337,9 @@ the address it came from and when it was last opened (for expiry).
before the server tells anyone about the change.
- **Compaction** writes the file's state as the next snapshot and starts an empty log, after
`compact_after` entries or `compact_mb` of log, when the last person leaves, and when the server
stops. A log left over from a compaction cut short is recognised and ignored (the snapshot holds
it all).
stops. A log left over from a compaction cut short is replayed over the snapshot, which already
holds it all, so nothing changes. If a damaged generation field made a current log look like such
a leftover, its edits the snapshot lacks are recovered the same way and the damage is reported.
- **Damage is skipped and reported, never deleted.** At startup a folder whose description, links
or snapshot can't be read is left out and logged; a log that ends in a cut-short or damaged
record loses only that end (logged when the file opens). Leftover temporary files are removed.
@@ -489,7 +490,7 @@ are larger.
| Resource | Limit |
|---|---|
| Message size | A file's size and 1 MiB (`max_file_mb`, 16 by default when open, or `max_plugin_mb` if larger): checked as the message is read, before anything is decoded; 64 KiB on a connection that only browses plugins (a client sending more is disconnected with the limit as the reason; the app keeps its own copy). The core's 64 MiB otherwise |
| Message size | A file's size and 1 MiB (`max_file_mb`, 16 by default when open, or `max_plugin_mb` if larger): checked as the message is read, before anything is decoded; 64 KiB on a connection that only browses plugins (a client sending more is disconnected with the limit as the reason; the app keeps its own copy). On a private server, the larger of that and the core's 64 MiB |
| A file as it grows | Every batch is checked first (*always*): past `max_file_mb`, past its publisher's `max_mb_per_ip` or past `max_total_mb`, it is refused with the reason and the app rolls it back. A long log is compacted to make room at most once a minute per file (a compaction rewrites the whole file). A file's snapshot also holds the recent batches kept for reconnecting clients, at most a quarter of `max_file_mb` |
| Recent batches in memory | A quarter of `max_file_mb` (256 KiB at least) per open file; older ones are dropped (a client that missed them catches up with the whole file) |
| Bytes queued for slow clients | The largest message and 1 MiB per connection, 32 MiB per block of addresses, 256 MiB for everyone. A message sent to many people counts once for everyone and once per block (twenty colleagues behind one office address cost their block one copy of a broadcast), and welcomes (bounded by the file's size) don't count against blocks, so a whole office can join a file at once. A client that doesn't read its answers is dropped; when everyone's budget runs out, the connection holding the most is dropped |
+1 -1
View File
@@ -22,7 +22,7 @@ keeps Frame, Dev Mode and Smart animate as familiar tool names and uses « » qu
was machine-drafted and reviewed by a native speaker.
`es-419`), French (`fr`), German (`de`), Brazilian Portuguese (`pt-BR`), Japanese (`ja`), Korean (`ko`)
and Simplified Chinese (`zh-Hans`), machine-drafted and waiting for native speakers' review, and
Swedish (`sv`), which translates every UI string and some command labels.
Swedish (`sv`), which translates every UI string, command label and menu wording.
## The catalogs