Merge main into #1616

This commit is contained in:
bflatastic
2026-10-30 13:57:06 +07:00
115 changed files with 1907 additions and 238 deletions
Generated
+2 -1
View File
@@ -1115,13 +1115,14 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
+4 -2
View File
@@ -33,7 +33,8 @@ figcraft-mcp = { path = "crates/mcp" }
figcraft-collab-net = { path = "crates/collab-net" }
figcraft-plugin = { path = "crates/plugin" }
# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together.
# Keep coupled craft-libs crates on one rev. craft-update has no sibling-crate
# dependencies and is pinned separately for the suite updater rollout.
# Which paths stored in a document may be followed (craft-libs).
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
# Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the
@@ -41,7 +42,8 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l
# to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would
# pull a second egui family version).
craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
# The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto
# indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs).
craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
+2 -2
View File
@@ -97,13 +97,13 @@
<p align="center"><sub><b>Components and variants</b>: component sets, variant properties and the Assets panel · <code>examples/ridgeline-design-system.figcraft</code></sub></p>
</td>
<td width="50%" valign="top">
<img src="docs/images/shot-3-prototype.png" alt="Four onboarding phone screens in the Prototype tab, joined by blue connection curves from the Get started, Continue, Allow location and Not now buttons to the next screens; the Get started button is selected and the Prototype panel shows its On click, Navigate to 2 Interests interaction" width="100%">
<img src="docs/images/shot-3-prototype.png" alt="Four onboarding phone screens in the Prototype tab, joined by green connection curves from the Get started, Continue, Allow location and Not now buttons to the next screens; the Get started button is selected and the Prototype panel shows its On click, Navigate to 2 Interests interaction" width="100%">
<p align="center"><sub><b>Prototyping</b>: connections, transitions, back actions and a flow · <code>examples/ridgeline-onboarding.figcraft</code></sub></p>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<img src="docs/images/shot-4-devmode.png" alt="Dev Mode on the Ridgeline home screen: the frame carries a Ready for dev badge, a Development annotation callout points at the featured trail card, a trail row instance is selected and the Inspect panel shows generated React code with a TrailRow props interface and the instance's title and details values" width="100%">
<img src="docs/images/shot-4-devmode.png" alt="Dev Mode on the Ridgeline home screen: the frame carries a Ready for dev badge, a Development annotation callout points at the featured trail card, a trail row instance is selected and the Inspect panel shows a Playground preview of it and generated React code with a TrailRow props interface and the instance's title and details values" width="100%">
<p align="center"><sub><b>Dev Mode</b>: ready-for-dev status, annotations and React code where component properties become props · <code>examples/ridgeline-app.figcraft</code></sub></p>
</td>
<td width="50%" valign="top">
+10 -1
View File
File diff suppressed because one or more lines are too long
+20 -1
View File
@@ -402,8 +402,12 @@ pub fn parse_script(text: &str) -> Result<Vec<BatchStep>, String> {
.collect()
}
/// The largest script `run` reads (one may carry images as base64): a device, a pipe or a larger
/// file is refused before it is read (#1114).
pub const MAX_SCRIPT: u64 = 1 << 30;
pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult<ExitCode> {
let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?;
let bytes = figcraft_engine::file_access::read_capped(script, MAX_SCRIPT).map_err(|e| CliError::Failed(e.to_string()))?;
let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?;
let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?;
let (mut s, input_images_left_out) = match input {
@@ -538,6 +542,21 @@ mod tests {
dir
}
/// A script path that is a folder or a device is refused before anything is read (#1114).
#[test]
fn a_script_that_isnt_a_regular_file_is_refused() {
let dir = temp_dir("script-caps");
let mut bad = vec![dir.to_string_lossy().into_owned()];
if cfg!(unix) {
bad.push("/dev/zero".into());
}
for path in bad {
let Err(CliError::Failed(e)) = run_script(&path, None, None) else { panic!("{path} ran") };
assert!(e.contains("is a folder, not a file") || e.contains("isn't a regular file"), "{path}: {e}");
}
std::fs::remove_dir_all(dir).unwrap();
}
/// 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.
+5 -3
View File
@@ -27,7 +27,9 @@ pub const MAX_ADDRESSES: usize = 10_000;
pub const REPORTS_PER_HOUR: f64 = 10.0;
/// Bytes one address may download a second (team libraries, plugins), and at once: an IPv6 /48
/// gets four times that, an eighth of everyone's budgets (32 MiB a second, 512 MiB at once).
/// gets four times that, an eighth of everyone's budgets (32 MiB a second, 512 MiB at once). Larger
/// than the burst, a library or plugin is served once the address's budget is full, which it then
/// owes ([`Bucket::take_spread`]): it comes more slowly, never not at all.
pub const DOWNLOAD_RATE: f64 = (1u64 << 20) as f64;
pub const DOWNLOAD_BURST: f64 = (16u64 << 20) as f64;
@@ -165,7 +167,7 @@ impl Abuse {
}
for &(k, times) in &keys {
let (b, n) = Self::pick(self.address(k, times, now), what);
b.take_n(now, n);
b.take_spread(now, n);
}
true
}
@@ -211,7 +213,7 @@ impl Abuse {
/// The block has `what`'s tokens now (nothing is spent).
fn room(&mut self, ip: Block, times: f64, what: Action, now: Instant) -> bool {
let (b, n) = Self::pick(self.address(ip, times, now), what);
if b.take_n(now, n) {
if b.take_spread(now, n) {
b.give(n);
return true;
}
+15 -2
View File
@@ -147,6 +147,14 @@ impl Config {
client_max - 1
));
}
if self.people() < self.max_people {
out.push(format!(
"max_people is {}: the app keeps track of at most {} people in a file, so {} are let in",
self.max_people,
self.people(),
self.people()
));
}
if !self.open_publish {
return out;
}
@@ -171,6 +179,12 @@ impl Config {
out
}
/// Most people in one file at once: `max_people`, lowered to what the app keeps track of
/// ([`figcraft_collab::message::MAX_PEERS`]).
pub fn people(&self) -> usize {
self.max_people.min(figcraft_collab::message::MAX_PEERS)
}
/// The trusted proxy's address, when set.
pub fn proxy(&self) -> Result<Option<IpAddr>, String> {
let Some(p) = self.trusted_proxy.as_deref().map(str::trim).filter(|p| !p.is_empty()) else { return Ok(None) };
@@ -284,8 +298,7 @@ impl Config {
/// most, and those batches also at most a quarter of a file's bytes (256 KiB at least).
fn core(&self) -> figcraft_collab::Limits {
let bytes = |mb: u64| usize::try_from(mb.saturating_mul(1 << 20)).unwrap_or(usize::MAX);
let base =
figcraft_collab::Limits { max_peers: self.max_people, max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() };
let base = figcraft_collab::Limits { max_peers: self.people(), max_log_bytes: bytes(self.max_log_mb), ..figcraft_collab::Limits::default() };
if !self.open_publish {
return figcraft_collab::Limits { max_message_bytes: base.max_message_bytes.max(self.open_message()), ..base };
}
@@ -0,0 +1,67 @@
//! A team library or plugin larger than one address's download burst (16 MiB) is served once the
//! address's budget is full, which it then owes: it comes more slowly, never not at all, and the
//! address still gets no more than its rate over time (#1089).
use std::time::{Duration, Instant};
use figcraft_collab::message::{Access, LibraryReply, LibraryReq};
use figcraft_collab_net::hub::Backend;
use figcraft_collab_net::net::Bucket;
use crate::abuse::{Abuse, AbuseConfig, Action, DOWNLOAD_BURST, DOWNLOAD_RATE};
use crate::hardening_tests::archive;
use crate::store::{BUSY_DOWNLOADS, Store};
use crate::test_util::{TempDir, config, ip, snapshot};
const MIB: usize = 1 << 20;
fn secs(s: u64) -> Duration {
Duration::from_secs(s)
}
#[test]
fn a_bucket_lends_past_its_burst_only_when_full_and_is_paid_back_at_its_rate() {
let mut b = Bucket::new(1.0, 4.0);
let t = Instant::now();
assert!(b.take_n(t, 1.0));
assert!(!b.take_spread(t, 10.0), "not full: no loan");
assert!(b.take_spread(t + secs(1), 10.0), "full again: lent, owing 6");
assert!(!b.take_spread(t + secs(6), 1.0), "still owing 1");
assert!(!b.take_spread(t + secs(10), 10.0), "3 of 4 back");
assert!(b.take_spread(t + secs(11), 10.0), "paid back: lent again");
assert!(b.take_spread(t + secs(21), 2.0), "within the burst as before");
}
#[test]
fn large_downloads_keep_an_address_to_its_rate() {
let mut a = Abuse::new(AbuseConfig { open: false, connects_per_minute: 1, publishes_per_hour: 1, messages_per_second: 1 });
let t = Instant::now();
let me = ip("198.51.100.1");
let big = 40 * MIB;
// Asking for 40 MiB every second for ten minutes.
let mut got = Vec::new();
for s in 0..=600 {
if a.allow(me, Action::Download(big), t + secs(s)) {
got.push(s);
}
}
let bytes = |within: u64| (got.iter().filter(|s| **s < within).count() * big) as f64;
for minutes in 1..=10 {
let most = big as f64 + DOWNLOAD_RATE * (minutes * 60) as f64;
assert!(bytes(minutes * 60) <= most, "{} MiB in {minutes} min", bytes(minutes * 60) / MIB as f64);
}
assert!(bytes(601) >= DOWNLOAD_RATE * 600.0, "it is served at the rate, not refused: {got:?}");
assert!(big as f64 > DOWNLOAD_BURST);
}
#[test]
fn a_library_larger_than_the_burst_is_downloaded_then_the_address_waits() {
let dir = TempDir::new("biglib");
let mut store = Store::open(dir.path(), config(dir.path()).store()).unwrap();
let (file, ..) = store.publish("Library", &snapshot("Library")).unwrap();
store.publish_library(&file.id, &archive(0, 40 * MIB)).unwrap();
let fetch = |s: &mut Store, who: &str| s.library(ip(who), &file.id, Access::View, LibraryReq::Fetch { have: None, usage: None });
assert!(matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Library { archive, .. } if archive.len() > 40 * MIB));
assert!(matches!(fetch(&mut store, "198.51.100.1"), LibraryReply::Failed { reason } if reason == BUSY_DOWNLOADS), "then it waits");
assert!(matches!(fetch(&mut store, "198.51.100.9"), LibraryReply::Library { .. }), "another address has its own share");
}
+1 -1
View File
@@ -33,7 +33,7 @@ fn open(dir: &std::path::Path) -> Config {
/// A team library archive as the app makes it; with `bomb` bytes of spaces as its snapshot
/// (deflated: a few KiB) and `padding` incompressible bytes stored beside it.
fn archive(bomb: usize, padding: usize) -> Vec<u8> {
pub(crate) fn archive(bomb: usize, padding: usize) -> Vec<u8> {
let mut s = Session::new();
let f = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 50, "height": 20, "name": "Button"})).unwrap()["id"].as_u64().unwrap();
s.execute("component.create", &json!({"ids": [f]})).unwrap();
+2
View File
@@ -99,6 +99,8 @@ mod comment_link_tests;
#[cfg(test)]
mod directory_tests;
#[cfg(test)]
mod download_burst_tests;
#[cfg(test)]
mod hardening_tests;
#[cfg(test)]
mod idle_io_tests;
+7
View File
@@ -481,6 +481,13 @@ fn open_mode_has_safe_defaults_and_warns_the_operator() {
assert_eq!(set.file_mb(), 8);
assert!(Config { trusted_proxy: Some("localhost".into()), ..set.clone() }.check().is_err());
assert!(Config { terms_url: "javascript:alert(1)".into(), ..set.clone() }.check().is_err());
// Clients keep track of at most `MAX_PEERS` others (#1121).
let people = figcraft_collab::message::MAX_PEERS;
let many = Config { max_people: people + 1, ..set.clone() };
assert!(many.check().is_ok(), "a server configured for more still starts");
assert_eq!(many.people(), people, "with that many");
assert!(many.warnings().iter().any(|w| w.contains("max_people is 4097") && w.contains("4096 are let in")), "{:?}", many.warnings());
assert_eq!(Config { max_people: people, ..set.clone() }.people(), people);
assert!(Config { idle_days: f64::NAN, ..set }.check().is_err());
}
+3 -2
View File
@@ -1,7 +1,7 @@
use super::journal::flush_log;
use super::{
Access, Action, Admin, AdminReply, Admission, BUSY_ADMIN, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr,
LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret,
LibraryReply, LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, hash, now, same_secret,
};
/// Whether `req` changes the server's files (a write, a removal or a join code's stretching):
@@ -105,7 +105,8 @@ impl Backend for Store {
}
fn manage(&mut self, key: &str) -> Result<(), String> {
match same_secret(key, &self.key) {
// Both sides are SHA-256 digests, so the comparison sees equal lengths whatever was sent.
match same_secret(&hash(key), &hash(&self.key)) {
true => Ok(()),
false if self.cfg.hosting.open => {
Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into())
+1 -1
View File
@@ -16,7 +16,7 @@ impl Store {
let path = library_file(&dir, lib.version);
let size = fs::metadata(&path).map_or(0, |m| m.len());
let now = Instant::now();
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) {
if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_spread(now, size as f64) {
return Err(BUSY_DOWNLOADS.into());
}
Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? })
+40
View File
@@ -257,6 +257,46 @@ fn one_server_per_data_directory() {
assert_eq!(again.key(), key, "the key is kept");
}
#[test]
fn operator_key_authentication() {
let key = "operator-key-0123456789abcdef";
for open in [false, true] {
let dir = TempDir::new("operator-key");
fs::write(dir.path().join(crate::store::KEY), format!("{key}\n")).unwrap();
let mut c = cfg(dir.path());
c.hosting.open = open;
let mut store = Store::open(dir.path(), c).unwrap();
let refusal = match open {
true => "wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)",
false => "wrong server key",
};
assert_eq!(store.manage(key), Ok(()));
let same_length: String = key.chars().rev().collect();
for wrong in [same_length.as_str(), "", &key[..key.len() - 1], &format!("{key}x"), &format!("{key}\n")] {
assert_eq!(store.manage(wrong), Err(refusal.to_string()), "{wrong:?} is refused");
}
}
}
#[test]
fn operator_key_rotation_survives_restart() {
let dir = TempDir::new("operator-key-rotation");
let mut store = Store::open(dir.path(), cfg(dir.path())).unwrap();
let old = store.key().to_string();
assert_eq!(store.manage(&old), Ok(()));
let new = store.rotate_key().unwrap();
assert_ne!(new, old);
assert_eq!(store.key(), new);
assert!(store.manage(&old).is_err(), "the old key stops working");
assert_eq!(store.manage(&new), Ok(()));
drop(store);
let mut again = Store::open(dir.path(), cfg(dir.path())).unwrap();
assert_eq!(again.key(), new);
assert!(again.manage(&old).is_err(), "the old key stays refused after a restart");
assert_eq!(again.manage(&new), Ok(()));
assert_eq!(fs::read_to_string(dir.path().join(crate::store::KEY)).unwrap(), format!("{new}\n"), "the key file keeps the raw key");
}
#[cfg(unix)]
#[test]
fn an_existing_data_directory_is_made_private() {
+1 -1
View File
@@ -137,7 +137,7 @@ impl eframe::App for App {
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FIGCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(result) = craft_update::self_update::finish(&figcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
+3
View File
@@ -98,3 +98,6 @@ display_name = "Filip Strajnar"
[people.Carbocode]
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
+61
View File
@@ -0,0 +1,61 @@
//! A hostile server can't grow a guest's memory without bound (#1121): the presence waiting for
//! the UI and the people a connection has seen hold at most [`MAX_PEERS`] others; past it,
//! someone new is left out, and someone leaving (or the UI taking the presence) makes room.
use std::sync::{Arc, Mutex};
use figcraft_collab::message::{Peer, Presence, ServerMsg};
use figcraft_engine::live::{Event, MAX_PEERS};
use crate::guest::GuestConfig;
use crate::inbox::Inbox;
use crate::invite::Invite;
use crate::join::{Seen, take};
fn inbox() -> Inbox {
Inbox::new(Arc::new(|| {}))
}
#[test]
fn presence_from_more_people_than_the_cap_is_left_out() {
let inbox = inbox();
let over = MAX_PEERS as u32 + 500;
for id in 0..over {
inbox.presence(id, Presence::default());
}
// Someone already waiting is still updated.
let moved = Presence { selection: Some(Vec::new()), ..Presence::default() };
inbox.presence(3, moved.clone());
let out = inbox.drain();
assert_eq!(out.len(), MAX_PEERS);
assert!(out.iter().all(|e| matches!(e, Event::Presence(id, _) if (*id as usize) < MAX_PEERS)));
assert!(out.iter().any(|e| matches!(e, Event::Presence(3, p) if *p == moved)));
// Taken by the UI: room again.
inbox.presence(over, Presence::default());
assert!(matches!(inbox.drain().as_slice(), [Event::Presence(id, _)] if *id == over));
}
#[test]
fn joins_past_the_cap_are_left_out_until_someone_leaves() {
let inbox = inbox();
let cfg = GuestConfig::new(Invite::new("127.0.0.1:47900", "0123456789abcdef", "AbCdEfGhIjKlMnOpQrStUv"), "Ada".into());
let resume = Mutex::new(None);
let mut seen = Seen::default();
let mut send = |m: ServerMsg| {
take(m, &cfg, &inbox, &mut seen, &resume, &|| {});
};
let joined = |id: u32| ServerMsg::PeerJoined(Peer { client_id: id, name: format!("P{id}"), color: String::new() });
let over = MAX_PEERS as u32 + 500;
for id in 0..over {
send(joined(id));
}
let count = |events: &[Event]| events.iter().filter(|e| matches!(e, Event::PeerJoined(_))).count();
assert_eq!(count(&inbox.drain()), MAX_PEERS, "joins past the cap reach the UI");
// Someone already here may be renamed; someone left out stays out until someone leaves.
send(joined(5));
send(joined(over));
assert!(matches!(inbox.drain().as_slice(), [Event::PeerJoined(p)] if p.client_id == 5));
send(ServerMsg::PeerLeft { client_id: 7 });
send(joined(over));
assert!(matches!(inbox.drain().as_slice(), [Event::PeerLeft(7), Event::PeerJoined(p)] if p.client_id == over));
}
+14 -2
View File
@@ -5,7 +5,7 @@
use std::collections::{BTreeMap, VecDeque};
use std::sync::{Arc, Mutex, PoisonError};
use figcraft_engine::live::{Event, Presence};
use figcraft_engine::live::{Event, MAX_PEERS, Presence};
/// Wakes the UI (the desktop app passes egui's `request_repaint`).
pub type Waker = Arc<dyn Fn() + Send + Sync>;
@@ -23,7 +23,10 @@ struct State {
syncs: VecDeque<(u64, Event)>,
/// Every other event, capped at [`MAX_EVENTS`].
others: VecDeque<(u64, Event)>,
/// The latest presence of each peer, for at most [`MAX_PEERS`] peers.
presence: BTreeMap<u32, Presence>,
/// A presence was left out since the last drain (warned once).
presence_full: bool,
}
/// One session's mailbox (a new session gets a new one, so a stopped session's threads can't
@@ -64,9 +67,17 @@ impl Inbox {
});
}
/// Peer `id`'s latest presence (replaces one not taken yet).
/// Peer `id`'s latest presence (replaces one not taken yet). Past [`MAX_PEERS`] peers waiting,
/// another peer's is left out.
pub fn presence(&self, id: u32, p: Presence) {
self.with(|s| {
if s.presence.len() >= MAX_PEERS && !s.presence.contains_key(&id) {
if !s.presence_full {
log::warn!("live session: presence of more than {MAX_PEERS} people; leaving the rest out");
s.presence_full = true;
}
return;
}
s.presence.insert(id, p);
});
}
@@ -75,6 +86,7 @@ impl Inbox {
/// peer.
pub fn drain(&self) -> Vec<figcraft_engine::live::Event> {
let mut s = self.state.lock().unwrap_or_else(PoisonError::into_inner);
s.presence_full = false;
let mut syncs = std::mem::take(&mut s.syncs).into_iter().peekable();
let mut others = std::mem::take(&mut s.others).into_iter().peekable();
let mut out = Vec::with_capacity(syncs.len() + others.len() + s.presence.len());
+12 -3
View File
@@ -7,7 +7,7 @@ use std::time::Duration;
use figcraft_collab::Client;
use figcraft_collab::message::{ClientMsg, Peer, Resume, ServerMsg};
use figcraft_engine::live::{Event, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN};
use figcraft_engine::live::{Event, MAX_PEERS, Role, SessionInfo, Status, TOO_LARGE_TO_JOIN};
use crate::inbox::Inbox;
use crate::invite::Invite;
@@ -194,7 +194,7 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
*r = Some(Resume { session, client_id, seq, secret });
}
// The sync client opens the document (and fills in its name and pages).
let peers = peers.clone();
let peers: Vec<Peer> = peers.iter().take(MAX_PEERS).cloned().collect();
let inv = &cfg.invite;
let info = SessionInfo {
role: Role::Guest,
@@ -224,7 +224,16 @@ pub(crate) fn take(m: ServerMsg, cfg: &GuestConfig, inbox: &Inbox, seen: &mut Se
inbox.push(Event::Welcome { info: info.clone(), peers: seen.peers.clone() });
}
_ => {
seen.peers.retain(|q| q.client_id != p.client_id);
match seen.peers.iter().position(|q| q.client_id == p.client_id) {
Some(i) => {
seen.peers.remove(i);
}
None if seen.peers.len() >= MAX_PEERS => {
log::warn!("live session: {MAX_PEERS} people already here; leaving out another");
return Taken::Continue;
}
None => {}
}
seen.peers.push(p.clone());
inbox.push(Event::PeerJoined(p));
}
+2
View File
@@ -55,6 +55,8 @@ pub use link::{DEFAULT_PORT, NetLink, lan_ip};
#[cfg(target_arch = "wasm32")]
pub use web::NetLink;
#[cfg(test)]
mod client_cap_tests;
#[cfg(test)]
mod code_tests;
#[cfg(test)]
+15
View File
@@ -298,6 +298,21 @@ impl Bucket {
true
}
/// Like [`take_n`](Self::take_n), but more than the burst may be taken from a full bucket,
/// which it leaves in debt: nothing more until the rate has paid it back. Something larger
/// than the burst (a big download) is then never refused for good, and the average stays at
/// `rate`.
pub fn take_spread(&mut self, now: Instant, n: f64) -> bool {
if n <= self.burst {
return self.take_n(now, n);
}
if !self.take_n(now, self.burst) {
return false;
}
self.tokens -= n - self.burst;
true
}
/// Give back `n` tokens (up to the burst).
pub fn give(&mut self, n: f64) {
self.tokens = (self.tokens + n).min(self.burst);
+50
View File
@@ -0,0 +1,50 @@
//! A session remembers at most [`MAX_DELETED`] deleted node ids for undo (#1121): a long session
//! deleting layer after layer, or a saved state listing more, keeps the highest ids and forgets the
//! lowest.
use std::collections::BTreeSet;
use figcraft_doc::{Document, Node, NodeId, NodeKind};
use crate::message::ClientMsg;
use crate::server::MAX_DELETED;
use crate::{Client, Limits, Outgoing, Server};
fn filled() -> BTreeSet<NodeId> {
(1..=MAX_DELETED as u64 + 10).map(NodeId).collect()
}
#[test]
fn a_restored_state_keeps_the_highest_deleted_ids() {
let mut d = Document::new("Shared");
d.repair();
let mut state = Server::new(d, "tok", Limits::default()).state().unwrap();
state.deleted = filled();
let kept = Server::restore(state, "tok", Limits::default()).unwrap().state().unwrap().deleted;
assert_eq!(kept.len(), MAX_DELETED);
assert_eq!(kept.first(), Some(&NodeId(11)), "the lowest went first");
}
#[test]
fn deleting_past_the_cap_forgets_the_lowest() {
let mut d = Document::new("Shared");
let page = d.first_page().unwrap();
// From an earlier session's id space, above every id in `filled`.
let frame = NodeId((7 << 32) | 1);
d.insert(Node::new(frame, NodeKind::Frame, "Frame", 100.0, 100.0), page, None).unwrap();
let mut state = Server::new(d, "tok", Limits::default()).state().unwrap();
state.deleted = filled();
let mut s = Server::restore(state, "tok", Limits::default()).unwrap();
let ClientMsg::Hello(hello) = Client::hello("f", "tok", "Ada", "#FF8800") else { panic!("not a hello") };
let (id, out) = s.join(hello).unwrap();
let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!("no welcome") };
let mut c = Client::join(welcome).unwrap();
let mut edited = c.document().clone();
edited.remove(frame).unwrap();
let msg = c.edit(&edited).unwrap().expect("a change");
s.receive(id, msg);
let deleted = s.state().unwrap().deleted;
assert_eq!(deleted.len(), MAX_DELETED);
assert!(deleted.contains(&frame), "the newly deleted frame is remembered");
assert!(!deleted.contains(&NodeId(11)));
}
+2
View File
@@ -133,6 +133,8 @@ mod codec_tests;
#[cfg(test)]
mod comment_access_tests;
#[cfg(test)]
mod deleted_cap_tests;
#[cfg(test)]
mod diff_tests;
#[cfg(test)]
mod entry_ids_tests;
+5
View File
@@ -375,6 +375,11 @@ pub enum Admin {
/// The most files one [`Admin::Owned`] asks about.
pub const MAX_OWNED: usize = 200;
/// Most other people a client keeps track of in one session (their names, presence and chat).
/// A server holds far fewer (256 by default, a larger `max_people` lowered to this): past it, someone new is
/// left out, so a hostile server can't grow a client's memory without bound.
pub const MAX_PEERS: usize = 4_096;
/// A file its owner asked about ([`Admin::Owned`]).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct OwnedFile {
+15 -1
View File
@@ -64,7 +64,7 @@ pub struct Server {
presence: BTreeMap<u32, Presence>,
acked: BTreeMap<u32, u64>,
/// Deleted node ids: only these may be created again (by undo); new ids must come from the
/// creating client's id space.
/// creating client's id space. At most [`MAX_DELETED`].
deleted: BTreeSet<NodeId>,
/// Removed document-level entries (styles, variables, collections, comments): these may be
/// put back by anyone (undo); a new one must not have an id from another client's space.
@@ -98,6 +98,11 @@ pub struct Server {
/// first; a forgotten client joins as new).
const MAX_REMEMBERED: usize = 65_536;
/// The most deleted node ids a session remembers for undo. Past it the lowest ids go first (the
/// earliest clients' first layers): undoing the delete of one of those is then refused, unless the
/// layer is in the undoing client's own id space, which it may always create.
pub(crate) const MAX_DELETED: usize = 1 << 18;
/// What a session server needs to carry on after a restart: the document, the sequence number,
/// the client-id counter (so ids are never handed out twice), the clients that can reconnect,
/// the deleted ids and removed entries that may be created again and the log of recent batches.
@@ -178,6 +183,7 @@ impl Server {
server.next_client = server.next_client.max(saved);
server.issued = state.issued.into_iter().filter(|(id, _)| *id < server.next_client).collect();
server.deleted = state.deleted;
server.forget_deleted();
server.removed = state.removed;
let contiguous = state.log.windows(2).all(|w| matches!(w, [a, b] if a.seq + 1 == b.seq));
// History saved before the image checks may hold image entries they refuse. The snapshot
@@ -251,6 +257,13 @@ impl Server {
}
}
/// Beyond [`MAX_DELETED`] deleted ids, forget the lowest.
fn forget_deleted(&mut self) {
while self.deleted.len() > MAX_DELETED {
self.deleted.pop_first();
}
}
/// Beyond [`MAX_REMEMBERED`] client ids, forget the oldest one that isn't connected.
fn forget_oldest(&mut self) {
if self.issued.len() > MAX_REMEMBERED
@@ -522,6 +535,7 @@ impl Server {
}
}
self.deleted.extend(applied.deleted);
self.forget_deleted();
for (entry, now) in entries {
if now {
self.removed.remove(&entry);
+67 -6
View File
@@ -826,12 +826,38 @@ impl Node {
self.strokes.iter().any(|p| p.visible) && self.stroke.weight > 0.0
}
/// Maximum distance (in local units) painting can reach outside the node box.
/// Maximum distance painting can reach outside the node box: strokes and glyphs (local units)
/// and effects (page units, see [`Node::effect_paint_reach`]), the same when the node's
/// transform doesn't scale.
pub fn paint_reach(&self) -> f64 {
self.outline_reach() + self.effects_reach() + self.spread_miter_reach()
self.outline_reach() + self.effect_paint_reach()
}
/// How far (local units) this node's effects paint past what they apply to: the layer effects
/// How far (page units: effects are drawn at the same size however a layer is scaled) this
/// node's effects paint outside its box, drop shadows' spread at sharp corners included.
pub fn effect_paint_reach(&self) -> f64 {
self.effects_reach() + effect_reach(self.spread_miter_reach())
}
/// Each visible drop shadow with a spread, as (local units, page units): a filled outline is
/// grown by the spread (sharp corners' mitre included) before it is transformed, so that part
/// follows the node's scale, and the shadow then reaches as far again as its offset and blur,
/// from the layer its layer effects moved. Layers that aren't one outline spread in page units
/// instead, as counted in [`Node::effect_paint_reach`].
pub fn outline_spreads(&self) -> impl Iterator<Item = (f64, f64)> + '_ {
let corners = if self.grows_by_exactly_its_spread() { 1.0 } else { SPREAD_MITER_LIMIT };
let layer = self.layer_effects_reach();
let outlined = self.may_spread_as_an_outline();
self.effects.iter().filter(|e| e.visible).filter_map(move |e| match &e.kind {
EffectKind::DropShadow(s) if outlined && s.spread.is_finite() && s.spread > 0.0 => {
let page = s.radius.abs() * 1.5 + s.offset.0.abs().max(s.offset.1.abs());
Some((effect_reach(s.spread) * corners, layer + effect_reach(page)))
}
_ => None,
})
}
/// How far (page units) this node's effects paint past what they apply to: the layer effects
/// together (see [`Node::layer_effects_reach`]), then the largest of the others.
pub fn effects_reach(&self) -> f64 {
let fx = self.effects.iter().filter(|e| !e.moves_the_layer()).map(|e| effect_reach(e.reach())).fold(0.0, f64::max);
@@ -853,7 +879,30 @@ impl Node {
/// grown by its strokes and glyphs, then by its effects (see [`Node::grow_by_effects`]).
pub fn own_paint_bounds(&self, world: Affine) -> figcraft_geom::Rect {
let o = self.outline_reach();
self.grow_by_effects(figcraft_geom::transform_rect_bbox(world, self.local_rect().inflate(o, o)))
let base = self.local_rect().inflate(o, o);
let grown = self.grow_by_effects(figcraft_geom::transform_rect_bbox(world, base));
// A filled outline's spread grows in local units (see [`Node::outline_spreads`]): a box by
// its spread all round, sharp corners' mitres anywhere within a circle of it.
let [a, b, c, d, _, _] = world.as_coeffs();
let (exact, layer, outlined) = (self.grows_by_exactly_its_spread(), self.layer_effects_reach(), self.may_spread_as_an_outline());
self.effects.iter().filter(|e| e.visible).fold(grown, |r, e| match &e.kind {
EffectKind::DropShadow(s) if outlined && s.spread.is_finite() && s.spread > 0.0 => {
let spread = effect_reach(s.spread);
let outline = if exact {
figcraft_geom::transform_rect_bbox(world, base.inflate(spread, spread))
} else {
let m = spread * SPREAD_MITER_LIMIT;
figcraft_geom::transform_rect_bbox(world, base).inflate(m * a.hypot(c), m * b.hypot(d))
};
let (g, (dx, dy)) = (layer + s.radius.abs() * 1.5, s.offset);
if g.is_finite() && dx.is_finite() && dy.is_finite() && outline.is_finite() {
r.union(outline.inflate(g, g) + figcraft_geom::kurbo::Vec2::new(dx, dy))
} else {
r
}
}
_ => r,
})
}
/// Page-space area `b` grown by what this node's visible effects paint around it: blurs all
@@ -880,7 +929,7 @@ impl Node {
}
/// How far (local units) strokes and glyphs reach outside the box.
fn outline_reach(&self) -> f64 {
pub fn outline_reach(&self) -> f64 {
let stroke = if self.has_visible_stroke() {
let w = self.stroke.sides.map(|s| s.iter().copied().fold(0.0, f64::max)).unwrap_or_else(|| self.stroke.max_weight());
let base = match self.stroke.align {
@@ -904,11 +953,23 @@ impl Node {
stroke + glyphs
}
/// Whether a drop shadow's spread can grow this node's outline (see [`Node::outline_spreads`]):
/// not a group's or stroked text's, which the renderer spreads as pixels, in page units. Others
/// can still turn out to be drawn that way (children, images with alpha): counted both ways.
fn may_spread_as_an_outline(&self) -> bool {
!(matches!(self.kind, NodeKind::Group) || (matches!(self.kind, NodeKind::Text(_)) && self.has_visible_stroke()))
}
/// Whether a spread grows this node's outline by exactly the spread (no sharp corners).
fn grows_by_exactly_its_spread(&self) -> bool {
matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like()
}
/// How much further than its spread a drop shadow can reach at the outline's sharp corners:
/// spread moves the edges out in parallel and they meet in a mitre (see
/// [`SPREAD_MITER_LIMIT`]). Rectangles, frames and ellipses grow by exactly the spread.
fn spread_miter_reach(&self) -> f64 {
if matches!(self.kind, NodeKind::Rectangle | NodeKind::Ellipse { .. } | NodeKind::Section { .. }) || self.kind.is_frame_like() {
if self.grows_by_exactly_its_spread() {
return 0.0;
}
self.effects
+3 -3
View File
@@ -6,8 +6,8 @@ allow-expect-in-tests = true
allow-panic-in-tests = true
too-many-arguments-threshold = 10
disallowed-methods = [
{ path = "std::fs::read", reason = "use file_access::read (automation roots)" },
{ path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" },
{ path = "std::fs::read", reason = "use file_access::read_capped (automation roots, size caps)" },
{ path = "std::fs::read_to_string", reason = "use file_access::read_capped (automation roots, size caps)" },
{ path = "std::fs::write", reason = "use file_access::write (automation roots)" },
{ path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" },
{ path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" },
@@ -35,5 +35,5 @@ disallowed-methods = [
{ path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" },
{ path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" },
{ path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" },
{ path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" },
{ path = "figcraft_format::load_file", reason = "use file_access::read_capped (automation roots, size caps)" },
]
+30 -1
View File
@@ -26,6 +26,15 @@ pub const NOT_FETCHED: &str = "the library hasn't been fetched from its server y
pub const NO_LIBRARY: &str = "that file has no library on the server yet: publish it there first (Libraries ▸ Publish to server)";
/// Most components one usage report lists.
pub const MAX_USAGE: usize = 10_000;
/// Most team libraries remembered as yours ([`Prefs::team_libraries`](crate::Prefs)): adding or
/// checking another forgets the one used longest ago.
pub const MAX_TEAM_LIBRARIES: usize = 500;
/// How [`Prefs::team_libraries`](crate::Prefs) keeps a link: its SHA-256, so the preferences hold
/// no token.
fn link_key(link: &str) -> String {
figcraft_format::image_hash(link.trim().as_bytes())
}
/// Whether a library's path is a share link of a file on a server (else it is a library file).
pub fn is_server_link(path: &str) -> bool {
@@ -156,15 +165,35 @@ impl Session {
self.take_library(path, reply).map_err(EngineError::Other)
}
/// Whether team library `link` is one you added or checked here ([`Prefs::team_libraries`](crate::Prefs)):
/// only those are checked for updates by themselves.
pub fn library_allowed(&self, link: &str) -> bool {
self.prefs.team_libraries.contains(&link_key(link))
}
/// Remember team library `link` as one you use (the app saves the preferences at once).
pub(crate) fn allow_library(&mut self, link: &str) {
let key = link_key(link);
let list = &mut self.prefs.team_libraries;
if list.first() != Some(&key) {
list.retain(|k| *k != key);
list.insert(0, key);
list.truncate(MAX_TEAM_LIBRARIES);
self.live.unsaved = true;
}
}
/// Ask the servers of the active document's team libraries for newer versions, in the
/// background, reporting how the document uses each (the Assets panel calls it now and then).
/// Only libraries you added or checked here are asked: opening a file that names another
/// server contacts nothing until you check it (`library.check`, #1097).
pub fn refresh_server_libraries(&mut self) {
let Ok(doc) = self.doc() else { return };
let jobs: Vec<(String, LibraryReq)> = doc
.libraries
.enabled
.iter()
.filter(|l| is_server_link(&l.path))
.filter(|l| is_server_link(&l.path) && self.library_allowed(&l.path))
.map(|l| {
let have = match self.libraries.cached(&l.path) {
Some(Ok(lib)) => Some(lib.manifest.version),
+29 -8
View File
@@ -236,21 +236,42 @@ fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ {
move |e| EngineError::Other(format!("{}: {e}", path.display()))
}
/// The bytes of file `path`.
pub fn read(path: impl AsRef<Path>) -> Result<Vec<u8>> {
let path = path.as_ref();
std::fs::read(allow(path, Access::Read)?).map_err(io(path))
/// `bytes` for a person: "300 bytes", "12 MB", "6.5 GB".
fn size(bytes: u64) -> String {
match bytes {
b if b < 1 << 20 => format!("{b} bytes"),
b if b < 1 << 30 => format!("{} MB", b.div_ceil(1 << 20)),
b => format!("{:.1} GB", b as f64 / f64::from(1u32 << 30)),
}
}
/// The bytes of file `path`, refused when it isn't a file or is larger than `max`.
/// The bytes of file `path`, at most `max` of them. A folder, a device, a pipe or a file larger
/// than `max` is refused before anything is read, so a path a caller gives can't fill memory or
/// wait forever (#1114). Every whole-file read of a path goes through here.
pub fn read_capped(path: impl AsRef<Path>, max: u64) -> Result<Vec<u8>> {
use std::io::Read as _;
let path = path.as_ref();
let p = allow(path, Access::Read)?;
let refuse = |why: String| EngineError::Other(format!("{}: {why}", path.display()));
// Looked at before opening: opening a pipe waits for whoever writes it.
let meta = std::fs::metadata(&p).map_err(io(path))?;
if !meta.is_file() || meta.len() > max {
return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display())));
if meta.is_dir() {
return Err(refuse("is a folder, not a file".into()));
}
std::fs::read(p).map_err(io(path))
if !meta.is_file() {
return Err(refuse("isn't a regular file (a device or a pipe?), so it isn't read".into()));
}
if meta.len() > max {
return Err(refuse(format!("is {}, more than the {} allowed here", size(meta.len()), size(max))));
}
let f = std::fs::File::open(p).map_err(io(path))?;
let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0));
f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(io(path))?;
// It grew after it was looked at.
if bytes.len() as u64 > max {
return Err(refuse(format!("grew past the {} allowed here while it was read", size(max))));
}
Ok(bytes)
}
/// File `path` opened for reading.
@@ -198,10 +198,10 @@ fn a_scope_confines_the_thread_and_ends_with_it() {
assert!(current().is_none());
confined(Some(&roots), || {
assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err());
assert!(super::read(f.p("outside/secret.txt")).is_err());
assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in");
assert!(super::read_capped(f.p("outside/secret.txt"), 64).is_err());
assert_eq!(super::read_capped(f.p("root/in.txt"), 64).unwrap(), b"in");
// Not confined: the app's own files.
assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret");
assert_eq!(super::app_owned(|| super::read_capped(f.p("outside/secret.txt"), 64).unwrap()), b"secret");
assert!(current().is_some(), "back in the scope after app_owned");
// Another thread isn't confined by this one's scope.
let p = f.p("outside/secret.txt");
@@ -386,3 +386,8 @@ fn only_resolved_paths_count_as_the_apps_own_folder() {
assert!(!super::within_app_folder(&install.join("missing.wasm"), &install));
assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder")));
}
#[test]
fn sizes_read_as_a_person_says_them() {
assert_eq!((super::size(300), super::size((12 << 20) - 5), super::size(13 << 29)), ("300 bytes".into(), "12 MB".into(), "6.5 GB".into()));
}
@@ -0,0 +1,108 @@
//! A batch keeps one undo snapshot, not one per step (#1099): it is still one undo step that
//! restores exactly what was there before, and a failure still rolls all of it back.
use serde_json::{Value, json};
use crate::{NodeId, Session};
fn id(value: &Value) -> NodeId {
crate::params::id_of(&value["id"]).unwrap()
}
fn rectangles(s: &mut Session, n: usize) -> Vec<NodeId> {
(0..n).map(|i| id(&s.execute("shape.rectangle", &json!({"x": i * 20, "y": 0, "width": 10, "height": 10})).unwrap())).collect()
}
fn rename(target: NodeId, name: &str) -> Value {
json!({"command": "node.set", "params": {"id": target.0, "props": {"name": name}}})
}
fn undo_len(s: &Session) -> usize {
s.ds().unwrap().history.undo.len()
}
#[test]
fn a_thousand_step_batch_keeps_one_snapshot_and_undoes_as_one_step() {
let mut s = Session::new();
let ids = rectangles(&mut s, 50);
let (before, base) = (s.doc().unwrap().clone(), undo_len(&s));
let mut most = 0;
s.grouped("Many", |s| {
for i in 0..1000 {
let target = ids[i % ids.len()];
s.execute("node.set", &json!({"id": target.0, "props": {"name": format!("Step {i}")}}))?;
most = most.max(undo_len(s));
}
Ok(())
})
.unwrap();
assert_eq!(most, base + 1, "one snapshot held for the whole group, not one per step");
assert_eq!(undo_len(&s), base + 1);
assert_eq!(s.undo_labels(1), vec!["Many".to_string()]);
let after = s.doc().unwrap().clone();
assert!(s.undo().unwrap());
assert_eq!(s.doc().unwrap(), &before, "undo puts back exactly what was there");
assert!(s.redo().unwrap());
assert_eq!(s.doc().unwrap(), &after);
}
#[test]
fn command_batch_and_its_continue_mode_are_one_step_each() {
for mode in ["abort", "continue"] {
let mut s = Session::new();
let ids = rectangles(&mut s, 10);
let (before, base) = (s.doc().unwrap().clone(), undo_len(&s));
let mut steps: Vec<Value> = (0..1000).map(|i| rename(ids[i % ids.len()], &format!("{mode} {i}"))).collect();
if mode == "continue" {
steps.insert(500, json!({"command": "node.set", "params": {"id": 999_999, "props": {"name": "missing"}}}));
}
s.execute("command.batch", &json!({"onError": mode, "commands": steps})).unwrap();
assert_eq!(undo_len(&s), base + 1, "{mode}");
assert_eq!(s.doc().unwrap().node(ids[9]).unwrap().name, format!("{mode} 999"));
assert!(s.undo().unwrap());
assert_eq!(s.doc().unwrap(), &before, "{mode}");
}
}
#[test]
fn a_failing_step_after_hundreds_rolls_back_everything() {
let mut s = Session::new();
let ids = rectangles(&mut s, 10);
let (before, labels) = (s.doc().unwrap().clone(), s.undo_labels(100));
let mut steps: Vec<Value> = (0..500).map(|i| rename(ids[i % ids.len()], &format!("R {i}"))).collect();
steps.push(json!({"command": "shape.rectangle", "params": {"x": 0, "y": 0, "width": 5, "height": 5}}));
steps.push(json!({"command": "no.such.command"}));
assert!(s.execute("command.batch", &json!({"commands": steps})).is_err());
assert_eq!(s.doc().unwrap(), &before);
assert_eq!(s.undo_labels(100), labels);
assert_eq!(s.undo_depth().1, 0);
}
#[test]
fn undo_inside_a_batch_undoes_the_batch_so_far() {
let make = |x: i32| json!({"command": "shape.rectangle", "params": {"x": x, "y": 40, "width": 10, "height": 10}});
let start = || {
let mut s = Session::new();
let first = rectangles(&mut s, 1)[0];
(s, first)
};
// An edit and its undo: nothing changes.
let (mut s, _) = start();
let before = s.doc().unwrap().clone();
s.execute("command.batch", &json!({"commands": [make(0), {"command": "edit.undo"}]})).unwrap();
assert_eq!(s.doc().unwrap(), &before);
// The batch's edits so far go back together; the next undo is the step before the batch.
let (mut s, first) = start();
s.execute("command.batch", &json!({"commands": [make(0), make(20), {"command": "edit.undo"}, {"command": "edit.undo"}]})).unwrap();
assert!(!s.doc().unwrap().contains(first));
assert!(s.undo().unwrap());
assert!(s.doc().unwrap().contains(first));
// Undo then redo inside: the edits come back and stay one step.
let (mut s, _) = start();
let r = s.execute("command.batch", &json!({"commands": [make(0), {"command": "edit.undo"}, {"command": "edit.redo"}, make(20)]})).unwrap();
assert_eq!(r[2]["redone"], true);
let count = |s: &Session| s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len();
assert_eq!(count(&s), 3);
assert!(s.undo().unwrap());
assert_eq!(count(&s), 1);
}
+2 -1
View File
@@ -410,9 +410,10 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> {
})
}
/// An image file to place: no larger than a document takes an image from outside (#1114).
#[cfg(not(target_arch = "wasm32"))]
fn read_path(path: &str) -> Result<Vec<u8>> {
crate::file_access::read(path)
crate::file_access::read_capped(path, figcraft_doc::ImageData::MAX_BYTES)
}
#[cfg(target_arch = "wasm32")]
+16 -8
View File
@@ -30,9 +30,9 @@ pub use export::{
};
use export::{export, export_frames_pdf, export_layers, pdf_bytes};
use load::font_report;
pub use load::{MAX_DOCUMENT_FILE, load, load_bytes, load_path};
#[cfg(test)]
pub(crate) use load::{installed_family, sketch_line};
pub use load::{load, load_bytes, load_path};
pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variables_param, write};
pub use open::MAX_RECENT;
#[cfg(test)]
@@ -326,16 +326,24 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"doc.list",
"List documents",
"{} → [{index, id, name, path, dirty, active}]: `id` names the file in a control or MCP call's `document`",
"{} → [{index, id, name, path?, dirty, active}]: `id` names the file in a control or MCP call's `document`; `path` only for the user's own scripts, not AI agents or plugins",
false,
|s, _| {
// Where the files are on disk is the user's: an agent gets their names (#1117).
let paths = s.agent.is_none();
Ok(Value::Array(
s.docs
.iter()
.enumerate()
.map(|(i, d)| json!({"index": i, "id": d.uid, "name": d.doc.name, "path": d.path, "dirty": d.is_dirty(), "active": i == s.active}))
.collect(),
))
s.docs
.iter()
.enumerate()
.map(|(i, d)| {
let mut e = json!({"index": i, "id": d.uid, "name": d.doc.name, "dirty": d.is_dirty(), "active": i == s.active});
if paths {
e["path"] = json!(d.path);
}
e
})
.collect(),
))
}
),
spec!("doc.home", "Home", "{}", false, |s, _| {
+6 -1
View File
@@ -14,9 +14,14 @@ pub(crate) fn recovered_from(bytes: &[u8]) -> Result<DocState> {
Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved())
}
/// The largest file opened or imported as a document. A `.figcraft` file keeps its images as
/// they are, so a real one can be far larger than it unpacks to (#1313's took 7.4 GB); twice that
/// opens, while a device, a pipe or an absurd file is refused before it is read (#1114).
pub const MAX_DOCUMENT_FILE: u64 = 16 << 30;
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn read(path: &str) -> Result<Vec<u8>> {
crate::file_access::read(path)
crate::file_access::read_capped(path, MAX_DOCUMENT_FILE)
}
#[cfg(target_arch = "wasm32")]
+32
View File
@@ -85,6 +85,13 @@ pub fn specs() -> Vec<CommandSpec> {
false,
updates
),
spec!(
"library.check",
"Check team libraries for updates",
"{library? (an enabled team library: id, name or link; all of this file's when omitted)} asks their servers for the latest version in the background, and from then on checks them by themselves: opening a file doesn't contact the server of a team library you haven't added or checked → {checking}",
false,
check
),
spec!(
"library.acceptUpdates",
"Accept library updates",
@@ -524,6 +531,10 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
let raw = req_string(cmd, p, "path")?;
let path = if is_server_link(&raw) { raw.trim().to_string() } else { absolute_path(raw.trim()) };
let lib = s.load_library(&path)?;
if is_server_link(&path) {
// Added by you: its server may be checked for updates from now on (#1097).
s.allow_library(&path);
}
let m = &lib.manifest;
let doc = s.doc()?;
if doc.published.as_ref().is_some_and(|me| me.manifest.id == m.id) {
@@ -549,6 +560,27 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
Ok(out)
}
/// `library.check`: the user lets this file's team libraries (or one) be checked; they are asked now.
fn check(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "library.check";
let doc = s.doc()?;
let links: Vec<String> = match string(p, "library") {
Some(key) => {
let l = find_link(doc, &key).ok_or_else(|| bad(cmd, format!("no enabled library `{key}` (library.list shows them)")))?;
if !is_server_link(&l.path) {
return Err(bad(cmd, format!("`{key}` is a library file, not a team library on a server")));
}
vec![l.path.clone()]
}
None => doc.libraries.enabled.iter().filter(|l| is_server_link(&l.path)).map(|l| l.path.clone()).collect(),
};
for l in &links {
s.allow_library(l);
}
s.refresh_server_libraries();
Ok(json!({"checking": links.len()}))
}
fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "library.remove";
let key = req_string(cmd, p, "id")?;
+48
View File
@@ -0,0 +1,48 @@
//! `doc.list` tells the user's own scripts where the open files are, but AI agents and plugins
//! get their names only (#1117).
use serde_json::json;
use crate::Session;
use crate::components::tests::ex;
use crate::plugins_tests::{OnRefusal, install, manifest, script};
const SECRET: &str = "/home/someone/private/clients/acme.figcraft";
fn two_files() -> Session {
let mut s = Session::new();
s.docs[0].path = Some(SECRET.into());
ex(&mut s, "file.new", json!({"name": "Front"}));
s
}
#[test]
fn the_users_own_calls_get_the_paths() {
let mut s = two_files();
let list = ex(&mut s, "doc.list", json!({}));
assert_eq!(list[0]["path"], json!(SECRET));
assert_eq!(list[1]["path"], json!(null), "a new file has none yet");
}
#[test]
fn an_agent_gets_names_and_ids_but_no_paths() {
let mut s = two_files();
let list = s.execute_as(Some("Claude"), "doc.list", &json!({})).unwrap();
assert_eq!(list.as_array().unwrap().len(), 2);
assert!(list[0]["id"].is_u64() && list[0]["name"].is_string());
assert!(list.as_array().unwrap().iter().all(|e| e.get("path").is_none()), "{list}");
// An agent with no name of its own is an agent too.
let list = s.execute_as(Some(""), "doc.list", &json!({})).unwrap();
assert!(!list.to_string().contains(SECRET));
}
#[test]
fn a_plugin_gets_names_but_no_paths() {
let mut s = two_files();
// The reply to its last call becomes its output.
let tail = "(call $set_output (i32.const 40000) (call $reply_read (i32.const 40000) (i32.const 20000))) (return (i32.const 0))";
install(&mut s, manifest("org.test.list", "read", &[]), &script(&[("doc.list", json!({}))], OnRefusal::Fail, tail));
let out = ex(&mut s, "plugin.run", json!({"id": "org.test.list"}))["output"].to_string();
assert!(out.contains("Front"), "{out}");
assert!(!out.contains(SECRET) && !out.contains("path"), "{out}");
}
+3
View File
@@ -15,6 +15,9 @@ pub struct History {
pub undo: Vec<HistoryEntry>,
pub redo: Vec<HistoryEntry>,
pub limit: usize,
/// Inside a group, the undo depth just after the step the group pushed: the group's later
/// edits add to that step instead of keeping a snapshot each (#1099).
pub(crate) group_step: Option<usize>,
}
/// An in-progress gesture: the document before it started, so previews re-apply from scratch.
+13 -6
View File
@@ -107,6 +107,8 @@ const DENIED: &[&str] = &[
"plugins.",
"libraries.",
"library.add",
// Contacts the servers a file names, and remembers them as yours (#1097).
"library.check",
// Uploads the open document to a server.
"library.publishToServer",
"edit.undo",
@@ -232,7 +234,16 @@ impl figcraft_plugin::Host for EngineHost<'_> {
}
Ok(v)
});
r.map(|v| v.to_string()).map_err(|e| e.to_string())
r.map(|mut v| {
// A plugin gets the open files' names, not where they are on disk (#1117).
if command == "doc.list" {
for e in v.as_array_mut().into_iter().flatten().filter_map(Value::as_object_mut) {
e.remove("path");
}
}
v.to_string()
})
.map_err(|e| e.to_string())
}
fn selection(&mut self) -> String {
@@ -313,11 +324,7 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I
/// Read a file of at most `max` bytes.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result<Vec<u8>> {
let meta = crate::file_access::metadata(path)?;
if !meta.is_file() || meta.len() > max as u64 {
return Err(EngineError::Other(format!("{}: not a file of at most {} KB", path.display(), max / 1024)));
}
crate::file_access::read(path)
crate::file_access::read_capped(path, max as u64)
}
/// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute
+4 -1
View File
@@ -128,7 +128,10 @@ fn an_explicit_variables_path_is_capped_like_the_sibling_file() {
for cmd in ["file.open", "file.import"] {
let before = state(&s);
let e = s.execute(cmd, &json!({"path": path, "variables": bad})).unwrap_err().to_string();
assert!(e.contains("not a file of at most 268435456 bytes"), "{cmd} {bad}: {e}");
assert!(
["more than the 256 MB allowed here", "is a folder, not a file", "isn't a regular file"].iter().any(|w| e.contains(w)),
"{cmd} {bad}: {e}"
);
assert_eq!(state(&s), before, "{cmd} {bad}");
}
}
+10
View File
@@ -197,6 +197,8 @@ mod batch_rollback_tests;
#[cfg(test)]
mod batch_step_ref_tests;
#[cfg(test)]
mod batch_undo_snapshot_tests;
#[cfg(test)]
mod binding_lists_tests;
#[cfg(test)]
mod board_connector_tests;
@@ -275,6 +277,8 @@ mod devmode_behaviour_tests;
#[cfg(test)]
mod devmode_tail_tests;
#[cfg(test)]
mod doc_list_paths_tests;
#[cfg(test)]
mod doc_urls_tests;
#[cfg(test)]
mod double_click_tests;
@@ -357,6 +361,8 @@ mod jam_open_tests;
#[cfg(test)]
mod keymap_tests;
#[cfg(test)]
mod library_beacon_tests;
#[cfg(test)]
mod library_path_tests;
#[cfg(test)]
mod library_range_styles_tests;
@@ -369,6 +375,8 @@ mod live_access_tests;
#[cfg(test)]
mod live_docs_tests;
#[cfg(test)]
mod live_peer_cap_tests;
#[cfg(test)]
mod live_share_tests;
#[cfg(test)]
mod live_signal_tests;
@@ -411,6 +419,8 @@ mod paint_tests;
#[cfg(test)]
mod panels_behaviour_tests;
#[cfg(test)]
mod path_read_caps_tests;
#[cfg(test)]
mod pdf_print_tests;
#[cfg(test)]
mod pen_end_tests;
+117
View File
@@ -0,0 +1,117 @@
//! Opening a file that names a team library on some server doesn't contact that server (#1097):
//! only team libraries you added or checked are asked for updates by themselves.
use std::sync::{Arc, Mutex};
use figcraft_collab::message::{ClientMsg, LibraryReply, LibraryReq};
use figcraft_doc::{Document, LibraryLink};
use serde_json::json;
use crate::live::{Event, HostOptions, LiveLink, NetRequest, Presence, SessionInfo};
use crate::{Prefs, Session};
const LINK: &str = "ws://tracker.example:9/?file=f&token=per-file-secret";
/// Every request the app would send anywhere.
#[derive(Default)]
struct Wire {
sent: Vec<String>,
}
struct Link(Arc<Mutex<Wire>>);
impl Link {
fn note(&self, what: &str) {
self.0.lock().unwrap().sent.push(what.into());
}
}
impl LiveLink for Link {
fn host(&mut self, _: u64, _: HostOptions) -> Result<SessionInfo, String> {
self.note("host");
Err("no".into())
}
fn join(&mut self, _: u64, _: &str, _: &str) -> Result<(), String> {
self.note("join");
Err("no".into())
}
fn leave(&mut self, _: u64) {}
fn presence(&mut self, _: u64, _: &Presence) {}
fn send(&mut self, _: u64, _: ClientMsg) {
self.note("send");
}
fn poll(&mut self) -> Vec<(u64, Event)> {
Vec::new()
}
fn library(&mut self, link: &str, _: LibraryReq) -> Result<LibraryReply, String> {
self.note(link);
Ok(LibraryReply::Empty)
}
fn library_background(&mut self, jobs: Vec<(String, LibraryReq)>) {
for (link, _) in jobs {
self.note(&link);
}
}
fn background(&mut self, _: u64, _: NetRequest) -> bool {
self.note("background");
false
}
}
/// A session with `prefs` that opened a file (from disk) whose libraries include [`LINK`].
fn opened(prefs: Prefs, tag: &str) -> (Session, Arc<Mutex<Wire>>, std::path::PathBuf) {
let mut d = Document::new("Someone else's file");
d.libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 3 });
let path = std::env::temp_dir().join(format!("figcraft-beacon-{}-{tag}.figcraft", std::process::id()));
std::fs::write(&path, figcraft_format::save_bytes(&d, None).unwrap()).unwrap();
let mut s = Session::new();
s.prefs = prefs;
let wire = Arc::new(Mutex::new(Wire::default()));
s.live.install(Box::new(Link(wire.clone())));
s.execute("file.open", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(s.doc().unwrap().libraries.enabled.len(), 1);
(s, wire, path)
}
fn sent(w: &Arc<Mutex<Wire>>) -> Vec<String> {
w.lock().unwrap().sent.clone()
}
#[test]
fn opening_a_file_naming_an_unknown_team_library_contacts_nothing() {
let (mut s, wire, path) = opened(Prefs::default(), "unknown");
// What the Assets panel does now and then while the file is shown.
for _ in 0..3 {
s.refresh_server_libraries();
}
assert!(sent(&wire).is_empty(), "no connection attempt: {:?}", sent(&wire));
assert!(!s.library_allowed(LINK));
// You check it: now it is asked, and from then on by itself.
assert_eq!(s.execute("library.check", &json!({})).unwrap()["checking"], 1);
assert_eq!(sent(&wire), vec![LINK.to_string()]);
assert!(s.library_allowed(LINK) && s.live.unsaved, "kept with the preferences");
s.refresh_server_libraries();
assert_eq!(sent(&wire).len(), 2);
// Kept as a hash: the preferences hold no token.
assert!(!serde_json::to_string(&s.prefs).unwrap().contains("per-file-secret"));
let _ = std::fs::remove_file(path);
}
#[test]
fn a_team_library_you_checked_before_is_checked_when_a_file_names_it() {
let mut mine = Session::new();
mine.allow_library(LINK);
let (mut s, wire, path) = opened(mine.prefs.clone(), "checked");
s.refresh_server_libraries();
assert_eq!(sent(&wire), vec![LINK.to_string()]);
let _ = std::fs::remove_file(path);
}
#[test]
fn checking_one_team_library_by_name() {
let (mut s, wire, path) = opened(Prefs::default(), "named");
assert!(s.execute("library.check", &json!({"library": "Nope"})).is_err());
assert_eq!(s.execute("library.check", &json!({"library": "Kit"})).unwrap()["checking"], 1);
assert_eq!(sent(&wire), vec![LINK.to_string()]);
let _ = std::fs::remove_file(path);
}
+36
View File
@@ -0,0 +1,36 @@
//! A hostile server can't grow a client's list of people without bound (#1121): past
//! [`MAX_PEERS`], someone new is left out (their presence and chat with them), in a welcome or one
//! join at a time; someone leaving makes room again.
use serde_json::json;
use crate::live::{Event, MAX_PEERS, Peer, Presence, Role};
use crate::live_tests::{info, peer, push, session};
fn many(from: u32, n: usize) -> Vec<Peer> {
(from..).take(n).map(|id| peer(id, "Someone")).collect()
}
#[test]
fn a_flood_of_joins_keeps_at_most_the_cap() {
let (mut s, script) = session();
s.execute("collab.join", &json!({"link": "figcraft://join?host=a:1&file=f&token=t"})).unwrap();
push(&script, vec![Event::Welcome { info: info(Role::Guest, 1), peers: many(2, MAX_PEERS + 100) }]);
s.live_poll();
assert_eq!(s.live().peers.len(), MAX_PEERS, "a welcome listing too many");
let first_left_out = 2 + MAX_PEERS as u32;
let joins = many(first_left_out, 1_000).into_iter().map(Event::PeerJoined).collect();
push(&script, joins);
s.live_poll();
assert_eq!(s.live().peers.len(), MAX_PEERS, "joins past the cap");
// Presence of someone left out isn't kept either.
push(&script, vec![Event::Presence(first_left_out, Presence::default())]);
s.live_poll();
assert!(!s.live().presence.contains_key(&first_left_out));
// Someone already here may still be renamed; someone leaving makes room.
push(&script, vec![Event::PeerJoined(peer(2, "Renamed")), Event::PeerLeft(3), Event::PeerJoined(peer(first_left_out, "Late"))]);
s.live_poll();
assert_eq!(s.live().peers.len(), MAX_PEERS);
assert_eq!(s.live().peers[&2].name, "Renamed");
assert!(s.live().peers.contains_key(&first_left_out) && !s.live().peers.contains_key(&3));
}
+96
View File
@@ -0,0 +1,96 @@
//! Paths callers give are read only when they are regular files within a cap that fits their
//! kind (#1114): a folder, a device or an oversized file is refused with a clear error before
//! anything is read, and the session is left as it was.
use std::path::PathBuf;
use serde_json::{Value, json};
use crate::Session;
use crate::file_access::read_capped;
struct Dir(PathBuf);
impl Dir {
fn new(name: &str) -> Self {
let d = std::env::temp_dir().join(format!("figcraft-caps-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("folder")).unwrap();
Self(d)
}
fn p(&self, rel: &str) -> String {
self.0.join(rel).to_string_lossy().into_owned()
}
/// A sparse file of `len` bytes (nothing is written).
fn sized(&self, rel: &str, len: u64) -> String {
std::fs::File::create(self.0.join(rel)).unwrap().set_len(len).unwrap();
self.p(rel)
}
}
impl Drop for Dir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// `cmd` with `params` fails with a message containing `why`, and changes no document.
fn refused(s: &mut Session, cmd: &str, params: Value, why: &str) {
let before = (format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::<Vec<_>>()), s.active);
let e = s.execute(cmd, &params).unwrap_err().to_string();
assert!(e.contains(why), "{cmd} {params}: {e}");
assert_eq!((format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::<Vec<_>>()), s.active), before, "{cmd} {params}");
}
const FOLDER: &str = "is a folder, not a file";
const DEVICE: &str = "isn't a regular file";
#[test]
fn the_capped_read_refuses_folders_devices_and_oversized_files() {
let d = Dir::new("read");
std::fs::write(d.0.join("four.txt"), b"four").unwrap();
assert_eq!(read_capped(d.p("four.txt"), 4).unwrap(), b"four");
let e = read_capped(d.p("four.txt"), 3).unwrap_err().to_string();
assert!(e.contains("is 4 bytes, more than the 3 bytes allowed here"), "{e}");
assert!(read_capped(d.p("folder"), u64::MAX).unwrap_err().to_string().contains(FOLDER));
if cfg!(unix) {
// An endless source: refused, not read until memory runs out.
assert!(read_capped("/dev/zero", u64::MAX).unwrap_err().to_string().contains(DEVICE));
}
}
#[test]
fn documents_are_refused_as_folders_and_devices() {
let d = Dir::new("docs");
let mut s = Session::new();
for cmd in ["file.open", "file.import"] {
refused(&mut s, cmd, json!({"path": d.p("folder")}), FOLDER);
if cfg!(unix) {
refused(&mut s, cmd, json!({"path": "/dev/zero"}), DEVICE);
}
}
const { assert!(crate::cmd::file::MAX_DOCUMENT_FILE > 7_400_000_000, "a real 7.4 GB file still opens") };
}
#[test]
fn placed_images_are_capped_at_what_a_document_takes() {
let d = Dir::new("images");
let mut s = Session::new();
let big = d.sized("huge.png", figcraft_doc::ImageData::MAX_BYTES + 1);
refused(&mut s, "image.place", json!({"path": big}), "more than the 256 MB allowed here");
refused(&mut s, "image.place", json!({"path": d.p("folder")}), FOLDER);
if cfg!(unix) {
refused(&mut s, "image.place", json!({"path": "/dev/zero"}), DEVICE);
}
}
#[test]
fn fonts_and_plugins_are_refused_as_folders() {
let d = Dir::new("plugins");
let mut s = Session::new();
refused(&mut s, "text.loadFont", json!({"path": d.p("folder")}), FOLDER);
// A plugin folder whose manifest is a folder.
std::fs::create_dir_all(d.0.join("plugin").join("figcraft-plugin.json")).unwrap();
refused(&mut s, "plugin.import", json!({"path": d.p("plugin")}), FOLDER);
assert!(s.prefs.plugins.is_empty());
}
+7
View File
@@ -110,6 +110,12 @@ pub struct Prefs {
/// `widget:<plugin>`.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub recent_actions: Vec<String>,
/// The team libraries you added (`library.add`) or checked (`library.check`), as hashes of
/// their links (which carry tokens), newest first, at most
/// [`MAX_TEAM_LIBRARIES`](crate::library_server::MAX_TEAM_LIBRARIES): only these are checked
/// for updates by themselves. A file naming another one doesn't contact its server (#1097).
#[serde(skip_serializing_if = "Vec::is_empty")]
pub team_libraries: Vec<String>,
}
/// Recently run actions kept for the Actions palette (newest first).
@@ -150,6 +156,7 @@ impl Default for Prefs {
view: BTreeMap::new(),
board_grid: Default::default(),
recent_actions: Vec::new(),
team_libraries: Vec::new(),
}
}
}
+10
View File
@@ -256,8 +256,18 @@ impl Session {
if self.live_doc(uid) {
return self.live_edit(uid, label);
}
let grouped = self.group_depth > 0;
if let (Some(label), Some(ds)) = (label, self.docs.iter_mut().find(|d| d.uid == uid)) {
// A group becomes one step at its end: its edits add to the step it already pushed,
// so a long batch keeps one snapshot, not one per edit (#1099).
if grouped && ds.history.group_step == Some(ds.history.undo.len()) {
ds.history.redo.clear();
return;
}
ds.push_undo(label, before, selection, page);
if grouped {
ds.history.group_step = Some(ds.history.undo.len());
}
}
}
}
+15 -2
View File
@@ -13,6 +13,7 @@ struct GroupDocCheckpoint {
undo: Vec<HistoryEntry>,
redo: Vec<HistoryEntry>,
limit: usize,
group_step: Option<usize>,
}
struct GroupCheckpoint {
@@ -259,7 +260,7 @@ impl Session {
/// Without a label, only roll back failures: leave successful steps' history to the outer group.
/// A checkpoint also works between closing the last tab and opening another one.
fn checkpointed<T>(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result<T>) -> Result<T> {
let before = self.group_checkpoint();
let before = self.group_checkpoint(label.is_some());
let result = f(self);
self.group_depth -= 1;
let at_root = self.group_depth == 0;
@@ -275,6 +276,7 @@ impl Session {
let Some(ds) = self.docs.iter_mut().find(|ds| ds.uid == saved.uid) else { continue };
ds.history.limit = saved.limit;
if label.is_some() {
ds.history.group_step = saved.group_step;
if !Arc::ptr_eq(&saved.doc, &ds.doc) && ds.interaction.is_none() {
changed.push((saved.uid, saved.doc.clone(), saved.selection.clone(), saved.page));
}
@@ -288,13 +290,19 @@ impl Session {
}
}
if at_root {
// Tabs opened inside the group have no checkpoint to restore theirs from.
for ds in &mut self.docs {
ds.history.group_step = None;
}
self.closed_in_group.clear();
self.flush_pending_events();
}
result
}
fn group_checkpoint(&mut self) -> GroupCheckpoint {
/// `own_step`: the group becomes an undo step of its own (it has a label), so its edits don't
/// add to the step of a group around it.
fn group_checkpoint(&mut self, own_step: bool) -> GroupCheckpoint {
let docs = self
.docs
.iter()
@@ -308,6 +316,7 @@ impl Session {
// An edit inside clears redo; a failed group has to bring it back.
redo: ds.history.redo.clone(),
limit: ds.history.limit,
group_step: ds.history.group_step,
})
.collect();
let before = GroupCheckpoint {
@@ -320,6 +329,9 @@ impl Session {
// The steps inside are dropped again below, so they mustn't push the oldest ones out.
for ds in &mut self.docs {
ds.history.limit = usize::MAX;
if own_step {
ds.history.group_step = None;
}
}
for ds in &before.docs {
self.live_hold(ds.uid, true);
@@ -346,6 +358,7 @@ impl Session {
ds.page = saved.page;
ds.history.undo = saved.undo;
ds.history.limit = saved.limit;
ds.history.group_step = saved.group_step;
}
}
self.active = before.active;
+5 -5
View File
@@ -71,9 +71,9 @@ pub(crate) use host::{clean_shared, share_answer};
pub(crate) use join::start_join;
pub use join::{can_remove_people, specs};
pub use types::{
Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, MAX_NAME, MAX_SELECTION, NO_HOSTING, NO_SESSION, NetReply, NetRequest, ONLINE_OFF, PEOPLE,
PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via, join_target,
parse_color,
Bind, DEFAULT_SERVER, Event, HostOptions, LiveLink, MAX_NAME, MAX_PEERS, MAX_SELECTION, NO_HOSTING, NO_SESSION, NetReply, NetRequest, ONLINE_OFF,
PEOPLE, PageInfo, PublishOptions, Published, Role, SessionId, SessionInfo, Share, Shared, SharedLink, Status, TOO_LARGE_TO_JOIN, Via,
join_target, parse_color,
};
/// The view a follow last applied: any other view means the user moved.
@@ -196,8 +196,8 @@ pub struct Lives {
/// Registers the app as the opener of `figcraft://` invite links (the desktop app on Windows
/// installs one; see [`crate::invite_links`]).
pub links: Option<Box<dyn crate::invite_links::LinkRegistrar>>,
/// The recorded online files changed since the app last saved the preferences (it saves them
/// at once: a crash must not lose an owner token).
/// The recorded online files or your team libraries changed since the app last saved the
/// preferences (it saves them at once: a crash must not lose an owner token).
pub unsaved: bool,
/// Servers that couldn't say what became of your files at the last `collab.files {refresh}`.
pub servers: crate::online::Problems,
+24 -17
View File
@@ -1,5 +1,5 @@
use super::host::{clean_hosting, clean_shared};
use super::types::{Event, PageInfo, Role, SessionId, Status};
use super::types::{Event, MAX_PEERS, PageInfo, Role, SessionId, SessionInfo, Status};
use super::{
Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal,
ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left,
@@ -27,6 +27,24 @@ fn reset_for_reconnect(live: &mut Live) {
}
}
/// Joined, or joined again: who you are and who else is here (at most [`MAX_PEERS`] of them).
fn welcome_in(live: &mut Live, mut info: SessionInfo, peers: Vec<Peer>) {
// The shared file's name and pages come with the document (the sync welcome).
if let Some(old) = live.info.as_ref().filter(|_| info.file_name.is_empty()) {
info.file_name = old.file_name.clone();
info.pages = old.pages.clone();
}
live.status = Status::Live;
live.info = Some(info);
live.peers = peers.into_iter().take(MAX_PEERS).map(|p| (p.client_id, p)).collect();
live.presence.retain(|id, _| live.peers.contains_key(id));
if live.follow.is_some_and(|f| !live.peers.contains_key(&f)) {
live.unfollow();
}
// Others need your presence again.
live.sent = None;
}
impl Session {
/// The active document (`None`: no document is open).
pub(super) fn tab(&self) -> Option<u64> {
@@ -160,25 +178,14 @@ impl Session {
}
live.status = st;
}
Event::Welcome { mut info, peers } => {
// The shared file's name and pages come with the document (the sync welcome).
if let Some(old) = live.info.as_ref().filter(|_| info.file_name.is_empty()) {
info.file_name = old.file_name.clone();
info.pages = old.pages.clone();
}
live.status = Status::Live;
live.info = Some(info);
live.peers = peers.into_iter().map(|p| (p.client_id, p)).collect();
live.presence.retain(|id, _| live.peers.contains_key(id));
if live.follow.is_some_and(|f| !live.peers.contains_key(&f)) {
live.unfollow();
}
// Others need your presence again.
live.sent = None;
}
Event::Welcome { info, peers } => welcome_in(live, info, peers),
Event::PeerJoined(p) => {
// Someone already here was renamed: no newcomer to announce.
if !live.peers.contains_key(&p.client_id) {
if live.peers.len() >= MAX_PEERS {
log::warn!("live session: {MAX_PEERS} people already here; leaving out another");
return;
}
self.message = Some(format!("{} joined{behind}", p.name));
}
live.peers.insert(p.client_id, p);
+2
View File
@@ -17,6 +17,8 @@ pub const DEFAULT_SERVER: &str = match option_env!("FIGCRAFT_DEFAULT_SERVER") {
/// Most selected ids one presence update carries (the rest aren't shown to others).
pub const MAX_SELECTION: usize = 10_000;
pub use figcraft_collab::message::MAX_PEERS;
/// Longest name shown to others (characters).
pub const MAX_NAME: usize = Signal::MAX_NAME;
+1 -1
View File
@@ -291,7 +291,7 @@ fn variables_import_caps_the_file_it_reads() {
for bad in &refused {
let before = (format!("{:?}", t.docs), t.active);
let e = t.execute("variable.import", &json!({"variables": bad})).unwrap_err().to_string();
assert!(e.contains("not a file of at most 268435456 bytes"), "{bad}: {e}");
assert!(["more than the 256 MB allowed here", "is a folder, not a file", "isn't a regular file"].iter().any(|w| e.contains(w)), "{bad}: {e}");
assert_eq!((format!("{:?}", t.docs), t.active), before, "{bad}");
}
// A file under the limit still imports.
+3 -2
View File
@@ -24,8 +24,9 @@ fn kept_paint_bounds_are_the_bounds_worked_out_afresh() {
for id in &order {
let n = d.node(*id).unwrap();
for depth in [0, 20] {
let kept = draw::bounds_within(&d, n, depth, None, Some(&memo)).0;
assert_eq!(kept, draw::local_paint_bounds(&d, n, depth, None), "{} at depth {depth}", n.name);
let world = d.world_transform(*id);
let kept = draw::bounds_within(&d, n, world, depth, None, Some(&memo)).0;
assert_eq!(kept, draw::local_paint_bounds(&d, n, world, depth, None), "{} at depth {depth}", n.name);
}
}
}
+49 -13
View File
@@ -414,17 +414,41 @@ fn text_box_bounds(n: &Node) -> Rect {
/// Local bounds a node paints into (its box grown by stroke/effect reach, plus unclipped children).
/// Text that overflows its box counts where its glyphs are, so it isn't culled while visible;
/// text greeked at `greek_scale` (see [`lod::greeked`]) paints only inside [`text_box_bounds`].
pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, depth: usize, greek_scale: Option<f64>) -> Rect {
bounds_within(doc, n, depth, greek_scale, None).0
/// `to_page` is the node's local → page map, for how far its effects reach (see [`local_reach`]).
pub(crate) fn local_paint_bounds(doc: &Document, n: &Node, to_page: Affine, depth: usize, greek_scale: Option<f64>) -> Rect {
bounds_within(doc, n, to_page, depth, greek_scale, None).0
}
/// How far `reach` page units go in a layer's local units along x and y, under `to_page` (its
/// local → page map): effects are drawn at their size in page units however the layer is scaled,
/// so they reach further in a layer scaled down. A page square of half-size `reach` comes back as
/// the rows of the inverse map, however small the scale (capped where the sums would overflow); a
/// map that collapses the layer keeps `reach`.
fn local_reach(to_page: Affine, reach: f64) -> (f64, f64) {
let [a, b, c, d, _, _] = to_page.as_coeffs();
let det = (a * d - b * c).abs();
if reach <= 0.0 || !reach.is_finite() || !det.is_finite() || det == 0.0 {
return (reach, reach);
}
let along = |u: f64, v: f64| {
let r = (u.abs() + v.abs()) / det * reach;
if r.is_nan() { reach } else { r.min(MAX_LOCAL_REACH) }
};
(along(d, c), along(b, a))
}
/// The most [`local_reach`] makes of a reach (local units): bounds that large are cut to the view,
/// and grown by it they stay finite.
const MAX_LOCAL_REACH: f64 = 1e150;
/// [`local_paint_bounds`], kept in and read from `memo` when there is one. Also returns how many
/// levels the walk went down, or `None` when the nesting cap cut it short: such bounds depend on
/// the depth they were asked at and aren't kept, and kept bounds are read only where their levels
/// still fit under the cap.
/// still fit under the cap. They are kept by node: one render meets a node under one `to_page`.
pub(crate) fn bounds_within<'a>(
doc: &'a Document,
n: &Node,
to_page: Affine,
depth: usize,
greek_scale: Option<f64>,
memo: Option<&Bounds<'a>>,
@@ -437,8 +461,14 @@ pub(crate) fn bounds_within<'a>(
{
return (r, Some(levels));
}
let reach = n.paint_reach();
let mut r = n.local_rect().inflate(reach, reach);
// Effects in page units, but a filled outline's spread in local units: as far as either goes.
let (fx, fy) = n.outline_spreads().fold(local_reach(to_page, n.effect_paint_reach()), |(x, y), (spread, page)| {
let (px, py) = local_reach(to_page, page);
(x.max(spread + px), y.max(spread + py))
});
let outline = n.outline_reach();
let grow = |r: Rect| r.inflate(outline + fx, outline + fy);
let mut r = grow(n.local_rect());
let mut levels = Some(0);
let clips = n.clip && n.kind.is_frame_like();
if (!clips || n.kind.bounds_follow_children()) && !n.hides_contents() {
@@ -449,15 +479,15 @@ pub(crate) fn bounds_within<'a>(
if !c.visible {
continue;
}
let (cb, c_levels) = bounds_within(doc, c, depth + 1, greek_scale, memo);
let (cb, c_levels) = bounds_within(doc, c, to_page * c.transform, depth + 1, greek_scale, memo);
below = below.zip(c_levels).map(|(b, l)| b.max(l));
let cb = figcraft_geom::transform_rect_bbox(c.transform, cb);
kids = Some(kids.map_or(cb, |k| k.union(cb)));
}
// This layer's effects apply to what its children paint, past its box included.
if let Some(k) = kids {
let e = n.effects_reach();
r = r.union(if e > 0.0 { k.inflate(e, e) } else { k });
let (ex, ey) = local_reach(to_page, n.effects_reach());
r = r.union(if ex > 0.0 || ey > 0.0 { k.inflate(ex, ey) } else { k });
}
levels = below.map(|b| b + 1);
} else if !n.children.is_empty() {
@@ -466,7 +496,7 @@ pub(crate) fn bounds_within<'a>(
}
// Text can overflow its box.
if let NodeKind::Text(_) = n.kind {
r = r.union(text_box_bounds(n).inflate(reach, reach));
r = r.union(grow(text_box_bounds(n)));
if !greek_scale.is_some_and(|s| lod::greeked(n, s))
&& let Some((l, dy)) = figcraft_text::layout_node(n)
&& let Some(ink) = l.ink_bounds()
@@ -525,8 +555,14 @@ pub fn effect_margin(n: &Node) -> f64 {
pub fn paint_bounds(doc: &Document, id: NodeId) -> Option<Rect> {
let n = doc.node(id)?;
let m = effect_margin(n).abs();
let local = local_paint_bounds(doc, n, 0, None);
Some(figcraft_geom::transform_rect_bbox(doc.world_transform(id), local).inflate(m, m))
let world = doc.world_transform(id);
let local = local_paint_bounds(doc, n, world, 0, None);
Some(figcraft_geom::transform_rect_bbox(world, local).inflate(m, m))
}
/// The local → page map of a layer drawn at `xf` (local → device): the view's scale taken out.
fn to_page(ctx: &Ctx, xf: Affine) -> Affine {
if ctx.scale.is_finite() && ctx.scale > 0.0 { Affine::scale(1.0 / ctx.scale) * xf } else { xf }
}
fn needs_layer(n: &Node) -> bool {
@@ -622,7 +658,7 @@ fn draw_layer(ctx: &Ctx, pix: &mut Pixmap, n: &Node, parent_xf: Affine, depth: u
}
let xf = parent_xf * n.transform;
let (pw, ph) = (pix.width(), pix.height());
let local = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)).0;
let local = bounds_within(ctx.doc, n, to_page(ctx, xf), 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds)).0;
let dev = device_rect(xf, local);
// Effects that read outside (blur) need a margin when culling. Absurd effects (a spread or
// blur of millions of pixels) keep a sane layer: what lies further off reads as empty.
@@ -974,7 +1010,7 @@ fn masked_area(ctx: &Ctx, pix: &Pixmap, mask_id: NodeId, content: &[NodeId], xf:
}
let reach = |id: &NodeId| {
let n = ctx.bounds.node(ctx.doc, *id)?;
let (local, levels) = bounds_within(ctx.doc, n, 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds));
let (local, levels) = bounds_within(ctx.doc, n, to_page(ctx, xf * n.transform), 0, ctx.lod.then_some(ctx.scale), Some(ctx.bounds));
// Bounds the nesting cap cut short could miss a layer: anywhere then.
if levels.is_none() {
return Some(whole);
@@ -272,6 +272,101 @@ fn a_containers_effects_reach_past_what_its_children_paint() {
}
}
#[test]
fn effects_reach_as_far_under_a_scaled_or_turned_transform() {
use figcraft_geom::kurbo::Affine;
let shadow = |d: (f64, f64)| fx(EffectKind::DropShadow(sharp(d, 0.0)));
let spread = |s: f64| fx(EffectKind::DropShadow(sharp((0.0, 0.0), s)));
let turned = Affine::translate((150.0, 50.0)) * Affine::rotate(std::f64::consts::FRAC_PI_4);
// (why, kind, transform, effects, a child's effects): effects are page units however a layer
// is transformed, as the renderer draws them, but for a shadow's spread of a filled outline,
// which grows the outline before it is transformed.
let rows = [
("a halved rectangle's shadow", NodeKind::Rectangle, Affine::scale(0.5), vec![shadow((0.0, 80.0))], None),
("a turned rectangle's shadow", NodeKind::Rectangle, turned, vec![shadow((40.0, 40.0))], None),
("a halved group's shadow", NodeKind::Group, Affine::scale(0.5), vec![shadow((0.0, 80.0))], Some(vec![])),
("a shadow in a halved group", NodeKind::Group, Affine::scale(0.5), vec![], Some(vec![shadow((0.0, 80.0))])),
("a flattened rectangle's shadow", NodeKind::Rectangle, Affine::scale_non_uniform(1.0, 0.25), vec![shadow((0.0, 80.0))], None),
("a doubled rectangle's shadow", NodeKind::Rectangle, Affine::scale(2.0), vec![shadow((0.0, 80.0))], None),
("a doubled rectangle's spread shadow", NodeKind::Rectangle, Affine::scale(2.0), vec![spread(20.0)], None),
("a doubled star's spread shadow", NodeKind::Star { count: 5, inner_ratio: 0.4 }, Affine::scale(2.0), vec![spread(10.0)], None),
("a doubled group's spread shadow", NodeKind::Group, Affine::scale(2.0), vec![spread(20.0)], Some(vec![])),
("a turned star's spread shadow", NodeKind::Star { count: 5, inner_ratio: 0.4 }, turned, vec![spread(20.0)], None),
];
for (why, kind, xf, effects, child_fx) in rows {
// `room`: a clear drop shadow wider than the view on each layer, so none can be cut: what
// they should draw.
let scene = |room: bool| {
let clear = fx(EffectKind::DropShadow(Shadow { color: Color::TRANSPARENT, radius: 400.0, ..sharp((0.0, 0.0), 0.0) }));
let with_room = |mut e: Vec<Effect>| {
if room {
e.push(clear.clone());
}
e
};
let (mut d, p) = page();
let n = d.create(p, kind.clone(), "N", 0.0, 0.0, 100.0, 100.0).unwrap();
if let Some(cfx) = &child_fx {
let r = rect(&mut d, n, (0.0, 0.0, 100.0, 100.0), vec![Paint::solid(hex("FF0000"))]);
d.get_mut(r).unwrap().effects = with_room(cfx.clone());
}
let node = d.get_mut(n).unwrap();
node.fills = vec![Paint::solid(hex("FF0000"))];
node.transform = xf;
node.effects = with_room(effects.clone());
(d, n)
};
let ((d, n), (truth, _)) = (scene(false), scene(true));
let v = View { origin: (-100.0, -100.0), scale: 1.0, width: 400, height: 400 };
let (a, b) = (
render_view_single(&d, page_of(&d), &v, &Options::default()).unwrap(),
render_view_single(&truth, page_of(&truth), &v, &Options::default()).unwrap(),
);
let worst = a.data().iter().zip(b.data()).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
let ink = |p: Pixmap| p.pixels().iter().map(|c| u64::from(c.alpha())).sum::<u64>();
let (got, all) = (ink(render_node(&d, n, 1.0).unwrap()), ink(render_node(&truth, page_child(&truth), 1.0).unwrap()));
assert!(worst <= 1, "{why}: the canvas draws {worst} levels off");
assert!(got * 1000 >= all * 999, "{why}: the export holds {got} of {all} of the paint");
}
}
#[test]
fn effects_reach_as_far_under_a_tiny_scale() {
// A rectangle `w` units wide drawn 100 × 100 on the page casts the shadow a 100 × 100 one does:
// shadows are page units.
let scene = |w: f64| {
let (mut d, p) = page();
let r = rect(&mut d, p, (0.0, 0.0, w, w), vec![Paint::solid(hex("FF0000"))]);
let n = d.get_mut(r).unwrap();
n.transform = figcraft_geom::kurbo::Affine::scale(100.0 / w);
n.effects = vec![fx(EffectKind::DropShadow(sharp((0.0, 80.0), 0.0)))];
d
};
let v = View { origin: (-100.0, -100.0), scale: 1.0, width: 400, height: 400 };
let truth = scene(100.0);
let b = render_view_single(&truth, page_of(&truth), &v, &Options::default()).unwrap();
for w in [1e6, 1e8, 1e9] {
let d = scene(w);
let a = render_view_single(&d, page_of(&d), &v, &Options::default()).unwrap();
let worst = a.data().iter().zip(b.data()).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0);
assert!(worst <= 1, "{w} units wide: the canvas draws {worst} levels off");
}
}
#[test]
fn a_spread_counts_once_in_an_export() {
// A 100 × 100 rectangle with a shadow spread by 20 all round, unscaled and doubled.
for (scale, side) in [(1.0, 140), (2.0, 280)] {
let (mut d, p) = page();
let r = rect(&mut d, p, (0.0, 0.0, 100.0, 100.0), vec![Paint::solid(hex("FF0000"))]);
let n = d.get_mut(r).unwrap();
n.transform = figcraft_geom::kurbo::Affine::scale(scale);
n.effects = vec![fx(EffectKind::DropShadow(sharp((0.0, 0.0), 20.0)))];
let px = render_node(&d, r, 1.0).unwrap();
assert_eq!((px.width(), px.height()), (side, side), "at scale {scale}");
}
}
fn page_of(d: &Document) -> NodeId {
d.first_page().unwrap()
}
+3
View File
@@ -946,6 +946,9 @@ status = "draft — needs native review"
"Not in this font" = "غير متاح في هذا الخط"
"Not properties, kept as written: {names}" = "ليست خصائص، أُبقيت كما كُتبت: {names}"
"Not read yet" = "لم تُقرأ بعد"
"Check for updates" = "التحقق من التحديثات"
"Not checked yet" = "لم يُتحقق منها بعد"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "يستخدم هذا الملف مكتبات فريق على خوادم لم تستخدمها هنا. لا يُتصل بها حتى تتحقق منها."
"Not shared" = "غير مشارك"
"Note {n}" = "الملاحظة {n}"
"Nothing changed since this version." = "لم يتغير شيء منذ هذا الإصدار."
+3
View File
@@ -914,6 +914,9 @@ status = "draft — needs native review"
"Not in this font" = "এই ফন্টে নেই"
"Not properties, kept as written: {names}" = "প্রপার্টি নয়, যেমনটি লেখা হয়েছে তেমনি রাখা হয়েছে: {names}"
"Not read yet" = "এখনো পড়া হয়নি"
"Check for updates" = "আপডেট দেখুন"
"Not checked yet" = "এখনো যাচাই করা হয়নি"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "এই ফাইলটি এমন সার্ভারের টিম লাইব্রেরি ব্যবহার করে যা আপনি এখানে ব্যবহার করেননি। আপনি যাচাই না করা পর্যন্ত সেগুলোর সাথে যোগাযোগ করা হয় না।"
"Not shared" = "শেয়ার করা হয়নি"
"Note {n}" = "নোট {n}"
"Nothing changed since this was marked ready for dev." = "এটি ডেভের জন্য প্রস্তুত হিসেবে চিহ্নিত করার পর থেকে কিছু পরিবর্তিত হয়নি।"
+3
View File
@@ -896,6 +896,9 @@ status = "draft — needs native review"
"Not in this font" = "Není v tomto písmu"
"Not properties, kept as written: {names}" = "Nejde o vlastnosti, ponecháno beze změny: {names}"
"Not read yet" = "Dosud nepřečteno"
"Check for updates" = "Zkontrolovat aktualizace"
"Not checked yet" = "Dosud nezkontrolováno"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tento soubor používá týmové knihovny na serverech, které jste tu dosud nepoužili. Nekontaktují se, dokud je nezkontrolujete."
"Not shared" = "Nesdíleno"
"Note {n}" = "Poznámka {n}"
"Nothing changed since this was marked ready for dev." = "Od označení jako připravené pro vývoj se nic nezměnilo."
+3
View File
@@ -870,6 +870,9 @@ status = "draft — needs native review"
"Not in this font" = "Nicht in dieser Schrift"
"Not properties, kept as written: {names}" = "Keine Eigenschaften, unverändert übernommen: {names}"
"Not read yet" = "Noch nicht gelesen"
"Check for updates" = "Nach Updates suchen"
"Not checked yet" = "Noch nicht geprüft"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Diese Datei nutzt Teambibliotheken auf Servern, die Sie hier noch nicht verwendet haben. Diese Server werden erst kontaktiert, wenn Sie die Bibliotheken prüfen."
"Not shared" = "Nicht geteilt"
"Note {n}" = "Notiz {n}"
"Nothing changed since this version." = "Seit dieser Version hat sich nichts geändert."
+3
View File
@@ -221,6 +221,7 @@ name = "English"
"Can comment" = "Can comment"
"Check" = "Check"
"Check for Updates" = "Check for Updates"
"Check for updates" = "Check for updates"
"Check for updates…" = "Check for updates…"
"Checking for updates…" = "Checking for updates…"
"Checking the fonts installed on this computer…" = "Checking the fonts installed on this computer…"
@@ -953,6 +954,7 @@ name = "English"
"Not in this font" = "Not in this font"
"Not now" = "Not now"
"Not properties, kept as written: {names}" = "Not properties, kept as written: {names}"
"Not checked yet" = "Not checked yet"
"Not read yet" = "Not read yet"
"Not shared" = "Not shared"
"Note {n}" = "Note {n}"
@@ -1502,6 +1504,7 @@ name = "English"
"This branch was merged or archived." = "This branch was merged or archived."
"This computer only" = "This computer only"
"This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again."
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "This file uses team libraries on servers you haven't used here. They aren't contacted until you check them."
"This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again."
"This condition is edited as an expression" = "This condition is edited as an expression"
"This file" = "This file"
+3
View File
@@ -940,6 +940,9 @@ status = "draft — needs native review"
"Not in this font" = "No está en esta fuente"
"Not properties, kept as written: {names}" = "No son propiedades, se quedan tal cual: {names}"
"Not read yet" = "Aún no leída"
"Check for updates" = "Buscar actualizaciones"
"Not checked yet" = "Aún sin comprobar"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Este archivo usa bibliotecas de equipo en servidores que no has usado aquí. No se contactan hasta que las compruebes."
"Not shared" = "Sin compartir"
"Note {n}" = "Nota {n}"
"Nothing changed since this version." = "Nada cambió desde esta versión."
+3
View File
@@ -916,6 +916,9 @@ status = "draft — needs native review"
"Not in this font" = "Ei tässä fontissa"
"Not properties, kept as written: {names}" = "Eivät ole ominaisuuksia, säilytetään sellaisinaan: {names}"
"Not read yet" = "Ei vielä luettu"
"Check for updates" = "Tarkista päivitykset"
"Not checked yet" = "Ei vielä tarkistettu"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tämä tiedosto käyttää tiimikirjastoja palvelimilla, joita et ole käyttänyt täällä. Niihin ei oteta yhteyttä ennen kuin tarkistat ne."
"Not shared" = "Ei jaettu"
"Note {n}" = "Huomautus {n}"
"Nothing changed since this was marked ready for dev." = "Mikään ei ole muuttunut kehitysvalmiiksi merkitsemisen jälkeen."
+3
View File
@@ -914,6 +914,9 @@ status = "draft — needs native review"
"Not in this font" = "Absent de cette police"
"Not properties, kept as written: {names}" = "Pas des propriétés, conservés tels quels : {names}"
"Not read yet" = "Pas encore lu"
"Check for updates" = "Rechercher des mises à jour"
"Not checked yet" = "Pas encore vérifiée"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Ce fichier utilise des bibliothèques d’équipe sur des serveurs que vous n’avez pas utilisés ici. Ils ne sont contactés que lorsque vous les vérifiez."
"Not shared" = "Non partagé"
"Note {n}" = "Note {n}"
"Nothing changed since this version." = "Rien n’a changé depuis cette version."
+3
View File
@@ -903,6 +903,9 @@ status = "draft — needs native review"
"Not in this font" = "Tidak ada di font ini"
"Not properties, kept as written: {names}" = "Bukan properti, dibiarkan apa adanya: {names}"
"Not read yet" = "Belum dibaca"
"Check for updates" = "Periksa pembaruan"
"Not checked yet" = "Belum diperiksa"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "File ini memakai pustaka tim di server yang belum pernah Anda gunakan di sini. Server itu tidak dihubungi sampai Anda memeriksanya."
"Not shared" = "Tidak dibagikan"
"Note {n}" = "Catatan {n}"
"Nothing changed since this was marked ready for dev." = "Tidak ada perubahan sejak ditandai siap untuk dev."
+3
View File
@@ -884,6 +884,9 @@ status = "draft — needs native review"
"Not in this font" = "Non presente in questo font"
"Not properties, kept as written: {names}" = "Non sono proprietà, mantenute come scritte: {names}"
"Not read yet" = "Non ancora letto"
"Check for updates" = "Verifica aggiornamenti"
"Not checked yet" = "Non ancora verificata"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Questo file usa librerie del team su server che non hai usato qui. Non vengono contattati finché non le verifichi."
"Not shared" = "Non condiviso"
"Note {n}" = "Nota {n}"
"Nothing changed since this was marked ready for dev." = "Nulla è cambiato da quando è stato segnato come pronto per lo sviluppo."
+3
View File
@@ -907,6 +907,9 @@ status = "draft — needs native review"
"Not in this font" = "このフォントにはない"
"Not properties, kept as written: {names}" = "プロパティではないため記述どおりに保持:{names}"
"Not read yet" = "未読"
"Check for updates" = "アップデートを確認"
"Not checked yet" = "未確認"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "このファイルは、ここで使ったことのないサーバー上のチームライブラリを使う。確認するまでそれらのサーバーには接続しない。"
"Not shared" = "共有していない"
"Note {n}" = "メモ {n}"
"Nothing changed since this version." = "このバージョンから変更なし。"
+3
View File
@@ -923,6 +923,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv
"Not in this font" = "ამ შრიფტში არ არის"
"Not properties, kept as written: {names}" = "არ არის თვისებები, დარჩა დაწერილი სახით: {names}"
"Not read yet" = "ჯერ არ არის წაკითხული"
"Check for updates" = "განახლებების შემოწმება"
"Not checked yet" = "ჯერ არ არის შემოწმებული"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "ეს ფაილი იყენებს გუნდის ბიბლიოთეკებს სერვერებზე, რომლებიც აქ არ გამოგიყენებიათ. მათ არ დავუკავშირდებით, სანამ არ შეამოწმებთ."
"Not shared" = "არ არის გაზიარებული"
"Note {n}" = "შენიშვნა {n}"
"Nothing changed since this version." = "ამ ვერსიის შემდეგ არაფერი შეცვლილა."
+3
View File
@@ -866,6 +866,9 @@ status = "draft — needs native review"
"Not in this font" = "이 글꼴에 없음"
"Not properties, kept as written: {names}" = "속성이 아니므로 작성된 대로 유지: {names}"
"Not read yet" = "아직 읽지 않음"
"Check for updates" = "업데이트 확인"
"Not checked yet" = "아직 확인 안 함"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "이 파일은 여기서 사용한 적 없는 서버의 팀 라이브러리를 사용합니다. 확인하기 전까지는 해당 서버에 연결하지 않습니다."
"Not shared" = "공유 안 함"
"Note {n}" = "메모 {n}"
"Nothing changed since this version." = "이 버전 이후 변경된 내용 없음."
+3
View File
@@ -916,6 +916,9 @@ status = "draft — needs native review"
"Not in this font" = "Niet in dit lettertype"
"Not properties, kept as written: {names}" = "Geen eigenschappen, ongewijzigd overgenomen: {names}"
"Not read yet" = "Nog niet gelezen"
"Check for updates" = "Controleren op updates"
"Not checked yet" = "Nog niet gecontroleerd"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Dit bestand gebruikt teambibliotheken op servers die je hier nog niet hebt gebruikt. Er wordt pas contact mee gemaakt als je ze controleert."
"Not shared" = "Niet gedeeld"
"Note {n}" = "Notitie {n}"
"Nothing changed since this was marked ready for dev." = "Er is niets gewijzigd sinds dit was gemarkeerd als ready for dev."
+3
View File
@@ -918,6 +918,9 @@ name = "Polski"
"Not in this font" = "Brak w tej czcionce"
"Not properties, kept as written: {names}" = "Nie są to właściwości, zachowano bez zmian: {names}"
"Not read yet" = "Jeszcze nieprzeczytane"
"Check for updates" = "Sprawdź aktualizacje"
"Not checked yet" = "Jeszcze nie sprawdzono"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Ten plik korzysta z bibliotek zespołu na serwerach, których tu jeszcze nie używano. Nie są kontaktowane, dopóki ich nie sprawdzisz."
"Not shared" = "Nieudostępnione"
"Note {n}" = "Notatka {n}"
"Nothing changed since this was marked ready for dev." = "Nic się nie zmieniło od czasu oznaczenia jako gotowe do wdrożenia."
+3
View File
@@ -865,6 +865,9 @@ status = "draft — needs native review"
"Not in this font" = "Não existe nesta fonte"
"Not properties, kept as written: {names}" = "Não são propriedades, mantidos como escritos: {names}"
"Not read yet" = "Ainda não lido"
"Check for updates" = "Verificar atualizações"
"Not checked yet" = "Ainda não verificada"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Este arquivo usa bibliotecas de equipe em servidores que você não usou aqui. Eles só são contatados quando você os verifica."
"Not shared" = "Não compartilhado"
"Note {n}" = "Nota {n}"
"Nothing changed since this version." = "Nada mudou desde esta versão."
+3
View File
@@ -918,6 +918,9 @@ name = "Русский"
"Not in this font" = "Нет в этом шрифте"
"Not properties, kept as written: {names}" = "Не свойства, оставлены как есть: {names}"
"Not read yet" = "Ещё не прочитано"
"Check for updates" = "Проверить обновления"
"Not checked yet" = "Ещё не проверено"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Этот файл использует командные библиотеки на серверах, которые вы здесь не использовали. К ним не обращаются, пока вы их не проверите."
"Not shared" = "Не опубликовано"
"Note {n}" = "Заметка {n}"
"Nothing changed since this was marked ready for dev." = "С момента отметки «Готово к разработке» изменений нет."
+3
View File
@@ -940,6 +940,9 @@ name = "Svenska"
"Not in this font" = "Finns inte i det här typsnittet"
"Not properties, kept as written: {names}" = "Inte egenskaper, behölls som de skrevs: {names}"
"Not read yet" = "Inte läst än"
"Check for updates" = "Sök efter uppdateringar"
"Not checked yet" = "Inte kontrollerat än"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Den här filen använder teambibliotek på servrar som du inte har använt här. De kontaktas inte förrän du kontrollerar dem."
"Not shared" = "Inte delad"
"Note {n}" = "Anteckning {n}"
"Nothing changed since this version." = "Ingenting har ändrats sedan den här versionen."
+3
View File
@@ -910,6 +910,9 @@ status = "draft — needs native review"
"Not in this font" = "ไม่มีในฟอนต์นี้"
"Not properties, kept as written: {names}" = "ไม่ใช่พร็อพเพอร์ตี คงไว้ตามที่เขียน: {names}"
"Not read yet" = "ยังไม่ได้อ่าน"
"Check for updates" = "ตรวจหาอัปเดต"
"Not checked yet" = "ยังไม่ได้ตรวจ"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "ไฟล์นี้ใช้ไลบรารีทีมบนเซิร์ฟเวอร์ที่คุณยังไม่เคยใช้ที่นี่ จะไม่มีการติดต่อเซิร์ฟเวอร์เหล่านั้นจนกว่าคุณจะตรวจ"
"Not shared" = "ไม่ได้แชร์"
"Note {n}" = "โน้ต {n}"
"Nothing changed since this version." = "ไม่มีอะไรเปลี่ยนตั้งแต่เวอร์ชันนี้"
+3
View File
@@ -900,6 +900,9 @@ name = "Türkçe"
"Not in this font" = "Bu yazı tipinde yok"
"Not properties, kept as written: {names}" = "Özellik değil, yazıldığı gibi korundu: {names}"
"Not read yet" = "Henüz okunmadı"
"Check for updates" = "Güncellemeleri denetle"
"Not checked yet" = "Henüz denetlenmedi"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Bu dosya, burada kullanmadığınız sunuculardaki ekip kitaplıklarını kullanıyor. Siz denetleyene kadar bu sunuculara bağlanılmaz."
"Not shared" = "Paylaşılmadı"
"Note {n}" = "Not {n}"
"Nothing changed since this was marked ready for dev." = "Geliştirmeye hazır olarak işaretlendiğinden beri hiçbir şey değişmedi."
+3
View File
@@ -885,6 +885,9 @@ status = "draft — needs native review"
"Not in this font" = "Không có trong phông chữ này"
"Not properties, kept as written: {names}" = "Không phải thuộc tính, được giữ nguyên như đã viết: {names}"
"Not read yet" = "Chưa đọc"
"Check for updates" = "Kiểm tra cập nhật"
"Not checked yet" = "Chưa kiểm tra"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Tệp này dùng thư viện nhóm trên các máy chủ bạn chưa dùng ở đây. Các máy chủ đó không được liên hệ cho đến khi bạn kiểm tra."
"Not shared" = "Chưa chia sẻ"
"Note {n}" = "Ghi chú {n}"
"Nothing changed since this was marked ready for dev." = "Chưa có gì thay đổi kể từ khi được đánh dấu sẵn sàng cho dev."
+3
View File
@@ -871,6 +871,9 @@ status = "draft — needs native review"
"Not in this font" = "此字体不支持"
"Not properties, kept as written: {names}" = "不是属性,按原样保留:{names}"
"Not read yet" = "尚未读取"
"Check for updates" = "检查更新"
"Not checked yet" = "尚未检查"
"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "此文件使用了你在此处未用过的服务器上的团队库。在你检查之前,不会连接这些服务器。"
"Not shared" = "未分享"
"Note {n}" = "备注 {n}"
"Nothing changed since this version." = "自此版本以来没有任何更改。"
+3
View File
@@ -374,6 +374,9 @@ mod builder_poly_tests;
#[cfg(test)]
mod outline_tests;
#[cfg(test)]
mod selection_cache_tests;
#[cfg(test)]
mod wheel_zoom_tests;
+32 -27
View File
@@ -71,7 +71,30 @@ pub(super) fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) {
/// The slots of the selected components and instances (and a slot being dropped onto): a dashed
/// outline and the slot's name above its top-left corner, like a frame label (never over the
/// content).
pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document, sel: &[NodeId], drop: Option<NodeId>) {
pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Arc<Document>, sel: &[NodeId], drop: Option<NodeId>) {
let key = [drop.map_or(0, |d| d.0.wrapping_add(1))];
let slots = crate::selection_cache::cached(painter.ctx(), "slots", doc, sel, &key, || slots_shown(doc, sel, drop));
for (id, name) in slots {
let Some(q) = node_quad(app, doc, id) else { continue };
if !Rect::from_points(&q).intersects(app.canvas.rect) {
continue;
}
painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0));
// The label, when the slot is wide enough on screen to hold it.
let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT);
let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0));
if (q[1] - q[0]).length() < w {
continue;
}
let at = q[0] - vec2(0.0, h + 2.0);
crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT);
painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT);
}
}
/// The slots [`slot_badges`] shows, with their names: those of the selected components and
/// instances (and of the ones the selected layers are in), and the slot being dropped onto.
fn slots_shown(doc: &Document, sel: &[NodeId], drop: Option<NodeId>) -> Vec<(NodeId, String)> {
use figcraft_engine::components::{owning_component, owning_instance, slot_name, slots_of};
let mut roots: Vec<NodeId> = Vec::new();
for id in sel {
@@ -90,22 +113,7 @@ pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Document,
{
slots.push((d, name));
}
for (id, name) in slots {
let Some(q) = node_quad(app, doc, id) else { continue };
if !Rect::from_points(&q).intersects(app.canvas.rect) {
continue;
}
painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(COMPONENT, 1.0), 4.0, 3.0));
// The label, when the slot is wide enough on screen to hold it.
let galley = painter.layout_no_wrap(name, regular(10.0), COMPONENT);
let (w, h) = (galley.size().x + 14.0, galley.size().y.max(11.0));
if (q[1] - q[0]).length() < w {
continue;
}
let at = q[0] - vec2(0.0, h + 2.0);
crate::icons::paint(painter, Rect::from_min_size(pos2(at.x, at.y + (h - 11.0) / 2.0), vec2(11.0, 11.0)), crate::icons::Icon::Slot, COMPONENT);
painter.galley(pos2(at.x + 14.0, at.y + (h - galley.size().y) / 2.0), galley, COMPONENT);
}
slots
}
/// The instance an Alt-drop of a component from the Assets panel swaps: tinted and outlined in
@@ -197,16 +205,13 @@ fn outline_shape(app: &FigApp, doc: &Document, id: NodeId) -> Option<Vec<Vec<Pos
/// A layer's outline on the canvas: its shape where [`outline_path`] has one, its box otherwise.
pub(super) fn draw_outline(app: &FigApp, painter: &egui::Painter, doc: &Document, id: NodeId, s: Stroke) {
painter.extend(outline_shapes(app, doc, id, s));
}
/// The shapes [`draw_outline`] draws.
pub(super) fn outline_shapes(app: &FigApp, doc: &Document, id: NodeId, s: Stroke) -> Vec<Shape> {
match outline_shape(app, doc, id) {
Some(lines) => {
for l in lines {
painter.add(Shape::line(l, s));
}
}
None => {
if let Some(q) = node_quad(app, doc, id) {
quad(painter, q, s);
}
}
Some(lines) => lines.into_iter().map(|l| Shape::line(l, s)).collect(),
None => node_quad(app, doc, id).map(|q| Shape::closed_line(q.to_vec(), s)).into_iter().collect(),
}
}
+42 -14
View File
@@ -1,5 +1,7 @@
use std::sync::Arc;
use super::builder::{builder_overlay, hatched, region_hatch};
use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, quad, slot_badges, stroke, swap_highlight};
use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, outline_shapes, quad, slot_badges, stroke, swap_highlight};
use super::rulers::{boards, fmt, rulers};
use super::{
Align2, COMPONENT, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Point, Pos2, Rect, Shape, Stroke, StrokeKind, Tokens, ToolId,
@@ -234,20 +236,51 @@ fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Do
painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
}
/// What the selection's overlay draws that only the document, the selection, the view and the
/// overlay colour decide: kept for the frames that show the same ([`crate::selection_cache`]).
#[derive(Clone, Debug, PartialEq)]
pub(super) struct SelectionGeometry {
/// The outline colour: the component colour when any selected layer is in one.
pub(super) col: Color32,
/// Every selected layer's outline on screen.
pub(super) outlines: Arc<Vec<Shape>>,
/// The transform box (page space) and its width and height.
bbox: Option<[Point; 4]>,
size: Option<(f64, f64)>,
/// A selected layer is locked (no handles).
locked: bool,
}
pub(super) fn selection_geometry(app: &FigApp, painter: &egui::Painter, doc: &Arc<Document>, sel: &[NodeId]) -> SelectionGeometry {
let green = accent(painter);
let mapping = [app.to_screen(0.0, 0.0), app.to_screen(1.0, 1.0)].map(crate::selection_cache::bits);
let extra = [mapping[0][0], mapping[0][1], mapping[1][0], mapping[1][1], u64::from(u32::from_le_bytes(green.to_array()))];
crate::selection_cache::cached(painter.ctx(), "selection overlay", doc, sel, &extra, || {
let col = if sel.iter().any(|i| outline_colour(doc, *i, green) == COMPONENT) { COMPONENT } else { green };
let outlines = sel.iter().flat_map(|id| outline_shapes(app, doc, *id, stroke(col, 1.0))).collect();
SelectionGeometry {
col,
outlines: Arc::new(outlines),
bbox: selection_box(doc, sel),
size: selection_frame(doc, sel).map(|(_, w, h)| (w, h)),
locked: sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)),
}
})
}
/// The selection's outlines, transform box and handles; returns the size label's box.
fn selection_overlay(
app: &FigApp,
painter: &egui::Painter,
ds: &figcraft_engine::DocState,
doc: &Document,
doc: &Arc<Document>,
sel: &[NodeId],
zoom: f64,
) -> Option<Rect> {
let mut size_label = None;
let col = if sel.iter().any(|i| outline_colour(doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) };
for id in sel {
draw_outline(app, painter, doc, *id, stroke(col, 1.0));
}
let geo = selection_geometry(app, painter, doc, sel);
let col = geo.col;
painter.extend(geo.outlines.iter().cloned());
// Constraint guides belong to the normal selection overlay: not while the Shape Builder
// reshapes by regions, nor while measuring to another layer (Alt), whose lines take over.
if app.session.tool.tool != ToolId::ShapeBuilder && app.session.tool.overlay.measure_box.is_none() {
@@ -255,8 +288,8 @@ fn selection_overlay(
}
// The shape builder reshapes by regions: no transform box.
if app.session.tool.tool != ToolId::ShapeBuilder
&& let Some(b) = selection_box(doc, sel)
&& let Some((_, w, h)) = selection_frame(doc, sel)
&& let Some(b) = geo.bbox
&& let Some((w, h)) = geo.size
{
let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line));
// A selected connector's handles are its two ends (drag one onto another object).
@@ -275,12 +308,7 @@ fn selection_overlay(
// Mode: no handles either.
// Crop mode draws its own marks on the box instead (`handles::crop_marks`).
let cropping = app.ui.crop.is_some();
let handles = if cropping
|| measuring
|| app.session.tool.dev_mode
|| connector.is_some()
|| sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i))
{
let handles = if cropping || measuring || app.session.tool.dev_mode || connector.is_some() || geo.locked {
0
} else if is_line {
2
@@ -0,0 +1,178 @@
//! The selection overlay's geometry and the Design panel's values are kept for the frames that
//! show the same (#1571), made again on every input they show (an edit, the selection, zoom, pan,
//! the canvas's place, the theme's overlay green), and the same as when made afresh; what they
//! don't keep (the hover and drop-target highlight) shows on the next frame.
use std::sync::Arc;
use egui::{Color32, Context, LayerId, Painter, Rect, pos2, vec2};
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::{Value, json};
use super::overlays::{SelectionGeometry, overlays, selection_geometry};
use crate::theme::{Tokens, set_canvas_page};
use crate::{FigApp, NoServices};
fn id(v: Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// Three rectangles (the middle one rounded, outlined along its corners) selected, and a fourth
/// that isn't, on a light page.
fn setup() -> (FigApp, Context, Vec<NodeId>) {
let mut s = Session::new();
let mut ids = Vec::new();
for x in [0, 80, 160, 240] {
ids.push(id(s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 50, "height": 50})).unwrap()));
}
let radii = json!({"topLeftRadius": 12, "topRightRadius": 12, "bottomRightRadius": 12, "bottomLeftRadius": 12});
s.execute("node.set", &json!({"id": ids[1], "props": radii})).unwrap();
s.execute("select.set", &json!({"ids": ids[..3]})).unwrap();
let mut app = FigApp::new(s, Box::new(NoServices));
app.canvas.rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0));
let ctx = Context::default();
set_canvas_page(&ctx, Color32::WHITE);
(app, ctx, ids)
}
fn geometry(app: &FigApp, ctx: &Context) -> SelectionGeometry {
let ds = app.session.ds().unwrap();
selection_geometry(app, &Painter::new(ctx.clone(), LayerId::background(), Rect::EVERYTHING), &ds.doc.clone(), &ds.selection.clone())
}
/// The geometry made with nothing kept, on a page of colour `page`.
fn fresh(app: &FigApp, page: Color32) -> SelectionGeometry {
let ctx = Context::default();
set_canvas_page(&ctx, page);
geometry(app, &ctx)
}
fn kept(a: &SelectionGeometry, b: &SelectionGeometry) -> bool {
Arc::ptr_eq(&a.outlines, &b.outlines)
}
#[test]
fn an_unchanged_frame_reuses_the_selection_geometry() {
let (app, ctx, _) = setup();
let first = geometry(&app, &ctx);
assert_eq!(first.outlines.len(), 3);
assert!(kept(&first, &geometry(&app, &ctx)));
assert_eq!(first, fresh(&app, Color32::WHITE));
}
#[test]
fn a_kept_value_does_not_keep_its_document_alive() {
let (app, ctx, _) = setup();
let doc = app.session.ds().unwrap().doc.clone();
let holders = Arc::strong_count(&doc);
geometry(&app, &ctx);
assert_eq!(Arc::strong_count(&doc), holders);
}
type Change = Box<dyn Fn(&mut FigApp, &Context)>;
#[test]
fn every_input_the_geometry_shows_makes_it_again() {
let (mut app, ctx, ids) = setup();
let (a, b) = (ids[0], ids[1]);
let changes: Vec<(&str, Change)> = vec![
(
"an edit",
Box::new(move |app: &mut FigApp, _: &Context| {
app.set_props(&[b], json!({"x": 90}));
}),
),
(
"the selection",
Box::new(move |app: &mut FigApp, _: &Context| {
app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap();
}),
),
("zoom", Box::new(|app: &mut FigApp, _: &Context| app.session.ds_mut().unwrap().view.zoom *= 2.0)),
("pan", Box::new(|app: &mut FigApp, _: &Context| app.session.ds_mut().unwrap().view.cx += 30.0)),
("the canvas moving", Box::new(|app: &mut FigApp, _: &Context| app.canvas.rect = app.canvas.rect.translate(vec2(40.0, 0.0)))),
("the theme", Box::new(|_: &mut FigApp, ctx: &Context| set_canvas_page(ctx, Color32::BLACK))),
];
let mut page = Color32::WHITE;
for (what, change) in changes {
let before = geometry(&app, &ctx);
change(&mut app, &ctx);
if what == "the theme" {
page = Color32::BLACK;
assert_ne!(crate::theme::canvas_accent(&ctx), before.col, "a dark page takes the other green");
}
let after = geometry(&app, &ctx);
assert!(!kept(&before, &after), "{what}");
assert_ne!(before, after, "{what}");
assert_eq!(after, fresh(&app, page), "{what}: as if nothing were kept");
assert!(kept(&after, &geometry(&app, &ctx)), "{what}: kept again");
}
}
/// How many shapes one frame of the canvas overlays draws.
fn drawn(app: &FigApp, ctx: &Context) -> usize {
let mut out = ctx.run_ui(Default::default(), |ui| {
overlays(app, &ui.ctx().layer_painter(LayerId::background()), &Tokens::light());
});
out.textures_delta.clear();
out.shapes.len()
}
#[test]
fn the_hover_and_drop_highlights_show_over_kept_geometry() {
let (mut app, ctx, ids) = setup();
let other = ids[3];
let plain = drawn(&app, &ctx);
let first = geometry(&app, &ctx);
app.session.ds_mut().unwrap().hover = Some(other);
assert_eq!(drawn(&app, &ctx), plain + 1, "the hovered layer's outline");
assert!(kept(&first, &geometry(&app, &ctx)), "the hover isn't part of the selection's geometry");
app.session.ds_mut().unwrap().hover = None;
app.session.tool.overlay.drop_target = Some(other);
assert_eq!(drawn(&app, &ctx), plain + 1, "the drop target's outline");
app.session.tool.overlay.drop_target = None;
assert_eq!(drawn(&app, &ctx), plain);
}
#[test]
fn the_design_panels_instance_facts_follow_edits_and_the_selection() {
let mut s = Session::new();
let f = id(s.execute("node.create", &json!({"type": "frame", "x": 0, "y": 0, "width": 100, "height": 100})).unwrap());
id(s.execute("node.create", &json!({"type": "rectangle", "x": 10, "y": 10, "width": 20, "height": 20, "parent": f})).unwrap());
let main = id(s.execute("component.create", &json!({"ids": [f]})).unwrap());
let inst = id(s.execute("instance.create", &json!({"component": main, "x": 300, "y": 0})).unwrap());
let child = s.doc().unwrap().node(inst).unwrap().children[0];
let ctx = Context::default();
let facts = |s: &Session| {
let ds = s.ds().unwrap();
crate::right::in_instance(&ctx, &ds.doc.clone(), &ds.selection.clone())
};
let facts_of = |s: &mut Session, ids: Value| {
s.execute("select.set", &json!({"ids": ids})).unwrap();
let f = facts(s);
(f.any, f.nested)
};
assert_eq!(facts_of(&mut s, json!([main])), (false, false));
assert_eq!(facts_of(&mut s, json!([inst])), (true, false));
assert_eq!(facts_of(&mut s, json!([inst, child])), (true, true));
assert_eq!(facts_of(&mut s, json!([inst])), (true, false));
s.execute("component.detach", &json!({"ids": [inst]})).unwrap();
assert!(s.doc().unwrap().node(inst).is_some(), "detaching keeps the layer");
assert_eq!(s.ds().unwrap().selection, vec![inst], "and the selection");
assert_eq!(facts(&s), crate::right::InInstance { any: false, nested: false }, "an edit");
}
#[test]
fn the_position_fields_follow_edits_and_the_selection() {
let (mut app, ctx, ids) = setup();
let x = |app: &FigApp| {
let ds = app.session.ds().unwrap();
crate::right::position_values(&ctx, &ds.doc.clone(), &ds.selection.clone()).x
};
assert_eq!(x(&app), None, "mixed");
app.session.execute("select.set", &json!({"ids": [ids[1]]})).unwrap();
assert_eq!(x(&app), Some(80.0), "the selection");
app.set_props(&[ids[1]], json!({"x": 90}));
assert_eq!(x(&app), Some(90.0), "an edit");
}
@@ -0,0 +1,56 @@
//! A component set's Properties at the narrowest right panel (240 px, the default), driven through
//! egui headless: every value field and every + and − button stays inside the panel, and the
//! values wrap onto more lines instead of running off its edge (found while retaking the README
//! screenshots).
use egui::accesskit::Node;
use figcraft_doc::NodeId;
use figcraft_engine::Session;
use serde_json::json;
use crate::component_swap_default_tests::tree;
use crate::layers_drag_tests::Ui;
fn id(v: serde_json::Value) -> NodeId {
NodeId(v["id"].as_u64().unwrap())
}
/// The right edge of what a panel row may use: the window's, less the panel's right inset.
const RIGHT: f64 = 1440.0 - crate::widgets::PAD_RIGHT as f64;
fn label(n: &Node) -> String {
n.label().or(n.value()).unwrap_or_default().to_string()
}
#[test]
fn a_component_sets_properties_fit_the_narrowest_panel() {
let mut s = Session::new();
let mut variants = Vec::new();
let names = ["Style=Primary, State=Default", "Style=Tonal, State=Hover", "Style=Outline, State=Disabled", "Style=Primary, State=Hover"];
for (k, name) in names.iter().enumerate() {
let f = id(s.execute("frame.create", &json!({"x": 0, "y": k * 60, "width": 80, "height": 40, "name": name})).unwrap());
variants.push(id(s.execute("component.create", &json!({"ids": [f]})).unwrap()));
}
let set = id(s.execute("component.combineVariants", &json!({"ids": variants})).unwrap());
s.execute("component.addProperty", &json!({"id": set, "name": "Show icon", "type": "boolean", "default": true})).unwrap();
s.execute("component.addProperty", &json!({"id": set, "name": "Label", "type": "text", "default": "Continue"})).unwrap();
s.execute("select.set", &json!({"ids": [set]})).unwrap();
let mut u = Ui::new(s);
assert_eq!(u.app.ui.right_width, crate::PANEL_DEFAULT);
u.ctx.enable_accesskit();
let nodes = tree(&mut u);
let find = |text: &str| -> Vec<&Node> { nodes.iter().filter(|n| label(n) == text && n.bounds().is_some()).collect() };
for text in ["Primary", "Tonal", "Outline", "Default", "Hover", "Disabled", "Delete property", "Add variant with a new value"] {
let found = find(text);
assert!(!found.is_empty(), "{text} is in the panel");
for n in found {
let b = n.bounds().unwrap_or_default();
assert!(b.x1 <= RIGHT + 0.5, "{text} ends at {} past the panel's {RIGHT}", b.x1);
}
}
assert_eq!(find("Delete property").len(), 4, "a − for each property");
assert_eq!(find("Add variant with a new value").len(), 2, "a + for each variant property");
// Two values to a line at this width: Outline goes on the line under Tonal.
let y = |text: &str| find(text).first().map(|n| n.bounds().unwrap_or_default().y0).unwrap_or_default();
assert!(y("Outline") > y("Tonal") + 10.0, "Outline wraps to the next line");
}
+1 -1
View File
@@ -25,7 +25,7 @@ impl Default for Runtime {
fn default() -> Self {
Self {
#[cfg(not(target_arch = "wasm32"))]
updater: Updater::new(&craft_update::suite::FIGCRAFT, env!("CARGO_PKG_VERSION")),
updater: Updater::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
#[cfg(not(target_arch = "wasm32"))]
startup_check: false,
#[cfg(not(target_arch = "wasm32"))]
+14
View File
@@ -5,6 +5,15 @@
//! below this crate knows about egui, so the frontend can be replaced.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
/// FigCraft as craft-update knows it, with its 128 px icon and accent colour: the update
/// prompt shows both, and an AppImage's menu entry gets the icon.
#[cfg(not(target_arch = "wasm32"))]
pub const UPDATE_APP: craft_update::app::App = craft_update::app::App {
icon_png: Some(include_bytes!("../../../assets/app-icon/hicolor/128x128/apps/ai.storyteller.figcraft.png")),
accent: Some([0x02, 0xda, 0x96]),
..craft_update::suite::FIGCRAFT
};
pub mod a11y;
pub mod bidi;
pub mod canvas;
@@ -63,6 +72,8 @@ mod comments_tests;
#[cfg(test)]
mod component_props_reorder_tests;
#[cfg(test)]
mod component_set_narrow_tests;
#[cfg(test)]
mod component_swap_default_tests;
#[cfg(test)]
mod component_swap_search_tests;
@@ -121,6 +132,8 @@ mod layers_keys_tests;
#[cfg(test)]
mod layers_rename_tests;
#[cfg(test)]
mod library_beacon_tests;
#[cfg(test)]
mod live_chat_tests;
#[cfg(test)]
mod live_docs_tests;
@@ -307,6 +320,7 @@ pub use control::{ControlReply, ControlRequest, RequestGate, RequestState};
mod app;
mod clock;
mod open;
mod selection_cache;
mod services;
mod state;
@@ -0,0 +1,32 @@
//! The Assets panel showing a file that names a team library on an unknown server doesn't
//! contact it (#1097): the library waits until the user clicks Check for updates. That a library
//! not allowed is never asked is the engine's `library_beacon_tests`; here, what allows it.
use figcraft_doc::LibraryLink;
use figcraft_engine::Session;
use crate::layers_drag_tests::Ui;
use crate::live_tests::{flat, has_text, text_center};
const LINK: &str = "ws://tracker.example:9/?file=f&token=per-file-secret";
#[test]
fn the_assets_panel_checks_an_unknown_library_server_only_when_asked() {
let mut s = Session::new();
s.doc_mut().unwrap().libraries_mut().enabled.push(LibraryLink { id: "lib".into(), name: "Kit".into(), path: LINK.into(), version: 1 });
let mut u = Ui::new(s);
u.app.ui.left_tab = crate::LeftTab::Assets;
u.app.ui.libraries = true;
for _ in 0..4 {
u.frame(vec![]);
u.advance_clock(60.0);
}
assert!(!u.app.session.library_allowed(LINK), "showing the panel allows nothing");
let shapes = flat(&u.frame_output(vec![]).shapes);
assert!(has_text(&shapes, "Not checked yet"));
let at = text_center(&shapes, "Check for updates");
u.button(at, true);
u.button(at, false);
u.frame(vec![]);
assert!(u.app.session.library_allowed(LINK), "allowed once the user checked");
}
+18 -3
View File
@@ -50,6 +50,7 @@ const PAD: f32 = 16.0;
const TO_SERVER: &str = "library.publishToServer";
const USAGE: &str = "library.usage";
const ADD: &str = "library.add";
const CHECK: &str = "library.check";
/// Where an asset comes from: this file or an enabled library (its id).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
@@ -288,8 +289,10 @@ fn refresh_libraries(app: &mut FigApp, ui: &Ui) {
// Team libraries: in the background, at once for one not fetched yet.
let sid = Id::unique("asset_server_libraries_checked");
let fetched: Option<f64> = ui.data(|d| d.get_temp(sid));
let unread = paths.iter().any(|p| is_server_link(p) && app.session.libraries.cached(p).is_none());
if paths.iter().any(|p| is_server_link(p)) && (unread || fetched.is_none_or(|l| now - l >= SERVER_REFRESH)) {
// Only the ones you added or checked: a file can't make the app contact a server (#1097).
let team: Vec<&String> = paths.iter().filter(|p| is_server_link(p) && app.session.library_allowed(p)).collect();
let unread = team.iter().any(|p| app.session.libraries.cached(p).is_none());
if !team.is_empty() && (unread || fetched.is_none_or(|l| now - l >= SERVER_REFRESH)) {
app.session.refresh_server_libraries();
ui.data_mut(|d| d.insert_temp(sid, now));
}
@@ -1054,7 +1057,8 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
}
Some(Ok(_)) => (tr("The file now holds another library").into(), t.danger),
Some(Err(e)) => (e.to_string(), t.danger),
None if is_server_link(&l.path) => (tr("Fetching from the server…").into(), t.text_secondary),
None if is_server_link(&l.path) && app.session.library_allowed(&l.path) => (tr("Fetching from the server…").into(), t.text_secondary),
None if is_server_link(&l.path) => (tr("Not checked yet").into(), t.text_secondary),
None => (tr("Not read yet").into(), t.text_secondary),
};
let text_w = w - PAD - if swappable { 72.0 } else { 44.0 };
@@ -1092,6 +1096,17 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) {
});
}
}
// A file names its team libraries' servers: they are contacted only once you say so (#1097).
let unchecked = doc.libraries.enabled.iter().any(|l| is_server_link(&l.path) && !app.session.library_allowed(&l.path));
if unchecked {
hint(ui, tr("This file uses team libraries on servers you haven't used here. They aren't contacted until you check them."));
ui.horizontal(|ui| {
ui.add_space(PAD);
if crate::widgets::button(ui, tr("Check for updates"), false, true).clicked() && !crate::menus::dev_mode_refuses(app, CHECK) {
app.run(CHECK, json!({}));
}
});
}
ui.add_space(4.0);
let mut add = false;
ui.horizontal(|ui| {
+36 -10
View File
@@ -207,6 +207,23 @@ fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, prefer
});
}
/// The indent of a variant property's value lines (under its icon).
const INDENT: f32 = 22.0;
/// A variant value field.
const VALUE_W: f32 = 62.0;
/// The + and − buttons.
const BUTTON: f32 = 22.0;
/// A variant property's default dropdown.
const DEFAULT_W: f32 = 70.0;
/// A shared property's default (a text field, a dropdown; a checkbox takes less).
const SHARED_W: f32 = 84.0;
/// A property row's name field: `max` wide, less when the panel is narrow so that the type icon,
/// `default_w` of default and the − button after it stay in view.
fn name_width(ui: &Ui, default_w: f32, max: f32) -> f32 {
(ui.available_width() - default_w - BUTTON - 2.0 * widgets::GUTTER).clamp(48.0, max)
}
/// Properties of a component or component set.
fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
let t = Tokens::get(ui.ctx());
@@ -257,15 +274,20 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
// Variant properties: name, values (the matrix), add value, delete. Dragged by their left
// margin to reorder them (only among themselves: the shared ones keep the engine's order).
let mut drag = ListDrag::in_order(ui, ("variant_props", owner));
// As many value fields to a line as the panel fits (2 at its narrowest), with the + after them.
let inner = ui.available_width() - widgets::PAD - widgets::PAD_RIGHT;
let per_line = (((inner - INDENT - BUTTON - widgets::GUTTER) / (VALUE_W + widgets::GUTTER)).floor() as usize).max(1);
for (i, (k, vals)) in variants.iter().enumerate() {
let header = row_rect(ui, 32.0, |ui| {
let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
icons::paint(ui.painter(), ir, Icon::ComponentSet, COMPONENT);
if let Some(nn) = widgets::text_field(ui, Id::unique(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) {
// The name gives way first, so the default and the delete button stay in view.
let name_w = name_width(ui, DEFAULT_W, 110.0);
if let Some(nn) = widgets::text_field(ui, Id::unique(("vprop_name", owner, k.as_str())), k, name_w, tr("Name")) {
app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn}));
}
let def = defaults.get(k).or(vals.first()).cloned().unwrap_or_default();
let resp = widgets::dropdown(ui, &def, 70.0, None).on_hover_text(tr("Default value (the top-left variant)"));
let resp = widgets::dropdown(ui, &def, DEFAULT_W, None).on_hover_text(tr("Default value (the top-left variant)"));
menu_popup(&resp).show(|ui| {
for v in vals {
if menu_item(ui, v, "") {
@@ -274,30 +296,31 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
}
}
});
if icons::button(ui, Icon::Minus, 22.0, false, tr("Delete property")).clicked() {
if icons::button(ui, Icon::Minus, BUTTON, false, tr("Delete property")).clicked() {
app.run("component.deleteProperty", json!({"id": owner, "name": k}));
}
});
// Values: rename inline; "+" adds a variant with a new value.
let mut vi = 0;
while vi < vals.len() {
let chunk: Vec<String> = vals.iter().skip(vi).take(3).cloned().collect();
let chunk: Vec<String> = vals.iter().skip(vi).take(per_line).cloned().collect();
let last = vi + chunk.len() >= vals.len();
row(ui, 30.0, |ui| {
ui.add_space(22.0);
ui.add_space(INDENT);
for v in &chunk {
if let Some(nv) = widgets::text_field(ui, Id::unique(("vprop_val", owner, k.as_str(), v.as_str())), v, 62.0, "")
let field = widgets::text_field(ui, Id::unique(("vprop_val", owner, k.as_str(), v.as_str())), v, VALUE_W, "");
if let Some(nv) = field
&& nv != *v
{
app.run("component.editProperty", json!({"id": owner, "name": k, "value": v, "newValue": nv}));
}
}
if last && icons::button(ui, Icon::Plus, 22.0, false, tr("Add variant with a new value")).clicked() {
if last && icons::button(ui, Icon::Plus, BUTTON, false, tr("Add variant with a new value")).clicked() {
let nv = (vals.len() + 1..).map(|n| format!("Value {n}")).find(|n| !vals.contains(n)).unwrap_or_default();
app.run("component.addVariant", json!({"id": owner, "property": k, "value": nv}));
}
});
vi += 3;
vi += chunk.len().max(1);
}
drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || k.clone());
}
@@ -314,12 +337,15 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) {
row(ui, 32.0, |ui| {
let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
icons::paint(ui.painter(), ir, type_icon(d), COMPONENT);
if let Some(nn) = widgets::text_field(ui, Id::unique(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) {
let name_w = name_width(ui, SHARED_W, 96.0);
if let Some(nn) = widgets::text_field(ui, Id::unique(("prop_name", owner, k.as_str())), k, name_w, tr("Name")) {
app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn}));
}
match d {
PropDef::Boolean { default } => {
if widgets::checkbox(ui, *default, if *default { tr("True") } else { tr("False") }) {
// As wide as the other defaults: a checkbox takes all the room, which the − needs.
let label = if *default { tr("True") } else { tr("False") };
if ui.allocate_ui(vec2(SHARED_W, widgets::FIELD_H), |ui| widgets::checkbox(ui, *default, label)).inner {
app.run("component.editProperty", json!({"id": owner, "name": k, "default": !default}));
}
}
+5 -3
View File
@@ -4,6 +4,8 @@
//! alignment in its cell, and clip content. Everything goes through `node.set` props or `layout.*`
//! commands.
use std::sync::Arc;
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_doc::{AutoLayout, CellAlign, ContentAlign, CrossAlign, Document, LayoutMode, MainAlign, Node, NodeId, NodeKind, Sizing};
use serde_json::json;
@@ -25,7 +27,7 @@ fn parent_of(doc: &Document, id: NodeId) -> Option<&Node> {
/// Show selection sizing and layout controls; offer Add only when every selected container has
/// no auto layout and the selection is editable outside an instance.
pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId]) {
let ids = sel.to_vec();
let frames = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like()));
let layout_containers = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Group)));
@@ -35,8 +37,8 @@ pub fn layout_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[Node
let title = if has_al { tr("Auto layout") } else { tr("Layout") };
// An instance's auto layout is its main's (only padding, gap and alignment are its own); the
// layers inside it override their size and resizing, not their place in the layout.
let in_instance = sel.iter().any(|i| figcraft_engine::components::owning_instance(doc, *i).is_some());
let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i));
let facts = crate::right::in_instance(ui.ctx(), doc, sel);
let (in_instance, nested) = (facts.any, facts.nested);
let btns: Vec<(Icon, &str)> = if in_instance {
vec![]
} else if layout_containers && no_al {
+128 -62
View File
@@ -1,5 +1,7 @@
//! The right panel: Design, Prototype and Inspect tabs.
use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
use figcraft_color::{BlendMode, Paint, PaintKind};
use figcraft_doc::{
@@ -274,7 +276,7 @@ fn design(app: &mut FigApp, ui: &mut Ui) {
crate::layout_panel::layout_section(app, ui, &doc, &sel);
widgets::separator(ui);
}
if vector_edit_none && (arc_section(app, ui, &doc, &sel) || points_section(app, ui, &doc, &sel)) {
if vector_edit_none && arc_section(app, ui, &doc, &sel) || points_section(app, ui, &doc, &sel) {
widgets::separator(ui);
}
appearance(app, ui, &doc, &sel);
@@ -500,21 +502,95 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
crate::component_panel::instance_section(app, ui, doc, sel);
}
fn position(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
/// Whether a selected layer is an instance or in one, and whether one is inside an instance (not
/// the instance itself): the Design panel asks every frame, kept while the document and the
/// selection stay the same ([`crate::selection_cache`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct InInstance {
pub any: bool,
pub nested: bool,
}
pub(crate) fn in_instance(ctx: &egui::Context, doc: &Arc<Document>, sel: &[NodeId]) -> InInstance {
use figcraft_engine::components::{inside_instance, owning_instance};
crate::selection_cache::cached(ctx, "in instance", doc, sel, &[], || InInstance {
any: sel.iter().any(|i| owning_instance(doc, *i).is_some()),
nested: sel.iter().any(|i| inside_instance(doc, *i)),
})
}
fn position(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId]) {
widgets::section_header(ui, tr("Position"), &[], false);
// Layers inside an instance take their place and constraints from the main component.
let nested = sel.iter().any(|i| figcraft_engine::components::inside_instance(doc, *i));
let nested = in_instance(ui.ctx(), doc, sel).nested;
ui.add_enabled_ui(!nested, |ui| position_fields(app, ui, doc, sel))
.response
.on_disabled_hover_text(tr("Layers in an instance take their position from the main component"));
ui.add_space(8.0);
}
/// What the position fields show of the selection (`None`: mixed), kept like [`in_instance`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct PositionValues {
pub can_align: bool,
pub x: Option<f64>,
pub y: Option<f64>,
pub rot: Option<f64>,
/// Every selected layer is in an auto layout frame, and whether they ignore its layout.
pub parent_al: bool,
pub absolute: Option<bool>,
/// Every selected layer is in a frame that places it by constraints, and those constraints.
pub in_frame: bool,
pub constraints: Option<figcraft_doc::Constraints>,
}
/// What the X field shows of a layer (in its frame's space: groups have none of their own); a
/// relative edit of mixed values starts from it.
fn x_of(doc: &Document, n: &Node) -> f64 {
(doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0
}
fn y_of(doc: &Document, n: &Node) -> f64 {
(doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0
}
fn rot_of(n: &Node) -> f64 {
(figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0
}
pub(crate) fn position_values(ctx: &egui::Context, doc: &Arc<Document>, sel: &[NodeId]) -> PositionValues {
let parent = |n: &Node| n.parent.and_then(|p| doc.node(p));
crate::selection_cache::cached(ctx, "position", doc, sel, &[], || PositionValues {
can_align: figcraft_engine::cmd::arrange::can_align(doc, sel),
x: common(doc, sel, |n| x_of(doc, n)),
y: common(doc, sel, |n| y_of(doc, n)),
rot: common(doc, sel, rot_of),
parent_al: sel.iter().all(|i| doc.node(*i).and_then(parent).is_some_and(|p| p.layout.is_some())),
absolute: common(doc, sel, |n| n.child_layout.absolute),
// Constraints: children of frames without auto layout, or ignoring it.
in_frame: sel.iter().all(|i| {
doc.node(*i).is_some_and(|n| parent(n).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute)))
}),
constraints: common(doc, sel, |n| n.constraints),
})
}
/// Draw alignment and distribution, X/Y and rotation with flip/rotate controls, the
/// absolute-position toggle and frame constraints.
fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) {
let ids = sel.to_vec();
fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Arc<Document>, sel: &[NodeId]) {
let pv = position_values(ui.ctx(), doc, sel);
let col = widgets::col_width(ui.available_width());
align_row(app, ui, sel, pv.can_align, col);
position_row(app, ui, doc, sel, &pv, col);
rotation_row(app, ui, sel, pv.rot, col);
if pv.in_frame {
constraints_row(app, ui, sel, pv.constraints, col);
}
}
/// Alignment, and distribution and tidy up for three or more layers.
fn align_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], can_align: bool, col: f32) {
let ids = sel.to_vec();
widgets::sublabels(ui, tr("Alignment"), None);
row(ui, 32.0, |ui| {
let cmds = ["align.left", "align.hcenter", "align.right", "align.top", "align.vcenter", "align.bottom"];
@@ -528,7 +604,7 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
(Icon::AlignBottom, tip(tr("Align bottom"), cmds[5])),
];
let w = col / 3.0;
let picked = ui.add_enabled_ui(figcraft_engine::cmd::arrange::can_align(doc, sel), |ui| {
let picked = ui.add_enabled_ui(can_align, |ui| {
let h = widgets::segmented(ui, &items[..3], None, w);
let v = widgets::segmented(ui, &items[3..], None, w).map(|i| i + 3);
h.or(v)
@@ -553,23 +629,20 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
});
}
});
// In the frame's space: groups have none of their own.
// What each field shows of a layer (a relative edit of mixed values starts from it).
let x_of = |n: &Node| (doc.frame_relative(n.id).as_coeffs()[4] * 100.0).round() / 100.0;
let y_of = |n: &Node| (doc.frame_relative(n.id).as_coeffs()[5] * 100.0).round() / 100.0;
let rot_of = |n: &Node| (figcraft_geom::rotation_degrees(n.transform) * 100.0).round() / 100.0;
let (x, y, rot) = (common(doc, sel, x_of), common(doc, sel, y_of), common(doc, sel, rot_of));
let parent_al = sel.iter().all(|i| doc.node(*i).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()));
let absolute = common(doc, sel, |n| n.child_layout.absolute);
}
/// X and Y, and the absolute-position toggle for children of auto layout frames.
fn position_row(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId], pv: &PositionValues, col: f32) {
let ids = sel.to_vec();
widgets::sublabels(ui, tr("Position"), None);
row(ui, 32.0, |ui| {
let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), x, "", col, 1.0);
app.field_of(&ids, "x", e, x_of, |v| json!(v));
let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), y, "", col, 1.0);
app.field_of(&ids, "y", e, y_of, |v| json!(v));
let e = widgets::number_field_mixed(ui, Id::unique("pos_x"), Lead::Text("X"), pv.x, "", col, 1.0);
app.field_of(&ids, "x", e, |n| x_of(doc, n), |v| json!(v));
let e = widgets::number_field_mixed(ui, Id::unique("pos_y"), Lead::Text("Y"), pv.y, "", col, 1.0);
app.field_of(&ids, "y", e, |n| y_of(doc, n), |v| json!(v));
// Children of auto layout frames can ignore the layout (absolute position).
if parent_al {
let on = absolute.unwrap_or(false);
if pv.parent_al {
let on = pv.absolute.unwrap_or(false);
if icons::button(
ui,
Icon::AbsolutePosition,
@@ -583,6 +656,11 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
}
}
});
}
/// Rotation, with the rotate and flip buttons.
fn rotation_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], rot: Option<f64>, col: f32) {
let ids = sel.to_vec();
widgets::sublabels(ui, tr("Rotation"), None);
row(ui, 32.0, |ui| {
let e = widgets::number_field_mixed(ui, Id::unique("pos_rot"), Lead::Icon(Icon::Angle, tr("Rotation")), rot, "°", col, 1.0);
@@ -596,50 +674,38 @@ fn position_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]
app.run(["object.rotateRight", "object.flipH", "object.flipV"][i], json!({"ids": ids}));
}
});
// Constraints (children of frames without auto layout, or ignoring it).
let in_frame = sel.iter().all(|i| {
doc.node(*i).is_some_and(|n| {
n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like() && (p.layout.is_none() || n.child_layout.absolute))
})
});
if in_frame {
let c = common(doc, sel, |n| n.constraints);
widgets::sublabels(ui, tr("Constraints"), None);
row(ui, 32.0, |ui| {
let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into());
let resp = widgets::dropdown(ui, tr(&pretty(&h)), col, None);
menu_popup(&resp).show(|ui| {
for (l, v) in [
(tr("Left"), "left"),
(tr("Right"), "right"),
(tr("Left & right"), "leftRight"),
(tr("Center"), "center"),
(tr("Scale"), "scale"),
] {
if menu_item(ui, l, "") {
app.set_props(&ids, json!({"constraints": {"horizontal": v}}));
ui.close();
}
}
/// The horizontal and vertical constraints of children of frames without auto layout.
fn constraints_row(app: &mut FigApp, ui: &mut Ui, sel: &[NodeId], c: Option<figcraft_doc::Constraints>, col: f32) {
let ids = sel.to_vec();
widgets::sublabels(ui, tr("Constraints"), None);
row(ui, 32.0, |ui| {
let h = c.map(|c| format!("{:?}", c.horizontal)).unwrap_or_else(|| "Mixed".into());
let resp = widgets::dropdown(ui, tr(&pretty(&h)), col, None);
menu_popup(&resp).show(|ui| {
for (l, v) in
[(tr("Left"), "left"), (tr("Right"), "right"), (tr("Left & right"), "leftRight"), (tr("Center"), "center"), (tr("Scale"), "scale")]
{
if menu_item(ui, l, "") {
app.set_props(&ids, json!({"constraints": {"horizontal": v}}));
ui.close();
}
});
let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into());
let resp = widgets::dropdown(ui, tr(&pretty(&v)), col, None);
menu_popup(&resp).show(|ui| {
for (l, val) in [
(tr("Top"), "top"),
(tr("Bottom"), "bottom"),
(tr("Top & bottom"), "topBottom"),
(tr("Center"), "center"),
(tr("Scale"), "scale"),
] {
if menu_item(ui, l, "") {
app.set_props(&ids, json!({"constraints": {"vertical": val}}));
ui.close();
}
}
});
}
});
}
let v = c.map(|c| format!("{:?}", c.vertical)).unwrap_or_else(|| "Mixed".into());
let resp = widgets::dropdown(ui, tr(&pretty(&v)), col, None);
menu_popup(&resp).show(|ui| {
for (l, val) in
[(tr("Top"), "top"), (tr("Bottom"), "bottom"), (tr("Top & bottom"), "topBottom"), (tr("Center"), "center"), (tr("Scale"), "scale")]
{
if menu_item(ui, l, "") {
app.set_props(&ids, json!({"constraints": {"vertical": val}}));
ui.close();
}
}
});
});
}
/// The selected ellipses' arc: Start (degrees), Sweep (a share of a full turn, negative running
+48
View File
@@ -0,0 +1,48 @@
//! Values worked out from the document and the selection that several frames show unchanged
//! (#1571): a pointer move draws about three frames, and a large selection's outlines, slots and
//! mixed values cost milliseconds each time. A value is kept with the document snapshot it was
//! made from (compared by pointer: an edit is a new snapshot), the selection and whatever else it
//! depends on (`extra`: the view's mapping to the screen, a colour…), and made again when any
//! differs.
use std::sync::{Arc, Weak};
use figcraft_doc::{Document, NodeId};
/// One kept value and what it was made from. The document is held weakly, so a kept value never
/// keeps an old snapshot alive; the weak reference keeps its address from being reused.
#[derive(Clone)]
struct Kept<T> {
doc: Weak<Document>,
sel: Vec<NodeId>,
extra: Vec<u64>,
value: T,
}
/// The value `name` for `doc`, `sel` and `extra`: the kept one when they are what it was made
/// from, else `make()`, kept for next time.
pub(crate) fn cached<T: Clone + Send + Sync + 'static>(
ctx: &egui::Context,
name: &'static str,
doc: &Arc<Document>,
sel: &[NodeId],
extra: &[u64],
make: impl FnOnce() -> T,
) -> T {
let id = egui::Id::unique(("selection_cache", name));
if let Some(k) = ctx.data(|d| d.get_temp::<Kept<T>>(id))
&& std::ptr::eq(k.doc.as_ptr(), Arc::as_ptr(doc))
&& k.sel == sel
&& k.extra == extra
{
return k.value;
}
let value = make();
ctx.data_mut(|d| d.insert_temp(id, Kept { doc: Arc::downgrade(doc), sel: sel.to_vec(), extra: extra.to_vec(), value: value.clone() }));
value
}
/// A screen point as key material.
pub(crate) fn bits(p: egui::Pos2) -> [u64; 2] {
[u64::from(p.x.to_bits()), u64::from(p.y.to_bits())]
}
+1 -1
View File
@@ -584,7 +584,7 @@ fn paint_type_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: &
image_pending = true;
let target = target.clone();
app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {
let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };
let Ok(bytes) = figcraft_engine::file_access::read_capped(&p, figcraft_doc::ImageData::MAX_BYTES) else { return };
let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
if let Some(mut paint) = current_paint(app, &target) {
paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
+6
View File
@@ -955,6 +955,12 @@ chooses to share on the network; the default is the invite link with a random to
characters; presence numbers must be finite and selections are cut to 10,000 ids; signals are
at most 1 KiB, chat 100 characters without control characters, 20 a second (bursts of 40,
the rest dropped), and only known kinds are passed on.
- **A hostile host or server.** A client keeps track of at most 4,096 other people (their presence
waiting for the UI, the people a connection has seen, the session's list of who is here); past
that, someone new is left out with a logged warning, and someone leaving makes room. Events
waiting for the UI are capped at 10,000 (document batches excepted: dropping one would end the
session). The session core remembers at most 262,144 deleted layer ids for undo, forgetting the
lowest first; undoing the delete of a forgotten layer outside your own id space is refused.
- **Malformed input.** Anything that isn't a binary message of the current wire version that
decodes in full is answered with `Refused` and a close; the first message must be `Hello` (for
this file) or, on a server, `Manage`. The invite parser and the host's reader are fuzzed
+2 -1
View File
@@ -1,6 +1,6 @@
# Collaboration parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`figcraft-server` checks the server key by comparing SHA-256 digests, not the raw keys (#1130); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; `comment.list {mentions}` lists only the threads that @mention a name (#1415); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); an AI agent attached over MCP shows where it works (#1416); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); comment.list reports each message's @mentions (#1415); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); a refused batch's fixes take time linear in the batch (#1063); image entries are checked as they apply; branches in live sessions and branch history entries; server message limits follow `max_file_mb`; a damaged log generation field keeps flushed edits; the web build joins from a `#join=` link; removing someone from a live session; live-session revert guard; live sessions per document; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; cursor chat and spotlight in live sessions; branch version history and finer list merges; comments and version history behaviour suite and fixes; hosting the server: `wss://` in the desktop app, server releases, deployment guide; team libraries on the server and swap library; branches: create, update from main, review and merge; wording: the standalone server with share links to edit or view; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7)
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
@@ -29,6 +29,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M9, #1130: `figcraft-server` compares the SHA-256 digests of the presented and the kept server key instead of the raw strings, as `docs/server.md` says; the key file, `figcraft-server key` and rotation are unchanged. Numbers unchanged. |
| 2026-10-30 | minor | M8, #1074: `figcraft-server` keeps a file everyone left loaded for a minute (at most 16 files, 256 MiB together), so rejoining it reads nothing, and writes a file's open time at most hourly (and when it stops) instead of at every join and leave: join/leave cycles of an unchanged file do no disk I/O on the hub thread. Edits are saved as before. Numbers unchanged. |
| 2026-10-30 | minor | M9, #1415 (the rest): `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry each thread's mentions as `comment.list` does. Numbers unchanged. |
| 2026-10-30 | minor | M9, #1525: share links can comment. A third access level, `comment`, sits between `edit` and `view`: someone who joins with a comment link (or its join code) sees the file live, as with a view link, and adds comment threads, replies, reactions and resolves them; any other edit is refused, by their app and by the server, which accepts a comment link's batch only when every change in it touches comment threads. Sharing online makes a comment link and code beside the edit and view ones, and the Share popover offers Can edit, Can comment and Can view; the toolbar shows a "Can comment" badge with Move, Hand and Comment. `collab.share {access: "comment"}`, `collab.manageFile {action: createLink, access: "comment"}` and `figcraft-server links create FILE --access comment` make and pick it. Older apps are unaffected: one that joins with a comment link is welcomed as a viewer, links listed to it read as view links, and a level an app doesn't know reads as view; an older server makes no comment link and sharing goes on without one. Comment authorship is the name in Preferences, as before (no accounts), so a commenter can edit or remove other people's threads. Tests: `comment_access_tests.rs` (collab), `comment_link_tests.rs` (engine, server end to end), the Share popover and toolbar in `live_share_tests.rs` and `live_server_tests.rs`. |
+3 -1
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7)
Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and
variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300
@@ -78,6 +78,8 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M8: the component set's Properties section fits the narrowest right panel: variant values wrap onto more lines and every + and − button stays visible. Grade unchanged. |
| 2026-10-30 | minor | M8, #1097: library updates from a team library a file names are offered, not fetched from its server by themselves: only team libraries you added or checked are checked in the background (**Check for updates** in Assets ▸ Libraries). Numbers unchanged. |
| 2026-10-30 | minor | M8, #1114: variables import from an explicit file path (`variable.import`, the `variables` of `file.open` and `file.import`) takes regular files of at most 256 MiB, the limit the sibling `variables.json` already had; anything else is refused before the document changes. Grade unchanged. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads` |
| 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged |
| 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged |
+2 -1
View File
@@ -70,7 +70,8 @@ unknown id is an error.
request acts on that file without bringing its tab to the front; the tab in front comes back
afterwards unless the command opened or switched to another file itself. A file that isn't open is
an error, and so is a `document` naming a file behind the tab in front on any other method
(`ui.screenshot`, `ui.input`, `ui.key`… act on what the window shows).
(`ui.screenshot`, `ui.input`, `ui.key`… act on what the window shows). `doc.list` includes each file's
`path` only when the call names no `agent`: an AI agent's calls (MCP sets `agent`) get ids and names.
## Confining file access
+2 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7)
Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of
`/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's
@@ -186,6 +186,7 @@ source paint coordinates.
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M8, #1076: `.fig` ZIP images now share a budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images. The 256 MiB member cap stays; members that do not fit are skipped before allocation with an import warning. Failed reads are charged too, repeated references keep their cached resolution, and smaller later members can still fit. Embedded images and decoded pixel memory are outside this budget. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. |
| 2026-10-30 | minor | M8, #1114: a document opened or imported by path must be a regular file of at most 16 GiB, and an image placed by path one of at most 256 MiB: a folder, a device or a pipe is refused before anything is read. Real files open as before. Numbers unchanged. |
| 2026-10-30 | minor | M8, #1101: `.fig` and REST imports of files nested 256 levels deep (the cap) fit the browser's 1 MB stack: the REST layer walk no longer recurses. Numbers unchanged. |
| 2026-10-30 | minor | M8, #1082: SVG and PDF exports draw a pattern's source one level below the layer it fills, and the images they embed of layers and pattern cells go on from the layer's depth, so one depth cap bounds a whole export. Numbers unchanged. |
| 2026-10-30 | minor | M8, #1079: a `.figcraft` file unpacks at most 6 GiB in all when it opens (2 GiB in the browser), every area together, beyond each area's own cap; what passes it is left out with a note (images stay in the file, #1313). Numbers unchanged. |
+3 -1
View File
@@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7)
How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is
Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out
@@ -51,6 +51,8 @@ Nothing has been timed against Figma on the same document (needs `.fig` import).
| Date | Change | Summary |
|---|---|---|
| 2026-10-30 | minor | M8, #1117: AI agents and plugins no longer learn where the open files are on disk from `doc.list`. Numbers unchanged. |
| 2026-10-30 | minor | M8, #1099: long batches (scripts, plugins, MCP `batch`) no longer hold a document copy per step: one undo snapshot per batch (a 1,000-step batch: +165 MiB to +10 MiB peak). Numbers unchanged. |
| 2026-10-30 | minor | M8, #1082: canvas rendering of the deepest documents can't overflow a render thread: one depth cap bounds a render, pattern sources included, and render threads get 8 MiB stacks (a level takes about 6.5 KB). Numbers unchanged. |
| 2026-10-30 | minor | M8, #1123: the browser can't catch a panic, so an internal error stopped the page and lost its unsaved changes. Its panic hook now keeps the files with unsaved changes in the page's local storage (as many as fit, about 5 MB) and says so on the page; reloading opens them again as unsaved files. Numbers unchanged. |
| 2026-10-30 | minor | M1, #1320: settings in portable mode. The settings folder now comes from craft-settings, the suite's rules: `FIGCRAFT_CONFIG_DIR` names a folder (set but empty: none, nothing read or written); a `portable` file next to the executable (next to the `.app` on macOS) puts the preferences, recovery copies and installed plugins in `config/` beside it; otherwise they stay in `FigCraft` in the platform folder, where every earlier version kept them, so nothing moves for an installed app. The Windows portable zip now ships the `portable` file. A portable folder without preferences yet starts with a copy of the platform folder's `state.json` (owner-only, the original left for an installed app), so a portable copy that kept its settings in `%APPDATA%` before keeps them after the update. Checked on Windows 11 with a portable copy beside an installed copy's settings: the first start took them (its name and start screen), a name changed there was kept in `config\` only, and without the marker the app read the installed settings again. Tests: `settings_dir_tests`. Numbers unchanged |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Some files were not shown because too many files have changed in this diff Show More