diff --git a/ROADMAP.md b/ROADMAP.md index dcd2613..d645e0d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~28 points and ~200–365 h away (one Opus 5.5 agent; ~40–80 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; collaboration polish: collision-free ids, remote text carets; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through @@ -59,7 +59,7 @@ the localization layer with Figma's own languages (20–35 h). | Features (depth) | 57% (core editor 67%) | 78–146 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | | File formats | 40% (no `.fig`) | 30–60 | [file-format-parity](docs/file-format-parity.md) | -| Collaboration | 12% (core in; live sessions with document sync and cursors, the browser joins, invite links open the app; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) | +| Collaboration | 12% (core in; live sessions with document sync, cursors and text carets, the browser joins, invite links open the app; preview) | 55–110 | [collaboration-parity](docs/collaboration-parity.md) | | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | | Hardware | 50% | 15–30 | [hardware-parity](docs/hardware-parity.md) | @@ -123,7 +123,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). 2. Real-file corpus and measured fidelity: 15–30 h. 3. Tool-by-tool interaction pass against Figma: 40–60 h. 4. Localization layer, then ja, ko, es, pt-BR, fr, de: 35–55 h. -5. Multiplayer (M9): sharing and a standalone server with TLS on top of the live sessions (transport, hosting, invite links that open the app, document sync, presence, per-user undo, reconnect with offline edits and the browser client landed): 45–90 h. +5. Multiplayer (M9): sharing and a standalone server with TLS on top of the live sessions (transport, hosting, invite links that open the app, document sync, presence with remote text carets, per-user undo, reconnect with offline edits, collision-free ids for styles, variables and comments, and the browser client landed): 45–90 h. ## Progress log @@ -152,6 +152,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | | 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | | 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | | 2026-10-10 | minor | Readiness by audience with hours to ~95% (full ~400–750, mainstream ~180–330, essentials ~85–160) | diff --git a/crates/collab/src/change.rs b/crates/collab/src/change.rs index d9d673f..60aacde 100644 --- a/crates/collab/src/change.rs +++ b/crates/collab/src/change.rs @@ -295,6 +295,33 @@ impl DocProp { } impl Entry { + /// The client id space the entry's id lies in ([`figcraft_doc::ids`]): ids made during a live + /// session carry their creator's client id, ids from before and ids of other forms are in + /// space 0. `None` for entries keyed by something else (dev snapshots by layer, images by + /// content). + pub fn id_space(&self) -> Option { + use figcraft_doc::ids::{COLLECTION_ID_PREFIX, STYLE_ID_PREFIX, VARIABLE_ID_PREFIX, id_space, space_of}; + match self { + Entry::Style(id) => Some(id_space(id, STYLE_ID_PREFIX)), + Entry::Variable(id) => Some(id_space(id, VARIABLE_ID_PREFIX)), + Entry::Collection(id) => Some(id_space(id, COLLECTION_ID_PREFIX)), + Entry::Comment(id) => Some(space_of(*id)), + Entry::DevSnapshot(_) | Entry::Image(_) => None, + } + } + + /// What the entry is, for messages ("style S:3"). + pub fn describe(&self) -> String { + match self { + Entry::Style(id) => format!("style {id}"), + Entry::Comment(id) => format!("comment thread {id}"), + Entry::Collection(id) => format!("variable collection {id}"), + Entry::Variable(id) => format!("variable {id}"), + Entry::DevSnapshot(id) => format!("ready-for-dev snapshot of {id}"), + Entry::Image(hash) => format!("image {hash}"), + } + } + /// The list this entry belongs to (dev snapshots and images aren't ordered lists). pub(crate) fn list(&self) -> Option { match self { diff --git a/crates/collab/src/docprops.rs b/crates/collab/src/docprops.rs index 52aea0c..535c333 100644 --- a/crates/collab/src/docprops.rs +++ b/crates/collab/src/docprops.rs @@ -340,6 +340,31 @@ pub(crate) fn entry_value(doc: &Document, entry: &Entry) -> Result, value.transpose() } +/// Whether `doc` has entry `entry`. +pub(crate) fn has(doc: &Document, entry: &Entry) -> bool { + match entry { + Entry::Style(k) => doc.styles.iter().any(|s| s.id == *k), + Entry::Comment(k) => doc.comments.iter().any(|c| c.id == *k), + Entry::Collection(k) => doc.variables.collections.iter().any(|c| c.id == *k), + Entry::Variable(k) => doc.variables.variables.iter().any(|v| v.id == *k), + Entry::DevSnapshot(k) => doc.dev_snapshots.contains_key(k), + Entry::Image(k) => doc.images.contains_key(k), + } +} + +/// The entries of list `family` in `doc`. +pub(crate) fn entries(doc: &Document, family: Family) -> Vec { + fn keys(list: &List, doc: &Document) -> Vec { + (list.get)(doc).iter().map(|t| (list.entry)(t.key())).collect() + } + match family { + Family::Styles => keys(&STYLES, doc), + Family::Comments => keys(&COMMENTS, doc), + Family::Collections => keys(&COLLECTIONS, doc), + Family::Variables => keys(&VARIABLES, doc), + } +} + /// The current value of what `change` writes, as a change (for a rejected batch's fixes): the /// property or entry as it is now, or the entry's removal. List properties revert whole. pub(crate) fn current(doc: &Document, change: &Change) -> Result, Error> { diff --git a/crates/collab/src/entry_ids_tests.rs b/crates/collab/src/entry_ids_tests.rs new file mode 100644 index 0000000..109d49d --- /dev/null +++ b/crates/collab/src/entry_ids_tests.rs @@ -0,0 +1,163 @@ +//! Styles, variables, collections and comments created by several people at once: ids from each +//! creator's space, checked by the server, so concurrent creations never overwrite each other. + +use figcraft_doc::ids::{STYLE_ID_PREFIX, id_space}; +use figcraft_doc::{Collection, Comment, Document, Message, Mode, Style, StyleKind, Variable, VariableType}; + +use crate::change::{Change, Entry, Json}; +use crate::message::{ClientMsg, ServerMsg}; +use crate::sim::Sim; +use crate::{Client, Limits, NoRecompute, Outgoing, Server}; + +fn style(id: String, name: &str) -> Style { + Style { id, name: name.into(), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() } } +} + +fn comment(id: u64, doc: &Document) -> Comment { + let page = doc.first_page().unwrap(); + Comment { id, page, x: 1.0, y: 2.0, anchor: None, first: Message::new("A", "hi", 0), resolved: false, replies: Vec::new(), read_by: None } +} + +fn join(server: &mut Server) -> Client { + let ClientMsg::Hello(h) = Client::hello("f", "", "Ada", "#FF8800") else { panic!("not a hello") }; + let (_, out) = server.join(h).unwrap(); + let Some(Outgoing::To(_, welcome)) = out.into_iter().next() else { panic!("no welcome") }; + Client::join(welcome).unwrap() +} + +/// Send `changes` from client `from` as batch `batch_id`; the server's answer to it. +fn send(server: &mut Server, from: u32, batch_id: u64, changes: Vec) -> ServerMsg { + let out = server.receive(from, ClientMsg::Changes { base_seq: server.seq(), batch_id, changes }); + match out.into_iter().next() { + Some(Outgoing::All(m) | Outgoing::To(_, m)) => m, + other => panic!("unexpected {other:?}"), + } +} + +fn put_style(id: String) -> Change { + let value = Json::of(&style(id.clone(), "Forged")).unwrap(); + Change::Put { entry: Entry::Style(id), value } +} + +#[test] +fn new_entries_must_come_from_the_senders_space() { + let mut base = Document::new("Shared"); + base.styles.push(style("S:1".into(), "Old")); + let mut server = Server::new(base, "", Limits::default()); + let mut a = join(&mut server); + let b = join(&mut server); + // A style made in a session takes its id from its creator's space. + let mut doc = a.document().clone(); + let id = doc.new_style_id(); + assert_eq!(id_space(&id, STYLE_ID_PREFIX), a.id(), "{id}"); + doc.styles.push(style(id.clone(), "Brand")); + let msg = a.edit(&doc).unwrap().unwrap(); + assert!(matches!(server.receive(a.id(), msg).first(), Some(Outgoing::All(ServerMsg::Changes(_))))); + // Someone else can change it (last writer wins)… + assert!(matches!(send(&mut server, b.id(), 1, vec![put_style(id.clone())]), ServerMsg::Changes(_))); + // …but not make a new one in another person's space. + let theirs = format!("{STYLE_ID_PREFIX}{}", (u64::from(a.id()) << 32) | 999); + let ServerMsg::Reject { reason, .. } = send(&mut server, b.id(), 2, vec![put_style(theirs)]) else { panic!("accepted") }; + assert!(reason.contains("id space"), "{reason}"); + // Ids from before the session (or of other forms, such as library copies) are anyone's. + for (k, old) in ["S:77", "library:S:5"].into_iter().enumerate() { + assert!(matches!(send(&mut server, b.id(), 3 + k as u64, vec![put_style(old.into())]), ServerMsg::Changes(_)), "{old}"); + } + // A removed entry can be put back by anyone (undo). + assert!(matches!(send(&mut server, a.id(), 100, vec![Change::Remove { entry: Entry::Style(id.clone()) }]), ServerMsg::Changes(_))); + assert!(matches!(send(&mut server, b.id(), 10, vec![put_style(id.clone())]), ServerMsg::Changes(_))); + // The whole-list fallback is checked the same way. + let mut list = server.document().styles.clone(); + list.push(style(format!("{STYLE_ID_PREFIX}{}", (u64::from(a.id()) << 32) | 1000), "Sneaky")); + let set = Change::SetDoc { prop: crate::DocProp::Styles, value: Json::of(&list).unwrap() }; + assert!(matches!(send(&mut server, b.id(), 11, vec![set]), ServerMsg::Reject { .. })); + // Comments too. + let forged = comment((u64::from(a.id()) << 32) | 5, server.document()); + let put = Change::Put { entry: Entry::Comment(forged.id), value: Json::of(&forged).unwrap() }; + assert!(matches!(send(&mut server, b.id(), 12, vec![put]), ServerMsg::Reject { .. })); + // Removed entries are part of the saved state. + let state = server.state().unwrap(); + assert!(state.removed.is_empty(), "put back since"); + let restored = Server::restore(state.clone(), "", Limits::default()).unwrap(); + assert_eq!(restored.state().unwrap(), state); +} + +#[test] +fn client_ids_skip_spaces_used_by_styles_and_comments() { + let mut d = Document::new("a"); + d.styles.push(style(format!("{STYLE_ID_PREFIX}{}", (4u64 << 32) | 1), "From session 4")); + let c = comment((9u64 << 32) | 1, &d); + d.comments.push(c); + let mut server = Server::new(d, "", Limits::default()); + assert_eq!(join(&mut server).id(), 10); +} + +/// Each client makes `rounds` styles, collections (with an extra mode), variables and comments +/// before seeing anyone else's. +fn create_everything(doc: &mut Document, who: usize, round: usize) { + let name = format!("p{who} r{round}"); + let id = doc.new_style_id(); + doc.styles.push(style(id, &name)); + let cid = doc.new_collection_id(); + doc.variables.collections.push(Collection { + id: cid.clone(), + name: name.clone(), + modes: vec![Mode { id: format!("{cid}/mode1"), name: "Light".into() }], + }); + if let Some(m) = doc.new_mode_id(&cid) + && let Some(c) = doc.variables.collections.iter_mut().find(|c| c.id == cid) + { + c.modes.push(Mode { id: m, name: "Dark".into() }); + } + let vid = doc.new_variable_id(); + doc.variables.variables.push(Variable { + id: vid, + name, + collection: cid, + kind: VariableType::Number, + values: Default::default(), + description: String::new(), + scopes: None, + hidden_from_publishing: false, + }); + let k = doc.new_comment_id(); + let c = comment(k, doc); + doc.comments.push(c); +} + +#[test] +fn concurrent_creations_all_survive() { + for seed in 0..20 { + let n = 2 + (seed as usize % 4); + let rounds = 3; + let mut sim = Sim::new(Document::new("Shared"), n, seed, 12).unwrap(); + for round in 0..rounds { + // Everyone creates before anything is delivered: the worst case for colliding ids. + for i in 0..n { + let mut doc = sim.document(i).unwrap().clone(); + create_everything(&mut doc, i, round); + assert!(sim.edit(i, &doc).unwrap()); + } + for _ in 0..sim.rng().below(6) { + sim.step(&NoRecompute).unwrap(); + } + } + sim.settle(&NoRecompute, 1_000_000).unwrap(); + sim.converged().unwrap_or_else(|e| panic!("seed {seed}: {e}")); + assert_eq!(sim.rejected, 0, "seed {seed}: nothing is refused"); + let d = sim.server.document(); + let made = n * rounds; + assert_eq!(d.styles.len(), made, "seed {seed}: every style survives"); + assert_eq!(d.variables.collections.len(), made, "seed {seed}"); + assert!(d.variables.collections.iter().all(|c| c.modes.len() == 2), "seed {seed}: both modes of each"); + assert_eq!(d.variables.variables.len(), made, "seed {seed}"); + assert_eq!(d.comments.len(), made, "seed {seed}"); + for who in 0..n { + for round in 0..rounds { + let name = format!("p{who} r{round}"); + assert!(d.styles.iter().any(|s| s.name == name), "seed {seed}: {name}'s style"); + assert!(d.variables.variables.iter().any(|v| v.name == name), "seed {seed}: {name}'s variable"); + } + } + } +} diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 8659cbf..8a9c026 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -115,6 +115,8 @@ mod codec_tests; #[cfg(test)] mod diff_tests; #[cfg(test)] +mod entry_ids_tests; +#[cfg(test)] mod frac_tests; #[cfg(test)] mod reconnect_tests; diff --git a/crates/collab/src/server.rs b/crates/collab/src/server.rs index 4bed36c..9bb12f1 100644 --- a/crates/collab/src/server.rs +++ b/crates/collab/src/server.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, VecDeque}; use figcraft_doc::{Document, NodeId}; use serde::{Deserialize, Serialize}; -use crate::change::{Change, Prop, Target}; +use crate::change::{Change, Entry, Prop, Target}; use crate::message::{Accepted, CatchUp, ClientMsg, Hello, PROTOCOL, Peer, Presence, Processed, ServerMsg}; use crate::replica::{Mode, Replica, create_change}; use crate::{Error, Limits, docprops}; @@ -38,6 +38,9 @@ pub struct Server { /// Deleted node ids: only these may be created again (by undo); new ids must come from the /// creating client's id space. deleted: BTreeSet, + /// Removed document-level entries (styles, variables, collections, comments): these may be + /// put back by anyone (undo); a new one must not have an id from another client's space. + removed: BTreeSet, /// Every client id handed out (a reconnecting client keeps its id) and what was done with /// its batches. issued: BTreeMap, @@ -53,7 +56,7 @@ const MAX_REMEMBERED: usize = 65_536; /// What a session server needs to carry on after a restart: the document, the sequence number, /// the client-id counter (so ids are never handed out twice), the clients that can reconnect, -/// the deleted ids that may be created again and the log of recent batches. +/// the deleted ids and removed entries that may be created again and the log of recent batches. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct SessionState { pub session: u64, @@ -62,16 +65,18 @@ pub struct SessionState { pub next_client: u32, pub issued: BTreeMap, pub deleted: BTreeSet, + /// Absent in states saved before entries were tracked. + #[serde(default)] + pub removed: BTreeSet, pub log: Vec, } impl Server { /// A session for `doc`. Joining requires `token` (empty: no token). Client ids start above every - /// client id space already used in the document, so ids from earlier sessions are never handed - /// out again. + /// client id space already used in the document (by layers, styles, variables, collections, + /// modes or comments), so ids from earlier sessions are never handed out again. pub fn new(doc: Document, token: impl Into, limits: Limits) -> Server { - let used = doc.ids().map(|id| id.0 >> 32).max().unwrap_or(0); - let next_client = u32::try_from(used.saturating_add(1)).unwrap_or(u32::MAX).max(1); + let next_client = doc.highest_id_space().saturating_add(1); Server { session: new_session_id(), state: Replica::new(doc), @@ -83,6 +88,7 @@ impl Server { presence: BTreeMap::new(), acked: BTreeMap::new(), deleted: BTreeSet::new(), + removed: BTreeSet::new(), issued: BTreeMap::new(), log: VecDeque::new(), log_changes: 0, @@ -98,6 +104,7 @@ impl Server { next_client: self.next_client, issued: self.issued.clone(), deleted: self.deleted.clone(), + removed: self.removed.clone(), log: self.log.iter().cloned().collect(), }) } @@ -114,6 +121,7 @@ impl Server { server.next_client = server.next_client.max(state.next_client); server.issued = state.issued.into_iter().filter(|(id, _)| *id < server.next_client).collect(); server.deleted = state.deleted; + server.removed = state.removed; let contiguous = state.log.windows(2).all(|w| matches!(w, [a, b] if a.seq + 1 == b.seq)); if contiguous && state.log.last().is_none_or(|a| a.seq == state.seq) { for a in state.log { @@ -283,17 +291,65 @@ impl Server { if next.document().node_count() > self.limits.max_nodes { return Err(Error::Invalid(format!("more than {} layers", self.limits.max_nodes))); } + let entries = self.check_entries(from, changes, next.document())?; for c in changes { if let Change::Create { id, .. } = c { self.deleted.remove(id); } } self.deleted.extend(applied.deleted); + for (entry, now) in entries { + if now { + self.removed.remove(&entry); + } else { + self.removed.insert(entry); + } + } self.state = next; self.seq += 1; Ok(()) } + /// The document-level entries (styles, variables, collections, comments) a batch adds or + /// removes, each with whether it is there afterwards. A new one is refused when its id lies in + /// the space of another client this session gave an id to (unless it was removed before: undo + /// puts entries back); ids from before the session (space 0, earlier sessions, other files) + /// are fine. + fn check_entries(&self, from: u32, changes: &[Change], after: &Document) -> Result, Error> { + let before = self.state.document(); + let mut touched: BTreeSet = BTreeSet::new(); + for c in changes { + match c { + Change::Put { entry, .. } | Change::Remove { entry } if entry.id_space().is_some() => { + touched.insert(entry.clone()); + } + // A whole list replaced (the fallback for lists whose ids aren't unique). + Change::SetDoc { prop, .. } => { + if let Some(Ok(family)) = prop.list() { + touched.extend(docprops::entries(before, family)); + touched.extend(docprops::entries(after, family)); + } + } + _ => {} + } + } + let mut out = Vec::new(); + for entry in touched { + let (was, now) = (docprops::has(before, &entry), docprops::has(after, &entry)); + if was == now { + continue; + } + if now + && !self.removed.contains(&entry) + && let Some(space) = entry.id_space().filter(|s| *s != from && self.issued.contains_key(s)) + { + return Err(Error::Invalid(format!("new {} isn't in client {from}'s id space (it is client {space}'s)", entry.describe()))); + } + out.push((entry, now)); + } + Ok(out) + } + /// The current values of what a refused batch touched, so a client can roll its optimistic /// copy back: nodes that don't exist are deleted, deleted subtrees come back whole, edited /// properties get their current values, document-level data likewise. diff --git a/crates/doc/src/comments.rs b/crates/doc/src/comments.rs index 564c30d..53edc9c 100644 --- a/crates/doc/src/comments.rs +++ b/crates/doc/src/comments.rs @@ -218,9 +218,10 @@ impl Document { self.comments.iter_mut().find(|c| c.id == id) } - /// The next free comment id. + /// The next free comment id outside a live session (one above the highest; see + /// [`new_comment_id`](Self::new_comment_id)). pub fn next_comment_id(&self) -> u64 { - self.comments.iter().map(|c| c.id).max().unwrap_or(0) + 1 + self.comments.iter().map(|c| c.id).max().unwrap_or(0).saturating_add(1) } /// Everyone named in the file's comments (authors and those who reacted), sorted. diff --git a/crates/doc/src/ids.rs b/crates/doc/src/ids.rs new file mode 100644 index 0000000..024946b --- /dev/null +++ b/crates/doc/src/ids.rs @@ -0,0 +1,141 @@ +//! Ids of document-level objects (styles, variables, variable collections and modes, comment +//! threads) that stay unique when several people create them at the same time. +//! +//! Outside a live session (no client id, see [`Document::set_client_id`]) new ids are made exactly +//! as before: `S:` with the first free `k` from the number of styles, `VariableID:` and +//! `VariableCollectionId:` one above the highest, `/mode` with the first free `k` +//! from the number of modes, and comment ids one above the highest. +//! +//! During a session every new one takes its number from the document's id allocator, which hands +//! out `client_id << 32 | counter` like node ids and never hands a number out twice: `S:`, +//! `VariableID:`, `VariableCollectionId:`, `/mode` and comment id `n`. The +//! number's high 32 bits are the client whose space it lies in ([`space_of`]); ids from before +//! (small numbers) and ids of other forms (library copies `library:key`, imported files) are in +//! space 0. The session server accepts a new object only with an id in its creator's space or +//! space 0, so two people never make the same id. + +use crate::{CollectionId, Document, ModeId, NodeId, StyleId, VariableId}; + +/// What style ids start with. +pub const STYLE_ID_PREFIX: &str = "S:"; +/// What variable ids start with. +pub const VARIABLE_ID_PREFIX: &str = "VariableID:"; +/// What variable collection ids start with. +pub const COLLECTION_ID_PREFIX: &str = "VariableCollectionId:"; +/// What separates a mode id's collection from its number. +pub const MODE_ID_INFIX: &str = "/mode"; + +/// The client id space number `n` lies in (its high 32 bits; 0: made outside a live session). +pub fn space_of(n: u64) -> u32 { + u32::try_from(n >> 32).unwrap_or(u32::MAX) +} + +/// The number of id ``, if it has that form. +pub fn id_number(id: &str, prefix: &str) -> Option { + digits(id.strip_prefix(prefix)?) +} + +/// The client space of id `` (0 for ids of any other form). +pub fn id_space(id: &str, prefix: &str) -> u32 { + id_number(id, prefix).map_or(0, space_of) +} + +/// The number at the end of mode id `/mode`. +pub fn mode_number(id: &str) -> Option { + digits(id.rsplit_once(MODE_ID_INFIX)?.1) +} + +/// `s` as a number when it is only ASCII digits. +fn digits(s: &str) -> Option { + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +impl Document { + /// A number for a new document-level id in this document's client space, above every number + /// of `taken` in that space. `None` outside a live session (ids are then made as before) or + /// when the space is full. + fn session_number(&mut self, taken: impl IntoIterator) -> Option { + let client = self.client_id(); + if client == 0 { + return None; + } + // Numbers of this space the allocator hasn't seen (a document rebuilt from a snapshot) + // stay taken. + let highest = taken.into_iter().filter(|n| space_of(*n) == client).max(); + if let Some(h) = highest { + self.reserve_ids(NodeId(h)); + } + let n = self.alloc_id().0; + (space_of(n) == client && highest.is_none_or(|h| n > h)).then_some(n) + } + + /// A new style id. + pub fn new_style_id(&mut self) -> StyleId { + let taken: Vec = self.styles.iter().filter_map(|s| id_number(&s.id, STYLE_ID_PREFIX)).collect(); + if let Some(n) = self.session_number(taken) { + return format!("{STYLE_ID_PREFIX}{n}"); + } + (self.styles.len() + 1..) + .map(|k| format!("{STYLE_ID_PREFIX}{k}")) + .find(|id| self.style(id).is_none()) + .unwrap_or_else(|| format!("{STYLE_ID_PREFIX}0")) + } + + /// A new variable id. + pub fn new_variable_id(&mut self) -> VariableId { + let taken: Vec = self.variables.variables.iter().filter_map(|v| id_number(&v.id, VARIABLE_ID_PREFIX)).collect(); + self.new_numbered_id(VARIABLE_ID_PREFIX, taken) + } + + /// A new variable collection id. + pub fn new_collection_id(&mut self) -> CollectionId { + let taken: Vec = self.variables.collections.iter().filter_map(|c| id_number(&c.id, COLLECTION_ID_PREFIX)).collect(); + self.new_numbered_id(COLLECTION_ID_PREFIX, taken) + } + + /// ``: from this client's space in a session, else one above the highest of `taken`. + fn new_numbered_id(&mut self, prefix: &str, taken: Vec) -> String { + let n = match self.session_number(taken.iter().copied()) { + Some(n) => n, + None => taken.iter().max().map_or(1, |m| m.saturating_add(1)), + }; + format!("{prefix}{n}") + } + + /// A new id for a mode of collection `collection` (`None`: no such collection). + pub fn new_mode_id(&mut self, collection: &str) -> Option { + let modes = &self.variables.collections.iter().find(|c| c.id == collection)?.modes; + let count = modes.len(); + let free = |doc: &Document, id: &str| doc.variables.collections.iter().any(|c| c.id == collection && c.modes.iter().all(|m| m.id != id)); + let taken: Vec = self.variables.collections.iter().flat_map(|c| c.modes.iter().filter_map(|m| mode_number(&m.id))).collect(); + if let Some(n) = self.session_number(taken) { + let id = format!("{collection}{MODE_ID_INFIX}{n}"); + if free(self, &id) { + return Some(id); + } + } + (count + 1..).map(|k| format!("{collection}{MODE_ID_INFIX}{k}")).find(|id| free(self, id)) + } + + /// A new comment thread id. + pub fn new_comment_id(&mut self) -> u64 { + let taken: Vec = self.comments.iter().map(|c| c.id).collect(); + self.session_number(taken).unwrap_or_else(|| self.next_comment_id()) + } + + /// The highest client id space any id in the document lies in (layers, comments, styles, + /// variables, collections, modes): a session hands out client ids above it, so ids made in + /// earlier sessions are never made again. + pub fn highest_id_space(&self) -> u32 { + let nodes = self.ids().map(|id| id.0); + let comments = self.comments.iter().map(|c| c.id); + let styles = self.styles.iter().filter_map(|s| id_number(&s.id, STYLE_ID_PREFIX)); + let vars = self.variables.variables.iter().filter_map(|v| id_number(&v.id, VARIABLE_ID_PREFIX)); + let colls = self.variables.collections.iter().filter_map(|c| id_number(&c.id, COLLECTION_ID_PREFIX)); + let modes = self.variables.collections.iter().flat_map(|c| c.modes.iter().filter_map(|m| mode_number(&m.id))); + nodes.chain(comments).chain(styles).chain(vars).chain(colls).chain(modes).map(space_of).max().unwrap_or(0) + } +} diff --git a/crates/doc/src/ids_tests.rs b/crates/doc/src/ids_tests.rs new file mode 100644 index 0000000..8565b4f --- /dev/null +++ b/crates/doc/src/ids_tests.rs @@ -0,0 +1,106 @@ +//! Ids of styles, variables, collections, modes and comments: unchanged outside a live session, +//! from the client's own space during one. + +use crate::ids::{COLLECTION_ID_PREFIX, id_number, id_space, mode_number, space_of}; +use crate::{Collection, Comment, Document, Message, Mode, NodeId, NodeKind, Style, StyleKind}; + +const C3: u64 = 3 << 32; + +fn style(id: &str) -> Style { + Style { id: id.into(), name: format!("Style {id}"), description: String::new(), kind: StyleKind::Paint { paints: Vec::new() } } +} + +fn comment(id: u64, page: NodeId) -> Comment { + Comment { id, page, x: 0.0, y: 0.0, anchor: None, first: Message::new("A", "hi", 0), resolved: false, replies: Vec::new(), read_by: None } +} + +fn collection(id: &str, modes: &[&str]) -> Collection { + Collection { id: id.into(), name: id.into(), modes: modes.iter().map(|m| Mode { id: (*m).into(), name: (*m).into() }).collect() } +} + +#[test] +fn outside_a_session_ids_are_made_as_before() { + let mut d = Document::new("a"); + let page = d.first_page().unwrap(); + assert_eq!(d.new_style_id(), "S:1"); + d.styles.push(style("S:1")); + d.styles.push(style("S:3")); + // The first free number from the count up. + assert_eq!(d.new_style_id(), "S:4"); + assert_eq!(d.new_variable_id(), "VariableID:1"); + assert_eq!(d.new_collection_id(), "VariableCollectionId:1"); + d.variables.collections.push(collection("VariableCollectionId:7", &["VariableCollectionId:7/mode1", "VariableCollectionId:7/mode3"])); + assert_eq!(d.new_collection_id(), "VariableCollectionId:8"); + assert_eq!(d.new_mode_id("VariableCollectionId:7").as_deref(), Some("VariableCollectionId:7/mode4")); + assert_eq!(d.new_mode_id("nope"), None); + assert_eq!(d.new_comment_id(), 1); + d.comments.push(comment(5, page)); + assert_eq!(d.new_comment_id(), 6); + // Nothing allocated from the layer counter. + assert_eq!(d.next_id(), NodeId(2)); +} + +#[test] +fn in_a_session_ids_come_from_the_clients_space_and_never_repeat() { + let mut d = Document::new("a"); + let page = d.first_page().unwrap(); + d.styles.push(style("S:1")); + d.set_client_id(3); + let s1 = d.new_style_id(); + assert_eq!(id_number(&s1, "S:").map(space_of), Some(3), "{s1}"); + d.styles.push(style(&s1)); + let s2 = d.new_style_id(); + assert_ne!(s1, s2); + // Deleted ids aren't made again. + d.styles.retain(|s| s.id != s1); + let s3 = d.new_style_id(); + assert!(s3 != s1 && s3 != s2); + let v = d.new_variable_id(); + let c = d.new_collection_id(); + assert_eq!(id_space(&v, "VariableID:"), 3); + assert_eq!(id_space(&c, COLLECTION_ID_PREFIX), 3); + d.variables.collections.push(collection(&c, &[&format!("{c}/mode1")])); + let m = d.new_mode_id(&c).unwrap(); + assert_eq!(mode_number(&m).map(space_of), Some(3), "{m}"); + let k = d.new_comment_id(); + assert_eq!(space_of(k), 3); + d.comments.push(comment(k, page)); + assert!(d.new_comment_id() > k); + // A layer made next doesn't reuse any of those numbers either. + let r = d.create(page, NodeKind::Rectangle, "R", 0.0, 0.0, 1.0, 1.0).unwrap(); + assert!(r.0 > k && space_of(r.0) == 3); +} + +#[test] +fn ids_of_the_space_already_in_the_document_stay_taken() { + // A document rebuilt from a snapshot: its allocator hasn't seen this client's styles. + let mut d = Document::new("a"); + d.styles.push(style(&format!("S:{}", C3 | 40))); + d.comments.push(comment(C3 | 41, d.first_page().unwrap())); + d.set_client_id(3); + assert_eq!(d.new_style_id(), format!("S:{}", C3 | 41)); + assert_eq!(d.new_comment_id(), C3 | 42); + // Someone else's ids don't move this client's numbers. + let mut e = Document::new("b"); + e.styles.push(style(&format!("S:{}", (5u64 << 32) | 9))); + e.set_client_id(3); + assert_eq!(e.new_style_id(), format!("S:{}", C3 | 1)); +} + +#[test] +fn the_highest_space_counts_every_kind_of_id() { + let mut d = Document::new("a"); + assert_eq!(d.highest_id_space(), 0); + d.styles.push(style(&format!("S:{}", (4u64 << 32) | 1))); + assert_eq!(d.highest_id_space(), 4); + d.variables.collections.push(collection("lib:Tokens", &[&format!("lib:Tokens/mode{}", (9u64 << 32) | 2)])); + assert_eq!(d.highest_id_space(), 9); + d.comments.push(comment(11u64 << 32, d.first_page().unwrap())); + assert_eq!(d.highest_id_space(), 11); + // Other forms are space 0. + assert_eq!(id_space("lib:S:99999999999", "S:"), 0); + assert_eq!(id_space("S:+5", "S:"), 0); + assert_eq!(id_space("S:", "S:"), 0); + assert_eq!(id_space("S:99999999999999999999999", "S:"), 0, "too big for a number"); + assert_eq!(mode_number("1:0"), None); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 92b9540..f836b26 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -12,6 +12,7 @@ pub mod colors; pub mod comments; pub mod geometry; pub mod handoff; +pub mod ids; pub mod library; pub mod node; pub mod props; @@ -1002,6 +1003,8 @@ mod effects_tests; #[cfg(test)] mod handoff_tests; #[cfg(test)] +mod ids_tests; +#[cfg(test)] mod print_tests; #[cfg(test)] mod proto_expr_mode_tests; diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs index 845b42a..fddc83a 100644 --- a/crates/engine/src/cmd/comment.rs +++ b/crates/engine/src/cmd/comment.rs @@ -393,7 +393,7 @@ pub fn specs() -> Vec { let anchor = anchor_at(doc, page, x, y, get(p, "anchor")); let (lx, ly) = local(doc, anchor, x, y); let mut c = Comment { - id: doc.next_comment_id(), + id: s.doc_mut()?.new_comment_id(), page, x: lx, y: ly, diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 48bbcfb..1419ba1 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -90,7 +90,7 @@ pub fn specs() -> Vec { spec!("variable.createCollection", "Create variable collection", "{name, modes? [names]}", true, |s, p| { let name = req_string("variable.createCollection", p, "name")?; let doc = s.doc_mut()?; - let id = unique_id("VariableCollectionId", doc.variables.collections.iter().map(|c| c.id.as_str())); + let id = doc.new_collection_id(); let modes: Vec = match get(p, "modes") { Some(Value::Array(a)) => a.iter().filter_map(|m| m.as_str().map(str::to_string)).collect(), _ => vec!["Mode 1".into()], @@ -102,19 +102,15 @@ pub fn specs() -> Vec { spec!("variable.addMode", "Add mode", "{collection, name}", true, |s, p| { let c = req_string("variable.addMode", p, "collection")?; let name = req_string("variable.addMode", p, "name")?; + let missing = || bad("variable.addMode", "no such collection"); let doc = s.doc_mut()?; - let coll = doc - .variables - .collections - .iter_mut() - .find(|x| x.id == c || x.name == c) - .ok_or_else(|| bad("variable.addMode", "no such collection"))?; - // A fresh id: modes can be deleted, so the count alone may repeat one. - let mid = - (coll.modes.len() + 1..).map(|k| format!("{}/mode{k}", coll.id)).find(|m| !coll.modes.iter().any(|x| x.id == *m)).unwrap_or_default(); + let cid = doc.variables.collections.iter().find(|x| x.id == c || x.name == c).map(|x| x.id.clone()).ok_or_else(missing)?; + // A fresh id (modes can be deleted, so the count alone may repeat one; in a live + // session it comes from this client's id space). + let mid = doc.new_mode_id(&cid).ok_or_else(missing)?; + let coll = doc.variables.collections.iter_mut().find(|x| x.id == cid).ok_or_else(missing)?; let first = coll.modes.first().map(|m| m.id.clone()); coll.modes.push(Mode { id: mid.clone(), name }); - let cid = coll.id.clone(); // New mode starts with the default mode's values. for v in doc.variables.variables.iter_mut().filter(|v| v.collection == cid) { if let Some(val) = first.as_ref().and_then(|f| v.values.get(f)).cloned() { @@ -162,17 +158,6 @@ pub fn specs() -> Vec { ] } -fn next_style_id(doc: &Document) -> String { - let mut k = doc.styles.len() + 1; - loop { - let id = format!("S:{k}"); - if doc.style(&id).is_none() { - return id; - } - k += 1; - } -} - /// A text layer and the chars a command covers in it (None = all of them). pub(crate) type TextTarget = (NodeId, Option<(usize, usize)>); @@ -290,7 +275,7 @@ fn create(s: &mut Session, p: &Value) -> Result { let n = from.and_then(|f| doc.node(f)).ok_or_else(|| bad(cmd, "pass `value` or select a layer to take it from"))?; kind_from_node(&t, n).ok_or_else(|| bad(cmd, "that layer has no such property"))? }; - let sid = next_style_id(doc); + let sid = doc.new_style_id(); doc.styles.push(Style { id: sid.clone(), name, description: string(p, "description").unwrap_or_default(), kind: kind.clone() }); // The source layer (or the selected chars) becomes linked to the new style. if let (Some((tid, a, b)), StyleKind::Text { style }) = (range, &kind) { @@ -615,7 +600,7 @@ fn var_create(s: &mut Session, p: &Value) -> Result { for m in &coll.modes { values.entry(m.id.clone()).or_insert_with(|| default.clone()); } - let id = unique_id("VariableID", doc.variables.variables.iter().map(|v| v.id.as_str())); + let id = doc.new_variable_id(); doc.variables.variables.push(Variable { id: id.clone(), name, @@ -783,12 +768,6 @@ fn apply_effect_bindings(doc: &mut Document, id: NodeId, sets: &[(usize, String, } } -/// `prefix:N` with N one more than the largest existing. -pub(crate) fn unique_id<'a>(prefix: &str, existing: impl Iterator) -> String { - let max = existing.filter_map(|e| e.strip_prefix(prefix)?.strip_prefix(':')?.parse::().ok()).max().unwrap_or(0); - format!("{prefix}:{}", max + 1) -} - /// Solid fill and stroke colours used under `page` (alpha includes the paint's opacity), most used /// first. pub fn document_colors(doc: &Document, page: NodeId) -> Vec<(Color, usize)> { diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index a5ad6c7..2940617 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -100,7 +100,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result { .map(|k| if k == 1 { format!("{} copy", copy.name) } else { format!("{} copy {k}", copy.name) }) .find(|n| !taken(n)) .ok_or_else(|| bad(cmd, "too many copies"))?; - copy.id = super::styles::unique_id("VariableID", doc.variables.variables.iter().map(|x| x.id.as_str())); + copy.id = doc.new_variable_id(); copy.name = name; let id = copy.id.clone(); doc.variables.variables.insert(pos + 1, copy); diff --git a/crates/engine/src/collab_ids_tests.rs b/crates/engine/src/collab_ids_tests.rs new file mode 100644 index 0000000..bf6ed1f --- /dev/null +++ b/crates/engine/src/collab_ids_tests.rs @@ -0,0 +1,94 @@ +//! Several people creating styles, variables, collections, modes and comments at once with real +//! commands: each gets ids from its own client space, so nothing created is overwritten. + +use figcraft_collab::sim::Sim; +use figcraft_collab::{Change, Entry}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::collab::EngineRecompute; +use crate::collab_tests::base; + +/// The commands one person runs in a round, before seeing anyone else's edits. +fn creations(who: usize, round: usize) -> Vec<(&'static str, Value)> { + let name = format!("p{who} r{round}"); + vec![ + ("style.create", json!({"type": "paint", "name": name, "value": "#3366FF"})), + ("variable.create", json!({"collection": "Tokens", "name": name, "type": "number", "value": who})), + ("variable.duplicate", json!({"variable": "v0"})), + ("variable.createCollection", json!({"name": name})), + ("variable.addMode", json!({"collection": name, "name": "Dark"})), + ("comment.add", json!({"x": 10 * who, "y": 10 * round, "text": name})), + ] +} + +#[test] +fn concurrent_creations_with_real_commands_all_survive() { + let start = base(); + let (styles, vars, colls, comments) = + (start.styles.len(), start.variables.variables.len(), start.variables.collections.len(), start.comments.len()); + for seed in 0..8 { + let n = 2 + (seed as usize % 3); + let rounds = 2; + let mut sim = Sim::new(start.clone(), n, seed, 10).unwrap(); + for round in 0..rounds { + for i in 0..n { + let mut s = Session::with_document(sim.document(i).unwrap().clone()); + for (cmd, params) in creations(i, round) { + s.execute(cmd, ¶ms).unwrap_or_else(|e| panic!("seed {seed}: {cmd} {params}: {e}")); + } + sim.edit(i, s.doc().unwrap()).unwrap(); + } + for _ in 0..sim.rng().below(5) { + sim.step(&EngineRecompute).unwrap(); + } + } + sim.settle(&EngineRecompute, 1_000_000).unwrap(); + sim.converged().unwrap_or_else(|e| panic!("seed {seed}: {e}")); + assert_eq!(sim.rejected, 0, "seed {seed}"); + let d = sim.server.document(); + let made = n * rounds; + assert_eq!(d.styles.len(), styles + made, "seed {seed}: every style survives"); + // Each round: one new variable and one copy. + assert_eq!(d.variables.variables.len(), vars + 2 * made, "seed {seed}: every variable survives"); + assert_eq!(d.variables.collections.len(), colls + made, "seed {seed}"); + assert_eq!(d.comments.len(), comments + made, "seed {seed}: every comment survives"); + for who in 0..n { + for round in 0..rounds { + let name = format!("p{who} r{round}"); + let coll = d.variables.collections.iter().find(|c| c.name == name).unwrap_or_else(|| panic!("seed {seed}: {name}'s collection")); + assert_eq!(coll.modes.len(), 2, "seed {seed}: {name}'s modes"); + assert!(d.styles.iter().any(|s| s.name == name) && d.comments.iter().any(|c| c.first.text == name), "seed {seed}: {name}"); + } + } + } +} + +#[test] +fn outside_a_session_the_ids_are_unchanged() { + let mut s = Session::with_document(base()); + let id = |v: Value| v["id"].clone(); + assert_eq!(id(s.execute("style.create", &json!({"type": "paint", "name": "A", "value": "#000000"})).unwrap()), "S:1"); + assert_eq!(id(s.execute("variable.createCollection", &json!({"name": "More"})).unwrap()), "VariableCollectionId:2"); + assert_eq!(id(s.execute("variable.addMode", &json!({"collection": "More", "name": "Dark"})).unwrap()), "VariableCollectionId:2/mode2"); + assert_eq!(id(s.execute("variable.create", &json!({"collection": "More", "name": "x", "type": "number", "value": 1})).unwrap()), "VariableID:5"); + assert_eq!(id(s.execute("comment.add", &json!({"x": 0, "y": 0, "text": "hi"})).unwrap()), 1); +} + +#[test] +fn a_style_made_in_a_session_syncs_as_an_entry_of_the_creators_space() { + let mut sim = Sim::new(base(), 2, 7, 3).unwrap(); + let mut s = Session::with_document(sim.document(1).unwrap().clone()); + let me = sim.clients()[1].id(); + let r = s.execute("style.create", &json!({"type": "paint", "name": "Mine", "value": "#FF0000"})).unwrap(); + let sid = r["id"].as_str().unwrap().to_string(); + let doc = s.doc().unwrap().clone(); + let batch = figcraft_collab::diff(sim.clients()[1].view(), &doc).unwrap(); + let entry = + batch.changes.iter().find_map(|c| if let Change::Put { entry: e @ Entry::Style(_), .. } = c { Some(e.clone()) } else { None }).unwrap(); + assert_eq!(entry, Entry::Style(sid)); + assert_eq!(entry.id_space(), Some(me)); + sim.edit(1, &doc).unwrap(); + sim.settle(&EngineRecompute, 10_000).unwrap(); + assert!(sim.server.document().styles.iter().any(|st| st.name == "Mine")); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 09498ee..02eff05 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1129,6 +1129,8 @@ mod autosave_tests; #[cfg(test)] mod binding_lists_tests; #[cfg(test)] +mod collab_ids_tests; +#[cfg(test)] mod collab_live_tests; #[cfg(test)] mod collab_tests; diff --git a/crates/engine/src/live.rs b/crates/engine/src/live.rs index 7cc8747..34af732 100644 --- a/crates/engine/src/live.rs +++ b/crates/engine/src/live.rs @@ -32,7 +32,7 @@ pub use figcraft_collab::message::{Peer, Presence}; use crate::cmd::CommandSpec; use crate::collab::EngineRecompute; use crate::params::{get, num, req_string, string}; -use crate::{DocState, EngineError, Result, Session, ViewState, bad, spec}; +use crate::{DocState, EngineError, Result, Session, TextEdit, ViewState, bad, spec}; /// Most selected ids one presence update carries (the rest aren't shown to others). pub const MAX_SELECTION: usize = 10_000; @@ -331,6 +331,24 @@ fn close(a: f64, b: f64) -> bool { (a - b).abs() <= 1e-9 * a.abs().max(b.abs()).max(1.0) } +/// What others are told about your text editing: the layer, caret and selected characters. +fn editing_of(t: &TextEdit) -> EditingText { + let (a, b) = (t.anchor.min(t.caret), t.anchor.max(t.caret)); + EditingText { node: t.id, caret: t.caret, selection: (a != b).then_some((a, b)) } +} + +/// The note shown while others type in the text layer you look at: "Hana is editing this text", +/// "Hana and Ada are…", "Hana and 2 others are…" (`None`: nobody). +pub fn editing_hint(names: &[&str]) -> Option { + let verb = "editing this text"; + Some(match names { + [] => return None, + [one] => format!("{one} is {verb}"), + [a, b] => format!("{a} and {b} are {verb}"), + [first, rest @ ..] => format!("{first} and {} others are {verb}", rest.len()), + }) +} + /// A gesture, version preview or live text preview shows a document that isn't the edit to /// sync yet. fn busy(ds: &DocState) -> bool { @@ -375,13 +393,28 @@ impl Session { cursor: self.live.cursor.map(|(x, y)| (ds.page, x, y)), selection: Some(ds.selection.clone()), viewport: Some((ds.page, x0, y0, ds.view.vw / z, ds.view.vh / z)), - editing_text: ds.text_edit.as_ref().map(|t| { - let (a, b) = (t.anchor.min(t.caret), t.anchor.max(t.caret)); - Box::new(EditingText { node: t.id, caret: t.caret, selection: (a != b).then_some((a, b)) }) - }), + editing_text: ds.text_edit.as_ref().map(|t| Box::new(editing_of(t))), }) } + /// The others typing in text layer `id` of the session document (only while it is in front). + pub fn live_text_editors(&self, id: NodeId) -> Vec<&Peer> { + if !self.live_shared() { + return Vec::new(); + } + let live = &self.live; + live.presence.iter().filter(|(_, p)| p.editing_text.as_ref().is_some_and(|e| e.node == id)).filter_map(|(c, _)| live.peers.get(c)).collect() + } + + /// You started typing in text layer `id`: say so when someone else is typing in it too (both + /// edits sync when they finish; the one finished last wins). + pub(crate) fn live_text_notice(&mut self, id: NodeId) { + let names: Vec<&str> = self.live_text_editors(id).iter().map(|p| p.name.as_str()).collect(); + if let Some(hint) = editing_hint(&names) { + self.message = Some(format!("{hint} too: the version finished last is kept")); + } + } + /// Take in what happened in the session (the UI calls it every frame; the network wakes the /// UI when something arrives). Returns whether anything changed. pub fn live_poll(&mut self) -> bool { @@ -728,11 +761,22 @@ impl Session { pub fn live_summary(&self) -> Value { let l = &self.live; let info = l.info.as_ref(); + let synced = l.doc_uid.and_then(|u| self.docs.iter().find(|d| d.uid == u)); + // Who types in which layer of the session document. + let editing = |e: &EditingText| { + let layer = synced.and_then(|d| d.doc.node(e.node)).map(|n| n.name.as_str()); + json!({"node": e.node, "layer": layer, "caret": e.caret, "selection": e.selection}) + }; let peers: Vec = l .peers .values() - .map(|p| json!({"id": p.client_id, "name": p.name, "color": p.color, "presence": l.presence.get(&p.client_id)})) + .map(|p| { + let presence = l.presence.get(&p.client_id); + let typing = presence.and_then(|pr| pr.editing_text.as_deref()).map(editing); + json!({"id": p.client_id, "name": p.name, "color": p.color, "editing": typing, "presence": presence}) + }) .collect(); + let mine = synced.and_then(|d| d.text_edit.as_ref()).map(|t| editing(&editing_of(t))); json!({ "available": l.available(), "canHost": l.can_host(), @@ -752,6 +796,7 @@ impl Session { "follow": l.follow, "syncing": l.syncing(), "document": l.doc_uid.and_then(|u| self.docs.iter().position(|d| d.uid == u)), + "editing": mine, "peers": peers, }) } @@ -850,7 +895,7 @@ pub fn specs() -> Vec { spec!( "collab.peers", "People in this file", - "{} → {available, canHost (false in the browser: it can only join), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, follow, syncing, document (index of the synced open document), peers: [{id, name, color, presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}]}", + "{} → {available, canHost (false in the browser: it can only join), status: off|connecting|live|reconnecting|failed, reason?, role, me, name, color, fileName, pages, link, url, address, network, follow, syncing, document (index of the synced open document), editing (the text layer you type in: {node, layer, caret, selection}), peers: [{id, name, color, editing (theirs, same shape, or null), presence: {cursor: [page, x, y], selection, viewport: [page, x, y, w, h], editing_text}}]}", false, |s, _| Ok(s.live_summary()) ), diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 7ab7262..8330406 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -24,6 +24,7 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - ds.text_edit = Some(TextEdit { id, caret, anchor, created, preedit: None }); ds.selection = vec![id]; ds.revision += 1; + s.live_text_notice(id); Ok(()) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 21f37c7..77d1b7a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -192,6 +192,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { crate::dev_mode::draw(app, &painter); crate::handles::draw(app, &painter); crate::comments::draw(app, &painter); + crate::live_text::draw(app, &painter); crate::live::draw(app, &painter, &ctx); crate::comments::popovers(app, &ctx); crate::versions::preview_banner(app, &ctx, rect); @@ -479,6 +480,52 @@ fn stroke(c: Color32, w: f32) -> Stroke { Stroke::new(w, c) } +/// A text layer's layout placed on the canvas: where carets and selected characters show (yours +/// and other people's in a live session). +pub(crate) struct TextOnScreen { + pub layout: Arc, + /// How many characters the text has. + pub chars: usize, + /// The layout's vertical offset in the box (vertical alignment). + dy: f64, + world: figcraft_geom::Affine, +} + +impl TextOnScreen { + /// Text layer `id` of `doc` (`None`: not a text layer). + pub(crate) fn of(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + let NodeKind::Text(t) = &n.kind else { return None }; + let (layout, dy) = figcraft_text::layout_node(n)?; + Some(Self { layout, chars: t.char_count(), dy, world: doc.world_transform(id) }) + } + + /// Layout point `(x, y)` on screen. + pub(crate) fn at(&self, app: &FigApp, x: f64, y: f64) -> Pos2 { + let p = self.world * Point::new(x, y + self.dy); + app.to_screen(p.x, p.y) + } + + /// The highlight of characters `a..b`, one screen quad per line. + pub(crate) fn selection(&self, app: &FigApp, a: usize, b: usize) -> Vec<[Pos2; 4]> { + let rects = self.layout.selection_rects(a, b); + rects.iter().map(|r| [self.at(app, r.x0, r.y0), self.at(app, r.x1, r.y0), self.at(app, r.x1, r.y1), self.at(app, r.x0, r.y1)]).collect() + } + + /// The caret before character `index`: its top and bottom on screen (an empty layer: a line + /// of default height at its origin). + pub(crate) fn caret(&self, app: &FigApp, index: usize, zoom: f64) -> [Pos2; 2] { + let (cx, li) = self.layout.caret(index); + match self.layout.lines.get(li).or(self.layout.lines.last()) { + Some(line) => [self.at(app, cx, line.top), self.at(app, cx, line.top + line.height)], + None => { + let p = self.at(app, 0.0, 0.0); + [p, p + vec2(0.0, 14.0 * zoom as f32)] + } + } + } +} + /// A node's oriented outline in screen space. pub(crate) fn node_quad(app: &FigApp, doc: &Document, id: NodeId) -> Option<[Pos2; 4]> { let n = doc.node(id)?; @@ -781,22 +828,16 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ let preedit = app.preedit_view(); let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(&doc); if let Some(te) = editing - && let Some(n) = edoc.node(te.id) - && let Some((layout, dy)) = figcraft_text::layout_node(n) + && let Some(text) = TextOnScreen::of(edoc, te.id) { - let w = edoc.world_transform(te.id); - let to = |x: f64, y: f64| { - let p = w * Point::new(x, y + dy); - app.to_screen(p.x, p.y) - }; + let layout = &text.layout; if let Some(q) = node_quad(app, edoc, te.id) { quad(painter, q, stroke(ACCENT, 1.0)); } let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor)); if a != b && preedit.is_none() { - for r in layout.selection_rects(a, b) { - let qq = [to(r.x0, r.y0), to(r.x1, r.y0), to(r.x1, r.y1), to(r.x0, r.y1)]; - painter.add(Shape::convex_polygon(qq.to_vec(), ACCENT.gamma_multiply(0.3), Stroke::NONE)); + for q in text.selection(app, a, b) { + painter.add(Shape::convex_polygon(q.to_vec(), ACCENT.gamma_multiply(0.3), Stroke::NONE)); } } if let Some(p) = preedit { @@ -812,21 +853,15 @@ fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec<(NodeId, [ .map(|g| g.x + g.advance) .fold(r.x0, f64::max); let y = line.baseline + (line.top + line.height - line.baseline) * 0.35; - painter.line_segment([to(r.x0, y), to(x1, y)], stroke(t.text, 1.0)); + painter.line_segment([text.at(app, r.x0, y), text.at(app, x1, y)], stroke(t.text, 1.0)); } } let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret); - let (cx, li) = layout.caret(caret); - if let Some(line) = layout.lines.get(li).or(layout.lines.last()) { - let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0; - if blink || a != b { - painter.line_segment([to(cx, line.top), to(cx, line.top + line.height)], stroke(t.text, 1.0)); - } - painter.ctx().request_repaint_after(std::time::Duration::from_millis(500)); - } else { - let p = to(0.0, 0.0); - painter.line_segment([p, p + vec2(0.0, 14.0 * zoom as f32)], stroke(t.text, 1.0)); + let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0; + if blink || a != b { + painter.line_segment(text.caret(app, caret, zoom), stroke(t.text, 1.0)); } + painter.ctx().request_repaint_after(std::time::Duration::from_millis(500)); } // Smart guides and measurements (their labels kept clear of the size label). crate::measure_overlay::draw(app, painter, t, size_label); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 51e7c43..60ed3ce 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -30,6 +30,7 @@ pub mod layers; pub mod layout_panel; pub mod list_drag; pub mod live; +pub mod live_text; pub mod measure_overlay; pub mod menus; pub mod plugins_ui; @@ -77,6 +78,8 @@ mod live_join_tests; #[cfg(test)] mod live_tests; #[cfg(test)] +mod live_text_tests; +#[cfg(test)] mod panel_reorder_tests; #[cfg(test)] mod perf_tests; @@ -786,7 +789,10 @@ impl FigApp { .corner_radius(egui::CornerRadius::same(8)) .inner_margin(egui::Margin::symmetric(14, 10)) .show(ui, |ui| { - ui.label(egui::RichText::new(msg).color(egui::Color32::WHITE).font(theme::regular(12.0))); + // Wrapped at a readable width (the area alone would wrap at a sliver). + let width = (screen.width() - 64.0).clamp(120.0, 480.0); + let galley = ui.painter().layout(msg, theme::regular(12.0), egui::Color32::WHITE, width); + ui.label(galley); }); }); } diff --git a/crates/ui-egui/src/live.rs b/crates/ui-egui/src/live.rs index b46ee04..ec23967 100644 --- a/crates/ui-egui/src/live.rs +++ b/crates/ui-egui/src/live.rs @@ -86,10 +86,11 @@ pub fn pointer(painter: &egui::Painter, tip: Pos2, fill: Color32) { painter.add(Shape::closed_line(vec![tip, left, notch, right], Stroke::new(1.5, Color32::WHITE))); } -/// The name tag next to a remote pointer. -fn name_tag(painter: &egui::Painter, tip: Pos2, name: &str, fill: Color32) { +/// A name tag in a person's colour, placed by `align` at `anchor` (next to their pointer, above +/// their text caret). +pub(crate) fn name_tag(painter: &egui::Painter, anchor: Pos2, align: Align2, name: &str, fill: Color32) { let galley = painter.layout_no_wrap(name.to_string(), semibold(11.0), text_on(fill)); - let r = Rect::from_min_size(tip + vec2(12.0, 16.0), galley.size() + vec2(12.0, 6.0)); + let r = align.anchor_size(anchor, galley.size() + vec2(12.0, 6.0)); painter.rect_filled(r, CornerRadius::same(4), fill); painter.galley(r.min + vec2(6.0, 3.0), galley, text_on(fill)); } @@ -162,7 +163,7 @@ pub fn draw(app: &mut FigApp, painter: &egui::Painter, ctx: &egui::Context) { shown.insert(id, (sx, sy)); let tip = app.to_screen(sx, sy); pointer(painter, tip, fill); - name_tag(painter, tip, &name, fill); + name_tag(painter, tip + vec2(12.0, 16.0), Align2::LEFT_TOP, &name, fill); } app.ui.live.shown = shown; if gliding { diff --git a/crates/ui-egui/src/live_tests.rs b/crates/ui-egui/src/live_tests.rs index d369d39..ea0df9e 100644 --- a/crates/ui-egui/src/live_tests.rs +++ b/crates/ui-egui/src/live_tests.rs @@ -15,7 +15,7 @@ use serde_json::json; use crate::present_tests::id; use crate::{FigApp, NoServices}; -const HANA: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); +pub(crate) const HANA: Color32 = Color32::from_rgb(0x97, 0x47, 0xFF); /// A link that hands out scripted events. struct Mock(Arc>>); @@ -43,7 +43,7 @@ pub(crate) struct Harness { } /// Everything painted, flattened. -fn flat(shapes: &[egui::epaint::ClippedShape]) -> Vec { +pub(crate) fn flat(shapes: &[egui::epaint::ClippedShape]) -> Vec { fn walk(s: &Shape, out: &mut Vec) { match s { Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), @@ -97,12 +97,12 @@ impl Harness { } } - fn send(&mut self, e: Vec) { + pub(crate) fn send(&mut self, e: Vec) { self.events.lock().unwrap().extend(e); } /// Joined as Ada, with Hana (the host) already here. - fn joined(&mut self) { + pub(crate) fn joined(&mut self) { self.app.session.live.status = Status::Connecting; let info = SessionInfo { role: Role::Guest, @@ -121,7 +121,7 @@ impl Harness { /// The host's welcome with the document (a session server seeded with this document): it opens /// as a new tab, which is then the synced document. - fn sync(&mut self) { + pub(crate) fn sync(&mut self) { use figcraft_engine::figcraft_collab::message::{ClientMsg, ServerMsg}; use figcraft_engine::figcraft_collab::{Client, Limits, Outgoing, Server}; let mut server = Server::new(self.app.session.doc().unwrap().clone(), "", Limits::default()); @@ -135,12 +135,30 @@ impl Harness { assert!(self.app.session.live_shared()); } - fn page(&self) -> NodeId { + pub(crate) fn page(&self) -> NodeId { self.app.session.page().unwrap() } + + /// Frames until the window stops asking for more (the canvas render finishing aside): + /// whether it did within a few hundred frames. + pub(crate) fn goes_quiet(&mut self) -> bool { + let mut quiet = 0; + for _ in 0..200 { + let rendering = self.app.worker.as_ref().is_some_and(|w| w.in_flight); + if rendering { + std::thread::sleep(Duration::from_millis(2)); + } + let (_, d) = self.frame(vec![]); + quiet = if d >= Duration::from_secs(1) && !rendering { quiet + 1 } else { 0 }; + if quiet >= 3 { + return true; + } + } + false + } } -fn texts(shapes: &[Shape]) -> Vec<(String, Pos2, egui::Vec2)> { +pub(crate) fn texts(shapes: &[Shape]) -> Vec<(String, Pos2, egui::Vec2)> { shapes .iter() .filter_map(|s| match s { @@ -161,7 +179,7 @@ pub(crate) fn text_center(shapes: &[Shape], s: &str) -> Pos2 { } /// Filled polygons and outlines in `c`. -fn filled(shapes: &[Shape], c: Color32) -> usize { +pub(crate) fn filled(shapes: &[Shape], c: Color32) -> usize { shapes.iter().filter(|s| matches!(s, Shape::Path(p) if p.fill == c)).count() } @@ -220,19 +238,7 @@ fn a_cursor_glides_then_rests() { h.frames(60); assert_eq!(crate::live::shown_at(&h.app)[0].1, (400.0, 0.0), "arrived"); // Settled: no more frames until something happens. - let mut quiet = 0; - for _ in 0..200 { - let rendering = h.app.worker.as_ref().is_some_and(|w| w.in_flight); - if rendering { - std::thread::sleep(Duration::from_millis(2)); - } - let (_, d) = h.frame(vec![]); - quiet = if d >= Duration::from_secs(1) && !rendering { quiet + 1 } else { 0 }; - if quiet >= 3 { - break; - } - } - assert!(quiet >= 3, "a resting cursor draws no frames: {:?}", h.ctx.repaint_causes()); + assert!(h.goes_quiet(), "a resting cursor draws no frames: {:?}", h.ctx.repaint_causes()); // Reduced motion: it jumps. h.app.session.prefs.reduce_motion = true; h.send(vec![LiveEvent::Presence(1, cursor(page, -50.0, 20.0))]); diff --git a/crates/ui-egui/src/live_text.rs b/crates/ui-egui/src/live_text.rs new file mode 100644 index 0000000..e107dc9 --- /dev/null +++ b/crates/ui-egui/src/live_text.rs @@ -0,0 +1,48 @@ +//! Other people's text carets in a live session: while someone types in a text layer of the +//! shared document, their caret and selected characters show on it in their colour, at the glyph +//! positions of your copy of the text (the text layout's own caret and selection geometry), with +//! their name above the caret; when you type in the same layer the tag says so. Remote carets +//! don't blink: they draw only when the canvas draws anyway (a presence update wakes it), so an +//! idle session asks for no frames. + +use egui::{Align2, Shape, Stroke, vec2}; +use figcraft_engine::live::editing_hint; + +use crate::FigApp; +use crate::canvas::TextOnScreen; +use crate::live::{color, name_tag}; + +/// How strongly someone else's selection tints the characters. +const SELECTION_ALPHA: f32 = 0.25; +/// Width of someone else's caret (points). +const CARET_WIDTH: f32 = 2.0; + +/// Draw the carets and selections of everyone typing in a text layer on your page. +pub fn draw(app: &FigApp, painter: &egui::Painter) { + let live = &app.session.live; + if live.presence.is_empty() || !app.session.live_shared() { + return; + } + let Ok(ds) = app.session.ds() else { return }; + let mine = ds.text_edit.as_ref().map(|t| t.id); + for (id, p) in &live.presence { + let (Some(peer), Some(e)) = (live.peers.get(id), p.editing_text.as_deref()) else { continue }; + if ds.doc.page_of(e.node) != Some(ds.page) { + continue; + } + let Some(text) = TextOnScreen::of(&ds.doc, e.node) else { continue }; + let fill = color(&peer.color); + // Positions count characters of their copy (typing syncs when they finish): kept within + // the text you have. + let at = |i: usize| i.min(text.chars); + if let Some((a, b)) = e.selection.map(|(a, b)| (at(a), at(b))).filter(|(a, b)| a < b) { + for q in text.selection(app, a, b) { + painter.add(Shape::convex_polygon(q.to_vec(), fill.gamma_multiply(SELECTION_ALPHA), Stroke::NONE)); + } + } + let [top, bottom] = text.caret(app, at(e.caret), ds.view.zoom); + painter.line_segment([top, bottom], Stroke::new(CARET_WIDTH, fill)); + let label = if mine == Some(e.node) { editing_hint(&[peer.name.as_str()]).unwrap_or_default() } else { peer.name.clone() }; + name_tag(painter, top.min(bottom) + vec2(-CARET_WIDTH / 2.0, -2.0), Align2::LEFT_BOTTOM, &label, fill); + } +} diff --git a/crates/ui-egui/src/live_text_tests.rs b/crates/ui-egui/src/live_text_tests.rs new file mode 100644 index 0000000..262c6d0 --- /dev/null +++ b/crates/ui-egui/src/live_text_tests.rs @@ -0,0 +1,125 @@ +//! Other people's text carets drawn headless: the caret and selection in their colour at the +//! glyphs of the text, their name above it, the note when you type in the same layer, what +//! `collab.peers` reports, and no frames while nothing moves. + +use egui::{Color32, Pos2, Shape}; +use figcraft_doc::NodeId; +use figcraft_engine::figcraft_collab::EditingText; +use figcraft_engine::live::{Event as LiveEvent, Presence, editing_hint}; +use serde_json::json; + +use crate::canvas::TextOnScreen; +use crate::live_tests::{HANA, Harness, filled, has_text}; +use crate::present_tests::id; + +/// In a session with Hana; a text layer "Hello world" in the shared file. +fn session() -> (Harness, NodeId) { + let (mut h, _) = Harness::new(); + let text = id(&h.app.session.execute("text.create", &json!({"text": "Hello world", "x": 100, "y": 300})).unwrap()); + h.app.session.execute("select.set", &json!({"ids": []})).unwrap(); + h.frames(3); + h.joined(); + h.sync(); + (h, text) +} + +fn typing(h: &mut Harness, node: NodeId, caret: usize, selection: Option<(usize, usize)>) { + let page = h.page(); + let presence = Presence { + viewport: Some((page, 0.0, 0.0, 800.0, 600.0)), + editing_text: Some(Box::new(EditingText { node, caret, selection })), + ..Presence::default() + }; + h.send(vec![LiveEvent::Presence(1, presence)]); +} + +/// Line segments drawn in `c`. +fn carets(shapes: &[Shape], c: Color32) -> Vec<[Pos2; 2]> { + shapes + .iter() + .filter_map(|s| match s { + Shape::LineSegment { points, stroke } if stroke.color == c => Some(*points), + _ => None, + }) + .collect() +} + +/// Where the caret before char `index` of `node` sits on screen now. +fn caret_at(h: &Harness, node: NodeId, index: usize) -> [Pos2; 2] { + let ds = h.app.session.ds().unwrap(); + TextOnScreen::of(&ds.doc, node).unwrap().caret(&h.app, index, ds.view.zoom) +} + +fn near(a: [Pos2; 2], b: [Pos2; 2]) -> bool { + a.iter().zip(b.iter()).all(|(p, q)| p.distance(*q) < 0.01) +} + +#[test] +fn a_remote_caret_and_selection_show_at_the_glyphs() { + let (mut h, text) = session(); + typing(&mut h, text, 5, Some((0, 5))); + let shapes = h.frames(2); + let drawn = carets(&shapes, HANA); + assert_eq!(drawn.len(), 1, "one caret in Hana's colour"); + assert!(near(drawn[0], caret_at(&h, text, 5)), "after \"Hello\": {drawn:?}"); + assert!(drawn[0][1].y - drawn[0][0].y > 4.0, "a line's height"); + // The selected chars, tinted in her colour (one line, where fonts lay the text out); her name + // above the caret. + let lines = { + let ds = h.app.session.ds().unwrap(); + TextOnScreen::of(&ds.doc, text).unwrap().selection(&h.app, 0, 5).len() + }; + assert!(lines <= 1); + assert_eq!(filled(&shapes, HANA.gamma_multiply(0.25)), lines, "the selected line"); + assert!(has_text(&shapes, "Hana")); + // A caret past the end of your copy (she typed more, not synced yet) sits at its end. + typing(&mut h, text, 999, None); + let shapes = h.frames(2); + let drawn = carets(&shapes, HANA); + assert!(near(drawn[0], caret_at(&h, text, 11)), "{drawn:?}"); + assert_eq!(filled(&shapes, HANA.gamma_multiply(0.25)), 0, "no selection"); + // collab.peers says who types where. + let peers = h.app.session.execute("collab.peers", &json!({})).unwrap(); + assert_eq!(peers["peers"][0]["editing"], json!({"node": text, "layer": "Hello world", "caret": 999, "selection": null})); + assert_eq!(peers["editing"], json!(null)); + // Done typing: gone. + h.send(vec![LiveEvent::Presence(1, Presence::default())]); + let shapes = h.frames(2); + assert!(carets(&shapes, HANA).is_empty() && !has_text(&shapes, "Hana")); +} + +#[test] +fn typing_in_the_same_layer_says_so() { + let (mut h, text) = session(); + typing(&mut h, text, 2, None); + h.frames(2); + assert!(h.app.run("text.edit.begin", json!({"id": text, "caret": 4})).is_some()); + let shapes = h.frames(2); + assert_eq!(h.app.ui.toast.as_ref().map(|t| t.0.as_str()), Some("Hana is editing this text too: the version finished last is kept")); + assert!(has_text(&shapes, "Hana is editing this text"), "the tag on her caret"); + assert_eq!(carets(&shapes, HANA).len(), 1); + let peers = h.app.session.execute("collab.peers", &json!({})).unwrap(); + assert_eq!(peers["editing"], json!({"node": text, "layer": "Hello world", "caret": 4, "selection": null})); + h.app.session.execute("text.edit.end", &json!({})).unwrap(); + // The names in the note. + assert_eq!(editing_hint(&[]), None); + assert_eq!(editing_hint(&["Ada", "Hana"]).as_deref(), Some("Ada and Hana are editing this text")); + assert_eq!(editing_hint(&["Ada", "Hana", "Lin"]).as_deref(), Some("Ada and 2 others are editing this text")); +} + +#[test] +fn a_resting_remote_caret_draws_no_frames() { + let (mut h, text) = session(); + typing(&mut h, text, 3, Some((1, 3))); + h.frames(2); + assert!(h.goes_quiet(), "a remote caret asks for no frames: {:?}", h.ctx.repaint_causes()); + // A layer on another page: nothing drawn here. + h.app.session.execute("page.add", &json!({"name": "Two"})).unwrap(); + h.app.session.execute("page.select", &json!({"index": 1})).unwrap(); + let elsewhere = id(&h.app.session.execute("text.create", &json!({"text": "Elsewhere", "x": 0, "y": 0})).unwrap()); + h.app.session.execute("page.select", &json!({"index": 0})).unwrap(); + assert_ne!(h.app.session.doc().unwrap().page_of(elsewhere), Some(h.page())); + typing(&mut h, elsewhere, 1, None); + let shapes = h.frames(2); + assert!(carets(&shapes, HANA).is_empty(), "on another page"); +} diff --git a/docs/collab-protocol.md b/docs/collab-protocol.md index 5505801..58b9f1e 100644 --- a/docs/collab-protocol.md +++ b/docs/collab-protocol.md @@ -8,7 +8,9 @@ embeds, invite links, document sync end to end (several apps edit one file), liv selections, avatars and follow. **Step 3 is implemented**: per-user undo and redo, rejection handling and offline queueing with reconnect (`crates/collab`, wired into the engine's live session and the transport). **Step 4 is implemented**: the browser build joins sessions a desktop -app hosts, and `figcraft://` invite links open the app and join. The rest is the plan the next +app hosts, and `figcraft://` invite links open the app and join. Styles, variables, collections, +modes and comments created in a session take ids from their creator's id space, and others' text +carets show on the canvas. The rest is the plan the next implementation PRs follow and update; sections say what is built. Goal: several people edit the same `.figcraft` file at the same time and see each other's edits, @@ -69,9 +71,30 @@ fit FigCraft's architecture: - An id is never *allocated* twice. A deleted node can come back with its id only by being created again whole (what undo does): the server accepts creating an id that doesn't exist only if it was deleted before or lies in the sender's own id space. -- Styles, variables, collections and comments keep their existing string or numeric ids (made - unique by the engine); a client-scoped scheme for them is still to come (until then two people - creating one at the same moment can get the same id, and the later write wins). +- **Document-level ids** (`figcraft_doc::ids`): styles, variables, variable collections, modes + and comment threads created during a session take a number from the same allocator as node ids + (`client_id << 32 | counter`, never handed out twice): `S:`, `VariableID:`, + `VariableCollectionId:`, `/mode` and comment id `n`. So two people creating a + style (or a variable, a comment…) at the same moment get different ids and both survive; before, + both got the next free number and the later write replaced the earlier object. Outside a session + ids are made exactly as before (`S:` with the first free `k`, one above the highest for + variables, collections and comments, the first free `mode`), so existing files and single-user + editing are unchanged. The number's high 32 bits are its id space; ids from before (small + numbers) and ids of other forms (library copies `library:key`, files placed into the document) + are in space 0. Server client ids start above every space used by any of these, not only by + layers. +- The server refuses a batch that creates a document-level entry (by `Put` or by replacing a whole + list) whose id lies in the space of another client this session gave an id to, unless the entry + was removed earlier in the session (undo puts entries back, whoever made them). Ids in space 0 or + in spaces this session never handed out (earlier sessions, other files) are accepted: they can't + collide with what a connected client makes, because a client's numbers continue above every id of + its space already in the document. Removed entries are part of the saved session state + (`SessionState::removed`). +- Objects inside an entry are not separate objects: modes are part of their collection, a + variable's values per mode part of the variable and replies part of their comment thread. Two + people adding a mode to the same collection, editing different modes of one variable, or replying + to the same thread at the same moment still resolve last-writer-wins on the whole entry (mode ids + no longer collide, so nothing is mixed up; one of the two additions is lost). ## The document as objects and properties @@ -113,8 +136,8 @@ As built: hash, the encoded bytes as base64). The page order is the pages' positions. - **Next**: this file's published library (it embeds a snapshot document; it moves with an asset channel, together with images, which then stop travelling inside batches); the version history - (kept beside the document, not in it) with the standalone server; client-scoped ids for new - styles, variables and comments. + (kept beside the document, not in it) with the standalone server; finer entries for modes, + per-mode variable values and comment replies (see Identifiers). - **Changes** (`Change`): `Create {id, kind, parent, position, props}` (only the properties that differ from a new node of that kind), `Set {id, prop, value}`, `Delete {id}` (the subtree; a delete of something already gone does nothing), and for document-level data @@ -186,7 +209,8 @@ messages). As built: the server validates each batch as a whole by applying it to a copy (limits on changes per batch and layers per file; values of the right type; finite, in-range sizes, transforms and opacity; known nodes; parents that exist and can hold layers; pages only at the top; no cycles; at -least one page; new ids from the sender's space), then either commits it with the next sequence +least one page; new layer ids from the sender's space; new styles, variables, collections and +comments not in another client's space), then either commits it with the next sequence number or rejects it with the current values of what it touched as fixes. The client keeps the confirmed state, applies its own batches at once, and on someone else's batch or a rejection rebuilds its view as the confirmed state with its pending batches replayed leniently (the fixes @@ -294,6 +318,18 @@ resuming with its offline edits, and leaving the session going back to snapshot outlines in the person's colour, and avatars (initials) at the top of the right panel beside Share; click an avatar to **follow** that person's viewport (`collab.follow {client}`), click again, press Escape or pan/zoom yourself to stop. The chevron lists everyone. +- **Text carets.** While someone types in a text layer of the shared document on your page, their + caret and selected characters show on it in their colour (the text layout's own caret and + selection geometry, so they sit at the right glyphs of your copy), with their name in a tag above + the caret. Their caret counts characters of their own copy: typing syncs as one batch when they + finish (text editing is one gesture), so until then a caret past the end of your copy sits at its + end. Remote carets don't blink, so they never ask for frames of their own. +- **Two people in one text layer.** Starting to type in a layer someone else is typing in shows + "Hana is editing this text too: the version finished last is kept" (the session's message), and + their caret's tag reads "Hana is editing this text" while you type. Both edits sync when each + person finishes, as whole values: the one the server orders last wins, as for any property. + `collab.peers` reports who types where (`editing {node, layer, caret, selection}` per person, and + yours). - Never stored; dropped when a client disconnects. Throttled to about 30 updates a second on both ends, latest wins (a value offered too soon waits and is replaced by newer ones, so the last position always arrives). A newcomer gets everyone's latest presence after its welcome. @@ -306,7 +342,7 @@ resuming with its offline edits, and leaving the session going back to snapshot popover). After a newcomer's `Welcome` the host sends it its own `PeerJoined`, which is how it learns its name and colour. - Idle cost is zero: network threads block, wake the window (`request_repaint`) only when a - message arrives, and cursors only ask for frames while they glide. + message arrives, cursors only ask for frames while they glide, and text carets never do. ## Hosting and joining @@ -466,8 +502,8 @@ sessions; an agent can be a participant with its own cursor. *Built*: the desktop app (control channel) and the MCP server's headless session (`figcraft-cli mcp`) both have the network link installed; `collab.join` returns at once with status -`"connecting"` and `collab.peers` tells when it is live (or why it failed). An agent's pointer is -`collab.cursor {x, y}`. The one-shot CLI (`figcraft-cli run`) has no link: there the commands say +`"connecting"` and `collab.peers` tells when it is live (or why it failed) and who is typing in +which text layer. An agent's pointer is `collab.cursor {x, y}`. The one-shot CLI (`figcraft-cli run`) has no link: there the commands say live sessions aren't available. ## Security @@ -518,7 +554,8 @@ chooses to share on the network; the default is the invite link with a random to client and reaches the network only through the `LiveLink` the app installs). 3. **Per-user undo**, reconnect and offline queueing, rejection handling, presence for text editing. **Done** (the core's client and server, the engine's live session, the guest's - resuming connection). + resuming connection), followed by client-scoped ids for document-level objects and remote text + carets on the canvas. 4. **Browser client** (WebSocket through `web-sys`). **Done** (the web build joins desktop-hosted sessions; `figcraft://` links open the desktop app and join). 5. **Standalone `figcraft-server`** with persistence, compaction and accounts. diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index b3b3677..d76fa76 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -1,18 +1,18 @@ # Collaboration parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; collaboration polish: collision-free ids, remote text carets; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Figma is multiplayer first; FigCraft is local first. **Ready ~12% (estimated):** comments and version history work inside one file; the real-time collaboration core landed in #103 (sync model, server ordering, client reconciliation, convergence tests), and live sessions (preview) let -several apps co-edit a file hosted by one desktop app, joined from the desktop app or the browser -([`collab-protocol.md`](collab-protocol.md)). Remaining: 55–110 h +several apps co-edit a file hosted by one desktop app, joined from the desktop app or the browser, +with others' cursors, selections and text carets shown live ([`collab-protocol.md`](collab-protocol.md)). Remaining: 55–110 h (multiplayer 45–90 h, shared libraries 10–20 h), plus branching 8–15 h. | 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, reconnect with backoff), 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). 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. Left: standalone server with TLS | 25–55 | -| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **partial**: cursors with name tags, selections, avatars and follow in live sessions; no spotlight | 8–15 | +| 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, reconnect with backoff), 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). 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. Left: standalone server with TLS | 25–55 | +| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **partial, in live sessions**: named cursors, selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; no spotlight | 8–15 | | Cursor chat, audio, high-fives | yes | no | 4–8 | | Share links, permissions (view / edit), teams and projects | yes | no | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences | (shared once multiplayer lands) | @@ -25,6 +25,7 @@ several apps co-edit a file hosted by one desktop app, joined from the desktop a | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | | 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | | 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | | 2026-10-10 | major | Created from the 2026-10-10 re-measure | diff --git a/docs/gaps.md b/docs/gaps.md index 01bed29..b87062d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; collaboration polish: collision-free ids, remote text carets; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -61,10 +61,11 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ordering and validation, client reconciliation, binary messages, randomized convergence tests over real commands), and live sessions run over the network (`crates/collab-net`: hosting from the app, invite links that open the app, document sync between apps, live cursors, selections, - avatars and follow, and the browser build joins) with per-person undo, refused edits rolled back - with a reason, and reconnects that resume and send what was made offline; but sessions are plain - `ws://` (no TLS, so an https page can only join on the same computer), and there are no share - links or permissions (`docs/collab-protocol.md`). + text carets, avatars and follow, and the browser build joins) with per-person undo, refused + edits rolled back with a reason, reconnects that resume and send what was made offline, and + styles, variables and comments that several people create at once all kept; but sessions are + plain `ws://` (no TLS, so an https page can only join on the same computer), and there are no + share links or permissions (`docs/collab-protocol.md`). - **Impact:** Figma's defining feature; most teams use it every day. Solo designers can work without it. - **Estimate:** 45–90 h: sharing and permissions and a standalone server with TLS. @@ -217,6 +218,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | | 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | | 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | | 2026-10-10 | minor | Marked beta blockers; no alpha blockers (the alpha gate passes) | diff --git a/docs/mcp.md b/docs/mcp.md index 2da0c37..fb91b22 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -505,12 +505,23 @@ joined file opens as a new document). - `collab.peers {}` → `{available, canHost (false in the web build), status (off|connecting|live|reconnecting|failed), reason?, attempt?, role (host|guest), me, name, color, fileName, pages, link, url, address, network, - follow, syncing (the document arrived), document (the index of the synced open document), peers: [{id, name, color, presence: {cursor: [page, x, y], selection: [ids], viewport: - [page, x, y, w, h], editing_text: {node, caret, selection?}}}]}`. + follow, syncing (the document arrived), document (the index of the synced open document), + editing (the text layer you are typing in: {node, layer, caret, selection}, or null), peers: [{id, + name, color, editing (the text layer they are typing in, same shape, or null), presence: {cursor: + [page, x, y], selection: [ids], viewport: [page, x, y, w, h], editing_text: {node, caret, + selection?}}}]}`. Carets and selections count characters; someone's typing syncs when they + finish, so their caret may be past the end of your copy until then. Starting `text.edit.begin` + in a layer someone else is typing in sets the message " is editing this text too: the + version finished last is kept" (both edits sync as whole values when each person finishes; the + last one wins). - `collab.cursor {x, y}` puts your cursor at a page position (omit both to hide it): an agent's pointer. Your selection, page and view go out by themselves when they change. - `collab.follow {client}` follows that person's viewport; `collab.follow {}` stops (so does any pan or zoom of your own, or Escape). +- Styles, variables, collections, modes and comments you create on the session's document take + ids from your own id space (`S:`, `VariableID:`, … with large numbers), so two people + creating them at once never overwrite each other's; outside a session ids look as before (`S:1`, + `VariableID:1`…). - On the session's document every command (or `batch`, or gesture) is one step of your own undo history: `edit.undo` / `edit.redo` take back only your steps and leave what others changed since. An edit the host refuses (someone deleted the layer) is rolled back and the reason is the diff --git a/docs/roadmap.md b/docs/roadmap.md index 2e82ebb..13d35a6 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (wording: live sessions, the browser joins them, invite links open the app; collaboration polish: collision-free ids, remote text carets; numbers unchanged) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -17,7 +17,7 @@ are one Opus 5.5 agent; most milestones split across 4–6 agents on disjoint cr 40–60 h, parallel by tool. 4. **Localization layer**, then Figma's own languages first (ja, ko, es, pt-BR, fr, de): 15–25 h for the layer, 3–5 h per language plus native review. -5. **Multiplayer M9**: a standalone server with TLS and sharing on top of the live sessions (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, avatars and follow, per-user undo, reconnect with offline edits and joining from the browser landed), per [`collab-protocol.md`](collab-protocol.md): 45–90 h. +5. **Multiplayer M9**: a standalone server with TLS and sharing on top of the live sessions (the WebSocket transport, hosting from the app, invite links that open the app, document sync, live cursors, selections, text carets, avatars and follow, per-user undo, reconnect with offline edits, collision-free ids for styles, variables, collections and comments, and joining from the browser landed), per [`collab-protocol.md`](collab-protocol.md): 45–90 h. ## Alpha gate @@ -55,7 +55,7 @@ blockers. | M6 Interop & Dev Mode | ~55% | `.fig` import, video/HEIC/TIFF, CMYK intent, Code Connect | 40–80 | | M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | | M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | -| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme) | standalone server with TLS, sharing | 45–90 | +| M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme) | standalone server with TLS, sharing | 45–90 | | M10 Localization (new) | not started | layer + 11 languages | 60–100 | | M11 Plugins | runtime, API, menu, manager and SDK done (#101) | run-time plugin UI, settings storage, distribution, widgets; Figma plugin compatibility is an owner decision | 25–55 | | M12 AI features (new, owner decision) | not started | local models | 30–60 | @@ -71,6 +71,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Collaboration wording after the collaboration polish: styles, variables, collections, modes and comments created in a live session take ids from their creator's id space (concurrent creations no longer overwrite each other; the server checks it), others' text carets and selections show on the canvas, and typing in a layer someone else is typing in says so; percentages unchanged | | 2026-10-11 | minor | Wording only: live sessions (#104), then the browser joining them and `figcraft://` invite links opening the desktop app; percentages and hours unchanged until the next re-measure | | 2026-10-10 | minor | Merged main after #101–#103: WASM plugins (plugins area 0% → 35% breadth, 20% ready; ecosystem 5% → 20%) and the multiplayer core without networking (multiplayer 5% → 15% breadth, 5% ready; collaboration 8% → 12%); menu breadth 97.5% → 98.5% (200/203); ready for real work ~46% → ~47%; full parity 420–790 h → 390–745 h; beta 210–380 h → 200–365 h. Fixed the stated weight sums (areas 1–16 sum to 94, total 113; percentages were already computed with those) | | 2026-10-10 | minor | Added the alpha gate (core-workflow table): passes, stage stays alpha |