mirror of
https://github.com/storytold/figcraft.git
synced 2026-11-07 12:13:14 +00:00
Merge remote-tracking branch 'upstream/main' into fix/layers-component-contrast
# Conflicts: # crates/ui-egui/src/panels/layers.rs # docs/ui-parity.md
This commit is contained in:
@@ -319,9 +319,10 @@ fn a_dropped_connection_keeps_editing_and_catches_up() {
|
||||
}
|
||||
|
||||
impl Room {
|
||||
/// Person `i` has been told to join again: the server takes them as someone new.
|
||||
fn welcome_anew(&mut self, i: usize) {
|
||||
let ClientMsg::Hello(h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") };
|
||||
/// Person `i` has been told to join again: the server takes them as `resume` says.
|
||||
fn welcome_as(&mut self, i: usize, resume: Option<figcraft_collab::Resume>) {
|
||||
let ClientMsg::Hello(mut h) = Client::hello("f", "t", "Person", "") else { panic!("not a hello") };
|
||||
h.resume = resume;
|
||||
let (id, out) = self.server.join(h).unwrap();
|
||||
let w = welcome_of(&out, id);
|
||||
let p = &mut self.people[i];
|
||||
@@ -351,7 +352,7 @@ fn reconnecting_can_be_told_to_try_now_and_shows_as_disconnected() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() {
|
||||
fn a_session_that_cant_go_on_stays_on_its_tab_and_merges_when_it_joins_again() {
|
||||
let mut room = Room::new(base(), 2, 7);
|
||||
let (f, r) = frame_and_rect(room.server.document());
|
||||
let uid = room.s(0).ds().unwrap().uid;
|
||||
@@ -363,7 +364,8 @@ fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() {
|
||||
let live = room.s(0).live();
|
||||
assert!(live.disconnected() && live.rejoinable() && live.doc_uid == Some(uid));
|
||||
assert!(room.s(0).live_read_only(uid).is_none());
|
||||
room.s(0).execute("node.set", &json!({"id": f, "props": {"name": "Offline too"}})).unwrap();
|
||||
let made = room.s(0).execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
||||
let made = NodeId(made["id"].as_u64().unwrap());
|
||||
// A try that fails leaves it just as it was.
|
||||
assert_eq!(room.s(0).execute("collab.reconnect", &json!({})).unwrap()["status"], "connecting");
|
||||
assert!(room.s(0).live().disconnected() && !room.s(0).live().rejoinable());
|
||||
@@ -371,23 +373,85 @@ fn a_session_that_cant_go_on_stays_on_its_tab_and_joins_again_there() {
|
||||
room.event(0, Event::Status(Status::Failed { reason: "Couldn't join the live session: no answer".into() }));
|
||||
assert!(room.s(0).live().rejoinable());
|
||||
// Someone else's edit lands meanwhile.
|
||||
room.s(1).execute("transform.move", &json!({"ids": [r], "dx": 5, "dy": 5})).unwrap();
|
||||
room.s(1).execute("transform.move", &json!({"ids": [f], "dx": 5, "dy": 5})).unwrap();
|
||||
let theirs = |d: &Document| d.node(f).unwrap().transform;
|
||||
room.settle();
|
||||
// The next try joins in the same tab, and what was done offline opens beside it.
|
||||
let moved = theirs(room.server.document());
|
||||
// The next try joins in the same tab, as the same client, and everything goes on top.
|
||||
room.s(0).execute("collab.reconnect", &json!({})).unwrap();
|
||||
room.welcome_anew(0);
|
||||
let resumed = room.people[0].script.lock().unwrap().resumed.expect("as who you were");
|
||||
assert_eq!((resumed.client_id, resumed.seq), (room.people[0].id, u64::MAX));
|
||||
room.welcome_as(0, Some(resumed));
|
||||
room.settle();
|
||||
room.check();
|
||||
let s = room.s(0);
|
||||
assert_eq!(s.live().status, Status::Live);
|
||||
assert!(!s.live().disconnected());
|
||||
assert_eq!(s.docs.len(), tabs + 1, "the copy's is the only new tab");
|
||||
let (tab, copy) = (s.docs.iter().find(|d| d.uid == uid).unwrap(), s.docs.last().unwrap());
|
||||
assert_eq!(s.live().doc_uid, Some(uid));
|
||||
assert_ne!(tab.doc.node(r).unwrap().name, "Offline", "the session's tab shows the server's version");
|
||||
assert_eq!(copy.doc.name, format!("{} (offline copy)", tab.doc.name));
|
||||
assert_eq!(copy.doc.node(r).unwrap().name, "Offline");
|
||||
assert_eq!(copy.doc.node(f).unwrap().name, "Offline too");
|
||||
assert!(copy.is_dirty(), "closing the copy asks first");
|
||||
assert!(s.message.as_deref().is_some_and(|m| m.contains("offline copy")));
|
||||
assert_eq!(s.docs.len(), tabs, "no copy: it all went in");
|
||||
let doc = room.server.document();
|
||||
assert_eq!(doc.node(r).unwrap().name, "Offline");
|
||||
assert!(doc.contains(made), "layers you made too");
|
||||
assert_eq!(theirs(doc), moved, "and the other edit is still there");
|
||||
assert!(room.s(0).message.as_deref().is_some_and(|m| m.contains("is in the file")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_same_thing_changed_both_ways_keeps_the_others_and_opens_your_version_beside_it() {
|
||||
let mut room = Room::new(base(), 2, 7);
|
||||
let (f, r) = frame_and_rect(room.server.document());
|
||||
let tabs = room.s(0).docs.len();
|
||||
room.disconnect(0);
|
||||
room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Mine"}})).unwrap();
|
||||
room.s(0).execute("node.set", &json!({"id": f, "props": {"name": "Only mine"}})).unwrap();
|
||||
room.event(0, Event::Status(Status::Failed { reason: "gone".into() }));
|
||||
room.s(1).execute("node.set", &json!({"id": r, "props": {"name": "Theirs"}})).unwrap();
|
||||
room.settle();
|
||||
room.s(0).execute("collab.reconnect", &json!({})).unwrap();
|
||||
let resumed = room.people[0].script.lock().unwrap().resumed;
|
||||
room.welcome_as(0, resumed);
|
||||
room.settle();
|
||||
room.check();
|
||||
let doc = room.server.document();
|
||||
assert_eq!(doc.node(r).unwrap().name, "Theirs", "what conflicts isn't written over");
|
||||
assert_eq!(doc.node(f).unwrap().name, "Only mine", "what doesn't goes in");
|
||||
let s = room.s(0);
|
||||
assert_eq!(s.docs.len(), tabs + 1);
|
||||
let copy = s.docs.last().unwrap();
|
||||
assert!(copy.doc.name.ends_with("(offline copy)") && copy.is_dirty(), "closing the copy asks first");
|
||||
assert_eq!(copy.doc.node(r).unwrap().name, "Mine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layers_the_new_client_cant_make_open_beside_the_file() {
|
||||
let mut room = Room::new(base(), 1, 7);
|
||||
let tabs = room.s(0).docs.len();
|
||||
room.disconnect(0);
|
||||
let made = room.s(0).execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
|
||||
let made = NodeId(made["id"].as_u64().unwrap());
|
||||
room.event(0, Event::Status(Status::Failed { reason: "gone".into() }));
|
||||
room.s(0).execute("collab.reconnect", &json!({})).unwrap();
|
||||
// The server takes you as someone new.
|
||||
room.welcome_as(0, None);
|
||||
room.settle();
|
||||
room.check();
|
||||
assert!(!room.server.document().contains(made));
|
||||
let s = room.s(0);
|
||||
assert_eq!(s.docs.len(), tabs + 1);
|
||||
assert!(s.docs.last().unwrap().doc.contains(made), "it isn't lost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_forgot_you_keeps_your_offline_edits() {
|
||||
let mut room = Room::new(base(), 1, 7);
|
||||
let (_, r) = frame_and_rect(room.server.document());
|
||||
room.disconnect(0);
|
||||
room.s(0).execute("node.set", &json!({"id": r, "props": {"name": "Offline"}})).unwrap();
|
||||
// The same file under a new session: it knows no one.
|
||||
room.server = Server::new(room.server.document().clone(), "t", Limits::default());
|
||||
room.welcome_as(0, None);
|
||||
room.settle();
|
||||
assert_eq!(room.server.document().node(r).unwrap().name, "Offline");
|
||||
assert_eq!(room.s(0).live().status, Status::Live);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -399,7 +463,8 @@ fn joining_again_with_nothing_changed_opens_no_copy() {
|
||||
assert!(room.s(0).live().rejoinable(), "the end of a session you were in shows on its tab too");
|
||||
assert_eq!(room.s(0).message.as_deref(), Some("Sharing was stopped"));
|
||||
room.s(0).execute("collab.reconnect", &json!({})).unwrap();
|
||||
room.welcome_anew(0);
|
||||
let resumed = room.people[0].script.lock().unwrap().resumed;
|
||||
room.welcome_as(0, resumed);
|
||||
assert_eq!(room.s(0).docs.len(), tabs);
|
||||
assert_eq!(room.s(0).live().status, Status::Live);
|
||||
}
|
||||
@@ -414,7 +479,8 @@ fn a_session_out_of_step_joins_again_by_itself() {
|
||||
assert_eq!(room.s(0).live().status, Status::Connecting);
|
||||
assert!(room.s(0).live().disconnected());
|
||||
assert_eq!(room.people[0].script.lock().unwrap().joined.len(), joins + 1);
|
||||
room.welcome_anew(0);
|
||||
let resumed = room.people[0].script.lock().unwrap().resumed;
|
||||
room.welcome_as(0, resumed);
|
||||
assert_eq!(room.s(0).live().status, Status::Live);
|
||||
assert_eq!(room.s(0).docs.len(), tabs);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::{Condvar, Mutex, PoisonError};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
// The browser build has no `std` clock: `std::time::SystemTime::now()` panics on
|
||||
// wasm32-unknown-unknown ("time not implemented on this platform"), and every `comment.*` command
|
||||
// stamps an event. `web_time` is `std::time` on native targets and the JavaScript clock on wasm.
|
||||
use web_time::{Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Most events kept; the oldest are dropped.
|
||||
pub const MAX_EVENTS: usize = 1024;
|
||||
|
||||
@@ -44,6 +44,19 @@ fn events_keep_sequence_and_filter_topics() {
|
||||
assert_eq!(r.events[0].kind, "document.changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_are_stamped_with_the_wall_clock() {
|
||||
// The stamp is read through `web_time`, the clock the browser build has as well: with
|
||||
// `std::time::SystemTime` every comment posted on the web panicked (#1764).
|
||||
let log = EventLog::default();
|
||||
log.push("comment.added", "Doc", None, json!({"commentId": 1}));
|
||||
let r = log.poll(0, &comments(), 10, Duration::ZERO);
|
||||
let now = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).unwrap().as_secs_f64();
|
||||
let time = r.events[0].time;
|
||||
// Within a minute of the same clock read afterwards (not ordered: the wall clock can step).
|
||||
assert!(time > 1_700_000_000.0 && (time - now).abs() < 60.0, "{time} is seconds since the Unix epoch, read next to {now}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_poll_wakes_for_a_push() {
|
||||
let log = Arc::new(EventLog::default());
|
||||
|
||||
@@ -18,6 +18,8 @@ pub(crate) struct Script {
|
||||
pub(crate) sent: Vec<Presence>,
|
||||
pub(crate) hosted: Vec<HostOptions>,
|
||||
pub(crate) joined: Vec<String>,
|
||||
/// What the last join asked to resume as.
|
||||
pub(crate) resumed: Option<crate::live::Resume>,
|
||||
/// The sessions told to reconnect now, in order.
|
||||
pub(crate) retried: Vec<u64>,
|
||||
pub(crate) left: usize,
|
||||
@@ -48,6 +50,11 @@ impl LiveLink for Mock {
|
||||
i.url = format!("ws://{}/?file=f&token=t", i.address);
|
||||
Ok(i)
|
||||
}
|
||||
fn join_resuming(&mut self, session: u64, link: &str, name: &str, resume: Option<crate::live::Resume>) -> Result<(), String> {
|
||||
self.0.lock().unwrap().resumed = resume;
|
||||
self.join(session, link, name)
|
||||
}
|
||||
|
||||
fn retry(&mut self, session: u64) {
|
||||
self.0.lock().unwrap().retried.push(session);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,8 @@ struct Stranded {
|
||||
link: String,
|
||||
/// The server's state when it stopped: what's different from it in the tab is your own work.
|
||||
confirmed: Box<figcraft_collab::Replica>,
|
||||
/// Who you were: the layers you made are in that client's id space.
|
||||
resume: Resume,
|
||||
}
|
||||
|
||||
impl Live {
|
||||
@@ -182,17 +184,19 @@ impl Live {
|
||||
*self = Live { id: self.id, tab: self.tab, ..Live::default() };
|
||||
}
|
||||
|
||||
/// Keep this session on its tab for joining again (see [`Stranded`]): a session you joined, or
|
||||
/// one that already is; any other goes back to no session.
|
||||
fn strand(&mut self) {
|
||||
/// Remember what joining this session again takes, if it is one you joined (see [`Stranded`]).
|
||||
fn hold(&mut self) {
|
||||
let joined = self.info.as_ref().filter(|i| i.role == Role::Guest && !i.link.is_empty());
|
||||
let held = self.stranded.take().or_else(|| {
|
||||
let (client, info) = (self.client.as_ref()?, joined?);
|
||||
Some(Stranded { link: info.link.clone(), confirmed: Box::new(client.confirmed().clone()) })
|
||||
});
|
||||
if held.is_none() {
|
||||
return self.reset();
|
||||
if let (None, Some(client), Some(info)) = (&self.stranded, &self.client, joined) {
|
||||
self.stranded = Some(Stranded { link: info.link.clone(), confirmed: Box::new(client.confirmed().clone()), resume: client.resume() });
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep this session on its tab for joining again: a session you joined, or one that already
|
||||
/// is; any other goes back to no session.
|
||||
fn strand(&mut self) {
|
||||
self.hold();
|
||||
let Some(held) = self.stranded.take() else { return self.reset() };
|
||||
*self = Live {
|
||||
id: self.id,
|
||||
tab: self.tab,
|
||||
@@ -201,7 +205,7 @@ impl Live {
|
||||
server: std::mem::take(&mut self.server),
|
||||
published: self.published.take(),
|
||||
share: self.share.take(),
|
||||
stranded: held,
|
||||
stranded: Some(held),
|
||||
..Live::default()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
use super::host::{clean_hosting, clean_shared};
|
||||
use figcraft_collab::Change;
|
||||
use figcraft_collab::merge::{self, Side};
|
||||
|
||||
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,
|
||||
Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, Resume, ServerMsg, Session,
|
||||
Signal, ToolId, display_name, editing_hint, editing_of, limited_by, sanitize, tool_left,
|
||||
};
|
||||
|
||||
/// What a live session's document holding components that contain each other's instances shows
|
||||
@@ -371,11 +374,12 @@ impl Session {
|
||||
/// Session `id`'s welcome: start the sync client; the host keeps editing the document it
|
||||
/// shared, a guest opens the host's file as a new tab (or gets it again in that tab after a
|
||||
/// reconnect).
|
||||
fn live_welcome(&mut self, id: SessionId, welcome: ServerMsg) {
|
||||
let client = match Client::join(welcome) {
|
||||
pub(super) fn live_welcome(&mut self, id: SessionId, welcome: ServerMsg) {
|
||||
let mut client = match Client::join(welcome) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return self.live_fail(id, format!("Couldn't open the shared file: {e}")),
|
||||
};
|
||||
let mut merged = None;
|
||||
let Some(live) = self.live.sessions.get(&id) else { return };
|
||||
let existing = live.doc_uid.filter(|u| self.docs.iter().any(|d| d.uid == *u));
|
||||
let (uid, synced) = match (live.role(), existing) {
|
||||
@@ -396,7 +400,7 @@ impl Session {
|
||||
}
|
||||
(Some(Role::Guest), Some(uid)) => {
|
||||
// Joining again in this tab, or the file you published arriving from the server.
|
||||
self.live_keep_offline_work(id, uid);
|
||||
merged = self.live_merge_offline_work(id, uid, &mut client);
|
||||
let doc = Arc::new(client.document().clone());
|
||||
let was_dirty = self.docs.iter().find(|d| d.uid == uid).is_some_and(DocState::is_dirty);
|
||||
self.apply_remote(uid, doc.clone());
|
||||
@@ -444,31 +448,50 @@ impl Session {
|
||||
live.client = Some(client);
|
||||
live.deferred.clear();
|
||||
live.stranded = None;
|
||||
self.live.send(id, merged);
|
||||
}
|
||||
|
||||
/// Session `id` is back on document `uid` after it couldn't go on, and the server's version is
|
||||
/// about to replace the tab's: what you did since opens beside it, unsaved, so nothing is lost.
|
||||
fn live_keep_offline_work(&mut self, id: SessionId, uid: u64) {
|
||||
let Some(held) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()) else { return };
|
||||
let Some(local) = self.docs.iter().find(|d| d.uid == uid).map(|d| d.doc.clone()) else { return };
|
||||
/// about to replace the tab's: what you did since goes on top of it as one edit of `client`,
|
||||
/// returned to send, the way two people's edits merge. What can't (the same thing changed both
|
||||
/// ways, or layers `client` can't make) leaves the server's as it is, and your whole version opens
|
||||
/// beside it, unsaved, so nothing is lost.
|
||||
fn live_merge_offline_work(&mut self, id: SessionId, uid: u64, client: &mut Client) -> Option<ClientMsg> {
|
||||
let held = self.live.sessions.get(&id)?.stranded.as_ref()?;
|
||||
let local = self.docs.iter().find(|d| d.uid == uid)?.doc.clone();
|
||||
let name = local.name.clone();
|
||||
// A document that can't be compared is kept too.
|
||||
if figcraft_collab::diff(&held.confirmed, &local).is_ok_and(|d| d.changes.is_empty()) {
|
||||
let plan = merge::plan(held.confirmed.document(), &local, client.document()).ok();
|
||||
let mine = plan.as_ref().map(|p| p.changes(Side::Branch));
|
||||
if mine.is_some_and(<[Change]>::is_empty) {
|
||||
self.message = Some(format!("Reconnected to {name}"));
|
||||
return;
|
||||
return None;
|
||||
}
|
||||
let me = u64::from(client.id());
|
||||
let own = mine.is_some_and(|m| m.iter().all(|c| !matches!(c, Change::Create { id, .. } if id.0 >> 32 != me)));
|
||||
let merged = plan.as_ref().filter(|_| own).and_then(|p| p.merge(client.document(), Side::Branch, &|_| Some(Side::Main)).ok());
|
||||
let sent = merged.and_then(|(mut doc, touched)| {
|
||||
doc.mark_dirty(touched);
|
||||
client.edit_with(&doc, Some("Changes made while disconnected")).ok()
|
||||
});
|
||||
if sent.is_some() && plan.is_some_and(|p| p.conflicts.is_empty()) {
|
||||
self.message = Some(format!("Reconnected to {name}. What you changed while disconnected is in the file"));
|
||||
return sent.flatten();
|
||||
}
|
||||
let mut copy = DocState::new((*local).clone(), None);
|
||||
let copy_name = format!("{name} (offline copy)");
|
||||
Arc::make_mut(&mut copy.doc).name.clone_from(©_name);
|
||||
self.docs.push(copy);
|
||||
self.message = Some(format!("Reconnected to {name}. What you changed while disconnected is open beside it as “{copy_name}”"));
|
||||
self.message = Some(format!(
|
||||
"Reconnected to {name}. Some of what you changed while disconnected couldn't go in: your whole version is open beside it as “{copy_name}”"
|
||||
));
|
||||
sent.flatten()
|
||||
}
|
||||
|
||||
/// Join session `id` again in its tab (see [`Live::strand`]), as someone new: a fresh join needs
|
||||
/// nothing from the connection that was lost.
|
||||
/// Join session `id` again in its tab (see [`Live::strand`]): nothing is needed from the
|
||||
/// connection that was lost.
|
||||
pub(super) fn live_rejoin(&mut self, cmd: &str, id: SessionId) -> Result<()> {
|
||||
let name = display_name(&self.prefs);
|
||||
let Some(link) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()).map(|s| s.link.clone()) else {
|
||||
let Some((link, resume)) = self.live.sessions.get(&id).and_then(|l| l.stranded.as_ref()).map(|s| (s.link.clone(), s.resume)) else {
|
||||
return Err(crate::bad(cmd, "this file has no live session to join again"));
|
||||
};
|
||||
self.live.leave(id);
|
||||
@@ -476,7 +499,9 @@ impl Session {
|
||||
return Err(EngineError::Disabled(cmd.to_string(), why));
|
||||
}
|
||||
let Some(net) = self.live.link() else { return Err(super::unavailable(cmd)) };
|
||||
net.join_resuming(id, &link, &name, None).map_err(|e| crate::bad(cmd, e))?;
|
||||
// As the client you were when the server still knows it, so the layers you made are yours to
|
||||
// send; the whole document is asked for (no change is "after" this number).
|
||||
net.join_resuming(id, &link, &name, Some(Resume { seq: u64::MAX, ..resume })).map_err(|e| crate::bad(cmd, e))?;
|
||||
if let Some(live) = self.live.sessions.get_mut(&id) {
|
||||
live.status = Status::Connecting;
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
spec!(
|
||||
"collab.reconnect",
|
||||
"Reconnect live session",
|
||||
"{} → {status}: for the open file's session when it is disconnected: a reconnect that is waiting tries now, and a session that couldn't go on (`failed`, the file open in its tab) is joined again there, what you changed since opening beside it. Refused when nothing was lost",
|
||||
"{} → {status}: for the open file's session when it is disconnected: a reconnect that is waiting tries now, and a session that couldn't go on (`failed`, the file open in its tab) is joined again there, what you changed since going on top (your whole version opening beside it when some of it can't). Refused when nothing was lost",
|
||||
false,
|
||||
reconnect
|
||||
),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::host::hosting_json;
|
||||
use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status};
|
||||
use super::{
|
||||
Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code,
|
||||
editing_of, json,
|
||||
Access, Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, ServerMsg, Session, Value, ViewState, bad, busy,
|
||||
close, code, editing_of, json,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
@@ -115,7 +115,23 @@ impl Session {
|
||||
let Some(live) = self.live.sessions.get_mut(&id) else { return };
|
||||
let Some(client) = live.client.as_mut() else { return };
|
||||
let mut out = Vec::new();
|
||||
for m in std::mem::take(&mut live.deferred) {
|
||||
let mut waiting = std::mem::take(&mut live.deferred).into_iter();
|
||||
while let Some(m) = waiting.next() {
|
||||
// The server no longer knows this client (it restarted without its saved state, say):
|
||||
// it welcomed you as someone new. What you did goes on top like after any rejoin.
|
||||
if let ServerMsg::Welcome { session, client_id, .. } = &m {
|
||||
let me = client.resume();
|
||||
if (*session, *client_id) != (me.session, me.client_id) {
|
||||
live.hold();
|
||||
live.client = None;
|
||||
let rest: Vec<_> = waiting.collect();
|
||||
self.live_welcome(id, m);
|
||||
if let Some(live) = self.live.sessions.get_mut(&id) {
|
||||
live.deferred = rest;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
match client.receive(m, &EngineRecompute) {
|
||||
Ok(more) => out.extend(more),
|
||||
Err(e) => {
|
||||
|
||||
@@ -48,6 +48,7 @@ const MAX_ASSET_PART: usize = 256;
|
||||
/// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]).
|
||||
pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec<String>, Vec<Option<VectorNetwork>>) {
|
||||
let mut m = Mapper::new(msg, max_nodes);
|
||||
let _canon = VarCanon::install(&m);
|
||||
let doc = m.document();
|
||||
// Large values are moved in, never passed through `json!`, which serialises its arguments:
|
||||
// a deep copy of the whole tree, briefly holding it twice.
|
||||
@@ -365,6 +366,31 @@ impl<'a> Mapper<'a> {
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// The collection a `VariableSetID` names, as one collection per library asset: a file that
|
||||
/// took several updates of a library holds a copy of the collection for each version it met,
|
||||
/// all with the same asset `key`. Variables and explicit modes may name any of those copies,
|
||||
/// while the mode ids stay the same across them, so every copy maps to the newest one (the
|
||||
/// one [`Self::variables`] keeps, with the modes of the others added). Without this, a page set
|
||||
/// to a mode an older copy didn't have yet lost its mode, and the layers fell back to the
|
||||
/// default mode of the newest copy. A reference by asset that names no copy by itself (no
|
||||
/// version, or a version the file doesn't hold, while it holds several) still names the asset:
|
||||
/// its newest live copy.
|
||||
pub(super) fn collection_id(&self, v: &Kv) -> Option<String> {
|
||||
let by_asset = || v.get("assetRef")?.get("key").and_then(Kv::str).and_then(VarCanon::newest_set);
|
||||
match self.resource_id(v) {
|
||||
Some(id) if self.nodes.contains_key(&id) => Some(self.canonical_collection(&id).unwrap_or(id)),
|
||||
// The asset index can name a node a later change removed: the asset's live copy instead.
|
||||
Some(id) => by_asset().or(Some(id)),
|
||||
None => by_asset(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest live copy of collection node `id`'s library asset, when it has a key.
|
||||
pub(super) fn canonical_collection(&self, id: &str) -> Option<String> {
|
||||
let key = self.nodes.get(id)?.get("key").and_then(Kv::str).filter(|k| !k.is_empty())?;
|
||||
VarCanon::newest_set(key)
|
||||
}
|
||||
|
||||
/// A note about what this file's mapping left out or changed. Counted, as the REST importer
|
||||
/// counts its own: these notes are met once per layer, so the kinds were named once each with
|
||||
/// no sign of how much of the file they covered -- a file whose every text layer lost a
|
||||
@@ -384,3 +410,121 @@ impl<'a> Mapper<'a> {
|
||||
self.max_nodes.saturating_sub(self.reserved)
|
||||
}
|
||||
}
|
||||
|
||||
/// A library version (`"40727:1183"`) as numbers to order by; unparsable parts rank lowest.
|
||||
fn version_rank(v: &str) -> (u64, u64) {
|
||||
let mut parts = v.split(':').map(|p| p.trim().parse::<u64>().unwrap_or(0));
|
||||
(parts.next().unwrap_or(0), parts.next().unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Library variables, one per asset. A file that took several updates of a library keeps a copy
|
||||
/// of a variable for each version it met (same `key`, another `version`), and layers and other
|
||||
/// variables name them by that asset (`assetRef {key, version}`) rather than by guid. Each
|
||||
/// reference goes to the newest copy: it has the values of every mode the library has now, where
|
||||
/// an older one lacks the modes added since (a layer under a page set to such a mode fell back to
|
||||
/// the default one). Kept per thread while a file is mapped: the paint and variable readers that
|
||||
/// meet references are free functions.
|
||||
#[derive(Default)]
|
||||
pub(super) struct VarCanon {
|
||||
by_key: HashMap<String, String>,
|
||||
by_id: HashMap<String, String>,
|
||||
/// Every live variable node of the file, with a key or not.
|
||||
local: BTreeSet<String>,
|
||||
/// The newest live copy of each library collection, by asset key. Read from the file's live
|
||||
/// nodes (the last change of each guid, REMOVED ones gone), not from the asset index, which
|
||||
/// keeps the first node it met for a key and version even when a later change removed it.
|
||||
sets_by_key: HashMap<String, String>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static VAR_CANON: std::cell::RefCell<Option<VarCanon>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Removes the thread's table when the mapping is done.
|
||||
pub(super) struct VarCanonGuard;
|
||||
|
||||
impl Drop for VarCanonGuard {
|
||||
/// Clears the thread's table, so a later file on this thread starts without it.
|
||||
fn drop(&mut self) {
|
||||
VAR_CANON.with(|c| c.borrow_mut().take());
|
||||
}
|
||||
}
|
||||
|
||||
impl VarCanon {
|
||||
/// Builds the table from mapper `m`'s live nodes (newest copy per asset key, for variables and
|
||||
/// for collections) and installs it for this thread until the returned guard is dropped.
|
||||
fn install(m: &Mapper) -> VarCanonGuard {
|
||||
let mut newest: HashMap<String, ((u64, u64), String)> = HashMap::new();
|
||||
let mut copies: Vec<(String, String)> = Vec::new();
|
||||
let mut local = BTreeSet::new();
|
||||
let mut sets: HashMap<String, ((u64, u64), String)> = HashMap::new();
|
||||
for (id, n) in &m.nodes {
|
||||
let ty = n.get("type").and_then(Kv::str);
|
||||
if !matches!(ty, Some("VARIABLE" | "VARIABLE_SET")) || n.get("isSoftDeleted").and_then(Kv::bool) == Some(true) {
|
||||
continue;
|
||||
}
|
||||
if ty == Some("VARIABLE_SET") {
|
||||
if let Some(key) = n.get("key").and_then(Kv::str).filter(|k| !k.is_empty()) {
|
||||
let rank = version_rank(n.get("version").and_then(Kv::str).unwrap_or(""));
|
||||
let e = sets.entry(key.to_string()).or_insert_with(|| (rank, id.clone()));
|
||||
if rank > e.0 || (rank == e.0 && *id > e.1) {
|
||||
*e = (rank, id.clone());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
local.insert(id.clone());
|
||||
let Some(key) = n.get("key").and_then(Kv::str).filter(|k| !k.is_empty()) else { continue };
|
||||
let rank = version_rank(n.get("version").and_then(Kv::str).unwrap_or(""));
|
||||
copies.push((id.clone(), key.to_string()));
|
||||
let e = newest.entry(key.to_string()).or_insert_with(|| (rank, id.clone()));
|
||||
if rank > e.0 || (rank == e.0 && *id > e.1) {
|
||||
*e = (rank, id.clone());
|
||||
}
|
||||
}
|
||||
let mut t = VarCanon::default();
|
||||
for (id, key) in copies {
|
||||
if let Some((_, canon)) = newest.get(&key) {
|
||||
t.by_id.insert(id, canon.clone());
|
||||
}
|
||||
}
|
||||
t.by_key = newest.into_iter().map(|(k, (_, id))| (k, id)).collect();
|
||||
t.local = local;
|
||||
t.sets_by_key = sets.into_iter().map(|(k, (_, id))| (k, id)).collect();
|
||||
VAR_CANON.with(|c| *c.borrow_mut() = Some(t));
|
||||
VarCanonGuard
|
||||
}
|
||||
|
||||
/// The variable a reference (`{guid}` or `{assetRef}`) names, as its newest copy. A guid
|
||||
/// counts when this file holds a variable by it; one from another file (a paste carries the
|
||||
/// library's own guids) names nothing here, so the asset it also names is tried before it is
|
||||
/// kept as it is.
|
||||
pub(super) fn resolve(r: &Kv) -> Option<String> {
|
||||
let by_guid = r.get("guid").and_then(guid).filter(|g| g != NO_STYLE);
|
||||
VAR_CANON.with(|c| {
|
||||
let c = c.borrow();
|
||||
let Some(t) = c.as_ref() else { return by_guid.clone() };
|
||||
if let Some(g) = by_guid.as_ref().filter(|g| t.local.contains(*g)) {
|
||||
return Some(t.by_id.get(g).cloned().unwrap_or_else(|| g.clone()));
|
||||
}
|
||||
let by_asset = r.get("assetRef").and_then(|a| a.get("key")).and_then(Kv::str).and_then(|k| t.by_key.get(k)).cloned();
|
||||
by_asset.or(by_guid)
|
||||
})
|
||||
}
|
||||
|
||||
/// The newest live copy of the library collection with asset key `key`.
|
||||
pub(super) fn newest_set(key: &str) -> Option<String> {
|
||||
VAR_CANON.with(|c| c.borrow().as_ref().and_then(|t| t.sets_by_key.get(key).cloned()))
|
||||
}
|
||||
|
||||
/// Whether a table is installed on this thread (for tests: none outlives the mapping).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn installed() -> bool {
|
||||
VAR_CANON.with(|c| c.borrow().is_some())
|
||||
}
|
||||
|
||||
/// Whether variable node `id` is an older copy another one replaces.
|
||||
pub(super) fn superseded(id: &str) -> bool {
|
||||
VAR_CANON.with(|c| c.borrow().as_ref().and_then(|t| t.by_id.get(id)).is_some_and(|canon| canon != id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,28 +259,37 @@ impl<'a> Mapper<'a> {
|
||||
};
|
||||
let mut order = Vec::new();
|
||||
let mut collections = Obj::new();
|
||||
// Copies of one library collection (one per version the file met) become one: the newest
|
||||
// keeps its modes in its order, the others add the modes it no longer has.
|
||||
let mut extra: BTreeMap<String, Vec<(String, Value)>> = BTreeMap::new();
|
||||
for (id, n) in of_type("VARIABLE_SET", "parentIndex") {
|
||||
order.push(json!(id));
|
||||
let mut modes: Vec<(&str, Value)> = n
|
||||
.get("variableSetModes")
|
||||
.map(Kv::list)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let mode = json!({"modeId": m.get("id").and_then(guid)?, "name": m.get("name").and_then(Kv::str).unwrap_or("Mode")});
|
||||
Some((m.get("sortPosition").and_then(Kv::str).unwrap_or(""), mode))
|
||||
})
|
||||
.collect();
|
||||
modes.sort_by(|a, b| a.0.cmp(b.0));
|
||||
let default = modes.first().map_or(Value::Null, |m| m.1["modeId"].clone());
|
||||
let name = n.get("name").and_then(Kv::str).unwrap_or("Collection");
|
||||
let modes: Vec<Value> = modes.into_iter().map(|m| m.1).collect();
|
||||
collections.insert(id.clone(), json!({"id": id, "name": name, "modes": modes, "defaultModeId": default}));
|
||||
let modes = sorted_modes(n);
|
||||
match self.canonical_collection(id).filter(|c| c != id) {
|
||||
Some(canonical) => extra.entry(canonical).or_default().extend(modes),
|
||||
None => {
|
||||
order.push(json!(id));
|
||||
let default = modes.first().map_or(Value::Null, |m| m.1["modeId"].clone());
|
||||
let name = n.get("name").and_then(Kv::str).unwrap_or("Collection");
|
||||
let modes: Vec<Value> = modes.into_iter().map(|m| m.1).collect();
|
||||
collections.insert(id.clone(), json!({"id": id, "name": name, "modes": modes, "defaultModeId": default}));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (canonical, more) in extra {
|
||||
let Some(modes) = collections.get_mut(&canonical).and_then(|c| c.get_mut("modes")).and_then(Value::as_array_mut) else { continue };
|
||||
for (_, m) in more {
|
||||
if !modes.iter().any(|x| x["modeId"] == m["modeId"]) {
|
||||
modes.push(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut variable_order = Vec::new();
|
||||
let mut variables = Obj::new();
|
||||
for (id, n) in of_type("VARIABLE", "sortPosition") {
|
||||
let Some(set) = n.get("variableSetID").and_then(|s| self.resource_id(s)).filter(|s| collections.contains_key(s)) else { continue };
|
||||
if super::VarCanon::superseded(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(set) = n.get("variableSetID").and_then(|s| self.collection_id(s)).filter(|s| collections.contains_key(s)) else { continue };
|
||||
let ty = n.get("variableResolvedType").and_then(Kv::str).unwrap_or("");
|
||||
if !matches!(ty, "BOOLEAN" | "FLOAT" | "STRING" | "COLOR") {
|
||||
continue;
|
||||
@@ -306,3 +315,19 @@ impl<'a> Mapper<'a> {
|
||||
.then(|| json!({"variableCollections": collections, "variables": variables, "collectionOrder": order, "variableOrder": variable_order}))
|
||||
}
|
||||
}
|
||||
|
||||
/// A collection node's modes in their order (`sortPosition`), as `(position, {modeId, name})`.
|
||||
fn sorted_modes(n: &Kv) -> Vec<(String, Value)> {
|
||||
let mut modes: Vec<(String, Value)> = n
|
||||
.get("variableSetModes")
|
||||
.map(Kv::list)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let mode = json!({"modeId": m.get("id").and_then(guid)?, "name": m.get("name").and_then(Kv::str).unwrap_or("Mode")});
|
||||
Some((m.get("sortPosition").and_then(Kv::str).unwrap_or("").to_string(), mode))
|
||||
})
|
||||
.collect();
|
||||
modes.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
modes
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ use super::paint::color;
|
||||
use super::{Kv, MAX_EXPR_DEPTH, Mapper, Obj, Value, View, guid, json, num, ref_guid};
|
||||
|
||||
impl<'a> Mapper<'a> {
|
||||
/// Layer `v`'s variable bindings (`boundVariables`, and the text style's) and its explicitly
|
||||
/// set modes (`explicitVariableModes`) into `o`, in the REST shape. Variables and collections
|
||||
/// named by library asset resolve to their newest copy in the file.
|
||||
pub(super) fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) {
|
||||
let mut bound = Obj::new();
|
||||
let mut text_bound = Obj::new();
|
||||
@@ -49,7 +52,7 @@ impl<'a> Mapper<'a> {
|
||||
let mut lost = false;
|
||||
for e in v.get("variableModeBySetMap").and_then(|m| m.get("entries")).map(Kv::list).unwrap_or_default() {
|
||||
let (Some(set), Some(mode)) = (e.get("variableSetID"), e.get("variableModeID").and_then(guid)) else { continue };
|
||||
match self.resource_id(set) {
|
||||
match self.collection_id(set) {
|
||||
Some(id) => {
|
||||
modes.insert(id, json!(mode));
|
||||
}
|
||||
@@ -137,13 +140,14 @@ pub(super) fn variable_data(d: &Kv, depth: usize) -> Option<Value> {
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The variable an `ALIAS` variable data points at.
|
||||
/// The variable an `ALIAS` variable data points at: by guid, or by library asset (the newest copy
|
||||
/// of it in the file).
|
||||
pub(super) fn alias(d: Option<&Kv>) -> Option<String> {
|
||||
let d = d?;
|
||||
if d.get("dataType").and_then(Kv::str) != Some("ALIAS") {
|
||||
return None;
|
||||
}
|
||||
d.get("value").and_then(|v| v.get("alias")).and_then(ref_guid)
|
||||
d.get("value").and_then(|v| v.get("alias")).and_then(super::VarCanon::resolve)
|
||||
}
|
||||
|
||||
pub(super) fn variable_value(d: &Kv) -> Option<Value> {
|
||||
|
||||
@@ -28,6 +28,7 @@ enum StrokeCap NONE ROUND SQUARE ARROW_LINES ARROW_EQUILATERAL
|
||||
enum StrokeJoin MITER BEVEL ROUND
|
||||
enum VectorMirror NONE ANGLE ANGLE_AND_LENGTH
|
||||
enum ShapeWithTextType SQUARE ELLIPSE
|
||||
enum NodePhase CREATED REMOVED
|
||||
enum ConnectorLineStyle ELBOWED STRAIGHT CURVED
|
||||
enum ConnectorMagnet NONE AUTO TOP LEFT BOTTOM RIGHT CENTER
|
||||
enum ExpressionFunction ADDITION SUBTRACTION RESOLVE_VARIANT MULTIPLY DIVIDE EQUALS
|
||||
@@ -76,7 +77,7 @@ message Event InteractionType interactionType
|
||||
message Action GUID transitionNodeID, TransitionType transitionType, float transitionDuration, EasingType easingType, ConnectionType connectionType, NavigationType navigationType, VariableTarget targetVariable, VariableData targetVariableData, VariableSetID targetVariableSetID, GUID targetVariableModeID, ConditionalActions[] conditionalActions
|
||||
message Interaction Event event, Action[] actions
|
||||
message StartingPoint string name, string position
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern, ConnectorControlPoint[] connectorControlPoints
|
||||
message NodeChange GUID guid, ParentIndex parentIndex, NodeType type, string name, bool visible, float opacity, Vector size, Matrix transform, Paint[] fillPaints, Paint[] strokePaints, float strokeWeight, float cornerRadius, bool resizeToFit, bool internalOnly, float fontSize, FontName fontName, TextData textData, uint styleID, Path[] fillGeometry, Path[] strokeGeometry, StyleType styleType, StyleId styleIdForFill, SymbolData symbolData, PropDef[] componentPropDefs, PropAssignment[] componentPropAssignments, VariableDataMap parameterConsumptionMap, SetMode[] variableSetModes, VariableID variableSetID, ResolvedType variableResolvedType, Values variableDataValues, ModeMap variableModeBySetMap, Interaction[] prototypeInteractions, StartingPoint prototypeStartingPoint, GUIDPath guidPath, StackMode stackMode, float stackSpacing, float stackHorizontalPadding, float stackVerticalPadding, uint count, float starInnerScale, VectorData vectorData, StrokeCap strokeCap, StrokeJoin strokeJoin, VectorMirror handleMirroring, ShapeWithTextType shapeWithTextType, ConnectorEndpoint connectorStart, ConnectorEndpoint connectorEnd, StrokeCap connectorStartCap, StrokeCap connectorEndCap, ConnectorLineStyle connectorLineStyle, ConstraintType horizontalConstraint, ConstraintType verticalConstraint, NodeChange[] derivedSymbolData, string key, string version, LeadingTrim leadingTrim, float[] dashPattern, ConnectorControlPoint[] connectorControlPoints, NodePhase phase
|
||||
message Blob byte[] bytes
|
||||
message Message NodeChange[] nodeChanges, Blob[] blobs
|
||||
";
|
||||
@@ -1589,3 +1590,238 @@ fn dump_samples() {
|
||||
|
||||
#[path = "board_import_tests.rs"]
|
||||
mod board;
|
||||
|
||||
/// A file that took several updates of a library holds a copy of its collection and of each of
|
||||
/// its variables per version it met (same asset key, another version). Layers and modes name any
|
||||
/// of them by asset, never by guid: they all become the newest copy, which has every mode — a page
|
||||
/// set to a mode only the newer copies have keeps it, and fills bound to an older copy of a
|
||||
/// variable follow its newest values.
|
||||
#[test]
|
||||
fn library_copies_of_several_versions_become_the_newest() {
|
||||
let mode = |id, name: &'static str, pos: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s(pos))]);
|
||||
let by_asset = |key: &'static str, version: &'static str| o(vec![("assetRef", o(vec![("key", s(key)), ("version", s(version))]))]);
|
||||
let color_value = |c| o(vec![("value", o(vec![("colorValue", c)])), ("dataType", e("COLOR"))]);
|
||||
let values = |pairs: Vec<(u64, Kv<'static>)>| {
|
||||
o(vec![("entries", arr(pairs.into_iter().map(|(m, c)| o(vec![("modeID", g(m)), ("variableData", color_value(c))])).collect()))])
|
||||
};
|
||||
let asset_alias =
|
||||
|key: &'static str, version: &'static str| o(vec![("value", o(vec![("alias", by_asset(key, version))])), ("dataType", e("ALIAS"))]);
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "Document", vec![]),
|
||||
// The page is set to "Purple", a mode the oldest copy of the collection didn't have yet.
|
||||
node(
|
||||
1,
|
||||
Some((0, "!")),
|
||||
"CANVAS",
|
||||
"Page",
|
||||
vec![(
|
||||
"variableModeBySetMap",
|
||||
o(vec![("entries", arr(vec![o(vec![("variableSetID", by_asset("products", "10:1")), ("variableModeID", g(92))])]))]),
|
||||
)],
|
||||
),
|
||||
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
|
||||
node(
|
||||
4,
|
||||
Some((2, "!")),
|
||||
"VARIABLE_SET",
|
||||
"Products",
|
||||
// "Legacy" was removed from the library since: only this older copy has it.
|
||||
vec![("variableSetModes", arr(vec![mode(90, "Blue", "!"), mode(93, "Legacy", "\"")])), ("key", s("products")), ("version", s("10:1"))],
|
||||
),
|
||||
node(
|
||||
6,
|
||||
Some((2, "#")),
|
||||
"VARIABLE_SET",
|
||||
"Products",
|
||||
vec![("variableSetModes", arr(vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")])), ("key", s("products")), ("version", s("20:1"))],
|
||||
),
|
||||
node(
|
||||
5,
|
||||
Some((2, "\"")),
|
||||
"VARIABLE",
|
||||
"button-bg",
|
||||
vec![
|
||||
("variableSetID", by_asset("products", "10:1")),
|
||||
("variableResolvedType", e("COLOR")),
|
||||
("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0))])),
|
||||
("key", s("button-bg")),
|
||||
("version", s("10:1")),
|
||||
],
|
||||
),
|
||||
node(
|
||||
7,
|
||||
Some((2, "$")),
|
||||
"VARIABLE",
|
||||
"button-bg",
|
||||
vec![
|
||||
("variableSetID", by_asset("products", "20:1")),
|
||||
("variableResolvedType", e("COLOR")),
|
||||
("variableDataValues", values(vec![(90, rgb(0.0, 0.0, 1.0)), (92, rgb(0.5, 0.0, 0.5))])),
|
||||
("key", s("button-bg")),
|
||||
("version", s("20:1")),
|
||||
],
|
||||
),
|
||||
// A pasted layer names the variable by the library's own guid (not a node of this file)
|
||||
// and by its asset: the asset wins.
|
||||
node(
|
||||
11,
|
||||
Some((1, "\"")),
|
||||
"FRAME",
|
||||
"Pasted button",
|
||||
vec![
|
||||
("size", xy(100.0, 40.0)),
|
||||
(
|
||||
"fillPaints",
|
||||
arr(vec![o(vec![
|
||||
("type", e("SOLID")),
|
||||
("color", rgb(0.0, 0.0, 1.0)),
|
||||
(
|
||||
"colorVar",
|
||||
o(vec![
|
||||
(
|
||||
"value",
|
||||
o(vec![(
|
||||
"alias",
|
||||
o(vec![("guid", g(777)), ("assetRef", o(vec![("key", s("button-bg")), ("version", s("20:1"))]))]),
|
||||
)]),
|
||||
),
|
||||
("dataType", e("ALIAS")),
|
||||
]),
|
||||
),
|
||||
])]),
|
||||
),
|
||||
],
|
||||
),
|
||||
// A page section set by a versionless reference, while the file holds several copies.
|
||||
node(
|
||||
12,
|
||||
Some((1, "#")),
|
||||
"FRAME",
|
||||
"Versionless",
|
||||
vec![
|
||||
("size", xy(100.0, 40.0)),
|
||||
(
|
||||
"variableModeBySetMap",
|
||||
o(vec![(
|
||||
"entries",
|
||||
arr(vec![o(vec![("variableSetID", o(vec![("assetRef", o(vec![("key", s("products"))]))])), ("variableModeID", g(92))])]),
|
||||
)]),
|
||||
),
|
||||
],
|
||||
),
|
||||
// The button names the older copy of the variable.
|
||||
node(
|
||||
10,
|
||||
Some((1, "!")),
|
||||
"FRAME",
|
||||
"Button",
|
||||
vec![
|
||||
("size", xy(100.0, 40.0)),
|
||||
(
|
||||
"fillPaints",
|
||||
arr(vec![o(vec![("type", e("SOLID")), ("color", rgb(0.0, 0.0, 1.0)), ("colorVar", asset_alias("button-bg", "10:1"))])]),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
|
||||
assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections);
|
||||
let products = &doc.variables.collections[0];
|
||||
assert_eq!(products.modes.iter().map(|m| m.name.as_str()).collect::<Vec<_>>(), ["Blue", "Purple", "Legacy"]);
|
||||
assert_eq!(doc.variables.variables.len(), 1);
|
||||
let var = &doc.variables.variables[0];
|
||||
assert_eq!(var.collection, products.id);
|
||||
// The newest copy's values: Purple included (a mode only it has), Legacy filled from the default.
|
||||
assert_eq!(var.values.len(), 3, "{:?}", var.values);
|
||||
assert!(matches!(var.values.get("1:92"), Some(figcraft_doc::VariableValue::Color(c)) if (c.r - 0.5).abs() < 1e-6), "{:?}", var.values);
|
||||
let page = doc.nodes().find(|n| n.name == "Page").unwrap();
|
||||
assert_eq!(page.var_modes.get(&products.id).map(String::as_str), Some("1:92"));
|
||||
let button = named(&doc, "Button");
|
||||
assert_eq!(button.bound.values().next(), Some(&var.id), "{:?}", button.bound);
|
||||
assert_eq!(named(&doc, "Versionless").var_modes.get(&products.id).map(String::as_str), Some("1:92"));
|
||||
let pasted = named(&doc, "Pasted button");
|
||||
assert_eq!(pasted.bound.values().next(), Some(&var.id), "{:?}", pasted.bound);
|
||||
assert!(!warnings.iter().any(|w| w.contains("variable modes of collections")), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// A copy of a library collection that a later change removed, followed by a live node for the
|
||||
/// same asset key and version: the live one is the collection, not the removed one the asset
|
||||
/// index met first.
|
||||
#[test]
|
||||
fn removed_library_collection_copies_give_way_to_live_ones() {
|
||||
let mode = |id, name: &'static str, pos: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s(pos))]);
|
||||
let by_asset = |version: &'static str| o(vec![("assetRef", o(vec![("key", s("products")), ("version", s(version))]))]);
|
||||
let set = |id, version: &'static str, modes: Vec<Kv<'static>>| {
|
||||
node(id, Some((2, "!")), "VARIABLE_SET", "Products", vec![("variableSetModes", arr(modes)), ("key", s("products")), ("version", s(version))])
|
||||
};
|
||||
let removed = o(vec![("guid", g(8)), ("phase", e("REMOVED"))]);
|
||||
let nodes = vec![
|
||||
node(0, None, "DOCUMENT", "Document", vec![]),
|
||||
node(
|
||||
1,
|
||||
Some((0, "!")),
|
||||
"CANVAS",
|
||||
"Page",
|
||||
vec![("variableModeBySetMap", o(vec![("entries", arr(vec![o(vec![("variableSetID", by_asset("30:1")), ("variableModeID", g(92))])]))]))],
|
||||
),
|
||||
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
|
||||
set(4, "10:1", vec![mode(90, "Blue", "!")]),
|
||||
set(8, "30:1", vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")]),
|
||||
removed,
|
||||
set(9, "30:1", vec![mode(90, "Blue", "!"), mode(92, "Purple", "\"")]),
|
||||
];
|
||||
let (doc, warnings) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
|
||||
assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections);
|
||||
let products = &doc.variables.collections[0];
|
||||
assert_eq!(products.id, "1:9");
|
||||
let page = doc.nodes().find(|n| n.name == "Page").unwrap();
|
||||
assert_eq!(page.var_modes.get("1:9").map(String::as_str), Some("1:92"), "{warnings:?}");
|
||||
}
|
||||
|
||||
/// Two copies of a library collection with the same version: the choice doesn't depend on the order
|
||||
/// of the node changes (the higher guid wins), and the thread's lookup table is gone after the import.
|
||||
#[test]
|
||||
fn same_version_library_copies_resolve_deterministically_and_leave_no_table() {
|
||||
let mode = |id, name: &'static str| o(vec![("id", g(id)), ("name", s(name)), ("sortPosition", s("!"))]);
|
||||
let set = |id, modes: Vec<Kv<'static>>| {
|
||||
node(id, Some((2, "!")), "VARIABLE_SET", "Products", vec![("variableSetModes", arr(modes)), ("key", s("products")), ("version", s("20:1"))])
|
||||
};
|
||||
let page = node(
|
||||
1,
|
||||
Some((0, "!")),
|
||||
"CANVAS",
|
||||
"Page",
|
||||
vec![(
|
||||
"variableModeBySetMap",
|
||||
o(vec![(
|
||||
"entries",
|
||||
arr(vec![o(vec![("variableSetID", o(vec![("assetRef", o(vec![("key", s("products"))]))])), ("variableModeID", g(90))])]),
|
||||
)]),
|
||||
)],
|
||||
);
|
||||
let base = || {
|
||||
vec![
|
||||
node(0, None, "DOCUMENT", "Document", vec![]),
|
||||
page.clone(),
|
||||
node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]),
|
||||
]
|
||||
};
|
||||
let mut picked = Vec::new();
|
||||
for swap in [false, true] {
|
||||
let (a, b) = (set(4, vec![mode(90, "Blue")]), set(6, vec![mode(90, "Blue")]));
|
||||
let mut nodes = base();
|
||||
if swap {
|
||||
nodes.extend([b, a]);
|
||||
} else {
|
||||
nodes.extend([a, b]);
|
||||
}
|
||||
let (doc, _) = import(&canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])), "x").unwrap();
|
||||
assert_eq!(doc.variables.collections.len(), 1, "{:?}", doc.variables.collections);
|
||||
let id = doc.variables.collections[0].id.clone();
|
||||
let page = doc.nodes().find(|n| n.name == "Page").unwrap();
|
||||
assert_eq!(page.var_modes.get(&id).map(String::as_str), Some("1:90"));
|
||||
picked.push(id);
|
||||
assert!(!super::map::VarCanon::installed());
|
||||
}
|
||||
assert_eq!(picked, ["1:6", "1:6"]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Long and deeply nested names in the Layers panel (#1904), drawn headless: the list is as wide
|
||||
//! as its widest name needs and scrolls sideways, so a name reads in full without widening the
|
||||
//! sidebar, as the reference app's Layers panel does with its horizontal scrollbar; names that
|
||||
//! fit leave the list as wide as the panel; a name past the width cap (#1650) still elides.
|
||||
|
||||
use egui::{Event, MouseWheelUnit, Pos2, Shape, TouchPhase, Vec2, vec2};
|
||||
use figcraft_doc::NodeId;
|
||||
use figcraft_engine::Session;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::layers_drag_tests::Ui;
|
||||
|
||||
fn id(v: serde_json::Value) -> NodeId {
|
||||
NodeId(v["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
/// A rectangle named `name` on the page, or inside `parent`.
|
||||
fn named(s: &mut Session, name: &str, parent: Option<NodeId>) -> NodeId {
|
||||
let mut p = json!({"x": 0, "y": 0, "width": 20, "height": 20, "props": {"name": name}});
|
||||
if let Some(parent) = parent {
|
||||
p["parent"] = json!(parent);
|
||||
}
|
||||
id(s.execute("shape.rectangle", &p).unwrap())
|
||||
}
|
||||
|
||||
/// The painted text starting with `prefix`: (whether it was elided, its position, its size).
|
||||
fn painted(u: &mut Ui, prefix: &str) -> (bool, Pos2, Vec2) {
|
||||
let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes);
|
||||
shapes
|
||||
.iter()
|
||||
.find_map(|s| match s {
|
||||
Shape::Text(t) if t.galley.text().starts_with(prefix) => Some((t.galley.elided, t.pos, t.galley.size())),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("no text starting {prefix:?}"))
|
||||
}
|
||||
|
||||
/// Turn the wheel sideways by `dx` points over `at`, and let the scroll settle.
|
||||
fn wheel_sideways(u: &mut Ui, at: Pos2, dx: f32) {
|
||||
let wheel = Event::MouseWheel {
|
||||
unit: MouseWheelUnit::Point,
|
||||
source: egui::MouseWheelSource::Unknown,
|
||||
delta: vec2(-dx, 0.0),
|
||||
phase: TouchPhase::Move,
|
||||
modifiers: Default::default(),
|
||||
};
|
||||
u.frame(vec![Event::PointerMoved(at), wheel]);
|
||||
for _ in 0..30 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
}
|
||||
|
||||
const LONG: &str = "Size=small, Type=primary, State=default, Icon=leading, Width=full";
|
||||
|
||||
#[test]
|
||||
fn a_long_name_is_painted_in_full_and_the_list_scrolls_sideways() {
|
||||
let mut s = Session::new();
|
||||
let long = named(&mut s, LONG, None);
|
||||
named(&mut s, "Short", None);
|
||||
let mut u = Ui::new(s);
|
||||
let (elided, p, size) = painted(&mut u, "Size=small");
|
||||
assert!(!elided, "the whole name, not an elided one");
|
||||
// The panel is 240 pt wide: the name runs past it, into the space a sideways scroll shows.
|
||||
assert!(p.x + size.x > 320.0, "the name runs past the panel: {} + {}", p.x, size.x);
|
||||
let at = u.row_at(long);
|
||||
wheel_sideways(&mut u, at, 120.0);
|
||||
let (_, scrolled, _) = painted(&mut u, "Size=small");
|
||||
assert!(scrolled.x <= p.x - 100.0, "the list scrolled sideways: {} -> {}", p.x, scrolled.x);
|
||||
// Short names move with it (one list), not on their own.
|
||||
let (_, short, _) = painted(&mut u, "Short");
|
||||
assert!(short.x < 100.0, "the short name moved with the list: {}", short.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deeply_nested_name_gets_its_indent_too() {
|
||||
let mut s = Session::new();
|
||||
// A name that fits at the top level, under five frames: the indent pushes it past the panel.
|
||||
let mut parent = None;
|
||||
for i in 0..5 {
|
||||
let f = id(s
|
||||
.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 100, "parent": parent, "props": {"name": format!("Frame {i}")}}))
|
||||
.unwrap());
|
||||
parent = Some(f);
|
||||
}
|
||||
let leaf = named(&mut s, "Component set original (não usar)", parent);
|
||||
s.execute("select.set", &json!({"ids": [leaf]})).unwrap();
|
||||
let mut u = Ui::new(s);
|
||||
let (elided, p, size) = painted(&mut u, "Component set original");
|
||||
assert!(!elided, "the nested name reads in full");
|
||||
assert!(p.x + size.x > 320.0, "it runs past the panel: {} + {}", p.x, size.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_that_fit_leave_the_list_as_wide_as_the_panel() {
|
||||
let mut s = Session::new();
|
||||
let short = named(&mut s, "Short", None);
|
||||
let mut u = Ui::new(s);
|
||||
let (elided, p, _) = painted(&mut u, "Short");
|
||||
assert!(!elided);
|
||||
let at = u.row_at(short);
|
||||
wheel_sideways(&mut u, at, 120.0);
|
||||
let (_, after, _) = painted(&mut u, "Short");
|
||||
assert_eq!(after.x, p.x, "nothing to scroll to sideways");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_past_the_room_cap_still_elides() {
|
||||
let mut s = Session::new();
|
||||
let name = "Layer ".repeat(500);
|
||||
named(&mut s, &name, None);
|
||||
let mut u = Ui::new(s);
|
||||
let (elided, _, size) = painted(&mut u, "Layer Layer");
|
||||
assert!(elided, "a 3000-character name elides");
|
||||
assert!(size.x <= crate::layers::NAME_ROOM + 1.0, "at the cap, not thousands of points wide: {}", size.x);
|
||||
}
|
||||
|
||||
/// Review: the widest name isn't always the longest in bytes. Fifty rows of narrow letters outrank
|
||||
/// one row of wide ones by the bound; it is laid out all the same, so it reads in full.
|
||||
#[test]
|
||||
fn a_wide_name_among_many_long_narrow_ones_is_still_measured() {
|
||||
let mut s = Session::new();
|
||||
for _ in 0..50 {
|
||||
named(&mut s, &"i".repeat(100), None);
|
||||
}
|
||||
named(&mut s, &"W".repeat(70), None);
|
||||
let mut u = Ui::new(s);
|
||||
let (elided, ..) = painted(&mut u, "WWWW");
|
||||
assert!(!elided, "the wide name reads in full");
|
||||
}
|
||||
|
||||
/// Review: a name is laid out as it is painted, on one line, so line breaks in it don't make it
|
||||
/// measure narrower than it paints.
|
||||
#[test]
|
||||
fn a_name_with_line_breaks_is_measured_as_painted() {
|
||||
let mut s = Session::new();
|
||||
named(&mut s, "Size=small,\nType=primary,\nState=default,\nIcon=leading,\nWidth=full", None);
|
||||
let mut u = Ui::new(s);
|
||||
let (elided, ..) = painted(&mut u, "Size=small,");
|
||||
assert!(!elided, "the whole name on one line");
|
||||
}
|
||||
|
||||
/// Review: a wide row runs on under the canvas; a dragged layer released there is no drop.
|
||||
#[test]
|
||||
fn a_drag_released_over_the_canvas_drops_nowhere() {
|
||||
let mut s = Session::new();
|
||||
let wide = named(&mut s, &"Size=small, Type=primary, ".repeat(6), None);
|
||||
let short = named(&mut s, "Short", None);
|
||||
let page = s.page().unwrap();
|
||||
let order = s.doc().unwrap().node(page).unwrap().children.clone();
|
||||
let mut u = Ui::new(s);
|
||||
let from = u.row_at(short);
|
||||
let level = u.row_at(wide).y;
|
||||
// Over the canvas, level with the wide row (which runs on under it).
|
||||
let over_canvas = Pos2::new(800.0, level);
|
||||
u.drag_to(from, over_canvas);
|
||||
u.button(over_canvas, false);
|
||||
for _ in 0..5 {
|
||||
u.frame(vec![]);
|
||||
}
|
||||
assert_eq!(u.app.session.doc().unwrap().node(page).unwrap().children, order, "no reorder");
|
||||
}
|
||||
@@ -148,6 +148,8 @@ mod layers_keys_tests;
|
||||
#[cfg(test)]
|
||||
mod layers_rename_tests;
|
||||
#[cfg(test)]
|
||||
mod layers_wide_names_tests;
|
||||
#[cfg(test)]
|
||||
mod library_beacon_tests;
|
||||
#[cfg(test)]
|
||||
mod live_chat_tests;
|
||||
|
||||
@@ -492,6 +492,8 @@ struct RowCache {
|
||||
expanded: u64,
|
||||
filter: String,
|
||||
rows: Arc<Vec<Row>>,
|
||||
/// The width the widest row needs to show its whole name ([`widest_row`]).
|
||||
width: f32,
|
||||
}
|
||||
|
||||
fn hash_expanded(expanded: &BTreeSet<NodeId>) -> u64 {
|
||||
@@ -504,15 +506,71 @@ fn hash_expanded(expanded: &BTreeSet<NodeId>) -> u64 {
|
||||
/// The rows to list: the expanded tree, or the layers matching `filter` (lowercase).
|
||||
pub(crate) fn rows_for(ui: &Ui, doc: &Arc<Document>, page: NodeId, expanded: &BTreeSet<NodeId>, filter: &str) -> Arc<Vec<Row>> {
|
||||
let ex = if filter.is_empty() { hash_expanded(expanded) } else { 0 };
|
||||
let id = Id::unique("layer_rows");
|
||||
let fresh = ui
|
||||
.data(|d| d.get_temp::<RowCache>(id))
|
||||
.filter(|c| c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter);
|
||||
if let Some(c) = fresh {
|
||||
return c.rows;
|
||||
}
|
||||
let rows = Arc::new(listed_rows(doc, page, expanded, filter));
|
||||
// Outside the context's data lock: laying out the names takes the context too.
|
||||
let width = widest_row(ui, doc, page, &rows);
|
||||
ui.data_mut(|d| {
|
||||
let c = d.get_temp_mut_or_default::<RowCache>(Id::unique("layer_rows"));
|
||||
let fresh = c.doc.as_ref().is_some_and(|d| Arc::ptr_eq(d, doc)) && c.page == Some(page) && c.expanded == ex && c.filter == filter;
|
||||
if !fresh {
|
||||
let rows = listed_rows(doc, page, expanded, filter);
|
||||
*c = RowCache { doc: Some(doc.clone()), page: Some(page), expanded: ex, filter: filter.to_string(), rows: Arc::new(rows) };
|
||||
d.insert_temp(id, RowCache { doc: Some(doc.clone()), page: Some(page), expanded: ex, filter: filter.to_string(), rows: rows.clone(), width })
|
||||
});
|
||||
rows
|
||||
}
|
||||
|
||||
/// The width the rows [`rows_for`] last listed need to show their names in full, from the
|
||||
/// panel's left edge (0 before any were listed).
|
||||
pub(crate) fn list_width(ui: &Ui) -> f32 {
|
||||
ui.data(|d| d.get_temp::<RowCache>(Id::unique("layer_rows"))).map_or(0.0, |c| c.width)
|
||||
}
|
||||
|
||||
/// A name's room on a row stops growing here: a name of thousands of characters (#1650) elides
|
||||
/// past it instead of making the list that wide.
|
||||
pub(crate) const NAME_ROOM: f32 = 1200.0;
|
||||
|
||||
/// A row's name: the layer's, or its kind's label when it has none.
|
||||
fn row_name(n: &figcraft_doc::Node) -> &str {
|
||||
if n.name.is_empty() { tr(n.kind.label()) } else { &n.name }
|
||||
}
|
||||
|
||||
/// The font a row's name is drawn in: top-level frames and sections stand out.
|
||||
fn name_font(doc: &Document, page: NodeId, n: &figcraft_doc::Node) -> egui::FontId {
|
||||
let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page());
|
||||
if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) }
|
||||
}
|
||||
|
||||
/// The width the widest of `rows` needs to show its whole name, from the panel's left edge: the
|
||||
/// row's margins, its indent, the disclosure triangle and icon, the name in its font (up to
|
||||
/// [`NAME_ROOM`]) and the eye and padlock's room. The list is made this wide, so it scrolls
|
||||
/// sideways when a name doesn't fit the panel (#1904). A name's bytes at the widest glyph bound
|
||||
/// its width, and the rows are laid out in descending order of that bound until the bound falls
|
||||
/// under the widest found, so no name that could be the widest is skipped: a list with one long
|
||||
/// name lays out a handful, a list of names all alike lays them all out, through egui's galley
|
||||
/// cache. A name is laid out as it is painted: on one line, cut at [`NAME_ROOM`].
|
||||
fn widest_row(ui: &Ui, doc: &Document, page: NodeId, rows: &[Row]) -> f32 {
|
||||
// What sits left of the name (the row's margin, the indent's start, the triangle and icon)
|
||||
// and right of it (the eye and padlock, the margin): see the row loop in `layers`.
|
||||
const AROUND: f32 = 8.0 + 4.0 + 36.0 + 56.0 + 8.0;
|
||||
// No glyph of the 11 pt fonts is wider than this: a name's byte count bounds its width.
|
||||
const GLYPH: f32 = 12.0;
|
||||
let indent = |r: &Row| r.depth as f32 * 16.0;
|
||||
let mut bounds: Vec<(f32, usize)> =
|
||||
rows.iter().enumerate().filter_map(|(i, r)| doc.node(r.id).map(|n| (indent(r) + row_name(n).len() as f32 * GLYPH, i))).collect();
|
||||
bounds.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
let mut widest = 0.0f32;
|
||||
for (bound, i) in bounds {
|
||||
if bound <= widest {
|
||||
break;
|
||||
}
|
||||
c.rows.clone()
|
||||
})
|
||||
let Some((r, n)) = rows.get(i).and_then(|r| doc.node(r.id).map(|n| (r, n))) else { continue };
|
||||
let w = crate::bidi::name_galley(ui.painter(), row_name(n), name_font(doc, page, n), Color32::WHITE, NAME_ROOM).size().x;
|
||||
widest = widest.max(indent(r) + w);
|
||||
}
|
||||
widest + AROUND
|
||||
}
|
||||
|
||||
/// The rows to list, uncached: the expanded tree, or the layers matching `filter` (lowercase).
|
||||
@@ -1039,6 +1097,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
let reveal = reveal_selection(app, &doc, page, &sel);
|
||||
let filter = app.ui.layer_filter.trim().to_lowercase();
|
||||
let rows = rows_for(ui, &doc, page, &app.ui.expanded, &filter);
|
||||
// The rows are as wide as the panel, or as the widest name needs: the list then scrolls
|
||||
// sideways too, so long and deeply nested names read in full without widening the sidebar,
|
||||
// as the reference app's Layers panel does with its horizontal scrollbar (#1904).
|
||||
let row_w = w.max(list_width(ui));
|
||||
let mut scroll_to = if reveal { sel.first().and_then(|s| rows.iter().position(|r| r.id == *s)) } else { None };
|
||||
// A rename in progress ends when its layer isn't listed any more; Tab / Shift+Tab commit it and
|
||||
// go on to the next / previous row.
|
||||
@@ -1080,13 +1142,17 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
// The drag is over a row that takes no drop: the not-allowed cursor shows.
|
||||
let mut refused = false;
|
||||
// Only the rows in view are laid out and drawn; the rest is empty scroll space.
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
|
||||
egui::ScrollArea::both().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
|
||||
let pitch = ROW + ui.spacing().item_spacing.y;
|
||||
let top = ui.max_rect().top();
|
||||
let left = ui.max_rect().left();
|
||||
let total = rows.len() as f32 * pitch;
|
||||
let row_w = row_w.max(viewport.width());
|
||||
ui.set_height(total.max(viewport.height()));
|
||||
ui.set_width(row_w);
|
||||
if let Some(i) = scroll_to {
|
||||
ui.scroll_to_rect(Rect::from_min_size(pos2(ui.max_rect().left(), top + i as f32 * pitch), vec2(w, ROW)), None);
|
||||
// The row at the width in view: revealing a layer scrolls up or down, not sideways.
|
||||
ui.scroll_to_rect(Rect::from_min_size(pos2(left + viewport.min.x, top + i as f32 * pitch), vec2(viewport.width(), ROW)), None);
|
||||
}
|
||||
if rows.is_empty() {
|
||||
ui.add_space(8.0);
|
||||
@@ -1104,14 +1170,14 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
let selected_ids: BTreeSet<_> = sel.iter().copied().collect();
|
||||
let roots_start = first.saturating_sub(1);
|
||||
let roots: Vec<_> = rows.iter().take(last.saturating_add(1)).skip(roots_start).map(|r| selection_root(&doc, &selected_ids, r.id)).collect();
|
||||
let band = Rect::from_x_y_ranges(ui.max_rect().x_range(), (top + first as f32 * pitch)..=(top + last as f32 * pitch));
|
||||
let band = Rect::from_x_y_ranges(left..=(left + row_w), (top + first as f32 * pitch)..=(top + last as f32 * pitch));
|
||||
let mut band_ui = ui.new_child(egui::UiBuilder::new().max_rect(band));
|
||||
// Stable widget ids for a row wherever the list is scrolled.
|
||||
band_ui.skip_ahead_auto_ids(first);
|
||||
let ui = &mut band_ui;
|
||||
for (i, row) in rows.iter().enumerate().take(last).skip(first) {
|
||||
let Some(n) = doc.node(row.id) else { continue };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(row_w, ROW), Sense::click_and_drag());
|
||||
let selected = selected_ids.contains(&row.id);
|
||||
a11y::layer_row(&mut app.ui.nav, &resp);
|
||||
if sel.last() == Some(&row.id) {
|
||||
@@ -1169,12 +1235,11 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
}
|
||||
} else {
|
||||
let col = row_color(&t, is_comp, shown, t.text, t.text_tertiary);
|
||||
let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page());
|
||||
let font =
|
||||
if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) };
|
||||
let name = if n.name.is_empty() { tr(n.kind.label()).to_string() } else { n.name.clone() };
|
||||
// Room left of the lock and eye buttons.
|
||||
let max_w = (rr.max.x - 56.0 - name_x).max(20.0);
|
||||
let font = name_font(&doc, page, n);
|
||||
let name = row_name(n).to_string();
|
||||
// Room left of the lock and eye buttons (the row is wide enough for the whole
|
||||
// name, up to `NAME_ROOM`: a row widened by a deeper name gives no more).
|
||||
let max_w = (rr.max.x - 56.0 - name_x).clamp(20.0, NAME_ROOM);
|
||||
crate::bidi::paint_name(ui.painter(), pos2(name_x, r.center().y), &name, font, col, max_w);
|
||||
}
|
||||
let (v, l) = eye_lock(ui, &t, [r, rr], row, n, over && !dev, dev, &mut sweep);
|
||||
@@ -1198,7 +1263,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
if resp.drag_started() && !crate::menus::dev_mode_refuses(app, "node.reparent") {
|
||||
ui.data_mut(|d| d.insert_temp(drag_id, row.id));
|
||||
}
|
||||
let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p));
|
||||
// In the row and in the list's view: a wide row runs on under the canvas (#1904), and a
|
||||
// drag released there lands nowhere.
|
||||
let clip = ui.clip_rect();
|
||||
let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p) && clip.contains(p));
|
||||
// What is inside the dragged layers, and inside an instance, takes no drop.
|
||||
refused |= over
|
||||
&& (doc.ancestors(row.id).iter().any(|a| moving.contains(a))
|
||||
@@ -1207,6 +1275,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
&& !blocked(row.id)
|
||||
&& let Some(p) = ui.input(|i| i.pointer.hover_pos())
|
||||
&& r.contains(p)
|
||||
&& clip.contains(p)
|
||||
{
|
||||
let collapsed = row.has_kids && !app.ui.expanded.contains(&row.id);
|
||||
let (zone, open, released) = drop_zone(ui, p, [r, rr], x0, row.id, collapsed, n);
|
||||
@@ -1222,7 +1291,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
|
||||
if let Some((i, id, depth)) = rename_row.filter(|(i, ..)| !(first..last).contains(i))
|
||||
&& let Some(n) = doc.node(id)
|
||||
{
|
||||
let r = Rect::from_min_size(pos2(band.min.x, top + i as f32 * pitch), vec2(w, ROW));
|
||||
let r = Rect::from_min_size(pos2(band.min.x, top + i as f32 * pitch), vec2(row_w, ROW));
|
||||
if let Some(new) = rename_field(app, ui, id, depth, &n.name, r) {
|
||||
rename_done = Some((id, new));
|
||||
}
|
||||
|
||||
@@ -546,10 +546,13 @@ resuming with its offline edits, and leaving the session going back to snapshot
|
||||
- Disconnected: while it reconnects, and after it can't go on, the tab shows a red mark and a strip
|
||||
across the top of the canvas says so, with **Retry now** (`collab.reconnect`). The tab can still
|
||||
be edited. A session you joined that ended stays on its tab with **Try again**, **Save a copy…**
|
||||
and **Dismiss** (`collab.leave`). **Try again** joins it in the same tab as someone new; if the
|
||||
tab changed since it lost the server, what changed opens beside it as an unsaved
|
||||
"offline copy" and the tab shows the server's version. A session that gets out of step does the
|
||||
same by itself. A reconnect resumes as the same
|
||||
and **Dismiss** (`collab.leave`). **Try again** joins it in the same tab, as the same client when
|
||||
the server still knows it, and what changed since it lost the server goes on top as one undoable
|
||||
edit, through the three-way merge of `crates/collab/src/merge.rs`. The same thing changed both
|
||||
ways keeps the other person's, and layers a new client can't make aren't sent; in those cases
|
||||
your whole version also opens beside it as an unsaved "offline copy", so nothing is lost. A
|
||||
session that gets out of step, or a server that no longer knows you, does the same by itself. A
|
||||
reconnect resumes as the same
|
||||
client: the guest's connection keeps the session id, client id and last sequence number from
|
||||
the messages it read and sends them in its next `Hello` (see Offline and reconnect).
|
||||
- The browser build joins and publishes to a server (see *Built (the browser client)*); hosting
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Collaboration parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (; a file shared on the local network can be shared to look at only: a view-only code next to the edit code, picked in the Share popover before or while sharing)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (M9: a joined live session reconnects until you leave and says when it is disconnected, and joining it again merges what you did while disconnected; ; a file shared on the local network can be shared to look at only: a view-only code next to the edit code, picked in the Share popover before or while sharing)
|
||||
|
||||
Figma is multiplayer first; FigCraft is local first. **Ready ~13% (estimated):** comments, version
|
||||
history and branches (review and merge) work inside one file; the real-time collaboration core landed in #103 (sync model,
|
||||
@@ -12,7 +12,7 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Feature | Figma | FigCraft | Hours |
|
||||
|---|---|---|---|
|
||||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, a live connection reconnecting until you leave), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 |
|
||||
| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, a live connection reconnecting until you leave, and rejoining with offline edits merged), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/<key>` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 |
|
||||
| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 |
|
||||
| Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 |
|
||||
| Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address, with an edit code and a view-only code, picked before or while sharing, whose holders see every change and can't make one; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer; a server only a team reaches can also list the files shared from it now for everyone (`team_files`): Home ▸ Online shows that team list for the server in Preferences, a row joins its file, and its links (`figcraft://join?server=…&file=…&team=1`, or the browser build's) carry no secret and stop working with the share. No accounts, teams or projects | 8–17 |
|
||||
@@ -26,6 +26,8 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-04 | minor | M9: a session you joined no longer turns into a local file by itself. A dropped connection reconnects until you leave (a minute's pause after each 12 attempts, a pause Retry now skips); only the host ending the session and refusals that are for good stop it; queued messages go out paced. While disconnected the tab shows a red mark and a strip across the canvas says so; a session that ended stays on its tab with Try again, Save a copy and Dismiss |
|
||||
| 2026-11-04 | minor | M9: joining a session again merges what you did while disconnected as one undoable edit, through the three-way merge branches use, as the same client when the server still knows it. What conflicts keeps the other person's, and your whole version opens beside it as an unsaved copy. A server that no longer knows you merges the same way instead of discarding unconfirmed work |
|
||||
| 2026-11-06 | patch | M9, #2026: one conflicting auto layout edit added a conflict for every size and place it moved (a padding changed on both sides of a file with one instance gave 10, with 15 instances 138), and a `layout` conflict took all auto layout fields together, so a gap changed only on the branch was lost if main won the padding. `merge::plan` now leaves out values both sides work out for themselves: a hugging frame's or a filling child's size, text sizing to its characters, an auto layout child's place (when both sides turn it the same way) and a value a layer in an instance takes from its main; merging keeps the file merged into's own value and normalising lays it out from the merged inputs. `layout` and `childLayout` merge field by field: a field changed on one side merges, each field changed both ways is its own conflict (`<id>/layout.padding`, shown as auto layout padding with both sides' values). Tests: `branch_tests::one_conflicting_auto_layout_edit_is_one_conflict`, `branch_tests::auto_layout_fields_changed_one_way_survive_a_conflict`, `branch_tests::sizes_auto_layout_works_out_never_conflict`; the merge fuzz test leaves worked-out values out of what each side must show |
|
||||
| 2026-11-06 | minor | M9: a file shared on the local network can be shared read-only. The session the app hosts has a second secret, so a view-only code next to the edit code (same address and port, a 44-bit secret of its own); whoever joins with it is welcomed as a viewer, sees every change and presence, gets the "View only" badge and only the moving tools, and the session refuses their edits. The Share popover offers Can edit / Can view under Local network before sharing starts (the edit code then never shows) and while sharing; `collab.share {via: "local", access: "view"}` does the same (its `link` and `url`, like its `code`, are the view-only way in), and `share` in `collab.peers` lists both codes and links. No comment code on the local network. Numbers unchanged. |
|
||||
| 2026-11-04 | minor | M9: a session you joined no longer turns into a local file by itself. A dropped connection reconnects until you leave (a minute's pause after each 12 attempts, a pause Retry now skips); only the host ending the session and refusals that are for good stop it; queued messages go out paced. While disconnected the tab shows a red mark and a strip across the canvas says so; a session that ended stays on its tab with Try again, Save a copy and Dismiss, and what changed offline opens beside it when it joins again |
|
||||
|
||||
@@ -173,6 +173,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| Screen-reader names, roles, values, states (AccessKit) | done on desktop | browser: eframe doesn't send the tree yet |
|
||||
| Keyboard navigation: F6 regions, Tab within, focus ring | done | |
|
||||
| Layers panel from the keyboard: arrows, F2 / Cmd+R rename (the row gets the keyboard back), Shift+F10 context menu; 10k-layer lists | done | revealing a layer, expanding, collapsing and arrow keys timed in `cargo xtask bench` |
|
||||
| Layers panel: long or deeply nested names | done | the list is as wide as its widest name needs and scrolls sideways, so a name reads in full without widening the sidebar (the reference app's Layers panel has a horizontal scrollbar); a drag released over the canvas drops nowhere; a name past 1200 pt still elides (`layers_wide_names_tests`, #1904) |
|
||||
| Reduced motion | done | system setting or preference |
|
||||
| WCAG AA contrast of secondary and accent text | done | disabled and dim text at least 3:1 against panels, fields and selected rows, selected text at least 3:1 on its highlight, in both themes (`theme::tests`, #890) |
|
||||
| Tested with VoiceOver, NVDA, Narrator, Orca | missing | human QA |
|
||||
@@ -187,6 +188,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look
|
||||
| 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged |
|
||||
| 2026-11-02 | minor | M8: layer hierarchy backgrounds compute each visible row's selection root once, plus its immediate offscreen neighbors, rather than walking ancestors three times per row. Selection membership uses a set instead of a linear search. The scrolling regression keeps the internal seams square at viewport boundaries. Numbers unchanged. |
|
||||
| 2026-11-02 | minor | M8: selected layer hierarchies have a continuous background across row spacing, with rounded outer corners; nested selections stay in the same block and separate selected trees keep their own corners. Regressions cover continuous fills, the selected-row highlight above them, scrolling and the original palette's dim-text contrast. Numbers unchanged. |
|
||||
| 2026-11-03 | patch | M1, #1904: the Layers panel cut a name that didn't fit with an ellipsis and had no horizontal scrollbar, so the variants of an imported component set all read `Size=small, Type=prim…` and a deeply nested name lost its room to the indent. The list is now as wide as its widest name needs (the row cache lays names out in descending order of a bound on their width until the bound falls under the widest found: a list with one long name lays out a handful, names all alike all of them, through egui's galley cache) and scrolls sideways, as the reference app's Layers panel does with its horizontal scrollbar; revealing a layer scrolls up or down only; the eye and padlock sit at the end of the row and scroll with it; a drag released past the list, over the canvas, drops nowhere; a name past 1200 pt still elides (#1650). Tests: `layers_wide_names_tests`. Numbers unchanged. |
|
||||
| 2026-11-03 | patch | M1, #1903: the Layers panel drew component, variant and instance names in the canvas outline purple whatever the theme (3.1:1 on the dark panel, 2.7:1 on the selected row, under WCAG AA's 4.5:1), and the rows inside a selected component in the green of a plain selection. They take the theme's component tint (`Tokens::component`: the dark theme's lilac; the light theme's a shade darker than the canvas purple, so it reads at AA on a selected and a hovered row too), and the rows inside a selected component a tone of its row (`component_row_child`). The selection's own green is FigCraft's and stays; the canvas keeps its purple. Test: `layers_component_rows_tests`. Numbers unchanged. |
|
||||
| 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) |
|
||||
| 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. |
|
||||
|
||||
Reference in New Issue
Block a user