Merge main into #1614

This commit is contained in:
bflatastic
2026-10-30 13:04:48 +07:00
98 changed files with 1578 additions and 195 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=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"semver",
+2 -1
View File
@@ -41,7 +41,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 with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", 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">
+7 -1
View File
File diff suppressed because one or more lines are too long
+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());
}
+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)? })
+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();
+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
+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),
@@ -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);
}
+15 -7
View File
@@ -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, _| {
+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.
+12 -1
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 {
+8
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;
+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));
}
+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;
+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");
}
+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"))]
+12
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;
@@ -121,6 +130,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 +318,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| {
+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())]
}
+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 @@
# 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 (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,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Date | Change | Summary |
|---|---|---|
| 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
+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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 KiB

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 374 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 369 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 336 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

After

Width:  |  Height:  |  Size: 139 KiB

+5 -3
View File
@@ -350,7 +350,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match.
|---|---|
| `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. |
| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose, ArkUI or Flutter for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). |
| `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`), which keeps one copy of the document however many steps there are (an `edit.undo` step undoes the batch's edits so far together); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). |
| `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. |
| `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. |
| `inspect_document {depth, allPages}` | The document as JSON (its `id`, layer tree, styles, selection, history). The tools that act on a document also take `document` (see Naming the document below). |
@@ -892,7 +892,9 @@ most when it can't be reached).
- `library.add {path: <share link>}` enables it (a view link is enough); then `library.place`,
`library.applyStyle`, `library.bindVariable`… work as for library files. `library.list` and
`library.updates` ask the server for a newer version first (`"server": true` in the listing);
`library.acceptUpdates` takes it, overrides kept.
`library.acceptUpdates` takes it, overrides kept. The app's own background checks ask only about
team libraries added (`library.add`) or checked (`library.check {library?}`) on this computer,
so opening a file that names another one contacts nothing (#1097).
- `library.usage {library?}` → `{id, name, instances, files: [{name, updated, instances}],
components: [{id, name, instances, files}]}`: what the files using it reported (each fetch
reports the asking document's name and its instances per component). `library` is an enabled
@@ -1333,7 +1335,7 @@ take an optional `document`: the file's `id` (in `run_command {command: "doc.lis
of the `document` outline `open_file` and `inspect_document` return) or the path of an open file.
The call acts on that file and the tab in front stays in front, unless the command itself opens or
switches to another file (`file.open`, `file.new`, `doc.switch`), as it would without `document`. A
file that isn't open is an error. `screenshot {window}` captures what the window shows, so it
file that isn't open is an error. `doc.list` gives an agent each open file's `id`, name and state, not`nwhere it is on disk (#1117). `screenshot {window}` captures what the window shows, so it
needs the file in front. Over the control channel, `engine.execute`, `document.inspect`,
`node.get` and `ui.render` take the same `document`; other methods refuse one naming a file behind
the tab in front. Headless sessions take it too.
+2 -2
View File
@@ -262,8 +262,8 @@ it can handle):
back), `codeConnect.import` with a `path` and `variable.import` with a file path instead of JSON,
`window.*` (new windows open files), `view.theme*`;
- live sessions and online files: `collab.*`;
- other documents and the version history: `doc.*` and `version.*` (except `doc.list` and
`version.list`);
- other documents and the version history: `doc.*` and `version.*` (except `doc.list`, which gives a
plugin the open files' names but not their paths, and `version.list`);
- the undo history and nesting: `edit.undo`, `edit.redo`, `edit.repeat`, `command.batch` (a run is
already one undo step), `plugin.*` / `plugins.*` (no plugin runs another) and `widget.*`;
- writing layer data whose key starts with `figcraft.widget` (a widget's own data: no plugin can
+7 -1
View File
File diff suppressed because one or more lines are too long
+12 -3
View File
@@ -209,6 +209,12 @@ which other files use without anyone copying the library file around.
**Library updates available**; accepting keeps instance overrides. While the server can't be
reached, the last version fetched stays in use. The file keeps the link, token included:
sharing that file shares read access to the library.
- **Only libraries you chose are checked by themselves.** The app asks only about team libraries
you added or checked on this computer (kept as hashes of their links in the preferences). A file
someone sent you that names another one contacts nothing when you open it: **Assets ▸ Libraries**
says it isn't checked yet, and **Check for updates** (`library.check`) asks its server and
checks it by itself from then on (#1097). Placing one of its components or applying one of its
styles asks the server too, since you did that yourself.
- **Usage.** Each time a file's app asks for the library it reports which of its components the
file uses and how often. **Show usage** in the library file (`library.usage`) lists the files and
the instances of each component. Files are told apart by their names; a file's report changes
@@ -291,7 +297,7 @@ are errors):
| `max_file_mb` | 64 (16 when open) | Largest file (its snapshot), in MiB; a welcome carries the whole file, so messages may be as large as a file and 1 MiB (at least 64 MiB on a private server). The app opens files of up to 1023 MiB; above 64 with `open_publish` the server warns |
| `max_links` | 100 | Most share links per file |
| `max_connections` | 512 | Most open connections |
| `max_people` | 256 | Most people in one file at once |
| `max_people` | 256 | Most people in one file at once (a value above 4096 is lowered to it, with a warning at startup: clients keep track of no more) |
| `compact_after` | 1000 | Compact a file's log into a new snapshot after this many entries… |
| `compact_mb` | 16 | …or once its log is this large (MiB) |
| `max_log_mb` | 64 | Most MiB of recent batches a file in use keeps in memory (and in its snapshot) for apps that reconnect; an app further behind catches up with the whole file. Open to the public, at most a quarter of `max_file_mb` too |
@@ -478,7 +484,10 @@ What someone can do, by what they hold (the server is reached through TLS at the
Limits that hold against everyone: message sizes by what a connection is for, file sizes as they
grow, connections in all and per address and block, connection, message, publish and download rates
per address and block, files and MiB per address and for everyone, the bytes queued for slow
clients and waiting for the server, and idle expiry ([Resource limits](#resource-limits)). Revoking
clients and waiting for the server, and idle expiry ([Resource limits](#resource-limits)). A
library or plugin larger than an address's download burst is still served, only from a full
budget that it then owes, so a large object comes more slowly but never lets an address past its
rate: over any stretch of time it gets at most the largest object plus the rate times that time. Revoking
a link or removing a file cuts every connection that used it, library connections included. Not
defended against: someone with many unrelated addresses (each gets its own limits; the global caps
still hold, so they can fill the server up to them until you block them or the files expire), a
@@ -508,7 +517,7 @@ are larger.
| Bytes being read | Messages not yet whole: the largest message and 1 MiB per block, 192 MiB for everyone; past everyone's budget the readers holding the most are cut off until it fits (their bytes count as free at once), and a reader is refused only when it is the biggest |
| Bytes waiting for the server | 128 MiB of read messages not handled yet: past it a connection is dropped as busy |
| Connections | `max_connections` (512) in all (*always*); `max_connections_per_ip` (32) per address, ×2 per IPv6 /56 (at most a sixteenth of all), ×4 per /48 (at most an eighth of all), joins included, so no block can take every connection; one-shot requests (asking about hosting, libraries, browsing plugins) can't take the last eighth, which stays for people joining files, and are dropped after 20 idle seconds |
| Rates | Per address and block: `connects_per_minute`, `publishes_per_hour` (and 32 addresses' worth for everyone), `messages_per_second` (cursors, selections, cursor chat and pings have their own budget beside it, twice that, so an office moving its cursors together doesn't trip it); reports, ten an hour (*always*); requests that change files (join codes, links, renames, removals, blocks, publishing with the key), sixty a minute, with the server key or an owner token too ("too many changes to this server's files from your address…"; listing and looking aren't counted) (*always*); downloads of libraries and plugins, 1 MiB a second with bursts of 16 MiB per address (*always*), checked before anything is read, within 32 MiB a second (512 MiB at once) for libraries and for plugins, everyone together |
| Rates | Per address and block: `connects_per_minute`, `publishes_per_hour` (and 32 addresses' worth for everyone), `messages_per_second` (cursors, selections, cursor chat and pings have their own budget beside it, twice that, so an office moving its cursors together doesn't trip it); reports, ten an hour (*always*); requests that change files (join codes, links, renames, removals, blocks, publishing with the key), sixty a minute, with the server key or an owner token too ("too many changes to this server's files from your address…"; listing and looking aren't counted) (*always*); downloads of libraries and plugins, 1 MiB a second with bursts of 16 MiB per address (*always*; a larger one is served once the address's budget is full, which it then pays back at that rate before its next download), checked before anything is read, within 32 MiB a second (512 MiB at once) for libraries and for plugins, everyone together |
| Team libraries | Only files published with the key hold one on a public server; an archive is at most `max_file_mb` and everything it unpacks to counts against the same cap (*always*), so a zip bomb is refused before it expands |
| Usage reports | Kept in memory, written at most once a minute per library and when the server stops; at most 1,000 components per file and 200 files per library (*always*) |
| Disk writes | A batch is confirmed only once it is on disk; when its log can't be written, nobody hears it was accepted: the file closes, everyone in it reconnects and their apps send again what wasn't confirmed. Below 1 GiB (or two of the largest files) free, changes and new files are refused with "the server's disk is nearly full" instead (*always*, where the free space can be told: Unix). The server keeps the last 200 problems for the operator |