diff --git a/apps/figcraft-server/src/store.rs b/apps/figcraft-server/src/store.rs index 1ea420a5..90fce92b 100644 --- a/apps/figcraft-server/src/store.rs +++ b/apps/figcraft-server/src/store.rs @@ -52,6 +52,14 @@ use crate::codes::{self, Sealed}; use crate::directory::{Directory, DirectoryConfig}; use crate::disk::{self, LIBRARY, LINKS, LOG, LOG_HEADER, META, SNAPSHOT, USAGE}; +mod backend; +mod files; +mod journal; +mod library; +mod links; + +pub use library::library_name; + pub const LOCK: &str = "LOCK"; pub const KEY: &str = "server.key"; pub const FILES: &str = "files"; @@ -541,847 +549,6 @@ impl Store { people: open.map_or(0, |o| o.people), } } - - /// Keep a document (its snapshot) as a new file with an edit link and a view link. - pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> { - self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view)) - } - - /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's - /// caps it gets, and the files and bytes kept by the files published from addresses in it. An - /// address that doesn't parse counts as itself only, ×1, by its exact text. - fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> { - let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default(); - let kept: Vec<(&str, Vec<_>, u64)> = self - .files - .iter() - .filter_map(|(id, e)| { - let from = e.meta.ip.as_deref()?; - Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id))) - }) - .collect(); - let sum = |mine: &mut dyn Iterator, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n))); - let blocks = blocks_of(ip); - if blocks.is_empty() { - let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip)); - return vec![(1, files, bytes)]; - } - blocks - .into_iter() - .map(|(block, times)| { - let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block))); - (times, files, bytes) - }) - .collect() - } - - /// Keep a document published without the key from `ip`, within the caps on what one address - /// and everyone may keep: the file, its links and its owner token. - pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> { - let h = &self.cfg.hosting; - let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes); - if !open { - return Err(KEY_ONLY.into()); - } - if !self.abuse.allow(ip, Action::Publish, Instant::now()) { - return Err("too many files published from your address lately: try again in an hour".into()); - } - let blocks = self.per_block(&ip.to_string()); - for &(times, files, _) in &blocks { - if files >= max_files.saturating_mul(times as usize) { - return Err(format!( - "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires" - )); - } - } - let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); - let fits = |n: u64| { - for &(times, _, used) in &blocks { - let cap = max_ip.saturating_mul(u64::from(times)); - if used.saturating_add(n) > cap { - return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20)); - } - } - if used_all.saturating_add(n) > max_all { - return Err("this server is full: it takes no more files for now (try again later, or another server)".into()); - } - Ok(()) - }; - let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?; - Ok((info, edit, view, owner.unwrap_or_default())) - } - - /// Keep a document as a new file: published without the key from `ip` (it gets an owner - /// token), else by the operator. `fits` checks its size against the caps that apply. - fn keep( - &mut self, - name: &str, - snapshot: &[Change], - ip: Option, - fits: &dyn Fn(u64) -> Result<(), String>, - ) -> Result<(FileInfo, NewLink, NewLink, Option), String> { - if self.files.len() >= self.cfg.max_files { - return Err(format!("the server keeps at most {} files", self.cfg.max_files)); - } - if let Some(why) = self.low_disk() { - return Err(why); - } - let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?; - if replica.document().node_count() > self.cfg.core.max_nodes { - return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes)); - } - let mut doc = replica.document().clone(); - let name = match clean_name(name) { - n if n.is_empty() => doc.name.clone(), - n => n, - }; - doc.name = name.clone(); - let server = Server::new(doc, "", self.cfg.core); - let state = server.state().map_err(|e| e.to_string())?; - let bytes = disk::encode_snapshot(1, &state)?; - if bytes.len() as u64 > self.cfg.max_file_bytes { - return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20)); - } - fits(bytes.len() as u64)?; - let id = loop { - let id = token::file_id()?; - if !self.files.contains_key(&id) && !self.dir(&id).exists() { - break id; - } - }; - let t = now(); - let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?; - let (view, view_token) = new_link(Access::View, "Published (view)", t)?; - let owner = ip.map(|_| token::secret()).transpose()?; - let meta = - Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 }; - let dir = self.dir(&id); - let written = fs::create_dir_all(&dir) - .map_err(|e| e.to_string()) - .and_then(|()| { - disk::sync_dir(Some(&self.root.join(FILES))); - disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()) - }) - .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string())) - .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()])) - // Last: a folder without its description is an upload cut short. - .and_then(|()| write_json(&dir.join(META), &meta)); - if let Err(e) = written { - let _ = fs::remove_dir_all(&dir); - return Err(format!("couldn't keep the file: {e}")); - } - let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 }; - self.files.insert(id.clone(), entry); - let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?; - match ip { - Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()), - None => log::info!("event=published file={id} bytes={} by=key", bytes.len()), - } - Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner)) - } - - /// The files kept, with what the operator moderates by. - pub fn listing(&self, open: &dyn Fn(&str) -> Option) -> Vec { - self.files - .iter() - .map(|(id, e)| FileRow { - info: self.info(id, e, open(id)), - opened: e.meta.opened, - expires: self.expires(&e.meta), - open: e.meta.owner.is_some(), - ip: e.meta.ip.clone().unwrap_or_default(), - reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX), - }) - .collect() - } - - /// Owner token `owner` manages file `file` (one published without the key). - fn owns(&self, file: &str, owner: &str) -> bool { - let presented = hash(owner); - self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h)) - } - - /// What became of the files these `(file id, owner token)` pairs own: each one's name, links - /// and expiry, or `None` when that token manages no file of that id here (which doesn't say - /// whether the id exists). Asking opens nothing: it doesn't count as using a file. - pub fn owned(&self, files: &[(String, String)]) -> AdminReply { - if files.len() > MAX_OWNED { - return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") }; - } - let one = |(file, owner): &(String, String)| { - let e = self.files.get(file).filter(|_| self.owns(file, owner))?; - Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) }) - }; - AdminReply::Owned(files.iter().map(one).collect()) - } - - /// Rename a file (its listing; the document inside keeps its own name). - pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> { - let name = clean_name(name); - if name.is_empty() { - return Err("give the file a name".into()); - } - let path = self.dir(file).join(META); - let entry = self.entry(file)?; - let before = std::mem::replace(&mut entry.meta.name, name); - if let Err(e) = write_json(&path, &entry.meta) { - entry.meta.name = before; - return Err(format!("couldn't rename the file: {e}")); - } - Ok(()) - } - - /// Delete the files published without the key that nobody opened for the idle time (not - /// those open now); their ids. - pub fn sweep(&mut self) -> Vec { - let t = now(); - self.next_sweep = t.saturating_add(SWEEP_EVERY); - let due: Vec = - self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect(); - for id in &due { - match self.remove(id) { - Ok(()) => log::info!("event=expired file={id}"), - Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"), - } - } - due - } - - /// Make a file look unused for `secs` more (tests: expiry without waiting). - #[cfg(test)] - pub fn age(&mut self, file: &str, secs: u64) { - let path = self.dir(file).join(META); - if let Some(e) = self.files.get_mut(file) { - let m = &mut e.meta; - (m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs)); - let _ = write_json(&path, m); - } - } - - /// Make an open file's log refuse writes (tests: a full or failing disk). - #[cfg(test)] - pub fn break_log(&mut self, file: &str) { - let path = self.dir(file).join(LOG); - if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut()) - && let Ok(f) = File::open(path) - { - log.file = f; - } - } - - /// Note that a file was opened now (it is used: it doesn't expire). - fn touch(&mut self, file: &str) { - let path = self.dir(file).join(META); - if let Some(e) = self.files.get_mut(file) { - e.meta.opened = now(); - if let Err(err) = write_json(&path, &e.meta) { - log::warn!("file {file}: couldn't update {META}: {err}"); - } - } - } - - /// What the server says about its hosting (with `file`'s expiry). - fn host_info(&self, file: Option<&str>) -> HostInfo { - let h = &self.cfg.hosting; - let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta)); - HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires } - } - - /// Block or unblock an address (an IPv6 address: its /64); the address as kept. - pub fn block(&mut self, ip: &str, on: bool) -> Result { - let ip = ip_key(ip.trim().parse::().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?); - if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED { - return Err(format!("at most {MAX_BLOCKED} addresses can be blocked")); - } - let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) }; - if changed { - let list: Vec = self.blocked.iter().map(IpAddr::to_string).collect(); - write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?; - log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" }); - } - Ok(ip) - } - - /// The blocked addresses. - pub fn blocked(&self) -> Vec { - self.blocked.iter().map(IpAddr::to_string).collect() - } - - /// The reports kept, oldest first. - pub fn reports(&self) -> &[ReportInfo] { - &self.reports - } - - /// Keep a report about `file` (from someone who joined it with `link`, at `ip`). - pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - let reason = clean_reason(reason); - if reason.is_empty() { - return Err("say what is wrong with the file".into()); - } - let name = self.entry(file)?.meta.name.clone(); - if !self.abuse.allow(ip, Action::Report, Instant::now()) { - return Err("too many reports from your address lately: try again in an hour".into()); - } - self.reports.push(ReportInfo { - at: now(), - file: file.to_string(), - name, - link: link.unwrap_or_default().to_string(), - ip: ip.to_string(), - reason, - }); - if self.reports.len() > MAX_REPORTS { - let extra = self.reports.len() - MAX_REPORTS; - self.reports.drain(..extra); - } - write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?; - log::info!("event=report file={file} ip={ip}"); - Ok(()) - } - - /// The files kept. - pub fn list(&self, open: &dyn Fn(&str) -> Option) -> Vec { - self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect() - } - - /// A file's links. - pub fn links(&mut self, file: &str) -> Result, String> { - Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect()) - } - - /// A new link to a file. - pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result { - let max = self.cfg.max_links; - let path = self.dir(file).join(LINKS); - let entry = self.entry(file)?; - if entry.links.len() >= max { - return Err(format!("a file has at most {max} links: revoke one first")); - } - let (link, token) = new_link(access, &clean_name(label), now())?; - entry.links.push(link.clone()); - if let Err(e) = write_json(&path, &entry.links) { - entry.links.pop(); - return Err(format!("couldn't keep the link: {e}")); - } - Ok(NewLink { link: link.info(file), token }) - } - - /// The link of `entry` whose token `token` is (every link is compared, in constant time, so - /// timing doesn't tell which one matched). - fn link_of(entry: &Entry, token: &str) -> Option { - let presented = hash(token); - let mut found = None; - for (i, l) in entry.links.iter().enumerate() { - if same_secret(&presented, &l.hash) { - found = Some(i); - } - } - found - } - - /// These join codes no longer open anything: typing one says so. - fn forget_codes(&mut self, codes: impl IntoIterator) { - self.gone_codes.extend(codes.into_iter().map(|c| c.hash)); - let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES); - self.gone_codes.drain(..extra); - } - - /// Where the link with the join code `key` is: its file, its place among the file's links - /// and its token (every code is compared, so timing doesn't tell which one matched). - fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> { - let mut found = None; - for (id, e) in &self.files { - for (i, l) in e.links.iter().enumerate() { - if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) { - found = Some((id.clone(), i, token)); - } - } - } - found - } - - /// A new join code for the link of `file` whose token is `token` (it takes the place of the - /// link's earlier code): the code, shown this once. - pub fn set_code(&mut self, file: &str, token: &str) -> Result { - let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?; - // 45 random bits: one already in use is all but impossible, and never handed out twice. - let (code, key) = loop { - let code = codes::new_code()?; - let key = codes::Key::new(&code); - if self.find_code(&key).is_none() { - break (code, key); - } - }; - let sealed = key.seal(token)?; - let path = self.dir(file).join(LINKS); - let entry = self.entry(file)?; - let link = entry.links.get_mut(at).ok_or("that file has no such link")?; - let before = link.code.replace(sealed); - let id = link.id.clone(); - if let Err(e) = write_json(&path, &entry.links) { - if let Some(l) = entry.links.get_mut(at) { - l.code = before; - } - return Err(format!("couldn't keep the join code: {e}")); - } - self.forget_codes(before); - log::info!("event=code file={file} link={id}"); - Ok(code) - } - - /// The file a join code typed at `ip` opens, and its link's token; wrong codes count against - /// the address (see `abuse`), and nothing is looked up for an address that is locked out. - pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> { - let now = Instant::now(); - if let Some(left) = self.abuse.code_locked(ip, now) { - return Err(format!( - "Too many wrong codes from your address: try again in {} minutes, or join with the invite link", - left.as_secs() / 60 + 1 - )); - } - if !self.abuse.allow(ip, Action::Code, now) { - return Err("Too many codes tried from your address: wait a minute and try again".into()); - } - if !self.abuse.codes_open(now) { - return Err(CODES_BUSY.into()); - } - // Anything that isn't a code's symbols is a wrong code too (and costs no hashing). - let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c)); - let found = key.as_ref().and_then(|k| self.find_code(k)); - self.abuse.code_tried(ip, found.is_some(), now); - match found { - Some((file, _, token)) => { - log::info!("event=code_used file={file} ip={ip}"); - Ok((file, token)) - } - None => { - let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash())); - Err(if gone { CODE_GONE } else { NO_CODE }.into()) - } - } - } - - /// Revoke a link. - pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> { - let path = self.dir(file).join(LINKS); - let entry = self.entry(file)?; - let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?; - let gone = entry.links.remove(at); - if let Err(e) = write_json(&path, &entry.links) { - entry.links.insert(at, gone); - return Err(format!("couldn't revoke the link: {e}")); - } - log::info!("revoked link {link} of file {file}"); - self.forget_codes(gone.code); - Ok(()) - } - - /// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no - /// longer served either way). - pub fn remove(&mut self, file: &str) -> Result<(), String> { - // Closed first: an open log would keep the folder from being deleted on Windows. - let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?; - self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone())); - drop(entry); - let dir = self.dir(file); - fs::remove_dir_all(&dir) - .map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?; - log::info!("removed file {file}"); - Ok(()) - } - - /// A file's team library for `ip`, unless its version is `have`; within what one address and - /// everyone may download (the size is known before anything is read). - fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result { - let (dir, max) = (self.dir(file), self.cfg.max_file_bytes); - let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) }; - if have == Some(lib.version) { - return Ok(LibraryReply::Unchanged { version: lib.version }); - } - let path = library_file(&dir, lib.version); - let size = fs::metadata(&path).map_or(0, |m| m.len()); - let now = Instant::now(); - if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) { - return Err(BUSY_DOWNLOADS.into()); - } - Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? }) - } - - /// Keep `archive` as a file's team library: it must read as a library (with the limits files - /// using it read it with), be the file's library if it has one, and be a newer version. - // shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile); - // move it to a worker if large libraries make that noticeable. - pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result { - let max = self.cfg.max_file_bytes; - if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() { - return Err(NO_PUBLIC_LIBRARY.into()); - } - if archive.len() as u64 > max { - return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20)); - } - // Everything it unpacks to counts against the same cap, so a small archive can't expand - // into a large library in memory. - let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) { - Ok(Some(lib)) => lib, - Ok(None) => return Err("that isn't a published library".into()), - Err(e) => return Err(format!("the library can't be read: {e}")), - }; - if lib.doc.node_count() > self.cfg.core.max_nodes { - return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes)); - } - let m = &lib.manifest; - let dir = self.dir(file); - let entry = self.entry(file)?; - if let Some(old) = &entry.meta.library { - if old.id != m.id { - return Err(format!("this file's library on the server is another one (\"{}\")", old.name)); - } - if m.version <= old.version { - return Err(format!("version {} is on the server already: publish again to follow it", old.version)); - } - // A jump would let a client use up the numbers and leave no newer version to publish. - if m.version > old.version.saturating_add(1) { - return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1))); - } - } else if m.version > MAX_FIRST_LIBRARY_VERSION { - return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version)); - } - // Each version has a file of its own, written before the description names it: a failed - // description write or a crash in between leaves the previous version served whole, never - // the new bytes under the old number. - let path = dir.join(library_name(m.version)); - disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?; - let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version }); - if let Err(e) = write_json(&dir.join(META), &entry.meta) { - // Never served (the description doesn't name it); the next publish of it replaces it. - if let Err(r) = fs::remove_file(&path) { - log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}"); - } - entry.meta.library = before; - return Err(format!("couldn't keep the library: {e}")); - } - // The previous version isn't served any more. - if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) { - log::warn!("file {file}: the previous library version couldn't be deleted: {e}"); - } - log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len()); - Ok(m.version) - } - - /// A library's usage reports in memory (read from disk the first time; a damaged list starts - /// over: it is only statistics). `None` when the file has no library. - fn usage_of(&mut self, file: &str) -> Result, String> { - if self.entry(file)?.meta.library.is_none() { - return Ok(None); - } - if !self.usage.contains_key(file) { - let path = self.dir(file).join(USAGE); - let list = if path.exists() { - disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() - } else { - Vec::new() - }; - self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() }); - } - Ok(self.usage.get_mut(file)) - } - - /// The usage reports kept for a file's library (none when it has no library). - pub fn library_usage(&mut self, file: &str) -> Result, String> { - Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default()) - } - - /// Keep how one file uses a file's library (in memory; written when it changed, at most every - /// [`USAGE_WRITE_EVERY`] seconds). - pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> { - let name = clean_name(&usage.file); - if name.is_empty() { - return Ok(()); - } - let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect(); - components.sort(); - components.dedup_by_key(|c| c.0); - components.truncate(MAX_REPORTED); - let Some(u) = self.usage_of(file)? else { return Ok(()) }; - match u.list.iter_mut().find(|f| f.usage.file == name) { - Some(f) if f.usage.components == components => return Ok(()), - Some(f) => { - f.usage.components = components; - f.updated = now(); - } - None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }), - } - if u.list.len() > MAX_USERS { - u.list.sort_by_key(|f| std::cmp::Reverse(f.updated)); - u.list.truncate(MAX_USERS); - } - u.dirty = true; - if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) { - self.write_usage(false); - } - Ok(()) - } - - /// Write the usage reports that changed (`all`: whenever they were last written). - fn write_usage(&mut self, all: bool) { - let t = now(); - let root = self.root.join(FILES); - for (file, u) in &mut self.usage { - if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) { - if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) { - log::warn!("file {file}: library usage couldn't be kept: {e}"); - } - (u.dirty, u.written) = (false, t); - } - } - } - - /// Whether `file` has room for about `n` more bytes: its cap, and for a file published - /// without the key its address's share and everyone's. A log that took it over its cap is - /// compacted first (the state may be smaller than its history). - fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> { - if let Some(why) = self.low_disk() { - return Err(why); - } - let max = self.cfg.max_file_bytes; - let over = |s: &Store| s.bytes(file).saturating_add(n) > max; - // A compaction to make room at most once a minute per file (it rewrites the whole file). - let t = now(); - let may_compact = self.files.get(file).is_some_and(|e| { - t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty()) - }); - if over(self) && may_compact { - if let Some(e) = self.files.get_mut(file) { - e.forced = t; - } - self.compact(file, server); - } - if over(self) { - return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20)); - } - let h = &self.cfg.hosting; - let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) }; - let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes); - for (times, _, used) in self.per_block(&ip) { - let cap = max_ip.saturating_mul(u64::from(times)); - if used.saturating_add(n) > cap { - return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20)); - } - } - let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); - if used_all.saturating_add(n) > max_all { - return Err("this server is full: that change wasn't kept".into()); - } - Ok(()) - } - - /// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported). - fn load(&mut self, file: &str) -> Result { - self.touch(file); - let dir = self.dir(file); - let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?; - let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?; - let mut issues = Vec::new(); - let mut fresh_log = true; - let mut kept = 0; - let log_path = dir.join(LOG); - // A log that's there but can't be read (an IO error, or over a lowered cap) may hold - // confirmed edits: the file isn't opened, and the log is left as it is for another try. - let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) { - Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")), - r => r, - }; - // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened). - let read = match read.map(|b| disk::read_log(&b)) { - Ok(Err(why)) => { - let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now())); - fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?; - Err(format!("{why}; it was moved aside to {}", aside.display())) - } - Ok(Ok(log)) => Ok(log), - Err(why) => Err(why), - }; - match read { - Ok(log) if log.generation == generation => { - fresh_log = false; - for entry in log.entries { - if let Err(e) = server.replay(entry) { - issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped")); - fresh_log = true; - break; - } - kept += 1; - } - if let Some(why) = log.damaged { - issues.push(format!("file {file}: its log ended with {why}; that part was dropped")); - fresh_log = true; - } - } - // From before a compaction that was cut short, the snapshot has it all; or a generation - // field damaged, which no checksum covers. Replaying tells them apart without trusting - // it: entries the snapshot holds are skipped, so only edits it lacks change anything. - Ok(log) => { - let before = server.seq(); - let mut stopped = None; - for entry in log.entries { - if let Err(e) = server.replay(entry) { - stopped = Some(e); - break; - } - } - let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before)); - if recovered > 0 { - issues.push(format!( - "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \ - {recovered} changes the snapshot didn't; they were recovered" - )); - } - if let Some(e) = stopped { - issues.push(format!( - "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \ - making sense after {recovered} newer changes ({e}); the rest was dropped" - )); - } - } - Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), - } - for i in issues { - log::warn!("{i}"); - self.note(i); - } - server.journaling(true); - let file_handle = OpenOptions::new().append(true).open(dir.join(LOG)); - let log = match (fresh_log, file_handle) { - (false, Ok(f)) => { - let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len()); - // Replayed entries make it worth compacting once everyone has left. - Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } - } - // A new snapshot and log of the next generation, so appends start clean. - _ => { - let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?; - self.rewrite(file, next, &server).map_err(|e| e.why)? - } - }; - self.entry(file)?.log = Some(log); - Ok(server) - } - - /// The largest snapshot read back: a file's cap plus room for what its state keeps beside the - /// document (recent batches for reconnecting clients, deletions), which may take it over. - fn snapshot_cap(&self) -> u64 { - self.cfg.max_file_bytes.saturating_mul(2) - } - - /// Write `server`'s state as snapshot `generation` and start an empty log of it. - fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result { - let dir = self.dir(file); - let kept = |why: String| RewriteError { why, snapshot_replaced: false }; - let state = server.state().map_err(|e| kept(e.to_string()))?; - let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?; - // Never replace a readable snapshot with one that can't be opened again: the old snapshot - // and log still hold everything. - if bytes.len() as u64 > self.snapshot_cap() { - return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20))); - } - if bytes.len() as u64 > self.cfg.max_file_bytes { - log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20); - } - disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?; - // From here the old log no longer goes with the snapshot on disk. - let replaced = |why: String| RewriteError { why, snapshot_replaced: true }; - disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?; - let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?; - if let Ok(e) = self.entry(file) { - e.bytes = (bytes.len() + LOG_HEADER) as u64; - e.meta.seq = server.seq(); - let meta = e.meta.clone(); - if let Err(err) = write_json(&dir.join(META), &meta) { - log::warn!("file {file}: couldn't update {META}: {err}"); - } - } - Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false }) - } - - /// Compactions so far. - pub fn compactions(&self) -> u64 { - self.compactions - } - - /// Why nothing more may be written when the data directory's disk is nearly full (less free - /// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space - /// can't be told, nothing is refused. - fn low_disk(&mut self) -> Option { - let now = Instant::now(); - let free = match self.free { - Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free, - _ => { - let free = disk::free_bytes(&self.root); - self.free = Some((now, free)); - free - } - }; - free.filter(|f| *f < self.cfg.min_free_bytes) - .map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string()) - } - - /// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept. - fn note(&mut self, problem: String) { - if self.problems.len() >= MAX_PROBLEMS { - self.problems.remove(0); - } - self.problems.push(problem); - } - - /// Compact an open file: its state as the next snapshot, an empty log. - fn compact(&mut self, file: &str, server: &Server) { - self.compactions += 1; - let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return }; - let Some(next) = generation.checked_add(1) else { - log::error!("file {file}: compaction failed: the file's generation can't go any higher"); - return; - }; - match self.rewrite(file, next, server) { - Ok(log) => { - if let Ok(e) = self.entry(file) { - e.log = Some(log); - } - log::debug!("compacted file {file}"); - } - // The new snapshot holds everything, but a restart would drop whatever is appended to - // the old log now (its generation no longer matches): the file closes, the hub hears - // it at the next flush, and it opens again from the new snapshot. - Err(RewriteError { why, snapshot_replaced: true }) => { - self.note(format!("file {file}: compaction was cut short ({why}); it was closed")); - log::error!("file {file}: compaction was cut short: {why}"); - if let Some(e) = self.files.get_mut(file) { - e.log = None; - } - self.cut_short.push(file.to_string()); - } - // The old snapshot and log still hold everything: carry on appending. - Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"), - } - } - - /// Write and sync what's pending in every open log; the files whose log couldn't be written. - /// Those are closed here (their pending records dropped: nobody was told of them) and open - /// again from what is on disk. - fn flush_all(&mut self) -> Vec { - let mut failed = std::mem::take(&mut self.cut_short); - for (id, e) in &mut self.files { - if let Some(log) = e.log.as_mut() { - match flush_log(id, log) { - Ok(n) => e.bytes += n, - Err(()) => { - e.log = None; - failed.push(id.clone()); - } - } - } - } - failed - } } /// Why a snapshot and log couldn't be rewritten, and whether the snapshot on disk was replaced @@ -1391,38 +558,6 @@ struct RewriteError { snapshot_replaced: bool, } -/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log -/// can't be trusted to hold them). -fn flush_log(id: &str, log: &mut Log) -> Result { - if log.pending.is_empty() { - return Ok(0); - } - match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) { - Ok(()) => { - let n = log.pending.len() as u64; - log.bytes += n; - log.pending.clear(); - Ok(n) - } - Err(e) => { - log::error!("file {id}: couldn't write its log: {e}"); - Err(()) - } - } -} - -/// The name of the file holding version `version` of a file's team library. -pub fn library_name(version: u64) -> String { - format!("library-{version}.bin") -} - -/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a -/// version published before each version had one. -fn library_file(dir: &Path, version: u64) -> PathBuf { - let path = dir.join(library_name(version)); - if path.exists() { path } else { dir.join(LIBRARY) } -} - fn write_json(path: &Path, value: &T) -> Result<(), String> { let text = serde_json::to_vec_pretty(value).map_err(|e| e.to_string())?; disk::write_atomic(path, &text).map_err(|e| e.to_string()) @@ -1433,243 +568,3 @@ fn new_link(access: Access, label: &str, created: u64) -> Result<(Link, String), let token = token::secret()?; Ok((Link { id: token::file_id()?, access, label: label.to_string(), created, hash: hash(&token), code: None }, token)) } - -impl Backend for Store { - fn admit(&mut self, file: &str, token: &str) -> Result { - let entry = self.entry(file)?; - let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i)); - link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) }) - .ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string()) - } - - fn open(&mut self, file: &str) -> Result { - self.load(file).map_err(|e| { - let why = format!("file {file} can't be opened: {e}"); - log::error!("{why}"); - self.note(why); - "this file can't be opened on the server (its data is damaged); tell the server's owner".to_string() - }) - } - - fn changed(&mut self, file: &str, server: &mut Server) { - let journal = server.take_journal(); - if journal.is_empty() { - return; - } - let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes); - let Some(e) = self.files.get_mut(file) else { return }; - let Some(log) = e.log.as_mut() else { return }; - for j in &journal { - match disk::record(j) { - Ok(r) => log.pending.extend(r), - Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"), - } - } - log.entries += journal.len(); - log.dirty = true; - e.meta.updated = now(); - if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes { - // The snapshot holds what's pending too. - self.compact(file, server); - } - } - - fn flush(&mut self) -> Vec { - self.flush_all() - } - - fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> { - self.room_for(file, server, bytes as u64) - } - - fn idle(&mut self, file: &str, server: &mut Server) -> bool { - let compact = self.files.get_mut(file).is_some_and(|entry| { - let snapshot_seq = entry.meta.seq; - entry.log.as_mut().is_some_and(|log| { - // Issued client ids and refusals are durable in the log; only edits need a - // new image-bearing snapshot. Still try a snapshot if syncing the log failed. - flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq) - }) - }); - if compact { - self.compact(file, server); - } - // It closes anyway, with its state in the snapshot and durable log. - self.cut_short.retain(|f| f != file); - if let Some(e) = self.files.get_mut(file) { - e.log = None; - } - // Everyone just left: idle time counts from now. - self.touch(file); - true - } - - fn stop(&mut self, open: Vec<(&str, &mut Server)>) { - self.write_usage(true); - self.flush_all(); - for (file, server) in open { - if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) { - self.compact(file, server); - } - } - } - - fn manage(&mut self, key: &str) -> Result<(), String> { - match same_secret(key, &self.key) { - true => Ok(()), - false if self.cfg.hosting.open => { - Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into()) - } - false => Err("wrong server key".into()), - } - } - - fn connect(&mut self, ip: IpAddr) -> Result<(), String> { - if now() >= self.next_sweep { - self.sweep(); - self.write_usage(false); - } - if self.blocked.contains(&ip) { - return Err("this server doesn't accept connections from your address".into()); - } - if !self.abuse.allow(ip, Action::Connect, Instant::now()) { - return Err("too many connections from your address lately: wait a minute and try again".into()); - } - Ok(()) - } - - fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool { - // Cursors and cursor chat (many people behind one address each send ~30 a second while - // they move) have a budget of their own, beside everything else. - let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message }; - self.abuse.allow(ip, what, Instant::now()) - } - - fn hosting(&mut self, file: &str) -> Option { - Some(self.host_info(Some(file))) - } - - fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result { - if file.is_empty() && owner.is_empty() { - return Ok(self.host_info(None)); - } - if !self.owns(file, owner) { - return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into()); - } - Ok(self.host_info(Some(file))) - } - - fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { - let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() }); - let file = match &req { - Admin::Publish { name, snapshot } => { - return match self.publish_open(ip, name, snapshot) { - Ok((file, edit, view, owner)) => { - let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta)); - AdminReply::Hosted { file, edit, view, owner, expires }.into() - } - Err(e) => { - log::info!("event=publish_refused ip={ip} reason={e:?}"); - Handled::from(AdminReply::Failed { reason: e }) - } - }; - } - Admin::Owned { files } => return self.owned(files).into(), - Admin::Links { file } - | Admin::CreateLink { file, .. } - | Admin::Revoke { file, .. } - | Admin::Rename { file, .. } - | Admin::Code { file, .. } - | Admin::Remove { file } => file, - _ => return failed("that needs the server key"), - }; - if owns != Some(file.as_str()) { - return failed("an owner token manages only its own file"); - } - self.admin(req, open) - } - - fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { - self.keep_report(file, link, ip, reason) - } - - fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { - let failed = |reason: String| Handled::from(AdminReply::Failed { reason }); - match req { - Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) { - Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(), - Err(e) => failed(e), - }, - Admin::Files => AdminReply::Files(self.list(open)).into(), - Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()), - Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()), - Admin::Revoke { file, link } => match self.revoke(&file, &link) { - Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None }, - Err(e) => failed(e), - }, - Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()), - Admin::Listing => AdminReply::Listing(self.listing(open)).into(), - Admin::Reports => AdminReply::Reports(self.reports.clone()).into(), - Admin::Block { ip } => match self.block(&ip, true) { - Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) }, - Err(e) => failed(e), - }, - Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()), - Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), - Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), - Admin::Owned { files } => self.owned(&files).into(), - // The hub removes people itself (it knows who is connected); it never asks the store. - Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), - Admin::Remove { file } => { - if !self.files.contains_key(&file) { - return failed("this file isn't on the server (it may have been removed)".into()); - } - let reply = match self.remove(&file) { - Ok(()) => AdminReply::Done, - Err(reason) => AdminReply::Failed { reason }, - }; - Handled { reply, revoked: None, removed: Some(file), blocked: None } - } - } - } - - fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply { - let failed = |reason: String| LibraryReply::Failed { reason }; - match req { - LibraryReq::Fetch { have, usage } => { - if let Some(u) = usage - && let Err(e) = self.report_usage(file, u) - { - log::warn!("file {file}: a library usage report couldn't be kept: {e}"); - } - self.fetch_library(ip, file, have).unwrap_or_else(failed) - } - LibraryReq::Publish { .. } if access != Access::Edit => { - failed("you can only view this file: publishing its library needs an edit link".into()) - } - LibraryReq::Publish { archive } => { - self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version }) - } - LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage), - } - } - - fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply { - // One address's share first (before everyone's budget is spent or anything is read). - if let PluginReq::Fetch { id, version } = &req - && let Some(size) = self.directory.size_of(id, version.as_deref()) - && !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now()) - { - return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() }; - } - self.directory.handle(publisher, req) - } - - fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> { - self.resolve_code(ip, code) - } - - fn closing(&self) -> Closing { - Closing::Restarting - } -} diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs new file mode 100644 index 00000000..af9b8bbb --- /dev/null +++ b/apps/figcraft-server/src/store/backend.rs @@ -0,0 +1,242 @@ +use super::journal::flush_log; +use super::*; + +impl Backend for Store { + fn admit(&mut self, file: &str, token: &str) -> Result { + let entry = self.entry(file)?; + let link = Self::link_of(entry, token).and_then(|i| entry.links.get(i)); + link.map(|l| Admission { access: l.access, link: Some(l.id.clone()) }) + .ok_or_else(|| "this link isn't valid (it may have been revoked)".to_string()) + } + + fn open(&mut self, file: &str) -> Result { + self.load(file).map_err(|e| { + let why = format!("file {file} can't be opened: {e}"); + log::error!("{why}"); + self.note(why); + "this file can't be opened on the server (its data is damaged); tell the server's owner".to_string() + }) + } + + fn changed(&mut self, file: &str, server: &mut Server) { + let journal = server.take_journal(); + if journal.is_empty() { + return; + } + let (after, max_bytes) = (self.cfg.compact_after, self.cfg.compact_bytes); + let Some(e) = self.files.get_mut(file) else { return }; + let Some(log) = e.log.as_mut() else { return }; + for j in &journal { + match disk::record(j) { + Ok(r) => log.pending.extend(r), + Err(err) => log::error!("file {file}: a change couldn't be logged: {err}"), + } + } + log.entries += journal.len(); + log.dirty = true; + e.meta.updated = now(); + if log.entries >= after || log.bytes + log.pending.len() as u64 >= max_bytes { + // The snapshot holds what's pending too. + self.compact(file, server); + } + } + + fn flush(&mut self) -> Vec { + self.flush_all() + } + + fn room(&mut self, file: &str, server: &Server, bytes: usize) -> Result<(), String> { + self.room_for(file, server, bytes as u64) + } + + fn idle(&mut self, file: &str, server: &mut Server) -> bool { + let compact = self.files.get_mut(file).is_some_and(|entry| { + let snapshot_seq = entry.meta.seq; + entry.log.as_mut().is_some_and(|log| { + // Issued client ids and refusals are durable in the log; only edits need a + // new image-bearing snapshot. Still try a snapshot if syncing the log failed. + flush_log(file, log).is_err() || (log.dirty && server.seq() > snapshot_seq) + }) + }); + if compact { + self.compact(file, server); + } + // It closes anyway, with its state in the snapshot and durable log. + self.cut_short.retain(|f| f != file); + if let Some(e) = self.files.get_mut(file) { + e.log = None; + } + // Everyone just left: idle time counts from now. + self.touch(file); + true + } + + fn stop(&mut self, open: Vec<(&str, &mut Server)>) { + self.write_usage(true); + self.flush_all(); + for (file, server) in open { + if self.files.get(file).and_then(|e| e.log.as_ref()).is_some_and(|l| l.dirty) { + self.compact(file, server); + } + } + } + + fn manage(&mut self, key: &str) -> Result<(), String> { + match same_secret(key, &self.key) { + true => Ok(()), + false if self.cfg.hosting.open => { + Err("wrong server key (this server is open to the public: publish without a key, with an up-to-date FigCraft)".into()) + } + false => Err("wrong server key".into()), + } + } + + fn connect(&mut self, ip: IpAddr) -> Result<(), String> { + if now() >= self.next_sweep { + self.sweep(); + self.write_usage(false); + } + if self.blocked.contains(&ip) { + return Err("this server doesn't accept connections from your address".into()); + } + if !self.abuse.allow(ip, Action::Connect, Instant::now()) { + return Err("too many connections from your address lately: wait a minute and try again".into()); + } + Ok(()) + } + + fn message(&mut self, ip: IpAddr, msg: &ClientMsg) -> bool { + // Cursors and cursor chat (many people behind one address each send ~30 a second while + // they move) have a budget of their own, beside everything else. + let what = if matches!(msg, ClientMsg::Presence(_) | ClientMsg::Signal(_) | ClientMsg::Ping) { Action::Presence } else { Action::Message }; + self.abuse.allow(ip, what, Instant::now()) + } + + fn hosting(&mut self, file: &str) -> Option { + Some(self.host_info(Some(file))) + } + + fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result { + if file.is_empty() && owner.is_empty() { + return Ok(self.host_info(None)); + } + if !self.owns(file, owner) { + return Err("that owner token doesn't manage a file of that id here (it may have been removed or expired)".into()); + } + Ok(self.host_info(Some(file))) + } + + fn hosted(&mut self, ip: IpAddr, owns: Option<&str>, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { + let failed = |reason: &str| Handled::from(AdminReply::Failed { reason: reason.to_string() }); + let file = match &req { + Admin::Publish { name, snapshot } => { + return match self.publish_open(ip, name, snapshot) { + Ok((file, edit, view, owner)) => { + let expires = self.files.get(&file.id).and_then(|e| self.expires(&e.meta)); + AdminReply::Hosted { file, edit, view, owner, expires }.into() + } + Err(e) => { + log::info!("event=publish_refused ip={ip} reason={e:?}"); + Handled::from(AdminReply::Failed { reason: e }) + } + }; + } + Admin::Owned { files } => return self.owned(files).into(), + Admin::Links { file } + | Admin::CreateLink { file, .. } + | Admin::Revoke { file, .. } + | Admin::Rename { file, .. } + | Admin::Code { file, .. } + | Admin::Remove { file } => file, + _ => return failed("that needs the server key"), + }; + if owns != Some(file.as_str()) { + return failed("an owner token manages only its own file"); + } + self.admin(req, open) + } + + fn report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { + self.keep_report(file, link, ip, reason) + } + + fn admin(&mut self, req: Admin, open: &dyn Fn(&str) -> Option) -> Handled { + let failed = |reason: String| Handled::from(AdminReply::Failed { reason }); + match req { + Admin::Publish { name, snapshot } => match self.publish(&name, &snapshot) { + Ok((file, edit, view)) => AdminReply::Published { file, edit, view }.into(), + Err(e) => failed(e), + }, + Admin::Files => AdminReply::Files(self.list(open)).into(), + Admin::Links { file } => self.links(&file).map_or_else(failed, |l| AdminReply::Links(l).into()), + Admin::CreateLink { file, access, label } => self.create_link(&file, access, &label).map_or_else(failed, |l| AdminReply::Link(l).into()), + Admin::Revoke { file, link } => match self.revoke(&file, &link) { + Ok(()) => Handled { reply: AdminReply::Done, revoked: Some((file, link)), removed: None, blocked: None }, + Err(e) => failed(e), + }, + Admin::Rename { file, name } => self.rename(&file, &name).map_or_else(failed, |()| AdminReply::Done.into()), + Admin::Listing => AdminReply::Listing(self.listing(open)).into(), + Admin::Reports => AdminReply::Reports(self.reports.clone()).into(), + Admin::Block { ip } => match self.block(&ip, true) { + Ok(ip) => Handled { reply: AdminReply::Done, revoked: None, removed: None, blocked: Some(ip) }, + Err(e) => failed(e), + }, + Admin::Unblock { ip } => self.block(&ip, false).map_or_else(failed, |_| AdminReply::Done.into()), + Admin::Blocked => AdminReply::Blocked(self.blocked()).into(), + Admin::Code { file, token } => self.set_code(&file, &token).map_or_else(failed, |code| AdminReply::Code { code }.into()), + Admin::Owned { files } => self.owned(&files).into(), + // The hub removes people itself (it knows who is connected); it never asks the store. + Admin::RemovePeer { .. } => failed("removing someone from a file is done by the server's hub".into()), + Admin::Remove { file } => { + if !self.files.contains_key(&file) { + return failed("this file isn't on the server (it may have been removed)".into()); + } + let reply = match self.remove(&file) { + Ok(()) => AdminReply::Done, + Err(reason) => AdminReply::Failed { reason }, + }; + Handled { reply, revoked: None, removed: Some(file), blocked: None } + } + } + } + + fn library(&mut self, ip: IpAddr, file: &str, access: Access, req: LibraryReq) -> LibraryReply { + let failed = |reason: String| LibraryReply::Failed { reason }; + match req { + LibraryReq::Fetch { have, usage } => { + if let Some(u) = usage + && let Err(e) = self.report_usage(file, u) + { + log::warn!("file {file}: a library usage report couldn't be kept: {e}"); + } + self.fetch_library(ip, file, have).unwrap_or_else(failed) + } + LibraryReq::Publish { .. } if access != Access::Edit => { + failed("you can only view this file: publishing its library needs an edit link".into()) + } + LibraryReq::Publish { archive } => { + self.publish_library(file, &archive).map_or_else(failed, |version| LibraryReply::Published { version }) + } + LibraryReq::Usage => self.library_usage(file).map_or_else(failed, LibraryReply::Usage), + } + } + + fn plugins(&mut self, ip: IpAddr, publisher: bool, req: PluginReq) -> PluginReply { + // One address's share first (before everyone's budget is spent or anything is read). + if let PluginReq::Fetch { id, version } = &req + && let Some(size) = self.directory.size_of(id, version.as_deref()) + && !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), Instant::now()) + { + return PluginReply::Failed { reason: BUSY_DOWNLOADS.into() }; + } + self.directory.handle(publisher, req) + } + + fn resolve(&mut self, ip: IpAddr, code: &str) -> Result<(String, String), String> { + self.resolve_code(ip, code) + } + + fn closing(&self) -> Closing { + Closing::Restarting + } +} diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs new file mode 100644 index 00000000..53ae9c87 --- /dev/null +++ b/apps/figcraft-server/src/store/files.rs @@ -0,0 +1,300 @@ +use super::*; + +impl Store { + /// Keep a document (its snapshot) as a new file with an edit link and a view link. + pub fn publish(&mut self, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink), String> { + self.keep(name, snapshot, None, &|_| Ok(())).map(|(info, edit, view, _)| (info, edit, view)) + } + + /// For each block the address `ip` counts in ([`ip_blocks`]), how many times one address's + /// caps it gets, and the files and bytes kept by the files published from addresses in it. An + /// address that doesn't parse counts as itself only, ×1, by its exact text. + pub(super) fn per_block(&self, ip: &str) -> Vec<(u32, usize, u64)> { + let blocks_of = |text: &str| text.parse::().map(|a| ip_blocks(ip_key(a))).unwrap_or_default(); + let kept: Vec<(&str, Vec<_>, u64)> = self + .files + .iter() + .filter_map(|(id, e)| { + let from = e.meta.ip.as_deref()?; + Some((from, blocks_of(from).into_iter().map(|(b, _)| b).collect(), self.bytes(id))) + }) + .collect(); + let sum = |mine: &mut dyn Iterator, u64)>| mine.fold((0, 0u64), |(f, b), (_, _, n)| (f + 1, b.saturating_add(*n))); + let blocks = blocks_of(ip); + if blocks.is_empty() { + let (files, bytes) = sum(&mut kept.iter().filter(|(from, _, _)| *from == ip)); + return vec![(1, files, bytes)]; + } + blocks + .into_iter() + .map(|(block, times)| { + let (files, bytes) = sum(&mut kept.iter().filter(|(_, blocks, _)| blocks.contains(&block))); + (times, files, bytes) + }) + .collect() + } + + /// Keep a document published without the key from `ip`, within the caps on what one address + /// and everyone may keep: the file, its links and its owner token. + pub fn publish_open(&mut self, ip: IpAddr, name: &str, snapshot: &[Change]) -> Result<(FileInfo, NewLink, NewLink, String), String> { + let h = &self.cfg.hosting; + let (open, max_files, max_ip, max_all) = (h.open, h.max_files_per_ip, h.max_bytes_per_ip, h.max_total_bytes); + if !open { + return Err(KEY_ONLY.into()); + } + if !self.abuse.allow(ip, Action::Publish, Instant::now()) { + return Err("too many files published from your address lately: try again in an hour".into()); + } + let blocks = self.per_block(&ip.to_string()); + for &(times, files, _) in &blocks { + if files >= max_files.saturating_mul(times as usize) { + return Err(format!( + "your address keeps {files} files on this server already, the most it may: remove one (with its owner token) or wait until one expires" + )); + } + } + let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); + let fits = |n: u64| { + for &(times, _, used) in &blocks { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("your address's files would take more than the {} MiB it may keep here: remove one first", cap >> 20)); + } + } + if used_all.saturating_add(n) > max_all { + return Err("this server is full: it takes no more files for now (try again later, or another server)".into()); + } + Ok(()) + }; + let (info, edit, view, owner) = self.keep(name, snapshot, Some(ip), &fits)?; + Ok((info, edit, view, owner.unwrap_or_default())) + } + + /// Keep a document as a new file: published without the key from `ip` (it gets an owner + /// token), else by the operator. `fits` checks its size against the caps that apply. + fn keep( + &mut self, + name: &str, + snapshot: &[Change], + ip: Option, + fits: &dyn Fn(u64) -> Result<(), String>, + ) -> Result<(FileInfo, NewLink, NewLink, Option), String> { + if self.files.len() >= self.cfg.max_files { + return Err(format!("the server keeps at most {} files", self.cfg.max_files)); + } + if let Some(why) = self.low_disk() { + return Err(why); + } + let replica = Replica::from_snapshot(snapshot).map_err(|e| format!("the file couldn't be read: {e}"))?; + if replica.document().node_count() > self.cfg.core.max_nodes { + return Err(format!("the file has more than {} layers", self.cfg.core.max_nodes)); + } + let mut doc = replica.document().clone(); + let name = match clean_name(name) { + n if n.is_empty() => doc.name.clone(), + n => n, + }; + doc.name = name.clone(); + let server = Server::new(doc, "", self.cfg.core); + let state = server.state().map_err(|e| e.to_string())?; + let bytes = disk::encode_snapshot(1, &state)?; + if bytes.len() as u64 > self.cfg.max_file_bytes { + return Err(format!("the file is {} MiB; this server keeps files up to {} MiB", bytes.len() >> 20, self.cfg.max_file_bytes >> 20)); + } + fits(bytes.len() as u64)?; + let id = loop { + let id = token::file_id()?; + if !self.files.contains_key(&id) && !self.dir(&id).exists() { + break id; + } + }; + let t = now(); + let (edit, edit_token) = new_link(Access::Edit, "Published (edit)", t)?; + let (view, view_token) = new_link(Access::View, "Published (view)", t)?; + let owner = ip.map(|_| token::secret()).transpose()?; + let meta = + Meta { name, created: t, updated: t, seq: 0, library: None, owner: owner.as_deref().map(hash), ip: ip.map(|i| i.to_string()), opened: 0 }; + let dir = self.dir(&id); + let written = fs::create_dir_all(&dir) + .map_err(|e| e.to_string()) + .and_then(|()| { + disk::sync_dir(Some(&self.root.join(FILES))); + disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| e.to_string()) + }) + .and_then(|()| disk::write_atomic(&dir.join(LOG), &disk::log_header(1)).map_err(|e| e.to_string())) + .and_then(|()| write_json(&dir.join(LINKS), &[edit.clone(), view.clone()])) + // Last: a folder without its description is an upload cut short. + .and_then(|()| write_json(&dir.join(META), &meta)); + if let Err(e) = written { + let _ = fs::remove_dir_all(&dir); + return Err(format!("couldn't keep the file: {e}")); + } + let entry = Entry { meta, links: vec![edit.clone(), view.clone()], log: None, bytes: (bytes.len() + LOG_HEADER) as u64, forced: 0 }; + self.files.insert(id.clone(), entry); + let info = self.files.get(&id).map(|e| self.info(&id, e, None)).ok_or("the file vanished")?; + match ip { + Some(ip) => log::info!("event=published file={id} bytes={} ip={ip} by=public", bytes.len()), + None => log::info!("event=published file={id} bytes={} by=key", bytes.len()), + } + Ok((info, NewLink { link: edit.info(&id), token: edit_token }, NewLink { link: view.info(&id), token: view_token }, owner)) + } + + /// The files kept, with what the operator moderates by. + pub fn listing(&self, open: &dyn Fn(&str) -> Option) -> Vec { + self.files + .iter() + .map(|(id, e)| FileRow { + info: self.info(id, e, open(id)), + opened: e.meta.opened, + expires: self.expires(&e.meta), + open: e.meta.owner.is_some(), + ip: e.meta.ip.clone().unwrap_or_default(), + reports: u32::try_from(self.reports.iter().filter(|r| r.file == *id).count()).unwrap_or(u32::MAX), + }) + .collect() + } + + /// Owner token `owner` manages file `file` (one published without the key). + pub(super) fn owns(&self, file: &str, owner: &str) -> bool { + let presented = hash(owner); + self.files.get(file).and_then(|e| e.meta.owner.as_deref()).is_some_and(|h| same_secret(&presented, h)) + } + + /// What became of the files these `(file id, owner token)` pairs own: each one's name, links + /// and expiry, or `None` when that token manages no file of that id here (which doesn't say + /// whether the id exists). Asking opens nothing: it doesn't count as using a file. + pub fn owned(&self, files: &[(String, String)]) -> AdminReply { + if files.len() > MAX_OWNED { + return AdminReply::Failed { reason: format!("ask about at most {MAX_OWNED} files at a time") }; + } + let one = |(file, owner): &(String, String)| { + let e = self.files.get(file).filter(|_| self.owns(file, owner))?; + Some(OwnedFile { name: e.meta.name.clone(), links: e.links.iter().map(|l| l.id.clone()).collect(), expires: self.expires(&e.meta) }) + }; + AdminReply::Owned(files.iter().map(one).collect()) + } + + /// Rename a file (its listing; the document inside keeps its own name). + pub fn rename(&mut self, file: &str, name: &str) -> Result<(), String> { + let name = clean_name(name); + if name.is_empty() { + return Err("give the file a name".into()); + } + let path = self.dir(file).join(META); + let entry = self.entry(file)?; + let before = std::mem::replace(&mut entry.meta.name, name); + if let Err(e) = write_json(&path, &entry.meta) { + entry.meta.name = before; + return Err(format!("couldn't rename the file: {e}")); + } + Ok(()) + } + + /// Delete the files published without the key that nobody opened for the idle time (not + /// those open now); their ids. + pub fn sweep(&mut self) -> Vec { + let t = now(); + self.next_sweep = t.saturating_add(SWEEP_EVERY); + let due: Vec = + self.files.iter().filter(|(_, e)| e.log.is_none() && self.expires(&e.meta).is_some_and(|x| x <= t)).map(|(id, _)| id.clone()).collect(); + for id in &due { + match self.remove(id) { + Ok(()) => log::info!("event=expired file={id}"), + Err(e) => log::warn!("event=expire_failed file={id} reason={e:?}"), + } + } + due + } + + /// Make a file look unused for `secs` more (tests: expiry without waiting). + #[cfg(test)] + pub fn age(&mut self, file: &str, secs: u64) { + let path = self.dir(file).join(META); + if let Some(e) = self.files.get_mut(file) { + let m = &mut e.meta; + (m.created, m.updated, m.opened) = (m.created.saturating_sub(secs), m.updated.saturating_sub(secs), m.opened.saturating_sub(secs)); + let _ = write_json(&path, m); + } + } + + /// Make an open file's log refuse writes (tests: a full or failing disk). + #[cfg(test)] + pub fn break_log(&mut self, file: &str) { + let path = self.dir(file).join(LOG); + if let Some(log) = self.files.get_mut(file).and_then(|e| e.log.as_mut()) + && let Ok(f) = File::open(path) + { + log.file = f; + } + } + + /// Note that a file was opened now (it is used: it doesn't expire). + pub(super) fn touch(&mut self, file: &str) { + let path = self.dir(file).join(META); + if let Some(e) = self.files.get_mut(file) { + e.meta.opened = now(); + if let Err(err) = write_json(&path, &e.meta) { + log::warn!("file {file}: couldn't update {META}: {err}"); + } + } + } + + /// What the server says about its hosting (with `file`'s expiry). + pub(super) fn host_info(&self, file: Option<&str>) -> HostInfo { + let h = &self.cfg.hosting; + let expires = file.and_then(|f| self.files.get(f)).and_then(|e| self.expires(&e.meta)); + HostInfo { open: h.open, operator: h.operator.clone(), terms: h.terms.clone(), idle_secs: h.idle_secs, max_file_mb: h.max_file_mb, expires } + } + + /// Block or unblock an address (an IPv6 address: its /64); the address as kept. + pub fn block(&mut self, ip: &str, on: bool) -> Result { + let ip = ip_key(ip.trim().parse::().map_err(|_| format!("`{}` isn't an IP address", ip.trim()))?); + if on && !self.blocked.contains(&ip) && self.blocked.len() >= MAX_BLOCKED { + return Err(format!("at most {MAX_BLOCKED} addresses can be blocked")); + } + let changed = if on { self.blocked.insert(ip) } else { self.blocked.remove(&ip) }; + if changed { + let list: Vec = self.blocked.iter().map(IpAddr::to_string).collect(); + write_json(&self.root.join(BLOCKED), &list).map_err(|e| format!("couldn't keep the blocked addresses: {e}"))?; + log::info!("event={} ip={ip}", if on { "blocked" } else { "unblocked" }); + } + Ok(ip) + } + + /// The blocked addresses. + pub fn blocked(&self) -> Vec { + self.blocked.iter().map(IpAddr::to_string).collect() + } + + /// The reports kept, oldest first. + pub fn reports(&self) -> &[ReportInfo] { + &self.reports + } + + /// Keep a report about `file` (from someone who joined it with `link`, at `ip`). + pub fn keep_report(&mut self, file: &str, link: Option<&str>, ip: IpAddr, reason: &str) -> Result<(), String> { + let reason = clean_reason(reason); + if reason.is_empty() { + return Err("say what is wrong with the file".into()); + } + let name = self.entry(file)?.meta.name.clone(); + if !self.abuse.allow(ip, Action::Report, Instant::now()) { + return Err("too many reports from your address lately: try again in an hour".into()); + } + self.reports.push(ReportInfo { + at: now(), + file: file.to_string(), + name, + link: link.unwrap_or_default().to_string(), + ip: ip.to_string(), + reason, + }); + if self.reports.len() > MAX_REPORTS { + let extra = self.reports.len() - MAX_REPORTS; + self.reports.drain(..extra); + } + write_json(&self.root.join(REPORTS), &self.reports).map_err(|e| format!("couldn't keep the report: {e}"))?; + log::info!("event=report file={file} ip={ip}"); + Ok(()) + } +} diff --git a/apps/figcraft-server/src/store/journal.rs b/apps/figcraft-server/src/store/journal.rs new file mode 100644 index 00000000..e3983543 --- /dev/null +++ b/apps/figcraft-server/src/store/journal.rs @@ -0,0 +1,270 @@ +use super::*; + +impl Store { + /// Whether `file` has room for about `n` more bytes: its cap, and for a file published + /// without the key its address's share and everyone's. A log that took it over its cap is + /// compacted first (the state may be smaller than its history). + pub(super) fn room_for(&mut self, file: &str, server: &Server, n: u64) -> Result<(), String> { + if let Some(why) = self.low_disk() { + return Err(why); + } + let max = self.cfg.max_file_bytes; + let over = |s: &Store| s.bytes(file).saturating_add(n) > max; + // A compaction to make room at most once a minute per file (it rewrites the whole file). + let t = now(); + let may_compact = self.files.get(file).is_some_and(|e| { + t >= e.forced.saturating_add(FORCED_COMPACTION_EVERY) && e.log.as_ref().is_some_and(|l| l.entries > 0 || !l.pending.is_empty()) + }); + if over(self) && may_compact { + if let Some(e) = self.files.get_mut(file) { + e.forced = t; + } + self.compact(file, server); + } + if over(self) { + return Err(format!("this file has reached the server's limit of {} MiB: that change wasn't kept", max >> 20)); + } + let h = &self.cfg.hosting; + let Some(ip) = self.files.get(file).filter(|e| e.meta.owner.is_some()).and_then(|e| e.meta.ip.clone()) else { return Ok(()) }; + let (max_ip, max_all) = (h.max_bytes_per_ip, h.max_total_bytes); + for (times, _, used) in self.per_block(&ip) { + let cap = max_ip.saturating_mul(u64::from(times)); + if used.saturating_add(n) > cap { + return Err(format!("the files published from this file's address have reached their {} MiB: that change wasn't kept", cap >> 20)); + } + } + let used_all: u64 = self.files.keys().map(|id| self.bytes(id)).sum(); + if used_all.saturating_add(n) > max_all { + return Err("this server is full: that change wasn't kept".into()); + } + Ok(()) + } + + /// Load a file's session: its snapshot, then its log (a damaged end is dropped and reported). + pub(super) fn load(&mut self, file: &str) -> Result { + self.touch(file); + let dir = self.dir(file); + let (generation, state) = disk::decode_snapshot(&disk::read_capped(&dir.join(SNAPSHOT), self.snapshot_cap())?)?; + let mut server = Server::restore(state, "", self.cfg.core).map_err(|e| format!("the snapshot can't be used: {e}"))?; + let mut issues = Vec::new(); + let mut fresh_log = true; + let mut kept = 0; + let log_path = dir.join(LOG); + // A log that's there but can't be read (an IO error, or over a lowered cap) may hold + // confirmed edits: the file isn't opened, and the log is left as it is for another try. + let read = match disk::read_capped(&log_path, self.cfg.max_file_bytes) { + Err(why) if log_path.exists() => return Err(format!("its log can't be read ({why}); it was left as it is")), + r => r, + }; + // One that isn't a log is moved aside before a new one replaces it (or the file isn't opened). + let read = match read.map(|b| disk::read_log(&b)) { + Ok(Err(why)) => { + let aside = dir.join(format!("{LOG}.unreadable-{generation}-{}", now())); + fs::rename(&log_path, &aside).map_err(|e| format!("{why}, and it couldn't be moved aside ({e})"))?; + Err(format!("{why}; it was moved aside to {}", aside.display())) + } + Ok(Ok(log)) => Ok(log), + Err(why) => Err(why), + }; + match read { + Ok(log) if log.generation == generation => { + fresh_log = false; + for entry in log.entries { + if let Err(e) = server.replay(entry) { + issues.push(format!("file {file}: its log stops making sense after {kept} entries ({e}); the rest was dropped")); + fresh_log = true; + break; + } + kept += 1; + } + if let Some(why) = log.damaged { + issues.push(format!("file {file}: its log ended with {why}; that part was dropped")); + fresh_log = true; + } + } + // From before a compaction that was cut short, the snapshot has it all; or a generation + // field damaged, which no checksum covers. Replaying tells them apart without trusting + // it: entries the snapshot holds are skipped, so only edits it lacks change anything. + Ok(log) => { + let before = server.seq(); + let mut stopped = None; + for entry in log.entries { + if let Err(e) = server.replay(entry) { + stopped = Some(e); + break; + } + } + let (snap, found, recovered) = (generation, log.generation, server.seq().saturating_sub(before)); + if recovered > 0 { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}), but it held \ + {recovered} changes the snapshot didn't; they were recovered" + )); + } + if let Some(e) = stopped { + issues.push(format!( + "file {file}: its log's generation ({found}) doesn't match its snapshot's ({snap}) and it stops \ + making sense after {recovered} newer changes ({e}); the rest was dropped" + )); + } + } + Err(why) => issues.push(format!("file {file}: {why}; starting a new log")), + } + for i in issues { + log::warn!("{i}"); + self.note(i); + } + server.journaling(true); + let file_handle = OpenOptions::new().append(true).open(dir.join(LOG)); + let log = match (fresh_log, file_handle) { + (false, Ok(f)) => { + let bytes = f.metadata().map_or(LOG_HEADER as u64, |m| m.len()); + // Replayed entries make it worth compacting once everyone has left. + Log { generation, file: f, pending: Vec::new(), entries: kept, bytes, dirty: kept > 0 } + } + // A new snapshot and log of the next generation, so appends start clean. + _ => { + let next = generation.checked_add(1).ok_or("the file's generation can't go any higher")?; + self.rewrite(file, next, &server).map_err(|e| e.why)? + } + }; + self.entry(file)?.log = Some(log); + Ok(server) + } + + /// The largest snapshot read back: a file's cap plus room for what its state keeps beside the + /// document (recent batches for reconnecting clients, deletions), which may take it over. + pub(super) fn snapshot_cap(&self) -> u64 { + self.cfg.max_file_bytes.saturating_mul(2) + } + + /// Write `server`'s state as snapshot `generation` and start an empty log of it. + fn rewrite(&mut self, file: &str, generation: u64, server: &Server) -> Result { + let dir = self.dir(file); + let kept = |why: String| RewriteError { why, snapshot_replaced: false }; + let state = server.state().map_err(|e| kept(e.to_string()))?; + let bytes = disk::encode_snapshot(generation, &state).map_err(kept)?; + // Never replace a readable snapshot with one that can't be opened again: the old snapshot + // and log still hold everything. + if bytes.len() as u64 > self.snapshot_cap() { + return Err(kept(format!("the snapshot would be {} MiB, more than can be opened again", bytes.len() >> 20))); + } + if bytes.len() as u64 > self.cfg.max_file_bytes { + log::warn!("file {file} is {} MiB, over the {} MiB cap (kept anyway)", bytes.len() >> 20, self.cfg.max_file_bytes >> 20); + } + disk::write_atomic(&dir.join(SNAPSHOT), &bytes).map_err(|e| kept(format!("couldn't write the snapshot: {e}")))?; + // From here the old log no longer goes with the snapshot on disk. + let replaced = |why: String| RewriteError { why, snapshot_replaced: true }; + disk::write_atomic(&dir.join(LOG), &disk::log_header(generation)).map_err(|e| replaced(format!("couldn't start the log: {e}")))?; + let f = OpenOptions::new().append(true).open(dir.join(LOG)).map_err(|e| replaced(format!("couldn't open the log: {e}")))?; + if let Ok(e) = self.entry(file) { + e.bytes = (bytes.len() + LOG_HEADER) as u64; + e.meta.seq = server.seq(); + let meta = e.meta.clone(); + if let Err(err) = write_json(&dir.join(META), &meta) { + log::warn!("file {file}: couldn't update {META}: {err}"); + } + } + Ok(Log { generation, file: f, pending: Vec::new(), entries: 0, bytes: LOG_HEADER as u64, dirty: false }) + } + + /// Compactions so far. + pub fn compactions(&self) -> u64 { + self.compactions + } + + /// Why nothing more may be written when the data directory's disk is nearly full (less free + /// than [`StoreConfig::min_free_bytes`]); looked up at most once a second. Where the free space + /// can't be told, nothing is refused. + pub(super) fn low_disk(&mut self) -> Option { + let now = Instant::now(); + let free = match self.free { + Some((at, free)) if now.saturating_duration_since(at) < std::time::Duration::from_secs(1) => free, + _ => { + let free = disk::free_bytes(&self.root); + self.free = Some((now, free)); + free + } + }; + free.filter(|f| *f < self.cfg.min_free_bytes) + .map(|_| "the server's disk is nearly full: it takes no changes or new files for now (tell its operator)".to_string()) + } + + /// Note a problem for the operator (logged too); only the latest [`MAX_PROBLEMS`] are kept. + pub(super) fn note(&mut self, problem: String) { + if self.problems.len() >= MAX_PROBLEMS { + self.problems.remove(0); + } + self.problems.push(problem); + } + + /// Compact an open file: its state as the next snapshot, an empty log. + pub(super) fn compact(&mut self, file: &str, server: &Server) { + self.compactions += 1; + let Some(generation) = self.files.get(file).and_then(|e| e.log.as_ref()).map(|l| l.generation) else { return }; + let Some(next) = generation.checked_add(1) else { + log::error!("file {file}: compaction failed: the file's generation can't go any higher"); + return; + }; + match self.rewrite(file, next, server) { + Ok(log) => { + if let Ok(e) = self.entry(file) { + e.log = Some(log); + } + log::debug!("compacted file {file}"); + } + // The new snapshot holds everything, but a restart would drop whatever is appended to + // the old log now (its generation no longer matches): the file closes, the hub hears + // it at the next flush, and it opens again from the new snapshot. + Err(RewriteError { why, snapshot_replaced: true }) => { + self.note(format!("file {file}: compaction was cut short ({why}); it was closed")); + log::error!("file {file}: compaction was cut short: {why}"); + if let Some(e) = self.files.get_mut(file) { + e.log = None; + } + self.cut_short.push(file.to_string()); + } + // The old snapshot and log still hold everything: carry on appending. + Err(RewriteError { why, .. }) => log::error!("file {file}: compaction failed: {why}"), + } + } + + /// Write and sync what's pending in every open log; the files whose log couldn't be written. + /// Those are closed here (their pending records dropped: nobody was told of them) and open + /// again from what is on disk. + pub(super) fn flush_all(&mut self) -> Vec { + let mut failed = std::mem::take(&mut self.cut_short); + for (id, e) in &mut self.files { + if let Some(log) = e.log.as_mut() { + match flush_log(id, log) { + Ok(n) => e.bytes += n, + Err(()) => { + e.log = None; + failed.push(id.clone()); + } + } + } + } + failed + } +} + +/// Write and sync a log's pending records: the bytes written, or `Err` when that failed (the log +/// can't be trusted to hold them). +pub(super) fn flush_log(id: &str, log: &mut Log) -> Result { + if log.pending.is_empty() { + return Ok(0); + } + match log.file.write_all(&log.pending).and_then(|()| log.file.sync_data()) { + Ok(()) => { + let n = log.pending.len() as u64; + log.bytes += n; + log.pending.clear(); + Ok(n) + } + Err(e) => { + log::error!("file {id}: couldn't write its log: {e}"); + Err(()) + } + } +} diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs new file mode 100644 index 00000000..adb592db --- /dev/null +++ b/apps/figcraft-server/src/store/library.rs @@ -0,0 +1,161 @@ +use super::*; + +impl Store { + /// A file's team library for `ip`, unless its version is `have`; within what one address and + /// everyone may download (the size is known before anything is read). + pub(super) fn fetch_library(&mut self, ip: IpAddr, file: &str, have: Option) -> Result { + let (dir, max) = (self.dir(file), self.cfg.max_file_bytes); + let Some(lib) = self.entry(file)?.meta.library.clone() else { return Ok(LibraryReply::Empty) }; + if have == Some(lib.version) { + return Ok(LibraryReply::Unchanged { version: lib.version }); + } + let path = library_file(&dir, lib.version); + let size = fs::metadata(&path).map_or(0, |m| m.len()); + let now = Instant::now(); + if !self.abuse.allow(ip, Action::Download(usize::try_from(size).unwrap_or(usize::MAX)), now) || !self.served.take_n(now, size as f64) { + return Err(BUSY_DOWNLOADS.into()); + } + Ok(LibraryReply::Library { version: lib.version, archive: disk::read_capped(&path, max)? }) + } + + /// Keep `archive` as a file's team library: it must read as a library (with the limits files + /// using it read it with), be the file's library if it has one, and be a newer version. + // shortcut: the archive is checked on the hub thread (other files' sessions wait meanwhile); + // move it to a worker if large libraries make that noticeable. + pub fn publish_library(&mut self, file: &str, archive: &[u8]) -> Result { + let max = self.cfg.max_file_bytes; + if self.cfg.hosting.open && self.entry(file)?.meta.owner.is_some() { + return Err(NO_PUBLIC_LIBRARY.into()); + } + if archive.len() as u64 > max { + return Err(format!("the library is {} MiB; this server keeps files up to {} MiB", archive.len() >> 20, max >> 20)); + } + // Everything it unpacks to counts against the same cap, so a small archive can't expand + // into a large library in memory. + let lib = match figcraft_format::read_library_within(Cursor::new(archive), max) { + Ok(Some(lib)) => lib, + Ok(None) => return Err("that isn't a published library".into()), + Err(e) => return Err(format!("the library can't be read: {e}")), + }; + if lib.doc.node_count() > self.cfg.core.max_nodes { + return Err(format!("the library has more than {} layers", self.cfg.core.max_nodes)); + } + let m = &lib.manifest; + let dir = self.dir(file); + let entry = self.entry(file)?; + if let Some(old) = &entry.meta.library { + if old.id != m.id { + return Err(format!("this file's library on the server is another one (\"{}\")", old.name)); + } + if m.version <= old.version { + return Err(format!("version {} is on the server already: publish again to follow it", old.version)); + } + // A jump would let a client use up the numbers and leave no newer version to publish. + if m.version > old.version.saturating_add(1) { + return Err(format!("version {} is too high: the next one after {} is {}", m.version, old.version, old.version.saturating_add(1))); + } + } else if m.version > MAX_FIRST_LIBRARY_VERSION { + return Err(format!("version {} is too high: a first publish goes up to {MAX_FIRST_LIBRARY_VERSION}", m.version)); + } + // Each version has a file of its own, written before the description names it: a failed + // description write or a crash in between leaves the previous version served whole, never + // the new bytes under the old number. + let path = dir.join(library_name(m.version)); + disk::write_atomic(&path, archive).map_err(|e| format!("couldn't keep the library: {e}"))?; + let before = entry.meta.library.replace(LibraryMeta { id: m.id.clone(), name: m.name.clone(), version: m.version }); + if let Err(e) = write_json(&dir.join(META), &entry.meta) { + // Never served (the description doesn't name it); the next publish of it replaces it. + if let Err(r) = fs::remove_file(&path) { + log::warn!("file {file}: an unpublished library version couldn't be deleted: {r}"); + } + entry.meta.library = before; + return Err(format!("couldn't keep the library: {e}")); + } + // The previous version isn't served any more. + if let Some(Err(e)) = before.map(|old| fs::remove_file(library_file(&dir, old.version))) { + log::warn!("file {file}: the previous library version couldn't be deleted: {e}"); + } + log::info!("file {file}: published library version {} ({} bytes)", m.version, archive.len()); + Ok(m.version) + } + + /// A library's usage reports in memory (read from disk the first time; a damaged list starts + /// over: it is only statistics). `None` when the file has no library. + fn usage_of(&mut self, file: &str) -> Result, String> { + if self.entry(file)?.meta.library.is_none() { + return Ok(None); + } + if !self.usage.contains_key(file) { + let path = self.dir(file).join(USAGE); + let list = if path.exists() { + disk::read_capped(&path, MAX_USAGE_FILE).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() + } else { + Vec::new() + }; + self.usage.insert(file.to_string(), UsageCache { list, dirty: false, written: now() }); + } + Ok(self.usage.get_mut(file)) + } + + /// The usage reports kept for a file's library (none when it has no library). + pub fn library_usage(&mut self, file: &str) -> Result, String> { + Ok(self.usage_of(file)?.map(|u| u.list.clone()).unwrap_or_default()) + } + + /// Keep how one file uses a file's library (in memory; written when it changed, at most every + /// [`USAGE_WRITE_EVERY`] seconds). + pub fn report_usage(&mut self, file: &str, usage: Usage) -> Result<(), String> { + let name = clean_name(&usage.file); + if name.is_empty() { + return Ok(()); + } + let mut components: Vec<_> = usage.components.into_iter().filter(|(_, n)| *n > 0).take(MAX_REPORTED * 4).collect(); + components.sort(); + components.dedup_by_key(|c| c.0); + components.truncate(MAX_REPORTED); + let Some(u) = self.usage_of(file)? else { return Ok(()) }; + match u.list.iter_mut().find(|f| f.usage.file == name) { + Some(f) if f.usage.components == components => return Ok(()), + Some(f) => { + f.usage.components = components; + f.updated = now(); + } + None => u.list.push(FileUsage { usage: Usage { file: name, components }, updated: now() }), + } + if u.list.len() > MAX_USERS { + u.list.sort_by_key(|f| std::cmp::Reverse(f.updated)); + u.list.truncate(MAX_USERS); + } + u.dirty = true; + if now() >= u.written.saturating_add(USAGE_WRITE_EVERY) { + self.write_usage(false); + } + Ok(()) + } + + /// Write the usage reports that changed (`all`: whenever they were last written). + pub(super) fn write_usage(&mut self, all: bool) { + let t = now(); + let root = self.root.join(FILES); + for (file, u) in &mut self.usage { + if u.dirty && (all || t >= u.written.saturating_add(USAGE_WRITE_EVERY)) { + if let Err(e) = write_json(&root.join(file).join(USAGE), &u.list) { + log::warn!("file {file}: library usage couldn't be kept: {e}"); + } + (u.dirty, u.written) = (false, t); + } + } + } +} + +/// The name of the file holding version `version` of a file's team library. +pub fn library_name(version: u64) -> String { + format!("library-{version}.bin") +} + +/// Where version `version` of the team library in `dir` is: its own file, or `library.bin` for a +/// version published before each version had one. +fn library_file(dir: &Path, version: u64) -> PathBuf { + let path = dir.join(library_name(version)); + if path.exists() { path } else { dir.join(LIBRARY) } +} diff --git a/apps/figcraft-server/src/store/links.rs b/apps/figcraft-server/src/store/links.rs new file mode 100644 index 00000000..ad30f458 --- /dev/null +++ b/apps/figcraft-server/src/store/links.rs @@ -0,0 +1,154 @@ +use super::*; + +impl Store { + /// The files kept. + pub fn list(&self, open: &dyn Fn(&str) -> Option) -> Vec { + self.files.iter().map(|(id, e)| self.info(id, e, open(id))).collect() + } + + /// A file's links. + pub fn links(&mut self, file: &str) -> Result, String> { + Ok(self.entry(file)?.links.iter().map(|l| l.info(file)).collect()) + } + + /// A new link to a file. + pub fn create_link(&mut self, file: &str, access: Access, label: &str) -> Result { + let max = self.cfg.max_links; + let path = self.dir(file).join(LINKS); + let entry = self.entry(file)?; + if entry.links.len() >= max { + return Err(format!("a file has at most {max} links: revoke one first")); + } + let (link, token) = new_link(access, &clean_name(label), now())?; + entry.links.push(link.clone()); + if let Err(e) = write_json(&path, &entry.links) { + entry.links.pop(); + return Err(format!("couldn't keep the link: {e}")); + } + Ok(NewLink { link: link.info(file), token }) + } + + /// The link of `entry` whose token `token` is (every link is compared, in constant time, so + /// timing doesn't tell which one matched). + pub(super) fn link_of(entry: &Entry, token: &str) -> Option { + let presented = hash(token); + let mut found = None; + for (i, l) in entry.links.iter().enumerate() { + if same_secret(&presented, &l.hash) { + found = Some(i); + } + } + found + } + + /// These join codes no longer open anything: typing one says so. + fn forget_codes(&mut self, codes: impl IntoIterator) { + self.gone_codes.extend(codes.into_iter().map(|c| c.hash)); + let extra = self.gone_codes.len().saturating_sub(MAX_GONE_CODES); + self.gone_codes.drain(..extra); + } + + /// Where the link with the join code `key` is: its file, its place among the file's links + /// and its token (every code is compared, so timing doesn't tell which one matched). + fn find_code(&self, key: &codes::Key) -> Option<(String, usize, String)> { + let mut found = None; + for (id, e) in &self.files { + for (i, l) in e.links.iter().enumerate() { + if let Some(token) = l.code.as_ref().and_then(|c| key.open(c)) { + found = Some((id.clone(), i, token)); + } + } + } + found + } + + /// A new join code for the link of `file` whose token is `token` (it takes the place of the + /// link's earlier code): the code, shown this once. + pub fn set_code(&mut self, file: &str, token: &str) -> Result { + let at = Self::link_of(self.entry(file)?, token).ok_or("that token isn't one of this file's links (it may have been revoked)")?; + // 45 random bits: one already in use is all but impossible, and never handed out twice. + let (code, key) = loop { + let code = codes::new_code()?; + let key = codes::Key::new(&code); + if self.find_code(&key).is_none() { + break (code, key); + } + }; + let sealed = key.seal(token)?; + let path = self.dir(file).join(LINKS); + let entry = self.entry(file)?; + let link = entry.links.get_mut(at).ok_or("that file has no such link")?; + let before = link.code.replace(sealed); + let id = link.id.clone(); + if let Err(e) = write_json(&path, &entry.links) { + if let Some(l) = entry.links.get_mut(at) { + l.code = before; + } + return Err(format!("couldn't keep the join code: {e}")); + } + self.forget_codes(before); + log::info!("event=code file={file} link={id}"); + Ok(code) + } + + /// The file a join code typed at `ip` opens, and its link's token; wrong codes count against + /// the address (see `abuse`), and nothing is looked up for an address that is locked out. + pub fn resolve_code(&mut self, ip: IpAddr, typed: &str) -> Result<(String, String), String> { + let now = Instant::now(); + if let Some(left) = self.abuse.code_locked(ip, now) { + return Err(format!( + "Too many wrong codes from your address: try again in {} minutes, or join with the invite link", + left.as_secs() / 60 + 1 + )); + } + if !self.abuse.allow(ip, Action::Code, now) { + return Err("Too many codes tried from your address: wait a minute and try again".into()); + } + if !self.abuse.codes_open(now) { + return Err(CODES_BUSY.into()); + } + // Anything that isn't a code's symbols is a wrong code too (and costs no hashing). + let key = figcraft_collab::code::normalize(typed).ok().filter(|c| c.len() == figcraft_collab::code::ONLINE_LEN).map(|c| codes::Key::new(&c)); + let found = key.as_ref().and_then(|k| self.find_code(k)); + self.abuse.code_tried(ip, found.is_some(), now); + match found { + Some((file, _, token)) => { + log::info!("event=code_used file={file} ip={ip}"); + Ok((file, token)) + } + None => { + let gone = key.is_some_and(|k| self.gone_codes.iter().any(|g| g == k.hash())); + Err(if gone { CODE_GONE } else { NO_CODE }.into()) + } + } + } + + /// Revoke a link. + pub fn revoke(&mut self, file: &str, link: &str) -> Result<(), String> { + let path = self.dir(file).join(LINKS); + let entry = self.entry(file)?; + let at = entry.links.iter().position(|l| l.id == link).ok_or("that file has no such link")?; + let gone = entry.links.remove(at); + if let Err(e) = write_json(&path, &entry.links) { + entry.links.insert(at, gone); + return Err(format!("couldn't revoke the link: {e}")); + } + log::info!("revoked link {link} of file {file}"); + self.forget_codes(gone.code); + Ok(()) + } + + /// Remove a file and its links for good. `Err` when its folder couldn't be deleted (it is no + /// longer served either way). + pub fn remove(&mut self, file: &str) -> Result<(), String> { + // Closed first: an open log would keep the folder from being deleted on Windows. + let entry = self.files.remove(file).ok_or("this file isn't on the server (it may have been removed)")?; + self.forget_codes(entry.links.iter().filter_map(|l| l.code.clone())); + drop(entry); + let dir = self.dir(file); + fs::remove_dir_all(&dir) + .map_err(|e| format!("it is no longer served, but its folder {} couldn't be deleted ({e}); delete it by hand", dir.display()))?; + log::info!("removed file {file}"); + Ok(()) + } +} diff --git a/crates/collab-net/src/hub.rs b/crates/collab-net/src/hub.rs index 1471c179..363ecae0 100644 --- a/crates/collab-net/src/hub.rs +++ b/crates/collab-net/src/hub.rs @@ -46,1047 +46,27 @@ use crate::join::{ENDED, RESTARTING}; use crate::net::{Bucket, Duplex, Meter, NetLimits, Readers, config, shutdown}; use crate::throttle::{PRESENCE_INTERVAL, Throttle, earliest}; -/// How long a closing connection waits for the peer to hang up. -const LINGER: Duration = Duration::from_secs(2); +mod backend; +mod conn; +mod hosting; +mod limits; +mod queue; -/// Signals (cursor chat, spotlight) a client may send per second on average, and in a burst; -/// beyond that they are dropped. -pub const SIGNAL_RATE: f64 = 20.0; -pub const SIGNAL_BURST: f64 = 40.0; +pub use backend::{Backend, Closing, Handled, OpenFile}; +pub use conn::{SIGNAL_BURST, SIGNAL_RATE}; +use conn::{encode, reason, refuse, refused}; +pub use hosting::Hosting; +pub(crate) use hosting::{Local, clean_name}; +pub use limits::{Admission, Block, ip_blocks, ip_key}; +pub use queue::wide_block; +#[cfg(test)] +use queue::{BlockUse, Blocks}; +use queue::{Charges, HubIn, InFlight, Line, Out, Push}; /// Most messages the hub handles before it sends what they produced (bounds the delay a burst /// adds). const BURST: usize = 256; -/// What a link lets a hello do. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Admission { - pub access: Access, - /// The link's id, when the backend has several per file (revoking it disconnects whoever - /// joined with it). - pub link: Option, -} - -/// An admin request's reply, and what the hub must do about connections it affects. -#[derive(Clone, Debug, PartialEq)] -pub struct Handled { - pub reply: AdminReply, - /// A link was revoked: `(file, link)`; whoever joined with it is disconnected. - pub revoked: Option<(String, String)>, - /// A file was removed: whoever is in it is disconnected and it is closed without saving. - pub removed: Option, - /// An address was blocked ([`ip_key`]): its connections are dropped. - pub blocked: Option, -} - -impl From for Handled { - fn from(reply: AdminReply) -> Self { - Self { reply, revoked: None, removed: None, blocked: None } - } -} - -/// The address limits and blocks count a client by: its IPv4 address (an IPv4-mapped IPv6 address -/// as IPv4), or its IPv6 address's /64 (one subscriber's block: they have billions of addresses). -pub fn ip_key(ip: IpAddr) -> IpAddr { - match ip.to_canonical() { - IpAddr::V6(v6) => { - let [a, b, c, d, ..] = v6.segments(); - IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)) - } - v4 => v4, - } -} - -/// What everyone is told when a hub stops. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Closing { - /// The session is over (the app stopped hosting): final. - Ended, - /// The server is restarting: clients reconnect until it is back. - Restarting, -} - -/// What the hub knows of an open file (for listings). -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct OpenFile { - /// People connected to it. - pub people: u32, - /// Its latest sequence number. - pub seq: u64, -} - -/// Where a hub's files come from and what it keeps of them (see the module docs). The hub calls it -/// from its own thread only. -pub trait Backend: Send + 'static { - /// A hello for `file` with `token`: what it may do, or why it may not join (the reason is - /// shown to the person joining; never echo the token). - fn admit(&mut self, file: &str, token: &str) -> Result; - - /// The session for a file that isn't open (after [`admit`](Self::admit) let someone in). - fn open(&mut self, file: &str) -> Result; - - /// The hub handled a message for an open file: keep what changed (its server's journal). - fn changed(&mut self, _file: &str, _server: &mut Server) {} - - /// Make what [`changed`](Self::changed) kept durable; the hub sends the servers' answers - /// after this. The files whose changes couldn't be made durable: nobody hears of them, the - /// files are closed (the backend forgets their open state) and everyone in them reconnects. - fn flush(&mut self) -> Vec { - Vec::new() - } - - /// Whether `file` has room for a batch of about `bytes` (its size on the wire): why not, and - /// the batch is refused. - fn room(&mut self, _file: &str, _server: &Server, _bytes: usize) -> Result<(), String> { - Ok(()) - } - - /// Everyone left `file`: save it. True closes it (the next hello opens it again). - fn idle(&mut self, _file: &str, _server: &mut Server) -> bool { - false - } - - /// The hub is stopping: save the open files. - fn stop(&mut self, _open: Vec<(&str, &mut Server)>) {} - - /// A managing connection's key: whether it may manage files. - fn manage(&mut self, _key: &str) -> Result<(), String> { - Err("this session is hosted by an app: it has no files to manage".into()) - } - - /// A request on a managing connection; `open` tells about a file that is open. - fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { - AdminReply::Failed { reason: "nothing to manage here".into() }.into() - } - - /// A request on a library connection to `file`, opened with a link of `access` (checked by - /// [`admit`](Self::admit)). - fn library(&mut self, _ip: IpAddr, _file: &str, _access: Access, _req: LibraryReq) -> LibraryReply { - LibraryReply::Failed { reason: "this session is hosted by an app: team libraries live on a figcraft-server".into() } - } - - /// A request on a plugin directory connection; `publisher`: it was opened with the right key - /// (checked by [`manage`](Self::manage)). - fn plugins(&mut self, _ip: IpAddr, _publisher: bool, _req: PluginReq) -> PluginReply { - PluginReply::Failed { reason: "this session is hosted by an app: plugin directories live on a figcraft-server".into() } - } - - /// A new connection from `ip` ([`ip_key`]): whether it may go on (why not is shown to the - /// client). - fn connect(&mut self, _ip: IpAddr) -> Result<(), String> { - Ok(()) - } - - /// A message from a connection from `ip`: false disconnects it (that address sends too much). - fn message(&mut self, _ip: IpAddr, _msg: &ClientMsg) -> bool { - true - } - - /// What to tell a client that reads it about the server's hosting, after its welcome to - /// `file` (`None`: nothing). - fn hosting(&mut self, _file: &str) -> Option { - None - } - - /// An open hosting connection from `ip`, with a file's owner token when `file` isn't empty: - /// what the server says about its hosting, or why the connection is refused. - fn open_hosting(&mut self, _ip: IpAddr, _file: &str, _owner: &str) -> Result { - Err("this session is hosted by an app: it doesn't keep files for anyone".into()) - } - - /// A request on an open hosting connection from `ip`; `owns`: the file whose owner token it - /// was opened with. - fn hosted(&mut self, _ip: IpAddr, _owns: Option<&str>, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { - AdminReply::Failed { reason: "nothing to manage here".into() }.into() - } - - /// Someone in `file` (joined with `link`, from `ip`) reports it to the operator. - fn report(&mut self, _file: &str, _link: Option<&str>, _ip: IpAddr, _reason: &str) -> Result<(), String> { - Err("this session is hosted by an app: tell the person hosting it".into()) - } - - /// A join code typed by someone at `ip`: the file it opens and that link's token, or why not - /// (shown to them; wrong codes are counted against the address). - fn resolve(&mut self, _ip: IpAddr, _code: &str) -> Result<(String, String), String> { - Err("this session is hosted by an app: join it with its own code or invite link".into()) - } - - /// What everyone is told when the hub stops. - fn closing(&self) -> Closing; -} - -/// A message for one connection's writer: bytes (with what they count against, released when -/// they are sent or dropped), or the close. -enum Out { - Bytes(Bytes, Option), - Close(CloseCode, String), -} - -/// One message's bytes held for one or more connections (a broadcast shares one buffer), counted -/// once against a shared counter (the server's, or a block's) until the last copy is sent or -/// dropped. -struct Held { - n: usize, - counter: Arc, -} - -impl Held { - fn new(n: usize, counter: &Arc) -> Arc { - counter.fetch_add(n, Ordering::Relaxed); - Arc::new(Held { n, counter: counter.clone() }) - } -} - -impl Drop for Held { - fn drop(&mut self) { - self.counter.fetch_sub(self.n, Ordering::Relaxed); - } -} - -/// What one message counts against, made by its first recipient and shared by the others: the -/// server's budget once, and each recipient block's once (twenty people behind one office's -/// address cost their block one copy of a broadcast, not twenty). -#[derive(Default)] -struct Charges { - all: Option>, - blocks: Vec<(usize, Arc)>, - /// Count it against blocks at all: a welcome isn't (it is bounded by the file's size and each - /// connection's cap, and a whole office joining at once must fit). - skip_blocks: bool, -} - -/// Bytes waiting in a connection's queue, counted against its cap until dropped (and, through -/// [`Held`], once against the server's budget and once against its block's). -struct Charge { - n: usize, - mine: Arc, - _all: Arc, - _block: Option>, -} - -impl Drop for Charge { - fn drop(&mut self) { - self.mine.fetch_sub(self.n, Ordering::Relaxed); - } -} - -/// Why a message couldn't be queued. -#[derive(Debug, PartialEq, Eq)] -enum Push { - /// The connection's or its block's queue is full: it can't keep up. - Full, - /// The server's budget for queued bytes is used up. - Everyone, - /// The connection is gone. - Gone, -} - -/// One connection's way out: its writer's queue, capped in messages and in bytes (its own, its -/// block of addresses', and the server's for everyone), so a client that doesn't read can't make -/// the server hold more than that for it. -#[derive(Clone)] -struct Line { - tx: SyncSender, - queued: Arc, - block: Arc, - all: Arc, - max: usize, - max_block: usize, - max_all: usize, -} - -impl Line { - /// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file - /// gets out). `charges` is what the message counts against, shared by its recipients; - /// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer - /// to make room). - fn push(&self, b: &Bytes, charges: &mut Charges, over_budget: bool) -> Result<(), Push> { - let n = b.len(); - let mine = self.queued.load(Ordering::Relaxed); - if mine > 0 && mine.saturating_add(n) > self.max { - return Err(Push::Full); - } - let key = Arc::as_ptr(&self.block) as usize; - let block = if charges.skip_blocks { - None - } else if let Some((_, h)) = charges.blocks.iter().find(|(k, _)| *k == key) { - Some(h.clone()) - } else { - let used = self.block.load(Ordering::Relaxed); - if used > 0 && used.saturating_add(n) > self.max_block { - return Err(Push::Full); - } - let h = Held::new(n, &self.block); - charges.blocks.push((key, h.clone())); - Some(h) - }; - let all = match &charges.all { - Some(h) => h.clone(), - None => { - if !over_budget && self.all.load(Ordering::Relaxed).saturating_add(n) > self.max_all { - return Err(Push::Everyone); - } - charges.all.insert(Held::new(n, &self.all)).clone() - } - }; - self.queued.fetch_add(n, Ordering::Relaxed); - let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block }; - self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e { - TrySendError::Full(_) => Push::Full, - TrySendError::Disconnected(_) => Push::Gone, - }) - } -} - -/// What one block of addresses (an IPv4 address, an IPv6 /48: [`wide_block`]) has queued and is -/// having read, shared by its connections. -#[derive(Clone, Default)] -struct BlockUse { - queued: Arc, - reading: Arc, -} - -/// The blocks with open connections; a block leaves when its last connection ends. -#[derive(Clone, Default)] -struct Blocks(Arc>>); - -impl Blocks { - /// Block `key`'s counters; blocks nobody uses any more (only the table holds their counters, - /// whose connections' queues and readers are all gone) are forgotten first, so the table - /// never holds more than the blocks of open connections. - fn get(&self, key: IpAddr) -> BlockUse { - let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); - t.retain(|_, u| Arc::strong_count(&u.queued) > 1 || Arc::strong_count(&u.reading) > 1); - t.entry(key).or_default().clone() - } - - /// Blocks remembered. - fn len(&self) -> usize { - self.0.lock().unwrap_or_else(PoisonError::into_inner).len() - } -} - -/// The widest block an address counts in for the server's shared budgets: an IPv4 address, or an -/// IPv6 address's /48. -pub fn wide_block(key: IpAddr) -> IpAddr { - ip_blocks(key).last().map_or(key, |(b, _)| b.0) -} - -/// Messages handed to the hub thread and not handled yet, in bytes: past the budget a reader -/// drops its connection instead of piling more on a busy hub. -#[derive(Clone)] -struct InFlight(Arc); - -impl InFlight { - fn take(&self, n: usize, max: usize) -> bool { - if self.0.fetch_add(n, Ordering::Relaxed).saturating_add(n) > max { - self.0.fetch_sub(n, Ordering::Relaxed); - return false; - } - true - } - - fn give(&self, n: usize) { - self.0.fetch_sub(n, Ordering::Relaxed); - } -} - -enum HubIn { - /// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins`, - /// `OpenHosting` or `Resolve`) and its client's address ([`ip_key`]). - Open { - conn: u64, - ip: IpAddr, - first: ClientMsg, - out: Line, - sock: TcpStream, - /// Its size (in flight until handled). - bytes: usize, - /// Told when the hub takes the connection in (dropped unsent when it doesn't). - admitted: SyncSender<()>, - }, - Msg { - conn: u64, - msg: ClientMsg, - bytes: usize, - }, - Gone { - conn: u64, - }, - Local(Presence), - LocalMsg(ClientMsg), - /// The person hosting removes client `id` from their file (app sessions). - Remove(u32), - /// Cut every connection without a word (tests: a network failure). - #[cfg(test)] - Cut, - Stop, -} - -/// The person hosting an app session: in the file's session without a socket. -pub(crate) struct Local { - pub file: String, - pub server: Server, - /// Their client id (they joined `server` already). - pub id: u32, - pub inbox: Inbox, -} - -fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result> { - std::thread::Builder::new().name(name.to_string()).spawn(f) -} - -/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters. -pub(crate) fn clean_name(s: &str) -> String { - s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() -} - -/// A running hub: its listener, connections and hub thread. Dropping it stops it. -pub struct Hosting { - addr: SocketAddr, - hub: Sender, - stop: Arc, - open: Arc, - /// Bytes queued for every connection together, and in flight to the hub. - queued: Arc, - in_flight: InFlight, - blocks: Blocks, - reading: Arc, - hub_thread: Option>, - accept_thread: Option>, -} - -impl Hosting { - /// Serve `backend`'s files on `listener`. - pub fn serve(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result { - Self::start(listener, backend, limits, None) - } - - pub(crate) fn start(listener: TcpListener, backend: B, limits: NetLimits, local: Option) -> io::Result { - let addr = listener.local_addr()?; - let in_flight = InFlight(Arc::default()); - let mut hub = Hub { - backend, - rooms: HashMap::new(), - conns: HashMap::new(), - local: None, - outbox: Vec::new(), - in_flight: in_flight.clone(), - barred: HashMap::new(), - }; - if let Some(l) = local { - let mut room = Room::new(l.server); - room.local = Some((l.id, l.inbox)); - hub.rooms.insert(l.file.clone(), room); - hub.local = Some(l.file); - } - let (hub_tx, hub_rx) = channel(); - let stop = Arc::new(AtomicBool::new(false)); - let open = Arc::new(AtomicUsize::new(0)); - let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?; - let shared = Shared { - per_address: PerAddress::default(), - queued: Arc::default(), - in_flight: in_flight.clone(), - blocks: Blocks::default(), - reading: Arc::default(), - readers: Readers::default(), - }; - let queued = shared.queued.clone(); - let blocks = shared.blocks.clone(); - let reading = shared.reading.clone(); - let accept_thread = { - let (hub_tx, stop, open) = (hub_tx.clone(), stop.clone(), open.clone()); - spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits, shared)) - }; - let accept_thread = match accept_thread { - Ok(t) => t, - Err(e) => { - let _ = hub_tx.send(HubIn::Stop); - let _ = hub_thread.join(); - return Err(e); - } - }; - log::info!("live session: listening on {addr}"); - Ok(Hosting { - addr, - hub: hub_tx, - stop, - open, - queued, - in_flight, - blocks, - reading, - hub_thread: Some(hub_thread), - accept_thread: Some(accept_thread), - }) - } - - /// Bytes waiting to be sent to clients, all connections together. - pub fn queued_bytes(&self) -> usize { - self.queued.load(Ordering::Relaxed) - } - - /// Blocks of addresses the server keeps counters for (those of open connections, and any - /// whose last connection just ended, until the next connection prunes them). - pub fn blocks_remembered(&self) -> usize { - self.blocks.len() - } - - /// Bytes being read into messages not whole yet, all connections together. - pub fn reading_bytes(&self) -> usize { - self.reading.load(Ordering::Relaxed) - } - - /// Bytes of messages read and not handled by the hub yet. - pub fn in_flight_bytes(&self) -> usize { - self.in_flight.0.load(Ordering::Relaxed) - } - - /// Where it listens. - pub fn addr(&self) -> SocketAddr { - self.addr - } - - /// Open connections (joined or not). - pub fn connections(&self) -> usize { - self.open.load(Ordering::Relaxed) - } - - /// The hosting person's presence (app sessions). - pub(crate) fn presence(&self, p: Presence) { - let _ = self.hub.send(HubIn::Local(p)); - } - - /// A message from the hosting person's sync client (their edits). - pub(crate) fn send(&self, msg: ClientMsg) { - let _ = self.hub.send(HubIn::LocalMsg(msg)); - } - - /// The hosting person removes client `id` from their file (app sessions; see - /// [`HubIn::Remove`]). - pub(crate) fn remove(&self, id: u32) { - let _ = self.hub.send(HubIn::Remove(id)); - } - - /// Cut every connection as a network failure would (no close frame). - #[cfg(test)] - pub(crate) fn cut_connections(&self) { - let _ = self.hub.send(HubIn::Cut); - } - - /// Stop: the backend saves what's open, everyone is told and disconnected, the port is - /// released. - pub fn stop(&mut self) { - if self.stop.swap(true, Ordering::SeqCst) { - return; - } - let _ = self.hub.send(HubIn::Stop); - if let Some(t) = self.hub_thread.take() { - let _ = t.join(); - } - // The accept loop checks the flag after each connection: wake it with one. - let wake = if self.addr.ip().is_unspecified() { SocketAddr::new(loopback(self.addr), self.addr.port()) } else { self.addr }; - let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok(); - if let Some(t) = self.accept_thread.take() { - if woke { - let _ = t.join(); - } else { - log::warn!("live session: couldn't wake the accept loop; it ends with the next connection"); - } - } - // Readers end once their writers have closed the sockets. - let deadline = Instant::now() + Duration::from_secs(2); - while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(5)); - } - log::info!("live session: stopped listening on {}", self.addr); - } -} - -impl Drop for Hosting { - fn drop(&mut self) { - self.stop(); - } -} - -/// The loopback address of `addr`'s family. -fn loopback(addr: SocketAddr) -> std::net::IpAddr { - if addr.is_ipv6() { std::net::Ipv6Addr::LOCALHOST.into() } else { std::net::Ipv4Addr::LOCALHOST.into() } -} - -/// Counts an open connection until dropped. -struct Open(Arc); - -impl Drop for Open { - fn drop(&mut self) { - self.0.fetch_sub(1, Ordering::Relaxed); - } -} - -/// A block of addresses: its first address and its prefix length (2001:db8:: as a /64 and as a -/// /48 are different blocks). -pub type Block = (IpAddr, u8); - -/// The blocks a client's address ([`ip_key`]) counts in, with how many times its own limits each -/// gets: the address itself (an IPv4 /32 or an IPv6 /64, ×1) and, for IPv6, its /56 (×2) and /48 -/// (×4), so a tunnel's free /48 doesn't multiply the limits 65,536 times. -pub fn ip_blocks(key: IpAddr) -> Vec<(Block, u32)> { - let IpAddr::V6(v6) = key else { return vec![((key, 32), 1)] }; - let [a, b, c, d, ..] = v6.segments(); - vec![ - ((IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)), 64), 1), - ((IpAddr::V6(Ipv6Addr::new(a, b, c, d & 0xff00, 0, 0, 0, 0)), 56), 2), - ((IpAddr::V6(Ipv6Addr::new(a, b, c, 0, 0, 0, 0, 0)), 48), 4), - ] -} - -/// Open connections per client address ([`ip_key`], and its wider IPv6 blocks); an address -/// leaves when its last one closes, so the table never holds more than the open connections. -#[derive(Clone, Default)] -struct PerAddress(Arc>>); - -/// One connection counted for a block until dropped. -struct Counted { - table: PerAddress, - ip: Block, -} - -impl PerAddress { - /// Count a connection from block `ip`, unless it has `cap` already (0: no cap). - fn enter(&self, ip: Block, cap: usize) -> Option { - let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); - let n = t.entry(ip).or_insert(0); - if cap > 0 && *n >= cap { - if *n == 0 { - t.remove(&ip); - } - return None; - } - *n += 1; - Some(Counted { table: self.clone(), ip }) - } - - /// Count a connection from `ip` in each of its blocks, unless one of them is full. A wider - /// block's cap is its multiple of `cap`, but at most a sixteenth (/56) or an eighth (/48) of - /// `all` connections, so no block takes every connection. - fn enter_all(&self, ip: IpAddr, cap: usize, all: usize) -> Option> { - let mut held = Vec::new(); - for ((addr, prefix), times) in ip_blocks(ip) { - let block_cap = match prefix { - 56 => cap.saturating_mul(times as usize).min(all / 16).max(1), - 48 => cap.saturating_mul(times as usize).min(all / 8).max(1), - _ => cap, - }; - held.push(self.enter((addr, prefix), if cap == 0 { 0 } else { block_cap })?); - } - Some(held) - } -} - -impl Drop for Counted { - fn drop(&mut self) { - let mut t = self.table.0.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(n) = t.get_mut(&self.ip) { - *n = n.saturating_sub(1); - if *n == 0 { - t.remove(&self.ip); - } - } - } -} - -/// Longest request line an uptime check may have. -const MAX_CHECK_LINE: usize = 2048; - -/// An uptime check: a plain `GET` of a path ending in `/healthz` is answered `200 ok` and closed. -/// True when it was one (the connection is done). Anything else is only peeked at, so a WebSocket -/// handshake goes on untouched. -fn health_check(stream: &mut TcpStream, until: Instant) -> bool { - let mut buf = [0u8; MAX_CHECK_LINE]; - let line = loop { - let n = match stream.peek(&mut buf) { - Ok(0) | Err(_) => return false, - Ok(n) => n, - }; - let seen = buf.get(..n).unwrap_or_default(); - if !seen.starts_with(b"GET ") && !b"GET ".starts_with(seen) { - return false; - } - if let Some(end) = seen.windows(2).position(|w| w == b"\r\n") { - break seen.get(..end).unwrap_or_default().to_vec(); - } - if n == buf.len() || Instant::now() >= until { - return false; - } - // The rest of the line is on its way (it nearly always arrives in one piece). - std::thread::sleep(Duration::from_millis(10)); - }; - let path = line.split(|b| *b == b' ').nth(1).unwrap_or_default(); - let path = path.split(|b| *b == b'?').next().unwrap_or_default(); - if !path.ends_with(b"/healthz") { - return false; - } - // Read the request (its headers end with a blank line), so closing doesn't reset it. - let mut req = Vec::new(); - let mut chunk = [0u8; 1024]; - while !req.windows(4).any(|w| w == b"\r\n\r\n") && req.len() < 16 << 10 { - match stream.read(&mut chunk) { - Ok(0) | Err(_) => break, - Ok(n) => req.extend_from_slice(chunk.get(..n).unwrap_or_default()), - } - } - let _ = stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 3\r\nCache-Control: no-store\r\nConnection: close\r\n\r\nok\n"); - let _ = stream.shutdown(std::net::Shutdown::Write); - true -} - -/// The client a trusted proxy names: the last address of its last `X-Forwarded-For` (the one the -/// proxy added; any before it are whatever the client sent). -fn forwarded_for(req: &Request) -> Option { - let last = req.headers().get_all("x-forwarded-for").iter().next_back()?.to_str().ok()?; - let entry = last.rsplit(',').next()?.trim(); - entry.parse::().ok().or_else(|| entry.parse::().ok().map(|a| a.ip())) -} - -/// What every connection shares: the count per address, the bytes queued for all of them and the -/// bytes in flight to the hub. -#[derive(Clone)] -struct Shared { - per_address: PerAddress, - queued: Arc, - in_flight: InFlight, - blocks: Blocks, - /// Bytes being read into messages, all connections together, and who is reading them. - reading: Arc, - readers: Readers, -} - -fn accept(listener: &TcpListener, hub: &Sender, stop: &AtomicBool, open: &Arc, limits: NetLimits, shared: Shared) { - let mut next = 0u64; - for stream in listener.incoming() { - if stop.load(Ordering::SeqCst) { - break; - } - let stream = match stream { - Ok(s) => s, - Err(e) => { - // Out of sockets or similar: don't spin. - log::warn!("live session: accept failed: {e}"); - std::thread::sleep(Duration::from_millis(50)); - continue; - } - }; - if open.load(Ordering::Relaxed) >= limits.max_connections { - log::info!("live session: too many connections; refusing one"); - continue; - } - open.fetch_add(1, Ordering::Relaxed); - let guard = Open(open.clone()); - next += 1; - let (conn, hub, shared) = (next, hub.clone(), shared.clone()); - if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard, &shared)) { - log::warn!("live session: can't start a connection thread: {e}"); - } - } -} - -/// A close reason that fits a WebSocket close frame (123 bytes). -fn reason(s: &str) -> String { - let mut end = s.len().min(123); - while !s.is_char_boundary(end) { - end -= 1; - } - s.get(..end).unwrap_or_default().to_string() -} - -fn encode(msg: &ServerMsg) -> Option { - match message::encode(msg) { - Ok(b) => Some(Bytes::from(b)), - Err(e) => { - log::warn!("live session: can't encode a message: {e}"); - None - } - } -} - -/// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps). -fn refuse(out: &Line, why: &str) { - refused(out, why); -} - -/// [`refuse`], and whether the close was queued (it isn't when the queue is full). -fn refused(out: &Line, why: &str) -> bool { - if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) { - let _ = out.tx.try_send(Out::Bytes(b, None)); - } - out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok() -} - -/// What the server tells a client when it is too busy to take more from it. -const BUSY: &str = "the server is busy: try again in a moment"; - -/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end. -fn next_message(ws: &mut WebSocket, max: usize, deadline: Option) -> Result, String> { - loop { - return match ws.read() { - Ok(Message::Binary(b)) => { - // Whole now: it counts as in flight from here, no longer as being read. - ws.get_mut().read_done(); - message::decode::(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string()) - } - Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()), - // Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and - // a message trickled in is cut off by it (`Duplex::read_until`). - Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_)) - if deadline.is_some_and(|d| Instant::now() >= d) => - { - Err("no hello in time".into()) - } - Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, - Ok(Message::Close(_)) => Ok(None), - Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)), - // The reading budget's refusal (see `Meter`). - Err(tungstenite::Error::Io(e)) if e.kind() == io::ErrorKind::Other => Err(e.to_string()), - Err(_) => Ok(None), - }; - } -} - -/// Why a message over `max` bytes is refused. -fn too_large(max: usize) -> String { - match max >> 20 { - 0 => format!("message too large: this server takes at most {} KiB at once here", max >> 10), - mib => format!("message too large: this server takes at most {mib} MiB at once (a change that big can't be kept here)"), - } -} - -/// One connection's reader (the writer runs on its own thread). -fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, open: Open, shared: &Shared) { - let _ = stream.set_nodelay(true); - let hello_deadline = Instant::now() + limits.hello_timeout; - if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { - return; - } - if health_check(&mut stream, Instant::now() + limits.hello_timeout) { - return; - } - let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; - let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); - let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return }; - // The handshake and the hello must be in by the deadline, however slowly they trickle in. - rd.read_until(Some(hello_deadline)); - let cfg = config(limits.max_client_message); - let mut forwarded = None; - // The handshake callback's type is tungstenite's (its error is an HTTP response). - #[allow(clippy::result_large_err)] - let read_client = |req: &Request, resp: Response| -> Result { - if proxied { - forwarded = forwarded_for(req); - } - Ok(resp) - }; - let mut ws = match tungstenite::accept_hdr_with_config(rd, read_client, Some(config(limits.max_hello))) { - Ok(ws) => ws, - Err(e) => { - log::debug!("live session: handshake failed: {e}"); - shutdown(&sock); - return; - } - }; - if proxied && forwarded.is_none() { - log::debug!("live session: connection {conn} came through the proxy without X-Forwarded-For"); - } - let ip = ip_key(forwarded.unwrap_or(peer)); - let counted = shared.per_address.enter_all(ip, limits.max_per_address, limits.max_connections); - let wide = wide_block(ip); - let block = shared.blocks.get(wide); - ws.get_mut().meter(Meter::new( - conn, - &sock, - &shared.readers, - block.reading.clone(), - shared.reading.clone(), - limits.max_reading_block, - limits.max_reading_total, - )); - let (tx, rx) = sync_channel::(limits.queue.max(4)); - let out = Line { - tx, - queued: Arc::default(), - block: block.queued.clone(), - all: shared.queued.clone(), - max: limits.max_queued_bytes, - max_block: limits.max_queued_block, - max_all: limits.max_queued_total, - }; - let writer = - match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) { - Ok(Ok(t)) => t, - _ => { - shutdown(&sock); - return; - } - }; - let end = |why: Option<&str>| { - if let Some(why) = why { - refuse(&out, why); - } else { - let _ = out.tx.try_send(Out::Close(CloseCode::Normal, String::new())); - } - }; - if counted.is_none() { - log::info!("event=refused ip={ip} reason=connections_per_address"); - end(Some("too many connections from your address: close some and try again")); - } else { - let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline)); - // In time or not, the hello's deadline is over: what's read next has timeouts of its own. - ws.get_mut().read_until(None); - match hello { - Ok(Some(( - first @ (ClientMsg::Hello(_) - | ClientMsg::Manage(_) - | ClientMsg::OpenLibrary(_) - | ClientMsg::OpenPlugins(_) - | ClientMsg::OpenHosting(_) - | ClientMsg::Resolve(_)), - bytes, - ))) => { - // Sessions and the operator may use the last connections; one-shot requests - // (hosting, libraries, browsing plugins) leave them free. - let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_)); - // A publish uploads a whole file; a large one on a slow link takes the server a - // while to receive and answer. The manage/hosting connections that carry uploads - // wait the longer request-reply timeout, not the short request idle. - let idle = match first { - ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout, - ClientMsg::Hello(_) => limits.idle_timeout, - _ => limits.request_idle, - }; - let max = match &first { - ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request, - _ => limits.max_client_message, - }; - let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions); - // (In flight is only taken when the connection goes on: the hub gives it back.) - if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { - end(Some(BUSY)); - } else { - let (admitted, verdict) = sync_channel(1); - let opened = - sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok()); - // Nothing more is read until the hub took the connection in (its token checked): - // only then may it send messages of the full size. - if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { - ws.set_config(|c| *c = config(max)); - read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end); - let _ = hub.send(HubIn::Gone { conn }); - } - } - } - Ok(Some(_)) => end(Some("say hello first")), - Ok(None) => {} - Err(why) => end(Some(&why)), - } - } - drop(counted); - // The block's counters go with the last handle on them (the table forgets it then). - drop(block); - ws.get_mut().read_until(None); - linger(&mut ws); - drop(out); - // The writer closes the socket once it has sent what's queued (a refusal, a close). - let _ = writer.join(); - shutdown(&sock); -} - -fn read_loop( - ws: &mut WebSocket, - conn: u64, - hub: &Sender, - limits: NetLimits, - max: usize, - in_flight: &InFlight, - end: &dyn Fn(Option<&str>), -) { - let mut bucket = Bucket::new(limits.rate, limits.burst); - let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST); - loop { - match next_message(ws, max, None) { - Ok(Some((msg, bytes))) => { - let now = Instant::now(); - if !bucket.take(now) { - log::info!("live session: connection {conn} sends too much; disconnecting it"); - return end(Some("too many messages")); - } - // Typing sends a chat signal per keystroke; more than this is dropped. - if matches!(msg, ClientMsg::Signal(_)) && !signals.take(now) { - log::debug!("live session: connection {conn} sends signals too fast; dropping one"); - continue; - } - if !in_flight.take(bytes, limits.max_in_flight) { - log::info!("live session: the hub is behind; dropping connection {conn}"); - return end(Some(BUSY)); - } - if hub.send(HubIn::Msg { conn, msg, bytes }).is_err() { - return; - } - } - Ok(None) => return end(None), - Err(why) => return end(Some(&why)), - } - } -} - -fn write_loop(mut ws: WebSocket, rx: &Receiver, sock: &TcpStream) { - let mut close = Some((CloseCode::Normal, String::new())); - while let Ok(o) = rx.recv() { - match o { - Out::Bytes(b, _charge) => { - if ws.send(Message::Binary(b)).is_err() { - close = None; - break; - } - } - Out::Close(code, why) => { - close = Some((code, why)); - break; - } - } - } - if let Some((code, why)) = close { - // Close politely: the close frame, then no more writing; the reader drains what the peer - // still sends until it hangs up (closing on unread data would reset the connection and - // could lose the close frame), or until the linger runs out. - let _ = ws.close(Some(CloseFrame { code, reason: why.into() })); - let _ = sock.shutdown(std::net::Shutdown::Write); - let until = Instant::now() + LINGER; - while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) { - if Instant::now() >= until { - break; - } - } - } - shutdown(sock); -} - -/// Read and drop what the peer still sends after the close, until it hangs up (at most -/// [`LINGER`]). Raw bytes, not frames: after a frame over the size limit the WebSocket's reader -/// is mid-frame and mustn't be asked for more. -fn linger(ws: &mut WebSocket) { - let until = Instant::now() + LINGER; - let mut buf = [0u8; 4096]; - while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() { - match ws.get_mut().read(&mut buf) { - Ok(0) => break, - Ok(_) => {} - Err(e) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted) => {} - Err(_) => break, - } - } -} - /// What a connection is for. enum Kind { /// In `file`'s session as client `id`, through link `link`. @@ -1783,13 +763,10 @@ pub(crate) fn to_local(inbox: &Inbox, local: u32, msg: &ServerMsg) { } #[cfg(test)] -#[path = "queue_tests.rs"] mod queue_tests; #[cfg(test)] -#[path = "barred_tests.rs"] mod barred_tests; #[cfg(test)] -#[path = "preauth_tests.rs"] mod preauth_tests; diff --git a/crates/collab-net/src/hub/backend.rs b/crates/collab-net/src/hub/backend.rs new file mode 100644 index 00000000..04c5363d --- /dev/null +++ b/crates/collab-net/src/hub/backend.rs @@ -0,0 +1,138 @@ +use super::limits::Admission; +use super::*; + +/// An admin request's reply, and what the hub must do about connections it affects. +#[derive(Clone, Debug, PartialEq)] +pub struct Handled { + pub reply: AdminReply, + /// A link was revoked: `(file, link)`; whoever joined with it is disconnected. + pub revoked: Option<(String, String)>, + /// A file was removed: whoever is in it is disconnected and it is closed without saving. + pub removed: Option, + /// An address was blocked ([`ip_key`]): its connections are dropped. + pub blocked: Option, +} + +impl From for Handled { + fn from(reply: AdminReply) -> Self { + Self { reply, revoked: None, removed: None, blocked: None } + } +} + +/// What everyone is told when a hub stops. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Closing { + /// The session is over (the app stopped hosting): final. + Ended, + /// The server is restarting: clients reconnect until it is back. + Restarting, +} + +/// What the hub knows of an open file (for listings). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct OpenFile { + /// People connected to it. + pub people: u32, + /// Its latest sequence number. + pub seq: u64, +} + +/// Where a hub's files come from and what it keeps of them (see the module docs). The hub calls it +/// from its own thread only. +pub trait Backend: Send + 'static { + /// A hello for `file` with `token`: what it may do, or why it may not join (the reason is + /// shown to the person joining; never echo the token). + fn admit(&mut self, file: &str, token: &str) -> Result; + + /// The session for a file that isn't open (after [`admit`](Self::admit) let someone in). + fn open(&mut self, file: &str) -> Result; + + /// The hub handled a message for an open file: keep what changed (its server's journal). + fn changed(&mut self, _file: &str, _server: &mut Server) {} + + /// Make what [`changed`](Self::changed) kept durable; the hub sends the servers' answers + /// after this. The files whose changes couldn't be made durable: nobody hears of them, the + /// files are closed (the backend forgets their open state) and everyone in them reconnects. + fn flush(&mut self) -> Vec { + Vec::new() + } + + /// Whether `file` has room for a batch of about `bytes` (its size on the wire): why not, and + /// the batch is refused. + fn room(&mut self, _file: &str, _server: &Server, _bytes: usize) -> Result<(), String> { + Ok(()) + } + + /// Everyone left `file`: save it. True closes it (the next hello opens it again). + fn idle(&mut self, _file: &str, _server: &mut Server) -> bool { + false + } + + /// The hub is stopping: save the open files. + fn stop(&mut self, _open: Vec<(&str, &mut Server)>) {} + + /// A managing connection's key: whether it may manage files. + fn manage(&mut self, _key: &str) -> Result<(), String> { + Err("this session is hosted by an app: it has no files to manage".into()) + } + + /// A request on a managing connection; `open` tells about a file that is open. + fn admin(&mut self, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { + AdminReply::Failed { reason: "nothing to manage here".into() }.into() + } + + /// A request on a library connection to `file`, opened with a link of `access` (checked by + /// [`admit`](Self::admit)). + fn library(&mut self, _ip: IpAddr, _file: &str, _access: Access, _req: LibraryReq) -> LibraryReply { + LibraryReply::Failed { reason: "this session is hosted by an app: team libraries live on a figcraft-server".into() } + } + + /// A request on a plugin directory connection; `publisher`: it was opened with the right key + /// (checked by [`manage`](Self::manage)). + fn plugins(&mut self, _ip: IpAddr, _publisher: bool, _req: PluginReq) -> PluginReply { + PluginReply::Failed { reason: "this session is hosted by an app: plugin directories live on a figcraft-server".into() } + } + + /// A new connection from `ip` ([`ip_key`]): whether it may go on (why not is shown to the + /// client). + fn connect(&mut self, _ip: IpAddr) -> Result<(), String> { + Ok(()) + } + + /// A message from a connection from `ip`: false disconnects it (that address sends too much). + fn message(&mut self, _ip: IpAddr, _msg: &ClientMsg) -> bool { + true + } + + /// What to tell a client that reads it about the server's hosting, after its welcome to + /// `file` (`None`: nothing). + fn hosting(&mut self, _file: &str) -> Option { + None + } + + /// An open hosting connection from `ip`, with a file's owner token when `file` isn't empty: + /// what the server says about its hosting, or why the connection is refused. + fn open_hosting(&mut self, _ip: IpAddr, _file: &str, _owner: &str) -> Result { + Err("this session is hosted by an app: it doesn't keep files for anyone".into()) + } + + /// A request on an open hosting connection from `ip`; `owns`: the file whose owner token it + /// was opened with. + fn hosted(&mut self, _ip: IpAddr, _owns: Option<&str>, _req: Admin, _open: &dyn Fn(&str) -> Option) -> Handled { + AdminReply::Failed { reason: "nothing to manage here".into() }.into() + } + + /// Someone in `file` (joined with `link`, from `ip`) reports it to the operator. + fn report(&mut self, _file: &str, _link: Option<&str>, _ip: IpAddr, _reason: &str) -> Result<(), String> { + Err("this session is hosted by an app: tell the person hosting it".into()) + } + + /// A join code typed by someone at `ip`: the file it opens and that link's token, or why not + /// (shown to them; wrong codes are counted against the address). + fn resolve(&mut self, _ip: IpAddr, _code: &str) -> Result<(String, String), String> { + Err("this session is hosted by an app: join it with its own code or invite link".into()) + } + + /// What everyone is told when the hub stops. + fn closing(&self) -> Closing; +} diff --git a/crates/collab-net/src/barred_tests.rs b/crates/collab-net/src/hub/barred_tests.rs similarity index 100% rename from crates/collab-net/src/barred_tests.rs rename to crates/collab-net/src/hub/barred_tests.rs diff --git a/crates/collab-net/src/hub/conn.rs b/crates/collab-net/src/hub/conn.rs new file mode 100644 index 00000000..109dfed9 --- /dev/null +++ b/crates/collab-net/src/hub/conn.rs @@ -0,0 +1,404 @@ +use super::hosting::{Open, spawn}; +use super::limits::{PerAddress, ip_key}; +use super::queue::{Blocks, HubIn, InFlight, Line, Out, wide_block}; +use super::*; + +/// How long a closing connection waits for the peer to hang up. +const LINGER: Duration = Duration::from_secs(2); + +/// Signals (cursor chat, spotlight) a client may send per second on average, and in a burst; +/// beyond that they are dropped. +pub const SIGNAL_RATE: f64 = 20.0; + +pub const SIGNAL_BURST: f64 = 40.0; + +/// Longest request line an uptime check may have. +const MAX_CHECK_LINE: usize = 2048; + +/// An uptime check: a plain `GET` of a path ending in `/healthz` is answered `200 ok` and closed. +/// True when it was one (the connection is done). Anything else is only peeked at, so a WebSocket +/// handshake goes on untouched. +fn health_check(stream: &mut TcpStream, until: Instant) -> bool { + let mut buf = [0u8; MAX_CHECK_LINE]; + let line = loop { + let n = match stream.peek(&mut buf) { + Ok(0) | Err(_) => return false, + Ok(n) => n, + }; + let seen = buf.get(..n).unwrap_or_default(); + if !seen.starts_with(b"GET ") && !b"GET ".starts_with(seen) { + return false; + } + if let Some(end) = seen.windows(2).position(|w| w == b"\r\n") { + break seen.get(..end).unwrap_or_default().to_vec(); + } + if n == buf.len() || Instant::now() >= until { + return false; + } + // The rest of the line is on its way (it nearly always arrives in one piece). + std::thread::sleep(Duration::from_millis(10)); + }; + let path = line.split(|b| *b == b' ').nth(1).unwrap_or_default(); + let path = path.split(|b| *b == b'?').next().unwrap_or_default(); + if !path.ends_with(b"/healthz") { + return false; + } + // Read the request (its headers end with a blank line), so closing doesn't reset it. + let mut req = Vec::new(); + let mut chunk = [0u8; 1024]; + while !req.windows(4).any(|w| w == b"\r\n\r\n") && req.len() < 16 << 10 { + match stream.read(&mut chunk) { + Ok(0) | Err(_) => break, + Ok(n) => req.extend_from_slice(chunk.get(..n).unwrap_or_default()), + } + } + let _ = stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 3\r\nCache-Control: no-store\r\nConnection: close\r\n\r\nok\n"); + let _ = stream.shutdown(std::net::Shutdown::Write); + true +} + +/// The client a trusted proxy names: the last address of its last `X-Forwarded-For` (the one the +/// proxy added; any before it are whatever the client sent). +fn forwarded_for(req: &Request) -> Option { + let last = req.headers().get_all("x-forwarded-for").iter().next_back()?.to_str().ok()?; + let entry = last.rsplit(',').next()?.trim(); + entry.parse::().ok().or_else(|| entry.parse::().ok().map(|a| a.ip())) +} + +/// What every connection shares: the count per address, the bytes queued for all of them and the +/// bytes in flight to the hub. +#[derive(Clone)] +pub(super) struct Shared { + pub(super) per_address: PerAddress, + pub(super) queued: Arc, + pub(super) in_flight: InFlight, + pub(super) blocks: Blocks, + /// Bytes being read into messages, all connections together, and who is reading them. + pub(super) reading: Arc, + pub(super) readers: Readers, +} + +pub(super) fn accept(listener: &TcpListener, hub: &Sender, stop: &AtomicBool, open: &Arc, limits: NetLimits, shared: Shared) { + let mut next = 0u64; + for stream in listener.incoming() { + if stop.load(Ordering::SeqCst) { + break; + } + let stream = match stream { + Ok(s) => s, + Err(e) => { + // Out of sockets or similar: don't spin. + log::warn!("live session: accept failed: {e}"); + std::thread::sleep(Duration::from_millis(50)); + continue; + } + }; + if open.load(Ordering::Relaxed) >= limits.max_connections { + log::info!("live session: too many connections; refusing one"); + continue; + } + open.fetch_add(1, Ordering::Relaxed); + let guard = Open(open.clone()); + next += 1; + let (conn, hub, shared) = (next, hub.clone(), shared.clone()); + if let Err(e) = spawn("live-conn", move || serve(stream, conn, &hub, limits, guard, &shared)) { + log::warn!("live session: can't start a connection thread: {e}"); + } + } +} + +/// A close reason that fits a WebSocket close frame (123 bytes). +pub(super) fn reason(s: &str) -> String { + let mut end = s.len().min(123); + while !s.is_char_boundary(end) { + end -= 1; + } + s.get(..end).unwrap_or_default().to_string() +} + +pub(super) fn encode(msg: &ServerMsg) -> Option { + match message::encode(msg) { + Ok(b) => Some(Bytes::from(b)), + Err(e) => { + log::warn!("live session: can't encode a message: {e}"); + None + } + } +} + +/// Queue `Refused {reason}` and a close for one connection (small: not counted against its caps). +pub(super) fn refuse(out: &Line, why: &str) { + refused(out, why); +} + +/// [`refuse`], and whether the close was queued (it isn't when the queue is full). +pub(super) fn refused(out: &Line, why: &str) -> bool { + if let Some(b) = encode(&ServerMsg::Refused { reason: why.to_string() }) { + let _ = out.tx.try_send(Out::Bytes(b, None)); + } + out.tx.try_send(Out::Close(CloseCode::Policy, reason(why))).is_ok() +} + +/// What the server tells a client when it is too busy to take more from it. +const BUSY: &str = "the server is busy: try again in a moment"; + +/// Read the next binary message (skipping pings, until `deadline`) and its size, or why the connection should end. +fn next_message(ws: &mut WebSocket, max: usize, deadline: Option) -> Result, String> { + loop { + return match ws.read() { + Ok(Message::Binary(b)) => { + // Whole now: it counts as in flight from here, no longer as being read. + ws.get_mut().read_done(); + message::decode::(&b, max).map(|m| Some((m, b.len()))).map_err(|_| "that message couldn't be read".to_string()) + } + Ok(Message::Text(_)) => Err("only binary messages are spoken here".into()), + // Pings keep the per-read timeout alive, but not the overall deadline (slowloris), and + // a message trickled in is cut off by it (`Duplex::read_until`). + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) | Err(tungstenite::Error::Io(_)) + if deadline.is_some_and(|d| Instant::now() >= d) => + { + Err("no hello in time".into()) + } + Ok(Message::Ping(_) | Message::Pong(_) | Message::Frame(_)) => continue, + Ok(Message::Close(_)) => Ok(None), + Err(tungstenite::Error::Capacity(_)) => Err(too_large(max)), + // The reading budget's refusal (see `Meter`). + Err(tungstenite::Error::Io(e)) if e.kind() == io::ErrorKind::Other => Err(e.to_string()), + Err(_) => Ok(None), + }; + } +} + +/// Why a message over `max` bytes is refused. +fn too_large(max: usize) -> String { + match max >> 20 { + 0 => format!("message too large: this server takes at most {} KiB at once here", max >> 10), + mib => format!("message too large: this server takes at most {mib} MiB at once (a change that big can't be kept here)"), + } +} + +/// One connection's reader (the writer runs on its own thread). +fn serve(mut stream: TcpStream, conn: u64, hub: &Sender, limits: NetLimits, open: Open, shared: &Shared) { + let _ = stream.set_nodelay(true); + let hello_deadline = Instant::now() + limits.hello_timeout; + if stream.set_read_timeout(Some(limits.hello_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() { + return; + } + if health_check(&mut stream, Instant::now() + limits.hello_timeout) { + return; + } + let Ok(peer) = stream.peer_addr().map(|a| a.ip().to_canonical()) else { return }; + let proxied = limits.trusted_proxy.is_some_and(|p| p.to_canonical() == peer); + let Ok((mut rd, wr, sock)) = Duplex::split(stream, None) else { return }; + // The handshake and the hello must be in by the deadline, however slowly they trickle in. + rd.read_until(Some(hello_deadline)); + let cfg = config(limits.max_client_message); + let mut forwarded = None; + // The handshake callback's type is tungstenite's (its error is an HTTP response). + #[allow(clippy::result_large_err)] + let read_client = |req: &Request, resp: Response| -> Result { + if proxied { + forwarded = forwarded_for(req); + } + Ok(resp) + }; + let mut ws = match tungstenite::accept_hdr_with_config(rd, read_client, Some(config(limits.max_hello))) { + Ok(ws) => ws, + Err(e) => { + log::debug!("live session: handshake failed: {e}"); + shutdown(&sock); + return; + } + }; + if proxied && forwarded.is_none() { + log::debug!("live session: connection {conn} came through the proxy without X-Forwarded-For"); + } + let ip = ip_key(forwarded.unwrap_or(peer)); + let counted = shared.per_address.enter_all(ip, limits.max_per_address, limits.max_connections); + let wide = wide_block(ip); + let block = shared.blocks.get(wide); + ws.get_mut().meter(Meter::new( + conn, + &sock, + &shared.readers, + block.reading.clone(), + shared.reading.clone(), + limits.max_reading_block, + limits.max_reading_total, + )); + let (tx, rx) = sync_channel::(limits.queue.max(4)); + let out = Line { + tx, + queued: Arc::default(), + block: block.queued.clone(), + all: shared.queued.clone(), + max: limits.max_queued_bytes, + max_block: limits.max_queued_block, + max_all: limits.max_queued_total, + }; + let writer = + match sock.try_clone().map(|s| spawn("live-write", move || write_loop(WebSocket::from_raw_socket(wr, Role::Server, Some(cfg)), &rx, &s))) { + Ok(Ok(t)) => t, + _ => { + shutdown(&sock); + return; + } + }; + let end = |why: Option<&str>| { + if let Some(why) = why { + refuse(&out, why); + } else { + let _ = out.tx.try_send(Out::Close(CloseCode::Normal, String::new())); + } + }; + if counted.is_none() { + log::info!("event=refused ip={ip} reason=connections_per_address"); + end(Some("too many connections from your address: close some and try again")); + } else { + let hello = next_message(&mut ws, limits.max_hello, Some(hello_deadline)); + // In time or not, the hello's deadline is over: what's read next has timeouts of its own. + ws.get_mut().read_until(None); + match hello { + Ok(Some(( + first @ (ClientMsg::Hello(_) + | ClientMsg::Manage(_) + | ClientMsg::OpenLibrary(_) + | ClientMsg::OpenPlugins(_) + | ClientMsg::OpenHosting(_) + | ClientMsg::Resolve(_)), + bytes, + ))) => { + // Sessions and the operator may use the last connections; one-shot requests + // (hosting, libraries, browsing plugins) leave them free. + let session = matches!(first, ClientMsg::Hello(_) | ClientMsg::Manage(_)); + // A publish uploads a whole file; a large one on a slow link takes the server a + // while to receive and answer. The manage/hosting connections that carry uploads + // wait the longer request-reply timeout, not the short request idle. + let idle = match first { + ClientMsg::Manage(_) | ClientMsg::OpenHosting(_) => limits.request_reply_timeout, + ClientMsg::Hello(_) => limits.idle_timeout, + _ => limits.request_idle, + }; + let max = match &first { + ClientMsg::OpenPlugins(o) if o.key.is_empty() => limits.max_request, + _ => limits.max_client_message, + }; + let reserved = !session && open.0.load(Ordering::Relaxed) > limits.max_connections.saturating_sub(limits.reserve_for_sessions); + // (In flight is only taken when the connection goes on: the hub gives it back.) + if reserved || !shared.in_flight.take(bytes, limits.max_in_flight) { + end(Some(BUSY)); + } else { + let (admitted, verdict) = sync_channel(1); + let opened = + sock.try_clone().is_ok_and(|s| hub.send(HubIn::Open { conn, ip, first, out: out.clone(), sock: s, bytes, admitted }).is_ok()); + // Nothing more is read until the hub took the connection in (its token checked): + // only then may it send messages of the full size. + if opened && verdict.recv().is_ok() && ws.get_ref().set_read_timeout(Some(idle)).is_ok() { + ws.set_config(|c| *c = config(max)); + read_loop(&mut ws, conn, hub, limits, max, &shared.in_flight, &end); + let _ = hub.send(HubIn::Gone { conn }); + } + } + } + Ok(Some(_)) => end(Some("say hello first")), + Ok(None) => {} + Err(why) => end(Some(&why)), + } + } + drop(counted); + // The block's counters go with the last handle on them (the table forgets it then). + drop(block); + ws.get_mut().read_until(None); + linger(&mut ws); + drop(out); + // The writer closes the socket once it has sent what's queued (a refusal, a close). + let _ = writer.join(); + shutdown(&sock); +} + +fn read_loop( + ws: &mut WebSocket, + conn: u64, + hub: &Sender, + limits: NetLimits, + max: usize, + in_flight: &InFlight, + end: &dyn Fn(Option<&str>), +) { + let mut bucket = Bucket::new(limits.rate, limits.burst); + let mut signals = Bucket::new(SIGNAL_RATE, SIGNAL_BURST); + loop { + match next_message(ws, max, None) { + Ok(Some((msg, bytes))) => { + let now = Instant::now(); + if !bucket.take(now) { + log::info!("live session: connection {conn} sends too much; disconnecting it"); + return end(Some("too many messages")); + } + // Typing sends a chat signal per keystroke; more than this is dropped. + if matches!(msg, ClientMsg::Signal(_)) && !signals.take(now) { + log::debug!("live session: connection {conn} sends signals too fast; dropping one"); + continue; + } + if !in_flight.take(bytes, limits.max_in_flight) { + log::info!("live session: the hub is behind; dropping connection {conn}"); + return end(Some(BUSY)); + } + if hub.send(HubIn::Msg { conn, msg, bytes }).is_err() { + return; + } + } + Ok(None) => return end(None), + Err(why) => return end(Some(&why)), + } + } +} + +fn write_loop(mut ws: WebSocket, rx: &Receiver, sock: &TcpStream) { + let mut close = Some((CloseCode::Normal, String::new())); + while let Ok(o) = rx.recv() { + match o { + Out::Bytes(b, _charge) => { + if ws.send(Message::Binary(b)).is_err() { + close = None; + break; + } + } + Out::Close(code, why) => { + close = Some((code, why)); + break; + } + } + } + if let Some((code, why)) = close { + // Close politely: the close frame, then no more writing; the reader drains what the peer + // still sends until it hangs up (closing on unread data would reset the connection and + // could lose the close frame), or until the linger runs out. + let _ = ws.close(Some(CloseFrame { code, reason: why.into() })); + let _ = sock.shutdown(std::net::Shutdown::Write); + let until = Instant::now() + LINGER; + while let Ok(_) | Err(RecvTimeoutError::Timeout) = rx.recv_timeout(until.saturating_duration_since(Instant::now())) { + if Instant::now() >= until { + break; + } + } + } + shutdown(sock); +} + +/// Read and drop what the peer still sends after the close, until it hangs up (at most +/// [`LINGER`]). Raw bytes, not frames: after a frame over the size limit the WebSocket's reader +/// is mid-frame and mustn't be asked for more. +fn linger(ws: &mut WebSocket) { + let until = Instant::now() + LINGER; + let mut buf = [0u8; 4096]; + while Instant::now() < until && ws.get_ref().set_read_timeout(Some(Duration::from_millis(250))).is_ok() { + match ws.get_mut().read(&mut buf) { + Ok(0) => break, + Ok(_) => {} + Err(e) if matches!(e.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut | io::ErrorKind::Interrupted) => {} + Err(_) => break, + } + } +} diff --git a/crates/collab-net/src/hub/hosting.rs b/crates/collab-net/src/hub/hosting.rs new file mode 100644 index 00000000..8588e56a --- /dev/null +++ b/crates/collab-net/src/hub/hosting.rs @@ -0,0 +1,201 @@ +use super::backend::Backend; +use super::conn::{Shared, accept}; +use super::limits::{PerAddress, loopback}; +use super::queue::{Blocks, HubIn, InFlight}; +use super::*; + +/// The person hosting an app session: in the file's session without a socket. +pub(crate) struct Local { + pub file: String, + pub server: Server, + /// Their client id (they joined `server` already). + pub id: u32, + pub inbox: Inbox, +} + +pub(super) fn spawn(name: &str, f: impl FnOnce() + Send + 'static) -> io::Result> { + std::thread::Builder::new().name(name.to_string()).spawn(f) +} + +/// A name others may see: trimmed, no control characters, at most [`MAX_NAME`] characters. +pub(crate) fn clean_name(s: &str) -> String { + s.trim().chars().filter(|c| !c.is_control()).take(MAX_NAME).collect() +} + +/// A running hub: its listener, connections and hub thread. Dropping it stops it. +pub struct Hosting { + addr: SocketAddr, + hub: Sender, + stop: Arc, + open: Arc, + /// Bytes queued for every connection together, and in flight to the hub. + queued: Arc, + in_flight: InFlight, + blocks: Blocks, + reading: Arc, + hub_thread: Option>, + accept_thread: Option>, +} + +impl Hosting { + /// Serve `backend`'s files on `listener`. + pub fn serve(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result { + Self::start(listener, backend, limits, None) + } + + pub(crate) fn start(listener: TcpListener, backend: B, limits: NetLimits, local: Option) -> io::Result { + let addr = listener.local_addr()?; + let in_flight = InFlight(Arc::default()); + let mut hub = Hub { + backend, + rooms: HashMap::new(), + conns: HashMap::new(), + local: None, + outbox: Vec::new(), + in_flight: in_flight.clone(), + barred: HashMap::new(), + }; + if let Some(l) = local { + let mut room = Room::new(l.server); + room.local = Some((l.id, l.inbox)); + hub.rooms.insert(l.file.clone(), room); + hub.local = Some(l.file); + } + let (hub_tx, hub_rx) = channel(); + let stop = Arc::new(AtomicBool::new(false)); + let open = Arc::new(AtomicUsize::new(0)); + let hub_thread = spawn("live-hub", move || hub.run(&hub_rx))?; + let shared = Shared { + per_address: PerAddress::default(), + queued: Arc::default(), + in_flight: in_flight.clone(), + blocks: Blocks::default(), + reading: Arc::default(), + readers: Readers::default(), + }; + let queued = shared.queued.clone(); + let blocks = shared.blocks.clone(); + let reading = shared.reading.clone(); + let accept_thread = { + let (hub_tx, stop, open) = (hub_tx.clone(), stop.clone(), open.clone()); + spawn("live-accept", move || accept(&listener, &hub_tx, &stop, &open, limits, shared)) + }; + let accept_thread = match accept_thread { + Ok(t) => t, + Err(e) => { + let _ = hub_tx.send(HubIn::Stop); + let _ = hub_thread.join(); + return Err(e); + } + }; + log::info!("live session: listening on {addr}"); + Ok(Hosting { + addr, + hub: hub_tx, + stop, + open, + queued, + in_flight, + blocks, + reading, + hub_thread: Some(hub_thread), + accept_thread: Some(accept_thread), + }) + } + + /// Bytes waiting to be sent to clients, all connections together. + pub fn queued_bytes(&self) -> usize { + self.queued.load(Ordering::Relaxed) + } + + /// Blocks of addresses the server keeps counters for (those of open connections, and any + /// whose last connection just ended, until the next connection prunes them). + pub fn blocks_remembered(&self) -> usize { + self.blocks.len() + } + + /// Bytes being read into messages not whole yet, all connections together. + pub fn reading_bytes(&self) -> usize { + self.reading.load(Ordering::Relaxed) + } + + /// Bytes of messages read and not handled by the hub yet. + pub fn in_flight_bytes(&self) -> usize { + self.in_flight.0.load(Ordering::Relaxed) + } + + /// Where it listens. + pub fn addr(&self) -> SocketAddr { + self.addr + } + + /// Open connections (joined or not). + pub fn connections(&self) -> usize { + self.open.load(Ordering::Relaxed) + } + + /// The hosting person's presence (app sessions). + pub(crate) fn presence(&self, p: Presence) { + let _ = self.hub.send(HubIn::Local(p)); + } + + /// A message from the hosting person's sync client (their edits). + pub(crate) fn send(&self, msg: ClientMsg) { + let _ = self.hub.send(HubIn::LocalMsg(msg)); + } + + /// The hosting person removes client `id` from their file (app sessions; see + /// [`HubIn::Remove`]). + pub(crate) fn remove(&self, id: u32) { + let _ = self.hub.send(HubIn::Remove(id)); + } + + /// Cut every connection as a network failure would (no close frame). + #[cfg(test)] + pub(crate) fn cut_connections(&self) { + let _ = self.hub.send(HubIn::Cut); + } + + /// Stop: the backend saves what's open, everyone is told and disconnected, the port is + /// released. + pub fn stop(&mut self) { + if self.stop.swap(true, Ordering::SeqCst) { + return; + } + let _ = self.hub.send(HubIn::Stop); + if let Some(t) = self.hub_thread.take() { + let _ = t.join(); + } + // The accept loop checks the flag after each connection: wake it with one. + let wake = if self.addr.ip().is_unspecified() { SocketAddr::new(loopback(self.addr), self.addr.port()) } else { self.addr }; + let woke = TcpStream::connect_timeout(&wake, Duration::from_secs(1)).is_ok(); + if let Some(t) = self.accept_thread.take() { + if woke { + let _ = t.join(); + } else { + log::warn!("live session: couldn't wake the accept loop; it ends with the next connection"); + } + } + // Readers end once their writers have closed the sockets. + let deadline = Instant::now() + Duration::from_secs(2); + while self.open.load(Ordering::Relaxed) > 0 && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(5)); + } + log::info!("live session: stopped listening on {}", self.addr); + } +} + +impl Drop for Hosting { + fn drop(&mut self) { + self.stop(); + } +} + +/// Counts an open connection until dropped. +pub(super) struct Open(pub(super) Arc); + +impl Drop for Open { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} diff --git a/crates/collab-net/src/hub/limits.rs b/crates/collab-net/src/hub/limits.rs new file mode 100644 index 00000000..333ff165 --- /dev/null +++ b/crates/collab-net/src/hub/limits.rs @@ -0,0 +1,99 @@ +use super::*; + +/// What a link lets a hello do. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Admission { + pub access: Access, + /// The link's id, when the backend has several per file (revoking it disconnects whoever + /// joined with it). + pub link: Option, +} + +/// The address limits and blocks count a client by: its IPv4 address (an IPv4-mapped IPv6 address +/// as IPv4), or its IPv6 address's /64 (one subscriber's block: they have billions of addresses). +pub fn ip_key(ip: IpAddr) -> IpAddr { + match ip.to_canonical() { + IpAddr::V6(v6) => { + let [a, b, c, d, ..] = v6.segments(); + IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)) + } + v4 => v4, + } +} + +/// The loopback address of `addr`'s family. +pub(super) fn loopback(addr: SocketAddr) -> std::net::IpAddr { + if addr.is_ipv6() { std::net::Ipv6Addr::LOCALHOST.into() } else { std::net::Ipv4Addr::LOCALHOST.into() } +} + +/// A block of addresses: its first address and its prefix length (2001:db8:: as a /64 and as a +/// /48 are different blocks). +pub type Block = (IpAddr, u8); + +/// The blocks a client's address ([`ip_key`]) counts in, with how many times its own limits each +/// gets: the address itself (an IPv4 /32 or an IPv6 /64, ×1) and, for IPv6, its /56 (×2) and /48 +/// (×4), so a tunnel's free /48 doesn't multiply the limits 65,536 times. +pub fn ip_blocks(key: IpAddr) -> Vec<(Block, u32)> { + let IpAddr::V6(v6) = key else { return vec![((key, 32), 1)] }; + let [a, b, c, d, ..] = v6.segments(); + vec![ + ((IpAddr::V6(Ipv6Addr::new(a, b, c, d, 0, 0, 0, 0)), 64), 1), + ((IpAddr::V6(Ipv6Addr::new(a, b, c, d & 0xff00, 0, 0, 0, 0)), 56), 2), + ((IpAddr::V6(Ipv6Addr::new(a, b, c, 0, 0, 0, 0, 0)), 48), 4), + ] +} + +/// Open connections per client address ([`ip_key`], and its wider IPv6 blocks); an address +/// leaves when its last one closes, so the table never holds more than the open connections. +#[derive(Clone, Default)] +pub(super) struct PerAddress(pub(super) Arc>>); + +/// One connection counted for a block until dropped. +pub(super) struct Counted { + table: PerAddress, + ip: Block, +} + +impl PerAddress { + /// Count a connection from block `ip`, unless it has `cap` already (0: no cap). + fn enter(&self, ip: Block, cap: usize) -> Option { + let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); + let n = t.entry(ip).or_insert(0); + if cap > 0 && *n >= cap { + if *n == 0 { + t.remove(&ip); + } + return None; + } + *n += 1; + Some(Counted { table: self.clone(), ip }) + } + + /// Count a connection from `ip` in each of its blocks, unless one of them is full. A wider + /// block's cap is its multiple of `cap`, but at most a sixteenth (/56) or an eighth (/48) of + /// `all` connections, so no block takes every connection. + pub(super) fn enter_all(&self, ip: IpAddr, cap: usize, all: usize) -> Option> { + let mut held = Vec::new(); + for ((addr, prefix), times) in ip_blocks(ip) { + let block_cap = match prefix { + 56 => cap.saturating_mul(times as usize).min(all / 16).max(1), + 48 => cap.saturating_mul(times as usize).min(all / 8).max(1), + _ => cap, + }; + held.push(self.enter((addr, prefix), if cap == 0 { 0 } else { block_cap })?); + } + Some(held) + } +} + +impl Drop for Counted { + fn drop(&mut self) { + let mut t = self.table.0.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(n) = t.get_mut(&self.ip) { + *n = n.saturating_sub(1); + if *n == 0 { + t.remove(&self.ip); + } + } + } +} diff --git a/crates/collab-net/src/preauth_tests.rs b/crates/collab-net/src/hub/preauth_tests.rs similarity index 100% rename from crates/collab-net/src/preauth_tests.rs rename to crates/collab-net/src/hub/preauth_tests.rs diff --git a/crates/collab-net/src/hub/queue.rs b/crates/collab-net/src/hub/queue.rs new file mode 100644 index 00000000..b6bce39e --- /dev/null +++ b/crates/collab-net/src/hub/queue.rs @@ -0,0 +1,210 @@ +use super::limits::ip_blocks; +use super::*; + +/// A message for one connection's writer: bytes (with what they count against, released when +/// they are sent or dropped), or the close. +pub(crate) enum Out { + Bytes(Bytes, Option), + Close(CloseCode, String), +} + +/// One message's bytes held for one or more connections (a broadcast shares one buffer), counted +/// once against a shared counter (the server's, or a block's) until the last copy is sent or +/// dropped. +pub(super) struct Held { + n: usize, + counter: Arc, +} + +impl Held { + fn new(n: usize, counter: &Arc) -> Arc { + counter.fetch_add(n, Ordering::Relaxed); + Arc::new(Held { n, counter: counter.clone() }) + } +} + +impl Drop for Held { + fn drop(&mut self) { + self.counter.fetch_sub(self.n, Ordering::Relaxed); + } +} + +/// What one message counts against, made by its first recipient and shared by the others: the +/// server's budget once, and each recipient block's once (twenty people behind one office's +/// address cost their block one copy of a broadcast, not twenty). +#[derive(Default)] +pub(crate) struct Charges { + pub(super) all: Option>, + pub(super) blocks: Vec<(usize, Arc)>, + /// Count it against blocks at all: a welcome isn't (it is bounded by the file's size and each + /// connection's cap, and a whole office joining at once must fit). + pub(crate) skip_blocks: bool, +} + +/// Bytes waiting in a connection's queue, counted against its cap until dropped (and, through +/// [`Held`], once against the server's budget and once against its block's). +pub(super) struct Charge { + n: usize, + mine: Arc, + _all: Arc, + _block: Option>, +} + +impl Drop for Charge { + fn drop(&mut self) { + self.mine.fetch_sub(self.n, Ordering::Relaxed); + } +} + +/// Why a message couldn't be queued. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Push { + /// The connection's or its block's queue is full: it can't keep up. + Full, + /// The server's budget for queued bytes is used up. + Everyone, + /// The connection is gone. + Gone, +} + +/// One connection's way out: its writer's queue, capped in messages and in bytes (its own, its +/// block of addresses', and the server's for everyone), so a client that doesn't read can't make +/// the server hold more than that for it. +#[derive(Clone)] +pub(crate) struct Line { + pub(crate) tx: SyncSender, + pub(crate) queued: Arc, + pub(crate) block: Arc, + pub(crate) all: Arc, + pub(crate) max: usize, + pub(crate) max_block: usize, + pub(crate) max_all: usize, +} + +impl Line { + /// Queue `b`. One message always fits an empty queue (a welcome as large as the largest file + /// gets out). `charges` is what the message counts against, shared by its recipients; + /// `over_budget` lets it past the server's budget (the hub just dropped the biggest consumer + /// to make room). + pub(crate) fn push(&self, b: &Bytes, charges: &mut Charges, over_budget: bool) -> Result<(), Push> { + let n = b.len(); + let mine = self.queued.load(Ordering::Relaxed); + if mine > 0 && mine.saturating_add(n) > self.max { + return Err(Push::Full); + } + let key = Arc::as_ptr(&self.block) as usize; + let block = if charges.skip_blocks { + None + } else if let Some((_, h)) = charges.blocks.iter().find(|(k, _)| *k == key) { + Some(h.clone()) + } else { + let used = self.block.load(Ordering::Relaxed); + if used > 0 && used.saturating_add(n) > self.max_block { + return Err(Push::Full); + } + let h = Held::new(n, &self.block); + charges.blocks.push((key, h.clone())); + Some(h) + }; + let all = match &charges.all { + Some(h) => h.clone(), + None => { + if !over_budget && self.all.load(Ordering::Relaxed).saturating_add(n) > self.max_all { + return Err(Push::Everyone); + } + charges.all.insert(Held::new(n, &self.all)).clone() + } + }; + self.queued.fetch_add(n, Ordering::Relaxed); + let charge = Charge { n, mine: self.queued.clone(), _all: all, _block: block }; + self.tx.try_send(Out::Bytes(b.clone(), Some(charge))).map_err(|e| match e { + TrySendError::Full(_) => Push::Full, + TrySendError::Disconnected(_) => Push::Gone, + }) + } +} + +/// What one block of addresses (an IPv4 address, an IPv6 /48: [`wide_block`]) has queued and is +/// having read, shared by its connections. +#[derive(Clone, Default)] +pub(crate) struct BlockUse { + pub(super) queued: Arc, + pub(super) reading: Arc, +} + +/// The blocks with open connections; a block leaves when its last connection ends. +#[derive(Clone, Default)] +pub(crate) struct Blocks(pub(crate) Arc>>); + +impl Blocks { + /// Block `key`'s counters; blocks nobody uses any more (only the table holds their counters, + /// whose connections' queues and readers are all gone) are forgotten first, so the table + /// never holds more than the blocks of open connections. + pub(crate) fn get(&self, key: IpAddr) -> BlockUse { + let mut t = self.0.lock().unwrap_or_else(PoisonError::into_inner); + t.retain(|_, u| Arc::strong_count(&u.queued) > 1 || Arc::strong_count(&u.reading) > 1); + t.entry(key).or_default().clone() + } + + /// Blocks remembered. + pub(crate) fn len(&self) -> usize { + self.0.lock().unwrap_or_else(PoisonError::into_inner).len() + } +} + +/// The widest block an address counts in for the server's shared budgets: an IPv4 address, or an +/// IPv6 address's /48. +pub fn wide_block(key: IpAddr) -> IpAddr { + ip_blocks(key).last().map_or(key, |(b, _)| b.0) +} + +/// Messages handed to the hub thread and not handled yet, in bytes: past the budget a reader +/// drops its connection instead of piling more on a busy hub. +#[derive(Clone)] +pub(crate) struct InFlight(pub(crate) Arc); + +impl InFlight { + pub(super) fn take(&self, n: usize, max: usize) -> bool { + if self.0.fetch_add(n, Ordering::Relaxed).saturating_add(n) > max { + self.0.fetch_sub(n, Ordering::Relaxed); + return false; + } + true + } + + pub(super) fn give(&self, n: usize) { + self.0.fetch_sub(n, Ordering::Relaxed); + } +} + +pub(super) enum HubIn { + /// A connection's first message (`Hello`, `Manage`, `OpenLibrary`, `OpenPlugins`, + /// `OpenHosting` or `Resolve`) and its client's address ([`ip_key`]). + Open { + conn: u64, + ip: IpAddr, + first: ClientMsg, + out: Line, + sock: TcpStream, + /// Its size (in flight until handled). + bytes: usize, + /// Told when the hub takes the connection in (dropped unsent when it doesn't). + admitted: SyncSender<()>, + }, + Msg { + conn: u64, + msg: ClientMsg, + bytes: usize, + }, + Gone { + conn: u64, + }, + Local(Presence), + LocalMsg(ClientMsg), + /// The person hosting removes client `id` from their file (app sessions). + Remove(u32), + /// Cut every connection without a word (tests: a network failure). + #[cfg(test)] + Cut, + Stop, +} diff --git a/crates/collab-net/src/queue_tests.rs b/crates/collab-net/src/hub/queue_tests.rs similarity index 100% rename from crates/collab-net/src/queue_tests.rs rename to crates/collab-net/src/hub/queue_tests.rs diff --git a/crates/engine/src/assets.rs b/crates/engine/src/assets.rs new file mode 100644 index 00000000..e648ad3b --- /dev/null +++ b/crates/engine/src/assets.rs @@ -0,0 +1,7 @@ +pub mod components; +pub mod library; +pub mod library_server; +pub mod library_swap; +pub mod playground; +pub mod style_vars; +pub mod token_roles; diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs new file mode 100644 index 00000000..3932efff --- /dev/null +++ b/crates/engine/src/assets/components.rs @@ -0,0 +1,301 @@ +//! Components and instances. +//! +//! An instance's layers are real nodes copied from its main component; each copy records the +//! main-component node it mirrors (`source`) and which property groups were changed locally +//! (`overrides`). When a main component changes, every instance is rebuilt from it, keeping layer +//! ids and re-applying overrides (matched by source, then by layer name path, so switching +//! variants keeps compatible overrides). Variants are components inside a component set, named +//! `Property=Value, …`; component properties (boolean, text, instance swap) are applied through +//! `prop_refs` on the layers they control. +//! +//! Override groups: `fills`, `strokes`, `effects`, `opacity`, `visible`, `name`, `corners`, +//! `characters`, `textStyle` (font, size, spacing, resizing, …), `padding`, `gap` and `alignment` +//! (of auto layout: its direction and other settings are the main's), `grids`, `exports`, +//! `interactions`, `size` and `resizing` (hug, fill or fixed, with min and max sizes) of layers +//! inside an instance, `modes` (the variable modes a layer inside an instance chooses), +//! `component` (a swapped nested instance), `props` (property values of a nested instance) and +//! `slot` (a slot holding the instance's own content). An instance's own export settings, +//! interactions, resizing and variable modes are its own, like its place. `position` +//! overrides of layers inside an instance are still read and kept, but new ones are refused +//! ([`check_override_edit`]): those layers take their place and constraints from the main. +//! +//! Switching variants keeps an override where the layer's name path matches and the two variants +//! agreed on that property (text layers keep all of theirs); swapping from the Assets panel keeps +//! only text layers' overrides ([`keep_text_overrides`]). +//! +//! Instances never gain or lose layers of their own: commands that would add, remove or reorder +//! layers inside an instance are refused ([`check_structure_edit`]), except inside **slots**. A +//! slot is a frame of a main component bound to a slot property (`prop_refs["slot"]`). An +//! instance's copy of it shows the main's default content until its layers are edited; from then +//! on (override group `slot`) its children are the instance's own content: free to edit, kept +//! across syncs and variant switches, and outside the instance's structure (instances placed in it +//! stand alone). + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_doc::*; +use figcraft_geom::Affine; +use serde_json::Value; + +mod diff; +mod guard; +mod instances; +mod patch; +mod rebase; +mod sync; +mod variants; + +pub use diff::{diff_groups, property_owners, set_boolean_default, variable_groups}; +pub use guard::{check_override_edit, check_structure_edit, refused_override, unlink_layers}; +pub(crate) use instances::copy_group; +pub use instances::{PROP_BINDING, copy_in_file, create_instance, create_instance_at, sync_instance}; +pub use patch::sync_after_edit; +#[cfg(test)] +pub(crate) use patch::{FULL_REBUILD, PATCHED}; +#[cfg(test)] +use rebase::RebaseSources; +pub use rebase::rebase_instance; +pub use sync::keep_text_overrides; +pub use variants::{default_variant, exposed_copies, find_variant, mirror_issues, variant_conflicts, variant_name, variant_options, variant_props}; + +/// Nesting limit for instances inside instances. +const MAX_DEPTH: usize = 16; +/// The override group (and `prop_refs` field) of slots. +pub const SLOT: &str = "slot"; +/// Every override group a layer can carry. +pub const OVERRIDE_GROUPS: &[&str] = &[ + "fills", + "strokes", + "effects", + "opacity", + "visible", + "name", + "corners", + "characters", + "textStyle", + "padding", + "gap", + "alignment", + "grids", + "exports", + "interactions", + "size", + "resizing", + "position", + "modes", + "component", + "props", + SLOT, +]; + +fn is_instance(doc: &Document, id: NodeId) -> bool { + doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Instance { .. })) +} + +/// A slot (inside an instance) whose children are the instance's own content. +fn holds_content(n: &Node) -> bool { + n.overrides.contains(SLOT) +} + +/// `id` and its ancestors, up to the nearest slot above it that holds an instance's own content: +/// content belongs to no instance (it edits freely, and instances placed in it stand alone). +fn scope(doc: &Document, id: NodeId) -> Vec { + let mut out = vec![id]; + for a in doc.ancestors(id) { + if doc.node(a).is_some_and(holds_content) { + break; + } + out.push(a); + } + out +} + +/// The component a node belongs to (itself, or the nearest component ancestor). +pub fn owning_component(doc: &Document, id: NodeId) -> Option { + let mut chain = vec![id]; + chain.extend(doc.ancestors(id)); + chain.into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) +} + +/// The innermost instance containing `id` (`id` itself when it is an instance). +pub fn owning_instance(doc: &Document, id: NodeId) -> Option { + scope(doc, id).into_iter().find(|c| is_instance(doc, *c)) +} + +/// The outermost instance containing `id` (`id` itself when it is a top-level instance). +pub fn root_instance(doc: &Document, id: NodeId) -> Option { + scope(doc, id).into_iter().rfind(|c| is_instance(doc, *c)) +} + +/// The innermost instance `id` is a layer of (never `id` itself). +fn enclosing_instance(doc: &Document, id: NodeId) -> Option { + scope(doc, id).into_iter().skip(1).find(|c| is_instance(doc, *c)) +} + +/// Whether `id` is a layer inside an instance (not the outermost instance itself). +pub fn inside_instance(doc: &Document, id: NodeId) -> bool { + enclosing_instance(doc, id).is_some() +} + +/// Mark the `modes` override on layers inside instances whose variable modes differ from their +/// main layer's, for files from before `modes` was an override group. Copies follow their main +/// layer's modes, so a layer on other modes chose them itself. +pub fn mark_layer_modes(doc: &mut Document) { + let chose: Vec = doc + .nodes() + .filter(|n| !n.overrides.contains("modes") && n.source.and_then(|s| doc.node(s)).is_some_and(|m| m.var_modes != n.var_modes)) + .map(|n| n.id) + .filter(|id| inside_instance(doc, *id)) + .collect(); + for id in chose { + if let Some(n) = doc.node_mut(id) { + n.overrides.insert("modes".into()); + } + } +} + +/// The slot property frame `id` stands for (its name and preferred components): a frame bound to +/// a slot property of its component, or an instance's copy of one (followed through its sources). +pub fn slot_def(doc: &Document, id: NodeId) -> Option<(&str, &[NodeId])> { + let mut cur = id; + for _ in 0..=MAX_DEPTH { + let n = doc.node(cur)?; + if !matches!(n.kind, NodeKind::Frame) { + return None; + } + if let Some(name) = n.prop_refs.get(SLOT) { + let NodeKind::Component { props, .. } = &doc.node(owning_component(doc, cur)?)?.kind else { return None }; + return match props.get(name) { + Some(PropDef::Slot { preferred }) => Some((name.as_str(), preferred.as_slice())), + _ => None, + }; + } + cur = n.source?; + } + None +} + +/// The name of the slot property frame `id` stands for ([`slot_def`]). +pub fn slot_name(doc: &Document, id: NodeId) -> Option { + slot_def(doc, id).map(|(name, _)| name.to_string()) +} + +/// Whether `id` is a slot (in a main component, or an instance's copy of one). +pub fn is_slot(doc: &Document, id: NodeId) -> bool { + slot_def(doc, id).is_some() +} + +/// Whether slot `id` (inside an instance) holds the instance's own content rather than its main's +/// default content. +pub fn slot_edited(doc: &Document, id: NodeId) -> bool { + doc.node(id).is_some_and(holds_content) +} + +/// The slot `id` is, or lies in, within its instance: where layers may be added, removed and +/// reordered freely. +pub fn slot_scope(doc: &Document, id: NodeId) -> Option { + let mut chain = vec![id]; + chain.extend(doc.ancestors(id)); + for c in chain { + if is_instance(doc, c) { + return None; + } + if is_slot(doc, c) { + return Some(c); + } + } + None +} + +/// Whether layers can't be added to, removed from or reordered in `id`: it is (inside) an +/// instance, outside its slots. +pub fn structure_locked(doc: &Document, id: NodeId) -> bool { + owning_instance(doc, id).is_some() && slot_scope(doc, id).is_none() +} + +/// For tree walks that look for where layers can go: whether the children of `id` are locked (in +/// an instance, outside its slots), given whether `id` itself is (`locked`). +pub fn locks_children(doc: &Document, id: NodeId, locked: bool) -> bool { + is_instance(doc, id) || (locked && !is_slot(doc, id)) +} + +/// Whether layer `id`'s place (parent and position among its siblings) changed between two +/// versions only where instance structure allows it. +pub fn move_allowed(before: &Document, after: &Document, id: NodeId) -> bool { + let place = |d: &Document| d.node(id).and_then(|n| n.parent).map(|p| (p, d.index_in_parent(id))); + let (was, is) = (place(before), place(after)); + was == is || (!was.is_some_and(|(p, _)| structure_locked(before, p)) && !is.is_some_and(|(p, _)| structure_locked(after, p))) +} + +/// The slots of a component or instance (its own: not those of instances nested in it, nor of the +/// content put into its slots), with their property names, top-most first. +pub fn slots_of(doc: &Document, root: NodeId) -> Vec<(NodeId, String)> { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(root).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default(); + let mut seen = 0usize; + while let Some(c) = stack.pop() { + seen += 1; + if seen > 1_000_000 { + break; + } + let Some(n) = doc.node(c) else { continue }; + if let Some(name) = slot_name(doc, c) { + out.push((c, name)); + } + if !is_instance(doc, c) && !holds_content(n) { + stack.extend(n.children.iter().rev().copied()); + } + } + out +} + +/// All instances of `component` in the document. +pub fn instances_of(doc: &Document, component: NodeId) -> Vec { + doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { component: c, .. } if c == component)).map(|n| n.id).collect() +} + +/// The component set a variant belongs to. +pub fn variant_set(doc: &Document, component: NodeId) -> Option { + doc.node(component).and_then(|c| c.parent).filter(|p| doc.node(*p).is_some_and(|n| matches!(n.kind, NodeKind::ComponentSet { .. }))) +} + +/// The layer name a fresh instance of `component` gets: the set's name for variants. +pub fn default_instance_name(doc: &Document, component: NodeId) -> String { + let set = variant_set(doc, component).and_then(|s| doc.node(s)); + set.or_else(|| doc.node(component)).map(|n| n.name.clone()).unwrap_or_else(|| "Instance".into()) +} + +/// Default property values of a component (slots hold content, not values: they have none). +pub fn default_props(comp: &Node) -> BTreeMap { + let mut out = BTreeMap::new(); + if let NodeKind::Component { props, .. } = &comp.kind { + for (k, d) in props { + if takes_value(d) { + out.insert(k.clone(), prop_default_value(d)); + } + } + } + out +} + +/// Whether instances store a value for the property (every kind but slots). +pub fn takes_value(d: &PropDef) -> bool { + !matches!(d, PropDef::Slot { .. }) +} + +/// A property definition's default as a JSON value. +pub fn prop_default_value(d: &PropDef) -> Value { + match d { + PropDef::Boolean { default } => Value::Bool(*default), + PropDef::Text { default } => Value::String(default.clone()), + PropDef::InstanceSwap { default, .. } => Value::from(default.0), + PropDef::Variant { default, .. } => Value::String(default.clone()), + PropDef::Slot { .. } => Value::Null, + } +} + +#[cfg(test)] +mod rebase_source_tests; + +#[cfg(test)] +pub(crate) mod tests; diff --git a/crates/engine/src/assets/components/diff.rs b/crates/engine/src/assets/components/diff.rs new file mode 100644 index 00000000..bf742fed --- /dev/null +++ b/crates/engine/src/assets/components/diff.rs @@ -0,0 +1,218 @@ +use super::instances::PROP_BINDING; +use super::*; + +/// The property groups whose value layer `n` takes from the same variables as its main layer +/// `src` (they differ only where the two resolve in different variable modes). +pub fn variable_groups(n: &Node, src: &Node) -> Vec<&'static str> { + let mut out = Vec::new(); + for (k, v) in &n.bound { + let Some(g) = binding_group(k) else { continue }; + if src.bound.get(k) == Some(v) && !out.contains(&g) { + out.push(g); + } + } + out +} + +/// The override group a variable binding key feeds (`None` for component property and layout +/// grid bindings, which follow other rules). +pub(super) fn binding_group(key: &str) -> Option<&'static str> { + use crate::cmd::lists::{BoundList, list_binding}; + if key.starts_with(PROP_BINDING) { + return None; + } + match list_binding(key).map(|(l, _, _)| l) { + Some(BoundList::Fills) => Some("fills"), + Some(BoundList::Strokes) => Some("strokes"), + Some(BoundList::Effects) => Some("effects"), + Some(BoundList::Grids) => None, + None => Some(crate::cmd::props::override_group(key)).filter(|g| *g != "other"), + } +} + +/// The variable bindings of `n` that feed group `g`. +pub(super) fn bindings_of<'a>(n: &'a Node, g: &'a str) -> impl Iterator + 'a { + n.bound.iter().filter(move |(k, _)| binding_group(k) == Some(g)) +} + +/// Property groups that differ between two versions of a node. +pub fn diff_groups(a: &Node, b: &Node) -> Vec<&'static str> { + let mut out = Vec::new(); + if a.fills != b.fills { + out.push("fills"); + } + if a.strokes != b.strokes || a.stroke != b.stroke { + out.push("strokes"); + } + if a.effects != b.effects { + out.push("effects"); + } + if a.opacity != b.opacity || a.blend != b.blend { + out.push("opacity"); + } + if a.visible != b.visible { + out.push("visible"); + } + if a.name != b.name { + out.push("name"); + } + if a.corners != b.corners { + out.push("corners"); + } + if (a.width - b.width).abs() > 1e-6 || (a.height - b.height).abs() > 1e-6 { + out.push("size"); + } + if resizing(a) != resizing(b) { + out.push("resizing"); + } + if a.transform != b.transform { + out.push("position"); + } + if let (NodeKind::Text(x), NodeKind::Text(y)) = (&a.kind, &b.kind) { + if x.characters != y.characters { + out.push("characters"); + } + if text_style_differs(x, y) { + out.push("textStyle"); + } + } + if let (Some(x), Some(y)) = (&a.layout, &b.layout) { + if x.padding != y.padding { + out.push("padding"); + } + if x.gap != y.gap || x.cross_gap != y.cross_gap { + out.push("gap"); + } + if x.main_align != y.main_align || x.cross_align != y.cross_align || x.content_align != y.content_align { + out.push("alignment"); + } + } + if a.grids != b.grids || a.styles.grid != b.styles.grid { + out.push("grids"); + } + if a.exports != b.exports { + out.push("exports"); + } + if a.interactions != b.interactions { + out.push("interactions"); + } + if a.var_modes != b.var_modes { + out.push("modes"); + } + if let (NodeKind::Instance { component: x, props: px }, NodeKind::Instance { component: y, props: py }) = (&a.kind, &b.kind) { + if x != y { + out.push("component"); + } + if px != py { + out.push("props"); + } + } + out +} + +/// The `resizing` group of a layer: how it sizes on each axis, and its min and max sizes. +fn resizing(n: &Node) -> (Sizing, Sizing, [Option; 4]) { + let c = &n.child_layout; + (c.sizing_h, c.sizing_v, [c.min_w, c.max_w, c.min_h, c.max_h]) +} + +/// Whether two texts differ in anything but their characters (the `textStyle` group). +fn text_style_differs(x: &TextData, y: &TextData) -> bool { + x.style != y.style + || x.runs != y.runs + || x.auto_resize != y.auto_resize + || x.align_h != y.align_h + || x.align_v != y.align_v + || x.paragraph_spacing != y.paragraph_spacing + || x.paragraph_indent != y.paragraph_indent + || x.list != y.list + || x.paragraphs != y.paragraphs + || x.list_spacing != y.list_spacing + || x.hanging_punctuation != y.hanging_punctuation + || x.hanging_list != y.hanging_list + || x.max_lines != y.max_lines + || x.features != y.features +} + +/// Whether a geometry change of `id` comes from layout rather than the user: an ancestor was +/// resized in the same edit (constraints), the parent is an auto layout frame, or auto-sized text. +pub(super) fn geometry_derived(doc: &Document, before: &Document, id: NodeId, group: &str) -> bool { + for a in doc.ancestors(id) { + let (Some(x), Some(y)) = (doc.node(a), before.node(a)) else { continue }; + if (x.width - y.width).abs() > 1e-6 || (x.height - y.height).abs() > 1e-6 { + return true; + } + } + let Some(n) = doc.node(id) else { return true }; + if group == "position" && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute { + return true; + } + if group == "size" + && let NodeKind::Text(t) = &n.kind + { + let wchanged = before.node(id).is_some_and(|b| (b.width - n.width).abs() > 1e-6); + return match t.auto_resize { + TextAutoResize::WidthAndHeight => true, + TextAutoResize::Height => !wchanged, + _ => false, + }; + } + false +} + +/// The components sharing the properties of component `c`: the variants of its set, or `c` alone. +pub fn property_owners(doc: &Document, c: NodeId) -> Vec { + match variant_set(doc, c).and_then(|s| doc.node(s)) { + Some(set) => set.children.iter().copied().filter(|v| doc.node(*v).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))).collect(), + None => vec![c], + } +} + +/// Make `value` the default of boolean property `name` on components `comps`, showing or hiding +/// their layers bound to it: on a main component, a boolean property's value is the visibility of +/// its layers. Returns the components and layers it changed. +pub fn set_boolean_default(doc: &mut Document, comps: &[NodeId], name: &str, value: bool) -> Vec { + let mut out = Vec::new(); + for &c in comps { + let Some(NodeKind::Component { props, .. }) = doc.node(c).map(|n| &n.kind) else { continue }; + let Some(PropDef::Boolean { default }) = props.get(name) else { continue }; + if *default != value + && let Some(NodeKind::Component { props, .. }) = doc.node_mut(c).map(|n| &mut n.kind) + { + props.insert(name.to_string(), PropDef::Boolean { default: value }); + out.push(c); + } + // The main's own layers (not those of instances nested in it, which follow their mains). + let layers: Vec = doc + .subtree(c) + .into_iter() + .filter(|d| *d != c && !inside_instance(doc, *d)) + .filter(|d| doc.node(*d).is_some_and(|n| n.visible != value && n.prop_refs.get("visible").map(String::as_str) == Some(name))) + .collect(); + for d in layers { + if let Some(n) = doc.node_mut(d) { + n.visible = value; + out.push(d); + } + } + } + out +} + +/// A main component's layer bound to a boolean property was shown or hidden: that is the +/// property's new default, which its other bound layers (in every variant) take too. Returns the +/// components and layers that changed as a result. +pub(super) fn follow_boolean_defaults(doc: &mut Document, before: &Document, changed: &[NodeId]) -> Vec { + let mut out = Vec::new(); + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + if n.visible == b.visible || inside_instance(doc, id) { + continue; + } + let Some(name) = n.prop_refs.get("visible").cloned() else { continue }; + let visible = n.visible; + let Some(c) = owning_component(doc, id).filter(|c| *c != id) else { continue }; + out.extend(set_boolean_default(doc, &property_owners(doc, c), &name, visible)); + } + out +} diff --git a/crates/engine/src/assets/components/guard.rs b/crates/engine/src/assets/components/guard.rs new file mode 100644 index 00000000..b320b7c4 --- /dev/null +++ b/crates/engine/src/assets/components/guard.rs @@ -0,0 +1,135 @@ +use super::diff::geometry_derived; +use super::rebase::rebase_instance; +use super::variants::instance_issues; +use super::*; + +/// Slot `slot` (inside an instance) takes the instance's own content: the layers it shows (the +/// main's default content until now) stop mirroring the main, and instances among them stand +/// alone, linked to their own mains. +pub(super) fn own_slot_content(doc: &mut Document, slot: NodeId) { + if let Some(n) = doc.node_mut(slot) { + n.overrides.insert(SLOT.into()); + } + for nested in unlink_layers(doc, slot, None) { + rebase_instance(doc, nested); + } +} + +/// Unlink the layers below `top` that are layers of `owner` (an instance, or `None` for the +/// content of a slot): plain layers forget the main layer they mirrored and their property +/// bindings. The instances among them are returned, to re-link to their own mains (with +/// [`rebase_instance`]) once the caller is done. +pub fn unlink_layers(doc: &mut Document, top: NodeId, owner: Option) -> Vec { + let picked: Vec<(NodeId, bool)> = + doc.subtree(top).into_iter().filter(|d| *d != top && enclosing_instance(doc, *d) == owner).map(|d| (d, is_instance(doc, d))).collect(); + let mut nested = Vec::new(); + for (d, instance) in picked { + let Some(n) = doc.node_mut(d) else { continue }; + n.prop_refs.clear(); + if instance { + nested.push(d); + } else { + n.source = None; + n.overrides.clear(); + } + } + nested +} + +/// A new layer copied from inside an instance (pasted, duplicated) to where no instance owns it +/// stops mirroring the main; a copied nested instance is re-linked to its own main (its look kept). +pub(super) fn relink_copy(doc: &mut Document, id: NodeId) { + let Some(n) = doc.node(id) else { return }; + match &n.kind { + NodeKind::Instance { component, .. } => { + let main_ok = doc.node(*component).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. })); + if main_ok && !inside_instance(doc, id) && (n.source != Some(*component) || !instance_issues(doc, id).is_empty()) { + rebase_instance(doc, id); + } + } + _ => { + if (n.source.is_some() || !n.overrides.is_empty()) + && owning_instance(doc, id).is_none() + && let Some(m) = doc.node_mut(id) + { + m.source = None; + m.overrides.clear(); + } + } + } +} + +/// Refuse edits that add, remove or reorder layers inside an instance (only its main can), outside +/// its slots. +pub fn check_structure_edit(before: &Document, after: &Document) -> Result<(), String> { + for &id in after.dirty() { + let (Some(n), Some(b)) = (after.node(id), before.node(id)) else { continue }; + // Library copies change only through library updates (and are read-only otherwise). + if n.children == b.children || after.is_remote(id) { + continue; + } + if structure_locked(before, id) && structure_locked(after, id) { + let name = owning_instance(after, id).and_then(|i| after.node(i)).map(|i| i.name.clone()).unwrap_or_default(); + return Err(format!( + "layers can't be added, removed or reordered inside the instance \"{name}\": edit its main component, or detach the instance first" + )); + } + } + Ok(()) +} + +/// Refuse edits an instance doesn't take as overrides ([`refused_override`]); the edit is rolled +/// back. +pub fn check_override_edit(before: &Document, after: &Document) -> Result<(), String> { + match after.dirty().iter().find_map(|id| refused_override(before, after, *id)) { + Some(what) => Err(format!("{what} comes from the main component in an instance: edit the main component, or detach the instance")), + None => Ok(()), + } +} + +/// What about layer `id` an edit from `before` to `after` changed that only its main can: the +/// auto layout of an instance or a layer in one but its padding, gap and alignment, or the +/// position (rotation too), constraints or place in auto layout (absolute, grid cell) of a layer +/// inside an instance (its size and resizing are overrides). `None` when the edit is fine, also +/// when the main changed the same way in this edit (a library update). +pub fn refused_override(before: &Document, after: &Document, id: NodeId) -> Option<&'static str> { + let (n, b) = (after.node(id)?, before.node(id)?); + if after.is_remote(id) || n.source != b.source || owning_instance(before, id).is_none() || owning_instance(after, id).is_none() { + return None; + } + let (mb, ma) = (before.node(n.source?)?, after.node(n.source?)?); + let mode = |x: &Node| x.layout.as_ref().map(|l| l.mode); + // Auto layout settings other than the overridable ones. + let settings = |x: &Node| { + x.layout.clone().map(|mut l| { + (l.gap, l.cross_gap, l.padding, l.main_align, l.cross_align) = (0.0, 0.0, [0.0; 4], MainAlign::Min, CrossAlign::Min); + l.content_align = ContentAlign::Auto; + l + }) + }; + // Child layout settings other than the resizing ones. + let placing = |x: &Node| { + let mut c = x.child_layout.clone(); + (c.sizing_h, c.sizing_v, c.min_w, c.max_w, c.min_h, c.max_h) = (Sizing::Fixed, Sizing::Fixed, None, None, None, None); + c + }; + if mode(n) != mode(b) && mode(ma) == mode(mb) { + return Some("The auto layout direction"); + } + if settings(n) != settings(b) && settings(ma) == settings(mb) { + return Some("This auto layout setting"); + } + if !inside_instance(after, id) || !inside_instance(before, id) { + return None; + } + if n.transform != b.transform && ma.transform == mb.transform && !geometry_derived(after, before, id, "position") { + return Some("A layer's position"); + } + if n.constraints != b.constraints && ma.constraints == mb.constraints { + return Some("A layer's constraints"); + } + if placing(n) != placing(b) && placing(ma) == placing(mb) { + return Some("A layer's place in auto layout"); + } + None +} diff --git a/crates/engine/src/assets/components/instances.rs b/crates/engine/src/assets/components/instances.rs new file mode 100644 index 00000000..9fd4e2c2 --- /dev/null +++ b/crates/engine/src/assets/components/instances.rs @@ -0,0 +1,342 @@ +use super::diff::{binding_group, bindings_of}; +use super::sync::{insert_linked, sync_instance_depth}; +use super::*; + +/// Create an instance of `component` under `parent` at `(x, y)` (parent space). +pub fn create_instance(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64) -> Result { + create_instance_at(doc, component, parent, x, y, None) +} + +/// [`create_instance`] at child `index` of `parent` (None: on top). +pub fn create_instance_at(doc: &mut Document, component: NodeId, parent: NodeId, x: f64, y: f64, index: Option) -> Result { + let comp = doc.get(component)?.clone(); + if !matches!(comp.kind, NodeKind::Component { .. }) { + return Err(DocError::Invalid(format!("{component} is not a component"))); + } + if doc.is_ancestor(component, parent) { + return Err(DocError::Invalid("an instance can't be placed inside its own main component".into())); + } + let id = doc.alloc_id(); + let mut root = comp.clone(); + root.id = id; + root.name = default_instance_name(doc, component); + root.kind = NodeKind::Instance { component, props: default_props(&comp) }; + root.children.clear(); + root.parent = None; + root.source = Some(component); + root.overrides.clear(); + root.transform = Affine::translate((x, y)); + root.prop_refs.clear(); + root.annotations.clear(); + root.code_links.clear(); + // The main's dev resources show on its instances without being copied. + root.dev_resources.clear(); + // Measurements belong to the layer they were placed on. + root.measurements.clear(); + // The main's interactions play on its instances in prototypes; the instance's own start empty. + root.interactions.clear(); + // So do the variable modes the main sets (see `Document::inherited_modes`). + root.var_modes.clear(); + doc.insert(root, parent, index)?; + sync_instance(doc, id); + Ok(id) +} + +/// Copy `id` within its own file under `parent` (at `index`), keeping its local transform. A file +/// has one main per component, so a main component's copy is an instance of it; a variant copied +/// into its set stays a new variant, and every other layer is deep-copied. +pub fn copy_in_file(doc: &mut Document, id: NodeId, parent: NodeId, index: Option) -> Result { + let n = doc.get(id)?; + let in_set = doc.node(parent).is_some_and(|p| matches!(p.kind, NodeKind::ComponentSet { .. })); + if !matches!(n.kind, NodeKind::Component { .. }) || in_set { + return doc.duplicate_into(id, parent, index); + } + let transform = n.transform; + let instance = create_instance_at(doc, id, parent, 0.0, 0.0, index)?; + doc.get_mut(instance)?.transform = transform; + Ok(instance) +} + +/// Which part of the carried overrides to apply. +#[derive(Clone, Copy, PartialEq)] +pub(super) enum Carry { + /// Paint, text, visibility, … (everything but geometry). + Values, + /// Values plus size (instance roots: their size decides how children follow constraints). + Root, + /// Size and position (children, after constraints ran). + Geometry, +} + +/// Copy the overridden property groups of `from` onto `to`. +pub(super) fn carry_overrides(from: &Node, to: &mut Node, what: Carry) { + for g in &from.overrides { + let geo = matches!(g.as_str(), "size" | "position"); + match what { + Carry::Values if geo => continue, + Carry::Root if g == "position" => continue, + Carry::Geometry if !geo => continue, + _ => {} + } + copy_group(from, to, g); + } + if what != Carry::Geometry { + to.overrides = from.overrides.clone(); + } +} + +/// An instance switched from variant `was` to `new`: its resizing (sizing and min / max per axis) +/// that is still `was`'s follows `new`; what the instance set itself stays in `own`. +pub(super) fn follow_variant_resizing(own: &mut ChildLayout, was: &ChildLayout, new: &ChildLayout) { + if own.sizing_h == was.sizing_h { + own.sizing_h = new.sizing_h; + } + if own.sizing_v == was.sizing_v { + own.sizing_v = new.sizing_v; + } + for (o, w, n) in [ + (&mut own.min_w, was.min_w, new.min_w), + (&mut own.max_w, was.max_w, new.max_w), + (&mut own.min_h, was.min_h, new.min_h), + (&mut own.max_h, was.max_h, new.max_h), + ] { + if *o == w { + *o = n; + } + } +} + +/// Copy the values of property group `g` of `from` onto `to`, with the variables bound to them. +pub(crate) fn copy_group(from: &Node, to: &mut Node, g: &str) { + if from.bound.iter().chain(to.bound.iter()).any(|(k, _)| binding_group(k) == Some(g)) { + to.bound.retain(|k, _| binding_group(k) != Some(g)); + to.bound.extend(bindings_of(from, g).map(|(k, v)| (k.clone(), v.clone()))); + } + match g { + "fills" => { + to.fills = from.fills.clone(); + to.styles.fill = from.styles.fill.clone(); + } + "strokes" => { + to.strokes = from.strokes.clone(); + to.stroke = from.stroke.clone(); + to.styles.stroke = from.styles.stroke.clone(); + } + "effects" => { + to.effects = from.effects.clone(); + to.styles.effect = from.styles.effect.clone(); + } + "opacity" => { + to.opacity = from.opacity; + to.blend = from.blend; + } + "visible" => to.visible = from.visible, + "name" => to.name = from.name.clone(), + "corners" => to.corners = from.corners, + "characters" => { + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.characters = a.characters.clone(); + b.runs = a.runs.clone(); + b.paragraphs = a.paragraphs.clone(); + } + } + "textStyle" => { + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.style = a.style.clone(); + b.runs = a.runs.clone(); + b.auto_resize = a.auto_resize; + b.align_h = a.align_h; + b.align_v = a.align_v; + b.paragraph_spacing = a.paragraph_spacing; + b.paragraph_indent = a.paragraph_indent; + b.list = a.list; + b.paragraphs = a.paragraphs.clone(); + b.list_spacing = a.list_spacing; + b.hanging_punctuation = a.hanging_punctuation; + b.hanging_list = a.hanging_list; + b.max_lines = a.max_lines; + b.features = a.features.clone(); + } + to.styles.text = from.styles.text.clone(); + } + "padding" | "gap" | "alignment" => { + if let (Some(a), Some(b)) = (&from.layout, &mut to.layout) { + match g { + "padding" => b.padding = a.padding, + "gap" => (b.gap, b.cross_gap) = (a.gap, a.cross_gap), + _ => (b.main_align, b.cross_align, b.content_align) = (a.main_align, a.cross_align, a.content_align), + } + } + } + "grids" => { + to.grids = from.grids.clone(); + to.styles.grid = from.styles.grid.clone(); + } + "exports" => to.exports = from.exports.clone(), + "interactions" => to.interactions = from.interactions.clone(), + "size" => { + to.width = from.width; + to.height = from.height; + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&from.kind, &mut to.kind) { + b.auto_resize = a.auto_resize; + } + } + "resizing" => { + let (a, b) = (&from.child_layout, &mut to.child_layout); + (b.sizing_h, b.sizing_v, b.min_w, b.max_w, b.min_h, b.max_h) = (a.sizing_h, a.sizing_v, a.min_w, a.max_w, a.min_h, a.max_h); + } + "position" => to.transform = from.transform, + "modes" => to.var_modes = from.var_modes.clone(), + "component" => { + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&from.kind, &mut to.kind) { + *b = *a; + } + } + "props" => { + if let (NodeKind::Instance { props: a, .. }, NodeKind::Instance { props: b, .. }) = (&from.kind, &mut to.kind) { + *b = a.clone(); + } + carry_prop_bindings(from, to); + } + _ => {} + } +} + +/// Layer name paths below `root` ("Frame/Label", with `#n` for same-named siblings). With +/// `src_names` (an instance's layers), layers are named after the main layer they mirror (renames +/// don't break matching), and the content of slots holding the instance's own is left out. +pub(super) fn path_map(doc: &Document, root: NodeId, src_names: bool) -> BTreeMap { + let mut out = BTreeMap::new(); + let mut stack = vec![(root, String::new())]; + while let Some((id, prefix)) = stack.pop() { + let Some(n) = doc.node(id) else { continue }; + let mut seen: BTreeMap<&str, usize> = BTreeMap::new(); + for c in &n.children { + let Some(cn) = doc.node(*c) else { continue }; + // A nested instance linked only to its own component so far (an imported one) mirrors no + // layer yet: it goes by its own name, like the main's layer it will be matched with. + let unplaced = matches!(&cn.kind, NodeKind::Instance { component, .. } if cn.source == Some(*component)); + let mirrored = cn.source.filter(|_| src_names && !unplaced).and_then(|s| doc.node(s)); + let name = mirrored.map_or(cn.name.as_str(), |s| s.name.as_str()); + let k = seen.entry(name).or_insert(0); + let seg = if *k == 0 { name.to_string() } else { format!("{name}#{k}") }; + *k += 1; + let path = if prefix.is_empty() { seg } else { format!("{prefix}/{seg}") }; + out.insert(*c, path.clone()); + if out.len() > 1_000_000 { + return out; + } + if !(src_names && holds_content(cn)) { + stack.push((*c, path)); + } + } + } + out +} + +/// The binding key prefix of an instance's component property value +/// (`componentProperties/`): a boolean property takes a boolean variable, a text property a +/// string variable. +pub const PROP_BINDING: &str = "componentProperties/"; + +/// Give `to` the component property bindings of instance `from` (they go with its property +/// values, which an instance keeps across rebuilds). +pub(super) fn carry_prop_bindings(from: &Node, to: &mut Node) { + to.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + to.bound.extend(from.bound.iter().filter(|(k, _)| k.starts_with(PROP_BINDING)).map(|(k, v)| (k.clone(), v.clone()))); +} + +/// The override group a property-bound field belongs to. +pub(super) fn field_group(field: &str) -> &'static str { + match field { + "visible" => "visible", + "characters" => "characters", + "mainComponent" => "component", + _ => "", + } +} + +/// Apply one component-property value to a layer. +pub(super) fn apply_prop(doc: &Document, n: &mut Node, field: &str, val: &Value) { + match field { + "visible" => { + if let Some(b) = val.as_bool() { + n.visible = b; + } + } + "characters" => { + if let (NodeKind::Text(t), Some(sv)) = (&mut n.kind, val.as_str().map(crate::cmd::props::normalize_line_endings)) + && t.characters != sv + { + let len = t.char_count(); + t.replace_range(0, len, &sv); + } + } + "mainComponent" => { + if let (NodeKind::Instance { component: c, .. }, Some(v)) = (&mut n.kind, crate::params::id_of(val)) + && doc.node(v).is_some_and(|m| matches!(m.kind, NodeKind::Component { .. })) + { + *c = v; + } + } + _ => {} + } +} + +/// Rebuild instance `inst` from its main component, keeping ids, overrides and the content of its +/// slots. An instance whose main component was deleted keeps its layers as they are. +pub fn sync_instance(doc: &mut Document, inst: NodeId) { + // Layers the rebuild leaves as they were keep their shared snapshot (and aren't edits). + let old: Vec<(std::sync::Arc, bool)> = + doc.subtree(inst).into_iter().filter_map(|i| doc.node_arc(i).map(|a| (a.clone(), doc.dirty().contains(&i)))).collect(); + let kept = slot_content(doc, inst); + sync_instance_depth(doc, inst, 0, BTreeMap::new()); + restore_slot_content(doc, kept); + for (a, was_dirty) in old { + doc.keep_if_unchanged(a, was_dirty); + } +} + +/// The slots in instance `inst` (nested instances included) holding the instance's own content: +/// each with its content (the subtrees of its children, pre-order). +fn slot_content(doc: &Document, inst: NodeId) -> Vec<(NodeId, Vec)> { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(inst).map(|n| n.children.clone()).unwrap_or_default(); + while let Some(c) = stack.pop() { + let Some(n) = doc.node(c) else { continue }; + if holds_content(n) { + let nodes = n.children.iter().flat_map(|k| doc.subtree(*k)).filter_map(|d| doc.node(d).cloned()).collect(); + out.push((c, nodes)); + } else { + stack.extend(n.children.iter().copied()); + } + } + out +} + +/// Put slot content back after a rebuild, into the slots that still hold the instance's own +/// content (a slot the main no longer has, or no longer marks as a slot, loses it). Constraints +/// and geometry overrides ran before, so the content keeps its place. +fn restore_slot_content(doc: &mut Document, kept: Vec<(NodeId, Vec)>) { + for (slot, nodes) in kept { + let keep = slot_edited(doc, slot) && is_slot(doc, slot) && nodes.iter().all(|n| !doc.contains(n.id)); + if !keep { + continue; + } + let ids: Vec = nodes.iter().map(|n| n.id).collect(); + for n in nodes { + // Pre-order: every parent is back before its children. + let _ = insert_linked(doc, n); + } + // Never leave links to a layer that couldn't come back. + for id in ids.into_iter().chain([slot]) { + let dangling = doc.node(id).is_some_and(|n| n.children.iter().any(|c| !doc.contains(*c))); + if dangling { + let live: Vec = doc.node(id).map(|n| n.children.iter().copied().filter(|c| doc.contains(*c)).collect()).unwrap_or_default(); + if let Some(n) = doc.node_mut(id) { + n.children = live; + } + } + } + } +} diff --git a/crates/engine/src/assets/components/patch.rs b/crates/engine/src/assets/components/patch.rs new file mode 100644 index 00000000..8c61f0b8 --- /dev/null +++ b/crates/engine/src/assets/components/patch.rs @@ -0,0 +1,528 @@ +use super::diff::{binding_group, bindings_of, diff_groups, follow_boolean_defaults, geometry_derived, variable_groups}; +use super::guard::{own_slot_content, relink_copy}; +use super::instances::{copy_group, field_group, sync_instance}; +use super::sync::insert_linked; +use super::*; + +/// After an edit: record overrides inside instances, then rebuild instances whose main changed, +/// whose properties or main were switched, or whose overrides were reset. +pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId], touched: &BTreeSet) { + let mut changed = changed.to_vec(); + for id in follow_boolean_defaults(doc, before, &changed) { + if !changed.contains(&id) { + changed.push(id); + } + } + let changed = changed.as_slice(); + // 0. A slot whose layers were added, removed or reordered holds the instance's own content + // from now on; copies of instance layers placed elsewhere stop mirroring their main. + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + if n.children == b.children || owning_instance(doc, id).is_none() { + continue; + } + if let Some(slot) = slot_scope(doc, id) + && !slot_edited(doc, slot) + { + own_slot_content(doc, slot); + } + } + for &id in changed { + if !before.contains(id) { + relink_copy(doc, id); + } + } + let mut resync: BTreeSet = BTreeSet::new(); + // 1. Overrides for layers edited inside instances. + for &id in changed { + let (Some(n), Some(b)) = (doc.node(id), before.node(id)) else { continue }; + let Some(inst) = owning_instance(doc, id) else { continue }; + let top_root = inst == id && !inside_instance(doc, id); + if top_root + && let (NodeKind::Instance { component: c1, props: p1 }, NodeKind::Instance { component: c2, props: p2 }) = (&n.kind, &b.kind) + && (c1 != c2 || p1 != p2) + { + // Switching the main or a property value of an instance isn't an override. + resync.insert(id); + } + let raw = diff_groups(b, n); + let mut groups = raw.clone(); + // An instance's own place, resizing, export settings, interactions and variable modes are + // its own, not overrides. + if top_root { + groups.retain(|g| !matches!(*g, "component" | "props" | "position" | "resizing" | "exports" | "interactions" | "modes")); + } + if groups.iter().any(|g| matches!(*g, "size" | "position")) { + groups.retain(|g| !matches!(*g, "size" | "position") || !geometry_derived(doc, before, id, g)); + } + // A value equal to the main's isn't an override (setting it back clears the override). + let mut cleared = Vec::new(); + if let Some(src) = n.source.and_then(|s| doc.node(s)) { + let mut differs = diff_groups(src, n); + if inst == id { + differs.retain(|g| *g != "name"); + if n.name != default_instance_name(doc, src.id) { + differs.push("name"); + } + } + // A value from the same variable as the main's (resolved in this layer's modes) isn't an + // override either. + let from_variables = variable_groups(n, src); + groups.retain(|g| differs.contains(g) && !from_variables.contains(g)); + // Binding another variable (or none) where the main binds one is an override. + for (k, _) in b.bound.iter().chain(n.bound.iter()) { + let Some(g) = binding_group(k) else { continue }; + let rebound = b.bound.get(k) != n.bound.get(k) && !bindings_of(n, g).eq(bindings_of(src, g)); + if rebound && !groups.contains(&g) { + groups.push(g); + } + } + cleared = + raw.iter().filter(|g| !matches!(**g, "size" | "position") && !differs.contains(g) && n.overrides.contains(**g)).copied().collect(); + if top_root { + cleared.retain(|g| !matches!(*g, "component" | "props")); + } + } + let lost = b.overrides.iter().any(|g| !n.overrides.contains(g)); + let nested_switch = !top_root && raw.iter().any(|g| matches!(*g, "component" | "props")); + if (!groups.is_empty() || !cleared.is_empty()) + && let Some(m) = doc.node_mut(id) + { + for g in groups { + m.overrides.insert(g.to_string()); + } + for g in cleared { + m.overrides.remove(g); + } + } + if (lost || nested_switch) + && let Some(r) = root_instance(doc, id) + { + resync.insert(r); + } + } + // 2. Components whose layers changed, were added or removed. Value-only edits (paint, text, + // visibility, …) are carried to the copies directly; anything else rebuilds the instances. + let mut comps: BTreeSet = BTreeSet::new(); + let mut patches: Vec<(NodeId, Vec<&'static str>)> = Vec::new(); + // Main layers that only gained new layers or lost some: their copies take the same change. + let mut restructured: Vec = Vec::new(); + let mut covered: BTreeSet = BTreeSet::new(); + for &id in changed { + if let (Some(b), Some(n)) = (before.node(id), doc.node(id)) + && b.children != n.children + && owning_instance(doc, id).is_none() + && owning_component(doc, id).is_some() + && let Some(layers) = restructurable(doc, before, b, n) + { + restructured.push(id); + covered.extend(layers); + } + } + for &id in changed { + if covered.contains(&id) || restructured.contains(&id) { + continue; + } + let Some(c) = owning_component(doc, id) else { continue }; + let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { + comps.insert(c); + continue; + }; + if b == n { + continue; + } + match patchable(doc, b, n) { + Some(groups) => patches.push((id, groups)), + None => { + comps.insert(c); + } + } + } + for &id in touched { + if !doc.contains(id) + && !covered.contains(&id) + && let Some(c) = owning_component(before, id) + && doc.contains(c) + { + comps.insert(c); + } + } + for r in resync { + if doc.contains(r) { + sync_instance(doc, r); + if let Some(c) = owning_component(doc, r) { + comps.insert(c); + } + } + } + // A copy the patch can't follow exactly rebuilds instead (as does any other change). + let patched = comps.is_empty() + && !full_rebuild() + && (restructured.is_empty() || patch_structure(doc, before, &restructured)) + && (patches.is_empty() || patch_copies(doc, &patches)); + #[cfg(test)] + if patched && (!restructured.is_empty() || !patches.is_empty()) { + PATCHED.with(|c| c.set(c.get() + 1)); + } + if !patched { + comps.extend(patches.iter().map(|(id, _)| *id).chain(restructured.iter().copied()).filter_map(|id| owning_component(doc, id))); + } + // 3. Instances of changed components follow; an instance inside another component makes that + // one change too. + rebuild_instances(doc, &comps); +} + +#[cfg(test)] +thread_local! { + /// Tests: rebuild instances even for edits that could be patched (to compare both ways). + pub(crate) static FULL_REBUILD: std::cell::Cell = const { std::cell::Cell::new(false) }; + /// Tests: edits carried to the copies without rebuilding instances. + pub(crate) static PATCHED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +#[cfg(test)] +fn full_rebuild() -> bool { + FULL_REBUILD.with(std::cell::Cell::get) +} + +#[cfg(not(test))] +fn full_rebuild() -> bool { + false +} + +/// The property groups of a main-component layer edited from `b` to `n` when the edit changed +/// values only (paint, text, visibility, size, position, …) that copies take as they are: carried to them without +/// rebuilding the instances ([`patch_copies`]). `None` when anything else changed. +fn patchable(doc: &Document, b: &Node, n: &Node) -> Option> { + const VALUES: [&str; 17] = [ + "fills", + "strokes", + "effects", + "opacity", + "visible", + "name", + "corners", + "characters", + "textStyle", + "padding", + "gap", + "alignment", + "grids", + "exports", + "interactions", + "size", + "position", + ]; + if b.children != n.children || b.parent != n.parent || b.kind.type_name() != n.kind.type_name() { + return None; + } + let groups = diff_groups(b, n); + if groups.is_empty() || groups.iter().any(|g| !VALUES.contains(g)) { + return None; + } + // Exactly those groups (and the layer's own overrides) changed. + let mut probe = n.clone(); + for g in &groups { + copy_group(b, &mut probe, g); + } + probe.overrides = b.overrides.clone(); + if probe != *b { + return None; + } + // Values a component property sets (on this layer or a main it mirrors) come from instances. + let mut cur = Some(n); + for _ in 0..=MAX_DEPTH { + let Some(x) = cur else { break }; + if x.prop_refs.keys().any(|f| groups.contains(&field_group(f))) { + return None; + } + cur = x.source.and_then(|s| doc.node(s)); + } + Some(groups) +} + +/// Copies by the layer they mirror; nested instances swapped to a main also follow it. +fn copies_index(doc: &Document) -> BTreeMap> { + let mut copies: BTreeMap> = BTreeMap::new(); + for n in doc.nodes() { + let Some(s) = n.source else { continue }; + copies.entry(s).or_default().push(n.id); + if let (NodeKind::Instance { component, .. }, Some(NodeKind::Instance { component: other, .. })) = (&n.kind, doc.node(s).map(|x| &x.kind)) + && component != other + { + copies.entry(*component).or_default().push(n.id); + } + } + copies +} + +/// When main layer `b` → `n` only gained new plain layers (created in this edit: no instances, +/// components or property bindings in them) or lost layers that were deleted: those layers, which +/// [`patch_structure`] carries to the copies. `None` for anything else. +fn restructurable(doc: &Document, before: &Document, b: &Node, n: &Node) -> Option> { + let mut probe = n.clone(); + probe.children = b.children.clone(); + probe.overrides = b.overrides.clone(); + if probe != *b { + return None; + } + let mut layers = Vec::new(); + for c in n.children.iter().filter(|c| !b.children.contains(c)) { + for d in doc.subtree(*c) { + let x = doc.node(d)?; + let plain = !matches!(x.kind, NodeKind::Instance { .. } | NodeKind::Component { .. } | NodeKind::ComponentSet { .. }); + if before.contains(d) || !plain || !x.prop_refs.is_empty() { + return None; + } + layers.push(d); + } + } + for c in b.children.iter().filter(|c| !n.children.contains(c)) { + if doc.contains(*c) { + return None; + } + layers.extend(before.subtree(*c)); + } + Some(layers) +} + +/// Carry the new and deleted layers of main layers `seeds` ([`restructurable`]) to every copy, +/// through copies of copies (slots holding an instance's own content keep it). Returns false +/// (leaving a partial patch for the caller to rebuild over) when a copy can't follow exactly. +fn patch_structure(doc: &mut Document, before: &Document, seeds: &[NodeId]) -> bool { + let copies = copies_index(doc); + let mut work: Vec = seeds.to_vec(); + let mut steps = 0usize; + while let Some(p) = work.pop() { + steps += 1; + if steps > 10_000_000 { + return false; + } + let Some(pn) = doc.node(p).cloned() else { continue }; + for c in copies.get(&p).cloned().unwrap_or_default() { + let Some(cn) = doc.node(c).cloned() else { continue }; + if holds_content(&cn) { + continue; + } + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind) + && a != b + { + continue; + } + // The copy's layers by the layer of `p` they mirror. + let mut mirrors: BTreeMap = BTreeMap::new(); + for k in &cn.children { + let Some(src) = doc.node(*k).and_then(|x| x.source) else { return false }; + if mirrors.insert(src, *k).is_some() { + return false; + } + } + let mut order = Vec::with_capacity(pn.children.len()); + for pc in &pn.children { + if let Some(k) = mirrors.remove(pc) { + order.push(k); + continue; + } + // A layer the copy lacks must be new in this edit, placed as in the main. + let absolute = doc.node(*pc).is_some_and(|x| x.child_layout.absolute); + if before.contains(*pc) || !child_follows_main(doc, c, absolute) { + return false; + } + match copy_layers(doc, *pc, c) { + Some(k) => order.push(k), + None => return false, + } + } + // Copies of layers the main lost. + for (src, k) in mirrors { + if doc.contains(src) || doc.remove(k).is_err() { + return false; + } + } + if doc.node(c).is_some_and(|x| x.children != order) + && let Some(m) = doc.node_mut(c) + { + m.children = order; + } + work.push(c); + } + } + true +} + +/// Whether a layer placed in copy `parent` (absolutely positioned or not) takes its main layer's +/// place and size: nothing on the way up applies constraints ([`follows_main_geometry`]). +fn child_follows_main(doc: &Document, parent: NodeId, absolute: bool) -> bool { + let Some(p) = doc.node(parent) else { return false }; + let constrains = p.layout.is_none() || absolute; + let resized = sized_own(p) + || match &p.kind { + NodeKind::Instance { component, .. } => { + doc.node(*component).is_none_or(|m| (m.width - p.width).abs() > 1e-9 || (m.height - p.height).abs() > 1e-9) + } + _ => false, + }; + !(constrains && resized) && follows_main_geometry(doc, parent) +} + +/// Copy main layer `src` (with its layers) into copy `parent` as layers mirroring them; returns the +/// new copy (the caller places it among `parent`'s layers). +fn copy_layers(doc: &mut Document, src: NodeId, parent: NodeId) -> Option { + let sub = doc.subtree(src); + let map: BTreeMap = sub.iter().map(|d| (*d, doc.alloc_id())).collect(); + for d in &sub { + let mut n = doc.node(*d)?.clone(); + n.id = *map.get(d)?; + n.parent = if *d == src { Some(parent) } else { n.parent.and_then(|x| map.get(&x).copied()) }; + n.children = n.children.iter().filter_map(|k| map.get(k).copied()).collect(); + n.source = Some(*d); + n.overrides.clear(); + n.prop_refs.clear(); + n.annotations.clear(); + n.measurements.clear(); + insert_linked(doc, n).ok()?; + } + map.get(&src).copied() +} + +/// Carry value-only edits of main-component layers (`seeds`: layer, groups) to every copy, through +/// copies of copies, except where a copy overrides the group. Returns false (leaving a partial +/// patch for the caller to rebuild over) when a copy can't follow exactly. +fn patch_copies(doc: &mut Document, seeds: &[(NodeId, Vec<&'static str>)]) -> bool { + let copies = copies_index(doc); + let text = |g: &str| matches!(g, "characters" | "textStyle"); + let mut work: Vec<(NodeId, Vec<&'static str>)> = seeds.to_vec(); + let mut steps = 0usize; + while let Some((p, groups)) = work.pop() { + steps += 1; + if steps > 10_000_000 { + return false; + } + let Some(pn) = doc.node(p).cloned() else { continue }; + for c in copies.get(&p).cloned().unwrap_or_default() { + let Some(cn) = doc.node(c) else { continue }; + // A swapped nested instance shows its own main, not the layer it stands in for. + if let (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) = (&pn.kind, &cn.kind) + && a != b + { + continue; + } + // An instance keeps its own visibility, place, export settings and interactions; its + // name follows the main's unless renamed; a resized one lays its layers out against + // the main's size. + let root = matches!(pn.kind, NodeKind::Component { .. }); + if root && ((groups.contains(&"name") && !cn.overrides.contains("name")) || (groups.contains(&"size") && cn.overrides.contains("size"))) { + return false; + } + let gs: Vec<&'static str> = groups + .iter() + .copied() + .filter(|g| !(cn.overrides.contains(*g) || root && matches!(*g, "visible" | "name" | "position" | "exports" | "interactions"))) + .collect(); + if gs.is_empty() { + continue; + } + let geometry = gs.iter().any(|g| matches!(*g, "size" | "position")); + if geometry && !follows_main_geometry(doc, c) { + return false; + } + // Text content and style share their runs: a copy overriding one keeps its runs when the + // other follows the main (as a rebuild carries them). + let runs = match &cn.kind { + NodeKind::Text(t) if gs.iter().any(|g| text(g)) && cn.overrides.iter().any(|g| text(g)) => Some(t.runs.clone()), + _ => None, + }; + if let Some(m) = doc.node_mut(c) { + for g in &gs { + copy_group(&pn, m, g); + } + if let (Some(r), NodeKind::Text(t)) = (runs, &mut m.kind) { + t.runs = r; + } + } + if (geometry || gs.iter().any(|g| text(g))) + && let Some((w, h)) = doc.node(c).and_then(figcraft_text::auto_size) + && doc.node(c).is_some_and(|n| (n.width - w).abs() > 1e-6 || (n.height - h).abs() > 1e-6) + { + figcraft_layout::set_size(doc, c, w, h); + } + work.push((c, gs)); + } + } + true +} + +/// Whether a copy's size and place are its main layer's: no layer around it inside its instances +/// applies constraints on the way down to it (a resized instance or frame without auto layout, or +/// a resized auto layout instance holding it absolutely positioned). Auto layout re-flows the rest. +fn follows_main_geometry(doc: &Document, id: NodeId) -> bool { + let chain = scope(doc, id); + chain.windows(2).all(|w| { + let (Some(below), Some(a)) = (w.first().and_then(|i| doc.node(*i)), w.get(1).and_then(|i| doc.node(*i))) else { return false }; + let constrains = a.layout.is_none() || below.child_layout.absolute; + if sized_own(a) && constrains { + return false; + } + match &a.kind { + NodeKind::Instance { component, .. } => { + !constrains || doc.node(*component).is_some_and(|m| (m.width - a.width).abs() <= 1e-9 && (m.height - a.height).abs() <= 1e-9) + } + _ => true, + } + }) +} + +/// Whether a layer inside an instance overrides its size or resizing: it may not have its main +/// layer's size, so the layers in it can't take their main layers' places as they are. +fn sized_own(n: &Node) -> bool { + n.overrides.contains("size") || n.overrides.contains("resizing") +} + +/// Rebuild every instance of `comps` (and of the components that hold those instances, and so on +/// outwards), each outermost instance once, inner components first. +fn rebuild_instances(doc: &mut Document, comps: &BTreeSet) { + if comps.is_empty() { + return; + } + // Outermost instances by the components their layers instantiate. + let mut roots_of: BTreeMap> = BTreeMap::new(); + for n in doc.nodes() { + if let NodeKind::Instance { component, .. } = &n.kind { + roots_of.entry(*component).or_default().insert(root_instance(doc, n.id).unwrap_or(n.id)); + } + } + // Affected components, with how deep their dependencies go (an instance inside component X of + // component Y: X follows Y). + let mut level: BTreeMap = comps.iter().map(|c| (*c, 0)).collect(); + let mut outer: BTreeMap> = BTreeMap::new(); + let mut frontier: Vec = comps.iter().copied().collect(); + let mut rounds = 0; + while !frontier.is_empty() && rounds < 64 { + rounds += 1; + let mut next = Vec::new(); + for c in frontier { + let lc = level.get(&c).copied().unwrap_or(0); + for r in roots_of.get(&c).into_iter().flatten() { + let o = *outer.entry(*r).or_insert_with(|| owning_component(doc, *r)); + if let Some(x) = o + && x != c + { + let lx = level.entry(x).or_insert(0); + if *lx < lc + 1 { + *lx = lc + 1; + next.push(x); + } + } + } + } + frontier = next; + } + // Instances inside components by level, then the rest (each once). + let mut order: Vec<(usize, NodeId)> = outer.iter().map(|(r, o)| (o.and_then(|x| level.get(&x).copied()).unwrap_or(usize::MAX), *r)).collect(); + order.sort(); + for (_, r) in order { + if doc.contains(r) { + sync_instance(doc, r); + } + } +} diff --git a/crates/engine/src/assets/components/rebase.rs b/crates/engine/src/assets/components/rebase.rs new file mode 100644 index 00000000..748a99d3 --- /dev/null +++ b/crates/engine/src/assets/components/rebase.rs @@ -0,0 +1,249 @@ +use super::diff::{diff_groups, variable_groups}; +use super::instances::{field_group, path_map}; +use super::*; + +/// Bounded source and parent lookups while a rebase reads an unchanged document. +pub(super) struct RebaseSources<'a> { + doc: &'a Document, + chains: BTreeMap>>, + parents: BTreeMap<(NodeId, NodeId), Option>, + main_changes: BTreeMap, +} + +impl<'a> RebaseSources<'a> { + const PARENTS: usize = 512; + const CACHE: usize = 8192; + + pub(super) fn new(doc: &'a Document) -> Self { + Self { doc, chains: BTreeMap::new(), parents: BTreeMap::new(), main_changes: BTreeMap::new() } + } + + fn chain(&mut self, source: NodeId) -> Option> { + if let Some(chain) = self.chains.get(&source) { + return chain.clone(); + } + let read = || { + let mut chain = Vec::new(); + let mut seen = BTreeSet::new(); + let mut current = Some(source); + for _ in 0..MAX_DEPTH { + let Some(id) = current else { return Some(chain) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + chain.push(id); + current = n.source; + } + current.is_none().then_some(chain) + }; + let chain = read(); + if self.chains.len() < Self::CACHE { + self.chains.insert(source, chain.clone()); + } + chain + } + + /// Validate the complete parent chain, even if `main` was found before a cycle. + fn in_main(&mut self, id: NodeId, main: NodeId) -> Option { + if let Some(found) = self.parents.get(&(id, main)) { + return *found; + } + let read = || { + let mut current = Some(id); + let mut seen = BTreeSet::new(); + let mut found = false; + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(found) }; + if !seen.insert(id) { + return None; + } + found |= id == main; + current = self.doc.node(id)?.parent; + } + current.is_none().then_some(found) + }; + let found = read(); + if self.parents.len() < Self::CACHE { + self.parents.insert((id, main), found); + } + found + } + + fn member(&mut self, source: NodeId, main: NodeId, target: NodeId) -> Option { + let chain = self.chain(source)?; + if chain.contains(&target) { + return None; + } + let mut member = None; + for id in chain { + if self.in_main(id, main)? && id != main && member.is_none() { + member = Some(id); + } + } + member + } + + /// Nearest enclosing instances and their current mains. Slots with own content end scope. + pub(super) fn owners(&self, id: NodeId) -> Option> { + let mut owners = Vec::new(); + let mut current = self.doc.node(id)?.parent; + let mut seen = BTreeSet::from([id]); + for _ in 0..Self::PARENTS { + let Some(id) = current else { return Some(owners) }; + if !seen.insert(id) { + return None; + } + let n = self.doc.node(id)?; + if holds_content(n) { + return Some(owners); + } + if let NodeKind::Instance { component, .. } = &n.kind { + owners.push((id, *component)); + } + current = n.parent; + } + current.is_none().then_some(owners) + } + + fn contextual(&mut self, source: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + owners.iter().any(|(_, main)| self.in_main(source, *main) == Some(true) && source != *main) + } + + /// A contextual child belongs to the same placed slot as its enclosing instance. + fn valid(&mut self, source: NodeId, target: NodeId, owners: &[(NodeId, NodeId)]) -> bool { + let Some((owner, main)) = owners.first() else { return false }; + if self.member(source, *main, target).is_none() { + return false; + } + if self.in_main(source, *main) == Some(true) { + return true; + } + let Some(source_owner) = self.doc.node(*owner).and_then(|n| n.source) else { return false }; + matches!(self.doc.node(source_owner).map(|n| &n.kind), Some(NodeKind::Instance { component, .. }) if component == main) + && self.owners(source).and_then(|owners| owners.first().copied()).is_some_and(|(id, _)| id == source_owner) + } + + fn placed(&mut self, id: NodeId, owners: &[(NodeId, NodeId)]) -> Option { + let source = self.doc.node(id)?.source?; + if !matches!(self.doc.node(source)?.kind, NodeKind::Instance { .. } | NodeKind::Frame) + || !self.valid(source, id, owners) + || !self.contextual(source, owners) + { + return None; + } + Some(source) + } + + fn beneath_swap(&mut self, owners: &[(NodeId, NodeId)], stop: NodeId) -> bool { + for (index, (id, main)) in owners.iter().enumerate() { + if *id == stop { + break; + } + let changed = if let Some(changed) = self.main_changes.get(id) { + *changed + } else { + let changed = self.placed(*id, &owners[index + 1..]).is_some_and(|source| match self.doc.node(source).map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => component != main, + Some(NodeKind::Frame) => true, + _ => false, + }); + if self.main_changes.len() < Self::CACHE { + self.main_changes.insert(*id, changed); + } + changed + }; + if changed { + return true; + } + } + false + } +} + +/// Re-derive `source` links and overrides of instance `inst` against its own main by comparing +/// values (after detaching an outer instance, restoring a main, or copying a nested instance out): +/// the look is kept exactly. Values a component property sets aren't overrides. +pub fn rebase_instance(doc: &mut Document, inst: NodeId) { + let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return }; + let mine = path_map(doc, inst, true); + let theirs: BTreeMap = + if doc.contains(component) { path_map(doc, component, false).into_iter().map(|(k, v)| (v, k)).collect() } else { BTreeMap::new() }; + let value_groups = |a: &Node, b: &Node| -> Vec { + diff_groups(a, b).into_iter().filter(|g| !matches!(*g, "size" | "position")).map(str::to_string).collect() + }; + let mut updates = Vec::new(); + let mut sources = RebaseSources::new(doc); + let root_owners = sources.owners(inst).unwrap_or_default(); + let placed_source = sources.placed(inst, &root_owners); + let mut contexts = vec![(inst, component)]; + contexts.extend_from_slice(&root_owners); + for (id, path) in &mine { + let Some(n) = doc.node(*id) else { continue }; + let owners = sources.owners(*id).unwrap_or_default(); + let nearest_main = owners.first().map(|(_, main)| *main); + let valid_source = n.source.filter(|source| sources.valid(*source, *id, &owners)); + let mut main = valid_source.and_then(|source| sources.member(source, component, *id)); + // A swapped or Frame-to-Instance subtree is compared to its current main by the inner + // rebase. An outer name path must not rebind those children to the old expanded copies. + if main.is_none() && valid_source.is_some() && sources.beneath_swap(&owners, inst) { + updates.push((*id, valid_source, n.overrides.clone())); + continue; + } + main = main + .or_else(|| theirs.get(path).copied().filter(|source| nearest_main.is_some_and(|scope| sources.member(*source, scope, *id).is_some()))); + let source = valid_source.filter(|source| sources.contextual(*source, &contexts)).filter(|_| main.is_some()).or(main); + let mut overrides: BTreeSet = n.overrides.iter().filter(|g| !matches!(g.as_str(), "component" | "props")).cloned().collect(); + if let Some(m) = source.and_then(|source| doc.node(source)) { + let mut bound: Vec<&str> = m.prop_refs.keys().map(|f| field_group(f)).collect(); + bound.extend(variable_groups(n, m)); + overrides.extend(value_groups(m, n).into_iter().filter(|g| !bound.contains(&g.as_str()))); + if matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) { + overrides.insert("component".into()); + } + } + updates.push((*id, source, overrides)); + } + // A placed nested root compares to its actual slot. A detached root compares to its own main. + let root_base = placed_source.or_else(|| { + let chain = sources.chain(component)?; + (!chain.contains(&inst) && sources.in_main(component, inst) == Some(false)).then_some(component) + }); + let root_groups = match (root_base.and_then(|source| doc.node(source)), doc.node(inst)) { + (Some(m), Some(n)) => { + let from_variables = variable_groups(n, m); + let mut groups: Vec = value_groups(m, n) + .into_iter() + .filter(|g| !matches!(g.as_str(), "name" | "exports" | "interactions") && !from_variables.contains(&g.as_str())) + .filter(|g| placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + .collect(); + let named = if placed_source.is_some() { n.name != m.name } else { n.name != default_instance_name(doc, component) }; + if named { + groups.push("name".into()); + } + if placed_source.is_some() + && matches!((&m.kind, &n.kind), (NodeKind::Instance { component: a, .. }, NodeKind::Instance { component: b, .. }) if a != b) + { + groups.push("component".into()); + } + groups + } + _ => Vec::new(), + }; + drop(sources); + for (id, main, overrides) in updates { + if let Some(n) = doc.node_mut(id) { + n.source = main; + n.overrides = if main.is_some() { overrides } else { BTreeSet::new() }; + } + } + if let Some(n) = doc.node_mut(inst) { + n.source = root_base; + // A top-level instance's resizing and modes are its own (a nested one's override its main + // layer's). + n.overrides.retain(|g| { + !matches!(g.as_str(), "component" | "props" | "position") && (placed_source.is_some() || !matches!(g.as_str(), "resizing" | "modes")) + }); + n.overrides.extend(root_groups); + } +} diff --git a/crates/engine/src/components_rebase_source_tests.rs b/crates/engine/src/assets/components/rebase_source_tests.rs similarity index 100% rename from crates/engine/src/components_rebase_source_tests.rs rename to crates/engine/src/assets/components/rebase_source_tests.rs diff --git a/crates/engine/src/assets/components/sync.rs b/crates/engine/src/assets/components/sync.rs new file mode 100644 index 00000000..c502322d --- /dev/null +++ b/crates/engine/src/assets/components/sync.rs @@ -0,0 +1,459 @@ +use super::diff::diff_groups; +use super::instances::{Carry, apply_prop, carry_overrides, carry_prop_bindings, copy_group, field_group, follow_variant_resizing, path_map}; +use super::*; + +/// `spare`: layers the outer rebuild didn't reuse that were below `inst` (a swapped nested +/// instance): its layers the outer main doesn't have, matched here so they keep ids and overrides. +pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize, spare: BTreeMap>) { + if depth > MAX_DEPTH { + return; + } + let Some(mut root) = doc.node(inst).cloned() else { return }; + let NodeKind::Instance { component, props: root_props } = &root.kind else { return }; + let component = *component; + let Some(comp) = doc.node(component).cloned() else { return }; + if !matches!(comp.kind, NodeKind::Component { .. }) || doc.is_ancestor(inst, component) || doc.is_ancestor(component, inst) { + // A missing main, or an instance inside its own main (would recurse forever). + return; + } + let mut eff_props = default_props(&comp); + for (k, v) in root_props { + if eff_props.contains_key(k) { + eff_props.insert(k.clone(), v.clone()); + } + } + // Old layers (kept alive for their overrides), by (parent, source), by source alone (when only + // one layer mirrors it) and, only when those don't match, by name path. + let mut old_ids = old_layers(doc, inst); + old_ids.extend(spare.keys().copied()); + old_ids.sort(); + old_ids.dedup(); + let mut old: BTreeMap> = spare; + let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new(); + let mut by_src: BTreeMap> = BTreeMap::new(); + for i in &old_ids { + let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue }; + if let Some(s) = n.source { + if let Some(p) = n.parent { + by_source.entry((p, s)).or_insert(*i); + } + by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i)); + } + old.insert(*i, n); + } + let mut by_path: Option> = None; + let mut main_paths: Option> = None; + // Ids for the copies: reuse the matching old layer's id (a layer the main moved to another + // parent keeps its id and overrides). + let src: Vec = doc.subtree(component).into_iter().filter(|i| *i != component).collect(); + let mut map: BTreeMap = BTreeMap::new(); + map.insert(component, inst); + let mut used: BTreeSet = BTreeSet::new(); + // Slots holding the instance's own content (new ids), and main layers not copied: the + // default content of those slots. + let mut edited: BTreeSet = BTreeSet::new(); + let mut skipped: BTreeSet = BTreeSet::new(); + let mut fresh_ids = Vec::new(); + for s in &src { + let Some(sn) = doc.node(*s) else { continue }; + if sn.parent.is_some_and(|p| skipped.contains(&p) || map.get(&p).is_some_and(|pn| edited.contains(pn))) { + skipped.insert(*s); + continue; + } + let parent_new = sn.parent.and_then(|p| map.get(&p).copied()); + let mut found = parent_new.and_then(|pn| by_source.get(&(pn, *s)).copied()).or_else(|| by_src.get(s).copied().flatten()); + if found.is_none() { + let paths = by_path.get_or_insert_with(|| path_map(doc, inst, true).into_iter().map(|(k, v)| (v, k)).collect()); + let mains = main_paths.get_or_insert_with(|| path_map(doc, component, false)); + found = mains.get(s).and_then(|p| paths.get(p)).copied(); + } + let nid = match found { + Some(o) if used.insert(o) => o, + _ => { + fresh_ids.push(*s); + continue; + } + }; + if let Some(m) = old.get(&nid).and_then(|o| variant_kept(doc, o, *s)) { + old.insert(nid, std::sync::Arc::new(m)); + } + if old.get(&nid).is_some_and(|o| holds_content(o)) && is_slot(doc, *s) { + edited.insert(nid); + } + map.insert(*s, nid); + } + for c in root.children.clone() { + let _ = doc.remove(c); + } + for s in fresh_ids { + let nid = doc.alloc_id(); + map.insert(s, nid); + } + // Build the copies (pre-order: parents first). + let mut enclosing: BTreeMap> = BTreeMap::new(); + enclosing.insert(inst, vec![inst]); + let mut inst_props: BTreeMap> = BTreeMap::new(); + inst_props.insert(inst, eff_props); + let mut nested = Vec::new(); + let mut new_nodes = Vec::new(); + // Text layers showing other text or style than their main layer (to re-measure). + let mut remeasure = Vec::new(); + for s in &src { + let Some(orig) = doc.node(*s) else { continue }; + let Some(&nid) = map.get(s) else { continue }; + let Some(parent) = orig.parent.and_then(|p| map.get(&p).copied()) else { continue }; + let mut n = orig.clone(); + n.id = nid; + n.parent = Some(parent); + n.children = orig.children.iter().filter_map(|c| map.get(c).copied()).collect(); + n.source = Some(*s); + n.overrides.clear(); + n.prop_refs.clear(); + let prev = old.get(&nid); + // Annotations belong to the layer they were added to, not to its copies. + n.annotations = prev.map(|o| o.annotations.clone()).unwrap_or_default(); + n.measurements = prev.map(|o| o.measurements.clone()).unwrap_or_default(); + if let Some(o) = prev { + carry_overrides(o, &mut n, Carry::Values); + keep_laid_out(doc, orig, o, &mut n); + } + if !edited.contains(&nid) { + // No longer a slot in the main: it mirrors the main again. + n.overrides.remove(SLOT); + } + // Component properties: the main layer's bindings use this instance's values; layers of + // nested instances use the nested instance's values (one level per source hop). + let enc = enclosing.get(&parent).cloned().unwrap_or_default(); + let mut chain: Vec<&Node> = vec![orig]; + while chain.len() < enc.len() { + let Some(next) = chain.last().and_then(|c| c.source).and_then(|x| doc.node(x)) else { break }; + chain.push(next); + } + for (j, level) in chain.iter().enumerate().rev() { + let Some(props) = enc.get(j).and_then(|e| inst_props.get(e)) else { continue }; + for (field, prop) in &level.prop_refs { + let g = field_group(field); + if n.overrides.contains(g) || (j > 0 && orig.overrides.contains(g)) { + continue; + } + if let Some(val) = props.get(prop) { + apply_prop(doc, &mut n, field, val); + } + } + } + if let NodeKind::Instance { component: c, props } = &n.kind { + inst_props.insert(nid, props.clone()); + let mut e = enc; + e.push(nid); + enclosing.insert(nid, e); + if !matches!(orig.kind, NodeKind::Instance { component: oc, .. } if oc == *c) { + nested.push(nid); + } + } else { + enclosing.insert(nid, enc); + } + if let (NodeKind::Text(a), NodeKind::Text(b)) = (&orig.kind, &n.kind) + && a != b + { + remeasure.push(nid); + } + new_nodes.push(n); + } + for n in new_nodes { + let (id, parent) = (n.id, n.parent); + if insert_linked(doc, n).is_err() + && let Some(p) = parent.and_then(|p| doc.node_mut(p)) + { + p.children.retain(|c| *c != id); + } + } + // Root: follows the main except its own placement, name, size and overrides. + let order: Vec = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect(); + let mut fresh = comp.clone(); + fresh.id = inst; + fresh.parent = root.parent; + fresh.children = order; + fresh.transform = root.transform; + fresh.kind = root.kind.clone(); + carry_prop_bindings(&root, &mut fresh); + // A nested instance keeps mirroring its layer of the outer main. + let nested_copy = inside_instance(doc, inst); + fresh.source = if nested_copy { root.source } else { Some(component) }; + fresh.constraints = root.constraints; + fresh.child_layout = root.child_layout.clone(); + fresh.interactions = root.interactions.clone(); + fresh.exports = root.exports.clone(); + fresh.prop_refs = root.prop_refs.clone(); + fresh.plugin_data = root.plugin_data.clone(); + fresh.annotations = root.annotations.clone(); + // Code links belong to the main component; instances read them from it. + fresh.code_links.clear(); + // An instance's own dev resources (its main's are listed with them, not copied). + fresh.dev_resources = root.dev_resources.clone(); + fresh.measurements = root.measurements.clone(); + fresh.locked = root.locked; + fresh.visible = root.visible; + fresh.var_modes = root.var_modes.clone(); + fresh.is_mask = root.is_mask; + fresh.exposed = root.exposed; + fresh.guides = root.guides.clone(); + fresh.dev_status = root.dev_status; + fresh.dev_changed = root.dev_changed; + fresh.dev_note = root.dev_note.clone(); + fresh.constrain_proportions = root.constrain_proportions; + // How the instance combines in a boolean group it sits in is its own. + fresh.bool_op = root.bool_op; + // A swapped nested instance keeps the name its layer was given in the outer main. + let renamed = nested_copy + && root.source.and_then(|s| doc.node(s)).is_some_and(|s| match &s.kind { + NodeKind::Instance { component: c, .. } => s.name != default_instance_name(doc, *c), + _ => false, + }); + fresh.name = if renamed { root.name.clone() } else { default_instance_name(doc, component) }; + // Switched to another variant: its own overrides stay where both variants agreed. + if let Some(prev) = root.source.and_then(|p| doc.node(p)).filter(|p| sibling_variants(doc, p.id, component)) { + let differs = diff_groups(prev, &comp); + root.overrides.retain(|g| g == "name" || !differs.contains(&g.as_str())); + follow_variant_resizing(&mut fresh.child_layout, &prev.child_layout, &comp.child_layout); + } + carry_overrides(&root, &mut fresh, Carry::Root); + if let Some(r) = doc.node_mut(inst) { + *r = fresh; + } + // A resized instance: children follow their constraints (auto layout re-flows later). + let (iw, ih) = doc.node(inst).map(|n| (n.width, n.height)).unwrap_or((comp.width, comp.height)); + if (iw - comp.width).abs() > 1e-9 || (ih - comp.height).abs() > 1e-9 { + figcraft_layout::apply_constraints(doc, inst, comp.width, comp.height); + remeasure = map.values().copied().collect(); + } + // Geometry overrides of children win over constraints. + for s in &src { + let Some(nid) = map.get(s).copied() else { continue }; + let Some(o) = old.get(&nid) else { continue }; + // A frame that kept the size its own resizing gave it: its layers (copied at their main + // layers' places) follow their constraints from its main layer's size. + if o.overrides.contains("resizing") + && !o.overrides.contains("size") + && doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) + && let Some((mw, mh)) = doc.node(*s).map(|m| (m.width, m.height)) + { + figcraft_layout::apply_constraints(doc, nid, mw, mh); + } + if !o.overrides.contains("size") && !o.overrides.contains("position") { + continue; + } + remeasure.push(nid); + let Some((ow, oh)) = doc.node(nid).map(|n| (n.width, n.height)) else { continue }; + if o.overrides.contains("size") { + // Through set_size so vector networks scale with the box. + figcraft_layout::set_size(doc, nid, o.width, o.height); + if doc.node(nid).is_some_and(|n| n.kind.is_frame_like() && n.layout.is_none()) { + figcraft_layout::apply_constraints(doc, nid, ow, oh); + } + } + if let Some(n) = doc.node_mut(nid) { + carry_overrides(o, n, Carry::Geometry); + } + } + // Text whose characters or style differ from the main re-measures. + for nid in remeasure { + if let Some(n) = doc.node(nid) + && let Some((w, h)) = figcraft_text::auto_size(n) + && ((w - n.width).abs() > 1e-6 || (h - n.height).abs() > 1e-6) + { + figcraft_layout::set_size(doc, nid, w, h); + } + } + // Nested instances showing another main than the main's copy (swapped): rebuild from it, + // with the old layers below them this rebuild didn't reuse. + let unused: BTreeMap> = + old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect(); + for n in nested { + let mut below: BTreeMap> = unused + .iter() + .filter(|(_, x)| { + let mut cur = x.parent; + for _ in 0..=MAX_DEPTH * 4 { + match cur { + Some(p) if p == n => return true, + Some(p) => cur = unused.get(&p).and_then(|a| a.parent), + None => return false, + } + } + false + }) + .map(|(i, x)| (*i, x.clone())) + .collect(); + take_placing_overrides(doc, n, &old, &mut below); + sync_instance_depth(doc, n, depth + 1, below); + } +} + +/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the +/// copies just made of the outer main's, and `below`) takes the overrides held along the source +/// chain it had before this rebuild (`before`), through layers inside instances in the mains that +/// place `n`. Matched by name, they carry onto the new main's layers as the instance's own, so a +/// swap takes what the outer main set once: later rebuilds find layers mirroring the new main. +/// Geometry and slot content follow the new main; groups the layer overrides itself keep its value. +fn take_placing_overrides( + doc: &mut Document, + n: NodeId, + before: &BTreeMap>, + below: &mut BTreeMap>, +) { + let placing = placing_mains(doc, n); + for id in doc.subtree(n).into_iter().filter(|i| *i != n) { + let held = before.get(&id).map(|b| held_along(doc, b, &placing)).unwrap_or_default(); + if let Some(layer) = doc.node_mut(id) { + take(layer, &held); + } + } + for layer in below.values_mut() { + let held = held_along(doc, layer, &placing); + if !held.is_empty() { + take(std::sync::Arc::make_mut(layer), &held); + } + } +} + +/// The mains placing nested instance `n`: those of the layers it mirrors, outward. +fn placing_mains(doc: &Document, n: NodeId) -> BTreeSet { + let mut out = BTreeSet::new(); + let mut cur = doc.node(n).and_then(|x| x.source); + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node(c)).filter(|s| !matches!(s.kind, NodeKind::Component { .. })) else { break }; + out.extend(owning_component(doc, s.id)); + cur = s.source; + } + out +} + +/// The overrides but geometry and slot content held along `layer`'s source chain while it runs +/// through layers inside instances in `placing` mains, each with the nearest layer holding it. +fn held_along(doc: &Document, layer: &Node, placing: &BTreeSet) -> BTreeMap> { + let mut out = BTreeMap::new(); + let mut cur = layer.source; + for _ in 0..=MAX_DEPTH { + let Some(s) = cur.and_then(|c| doc.node_arc(c)) else { break }; + if !inside_instance(doc, s.id) || !owning_component(doc, s.id).is_some_and(|m| placing.contains(&m)) { + break; + } + for g in s.overrides.iter().filter(|g| !matches!(g.as_str(), "size" | "position" | SLOT)) { + out.entry(g.clone()).or_insert_with(|| s.clone()); + } + cur = s.source; + } + out +} + +/// Give `layer` the `held` groups it has no override of its own for. +fn take(layer: &mut Node, held: &BTreeMap>) { + for (g, from) in held { + if layer.overrides.insert(g.clone()) { + copy_group(from, layer, g); + } + } +} + +/// Whether main components `a` and `b` are two variants of one set. +fn sibling_variants(doc: &Document, a: NodeId, b: NodeId) -> bool { + a != b && variant_set(doc, a).is_some_and(|s| variant_set(doc, b) == Some(s)) +} + +/// Old copy `o` matched by name to main layer `s` of another variant (the instance switched +/// variants): `o` without the overrides of properties the two variants' layers differ in (`None` +/// when it keeps them all). Text layers keep every override. +fn variant_kept(doc: &Document, o: &Node, s: NodeId) -> Option { + let from = o.source.filter(|f| *f != s).and_then(|f| doc.node(f))?; + if o.overrides.is_empty() || matches!(o.kind, NodeKind::Text(_)) { + return None; + } + if !sibling_variants(doc, owning_component(doc, from.id)?, owning_component(doc, s)?) { + return None; + } + let differs = diff_groups(from, doc.node(s)?); + let mut m = o.clone(); + m.overrides.retain(|g| !differs.contains(&g.as_str())); + (m.overrides.len() != o.overrides.len()).then_some(m) +} + +/// Drop the overrides of instance `inst` but its text layers' (and its own size and the content of +/// its slots): what swapping it from the Assets panel keeps. +pub fn keep_text_overrides(doc: &mut Document, inst: NodeId) { + for d in old_layers(doc, inst).into_iter().chain([inst]) { + let Some(n) = doc.node(d) else { continue }; + let text = matches!(n.kind, NodeKind::Text(_)); + let keep = |g: &String| text || g == SLOT || (d == inst && g == "size"); + if !n.overrides.iter().all(keep) + && let Some(m) = doc.node_mut(d) + { + m.overrides.retain(keep); + } + } +} + +/// Where auto layout places and sizes a rebuilt copy `n` of main layer `orig`, keep the old +/// copy's `o` values (an instance's auto layout re-flows from its own content, so they usually +/// differ from the main's): unchanged layers then stay unchanged, and auto layout re-flows wherever +/// something that sizes or places layers did change. +fn keep_laid_out(doc: &Document, orig: &Node, o: &Node, n: &mut Node) { + if o.parent != n.parent || o.overrides.contains("size") || o.overrides.contains("position") { + return; + } + let flows = !n.child_layout.absolute && orig.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); + if flows { + // The main's rotation and skew, the old place. + let [a, b, c, d, _, _] = n.transform.as_coeffs(); + let [.., e, f] = o.transform.as_coeffs(); + n.transform = Affine::new([a, b, c, d, e, f]); + } + // Only layers whose shape is their box (a vector's points scale with it). + let boxed = n.kind.is_frame_like() || matches!(n.kind, NodeKind::Text(_) | NodeKind::Rectangle | NodeKind::Ellipse { .. }); + let derived = |s: Sizing| boxed && ((s == Sizing::Hug && n.layout.is_some()) || (s == Sizing::Fill && flows)); + if derived(n.child_layout.sizing_h) { + n.width = o.width; + } + if derived(n.child_layout.sizing_v) { + n.height = o.height; + } +} + +/// The layers of instance `inst` a rebuild matches against (every layer below it but the content +/// of slots holding its own), ascending. +fn old_layers(doc: &Document, inst: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack = vec![inst]; + while let Some(id) = stack.pop() { + let Some(n) = doc.node(id) else { continue }; + for c in &n.children { + out.push(*c); + if out.len() > 1_000_000 { + break; + } + if doc.node(*c).is_some_and(|cn| !holds_content(cn)) { + stack.push(*c); + } + } + } + out.sort(); + out.dedup(); + out +} + +/// Insert a node whose parent/children links are already set (used when rebuilding instances). +pub(super) fn insert_linked(doc: &mut Document, n: Node) -> Result<(), DocError> { + let parent = n.parent.ok_or_else(|| DocError::Invalid("no parent".into()))?; + let id = n.id; + let mut n = n; + let kids = std::mem::take(&mut n.children); + doc.insert(n, parent, None)?; + if let Some(m) = doc.node_mut(id) { + m.children = kids; + } + // The parent may already list us (its children were copied with ids): keep the first. + if let Some(p) = doc.node_mut(parent) { + let mut seen = BTreeSet::new(); + p.children.retain(|c| seen.insert(*c)); + } + Ok(()) +} diff --git a/crates/engine/src/components_tests.rs b/crates/engine/src/assets/components/tests.rs similarity index 100% rename from crates/engine/src/components_tests.rs rename to crates/engine/src/assets/components/tests.rs diff --git a/crates/engine/src/assets/components/variants.rs b/crates/engine/src/assets/components/variants.rs new file mode 100644 index 00000000..40ac23ea --- /dev/null +++ b/crates/engine/src/assets/components/variants.rs @@ -0,0 +1,147 @@ +use super::*; + +/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then +/// the left-most). +pub fn default_variant(doc: &Document, set: NodeId) -> Option { + let corner = |c: &NodeId| { + doc.node(*c).filter(|n| matches!(n.kind, NodeKind::Component { .. })).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x + y, y, x)) + }; + let kids = &doc.node(set)?.children; + kids.iter() + .copied() + .filter_map(|c| corner(&c).map(|k| (k, c))) + .min_by(|(a, _), (b, _)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .map(|(_, c)| c) +} + +/// Variants of a set sharing one combination of property values (each group two or more, in +/// layer order): a conflict to resolve by giving each a unique combination. +pub fn variant_conflicts(doc: &Document, set: NodeId) -> Vec> { + let mut by: BTreeMap, Vec> = BTreeMap::new(); + for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { + if let Some(n) = doc.node(c).filter(|n| matches!(n.kind, NodeKind::Component { .. })) { + by.entry(variant_props(&n.name)).or_default().push(c); + } + } + by.into_values().filter(|v| v.len() > 1).collect() +} + +/// Parse a variant name "Size=Large, State=Hover" into pairs. +pub fn variant_props(name: &str) -> BTreeMap { + name.split(',') + .filter_map(|kv| { + let (k, v) = kv.split_once('=')?; + Some((k.trim().to_string(), v.trim().to_string())) + }) + .collect() +} + +/// Format variant pairs as a layer name, in the given property order (others after, sorted). +pub fn variant_name(pairs: &BTreeMap, order: &[String]) -> String { + let mut keys: Vec<&String> = order.iter().filter(|k| pairs.contains_key(*k)).collect(); + keys.extend(pairs.keys().filter(|k| !order.contains(k))); + keys.iter().filter_map(|k| pairs.get(*k).map(|v| format!("{k}={v}"))).collect::>().join(", ") +} + +/// Variant properties of a set: property → values, in first-seen order. +pub fn variant_options(doc: &Document, set: NodeId) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = Vec::new(); + for c in doc.node(set).map(|s| s.children.clone()).unwrap_or_default() { + let Some(cn) = doc.node(c) else { continue }; + for kv in cn.name.split(',') { + let Some((k, v)) = kv.split_once('=') else { continue }; + let (k, v) = (k.trim().to_string(), v.trim().to_string()); + match out.iter_mut().find(|(p, _)| *p == k) { + Some((_, vals)) => { + if !vals.contains(&v) { + vals.push(v); + } + } + None => out.push((k, vec![v])), + } + } + } + out +} + +/// The variant in `set` matching `wanted` best: all given properties equal, or else the most. +pub fn find_variant(doc: &Document, set: NodeId, wanted: &BTreeMap) -> Option { + let s = doc.node(set)?; + let score = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).map(|p| wanted.iter().filter(|(k, v)| p.get(*k) == Some(*v)).count()); + if let Some(exact) = s.children.iter().copied().find(|c| score(c) == Some(wanted.len())) { + return Some(exact); + } + s.children.iter().copied().filter(|c| score(c).is_some()).max_by_key(|c| score(c).unwrap_or(0)) +} + +/// Check that every instance mirrors its main's layer structure (tests and diagnostics): each +/// layer's `source` is the main layer at the same place, swapped nested instances mirror their +/// own main, slots holding the instance's own content aren't compared. Returns the problems found. +pub fn mirror_issues(doc: &Document) -> Vec { + doc.nodes() + .filter(|n| matches!(n.kind, NodeKind::Instance { .. }) && !inside_instance(doc, n.id)) + .flat_map(|n| instance_issues(doc, n.id)) + .collect() +} + +/// [`mirror_issues`] for one instance. +pub(super) fn instance_issues(doc: &Document, id: NodeId) -> Vec { + fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec) { + if depth > MAX_DEPTH || holds_content(inst) { + return; + } + if inst.children.len() != main.children.len() { + out.push(format!("{} has {} layers, its main {} has {}", inst.id, inst.children.len(), main.id, main.children.len())); + return; + } + for (a, b) in inst.children.iter().zip(&main.children) { + let (Some(an), Some(bn)) = (doc.node(*a), doc.node(*b)) else { continue }; + if an.source != Some(bn.id) { + out.push(format!("{} mirrors {:?}, expected {}", an.id, an.source, bn.id)); + continue; + } + match (&an.kind, &bn.kind) { + (NodeKind::Instance { component: x, .. }, NodeKind::Instance { component: y, .. }) if x != y => { + if let Some(m) = doc.node(*x) { + walk(doc, an, m, depth + 1, out); + } + } + _ => walk(doc, an, bn, depth + 1, out), + } + } + } + let mut out = Vec::new(); + if let Some(n) = doc.node(id) + && let NodeKind::Instance { component, .. } = &n.kind + && let Some(m) = doc.node(*component) + { + walk(doc, n, m, 0, &mut out); + } + out +} + +/// The nested instance copies inside instance `id` whose main-component counterpart is exposed +/// (top-most first; an exposed instance's own exposed nested instances follow it). +pub fn exposed_copies(doc: &Document, id: NodeId) -> Vec { + let mut out = Vec::new(); + let mut stack: Vec = doc.node(id).map(|n| n.children.iter().rev().copied().collect()).unwrap_or_default(); + let mut seen = 0usize; + while let Some(c) = stack.pop() { + seen += 1; + if seen > 100_000 { + break; + } + let Some(n) = doc.node(c) else { continue }; + // A hidden exposed instance hides its properties too. + let exposed = + n.visible && matches!(n.kind, NodeKind::Instance { .. }) && n.source.and_then(|src| doc.node(src)).is_some_and(|src| src.exposed); + if exposed { + out.push(c); + } + // Only exposed nested instances pass their own exposed instances up. + if !matches!(n.kind, NodeKind::Instance { .. }) || exposed { + stack.extend(n.children.iter().rev().copied()); + } + } + out +} diff --git a/crates/engine/src/library.rs b/crates/engine/src/assets/library.rs similarity index 99% rename from crates/engine/src/library.rs rename to crates/engine/src/assets/library.rs index 40d6b3ca..56b0fc07 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/assets/library.rs @@ -1331,5 +1331,4 @@ pub fn published_variables(lib: &PublishedLibrary) -> impl Iterator Re } #[cfg(test)] -#[path = "library_swap_tests.rs"] mod tests; diff --git a/crates/engine/src/library_swap_tests.rs b/crates/engine/src/assets/library_swap/tests.rs similarity index 100% rename from crates/engine/src/library_swap_tests.rs rename to crates/engine/src/assets/library_swap/tests.rs diff --git a/crates/engine/src/playground.rs b/crates/engine/src/assets/playground.rs similarity index 100% rename from crates/engine/src/playground.rs rename to crates/engine/src/assets/playground.rs diff --git a/crates/engine/src/style_vars.rs b/crates/engine/src/assets/style_vars.rs similarity index 100% rename from crates/engine/src/style_vars.rs rename to crates/engine/src/assets/style_vars.rs diff --git a/crates/engine/src/token_roles.rs b/crates/engine/src/assets/token_roles.rs similarity index 100% rename from crates/engine/src/token_roles.rs rename to crates/engine/src/assets/token_roles.rs diff --git a/crates/engine/src/automation.rs b/crates/engine/src/automation.rs new file mode 100644 index 00000000..acf11e5c --- /dev/null +++ b/crates/engine/src/automation.rs @@ -0,0 +1,5 @@ +pub mod batch; +pub mod doc_urls; +pub mod file_access; +pub mod inspect; +pub mod params; diff --git a/crates/engine/src/batch.rs b/crates/engine/src/automation/batch.rs similarity index 100% rename from crates/engine/src/batch.rs rename to crates/engine/src/automation/batch.rs diff --git a/crates/engine/src/doc_urls.rs b/crates/engine/src/automation/doc_urls.rs similarity index 100% rename from crates/engine/src/doc_urls.rs rename to crates/engine/src/automation/doc_urls.rs diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/automation/file_access.rs similarity index 99% rename from crates/engine/src/file_access.rs rename to crates/engine/src/automation/file_access.rs index 7a8ab8c9..f741e21c 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/automation/file_access.rs @@ -306,5 +306,4 @@ pub fn create_dir_all(path: impl AsRef) -> Result<()> { } #[cfg(test)] -#[path = "file_access_tests.rs"] mod tests; diff --git a/crates/engine/src/file_access_tests.rs b/crates/engine/src/automation/file_access/tests.rs similarity index 100% rename from crates/engine/src/file_access_tests.rs rename to crates/engine/src/automation/file_access/tests.rs diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/automation/inspect.rs similarity index 100% rename from crates/engine/src/inspect.rs rename to crates/engine/src/automation/inspect.rs diff --git a/crates/engine/src/params.rs b/crates/engine/src/automation/params.rs similarity index 100% rename from crates/engine/src/params.rs rename to crates/engine/src/automation/params.rs diff --git a/crates/engine/src/board.rs b/crates/engine/src/board.rs deleted file mode 100644 index 5408c6c9..00000000 --- a/crates/engine/src/board.rs +++ /dev/null @@ -1,1495 +0,0 @@ -//! Boards: files for thinking together, with sticky notes, shapes with text, connectors, ink and -//! stamps. Board objects are ordinary layers with a [`BoardItem`] on them; [`settle_text`] and -//! [`settle_routes`] keep what follows from it (a sticky's text size, a shape's text room, -//! connectors' routes and their labels) up to date in the normalize pass, so local edits, undo and -//! live-session edits from others all end in the same geometry. - -use std::collections::{BTreeMap, BTreeSet}; - -use figcraft_color::{BlendMode, Color, Paint}; -use figcraft_doc::{ - AutoLayout, BOARD_COLORS, BoardItem, BoardShape, Connector, Constraints, CrossAlign, DocError, Document, Effect, EffectKind, End, FileKind, - HConstraint, LayoutMode, MainAlign, Node, NodeId, NodeKind, Routing, STAMP_SIZE, Shadow, Side, Sizing, Stamp, StrokeAlign, StrokeCap, StrokeJoin, - TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color, -}; -use figcraft_geom::kurbo::PathEl; -use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2}; -use serde_json::{Value, json}; - -use crate::cmd::CommandSpec; -use crate::params::{boolean, color_of, get, id, id_of, num, num_or, opt_id, req_num, req_string, req_targets, string}; -use crate::tools::{Gesture, PointerEvent, ToolId}; -use crate::{EngineError, Result, Session, bad, spec, textedit}; - -/// Why a board command refuses on a design file. -pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board"; -/// Why prototyping and Dev Mode refuse on a board. -pub const NOT_ON_BOARDS: &str = "boards have no prototypes or Dev Mode: use a design file for those"; - -/// A sticky note's side (it grows taller for long notes). -pub const STICKY: f64 = 200.0; -const STICKY_PAD: f64 = 16.0; -/// The room between a sticky and the next one made beside it. -const STICKY_GAP: f64 = 24.0; -/// A new shape's size. -const SHAPE: f64 = 160.0; -/// Text on board objects, and the author line under a sticky's note. -pub(crate) const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); -const AUTHOR_INK: Color = Color::from_rgb8_const(0x5C, 0x5A, 0x55); -/// Connectors' default colour and weight. -const LINE: Color = Color::from_rgb8_const(0x3A, 0x39, 0x36); -const LINE_WEIGHT: f64 = 2.0; -/// Marker and highlighter strokes: (weight, opacity). -const MARKER: (f64, f32) = (4.0, 1.0); -const HIGHLIGHTER: (f64, f32) = (20.0, 0.45); -/// How far the eraser reaches around the pointer (screen pixels). -const ERASER_PX: f64 = 8.0; -/// The text sizes the contextual bar offers. -pub const TEXT_SIZES: [(&str, f64); 4] = [("S", 12.0), ("M", 16.0), ("L", 24.0), ("XL", 36.0)]; - -/// The board tools' choices: the colour new stickies get, the shape and stamp the tools place, -/// how new connectors run and the marker's and highlighter's colours. -#[derive(Clone, Debug)] -pub struct BoardTool { - pub sticky: Color, - pub shape: BoardShape, - pub stamp: Stamp, - pub routing: Routing, - pub marker: Color, - pub highlighter: Color, -} - -impl Default for BoardTool { - fn default() -> Self { - let first = BOARD_COLORS.first().map_or(Color::WHITE, |c| c.1); - Self { - sticky: first, - shape: BoardShape::Rectangle, - stamp: Stamp::ThumbsUp, - routing: Routing::Elbow, - marker: INK, - highlighter: Color::from_rgb8_const(0xFF, 0xD2, 0x1F), - } - } -} - -pub fn specs() -> Vec { - vec![ - spec!( - "board.sticky", - "Sticky note", - "{x, y (its top-left, in the parent), text?, color? (yellow|orange|red|pink|violet|blue|green|gray or hex; default the sticky tool's), author? (a name, or false for none; default yours), parent?} → {id, text}", - true, - sticky_cmd - ), - spec!( - "board.nextSticky", - "Next sticky note", - "{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it", - true, - next_sticky - ), - spec!( - "board.shape", - "Shape with text", - "{shape: rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron, x, y, width?, height? (default 160), text?, color?, parent?} → {id, text}", - true, - shape_cmd - ), - spec!( - "board.connector", - "Connector", - "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", - true, - connector_cmd - ), - spec!( - "board.reattach", - "Reattach connector", - "{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left)} → {id} moves one end of a connector", - true, - reattach - ), - spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd), - spec!( - "board.stamp", - "Stamp", - "{x, y (its centre on the page), stamp: thumbsUp|heart|plusOne|star|question|check} → {id} a badge with your name on hover", - true, - stamp_cmd - ), - spec!( - "board.ink", - "Marker stroke", - "{path (SVG path data on the page) | points [[x,y],..], highlighter? (wide and see-through), color?, select? (default true)} → {id}", - true, - ink_cmd - ), - spec!( - "board.setColor", - "Board color", - "{ids?, color (a board colour name or hex)} stickies and shapes take it as their fill, connectors and ink as their line", - true, - set_color - ), - spec!( - "board.setStyle", - "Board style", - "{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow|curved, startCap?, endCap? (connectors)}", - true, - set_style - ), - spec!("tool.sticky", "Sticky note", "{color?} (a click places a sticky and starts typing in it)", false, |s, p| { - if let Some(c) = color_param("tool.sticky", p)? { - s.tool.board.sticky = c; - } - board_tool(s, ToolId::Sticky) - }), - spec!("tool.shape", "Shapes", "{shape?} (a click places a shape with text)", false, |s, p| { - if let Some(v) = string(p, "shape") { - s.tool.board.shape = shape_of("tool.shape", &v)?; - } - board_tool(s, ToolId::BoardShape) - }), - spec!("tool.connector", "Connector", "{style?: straight|elbow|curved} (drag from one object to another)", false, |s, p| { - if let Some(v) = string(p, "style") { - s.tool.board.routing = routing_of("tool.connector", &v)?; - } - board_tool(s, ToolId::Connector) - }), - spec!("tool.marker", "Marker", "{color?}", false, |s, p| { - if let Some(c) = color_param("tool.marker", p)? { - s.tool.board.marker = c; - } - board_tool(s, ToolId::Marker) - }), - spec!("tool.highlighter", "Highlighter", "{color?}", false, |s, p| { - if let Some(c) = color_param("tool.highlighter", p)? { - s.tool.board.highlighter = c; - } - board_tool(s, ToolId::Highlighter) - }), - spec!("tool.eraser", "Eraser", "{} (drag over marker and highlighter strokes to remove them)", false, |s, _| board_tool(s, ToolId::Eraser)), - spec!("tool.stamp", "Stamp", "{stamp?} (a click places the stamp)", false, |s, p| { - if let Some(v) = string(p, "stamp") { - s.tool.board.stamp = stamp_of("tool.stamp", &v)?; - } - board_tool(s, ToolId::Stamp) - }), - ] -} - -pub fn is_board(doc: &Document) -> bool { - doc.kind == FileKind::Board -} - -/// Refuse `cmd` unless the active file is a board. -pub(crate) fn require(s: &Session, cmd: &str) -> Result<()> { - if s.doc().is_ok_and(is_board) { Ok(()) } else { Err(EngineError::Disabled(cmd.into(), NEEDS_BOARD.into())) } -} - -/// Prototyping and Dev Mode: not on boards. -fn design_only(cmd: &str) -> bool { - cmd.starts_with("proto.") || cmd.starts_with("dev.") || matches!(cmd, "view.prototype" | "view.designPrototype" | "view.devMode") -} - -/// Refuse `cmd` on a board when it is prototyping or Dev Mode. -pub(crate) fn refuse_on_board(s: &Session, cmd: &str) -> Result<()> { - if design_only(cmd) && s.doc().is_ok_and(is_board) { Err(EngineError::Disabled(cmd.into(), NOT_ON_BOARDS.into())) } else { Ok(()) } -} - -fn board_tool(s: &mut Session, t: ToolId) -> Result { - require(s, &format!("tool.{}", t.id()))?; - crate::cmd::view::tool(s, t) -} - -fn shape_of(cmd: &str, v: &str) -> Result { - BoardShape::from_id(v) - .ok_or_else(|| bad(cmd, format!("unknown shape `{v}` (rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron)"))) -} - -fn stamp_of(cmd: &str, v: &str) -> Result { - Stamp::from_id(v).ok_or_else(|| bad(cmd, format!("unknown stamp `{v}` (thumbsUp|heart|plusOne|star|question|check)"))) -} - -fn routing_of(cmd: &str, v: &str) -> Result { - Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight, elbow or curved, not `{v}`"))) -} - -fn cap_of(cmd: &str, p: &Value, key: &str) -> Result> { - match get(p, key) { - None | Some(Value::Null) => Ok(None), - Some(v) => crate::cmd::props::enum_of::(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}`: unknown cap"))), - } -} - -/// `color`: a board colour's name, or any colour. -fn color_param(cmd: &str, p: &Value) -> Result> { - match get(p, "color") { - None | Some(Value::Null) => Ok(None), - Some(Value::String(s)) if board_color(s).is_some() => Ok(board_color(s)), - Some(v) => color_of(v).map(Some).ok_or_else(|| bad(cmd, "`color` is a board colour (yellow, blue…) or a colour such as #FFE78A")), - } -} - -/// The deeper shade of a board colour lines are drawn in (light tints are too faint as lines). -fn line_shade(c: Color) -> Color { - let palette = BOARD_COLORS.iter().any(|(_, b)| *b == c); - if palette { Color::new(c.r * 0.55, c.g * 0.55, c.b * 0.55, c.a) } else { c } -} - -/// Your name on what you make: the name from Preferences, else the one a live session gave you -/// (none for the default name outside a session). -pub(crate) fn author(s: &Session) -> String { - let name = crate::live::display_name(&s.prefs); - if name.is_empty() { s.live().info.as_ref().map(|i| i.name.clone()).unwrap_or_default() } else { name } -} - -/// The parent a command places into, and that parent's page-to-local transform. -fn parent_of(cmd: &str, s: &Session, p: &Value) -> Result { - let page = s.page()?; - let parent = opt_id(cmd, p, "parent")?.unwrap_or(page); - let doc = s.doc()?; - let n = doc.get(parent)?; - let takes = n.is_page() || matches!(n.kind, NodeKind::Section { .. }) || (n.kind.is_frame_like() && !n.is_board_atom()); - if !takes { - return Err(bad(cmd, "`parent` is the page, a section or a frame")); - } - Ok(parent) -} - -/// A new layer at the top of `parent`, at `at` in it, made by `f`. -fn add( - doc: &mut Document, - parent: NodeId, - kind: NodeKind, - name: &str, - at: (f64, f64), - size: (f64, f64), - f: impl FnOnce(&mut Node), -) -> Result { - let id = doc.alloc_id(); - let mut n = Node::new(id, kind, name, size.0, size.1); - n.transform = Affine::translate(at); - f(&mut n); - doc.insert(n, parent, None)?; - Ok(id) -} - -/// A soft shadow under stickies and stamps. -fn soft_shadow() -> Effect { - Effect { - kind: EffectKind::DropShadow(Shadow { color: Color::new(0.0, 0.0, 0.0, 0.16), offset: (0.0, 2.0), radius: 6.0, ..Shadow::default() }), - visible: true, - } -} - -/// A text layer filling its frame's width (the frame's auto layout places it). -pub(crate) fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result { - let first: String = text.lines().next().unwrap_or("").chars().take(64).collect(); - let name = if first.trim().is_empty() { "Text" } else { first.as_str() }; - let mut td = TextData { - characters: crate::cmd::props::normalize_line_endings(text), - auto_resize: TextAutoResize::Height, - align_h: align, - ..TextData::default() - }; - td.style.font_size = size; - let id = add(doc, parent, NodeKind::Text(td), name, (0.0, 0.0), (width.max(1.0), size * 1.4), |n| { - n.fills = vec![Paint::solid(color)]; - n.child_layout.sizing_h = Sizing::Fill; - })?; - if let Some(n) = doc.node(id) - && let Some((w, h)) = figcraft_text::auto_size(n) - && let Some(n) = doc.node_mut(id) - { - (n.width, n.height) = (w, h); - } - Ok(id) -} - -/// The text size a sticky's note gets: smaller the more it holds (by characters and lines, so -/// every copy of the file picks the same size whatever fonts it has). -pub fn sticky_font_size(text: &str) -> f64 { - let weight = text.chars().count() + 24 * text.matches('\n').count(); - match weight { - 0..=24 => 24.0, - 25..=64 => 20.0, - 65..=140 => 16.0, - 141..=280 => 13.0, - _ => 11.0, - } -} - -/// A sticky note's layers: the frame, the note and (when known) its author. -fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, color: Color, author: &str) -> Result<(NodeId, NodeId)> { - let id = add(doc, parent, NodeKind::Frame, "Sticky", at, (STICKY, STICKY), |n| { - n.fills = vec![Paint::solid(color)]; - n.corners.radii = [2.0; 4]; - n.effects = vec![soft_shadow()]; - n.clip = false; - n.layout = Some(AutoLayout { - mode: LayoutMode::Vertical, - gap: 8.0, - padding: [STICKY_PAD; 4], - main_align: MainAlign::SpaceBetween, - ..AutoLayout::default() - }); - n.child_layout.sizing_v = Sizing::Hug; - n.child_layout.min_h = Some(STICKY); - n.board = Some(BoardItem::Sticky { author: author.to_string() }); - })?; - let inner = STICKY - 2.0 * STICKY_PAD; - let note = add_text(doc, id, text, sticky_font_size(text), INK, TextAlignH::Left, inner)?; - if !author.is_empty() { - let a = add_text(doc, id, author, 10.0, AUTHOR_INK, TextAlignH::Left, inner)?; - if let Some(n) = doc.node_mut(a) { - n.name = "Author".into(); - } - } - Ok((id, note)) -} - -fn sticky_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.sticky"; - require(s, cmd)?; - let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); - let author = match get(p, "author") { - Some(Value::String(a)) => a.trim().chars().filter(|c| !c.is_control()).take(64).collect(), - Some(Value::Bool(false)) => String::new(), - _ => author(s), - }; - let parent = parent_of(cmd, s, p)?; - let text = string(p, "text").unwrap_or_default(); - let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); - let (id, note) = make_sticky(s.doc_mut()?, parent, at, &text, color, &author)?; - s.set_selection(vec![id]); - Ok(json!({"id": id, "text": note})) -} - -/// The first text layer inside board object `id` (its note or label). -pub fn text_of(doc: &Document, id: NodeId) -> Option { - let n = doc.node(id)?; - n.board.as_ref().filter(|b| b.has_text())?; - n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_)))) -} - -/// The board object whose text `text` is, if any. -pub fn owner_of_text(doc: &Document, text: NodeId) -> Option { - let p = doc.node(text)?.parent?; - (text_of(doc, p) == Some(text)).then_some(p) -} - -/// Start typing in board object `id`'s text (all of it selected). False when it has none. -pub fn edit_text(s: &mut Session, id: NodeId) -> Result { - let Some(t) = s.doc().ok().and_then(|d| text_of(d, id)) else { return Ok(false) }; - textedit::begin(s, t, None, false)?; - textedit::key(s, "SelectAll", false, false, false)?; - Ok(true) -} - -fn next_sticky(s: &mut Session, p: &Value) -> Result { - let cmd = "board.nextSticky"; - require(s, cmd)?; - let editing = s.ds()?.text_edit.as_ref().map(|t| t.id); - let doc = s.doc()?; - let from = id(p, "id") - .or_else(|| editing.and_then(|t| owner_of_text(doc, t))) - .or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i))) - .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?; - let n = doc.get(from)?; - if !matches!(n.board, Some(BoardItem::Sticky { .. })) { - return Err(bad(cmd, "that isn't a sticky")); - } - let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky); - let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?; - let at = n.transform.translation(); - let (dx, dy) = match string(p, "side").as_deref().unwrap_or("right") { - "right" => (n.width + STICKY_GAP, 0.0), - "left" => (-(STICKY + STICKY_GAP), 0.0), - "below" => (0.0, n.height + STICKY_GAP), - "above" => (0.0, -(STICKY + STICKY_GAP)), - other => return Err(bad(cmd, format!("`side` is right, below, left or above, not `{other}`"))), - }; - let author = author(s); - if editing.is_some() { - textedit::end(s)?; - } - let (id, note) = make_sticky(s.doc_mut()?, parent, (at.x + dx, at.y + dy), "", color, &author)?; - s.set_selection(vec![id]); - if boolean(p, "edit") == Some(true) { - // Typing goes on in the new sticky once this step is recorded. - s.request_ui("board.editText", json!({"id": id})); - } - Ok(json!({"id": id, "text": note})) -} - -/// A shape with text's layers: the frame, its outline and its text. -fn make_shape( - doc: &mut Document, - parent: NodeId, - at: (f64, f64), - size: (f64, f64), - shape: BoardShape, - text: &str, - color: Color, -) -> Result<(NodeId, NodeId)> { - let (w, h) = size; - let id = add(doc, parent, NodeKind::Frame, shape.label(), at, size, |n| { - n.fills.clear(); - n.clip = false; - n.layout = Some(AutoLayout { - mode: LayoutMode::Vertical, - padding: shape_padding(shape, w, h), - main_align: MainAlign::Center, - cross_align: CrossAlign::Center, - ..AutoLayout::default() - }); - n.board = Some(BoardItem::Shape { shape }); - })?; - add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&shape.path(w, h)) }, "Outline", (0.0, 0.0), size, |n| { - n.fills = vec![Paint::solid(color)]; - n.strokes = vec![Paint::solid(line_shade(color))]; - n.stroke.weight = 1.5; - n.stroke.align = StrokeAlign::Inside; - n.child_layout.absolute = true; - n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::Scale }; - })?; - let [_, r, _, l] = shape_padding(shape, w, h); - let label = add_text(doc, id, text, 16.0, INK, TextAlignH::Center, (w - l - r).max(1.0))?; - Ok((id, label)) -} - -fn shape_padding(shape: BoardShape, w: f64, h: f64) -> [f64; 4] { - let [t, r, b, l] = shape.text_insets(); - [t * h, r * w, b * h, l * w] -} - -fn shape_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.shape"; - require(s, cmd)?; - let shape = match string(p, "shape") { - Some(v) => shape_of(cmd, &v)?, - None => s.tool.board.shape, - }; - let size = |k| { - num(p, k) - .map_or(Ok(SHAPE), |v| if (1.0..=100_000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{k}` is a size from 1 to 100000"))) }) - }; - let (w, h) = (size("width")?, size("height")?); - let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); - let parent = parent_of(cmd, s, p)?; - let text = string(p, "text").unwrap_or_default(); - let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); - let (id, label) = make_shape(s.doc_mut()?, parent, at, (w, h), shape, &text, color)?; - s.set_selection(vec![id]); - Ok(json!({"id": id, "text": label})) -} - -fn stamp_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.stamp"; - require(s, cmd)?; - let stamp = match string(p, "stamp") { - Some(v) => stamp_of(cmd, &v)?, - None => s.tool.board.stamp, - }; - let (x, y) = (req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); - let author = author(s); - let page = s.page()?; - let half = STAMP_SIZE / 2.0; - let doc = s.doc_mut()?; - let id = add(doc, page, NodeKind::Frame, stamp.label(), (x - half, y - half), (STAMP_SIZE, STAMP_SIZE), |n| { - n.fills = vec![Paint::solid(stamp.color())]; - n.corners.radii = [half; 4]; - n.effects = vec![soft_shadow()]; - n.clip = false; - n.board = Some(BoardItem::Stamp { stamp, author }); - })?; - for part in stamp.mark() { - let path = figcraft_geom::BezPath::from_svg(part.d).map_err(|e| EngineError::Other(format!("stamp mark: {e}")))?; - add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&path) }, "Mark", (0.0, 0.0), (STAMP_SIZE, STAMP_SIZE), |n| { - if part.filled { - n.fills = vec![Paint::solid(Color::WHITE)]; - } else { - n.fills.clear(); - n.strokes = vec![Paint::solid(Color::WHITE)]; - n.stroke.weight = 3.0; - (n.stroke.cap_start, n.stroke.cap_end) = (StrokeCap::Round, StrokeCap::Round); - n.stroke.join = StrokeJoin::Round; - n.stroke.align = StrokeAlign::Center; - } - })?; - } - s.set_selection(vec![id]); - Ok(json!({"id": id})) -} - -fn ink_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.ink"; - require(s, cmd)?; - let high = boolean(p, "highlighter").unwrap_or(false); - let color = color_param(cmd, p)?.unwrap_or(if high { s.tool.board.highlighter } else { s.tool.board.marker }); - let (weight, opacity) = if high { HIGHLIGHTER } else { MARKER }; - if get(p, "path").is_none() && get(p, "points").is_none() { - return Err(bad(cmd, "`path` or `points` is required")); - } - let page = s.page()?; - let select = boolean(p, "select").unwrap_or(true); - let mut q = json!({"type": "vector", "parent": page, "closed": false, "select": select, "name": if high { "Highlight" } else { "Ink" }, "props": { - "fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center", - }}); - for k in ["path", "points"] { - if let (Some(v), Some(o)) = (get(p, k), q.as_object_mut()) { - o.insert(k.into(), v.clone()); - } - } - let made = crate::cmd::create::create(s, &q)?; - let id = made.get("id").and_then(id_of).ok_or_else(|| EngineError::Other("no stroke made".into()))?; - let n = s.doc_mut()?.get_mut(id)?; - n.opacity = opacity; - if high { - n.blend = BlendMode::Multiply; - } - n.board = Some(BoardItem::Ink); - Ok(json!({"id": id})) -} - -/// One connector end from the params: a layer on this page, or a point. -fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result { - let side = match string(p, side_key) { - Some(v) => Side::from_id(&v).ok_or_else(|| bad(cmd, format!("`{side_key}` is auto, top, right, bottom or left")))?, - None => Side::Auto, - }; - match get(p, key) { - Some(Value::Object(o)) if o.contains_key("x") => { - let (x, y) = (num(&Value::Object(o.clone()), "x"), num(&Value::Object(o.clone()), "y")); - match (x, y) { - (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Ok(End::Point { x, y }), - _ => Err(bad(cmd, format!("`{key}` needs finite x and y"))), - } - } - Some(v) => { - let node = id_of(v).ok_or_else(|| bad(cmd, format!("`{key}` is a layer id or {{x, y}}")))?; - let doc = s.doc()?; - let n = doc.get(node)?; - if n.is_page() || doc.page_of(node) != Some(s.page()?) { - return Err(bad(cmd, format!("`{key}` must be a layer on this page"))); - } - if matches!(n.board, Some(BoardItem::Connector(_))) { - return Err(bad(cmd, format!("`{key}` can't be another connector"))); - } - Ok(End::Node { node, side }) - } - None => Err(bad(cmd, format!("`{key}` is required"))), - } -} - -fn connector_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.connector"; - require(s, cmd)?; - let from = end_param(cmd, s, p, "from", "fromSide")?; - let to = end_param(cmd, s, p, "to", "toSide")?; - if from.node().is_some() && from.node() == to.node() { - return Err(bad(cmd, "connect two different layers")); - } - let routing = match string(p, "style") { - Some(v) => routing_of(cmd, &v)?, - None => s.tool.board.routing, - }; - let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None); - let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow); - let c = Connector { from, to, routing }; - let page = s.page()?; - let doc = s.doc_mut()?; - let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; - if pts.windows(2).all(|w| w.first() == w.get(1)) { - return Err(bad(cmd, "the ends are in the same place")); - } - let id = add(doc, page, NodeKind::Vector { network: VectorNetwork::default() }, "Connector", (0.0, 0.0), (0.0, 0.0), |n| { - n.fills.clear(); - n.strokes = vec![Paint::solid(LINE)]; - n.stroke.weight = LINE_WEIGHT; - n.stroke.join = StrokeJoin::Round; - n.stroke.cap_start = start; - n.stroke.cap_end = end; - n.board = Some(BoardItem::Connector(c)); - })?; - place_route(doc, id, &pts, routing); - let mut out = json!({"id": id}); - if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) { - out["label"] = json!(set_label(doc, id, &text)?); - } - s.set_selection(vec![id]); - Ok(out) -} - -/// The connector `id` names: a connector, or the label on one. -fn connector_of(cmd: &str, doc: &Document, id: NodeId) -> Result<(NodeId, Connector)> { - match doc.get(id)?.board.clone() { - Some(BoardItem::Connector(c)) => Ok((id, c)), - Some(BoardItem::Label { connector }) => match doc.get(connector)?.board.clone() { - Some(BoardItem::Connector(c)) => Ok((connector, c)), - _ => Err(bad(cmd, "that isn't a connector")), - }, - _ => Err(bad(cmd, "that isn't a connector")), - } -} - -fn reattach(s: &mut Session, p: &Value) -> Result { - let cmd = "board.reattach"; - require(s, cmd)?; - let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; - let (id, mut c) = connector_of(cmd, s.doc()?, id)?; - let from = match string(p, "end").as_deref() { - Some("from") => true, - Some("to") => false, - _ => return Err(bad(cmd, "`end` is from or to")), - }; - let end = end_param(cmd, s, p, "target", "side")?; - let other = if from { c.to } else { c.from }; - if end.node().is_some() && end.node() == other.node() { - return Err(bad(cmd, "connect two different layers")); - } - *(if from { &mut c.from } else { &mut c.to }) = end; - let doc = s.doc_mut()?; - let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; - if pts.windows(2).all(|w| w.first() == w.get(1)) { - return Err(bad(cmd, "the ends are in the same place")); - } - doc.get_mut(id)?.board = Some(BoardItem::Connector(c)); - place_route(doc, id, &pts, c.routing); - Ok(json!({"id": id})) -} - -/// A connector end as `board.connector` and `board.reattach` take it. -fn end_json(e: End) -> Value { - match e { - End::Node { node, .. } => json!(node), - End::Point { x, y } => json!({"x": x, "y": y}), - } -} - -/// The end of connector `id` that stays while its other one (`from`: its start) is dragged. -fn other_end(doc: &Document, id: NodeId, from: bool) -> Option { - match &doc.node(id)?.board { - Some(BoardItem::Connector(c)) => Some(if from { c.to } else { c.from }), - _ => None, - } -} - -/// The selected connector (one, alone and unlocked) and its two ends on the page as drawn. -pub fn selected_connector_ends(doc: &Document, sel: &[NodeId]) -> Option<(NodeId, [Point; 2])> { - let [id] = sel else { return None }; - if !matches!(doc.node(*id)?.board, Some(BoardItem::Connector(_))) || crate::tools::locked_in(doc, *id) { - return None; - } - let (a, b) = ends_now(doc, *id)?; - Some((*id, [a, b])) -} - -/// How near (screen pixels) a press must be to a selected connector's end to drag it. -pub const END_HANDLE_PX: f64 = 8.0; - -/// The selected connector's end under `p` at `zoom`, nearest first (`true`: its start). -pub(crate) fn end_handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<(NodeId, bool)> { - let (id, [a, b]) = selected_connector_ends(doc, sel)?; - let (da, db) = ((a - p).hypot(), (b - p).hypot()); - (da.min(db) * zoom.max(1e-6) <= END_HANDLE_PX).then_some((id, da <= db)) -} - -/// The label of connector `id`, if it has one. -pub fn label_of(doc: &Document, id: NodeId) -> Option { - let parent = doc.node(id)?.parent?; - doc.node(parent)?.children.iter().copied().find(|c| doc.node(*c).is_some_and(|n| n.board == Some(BoardItem::Label { connector: id }))) -} - -/// Give connector `id` the label `text` (empty removes it); the label's id. -fn set_label(doc: &mut Document, id: NodeId, text: &str) -> Result> { - let existing = label_of(doc, id); - if text.trim().is_empty() { - if let Some(l) = existing { - doc.remove(l)?; - } - return Ok(None); - } - if let Some(l) = existing { - if let Some(t) = text_of(doc, l) - && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) - { - let len = td.char_count(); - td.replace_range(0, len, text); - } - return Ok(Some(l)); - } - let parent = doc.node(id).and_then(|n| n.parent).ok_or(DocError::NoNode(id))?; - let l = add(doc, parent, NodeKind::Frame, "Label", (0.0, 0.0), (40.0, 24.0), |n| { - n.fills = vec![Paint::solid(Color::WHITE)]; - n.corners.radii = [4.0; 4]; - n.clip = false; - n.layout = Some(AutoLayout { padding: [3.0, 6.0, 3.0, 6.0], ..AutoLayout::default() }); - n.child_layout.sizing_h = Sizing::Hug; - n.child_layout.sizing_v = Sizing::Hug; - n.board = Some(BoardItem::Label { connector: id }); - })?; - let t = add_text(doc, l, text, 12.0, INK, TextAlignH::Center, 40.0)?; - if let Some(n) = doc.node_mut(t) - && let NodeKind::Text(td) = &mut n.kind - { - // The label hugs its text: one line per paragraph. - td.auto_resize = TextAutoResize::WidthAndHeight; - n.child_layout.sizing_h = Sizing::Hug; - } - Ok(Some(l)) -} - -fn label_cmd(s: &mut Session, p: &Value) -> Result { - let cmd = "board.label"; - require(s, cmd)?; - let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; - let (id, _) = connector_of(cmd, s.doc()?, id)?; - let text = req_string(cmd, p, "text")?; - let label = set_label(s.doc_mut()?, id, &text)?; - Ok(json!({"id": id, "label": label})) -} - -/// The board objects among `ids`, each with its item (a label counts as its connector's text). -fn items(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, BoardItem)> { - ids.iter().filter_map(|i| doc.node(*i).and_then(|n| n.board.clone()).map(|b| (*i, b))).collect() -} - -fn set_color(s: &mut Session, p: &Value) -> Result { - let cmd = "board.setColor"; - require(s, cmd)?; - let ids = req_targets(cmd, s, p)?; - let color = color_param(cmd, p)?.ok_or_else(|| bad(cmd, "`color` is required"))?; - let doc = s.doc_mut()?; - let mut changed = Vec::new(); - for (id, item) in items(doc, &ids) { - match item { - BoardItem::Sticky { .. } => doc.get_mut(id)?.fills = vec![Paint::solid(color)], - BoardItem::Shape { .. } => { - let Some(outline) = doc.get(id)?.children.first().copied() else { continue }; - let n = doc.get_mut(outline)?; - n.fills = vec![Paint::solid(color)]; - n.strokes = vec![Paint::solid(line_shade(color))]; - } - BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))], - BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table => continue, - } - changed.push(id); - } - if changed.is_empty() { - return Err(bad(cmd, "select stickies, shapes, connectors or ink")); - } - Ok(json!({"ids": changed})) -} - -/// What `board.setStyle` changes (each `None` leaves it). -struct Restyle { - shape: Option, - routing: Option, - size: Option, - start: Option, - end: Option, -} - -impl Restyle { - fn of(cmd: &str, p: &Value) -> Result { - let size = num(p, "fontSize") - .map(|v| if v.is_finite() && (1.0..=1000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, "`fontSize` is from 1 to 1000")) }) - .transpose()?; - Ok(Restyle { - shape: string(p, "shape").map(|v| shape_of(cmd, &v)).transpose()?, - routing: string(p, "style").map(|v| routing_of(cmd, &v)).transpose()?, - size, - start: cap_of(cmd, p, "startCap")?, - end: cap_of(cmd, p, "endCap")?, - }) - } - - /// Restyle board object `id` (`item`); whether anything about it changed. - fn apply(&self, doc: &mut Document, id: NodeId, item: BoardItem) -> Result { - let changed = match item { - BoardItem::Shape { shape } => match self.shape.filter(|n| *n != shape) { - Some(new) => { - reshape(doc, id, new)?; - true - } - None => false, - }, - BoardItem::Connector(c) => self.restyle_connector(doc, id, c)?, - _ => false, - }; - // Text sizes: shapes' and labels' text (stickies size theirs to fit). - let sized = match (self.size, &item) { - (Some(size), BoardItem::Shape { .. } | BoardItem::Label { .. }) => resize_text(doc, id, size), - _ => false, - }; - Ok(changed || sized) - } - - fn restyle_connector(&self, doc: &mut Document, id: NodeId, mut c: Connector) -> Result { - let n = doc.get_mut(id)?; - if let Some(r) = self.routing { - c.routing = r; - n.board = Some(BoardItem::Connector(c)); - } - if let Some(cap) = self.start { - n.stroke.cap_start = cap; - } - if let Some(cap) = self.end { - n.stroke.cap_end = cap; - } - Ok(self.routing.is_some() || self.start.is_some() || self.end.is_some()) - } -} - -/// Shape with text `id` takes outline `new` (its text room too). -fn reshape(doc: &mut Document, id: NodeId, new: BoardShape) -> Result<()> { - let n = doc.get_mut(id)?; - n.board = Some(BoardItem::Shape { shape: new }); - n.name = new.label().into(); - let (w, h) = (n.width, n.height); - if let Some(l) = n.layout.as_mut() { - l.padding = shape_padding(new, w, h); - } - if let Some(o) = doc.get(id)?.children.first().copied() - && let Some(on) = doc.node_mut(o) - { - on.kind = NodeKind::Vector { network: VectorNetwork::from_path(&new.path(w, h)) }; - (on.width, on.height, on.transform) = (w, h, Affine::IDENTITY); - } - Ok(()) -} - -/// Board object `id`'s text at `size`; whether it has text. -fn resize_text(doc: &mut Document, id: NodeId, size: f64) -> bool { - let Some(t) = text_of(doc, id) else { return false }; - if let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) { - set_font_size(td, size); - return true; - } - false -} - -fn set_style(s: &mut Session, p: &Value) -> Result { - let cmd = "board.setStyle"; - require(s, cmd)?; - let ids = req_targets(cmd, s, p)?; - let restyle = Restyle::of(cmd, p)?; - let doc = s.doc_mut()?; - let mut changed = Vec::new(); - for (id, item) in items(doc, &ids) { - if restyle.apply(doc, id, item)? { - changed.push(id); - } - } - if changed.is_empty() { - return Err(bad(cmd, "nothing selected takes these (shapes with text, connectors, labels)")); - } - Ok(json!({"ids": changed})) -} - -fn set_font_size(td: &mut TextData, size: f64) { - td.style.font_size = size; - for r in &mut td.runs { - r.style.font_size = size; - } -} - -/// Each layer of `sources` (copied one by one, in order, as `copies`) and its copy: the whole -/// subtrees pair up when they have the same shape (an instance made of a main component may not; -/// then only the roots do). -pub(crate) fn copy_map(doc: &Document, sources: &[NodeId], copies: &[NodeId]) -> BTreeMap { - let mut map = BTreeMap::new(); - for (s, c) in sources.iter().zip(copies) { - let (a, b) = (doc.subtree(*s), doc.subtree(*c)); - if a.len() == b.len() { - map.extend(a.into_iter().zip(b)); - } else { - map.insert(*s, *c); - } - } - map -} - -/// The connectors and labels among copied layers, as `(copy, the original's board item)`, from the -/// originals in `doc` (`map`: original → copy). -pub(crate) fn copied_items(doc: &Document, map: &BTreeMap) -> Vec<(NodeId, BoardItem)> { - map.iter().filter_map(|(o, c)| doc.node(*o).and_then(|n| n.board.clone()).map(|b| (*c, b))).collect() -} - -/// After a copy (`map`: each copied original → its copy; `originals`: the copies' items as the -/// originals had them), copied connectors join the copies of what they joined and labels follow -/// the copies of their connectors. From another file (`foreign`), what wasn't copied isn't here: a -/// connector's end there stays where it is drawn, as a point, and a label without its connector -/// goes. -pub(crate) fn rejoin(doc: &mut Document, originals: &[(NodeId, BoardItem)], map: &BTreeMap, foreign: bool) { - for (copy, item) in originals { - let mut item = item.clone(); - let copied = |n: NodeId| map.contains_key(&n); - match &mut item { - BoardItem::Connector(c) if foreign => { - let ends = ends_now(doc, *copy); - for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { - if let (End::Node { node, .. }, Some(p)) = (*end, at) - && !copied(node) - { - *end = End::Point { x: p.x, y: p.y }; - } - } - } - BoardItem::Label { connector } if foreign && !copied(*connector) => { - let _ = doc.remove(*copy); - continue; - } - BoardItem::Connector(_) | BoardItem::Label { .. } => {} - _ => continue, - } - item.remap(map); - if let Some(n) = doc.node_mut(*copy) { - n.board = Some(item); - } - } -} - -// --------------------------------------------------------------------------------------------- -// Derived data (the normalize pass). - -/// Before text is measured: a sticky's note takes the size its length calls for, and a resized -/// shape keeps its text inside its outline. -pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTreeSet) { - if !is_board(doc) { - return; - } - for &id in touched { - let Some(n) = doc.node(id) else { continue }; - if let Some(owner) = - owner_of_text(doc, id).or(Some(id)).filter(|o| doc.node(*o).is_some_and(|k| matches!(k.board, Some(BoardItem::Sticky { .. })))) - && let Some(t) = text_of(doc, owner) - && let Some(NodeKind::Text(td)) = doc.node(t).map(|k| &k.kind) - { - let size = sticky_font_size(&td.characters); - let off = td.style.font_size != size || td.runs.iter().any(|r| r.style.font_size != size); - if off && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|k| &mut k.kind) { - set_font_size(td, size); - } - continue; - } - if let Some(BoardItem::Shape { shape }) = n.board - && before.node(id).is_none_or(|b| (b.width, b.height) != (n.width, n.height)) - { - let pad = shape_padding(shape, n.width, n.height); - if n.layout.as_ref().is_some_and(|l| l.padding != pad) - && let Some(l) = doc.node_mut(id).and_then(|k| k.layout.as_mut()) - { - l.padding = pad; - } - } - } -} - -/// After layout: connectors run between their ends again (an end whose layer is gone stays where -/// it was), one moved on its own takes its free ends along, and labels sit at their middles. Only -/// the connectors and labels an edit can have moved are looked at: touched themselves, or with a -/// touched (or gone) end, connector or layer around them. -pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTreeSet) { - if !is_board(doc) || touched.is_empty() { - return; - } - let hit = |doc: &Document, id: NodeId| touched.contains(&id) || doc.ancestors(id).iter().any(|a| touched.contains(a)); - let (mut connectors, mut labels) = (Vec::new(), Vec::new()); - for n in doc.nodes() { - match &n.board { - Some(BoardItem::Connector(c)) => { - let ends = [c.from.node(), c.to.node()].into_iter().flatten(); - if hit(doc, n.id) || ends.into_iter().any(|e| !doc.contains(e) || hit(doc, e)) { - connectors.push((n.id, *c)); - } - } - Some(BoardItem::Label { connector }) => labels.push((n.id, *connector)), - _ => {} - } - } - let rerouted: BTreeSet = connectors.iter().map(|(id, _)| *id).collect(); - for (id, mut c) in connectors { - let changed = detach_gone_ends(doc, id, &mut c) | carry_free_ends(doc, before, touched, id, &mut c); - if changed && let Some(n) = doc.node_mut(id) { - n.board = Some(BoardItem::Connector(c)); - } - if let Some(pts) = route(doc, &c) { - place_route(doc, id, &pts, c.routing); - } - } - for (label, connector) in labels { - if rerouted.contains(&connector) || touched.contains(&connector) || hit(doc, label) { - place_label(doc, label, connector); - } - } -} - -/// The ends of connector `id` (as `c`) whose layer is gone or on another page stay where they are -/// drawn, as points. Whether any did. -fn detach_gone_ends(doc: &Document, id: NodeId, c: &mut Connector) -> bool { - let (page, ends) = (doc.page_of(id), ends_now(doc, id)); - let mut detached = false; - for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { - if let (End::Node { node, .. }, Some(p)) = (*end, at) - && (!doc.contains(node) || doc.page_of(node) != page) - { - *end = End::Point { x: p.x, y: p.y }; - detached = true; - } - } - detached -} - -/// Connector `id` (as `c`) moved by itself (a drag, a nudge: its place changed but not what it -/// joins): its free ends move with it. Whether they did. -fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet, id: NodeId, c: &mut Connector) -> bool { - let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { return false }; - if !touched.contains(&id) || b.transform == n.transform || b.board != n.board { - return false; - } - let Some(expected) = route(doc, c).and_then(|pts| finite_box(&route_path(&pts, c.routing))).map(|b| b.origin()) else { return false }; - let d = (doc.world_transform(id) * Point::ZERO) - expected; - if d.hypot() <= 1e-9 { - return false; - } - let mut moved = false; - for end in [&mut c.from, &mut c.to] { - if let End::Point { x, y } = end { - (*x, *y) = (*x + d.x, *y + d.y); - moved = true; - } - } - moved -} - -/// Label `label` at the middle of connector `connector`; gone with it when it is gone. -fn place_label(doc: &mut Document, label: NodeId, connector: NodeId) { - let Some(mid) = doc.node(connector).and_then(|_| route_mid(doc, connector)) else { - let _ = doc.remove(label); - return; - }; - let Some(n) = doc.node(label) else { return }; - let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); - let local = pw.inverse() * mid; - let t = Affine::translate((local.x - n.width / 2.0, local.y - n.height / 2.0)); - if n.transform != t - && let Some(n) = doc.node_mut(label) - { - n.transform = t; - } -} - -/// Connector `id`'s first and last points on the page as it is drawn now. -fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> { - let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; - let w = doc.world_transform(id); - Some((w * network.vertices.first()?.point(), w * network.vertices.last()?.point())) -} - -/// The middle of connector `id`'s route by length, on the page. -fn route_mid(doc: &Document, id: NodeId) -> Option { - let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; - let w = doc.world_transform(id); - let mut pts: Vec = Vec::new(); - figcraft_geom::kurbo::flatten(network.stroke_path().iter(), 0.25, |el| { - if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { - pts.push(w * p); - } - }); - let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum(); - let mut left = total / 2.0; - for s in pts.windows(2) { - let (a, b) = (s[0], s[1]); - let l = (b - a).hypot(); - if l >= left && l > 0.0 { - return Some(a.lerp(b, left / l)); - } - left -= l; - } - pts.first().copied() -} - -/// The path through a route's page points `pts` (see [`route`]): one curve for a curved -/// connector, straight runs otherwise. -fn route_path(pts: &[Point], routing: Routing) -> BezPath { - let mut path = BezPath::new(); - match (routing, pts) { - (Routing::Curved, [p0, c0, c1, p1]) => { - path.move_to(*p0); - path.curve_to(*c0, *c1, *p1); - } - _ => { - for (i, p) in pts.iter().enumerate() { - if i == 0 { path.move_to(*p) } else { path.line_to(*p) } - } - } - } - path -} - -/// The box a path is drawn in, when it is finite. -fn finite_box(path: &BezPath) -> Option { - let b = path.bounding_box(); - (b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()).then_some(b) -} - -/// Write the route `pts` (page points, see [`route`]) into connector `id`: its box, place and -/// path. Untouched when it already runs there, so a connector that didn't move isn't an edit. -fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing: Routing) { - let Some(n) = doc.node(id) else { return }; - let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); - let local = pw.inverse() * route_path(pts, routing); - let Some(b) = finite_box(&local) else { return }; - let rel = Affine::translate((-b.x0, -b.y0)) * local; - let network = match routing { - Routing::Curved => VectorNetwork::from_path(&rel), - _ => VectorNetwork::from_points(&rel.elements().iter().filter_map(PathEl::end_point).collect::>(), false), - }; - let t = Affine::translate((b.x0, b.y0)); - let same = - n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network); - if same { - return; - } - if let Some(n) = doc.node_mut(id) { - n.kind = NodeKind::Vector { network }; - n.transform = t; - (n.width, n.height) = (b.width(), b.height()); - } -} - -/// How far an elbow connector going around the objects it joins keeps from them. -const ROOM: f64 = 24.0; - -/// The page points connector `c` runs through: from the middle of the side of each attached layer -/// facing the other end (or the side it names), straight, in right angles, or (curved) its two -/// ends with the curve's two control points between them. -pub fn route(doc: &Document, c: &Connector) -> Option> { - let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n)); - let centre = |e: &End, b: Option| match e { - End::Point { x, y } => Some(Point::new(*x, *y)), - End::Node { .. } => b.map(|b| b.center()), - }; - let (fb, tb) = (bounds(&c.from), bounds(&c.to)); - let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?); - let (p0, s0) = attach(&c.from, fb, tc)?; - let (p1, s1) = attach(&c.to, tb, fc)?; - let pts = match c.routing { - Routing::Straight => tidy(vec![p0, p1]), - Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::>()), - Routing::Curved => curve(p0, s0, p1, s1), - }; - pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts) -} - -/// `pts` without repeats or turns that don't turn (three in a line across or down). -fn tidy(pts: Vec) -> Vec { - let mut out: Vec = Vec::with_capacity(pts.len()); - for p in pts { - if out.last() == Some(&p) { - continue; - } - if let [.., a, b] = out.as_slice() - && ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y)) - { - out.pop(); - } - out.push(p); - } - out -} - -/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that -/// side (`None`: a free point). -fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { - match e { - End::Point { x, y } => Some((Point::new(*x, *y), None)), - End::Node { side, .. } => { - let b = b?; - let side = match side { - Side::Auto => { - let d = other - b.center(); - // Compare against the box's proportions: a wide box takes the sides more. - if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) { - if d.x >= 0.0 { Side::Right } else { Side::Left } - } else if d.y >= 0.0 { - Side::Bottom - } else { - Side::Top - } - } - s => *s, - }; - Some((side.point(b), Some(side))) - } - } -} - -/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no -/// way): the ends and the two control points between them. -fn curve(p0: Point, s0: Option, p1: Point, s1: Option) -> Vec { - let reach = (p1 - p0).hypot() / 2.0; - let pull = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach); - vec![p0, pull(p0, s0), pull(p1, s1), p1] -} - -/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a -/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`, -/// else the cheapest of a few ways around them (by length, and turns). -fn elbow(p0: Point, s0: Option, p1: Point, s1: Option, boxes: &[Rect]) -> Vec { - let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal))); - let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct }; - if !crosses(&direct, boxes) { - return direct; - } - // Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects. - let out = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM); - let (a, b) = (out(p0, s0), out(p1, s1)); - let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]]; - for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] { - ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]); - } - for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] { - ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]); - } - let cost = |r: &Vec| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::() + ROOM * r.len() as f64; - ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct) -} - -/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge, -/// or out of one, is outside). -fn crosses(pts: &[Point], boxes: &[Rect]) -> bool { - const EDGE: f64 = 0.5; - pts.windows(2).any(|s| { - let (a, b) = (s[0], s[1]); - boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE) - }) -} - -/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end -/// follows the other): halfway across between two sides facing the same way, one turn otherwise. -fn short_elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { - let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs()); - let h1 = h1.unwrap_or(h0); - match (h0, h1) { - (true, true) => { - let mx = (p0.x + p1.x) / 2.0; - vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1] - } - (false, false) => { - let my = (p0.y + p1.y) / 2.0; - vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1] - } - (true, false) => vec![p0, Point::new(p1.x, p0.y), p1], - (false, true) => vec![p0, Point::new(p0.x, p1.y), p1], - } -} - -// --------------------------------------------------------------------------------------------- -// The board tools. - -impl Session { - /// A press with a board tool: place a sticky, shape or stamp, start a connector or a stroke, - /// or start erasing. - pub(crate) fn board_down(&mut self, ev: &PointerEvent) -> Result<()> { - // A board tool kept while a design file came to the front: back to Move for this press. - if !is_board(self.doc()?) { - self.tool.tool = ToolId::Move; - return self.pointer(ev); - } - // A sticky still being typed in ends first, so the next object is an undo step of its own. - if self.ds()?.text_edit.is_some() { - crate::textedit::end(self)?; - } - match self.tool.tool { - ToolId::Sticky => { - let half = STICKY / 2.0; - let r = self.execute("board.sticky", &json!({"x": ev.x - half, "y": ev.y - half}))?; - self.board_placed(); - if let Some(id) = r.get("id").and_then(id_of) { - edit_text(self, id)?; - } - Ok(()) - } - ToolId::BoardShape => { - let half = SHAPE / 2.0; - self.execute("board.shape", &json!({"x": ev.x - half, "y": ev.y - half}))?; - self.board_placed(); - Ok(()) - } - ToolId::Stamp => { - // Stamps go on one after another: the tool stays. - self.execute("board.stamp", &json!({"x": ev.x, "y": ev.y}))?; - Ok(()) - } - ToolId::Marker | ToolId::Highlighter => { - self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; - Ok(()) - } - ToolId::Connector => { - let from = self.end_at(ev); - self.tool.gesture = Gesture::Connect { from, start: ev.p() }; - Ok(()) - } - ToolId::Eraser => { - self.begin_interaction("Erase")?; - self.tool.gesture = Gesture::Erase { gone: Vec::new() }; - self.erase_at(ev) - } - _ => Ok(()), - } - } - - /// After placing an object: back to the Move tool unless tools stay on. - fn board_placed(&mut self) { - if !self.prefs.keep_tool { - self.set_tool(ToolId::Move); - } - } - - /// What a connector end at the pointer attaches to: the object there, or the point. - fn end_at(&self, ev: &PointerEvent) -> End { - let hit = (|| { - let doc = self.doc().ok()?; - let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?; - let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false); - let n = doc.node(t)?; - (!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t) - })(); - match hit { - Some(node) => End::Node { node, side: Side::Auto }, - None => End::Point { x: ev.x, y: ev.y }, - } - } - - pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) { - let a = match from { - End::Point { x, y } => Point::new(x, y), - End::Node { .. } => start, - }; - self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]); - self.tool.overlay.drop_target = from.node(); - self.tool.gesture = Gesture::Connect { from, start }; - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - } - - pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> { - self.tool.overlay.rubber = None; - self.tool.overlay.drop_target = None; - // A click, not a drag: nothing to join. - if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { - return Ok(()); - } - let to = self.end_at(ev); - if to.node().is_some() && to.node() == from.node() { - return Ok(()); - } - let style = self.tool.board.routing.id(); - self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?; - self.board_placed(); - Ok(()) - } - - /// Dragging the selected connector `id`'s end (`from`: its start): a line from its other end - /// to the pointer, and the object it would attach to outlined. - pub(crate) fn reattach_drag(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) { - self.tool.gesture = Gesture::Reattach { id, from, start }; - let Ok(doc) = self.doc() else { return }; - let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a }); - let other = other_end(doc, id, from).and_then(|e| e.node()); - self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]); - self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other); - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - } - - /// The end let go: attached to the object under the pointer, or free there (`board.reattach`). - /// A click, or a drop on the object the other end is on, changes nothing. - pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> { - self.tool.overlay.rubber = None; - if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { - return Ok(()); - } - let target = self.end_at(ev); - let other = other_end(self.doc()?, id, from).and_then(|e| e.node()); - if target.node().is_some() && target.node() == other { - return Ok(()); - } - self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?; - Ok(()) - } - - /// The eraser at the pointer: the marker and highlighter strokes it touches go (until the - /// gesture ends, which is one undo step). - pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> { - let Gesture::Erase { mut gone } = std::mem::take(&mut self.tool.gesture) else { return Ok(()) }; - let tol = ERASER_PX / ev.zoom.max(1e-6); - self.tool.overlay.brush = Some([ev.x, ev.y, tol]); - let (doc, page) = (self.doc()?.clone(), self.page()?); - if let Some(hit) = - doc.hit_test(page, ev.p(), tol, None).filter(|h| doc.node(*h).is_some_and(|n| n.board == Some(BoardItem::Ink)) && !gone.contains(h)) - { - gone.push(hit); - let ids = gone.clone(); - self.preview(|d, sel| { - for i in &ids { - let _ = d.remove(*i); - } - sel.retain(|s| !ids.contains(s)); - Ok(()) - })?; - } - self.tool.gesture = Gesture::Erase { gone }; - if let Ok(ds) = self.ds_mut() { - ds.revision += 1; - } - Ok(()) - } - - pub(crate) fn erase_up(&mut self) -> Result<()> { - self.tool.overlay.brush = None; - self.commit_interaction() - } - - /// A finished marker or highlighter stroke (page points). - pub(crate) fn ink_finish(&mut self, path: &str) -> Result<()> { - let high = self.tool.tool == ToolId::Highlighter; - self.execute("board.ink", &json!({"path": path, "highlighter": high, "select": false}))?; - // Strokes come one after another: the selection stays as it was. - Ok(()) - } -} - -impl Session { - /// A double-click with the Move tool on a board object: type in its text (a connector gets a - /// label to type in). True when it was one. - pub(crate) fn board_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId]) -> Result { - let whole = figcraft_doc::geometry::select_target(doc, leaf, sel, false); - let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| doc.is_ancestor(whole, te.id)); - let Some(item) = doc.node(whole).and_then(|n| n.board.clone()).filter(|_| !editing) else { return Ok(false) }; - self.tool.gesture = Gesture::None; - self.cancel_interaction()?; - match item { - BoardItem::Connector(_) => { - let label = match label_of(doc, whole) { - Some(l) => l, - None => { - let r = self.execute("board.label", &json!({"id": whole, "text": "Label"}))?; - r.get("label").and_then(id_of).ok_or_else(|| EngineError::Other("no label made".into()))? - } - }; - edit_text(self, label) - } - BoardItem::Table => crate::board_table::edit_cell(self, doc, leaf), - b if b.has_text() => edit_text(self, whole), - _ => Ok(false), - } - } -} diff --git a/crates/engine/src/boards.rs b/crates/engine/src/boards.rs new file mode 100644 index 00000000..685c1d70 --- /dev/null +++ b/crates/engine/src/boards.rs @@ -0,0 +1,3 @@ +pub mod board; +pub mod board_table; +pub mod board_templates; diff --git a/crates/engine/src/boards/board.rs b/crates/engine/src/boards/board.rs new file mode 100644 index 00000000..b40bcc7d --- /dev/null +++ b/crates/engine/src/boards/board.rs @@ -0,0 +1,277 @@ +//! Boards: files for thinking together, with sticky notes, shapes with text, connectors, ink and +//! stamps. Board objects are ordinary layers with a [`BoardItem`] on them; [`settle_text`] and +//! [`settle_routes`] keep what follows from it (a sticky's text size, a shape's text room, +//! connectors' routes and their labels) up to date in the normalize pass, so local edits, undo and +//! live-session edits from others all end in the same geometry. + +use std::collections::{BTreeMap, BTreeSet}; + +use figcraft_color::{BlendMode, Color, Paint}; +use figcraft_doc::{ + AutoLayout, BOARD_COLORS, BoardItem, BoardShape, Connector, Constraints, CrossAlign, DocError, Document, Effect, EffectKind, End, FileKind, + HConstraint, LayoutMode, MainAlign, Node, NodeId, NodeKind, Routing, STAMP_SIZE, Shadow, Side, Sizing, Stamp, StrokeAlign, StrokeCap, StrokeJoin, + TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color, +}; +use figcraft_geom::kurbo::PathEl; +use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2}; +use serde_json::{Value, json}; + +use crate::cmd::CommandSpec; +use crate::params::{boolean, color_of, get, id, id_of, num, num_or, opt_id, req_num, req_string, req_targets, string}; +use crate::tools::{Gesture, PointerEvent, ToolId}; +use crate::{EngineError, Result, Session, bad, spec, textedit}; + +mod connectors; +mod derived; +mod edit; +mod items; +mod tools; + +pub(crate) use connectors::end_handle_at; +pub use connectors::{END_HANDLE_PX, selected_connector_ends}; +use connectors::{connector_cmd, reattach}; +pub use derived::route; +pub(crate) use derived::{settle_routes, settle_text}; +pub use edit::label_of; +pub(crate) use edit::{copied_items, copy_map, rejoin}; +use edit::{label_cmd, set_color, set_style}; +pub(crate) use items::add_text; +pub use items::{edit_text, owner_of_text, sticky_font_size, text_of}; +use items::{ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd}; + +/// Why a board command refuses on a design file. +pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board"; +/// Why prototyping and Dev Mode refuse on a board. +pub const NOT_ON_BOARDS: &str = "boards have no prototypes or Dev Mode: use a design file for those"; + +/// A sticky note's side (it grows taller for long notes). +pub const STICKY: f64 = 200.0; +const STICKY_PAD: f64 = 16.0; +/// The room between a sticky and the next one made beside it. +const STICKY_GAP: f64 = 24.0; +/// A new shape's size. +const SHAPE: f64 = 160.0; +/// Text on board objects, and the author line under a sticky's note. +pub(crate) const INK: Color = Color::from_rgb8_const(0x1E, 0x1E, 0x1E); +const AUTHOR_INK: Color = Color::from_rgb8_const(0x5C, 0x5A, 0x55); +/// Connectors' default colour and weight. +const LINE: Color = Color::from_rgb8_const(0x3A, 0x39, 0x36); +const LINE_WEIGHT: f64 = 2.0; +/// Marker and highlighter strokes: (weight, opacity). +const MARKER: (f64, f32) = (4.0, 1.0); +const HIGHLIGHTER: (f64, f32) = (20.0, 0.45); +/// How far the eraser reaches around the pointer (screen pixels). +const ERASER_PX: f64 = 8.0; +/// The text sizes the contextual bar offers. +pub const TEXT_SIZES: [(&str, f64); 4] = [("S", 12.0), ("M", 16.0), ("L", 24.0), ("XL", 36.0)]; + +/// The board tools' choices: the colour new stickies get, the shape and stamp the tools place, +/// how new connectors run and the marker's and highlighter's colours. +#[derive(Clone, Debug)] +pub struct BoardTool { + pub sticky: Color, + pub shape: BoardShape, + pub stamp: Stamp, + pub routing: Routing, + pub marker: Color, + pub highlighter: Color, +} + +impl Default for BoardTool { + fn default() -> Self { + let first = BOARD_COLORS.first().map_or(Color::WHITE, |c| c.1); + Self { + sticky: first, + shape: BoardShape::Rectangle, + stamp: Stamp::ThumbsUp, + routing: Routing::Elbow, + marker: INK, + highlighter: Color::from_rgb8_const(0xFF, 0xD2, 0x1F), + } + } +} + +pub fn specs() -> Vec { + vec![ + spec!( + "board.sticky", + "Sticky note", + "{x, y (its top-left, in the parent), text?, color? (yellow|orange|red|pink|violet|blue|green|gray or hex; default the sticky tool's), author? (a name, or false for none; default yours), parent?} → {id, text}", + true, + sticky_cmd + ), + spec!( + "board.nextSticky", + "Next sticky note", + "{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it", + true, + next_sticky + ), + spec!( + "board.shape", + "Shape with text", + "{shape: rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron, x, y, width?, height? (default 160), text?, color?, parent?} → {id, text}", + true, + shape_cmd + ), + spec!( + "board.connector", + "Connector", + "{from, to (each a layer id or {x, y} on the page), fromSide?, toSide? (auto|top|right|bottom|left), style?: straight|elbow|curved, startCap?, endCap? (none|lineArrow|triangleArrow|circleFilled|diamondFilled…; default an arrow at the end), label?} → {id, label?} a line that follows the layers it joins", + true, + connector_cmd + ), + spec!( + "board.reattach", + "Reattach connector", + "{id? (a connector; default the selection), end: from|to, target (a layer id, or {x, y} on the page to leave the end free), side? (auto|top|right|bottom|left)} → {id} moves one end of a connector", + true, + reattach + ), + spec!("board.label", "Connector label", "{id (a connector; default the selection), text (empty removes it)} → {id, label}", true, label_cmd), + spec!( + "board.stamp", + "Stamp", + "{x, y (its centre on the page), stamp: thumbsUp|heart|plusOne|star|question|check} → {id} a badge with your name on hover", + true, + stamp_cmd + ), + spec!( + "board.ink", + "Marker stroke", + "{path (SVG path data on the page) | points [[x,y],..], highlighter? (wide and see-through), color?, select? (default true)} → {id}", + true, + ink_cmd + ), + spec!( + "board.setColor", + "Board color", + "{ids?, color (a board colour name or hex)} stickies and shapes take it as their fill, connectors and ink as their line", + true, + set_color + ), + spec!( + "board.setStyle", + "Board style", + "{ids?, shape? (shapes with text), fontSize? (their text), style?: straight|elbow|curved, startCap?, endCap? (connectors)}", + true, + set_style + ), + spec!("tool.sticky", "Sticky note", "{color?} (a click places a sticky and starts typing in it)", false, |s, p| { + if let Some(c) = color_param("tool.sticky", p)? { + s.tool.board.sticky = c; + } + board_tool(s, ToolId::Sticky) + }), + spec!("tool.shape", "Shapes", "{shape?} (a click places a shape with text)", false, |s, p| { + if let Some(v) = string(p, "shape") { + s.tool.board.shape = shape_of("tool.shape", &v)?; + } + board_tool(s, ToolId::BoardShape) + }), + spec!("tool.connector", "Connector", "{style?: straight|elbow|curved} (drag from one object to another)", false, |s, p| { + if let Some(v) = string(p, "style") { + s.tool.board.routing = routing_of("tool.connector", &v)?; + } + board_tool(s, ToolId::Connector) + }), + spec!("tool.marker", "Marker", "{color?}", false, |s, p| { + if let Some(c) = color_param("tool.marker", p)? { + s.tool.board.marker = c; + } + board_tool(s, ToolId::Marker) + }), + spec!("tool.highlighter", "Highlighter", "{color?}", false, |s, p| { + if let Some(c) = color_param("tool.highlighter", p)? { + s.tool.board.highlighter = c; + } + board_tool(s, ToolId::Highlighter) + }), + spec!("tool.eraser", "Eraser", "{} (drag over marker and highlighter strokes to remove them)", false, |s, _| board_tool(s, ToolId::Eraser)), + spec!("tool.stamp", "Stamp", "{stamp?} (a click places the stamp)", false, |s, p| { + if let Some(v) = string(p, "stamp") { + s.tool.board.stamp = stamp_of("tool.stamp", &v)?; + } + board_tool(s, ToolId::Stamp) + }), + ] +} + +pub fn is_board(doc: &Document) -> bool { + doc.kind == FileKind::Board +} + +/// Refuse `cmd` unless the active file is a board. +pub(crate) fn require(s: &Session, cmd: &str) -> Result<()> { + if s.doc().is_ok_and(is_board) { Ok(()) } else { Err(EngineError::Disabled(cmd.into(), NEEDS_BOARD.into())) } +} + +/// Prototyping and Dev Mode: not on boards. +fn design_only(cmd: &str) -> bool { + cmd.starts_with("proto.") || cmd.starts_with("dev.") || matches!(cmd, "view.prototype" | "view.designPrototype" | "view.devMode") +} + +/// Refuse `cmd` on a board when it is prototyping or Dev Mode. +pub(crate) fn refuse_on_board(s: &Session, cmd: &str) -> Result<()> { + if design_only(cmd) && s.doc().is_ok_and(is_board) { Err(EngineError::Disabled(cmd.into(), NOT_ON_BOARDS.into())) } else { Ok(()) } +} + +fn board_tool(s: &mut Session, t: ToolId) -> Result { + require(s, &format!("tool.{}", t.id()))?; + crate::cmd::view::tool(s, t) +} + +fn shape_of(cmd: &str, v: &str) -> Result { + BoardShape::from_id(v) + .ok_or_else(|| bad(cmd, format!("unknown shape `{v}` (rectangle|roundedRectangle|ellipse|diamond|triangle|speechBubble|chevron)"))) +} + +fn stamp_of(cmd: &str, v: &str) -> Result { + Stamp::from_id(v).ok_or_else(|| bad(cmd, format!("unknown stamp `{v}` (thumbsUp|heart|plusOne|star|question|check)"))) +} + +fn routing_of(cmd: &str, v: &str) -> Result { + Routing::from_id(v).ok_or_else(|| bad(cmd, format!("`style` is straight, elbow or curved, not `{v}`"))) +} + +fn cap_of(cmd: &str, p: &Value, key: &str) -> Result> { + match get(p, key) { + None | Some(Value::Null) => Ok(None), + Some(v) => crate::cmd::props::enum_of::(v).map(Some).ok_or_else(|| bad(cmd, format!("`{key}`: unknown cap"))), + } +} + +/// `color`: a board colour's name, or any colour. +fn color_param(cmd: &str, p: &Value) -> Result> { + match get(p, "color") { + None | Some(Value::Null) => Ok(None), + Some(Value::String(s)) if board_color(s).is_some() => Ok(board_color(s)), + Some(v) => color_of(v).map(Some).ok_or_else(|| bad(cmd, "`color` is a board colour (yellow, blue…) or a colour such as #FFE78A")), + } +} + +/// The deeper shade of a board colour lines are drawn in (light tints are too faint as lines). +fn line_shade(c: Color) -> Color { + let palette = BOARD_COLORS.iter().any(|(_, b)| *b == c); + if palette { Color::new(c.r * 0.55, c.g * 0.55, c.b * 0.55, c.a) } else { c } +} + +/// Your name on what you make: the name from Preferences, else the one a live session gave you +/// (none for the default name outside a session). +pub(crate) fn author(s: &Session) -> String { + let name = crate::live::display_name(&s.prefs); + if name.is_empty() { s.live().info.as_ref().map(|i| i.name.clone()).unwrap_or_default() } else { name } +} + +/// The parent a command places into, and that parent's page-to-local transform. +fn parent_of(cmd: &str, s: &Session, p: &Value) -> Result { + let page = s.page()?; + let parent = opt_id(cmd, p, "parent")?.unwrap_or(page); + let doc = s.doc()?; + let n = doc.get(parent)?; + let takes = n.is_page() || matches!(n.kind, NodeKind::Section { .. }) || (n.kind.is_frame_like() && !n.is_board_atom()); + if !takes { + return Err(bad(cmd, "`parent` is the page, a section or a frame")); + } + Ok(parent) +} diff --git a/crates/engine/src/boards/board/connectors.rs b/crates/engine/src/boards/board/connectors.rs new file mode 100644 index 00000000..753550e5 --- /dev/null +++ b/crates/engine/src/boards/board/connectors.rs @@ -0,0 +1,147 @@ +use super::derived::{ends_now, place_route, route}; +use super::edit::set_label; +use super::items::add; +use super::*; + +/// One connector end from the params: a layer on this page, or a point. +fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result { + let side = match string(p, side_key) { + Some(v) => Side::from_id(&v).ok_or_else(|| bad(cmd, format!("`{side_key}` is auto, top, right, bottom or left")))?, + None => Side::Auto, + }; + match get(p, key) { + Some(Value::Object(o)) if o.contains_key("x") => { + let (x, y) = (num(&Value::Object(o.clone()), "x"), num(&Value::Object(o.clone()), "y")); + match (x, y) { + (Some(x), Some(y)) if x.is_finite() && y.is_finite() => Ok(End::Point { x, y }), + _ => Err(bad(cmd, format!("`{key}` needs finite x and y"))), + } + } + Some(v) => { + let node = id_of(v).ok_or_else(|| bad(cmd, format!("`{key}` is a layer id or {{x, y}}")))?; + let doc = s.doc()?; + let n = doc.get(node)?; + if n.is_page() || doc.page_of(node) != Some(s.page()?) { + return Err(bad(cmd, format!("`{key}` must be a layer on this page"))); + } + if matches!(n.board, Some(BoardItem::Connector(_))) { + return Err(bad(cmd, format!("`{key}` can't be another connector"))); + } + Ok(End::Node { node, side }) + } + None => Err(bad(cmd, format!("`{key}` is required"))), + } +} + +pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.connector"; + require(s, cmd)?; + let from = end_param(cmd, s, p, "from", "fromSide")?; + let to = end_param(cmd, s, p, "to", "toSide")?; + if from.node().is_some() && from.node() == to.node() { + return Err(bad(cmd, "connect two different layers")); + } + let routing = match string(p, "style") { + Some(v) => routing_of(cmd, &v)?, + None => s.tool.board.routing, + }; + let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None); + let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow); + let c = Connector { from, to, routing }; + let page = s.page()?; + let doc = s.doc_mut()?; + let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; + if pts.windows(2).all(|w| w.first() == w.get(1)) { + return Err(bad(cmd, "the ends are in the same place")); + } + let id = add(doc, page, NodeKind::Vector { network: VectorNetwork::default() }, "Connector", (0.0, 0.0), (0.0, 0.0), |n| { + n.fills.clear(); + n.strokes = vec![Paint::solid(LINE)]; + n.stroke.weight = LINE_WEIGHT; + n.stroke.join = StrokeJoin::Round; + n.stroke.cap_start = start; + n.stroke.cap_end = end; + n.board = Some(BoardItem::Connector(c)); + })?; + place_route(doc, id, &pts, routing); + let mut out = json!({"id": id}); + if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) { + out["label"] = json!(set_label(doc, id, &text)?); + } + s.set_selection(vec![id]); + Ok(out) +} + +/// The connector `id` names: a connector, or the label on one. +pub(super) fn connector_of(cmd: &str, doc: &Document, id: NodeId) -> Result<(NodeId, Connector)> { + match doc.get(id)?.board.clone() { + Some(BoardItem::Connector(c)) => Ok((id, c)), + Some(BoardItem::Label { connector }) => match doc.get(connector)?.board.clone() { + Some(BoardItem::Connector(c)) => Ok((connector, c)), + _ => Err(bad(cmd, "that isn't a connector")), + }, + _ => Err(bad(cmd, "that isn't a connector")), + } +} + +pub(super) fn reattach(s: &mut Session, p: &Value) -> Result { + let cmd = "board.reattach"; + require(s, cmd)?; + let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; + let (id, mut c) = connector_of(cmd, s.doc()?, id)?; + let from = match string(p, "end").as_deref() { + Some("from") => true, + Some("to") => false, + _ => return Err(bad(cmd, "`end` is from or to")), + }; + let end = end_param(cmd, s, p, "target", "side")?; + let other = if from { c.to } else { c.from }; + if end.node().is_some() && end.node() == other.node() { + return Err(bad(cmd, "connect two different layers")); + } + *(if from { &mut c.from } else { &mut c.to }) = end; + let doc = s.doc_mut()?; + let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?; + if pts.windows(2).all(|w| w.first() == w.get(1)) { + return Err(bad(cmd, "the ends are in the same place")); + } + doc.get_mut(id)?.board = Some(BoardItem::Connector(c)); + place_route(doc, id, &pts, c.routing); + Ok(json!({"id": id})) +} + +/// A connector end as `board.connector` and `board.reattach` take it. +pub(super) fn end_json(e: End) -> Value { + match e { + End::Node { node, .. } => json!(node), + End::Point { x, y } => json!({"x": x, "y": y}), + } +} + +/// The end of connector `id` that stays while its other one (`from`: its start) is dragged. +pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option { + match &doc.node(id)?.board { + Some(BoardItem::Connector(c)) => Some(if from { c.to } else { c.from }), + _ => None, + } +} + +/// The selected connector (one, alone and unlocked) and its two ends on the page as drawn. +pub fn selected_connector_ends(doc: &Document, sel: &[NodeId]) -> Option<(NodeId, [Point; 2])> { + let [id] = sel else { return None }; + if !matches!(doc.node(*id)?.board, Some(BoardItem::Connector(_))) || crate::tools::locked_in(doc, *id) { + return None; + } + let (a, b) = ends_now(doc, *id)?; + Some((*id, [a, b])) +} + +/// How near (screen pixels) a press must be to a selected connector's end to drag it. +pub const END_HANDLE_PX: f64 = 8.0; + +/// The selected connector's end under `p` at `zoom`, nearest first (`true`: its start). +pub(crate) fn end_handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<(NodeId, bool)> { + let (id, [a, b]) = selected_connector_ends(doc, sel)?; + let (da, db) = ((a - p).hypot(), (b - p).hypot()); + (da.min(db) * zoom.max(1e-6) <= END_HANDLE_PX).then_some((id, da <= db)) +} diff --git a/crates/engine/src/boards/board/derived.rs b/crates/engine/src/boards/board/derived.rs new file mode 100644 index 00000000..ac73df1e --- /dev/null +++ b/crates/engine/src/boards/board/derived.rs @@ -0,0 +1,339 @@ +use super::edit::set_font_size; +use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of}; +use super::*; + +// --------------------------------------------------------------------------------------------- +// Derived data (the normalize pass). + +/// Before text is measured: a sticky's note takes the size its length calls for, and a resized +/// shape keeps its text inside its outline. +pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTreeSet) { + if !is_board(doc) { + return; + } + for &id in touched { + let Some(n) = doc.node(id) else { continue }; + if let Some(owner) = + owner_of_text(doc, id).or(Some(id)).filter(|o| doc.node(*o).is_some_and(|k| matches!(k.board, Some(BoardItem::Sticky { .. })))) + && let Some(t) = text_of(doc, owner) + && let Some(NodeKind::Text(td)) = doc.node(t).map(|k| &k.kind) + { + let size = sticky_font_size(&td.characters); + let off = td.style.font_size != size || td.runs.iter().any(|r| r.style.font_size != size); + if off && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|k| &mut k.kind) { + set_font_size(td, size); + } + continue; + } + if let Some(BoardItem::Shape { shape }) = n.board + && before.node(id).is_none_or(|b| (b.width, b.height) != (n.width, n.height)) + { + let pad = shape_padding(shape, n.width, n.height); + if n.layout.as_ref().is_some_and(|l| l.padding != pad) + && let Some(l) = doc.node_mut(id).and_then(|k| k.layout.as_mut()) + { + l.padding = pad; + } + } + } +} + +/// After layout: connectors run between their ends again (an end whose layer is gone stays where +/// it was), one moved on its own takes its free ends along, and labels sit at their middles. Only +/// the connectors and labels an edit can have moved are looked at: touched themselves, or with a +/// touched (or gone) end, connector or layer around them. +pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTreeSet) { + if !is_board(doc) || touched.is_empty() { + return; + } + let hit = |doc: &Document, id: NodeId| touched.contains(&id) || doc.ancestors(id).iter().any(|a| touched.contains(a)); + let (mut connectors, mut labels) = (Vec::new(), Vec::new()); + for n in doc.nodes() { + match &n.board { + Some(BoardItem::Connector(c)) => { + let ends = [c.from.node(), c.to.node()].into_iter().flatten(); + if hit(doc, n.id) || ends.into_iter().any(|e| !doc.contains(e) || hit(doc, e)) { + connectors.push((n.id, *c)); + } + } + Some(BoardItem::Label { connector }) => labels.push((n.id, *connector)), + _ => {} + } + } + let rerouted: BTreeSet = connectors.iter().map(|(id, _)| *id).collect(); + for (id, mut c) in connectors { + let changed = detach_gone_ends(doc, id, &mut c) | carry_free_ends(doc, before, touched, id, &mut c); + if changed && let Some(n) = doc.node_mut(id) { + n.board = Some(BoardItem::Connector(c)); + } + if let Some(pts) = route(doc, &c) { + place_route(doc, id, &pts, c.routing); + } + } + for (label, connector) in labels { + if rerouted.contains(&connector) || touched.contains(&connector) || hit(doc, label) { + place_label(doc, label, connector); + } + } +} + +/// The ends of connector `id` (as `c`) whose layer is gone or on another page stay where they are +/// drawn, as points. Whether any did. +fn detach_gone_ends(doc: &Document, id: NodeId, c: &mut Connector) -> bool { + let (page, ends) = (doc.page_of(id), ends_now(doc, id)); + let mut detached = false; + for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { + if let (End::Node { node, .. }, Some(p)) = (*end, at) + && (!doc.contains(node) || doc.page_of(node) != page) + { + *end = End::Point { x: p.x, y: p.y }; + detached = true; + } + } + detached +} + +/// Connector `id` (as `c`) moved by itself (a drag, a nudge: its place changed but not what it +/// joins): its free ends move with it. Whether they did. +fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet, id: NodeId, c: &mut Connector) -> bool { + let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { return false }; + if !touched.contains(&id) || b.transform == n.transform || b.board != n.board { + return false; + } + let Some(expected) = route(doc, c).and_then(|pts| finite_box(&route_path(&pts, c.routing))).map(|b| b.origin()) else { return false }; + let d = (doc.world_transform(id) * Point::ZERO) - expected; + if d.hypot() <= 1e-9 { + return false; + } + let mut moved = false; + for end in [&mut c.from, &mut c.to] { + if let End::Point { x, y } = end { + (*x, *y) = (*x + d.x, *y + d.y); + moved = true; + } + } + moved +} + +/// Label `label` at the middle of connector `connector`; gone with it when it is gone. +fn place_label(doc: &mut Document, label: NodeId, connector: NodeId) { + let Some(mid) = doc.node(connector).and_then(|_| route_mid(doc, connector)) else { + let _ = doc.remove(label); + return; + }; + let Some(n) = doc.node(label) else { return }; + let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); + let local = pw.inverse() * mid; + let t = Affine::translate((local.x - n.width / 2.0, local.y - n.height / 2.0)); + if n.transform != t + && let Some(n) = doc.node_mut(label) + { + n.transform = t; + } +} + +/// Connector `id`'s first and last points on the page as it is drawn now. +pub(super) fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> { + let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; + let w = doc.world_transform(id); + Some((w * network.vertices.first()?.point(), w * network.vertices.last()?.point())) +} + +/// The middle of connector `id`'s route by length, on the page. +fn route_mid(doc: &Document, id: NodeId) -> Option { + let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None }; + let w = doc.world_transform(id); + let mut pts: Vec = Vec::new(); + figcraft_geom::kurbo::flatten(network.stroke_path().iter(), 0.25, |el| { + if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el { + pts.push(w * p); + } + }); + let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum(); + let mut left = total / 2.0; + for s in pts.windows(2) { + let (a, b) = (s[0], s[1]); + let l = (b - a).hypot(); + if l >= left && l > 0.0 { + return Some(a.lerp(b, left / l)); + } + left -= l; + } + pts.first().copied() +} + +/// The path through a route's page points `pts` (see [`route`]): one curve for a curved +/// connector, straight runs otherwise. +fn route_path(pts: &[Point], routing: Routing) -> BezPath { + let mut path = BezPath::new(); + match (routing, pts) { + (Routing::Curved, [p0, c0, c1, p1]) => { + path.move_to(*p0); + path.curve_to(*c0, *c1, *p1); + } + _ => { + for (i, p) in pts.iter().enumerate() { + if i == 0 { path.move_to(*p) } else { path.line_to(*p) } + } + } + } + path +} + +/// The box a path is drawn in, when it is finite. +fn finite_box(path: &BezPath) -> Option { + let b = path.bounding_box(); + (b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()).then_some(b) +} + +/// Write the route `pts` (page points, see [`route`]) into connector `id`: its box, place and +/// path. Untouched when it already runs there, so a connector that didn't move isn't an edit. +pub(super) fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing: Routing) { + let Some(n) = doc.node(id) else { return }; + let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p)); + let local = pw.inverse() * route_path(pts, routing); + let Some(b) = finite_box(&local) else { return }; + let rel = Affine::translate((-b.x0, -b.y0)) * local; + let network = match routing { + Routing::Curved => VectorNetwork::from_path(&rel), + _ => VectorNetwork::from_points(&rel.elements().iter().filter_map(PathEl::end_point).collect::>(), false), + }; + let t = Affine::translate((b.x0, b.y0)); + let same = + n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network); + if same { + return; + } + if let Some(n) = doc.node_mut(id) { + n.kind = NodeKind::Vector { network }; + n.transform = t; + (n.width, n.height) = (b.width(), b.height()); + } +} + +/// How far an elbow connector going around the objects it joins keeps from them. +const ROOM: f64 = 24.0; + +/// The page points connector `c` runs through: from the middle of the side of each attached layer +/// facing the other end (or the side it names), straight, in right angles, or (curved) its two +/// ends with the curve's two control points between them. +pub fn route(doc: &Document, c: &Connector) -> Option> { + let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n)); + let centre = |e: &End, b: Option| match e { + End::Point { x, y } => Some(Point::new(*x, *y)), + End::Node { .. } => b.map(|b| b.center()), + }; + let (fb, tb) = (bounds(&c.from), bounds(&c.to)); + let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?); + let (p0, s0) = attach(&c.from, fb, tc)?; + let (p1, s1) = attach(&c.to, tb, fc)?; + let pts = match c.routing { + Routing::Straight => tidy(vec![p0, p1]), + Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::>()), + Routing::Curved => curve(p0, s0, p1, s1), + }; + pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts) +} + +/// `pts` without repeats or turns that don't turn (three in a line across or down). +fn tidy(pts: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(pts.len()); + for p in pts { + if out.last() == Some(&p) { + continue; + } + if let [.., a, b] = out.as_slice() + && ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y)) + { + out.pop(); + } + out.push(p); + } + out +} + +/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that +/// side (`None`: a free point). +fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { + match e { + End::Point { x, y } => Some((Point::new(*x, *y), None)), + End::Node { side, .. } => { + let b = b?; + let side = match side { + Side::Auto => { + let d = other - b.center(); + // Compare against the box's proportions: a wide box takes the sides more. + if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) { + if d.x >= 0.0 { Side::Right } else { Side::Left } + } else if d.y >= 0.0 { + Side::Bottom + } else { + Side::Top + } + } + s => *s, + }; + Some((side.point(b), Some(side))) + } + } +} + +/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no +/// way): the ends and the two control points between them. +fn curve(p0: Point, s0: Option, p1: Point, s1: Option) -> Vec { + let reach = (p1 - p0).hypot() / 2.0; + let pull = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach); + vec![p0, pull(p0, s0), pull(p1, s1), p1] +} + +/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a +/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`, +/// else the cheapest of a few ways around them (by length, and turns). +fn elbow(p0: Point, s0: Option, p1: Point, s1: Option, boxes: &[Rect]) -> Vec { + let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal))); + let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct }; + if !crosses(&direct, boxes) { + return direct; + } + // Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects. + let out = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM); + let (a, b) = (out(p0, s0), out(p1, s1)); + let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]]; + for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] { + ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]); + } + for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] { + ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]); + } + let cost = |r: &Vec| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::() + ROOM * r.len() as f64; + ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct) +} + +/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge, +/// or out of one, is outside). +fn crosses(pts: &[Point], boxes: &[Rect]) -> bool { + const EDGE: f64 = 0.5; + pts.windows(2).any(|s| { + let (a, b) = (s[0], s[1]); + boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE) + }) +} + +/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end +/// follows the other): halfway across between two sides facing the same way, one turn otherwise. +fn short_elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { + let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs()); + let h1 = h1.unwrap_or(h0); + match (h0, h1) { + (true, true) => { + let mx = (p0.x + p1.x) / 2.0; + vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1] + } + (false, false) => { + let my = (p0.y + p1.y) / 2.0; + vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1] + } + (true, false) => vec![p0, Point::new(p1.x, p0.y), p1], + (false, true) => vec![p0, Point::new(p0.x, p1.y), p1], + } +} diff --git a/crates/engine/src/boards/board/edit.rs b/crates/engine/src/boards/board/edit.rs new file mode 100644 index 00000000..1770852c --- /dev/null +++ b/crates/engine/src/boards/board/edit.rs @@ -0,0 +1,260 @@ +use super::connectors::connector_of; +use super::derived::ends_now; +use super::items::{add, add_text, shape_padding, text_of}; +use super::*; + +/// The label of connector `id`, if it has one. +pub fn label_of(doc: &Document, id: NodeId) -> Option { + let parent = doc.node(id)?.parent?; + doc.node(parent)?.children.iter().copied().find(|c| doc.node(*c).is_some_and(|n| n.board == Some(BoardItem::Label { connector: id }))) +} + +/// Give connector `id` the label `text` (empty removes it); the label's id. +pub(super) fn set_label(doc: &mut Document, id: NodeId, text: &str) -> Result> { + let existing = label_of(doc, id); + if text.trim().is_empty() { + if let Some(l) = existing { + doc.remove(l)?; + } + return Ok(None); + } + if let Some(l) = existing { + if let Some(t) = text_of(doc, l) + && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) + { + let len = td.char_count(); + td.replace_range(0, len, text); + } + return Ok(Some(l)); + } + let parent = doc.node(id).and_then(|n| n.parent).ok_or(DocError::NoNode(id))?; + let l = add(doc, parent, NodeKind::Frame, "Label", (0.0, 0.0), (40.0, 24.0), |n| { + n.fills = vec![Paint::solid(Color::WHITE)]; + n.corners.radii = [4.0; 4]; + n.clip = false; + n.layout = Some(AutoLayout { padding: [3.0, 6.0, 3.0, 6.0], ..AutoLayout::default() }); + n.child_layout.sizing_h = Sizing::Hug; + n.child_layout.sizing_v = Sizing::Hug; + n.board = Some(BoardItem::Label { connector: id }); + })?; + let t = add_text(doc, l, text, 12.0, INK, TextAlignH::Center, 40.0)?; + if let Some(n) = doc.node_mut(t) + && let NodeKind::Text(td) = &mut n.kind + { + // The label hugs its text: one line per paragraph. + td.auto_resize = TextAutoResize::WidthAndHeight; + n.child_layout.sizing_h = Sizing::Hug; + } + Ok(Some(l)) +} + +pub(super) fn label_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.label"; + require(s, cmd)?; + let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?; + let (id, _) = connector_of(cmd, s.doc()?, id)?; + let text = req_string(cmd, p, "text")?; + let label = set_label(s.doc_mut()?, id, &text)?; + Ok(json!({"id": id, "label": label})) +} + +/// The board objects among `ids`, each with its item (a label counts as its connector's text). +fn items(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, BoardItem)> { + ids.iter().filter_map(|i| doc.node(*i).and_then(|n| n.board.clone()).map(|b| (*i, b))).collect() +} + +pub(super) fn set_color(s: &mut Session, p: &Value) -> Result { + let cmd = "board.setColor"; + require(s, cmd)?; + let ids = req_targets(cmd, s, p)?; + let color = color_param(cmd, p)?.ok_or_else(|| bad(cmd, "`color` is required"))?; + let doc = s.doc_mut()?; + let mut changed = Vec::new(); + for (id, item) in items(doc, &ids) { + match item { + BoardItem::Sticky { .. } => doc.get_mut(id)?.fills = vec![Paint::solid(color)], + BoardItem::Shape { .. } => { + let Some(outline) = doc.get(id)?.children.first().copied() else { continue }; + let n = doc.get_mut(outline)?; + n.fills = vec![Paint::solid(color)]; + n.strokes = vec![Paint::solid(line_shade(color))]; + } + BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))], + BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table => continue, + } + changed.push(id); + } + if changed.is_empty() { + return Err(bad(cmd, "select stickies, shapes, connectors or ink")); + } + Ok(json!({"ids": changed})) +} + +/// What `board.setStyle` changes (each `None` leaves it). +struct Restyle { + shape: Option, + routing: Option, + size: Option, + start: Option, + end: Option, +} + +impl Restyle { + fn of(cmd: &str, p: &Value) -> Result { + let size = num(p, "fontSize") + .map(|v| if v.is_finite() && (1.0..=1000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, "`fontSize` is from 1 to 1000")) }) + .transpose()?; + Ok(Restyle { + shape: string(p, "shape").map(|v| shape_of(cmd, &v)).transpose()?, + routing: string(p, "style").map(|v| routing_of(cmd, &v)).transpose()?, + size, + start: cap_of(cmd, p, "startCap")?, + end: cap_of(cmd, p, "endCap")?, + }) + } + + /// Restyle board object `id` (`item`); whether anything about it changed. + fn apply(&self, doc: &mut Document, id: NodeId, item: BoardItem) -> Result { + let changed = match item { + BoardItem::Shape { shape } => match self.shape.filter(|n| *n != shape) { + Some(new) => { + reshape(doc, id, new)?; + true + } + None => false, + }, + BoardItem::Connector(c) => self.restyle_connector(doc, id, c)?, + _ => false, + }; + // Text sizes: shapes' and labels' text (stickies size theirs to fit). + let sized = match (self.size, &item) { + (Some(size), BoardItem::Shape { .. } | BoardItem::Label { .. }) => resize_text(doc, id, size), + _ => false, + }; + Ok(changed || sized) + } + + fn restyle_connector(&self, doc: &mut Document, id: NodeId, mut c: Connector) -> Result { + let n = doc.get_mut(id)?; + if let Some(r) = self.routing { + c.routing = r; + n.board = Some(BoardItem::Connector(c)); + } + if let Some(cap) = self.start { + n.stroke.cap_start = cap; + } + if let Some(cap) = self.end { + n.stroke.cap_end = cap; + } + Ok(self.routing.is_some() || self.start.is_some() || self.end.is_some()) + } +} + +/// Shape with text `id` takes outline `new` (its text room too). +fn reshape(doc: &mut Document, id: NodeId, new: BoardShape) -> Result<()> { + let n = doc.get_mut(id)?; + n.board = Some(BoardItem::Shape { shape: new }); + n.name = new.label().into(); + let (w, h) = (n.width, n.height); + if let Some(l) = n.layout.as_mut() { + l.padding = shape_padding(new, w, h); + } + if let Some(o) = doc.get(id)?.children.first().copied() + && let Some(on) = doc.node_mut(o) + { + on.kind = NodeKind::Vector { network: VectorNetwork::from_path(&new.path(w, h)) }; + (on.width, on.height, on.transform) = (w, h, Affine::IDENTITY); + } + Ok(()) +} + +/// Board object `id`'s text at `size`; whether it has text. +fn resize_text(doc: &mut Document, id: NodeId, size: f64) -> bool { + let Some(t) = text_of(doc, id) else { return false }; + if let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) { + set_font_size(td, size); + return true; + } + false +} + +pub(super) fn set_style(s: &mut Session, p: &Value) -> Result { + let cmd = "board.setStyle"; + require(s, cmd)?; + let ids = req_targets(cmd, s, p)?; + let restyle = Restyle::of(cmd, p)?; + let doc = s.doc_mut()?; + let mut changed = Vec::new(); + for (id, item) in items(doc, &ids) { + if restyle.apply(doc, id, item)? { + changed.push(id); + } + } + if changed.is_empty() { + return Err(bad(cmd, "nothing selected takes these (shapes with text, connectors, labels)")); + } + Ok(json!({"ids": changed})) +} + +pub(super) fn set_font_size(td: &mut TextData, size: f64) { + td.style.font_size = size; + for r in &mut td.runs { + r.style.font_size = size; + } +} + +/// Each layer of `sources` (copied one by one, in order, as `copies`) and its copy: the whole +/// subtrees pair up when they have the same shape (an instance made of a main component may not; +/// then only the roots do). +pub(crate) fn copy_map(doc: &Document, sources: &[NodeId], copies: &[NodeId]) -> BTreeMap { + let mut map = BTreeMap::new(); + for (s, c) in sources.iter().zip(copies) { + let (a, b) = (doc.subtree(*s), doc.subtree(*c)); + if a.len() == b.len() { + map.extend(a.into_iter().zip(b)); + } else { + map.insert(*s, *c); + } + } + map +} + +/// The connectors and labels among copied layers, as `(copy, the original's board item)`, from the +/// originals in `doc` (`map`: original → copy). +pub(crate) fn copied_items(doc: &Document, map: &BTreeMap) -> Vec<(NodeId, BoardItem)> { + map.iter().filter_map(|(o, c)| doc.node(*o).and_then(|n| n.board.clone()).map(|b| (*c, b))).collect() +} + +/// After a copy (`map`: each copied original → its copy; `originals`: the copies' items as the +/// originals had them), copied connectors join the copies of what they joined and labels follow +/// the copies of their connectors. From another file (`foreign`), what wasn't copied isn't here: a +/// connector's end there stays where it is drawn, as a point, and a label without its connector +/// goes. +pub(crate) fn rejoin(doc: &mut Document, originals: &[(NodeId, BoardItem)], map: &BTreeMap, foreign: bool) { + for (copy, item) in originals { + let mut item = item.clone(); + let copied = |n: NodeId| map.contains_key(&n); + match &mut item { + BoardItem::Connector(c) if foreign => { + let ends = ends_now(doc, *copy); + for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] { + if let (End::Node { node, .. }, Some(p)) = (*end, at) + && !copied(node) + { + *end = End::Point { x: p.x, y: p.y }; + } + } + } + BoardItem::Label { connector } if foreign && !copied(*connector) => { + let _ = doc.remove(*copy); + continue; + } + BoardItem::Connector(_) | BoardItem::Label { .. } => {} + _ => continue, + } + item.remap(map); + if let Some(n) = doc.node_mut(*copy) { + n.board = Some(item); + } + } +} diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs new file mode 100644 index 00000000..6d96d34b --- /dev/null +++ b/crates/engine/src/boards/board/items.rs @@ -0,0 +1,297 @@ +use super::*; + +/// A new layer at the top of `parent`, at `at` in it, made by `f`. +pub(super) fn add( + doc: &mut Document, + parent: NodeId, + kind: NodeKind, + name: &str, + at: (f64, f64), + size: (f64, f64), + f: impl FnOnce(&mut Node), +) -> Result { + let id = doc.alloc_id(); + let mut n = Node::new(id, kind, name, size.0, size.1); + n.transform = Affine::translate(at); + f(&mut n); + doc.insert(n, parent, None)?; + Ok(id) +} + +/// A soft shadow under stickies and stamps. +fn soft_shadow() -> Effect { + Effect { + kind: EffectKind::DropShadow(Shadow { color: Color::new(0.0, 0.0, 0.0, 0.16), offset: (0.0, 2.0), radius: 6.0, ..Shadow::default() }), + visible: true, + } +} + +/// A text layer filling its frame's width (the frame's auto layout places it). +pub(crate) fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result { + let first: String = text.lines().next().unwrap_or("").chars().take(64).collect(); + let name = if first.trim().is_empty() { "Text" } else { first.as_str() }; + let mut td = TextData { + characters: crate::cmd::props::normalize_line_endings(text), + auto_resize: TextAutoResize::Height, + align_h: align, + ..TextData::default() + }; + td.style.font_size = size; + let id = add(doc, parent, NodeKind::Text(td), name, (0.0, 0.0), (width.max(1.0), size * 1.4), |n| { + n.fills = vec![Paint::solid(color)]; + n.child_layout.sizing_h = Sizing::Fill; + })?; + if let Some(n) = doc.node(id) + && let Some((w, h)) = figcraft_text::auto_size(n) + && let Some(n) = doc.node_mut(id) + { + (n.width, n.height) = (w, h); + } + Ok(id) +} + +/// The text size a sticky's note gets: smaller the more it holds (by characters and lines, so +/// every copy of the file picks the same size whatever fonts it has). +pub fn sticky_font_size(text: &str) -> f64 { + let weight = text.chars().count() + 24 * text.matches('\n').count(); + match weight { + 0..=24 => 24.0, + 25..=64 => 20.0, + 65..=140 => 16.0, + 141..=280 => 13.0, + _ => 11.0, + } +} + +/// A sticky note's layers: the frame, the note and (when known) its author. +fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, color: Color, author: &str) -> Result<(NodeId, NodeId)> { + let id = add(doc, parent, NodeKind::Frame, "Sticky", at, (STICKY, STICKY), |n| { + n.fills = vec![Paint::solid(color)]; + n.corners.radii = [2.0; 4]; + n.effects = vec![soft_shadow()]; + n.clip = false; + n.layout = Some(AutoLayout { + mode: LayoutMode::Vertical, + gap: 8.0, + padding: [STICKY_PAD; 4], + main_align: MainAlign::SpaceBetween, + ..AutoLayout::default() + }); + n.child_layout.sizing_v = Sizing::Hug; + n.child_layout.min_h = Some(STICKY); + n.board = Some(BoardItem::Sticky { author: author.to_string() }); + })?; + let inner = STICKY - 2.0 * STICKY_PAD; + let note = add_text(doc, id, text, sticky_font_size(text), INK, TextAlignH::Left, inner)?; + if !author.is_empty() { + let a = add_text(doc, id, author, 10.0, AUTHOR_INK, TextAlignH::Left, inner)?; + if let Some(n) = doc.node_mut(a) { + n.name = "Author".into(); + } + } + Ok((id, note)) +} + +pub(super) fn sticky_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.sticky"; + require(s, cmd)?; + let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); + let author = match get(p, "author") { + Some(Value::String(a)) => a.trim().chars().filter(|c| !c.is_control()).take(64).collect(), + Some(Value::Bool(false)) => String::new(), + _ => author(s), + }; + let parent = parent_of(cmd, s, p)?; + let text = string(p, "text").unwrap_or_default(); + let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); + let (id, note) = make_sticky(s.doc_mut()?, parent, at, &text, color, &author)?; + s.set_selection(vec![id]); + Ok(json!({"id": id, "text": note})) +} + +/// The first text layer inside board object `id` (its note or label). +pub fn text_of(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + n.board.as_ref().filter(|b| b.has_text())?; + n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_)))) +} + +/// The board object whose text `text` is, if any. +pub fn owner_of_text(doc: &Document, text: NodeId) -> Option { + let p = doc.node(text)?.parent?; + (text_of(doc, p) == Some(text)).then_some(p) +} + +/// Start typing in board object `id`'s text (all of it selected). False when it has none. +pub fn edit_text(s: &mut Session, id: NodeId) -> Result { + let Some(t) = s.doc().ok().and_then(|d| text_of(d, id)) else { return Ok(false) }; + textedit::begin(s, t, None, false)?; + textedit::key(s, "SelectAll", false, false, false)?; + Ok(true) +} + +pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result { + let cmd = "board.nextSticky"; + require(s, cmd)?; + let editing = s.ds()?.text_edit.as_ref().map(|t| t.id); + let doc = s.doc()?; + let from = id(p, "id") + .or_else(|| editing.and_then(|t| owner_of_text(doc, t))) + .or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i))) + .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?; + let n = doc.get(from)?; + if !matches!(n.board, Some(BoardItem::Sticky { .. })) { + return Err(bad(cmd, "that isn't a sticky")); + } + let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky); + let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?; + let at = n.transform.translation(); + let (dx, dy) = match string(p, "side").as_deref().unwrap_or("right") { + "right" => (n.width + STICKY_GAP, 0.0), + "left" => (-(STICKY + STICKY_GAP), 0.0), + "below" => (0.0, n.height + STICKY_GAP), + "above" => (0.0, -(STICKY + STICKY_GAP)), + other => return Err(bad(cmd, format!("`side` is right, below, left or above, not `{other}`"))), + }; + let author = author(s); + if editing.is_some() { + textedit::end(s)?; + } + let (id, note) = make_sticky(s.doc_mut()?, parent, (at.x + dx, at.y + dy), "", color, &author)?; + s.set_selection(vec![id]); + if boolean(p, "edit") == Some(true) { + // Typing goes on in the new sticky once this step is recorded. + s.request_ui("board.editText", json!({"id": id})); + } + Ok(json!({"id": id, "text": note})) +} + +/// A shape with text's layers: the frame, its outline and its text. +fn make_shape( + doc: &mut Document, + parent: NodeId, + at: (f64, f64), + size: (f64, f64), + shape: BoardShape, + text: &str, + color: Color, +) -> Result<(NodeId, NodeId)> { + let (w, h) = size; + let id = add(doc, parent, NodeKind::Frame, shape.label(), at, size, |n| { + n.fills.clear(); + n.clip = false; + n.layout = Some(AutoLayout { + mode: LayoutMode::Vertical, + padding: shape_padding(shape, w, h), + main_align: MainAlign::Center, + cross_align: CrossAlign::Center, + ..AutoLayout::default() + }); + n.board = Some(BoardItem::Shape { shape }); + })?; + add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&shape.path(w, h)) }, "Outline", (0.0, 0.0), size, |n| { + n.fills = vec![Paint::solid(color)]; + n.strokes = vec![Paint::solid(line_shade(color))]; + n.stroke.weight = 1.5; + n.stroke.align = StrokeAlign::Inside; + n.child_layout.absolute = true; + n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::Scale }; + })?; + let [_, r, _, l] = shape_padding(shape, w, h); + let label = add_text(doc, id, text, 16.0, INK, TextAlignH::Center, (w - l - r).max(1.0))?; + Ok((id, label)) +} + +pub(super) fn shape_padding(shape: BoardShape, w: f64, h: f64) -> [f64; 4] { + let [t, r, b, l] = shape.text_insets(); + [t * h, r * w, b * h, l * w] +} + +pub(super) fn shape_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.shape"; + require(s, cmd)?; + let shape = match string(p, "shape") { + Some(v) => shape_of(cmd, &v)?, + None => s.tool.board.shape, + }; + let size = |k| { + num(p, k) + .map_or(Ok(SHAPE), |v| if (1.0..=100_000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{k}` is a size from 1 to 100000"))) }) + }; + let (w, h) = (size("width")?, size("height")?); + let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky); + let parent = parent_of(cmd, s, p)?; + let text = string(p, "text").unwrap_or_default(); + let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0)); + let (id, label) = make_shape(s.doc_mut()?, parent, at, (w, h), shape, &text, color)?; + s.set_selection(vec![id]); + Ok(json!({"id": id, "text": label})) +} + +pub(super) fn stamp_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.stamp"; + require(s, cmd)?; + let stamp = match string(p, "stamp") { + Some(v) => stamp_of(cmd, &v)?, + None => s.tool.board.stamp, + }; + let (x, y) = (req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); + let author = author(s); + let page = s.page()?; + let half = STAMP_SIZE / 2.0; + let doc = s.doc_mut()?; + let id = add(doc, page, NodeKind::Frame, stamp.label(), (x - half, y - half), (STAMP_SIZE, STAMP_SIZE), |n| { + n.fills = vec![Paint::solid(stamp.color())]; + n.corners.radii = [half; 4]; + n.effects = vec![soft_shadow()]; + n.clip = false; + n.board = Some(BoardItem::Stamp { stamp, author }); + })?; + for part in stamp.mark() { + let path = figcraft_geom::BezPath::from_svg(part.d).map_err(|e| EngineError::Other(format!("stamp mark: {e}")))?; + add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&path) }, "Mark", (0.0, 0.0), (STAMP_SIZE, STAMP_SIZE), |n| { + if part.filled { + n.fills = vec![Paint::solid(Color::WHITE)]; + } else { + n.fills.clear(); + n.strokes = vec![Paint::solid(Color::WHITE)]; + n.stroke.weight = 3.0; + (n.stroke.cap_start, n.stroke.cap_end) = (StrokeCap::Round, StrokeCap::Round); + n.stroke.join = StrokeJoin::Round; + n.stroke.align = StrokeAlign::Center; + } + })?; + } + s.set_selection(vec![id]); + Ok(json!({"id": id})) +} + +pub(super) fn ink_cmd(s: &mut Session, p: &Value) -> Result { + let cmd = "board.ink"; + require(s, cmd)?; + let high = boolean(p, "highlighter").unwrap_or(false); + let color = color_param(cmd, p)?.unwrap_or(if high { s.tool.board.highlighter } else { s.tool.board.marker }); + let (weight, opacity) = if high { HIGHLIGHTER } else { MARKER }; + if get(p, "path").is_none() && get(p, "points").is_none() { + return Err(bad(cmd, "`path` or `points` is required")); + } + let page = s.page()?; + let select = boolean(p, "select").unwrap_or(true); + let mut q = json!({"type": "vector", "parent": page, "closed": false, "select": select, "name": if high { "Highlight" } else { "Ink" }, "props": { + "fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center", + }}); + for k in ["path", "points"] { + if let (Some(v), Some(o)) = (get(p, k), q.as_object_mut()) { + o.insert(k.into(), v.clone()); + } + } + let made = crate::cmd::create::create(s, &q)?; + let id = made.get("id").and_then(id_of).ok_or_else(|| EngineError::Other("no stroke made".into()))?; + let n = s.doc_mut()?.get_mut(id)?; + n.opacity = opacity; + if high { + n.blend = BlendMode::Multiply; + } + n.board = Some(BoardItem::Ink); + Ok(json!({"id": id})) +} diff --git a/crates/engine/src/boards/board/tools.rs b/crates/engine/src/boards/board/tools.rs new file mode 100644 index 00000000..2dc904c9 --- /dev/null +++ b/crates/engine/src/boards/board/tools.rs @@ -0,0 +1,210 @@ +use super::connectors::{end_json, other_end}; +use super::derived::ends_now; +use super::edit::label_of; +use super::items::edit_text; +use super::*; + +// --------------------------------------------------------------------------------------------- +// The board tools. + +impl Session { + /// A press with a board tool: place a sticky, shape or stamp, start a connector or a stroke, + /// or start erasing. + pub(crate) fn board_down(&mut self, ev: &PointerEvent) -> Result<()> { + // A board tool kept while a design file came to the front: back to Move for this press. + if !is_board(self.doc()?) { + self.tool.tool = ToolId::Move; + return self.pointer(ev); + } + // A sticky still being typed in ends first, so the next object is an undo step of its own. + if self.ds()?.text_edit.is_some() { + crate::textedit::end(self)?; + } + match self.tool.tool { + ToolId::Sticky => { + let half = STICKY / 2.0; + let r = self.execute("board.sticky", &json!({"x": ev.x - half, "y": ev.y - half}))?; + self.board_placed(); + if let Some(id) = r.get("id").and_then(id_of) { + edit_text(self, id)?; + } + Ok(()) + } + ToolId::BoardShape => { + let half = SHAPE / 2.0; + self.execute("board.shape", &json!({"x": ev.x - half, "y": ev.y - half}))?; + self.board_placed(); + Ok(()) + } + ToolId::Stamp => { + // Stamps go on one after another: the tool stays. + self.execute("board.stamp", &json!({"x": ev.x, "y": ev.y}))?; + Ok(()) + } + ToolId::Marker | ToolId::Highlighter => { + self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; + Ok(()) + } + ToolId::Connector => { + let from = self.end_at(ev); + self.tool.gesture = Gesture::Connect { from, start: ev.p() }; + Ok(()) + } + ToolId::Eraser => { + self.begin_interaction("Erase")?; + self.tool.gesture = Gesture::Erase { gone: Vec::new() }; + self.erase_at(ev) + } + _ => Ok(()), + } + } + + /// After placing an object: back to the Move tool unless tools stay on. + fn board_placed(&mut self) { + if !self.prefs.keep_tool { + self.set_tool(ToolId::Move); + } + } + + /// What a connector end at the pointer attaches to: the object there, or the point. + fn end_at(&self, ev: &PointerEvent) -> End { + let hit = (|| { + let doc = self.doc().ok()?; + let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?; + let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false); + let n = doc.node(t)?; + (!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t) + })(); + match hit { + Some(node) => End::Node { node, side: Side::Auto }, + None => End::Point { x: ev.x, y: ev.y }, + } + } + + pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) { + let a = match from { + End::Point { x, y } => Point::new(x, y), + End::Node { .. } => start, + }; + self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]); + self.tool.overlay.drop_target = from.node(); + self.tool.gesture = Gesture::Connect { from, start }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + } + + pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> { + self.tool.overlay.rubber = None; + self.tool.overlay.drop_target = None; + // A click, not a drag: nothing to join. + if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { + return Ok(()); + } + let to = self.end_at(ev); + if to.node().is_some() && to.node() == from.node() { + return Ok(()); + } + let style = self.tool.board.routing.id(); + self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?; + self.board_placed(); + Ok(()) + } + + /// Dragging the selected connector `id`'s end (`from`: its start): a line from its other end + /// to the pointer, and the object it would attach to outlined. + pub(crate) fn reattach_drag(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) { + self.tool.gesture = Gesture::Reattach { id, from, start }; + let Ok(doc) = self.doc() else { return }; + let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a }); + let other = other_end(doc, id, from).and_then(|e| e.node()); + self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]); + self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other); + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + } + + /// The end let go: attached to the object under the pointer, or free there (`board.reattach`). + /// A click, or a drop on the object the other end is on, changes nothing. + pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> { + self.tool.overlay.rubber = None; + if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 { + return Ok(()); + } + let target = self.end_at(ev); + let other = other_end(self.doc()?, id, from).and_then(|e| e.node()); + if target.node().is_some() && target.node() == other { + return Ok(()); + } + self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?; + Ok(()) + } + + /// The eraser at the pointer: the marker and highlighter strokes it touches go (until the + /// gesture ends, which is one undo step). + pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> { + let Gesture::Erase { mut gone } = std::mem::take(&mut self.tool.gesture) else { return Ok(()) }; + let tol = ERASER_PX / ev.zoom.max(1e-6); + self.tool.overlay.brush = Some([ev.x, ev.y, tol]); + let (doc, page) = (self.doc()?.clone(), self.page()?); + if let Some(hit) = + doc.hit_test(page, ev.p(), tol, None).filter(|h| doc.node(*h).is_some_and(|n| n.board == Some(BoardItem::Ink)) && !gone.contains(h)) + { + gone.push(hit); + let ids = gone.clone(); + self.preview(|d, sel| { + for i in &ids { + let _ = d.remove(*i); + } + sel.retain(|s| !ids.contains(s)); + Ok(()) + })?; + } + self.tool.gesture = Gesture::Erase { gone }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + Ok(()) + } + + pub(crate) fn erase_up(&mut self) -> Result<()> { + self.tool.overlay.brush = None; + self.commit_interaction() + } + + /// A finished marker or highlighter stroke (page points). + pub(crate) fn ink_finish(&mut self, path: &str) -> Result<()> { + let high = self.tool.tool == ToolId::Highlighter; + self.execute("board.ink", &json!({"path": path, "highlighter": high, "select": false}))?; + // Strokes come one after another: the selection stays as it was. + Ok(()) + } +} + +impl Session { + /// A double-click with the Move tool on a board object: type in its text (a connector gets a + /// label to type in). True when it was one. + pub(crate) fn board_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId]) -> Result { + let whole = figcraft_doc::geometry::select_target(doc, leaf, sel, false); + let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| doc.is_ancestor(whole, te.id)); + let Some(item) = doc.node(whole).and_then(|n| n.board.clone()).filter(|_| !editing) else { return Ok(false) }; + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + match item { + BoardItem::Connector(_) => { + let label = match label_of(doc, whole) { + Some(l) => l, + None => { + let r = self.execute("board.label", &json!({"id": whole, "text": "Label"}))?; + r.get("label").and_then(id_of).ok_or_else(|| EngineError::Other("no label made".into()))? + } + }; + edit_text(self, label) + } + BoardItem::Table => crate::board_table::edit_cell(self, doc, leaf), + b if b.has_text() => edit_text(self, whole), + _ => Ok(false), + } + } +} diff --git a/crates/engine/src/board_table.rs b/crates/engine/src/boards/board_table.rs similarity index 100% rename from crates/engine/src/board_table.rs rename to crates/engine/src/boards/board_table.rs diff --git a/crates/engine/src/board_templates.rs b/crates/engine/src/boards/board_templates.rs similarity index 100% rename from crates/engine/src/board_templates.rs rename to crates/engine/src/boards/board_templates.rs diff --git a/crates/engine/src/canvas.rs b/crates/engine/src/canvas.rs new file mode 100644 index 00000000..b513ffbd --- /dev/null +++ b/crates/engine/src/canvas.rs @@ -0,0 +1,14 @@ +pub mod al_keys; +pub mod arc_handles; +pub mod booleans; +pub mod distance; +pub mod drop; +pub mod guides; +pub mod rotation_origin; +pub mod shape_handles; +pub mod shapebuilder; +pub mod snap; +pub mod textedit; +pub mod textpreview; +pub mod tools; +pub mod vectoredit; diff --git a/crates/engine/src/al_keys.rs b/crates/engine/src/canvas/al_keys.rs similarity index 100% rename from crates/engine/src/al_keys.rs rename to crates/engine/src/canvas/al_keys.rs diff --git a/crates/engine/src/arc_handles.rs b/crates/engine/src/canvas/arc_handles.rs similarity index 100% rename from crates/engine/src/arc_handles.rs rename to crates/engine/src/canvas/arc_handles.rs diff --git a/crates/engine/src/booleans.rs b/crates/engine/src/canvas/booleans.rs similarity index 100% rename from crates/engine/src/booleans.rs rename to crates/engine/src/canvas/booleans.rs diff --git a/crates/engine/src/distance.rs b/crates/engine/src/canvas/distance.rs similarity index 100% rename from crates/engine/src/distance.rs rename to crates/engine/src/canvas/distance.rs diff --git a/crates/engine/src/drop.rs b/crates/engine/src/canvas/drop.rs similarity index 100% rename from crates/engine/src/drop.rs rename to crates/engine/src/canvas/drop.rs diff --git a/crates/engine/src/guides.rs b/crates/engine/src/canvas/guides.rs similarity index 100% rename from crates/engine/src/guides.rs rename to crates/engine/src/canvas/guides.rs diff --git a/crates/engine/src/rotation_origin.rs b/crates/engine/src/canvas/rotation_origin.rs similarity index 100% rename from crates/engine/src/rotation_origin.rs rename to crates/engine/src/canvas/rotation_origin.rs diff --git a/crates/engine/src/shape_handles.rs b/crates/engine/src/canvas/shape_handles.rs similarity index 100% rename from crates/engine/src/shape_handles.rs rename to crates/engine/src/canvas/shape_handles.rs diff --git a/crates/engine/src/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs similarity index 100% rename from crates/engine/src/shapebuilder.rs rename to crates/engine/src/canvas/shapebuilder.rs diff --git a/crates/engine/src/snap.rs b/crates/engine/src/canvas/snap.rs similarity index 100% rename from crates/engine/src/snap.rs rename to crates/engine/src/canvas/snap.rs diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/canvas/textedit.rs similarity index 53% rename from crates/engine/src/textedit.rs rename to crates/engine/src/canvas/textedit.rs index 2cd9bf97..91e411e5 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/canvas/textedit.rs @@ -796,576 +796,4 @@ pub fn selected_range(s: &Session) -> Option<(NodeId, usize, usize)> { } #[cfg(test)] -mod tests { - use super::*; - use crate::tools::{PointerEvent, PointerKind}; - use figcraft_doc::{ListStyle, TextAutoResize, TextCase, TextDecoration}; - use serde_json::json; - - fn setup(text: &str) -> (Session, NodeId) { - let mut s = Session::new(); - let r = s.execute("text.create", &json!({"text": text, "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap(); - let id = crate::params::id_of(&r["id"]).unwrap(); - (s, id) - } - - fn data(s: &Session, id: NodeId) -> TextData { - s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone() - } - - fn sel(s: &Session) -> (usize, usize) { - let te = s.ds().unwrap().text_edit.clone().unwrap(); - (te.anchor, te.caret) - } - - #[test] - fn whole_editing_session_is_one_undo_step() { - let (mut s, id) = setup("Hello"); - let undo_before = s.ds().unwrap().history.undo.len(); - begin(&mut s, id, Some(5), false).unwrap(); - insert(&mut s, " big").unwrap(); - insert(&mut s, " world").unwrap(); - key(&mut s, "Backspace", false, false, false).unwrap(); - // Styling a range mid-session joins the same step. - set_caret(&mut s, 0, false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); - s.execute("text.bold", &json!({})).unwrap(); - s.execute("text.alignCenter", &json!({})).unwrap(); - end(&mut s).unwrap(); - let t = data(&s, id); - assert_eq!(t.characters, "Hello big worl"); - assert_eq!(t.style_at(0).font_style, "Bold"); - assert_eq!(t.style_at(6).font_style, "Regular"); - assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); - s.undo().unwrap(); - let t = data(&s, id); - assert_eq!(t.characters, "Hello"); - assert!(t.runs.is_empty()); - assert_eq!(t.align_h, figcraft_doc::TextAlignH::Left); - } - - #[test] - fn typing_replaces_selection_and_paste_is_plain() { - let (mut s, id) = setup("Hello world"); - begin(&mut s, id, None, false).unwrap(); - assert_eq!(sel(&s), (0, 11), "begin without caret selects all"); - select_word(&mut s, 7).unwrap(); - assert_eq!(sel(&s), (6, 11)); - insert(&mut s, "there").unwrap(); - assert_eq!(data(&s, id).characters, "Hello there"); - assert_eq!(sel(&s), (11, 11)); - // Pasted text: CRLF / CR / Unicode separators become newlines, other controls are dropped. - insert(&mut s, "\r\na\rb\u{2029}c\u{0}\u{7}\td").unwrap(); - assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); - insert(&mut s, "\u{1}").unwrap(); - assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); - // Select the line (paragraph) then type over it. - select_line(&mut s, 0).unwrap(); - assert_eq!(sel(&s), (0, 11)); - insert(&mut s, "Hi").unwrap(); - assert_eq!(data(&s, id).characters, "Hi\na\nb\nc\td"); - assert_eq!(selected_text(&s).as_deref(), Some("")); - end(&mut s).unwrap(); - } - - #[test] - fn word_selection_classes() { - let (mut s, id) = setup("foo, bar_baz!\nnext"); - begin(&mut s, id, Some(0), false).unwrap(); - select_word(&mut s, 1).unwrap(); - assert_eq!(sel(&s), (0, 3)); - // Right after a word's last char selects the word. - select_word(&mut s, 13).unwrap(); - assert_eq!(sel(&s), (6, 13)); - // A run of spaces. - select_word(&mut s, 4).unwrap(); - assert_eq!(sel(&s), (4, 6)); - select_line(&mut s, 16).unwrap(); - assert_eq!(sel(&s), (15, 19)); - select_line(&mut s, 999).unwrap(); - assert_eq!(sel(&s), (15, 19)); - select_word(&mut s, 999).unwrap(); - assert_eq!(sel(&s), (15, 19)); - set_caret(&mut s, 999, false).unwrap(); - assert_eq!(sel(&s), (19, 19), "carets clamp to the text"); - } - - #[test] - fn keyboard_navigation_and_extension() { - let (mut s, id) = setup("one two\nthree"); - begin(&mut s, id, Some(0), false).unwrap(); - key(&mut s, "Right", false, true, false).unwrap(); // Alt+Right: end of word - assert_eq!(sel(&s), (3, 3)); - key(&mut s, "Right", true, true, false).unwrap(); // Shift+Alt+Right extends - assert_eq!(sel(&s), (3, 7)); - key(&mut s, "Left", false, false, false).unwrap(); // collapses to the start - assert_eq!(sel(&s), (3, 3)); - key(&mut s, "Right", false, false, true).unwrap(); // Cmd+Right: line end - assert_eq!(sel(&s), (7, 7)); - key(&mut s, "Down", true, false, true).unwrap(); // Shift+Cmd+Down: to the end - assert_eq!(sel(&s), (7, 13)); - key(&mut s, "Left", false, false, true).unwrap(); // Cmd+Left from the collapsed start - assert_eq!(sel(&s), (0, 0)); - key(&mut s, "Delete", false, true, false).unwrap(); // Alt+Delete: word forward - assert_eq!(data(&s, id).characters, " two\nthree"); - key(&mut s, "SelectAll", false, false, false).unwrap(); - assert_eq!(sel(&s), (0, 10)); - end(&mut s).unwrap(); - } - - #[test] - fn backspace_removes_only_the_final_thai_mark() { - let (mut s, id) = setup("น้ำ"); - begin(&mut s, id, Some(3), false).unwrap(); - - key(&mut s, "Backspace", false, false, false).unwrap(); - - assert_eq!(data(&s, id).characters, "น้"); - } - - fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent { - let mut e = PointerEvent::at(kind, x, y, 1.0); - e.shift = shift; - e - } - - /// Asserts double-clicking a text layer selects all text, a second double-click selects the - /// word without changing the editing session, and drag and Shift+click extend the - /// selection. - #[test] - fn pointer_double_click_drag_and_shift_click() { - let (mut s, id) = setup("Hello world"); - let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(id).unwrap()) else { return }; - if l.glyphs.is_empty() { - return; // no fonts - } - let y = l.lines[0].top + l.lines[0].height / 2.0; - let x_of = |i: usize| l.caret(i).0; - // Double-click inside "world" edits with all the text selected; another one there - // selects the word. - let mid = (x_of(7) + x_of(8)) / 2.0; - s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); - assert_eq!(sel(&s), (0, 11)); - let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session; - s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); - s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); - assert_eq!(sel(&s), (6, 11)); - assert_eq!( - s.ds().unwrap().text_edit.as_ref().unwrap().editing_session, - epoch, - "a double-click inside the edit is an ordinary edit: same IME replacement epoch" - ); - // Drag-select from the start of "Hello" to the middle. - s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap(); - assert_eq!(sel(&s), (0, 0)); - s.pointer(&ev(PointerKind::Move, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); - s.pointer(&ev(PointerKind::Up, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); - assert_eq!(sel(&s), (0, 3)); - // Shift+click extends. - s.pointer(&ev(PointerKind::Down, x_of(9) + 0.5, y, true)).unwrap(); - s.pointer(&ev(PointerKind::Up, x_of(9) + 0.5, y, true)).unwrap(); - assert_eq!(sel(&s), (0, 9)); - insert(&mut s, "J").unwrap(); - assert_eq!(data(&s, id).characters, "Jld"); - } - - #[test] - fn text_commands_apply_to_edited_range() { - let (mut s, id) = setup("Hello world"); - begin(&mut s, id, Some(0), false).unwrap(); - set_caret(&mut s, 6, true).unwrap(); // "Hello " - for (cmd, p) in [ - ("text.underline", json!({})), - ("text.italic", json!({})), - ("text.sizeUp", json!({})), - ("text.trackingUp", json!({})), - ("text.lineHeightUp", json!({})), - ("text.caseSmallCaps", json!({})), - ("text.link", json!({"url": "https://example.com"})), - ] { - s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); - } - let t = data(&s, id); - let (a, b) = (t.style_at(0), t.style_at(8)); - assert_eq!(a.decoration, TextDecoration::Underline); - assert!(a.italic() && !b.italic()); - assert_eq!(a.font_size, 25.0); - assert_eq!(b.font_size, 24.0); - assert_eq!(a.case, TextCase::SmallCaps); - assert_eq!(a.link.as_deref(), Some("https://example.com")); - assert!(b.link.is_none()); - // text.style with an explicit range (what the panel sends). - s.execute("text.style", &json!({"id": id, "start": 6, "end": 11, "fontSize": 40, "textCase": "upper"})).unwrap(); - let t = data(&s, id); - assert_eq!(t.style_at(8).font_size, 40.0); - assert_eq!(t.style_at(8).case, TextCase::Upper); - // Paragraph commands hit the edited node. - s.execute("text.listStyle", &json!({"style": "numbered"})).unwrap(); - assert_eq!(data(&s, id).list, ListStyle::Numbered); - // Every Text-menu command runs while editing. - for item in crate::catalog::MENU.iter().filter(|i| i.id.starts_with("text.")) { - let p = if item.id == "text.link" { json!({"url": "u"}) } else { json!({}) }; - if crate::find_command(item.id).is_some() { - s.execute(item.id, &p).unwrap_or_else(|e| panic!("{}: {e}", item.id)); - } - } - end(&mut s).unwrap(); - } - - #[test] - fn paragraph_and_feature_commands_on_nodes() { - let (mut s, id) = setup("Hello"); - let ids = json!([id]); - s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": false, "tnum": true}})).unwrap(); - assert_eq!(data(&s, id).features.get("liga"), Some(&false)); - s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": null, "ss01": 1}})).unwrap(); - let f = data(&s, id).features; - assert_eq!(f.get("liga"), None); - assert_eq!(f.get("ss01"), Some(&true)); - assert_eq!(f.get("tnum"), Some(&true)); - s.execute("text.setFeatures", &json!({"ids": ids, "features": {"zero": true}, "replace": true})).unwrap(); - assert_eq!(data(&s, id).features.len(), 1); - assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"ligatures": true}})).is_err()); - assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": "yes"}})).is_err()); - assert!(s.execute("text.setFeatures", &json!({"ids": ids})).is_err()); - s.execute("node.set", &json!({"id": id, "props": {"features": {"calt": false}}})).unwrap(); - assert_eq!(data(&s, id).features.len(), 1); - s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": 16})).unwrap(); - s.execute("text.paragraphSpacing", &json!({"ids": ids, "spacing": 8})).unwrap(); - s.execute("text.maxLines", &json!({"ids": ids, "lines": 2})).unwrap(); - s.execute("text.decoration", &json!({"ids": ids, "decoration": "strikethrough"})).unwrap(); - s.execute("text.case", &json!({"ids": ids, "case": "smallCaps"})).unwrap(); - s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap(); - let t = data(&s, id); - assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2)); - assert_eq!(t.auto_resize, TextAutoResize::Truncate); - assert_eq!(t.style.decoration, TextDecoration::Strikethrough); - assert_eq!(t.style.case, TextCase::SmallCaps); - assert_eq!(t.list, ListStyle::Bulleted); - assert!(s.execute("text.listStyle", &json!({"ids": ids, "style": "zigzag"})).is_err()); - assert!(s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": "NaN"})).is_err()); - s.execute("node.set", &json!({"id": id, "props": {"textCase": "smallCaps", "paragraphIndent": 4, "maxLines": 0}})).unwrap(); - assert_eq!(data(&s, id).paragraph_indent, 4.0); - } - - fn preedit_state(s: &Session) -> Option { - s.ds().unwrap().text_edit.clone().unwrap().preedit - } - - #[test] - fn ime_composition_is_shown_but_not_in_the_document_until_committed() { - let (mut s, id) = setup("Hello world"); - let undo_before = s.ds().unwrap().history.undo.len(); - begin(&mut s, id, Some(5), false).unwrap(); - let doc_before = s.ds().unwrap().doc.clone(); - s.execute("text.edit.preedit", &json!({"text": "にほ", "cursor": 1})).unwrap(); - assert_eq!(preedit_state(&s), Some(Preedit { text: "にほ".into(), cursor: 1 })); - assert!(std::sync::Arc::ptr_eq(&doc_before, &s.ds().unwrap().doc), "composing leaves the document alone"); - let (shown, range, cursor) = preedit_doc(&s).unwrap(); - assert_eq!(shown.node(id).unwrap().kind.text().unwrap().characters, "Helloにほ world"); - assert_eq!((range, cursor), ((5, 7), 6)); - let info = s.execute("text.edit.selection", &json!({})).unwrap(); - assert_eq!(info["preedit"]["text"], "にほ"); - // Updating the composition replaces it; an empty one cancels it. - preedit(&mut s, "にほん", None).unwrap(); - assert_eq!(preedit_doc(&s).unwrap().2, 8); - preedit(&mut s, "", None).unwrap(); - assert!(preedit_state(&s).is_none() && preedit_doc(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello world"); - // Commit puts the final text at the caret. - preedit(&mut s, "にほん", None).unwrap(); - s.execute("text.edit.insert", &json!({"text": "日本"})).unwrap(); - assert!(preedit_state(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello日本 world"); - assert_eq!(sel(&s), (7, 7)); - // Composing over a selection replaces it; moving the caret abandons the composition. - set_caret(&mut s, 7, false).unwrap(); - set_caret(&mut s, 13, true).unwrap(); - preedit(&mut s, "x", None).unwrap(); - assert_eq!(data(&s, id).characters, "Hello日本"); - set_caret(&mut s, 0, false).unwrap(); - assert!(preedit_state(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello日本"); - // Ending the edit drops an uncommitted composition; the session stays one undo step. - preedit(&mut s, "zz", None).unwrap(); - end(&mut s).unwrap(); - assert_eq!(data(&s, id).characters, "Hello日本"); - assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); - // Not editing: an error, not a crash. - assert!(preedit(&mut s, "a", None).is_err()); - } - - #[test] - fn variable_font_axes_as_commands_and_props() { - let (mut s, id) = setup("Hello world"); - let ids = json!([id]); - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 550, "wdth": 85}})).unwrap(); - let st = data(&s, id).style; - assert_eq!((st.variations.get("wght"), st.variations.get("wdth")), (Some(&550.0), Some(&85.0))); - assert_eq!(st.weight(), 550); - // null removes one axis; replace clears the rest. - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wdth": null}})).unwrap(); - assert_eq!(data(&s, id).style.variations.len(), 1); - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"opsz": 14}, "replace": true})).unwrap(); - assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["opsz"]); - // Undo restores the previous values. - s.undo().unwrap(); - assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["wght"]); - // Junk is rejected. - assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"weight": 500}})).is_err()); - assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": "bold"}})).is_err()); - assert!(s.execute("text.setVariations", &json!({"ids": ids})).is_err()); - // node.set props (whole text or a range) replace the custom values. - s.execute("node.set", &json!({"id": id, "props": {"fontVariations": {"wght": 300}, "start": 6, "end": 11}})).unwrap(); - let t = data(&s, id); - assert_eq!(t.style_at(0).variations.get("wght"), Some(&550.0)); - assert_eq!(t.style_at(7).variations.get("wght"), Some(&300.0)); - s.execute("node.set", &json!({"id": id, "props": {"variationSettings": null}})).unwrap(); - assert!(data(&s, id).runs.is_empty() && data(&s, id).style.variations.is_empty()); - // Picking a named style (Bold, weight steps) drops the custom weight but keeps width. - s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 650, "wdth": 90}})).unwrap(); - s.execute("text.bold", &json!({"ids": ids})).unwrap(); - let st = data(&s, id).style; - assert_eq!(st.font_style, "Bold", "650 is lighter than bold, so Bold applies"); - assert!(!st.variations.contains_key("wght")); - assert_eq!(st.variations.get("wdth"), Some(&90.0)); - // While editing, the command styles the selected range only. - begin(&mut s, id, Some(0), false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); - s.execute("text.setVariations", &json!({"variations": {"wdth": 75}})).unwrap(); - end(&mut s).unwrap(); - let t = data(&s, id); - assert_eq!(t.style_at(0).variations.get("wdth"), Some(&75.0)); - assert_eq!(t.style_at(8).variations.get("wdth"), Some(&90.0)); - // The axes query answers without fonts too. - let r = s.execute("text.fonts", &json!({"family": "Inter", "axes": true})).unwrap(); - assert!(r.is_null() || r["axes"].is_array()); - } - - /// No text command panics on junk parameters while a text is being edited. - #[test] - fn text_commands_survive_junk_while_editing() { - let junk = [ - json!({}), - json!(null), - json!({"index": -5, "features": {"": 1}}), - json!({"index": 1e300, "style": 3, "case": null}), - json!([1]), - json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}), - json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}), - ]; - // `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow. - for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") { - for j in &junk { - let (mut s, id) = setup("Hi there\nyou"); - begin(&mut s, id, Some(2), false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); - if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) { - panic!("{cmd} panicked on {j}: {msg}"); - } - } - } - } - - /// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed - /// selection reversed. - #[test] - fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() { - let (mut s, id) = setup("café😀e\u{301}x"); - begin(&mut s, id, Some(8), false).unwrap(); - set_caret(&mut s, 8, false).unwrap(); - set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text, "café😀e\u{301}x"); - assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes"); - assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed"); - assert_eq!(ime.owner.node, id); - assert_eq!(ime.owner.document, s.ds().unwrap().uid); - } - - /// Asserts an active composition is excluded from surrounding text and an empty text layer - /// reports an available, empty payload. - #[test] - fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() { - let (mut s, id) = setup("Hi"); - begin(&mut s, id, Some(2), false).unwrap(); - preedit(&mut s, "にほ", Some(1)).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text, "Hi", "the composition is not committed text"); - assert_eq!((ime.cursor, ime.anchor), (2, 2)); - end(&mut s).unwrap(); - - let (mut s, id) = setup(""); - begin(&mut s, id, Some(0), false).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable"); - } - - /// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized - /// selection is describable, and one byte more is `SelectionTooWide`. - #[test] - fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() { - let long = "a".repeat(5000); - let (mut s, id) = setup(&long); - begin(&mut s, id, Some(5000), false).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES); - assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES)); - - // Exactly the budget is still describable; one more byte is not. - set_caret(&mut s, 1000, false).unwrap(); - set_caret(&mut s, 5000, true).unwrap(); - let ime = ime_surrounding(&s).unwrap(); - assert_eq!(ime.text.len(), 4000); - assert_eq!((ime.cursor, ime.anchor), (4000, 0)); - set_caret(&mut s, 999, false).unwrap(); - set_caret(&mut s, 5000, true).unwrap(); - assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide)); - } - - /// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new - /// edit session changes the generation. - #[test] - fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() { - let (mut s, id) = setup("Hello"); - begin(&mut s, id, Some(0), false).unwrap(); - let first = ime_surrounding(&s).unwrap().owner; - insert(&mut s, "x").unwrap(); - set_caret(&mut s, 2, false).unwrap(); - assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner"); - end(&mut s).unwrap(); - begin(&mut s, id, Some(0), false).unwrap(); - let replaced = ime_surrounding(&s).unwrap().owner; - assert_eq!(replaced.node, id); - assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch"); - } - - /// Asserts byte deletion around a reversed selection removes whole characters on both - /// sides, keeps the selected text and direction, and a final no-op leaves the text alone. - #[test] - fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() { - let (mut s, id) = setup("ab你好cd"); - begin(&mut s, id, Some(4), false).unwrap(); - set_caret(&mut s, 4, false).unwrap(); - set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好 - assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); - // "b" (whole character before the selection) and "c" (whole character after it) go; the - // selected "你好" stays for the paired commit, and the reversed direction is preserved. - assert_eq!(data(&s, id).characters, "a你好d"); - assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed - assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); - assert_eq!(data(&s, id).characters, "你好"); - assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed - assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection"); - assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text"); - } - - /// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an - /// active composition and the revision untouched. - #[test] - fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() { - let (mut s, id) = setup("ab你好cd"); - begin(&mut s, id, Some(2), false).unwrap(); - set_caret(&mut s, 4, true).unwrap(); // "你好" - assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap()); - assert_eq!(data(&s, id).characters, "a你好d"); - assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix"); - set_caret(&mut s, 0, false).unwrap(); - // A composition in progress survives a delete that finds nothing to remove. - preedit(&mut s, "x", None).unwrap(); - let revision = s.ds().unwrap().revision; - assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap()); - assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); - assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision"); - assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap()); - assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); - assert_eq!(s.ds().unwrap().revision, revision); - } - - /// Asserts a text layer in another document has a different IME owner. - #[test] - fn ime_owner_identity_changes_with_the_document() { - let (mut s, id) = setup("Hi"); - begin(&mut s, id, Some(2), false).unwrap(); - let first = ime_surrounding(&s).unwrap().owner; - end(&mut s).unwrap(); - s.execute("file.new", &json!({"name": "Second"})).unwrap(); - let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap(); - let other = crate::params::id_of(&r["id"]).unwrap(); - begin(&mut s, other, Some(5), false).unwrap(); - let second = ime_surrounding(&s).unwrap().owner; - assert_ne!(second.document, first.document, "another document is another IME owner"); - assert_eq!(second.node, other); - } - - /// Asserts a space committed after a line-start `-` becomes a bulleted list and reports - /// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`. - #[test] - fn ime_commit_reports_list_autoformat_as_an_application_transformation() { - let (mut s, id) = setup("-"); - begin(&mut s, id, Some(1), false).unwrap(); - assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed); - assert_eq!(data(&s, id).characters, ""); - assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted); - assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain); - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - } - - /// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`, - /// and bare newline/CR commits are ignored while a live composition remains. - #[test] - fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() { - let (mut s, id) = setup("Hi"); - begin(&mut s, id, Some(2), false).unwrap(); - // CRLF is inserted as LF, but the editor changed the commit: application-origin. - assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed); - assert_eq!(data(&s, id).characters, "Hia\nb"); - // Control characters are dropped; the commit itself is a no-op. - assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); - assert_eq!(data(&s, id).characters, "Hia\nb"); - // Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one. - // (It also leaves a live composition alone, unlike the non-ignored commit below.) - preedit(&mut s, "x", None).unwrap(); - assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing); - assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing); - assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into())); - preedit(&mut s, "", None).unwrap(); - assert_eq!(data(&s, id).characters, "Hia\nb"); - insert(&mut s, "\n").unwrap(); - assert_eq!(data(&s, id).characters, "Hia\nb\n"); - } - - /// Asserts an empty or control-only IME commit ends a live composition (bumping the revision - /// once), inserts nothing, and is a harmless no-op when no composition is active. - #[test] - fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { - let (mut s, id) = setup("Hello"); - begin(&mut s, id, Some(5), false).unwrap(); - preedit(&mut s, "にほ", None).unwrap(); - assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into())); - let revision = s.ds().unwrap().revision; - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision"); - assert!(preedit_state(&s).is_none(), "an empty commit ends the composition"); - assert!(preedit_doc(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing"); - - // A control-only commit normalises to empty and ends the composition too. - preedit(&mut s, "x", None).unwrap(); - let revision = s.ds().unwrap().revision; - assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); - assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision"); - assert!(preedit_state(&s).is_none()); - assert_eq!(data(&s, id).characters, "Hello"); - - // With no composition (and not even while editing) an empty commit stays a harmless no-op. - let revision = s.ds().unwrap().revision; - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - assert_eq!(s.ds().unwrap().revision, revision); - end(&mut s).unwrap(); - assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); - } -} +mod tests; diff --git a/crates/engine/src/canvas/textedit/tests.rs b/crates/engine/src/canvas/textedit/tests.rs new file mode 100644 index 00000000..fecca580 --- /dev/null +++ b/crates/engine/src/canvas/textedit/tests.rs @@ -0,0 +1,571 @@ +use super::*; +use crate::tools::{PointerEvent, PointerKind}; +use figcraft_doc::{ListStyle, TextAutoResize, TextCase, TextDecoration}; +use serde_json::json; + +fn setup(text: &str) -> (Session, NodeId) { + let mut s = Session::new(); + let r = s.execute("text.create", &json!({"text": text, "x": 0, "y": 0, "props": {"fontSize": 24}})).unwrap(); + let id = crate::params::id_of(&r["id"]).unwrap(); + (s, id) +} + +fn data(s: &Session, id: NodeId) -> TextData { + s.doc().unwrap().node(id).unwrap().kind.text().unwrap().clone() +} + +fn sel(s: &Session) -> (usize, usize) { + let te = s.ds().unwrap().text_edit.clone().unwrap(); + (te.anchor, te.caret) +} + +#[test] +fn whole_editing_session_is_one_undo_step() { + let (mut s, id) = setup("Hello"); + let undo_before = s.ds().unwrap().history.undo.len(); + begin(&mut s, id, Some(5), false).unwrap(); + insert(&mut s, " big").unwrap(); + insert(&mut s, " world").unwrap(); + key(&mut s, "Backspace", false, false, false).unwrap(); + // Styling a range mid-session joins the same step. + set_caret(&mut s, 0, false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); + s.execute("text.bold", &json!({})).unwrap(); + s.execute("text.alignCenter", &json!({})).unwrap(); + end(&mut s).unwrap(); + let t = data(&s, id); + assert_eq!(t.characters, "Hello big worl"); + assert_eq!(t.style_at(0).font_style, "Bold"); + assert_eq!(t.style_at(6).font_style, "Regular"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); + s.undo().unwrap(); + let t = data(&s, id); + assert_eq!(t.characters, "Hello"); + assert!(t.runs.is_empty()); + assert_eq!(t.align_h, figcraft_doc::TextAlignH::Left); +} + +#[test] +fn typing_replaces_selection_and_paste_is_plain() { + let (mut s, id) = setup("Hello world"); + begin(&mut s, id, None, false).unwrap(); + assert_eq!(sel(&s), (0, 11), "begin without caret selects all"); + select_word(&mut s, 7).unwrap(); + assert_eq!(sel(&s), (6, 11)); + insert(&mut s, "there").unwrap(); + assert_eq!(data(&s, id).characters, "Hello there"); + assert_eq!(sel(&s), (11, 11)); + // Pasted text: CRLF / CR / Unicode separators become newlines, other controls are dropped. + insert(&mut s, "\r\na\rb\u{2029}c\u{0}\u{7}\td").unwrap(); + assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); + insert(&mut s, "\u{1}").unwrap(); + assert_eq!(data(&s, id).characters, "Hello there\na\nb\nc\td"); + // Select the line (paragraph) then type over it. + select_line(&mut s, 0).unwrap(); + assert_eq!(sel(&s), (0, 11)); + insert(&mut s, "Hi").unwrap(); + assert_eq!(data(&s, id).characters, "Hi\na\nb\nc\td"); + assert_eq!(selected_text(&s).as_deref(), Some("")); + end(&mut s).unwrap(); +} + +#[test] +fn word_selection_classes() { + let (mut s, id) = setup("foo, bar_baz!\nnext"); + begin(&mut s, id, Some(0), false).unwrap(); + select_word(&mut s, 1).unwrap(); + assert_eq!(sel(&s), (0, 3)); + // Right after a word's last char selects the word. + select_word(&mut s, 13).unwrap(); + assert_eq!(sel(&s), (6, 13)); + // A run of spaces. + select_word(&mut s, 4).unwrap(); + assert_eq!(sel(&s), (4, 6)); + select_line(&mut s, 16).unwrap(); + assert_eq!(sel(&s), (15, 19)); + select_line(&mut s, 999).unwrap(); + assert_eq!(sel(&s), (15, 19)); + select_word(&mut s, 999).unwrap(); + assert_eq!(sel(&s), (15, 19)); + set_caret(&mut s, 999, false).unwrap(); + assert_eq!(sel(&s), (19, 19), "carets clamp to the text"); +} + +#[test] +fn keyboard_navigation_and_extension() { + let (mut s, id) = setup("one two\nthree"); + begin(&mut s, id, Some(0), false).unwrap(); + key(&mut s, "Right", false, true, false).unwrap(); // Alt+Right: end of word + assert_eq!(sel(&s), (3, 3)); + key(&mut s, "Right", true, true, false).unwrap(); // Shift+Alt+Right extends + assert_eq!(sel(&s), (3, 7)); + key(&mut s, "Left", false, false, false).unwrap(); // collapses to the start + assert_eq!(sel(&s), (3, 3)); + key(&mut s, "Right", false, false, true).unwrap(); // Cmd+Right: line end + assert_eq!(sel(&s), (7, 7)); + key(&mut s, "Down", true, false, true).unwrap(); // Shift+Cmd+Down: to the end + assert_eq!(sel(&s), (7, 13)); + key(&mut s, "Left", false, false, true).unwrap(); // Cmd+Left from the collapsed start + assert_eq!(sel(&s), (0, 0)); + key(&mut s, "Delete", false, true, false).unwrap(); // Alt+Delete: word forward + assert_eq!(data(&s, id).characters, " two\nthree"); + key(&mut s, "SelectAll", false, false, false).unwrap(); + assert_eq!(sel(&s), (0, 10)); + end(&mut s).unwrap(); +} + +#[test] +fn backspace_removes_only_the_final_thai_mark() { + let (mut s, id) = setup("น้ำ"); + begin(&mut s, id, Some(3), false).unwrap(); + + key(&mut s, "Backspace", false, false, false).unwrap(); + + assert_eq!(data(&s, id).characters, "น้"); +} + +fn ev(kind: PointerKind, x: f64, y: f64, shift: bool) -> PointerEvent { + let mut e = PointerEvent::at(kind, x, y, 1.0); + e.shift = shift; + e +} + +/// Asserts double-clicking a text layer selects all text, a second double-click selects the +/// word without changing the editing session, and drag and Shift+click extend the +/// selection. +#[test] +fn pointer_double_click_drag_and_shift_click() { + let (mut s, id) = setup("Hello world"); + let Some((l, _)) = figcraft_text::layout_node(s.doc().unwrap().node(id).unwrap()) else { return }; + if l.glyphs.is_empty() { + return; // no fonts + } + let y = l.lines[0].top + l.lines[0].height / 2.0; + let x_of = |i: usize| l.caret(i).0; + // Double-click inside "world" edits with all the text selected; another one there + // selects the word. + let mid = (x_of(7) + x_of(8)) / 2.0; + s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); + assert_eq!(sel(&s), (0, 11)); + let epoch = s.ds().unwrap().text_edit.as_ref().unwrap().editing_session; + s.pointer(&ev(PointerKind::Down, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::DoubleClick, mid, y, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, mid, y, false)).unwrap(); + assert_eq!(sel(&s), (6, 11)); + assert_eq!( + s.ds().unwrap().text_edit.as_ref().unwrap().editing_session, + epoch, + "a double-click inside the edit is an ordinary edit: same IME replacement epoch" + ); + // Drag-select from the start of "Hello" to the middle. + s.pointer(&ev(PointerKind::Down, x_of(0) + 0.5, y, false)).unwrap(); + assert_eq!(sel(&s), (0, 0)); + s.pointer(&ev(PointerKind::Move, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, (x_of(3) + x_of(4)) / 2.0 - 1.0, y, false)).unwrap(); + assert_eq!(sel(&s), (0, 3)); + // Shift+click extends. + s.pointer(&ev(PointerKind::Down, x_of(9) + 0.5, y, true)).unwrap(); + s.pointer(&ev(PointerKind::Up, x_of(9) + 0.5, y, true)).unwrap(); + assert_eq!(sel(&s), (0, 9)); + insert(&mut s, "J").unwrap(); + assert_eq!(data(&s, id).characters, "Jld"); +} + +#[test] +fn text_commands_apply_to_edited_range() { + let (mut s, id) = setup("Hello world"); + begin(&mut s, id, Some(0), false).unwrap(); + set_caret(&mut s, 6, true).unwrap(); // "Hello " + for (cmd, p) in [ + ("text.underline", json!({})), + ("text.italic", json!({})), + ("text.sizeUp", json!({})), + ("text.trackingUp", json!({})), + ("text.lineHeightUp", json!({})), + ("text.caseSmallCaps", json!({})), + ("text.link", json!({"url": "https://example.com"})), + ] { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")); + } + let t = data(&s, id); + let (a, b) = (t.style_at(0), t.style_at(8)); + assert_eq!(a.decoration, TextDecoration::Underline); + assert!(a.italic() && !b.italic()); + assert_eq!(a.font_size, 25.0); + assert_eq!(b.font_size, 24.0); + assert_eq!(a.case, TextCase::SmallCaps); + assert_eq!(a.link.as_deref(), Some("https://example.com")); + assert!(b.link.is_none()); + // text.style with an explicit range (what the panel sends). + s.execute("text.style", &json!({"id": id, "start": 6, "end": 11, "fontSize": 40, "textCase": "upper"})).unwrap(); + let t = data(&s, id); + assert_eq!(t.style_at(8).font_size, 40.0); + assert_eq!(t.style_at(8).case, TextCase::Upper); + // Paragraph commands hit the edited node. + s.execute("text.listStyle", &json!({"style": "numbered"})).unwrap(); + assert_eq!(data(&s, id).list, ListStyle::Numbered); + // Every Text-menu command runs while editing. + for item in crate::catalog::MENU.iter().filter(|i| i.id.starts_with("text.")) { + let p = if item.id == "text.link" { json!({"url": "u"}) } else { json!({}) }; + if crate::find_command(item.id).is_some() { + s.execute(item.id, &p).unwrap_or_else(|e| panic!("{}: {e}", item.id)); + } + } + end(&mut s).unwrap(); +} + +#[test] +fn paragraph_and_feature_commands_on_nodes() { + let (mut s, id) = setup("Hello"); + let ids = json!([id]); + s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": false, "tnum": true}})).unwrap(); + assert_eq!(data(&s, id).features.get("liga"), Some(&false)); + s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": null, "ss01": 1}})).unwrap(); + let f = data(&s, id).features; + assert_eq!(f.get("liga"), None); + assert_eq!(f.get("ss01"), Some(&true)); + assert_eq!(f.get("tnum"), Some(&true)); + s.execute("text.setFeatures", &json!({"ids": ids, "features": {"zero": true}, "replace": true})).unwrap(); + assert_eq!(data(&s, id).features.len(), 1); + assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"ligatures": true}})).is_err()); + assert!(s.execute("text.setFeatures", &json!({"ids": ids, "features": {"liga": "yes"}})).is_err()); + assert!(s.execute("text.setFeatures", &json!({"ids": ids})).is_err()); + s.execute("node.set", &json!({"id": id, "props": {"features": {"calt": false}}})).unwrap(); + assert_eq!(data(&s, id).features.len(), 1); + s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": 16})).unwrap(); + s.execute("text.paragraphSpacing", &json!({"ids": ids, "spacing": 8})).unwrap(); + s.execute("text.maxLines", &json!({"ids": ids, "lines": 2})).unwrap(); + s.execute("text.decoration", &json!({"ids": ids, "decoration": "strikethrough"})).unwrap(); + s.execute("text.case", &json!({"ids": ids, "case": "smallCaps"})).unwrap(); + s.execute("text.listStyle", &json!({"ids": ids, "style": "bulleted"})).unwrap(); + let t = data(&s, id); + assert_eq!((t.paragraph_indent, t.paragraph_spacing, t.max_lines), (16.0, 8.0, 2)); + assert_eq!(t.auto_resize, TextAutoResize::Truncate); + assert_eq!(t.style.decoration, TextDecoration::Strikethrough); + assert_eq!(t.style.case, TextCase::SmallCaps); + assert_eq!(t.list, ListStyle::Bulleted); + assert!(s.execute("text.listStyle", &json!({"ids": ids, "style": "zigzag"})).is_err()); + assert!(s.execute("text.paragraphIndent", &json!({"ids": ids, "indent": "NaN"})).is_err()); + s.execute("node.set", &json!({"id": id, "props": {"textCase": "smallCaps", "paragraphIndent": 4, "maxLines": 0}})).unwrap(); + assert_eq!(data(&s, id).paragraph_indent, 4.0); +} + +fn preedit_state(s: &Session) -> Option { + s.ds().unwrap().text_edit.clone().unwrap().preedit +} + +#[test] +fn ime_composition_is_shown_but_not_in_the_document_until_committed() { + let (mut s, id) = setup("Hello world"); + let undo_before = s.ds().unwrap().history.undo.len(); + begin(&mut s, id, Some(5), false).unwrap(); + let doc_before = s.ds().unwrap().doc.clone(); + s.execute("text.edit.preedit", &json!({"text": "にほ", "cursor": 1})).unwrap(); + assert_eq!(preedit_state(&s), Some(Preedit { text: "にほ".into(), cursor: 1 })); + assert!(std::sync::Arc::ptr_eq(&doc_before, &s.ds().unwrap().doc), "composing leaves the document alone"); + let (shown, range, cursor) = preedit_doc(&s).unwrap(); + assert_eq!(shown.node(id).unwrap().kind.text().unwrap().characters, "Helloにほ world"); + assert_eq!((range, cursor), ((5, 7), 6)); + let info = s.execute("text.edit.selection", &json!({})).unwrap(); + assert_eq!(info["preedit"]["text"], "にほ"); + // Updating the composition replaces it; an empty one cancels it. + preedit(&mut s, "にほん", None).unwrap(); + assert_eq!(preedit_doc(&s).unwrap().2, 8); + preedit(&mut s, "", None).unwrap(); + assert!(preedit_state(&s).is_none() && preedit_doc(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello world"); + // Commit puts the final text at the caret. + preedit(&mut s, "にほん", None).unwrap(); + s.execute("text.edit.insert", &json!({"text": "日本"})).unwrap(); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello日本 world"); + assert_eq!(sel(&s), (7, 7)); + // Composing over a selection replaces it; moving the caret abandons the composition. + set_caret(&mut s, 7, false).unwrap(); + set_caret(&mut s, 13, true).unwrap(); + preedit(&mut s, "x", None).unwrap(); + assert_eq!(data(&s, id).characters, "Hello日本"); + set_caret(&mut s, 0, false).unwrap(); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello日本"); + // Ending the edit drops an uncommitted composition; the session stays one undo step. + preedit(&mut s, "zz", None).unwrap(); + end(&mut s).unwrap(); + assert_eq!(data(&s, id).characters, "Hello日本"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo_before + 1); + // Not editing: an error, not a crash. + assert!(preedit(&mut s, "a", None).is_err()); +} + +#[test] +fn variable_font_axes_as_commands_and_props() { + let (mut s, id) = setup("Hello world"); + let ids = json!([id]); + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 550, "wdth": 85}})).unwrap(); + let st = data(&s, id).style; + assert_eq!((st.variations.get("wght"), st.variations.get("wdth")), (Some(&550.0), Some(&85.0))); + assert_eq!(st.weight(), 550); + // null removes one axis; replace clears the rest. + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wdth": null}})).unwrap(); + assert_eq!(data(&s, id).style.variations.len(), 1); + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"opsz": 14}, "replace": true})).unwrap(); + assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["opsz"]); + // Undo restores the previous values. + s.undo().unwrap(); + assert_eq!(data(&s, id).style.variations.keys().collect::>(), ["wght"]); + // Junk is rejected. + assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"weight": 500}})).is_err()); + assert!(s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": "bold"}})).is_err()); + assert!(s.execute("text.setVariations", &json!({"ids": ids})).is_err()); + // node.set props (whole text or a range) replace the custom values. + s.execute("node.set", &json!({"id": id, "props": {"fontVariations": {"wght": 300}, "start": 6, "end": 11}})).unwrap(); + let t = data(&s, id); + assert_eq!(t.style_at(0).variations.get("wght"), Some(&550.0)); + assert_eq!(t.style_at(7).variations.get("wght"), Some(&300.0)); + s.execute("node.set", &json!({"id": id, "props": {"variationSettings": null}})).unwrap(); + assert!(data(&s, id).runs.is_empty() && data(&s, id).style.variations.is_empty()); + // Picking a named style (Bold, weight steps) drops the custom weight but keeps width. + s.execute("text.setVariations", &json!({"ids": ids, "variations": {"wght": 650, "wdth": 90}})).unwrap(); + s.execute("text.bold", &json!({"ids": ids})).unwrap(); + let st = data(&s, id).style; + assert_eq!(st.font_style, "Bold", "650 is lighter than bold, so Bold applies"); + assert!(!st.variations.contains_key("wght")); + assert_eq!(st.variations.get("wdth"), Some(&90.0)); + // While editing, the command styles the selected range only. + begin(&mut s, id, Some(0), false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); + s.execute("text.setVariations", &json!({"variations": {"wdth": 75}})).unwrap(); + end(&mut s).unwrap(); + let t = data(&s, id); + assert_eq!(t.style_at(0).variations.get("wdth"), Some(&75.0)); + assert_eq!(t.style_at(8).variations.get("wdth"), Some(&90.0)); + // The axes query answers without fonts too. + let r = s.execute("text.fonts", &json!({"family": "Inter", "axes": true})).unwrap(); + assert!(r.is_null() || r["axes"].is_array()); +} + +/// No text command panics on junk parameters while a text is being edited. +#[test] +fn text_commands_survive_junk_while_editing() { + let junk = [ + json!({}), + json!(null), + json!({"index": -5, "features": {"": 1}}), + json!({"index": 1e300, "style": 3, "case": null}), + json!([1]), + json!({"variations": {"wght": "heavy", "toolong": 1}, "text": "\u{0}\u{7}", "cursor": 1e300}), + json!({"variations": {"wght": 1e300, "opsz": -1e300}, "replace": "yes", "text": "日本語", "cursor": -1}), + ]; + // `text.rescanFonts` takes no params and walks the font folders: one walk per junk is slow. + for spec in crate::cmd::all_commands().iter().filter(|c| c.id.starts_with("text.") && c.id != "text.rescanFonts") { + for j in &junk { + let (mut s, id) = setup("Hi there\nyou"); + begin(&mut s, id, Some(2), false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); + if let Err(EngineError::Internal { cmd, msg }) = s.execute(spec.id, j) { + panic!("{cmd} panicked on {j}: {msg}"); + } + } + } +} + +/// Asserts surrounding text is logical UTF-8 text with byte offsets and keeps a reversed +/// selection reversed. +#[test] +fn ime_surrounding_uses_logical_utf8_bytes_and_keeps_selection_direction() { + let (mut s, id) = setup("café😀e\u{301}x"); + begin(&mut s, id, Some(8), false).unwrap(); + set_caret(&mut s, 8, false).unwrap(); + set_caret(&mut s, 5, true).unwrap(); // anchor after the emoji, caret before it + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text, "café😀e\u{301}x"); + assert_eq!((ime.cursor, ime.anchor), (9, 13), "char 5 and char 8 as UTF-8 bytes"); + assert!(ime.cursor < ime.anchor, "the reversed selection stays reversed"); + assert_eq!(ime.owner.node, id); + assert_eq!(ime.owner.document, s.ds().unwrap().uid); +} + +/// Asserts an active composition is excluded from surrounding text and an empty text layer +/// reports an available, empty payload. +#[test] +fn ime_surrounding_excludes_the_composition_and_empty_text_is_available() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + preedit(&mut s, "にほ", Some(1)).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text, "Hi", "the composition is not committed text"); + assert_eq!((ime.cursor, ime.anchor), (2, 2)); + end(&mut s).unwrap(); + + let (mut s, id) = setup(""); + begin(&mut s, id, Some(0), false).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!((ime.text.as_str(), ime.cursor, ime.anchor), ("", 0, 0), "an empty layer is available, not unavailable"); +} + +/// Asserts surrounding text is capped at `MAX_IME_SURROUNDING_BYTES`, an exactly budget-sized +/// selection is describable, and one byte more is `SelectionTooWide`. +#[test] +fn ime_surrounding_is_bounded_and_a_too_wide_selection_is_explicitly_unavailable() { + let long = "a".repeat(5000); + let (mut s, id) = setup(&long); + begin(&mut s, id, Some(5000), false).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text.len(), MAX_IME_SURROUNDING_BYTES); + assert_eq!((ime.cursor, ime.anchor), (MAX_IME_SURROUNDING_BYTES, MAX_IME_SURROUNDING_BYTES)); + + // Exactly the budget is still describable; one more byte is not. + set_caret(&mut s, 1000, false).unwrap(); + set_caret(&mut s, 5000, true).unwrap(); + let ime = ime_surrounding(&s).unwrap(); + assert_eq!(ime.text.len(), 4000); + assert_eq!((ime.cursor, ime.anchor), (4000, 0)); + set_caret(&mut s, 999, false).unwrap(); + set_caret(&mut s, 5000, true).unwrap(); + assert_eq!(ime_surrounding(&s), Err(ImeSurroundingUnavailable::SelectionTooWide)); +} + +/// Asserts edits and caret moves keep the same IME owner, while ending and beginning a new +/// edit session changes the generation. +#[test] +fn ime_owner_is_stable_through_edits_and_the_session_changes_on_begin() { + let (mut s, id) = setup("Hello"); + begin(&mut s, id, Some(0), false).unwrap(); + let first = ime_surrounding(&s).unwrap().owner; + insert(&mut s, "x").unwrap(); + set_caret(&mut s, 2, false).unwrap(); + assert_eq!(ime_surrounding(&s).unwrap().owner, first, "edits and caret moves keep the owner"); + end(&mut s).unwrap(); + begin(&mut s, id, Some(0), false).unwrap(); + let replaced = ime_surrounding(&s).unwrap().owner; + assert_eq!(replaced.node, id); + assert_ne!(replaced.editing_session, first.editing_session, "a new edit session is a replacement epoch"); +} + +/// Asserts byte deletion around a reversed selection removes whole characters on both +/// sides, keeps the selected text and direction, and a final no-op leaves the text alone. +#[test] +fn ime_delete_surrounding_bytes_keeps_the_reversed_selection_and_deletes_around_it() { + let (mut s, id) = setup("ab你好cd"); + begin(&mut s, id, Some(4), false).unwrap(); + set_caret(&mut s, 4, false).unwrap(); + set_caret(&mut s, 2, true).unwrap(); // reversed: caret before 你, anchor after 好 + assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); + // "b" (whole character before the selection) and "c" (whole character after it) go; the + // selected "你好" stays for the paired commit, and the reversed direction is preserved. + assert_eq!(data(&s, id).characters, "a你好d"); + assert_eq!(sel(&s), (3, 1)); // (anchor, caret), still reversed + assert!(delete_surrounding_bytes(&mut s, 1, 1).unwrap()); + assert_eq!(data(&s, id).characters, "你好"); + assert_eq!(sel(&s), (2, 0)); // (anchor, caret), still reversed + assert!(!delete_surrounding_bytes(&mut s, 1, 1).unwrap(), "nothing is left around the selection"); + assert_eq!(data(&s, id).characters, "你好", "a no-op delete does not touch the text"); +} + +/// Asserts character deletion keeps and shifts the selection, and a no-op delete leaves an +/// active composition and the revision untouched. +#[test] +fn ime_delete_surrounding_chars_keeps_the_selection_and_ignores_a_no_op() { + let (mut s, id) = setup("ab你好cd"); + begin(&mut s, id, Some(2), false).unwrap(); + set_caret(&mut s, 4, true).unwrap(); // "你好" + assert!(delete_surrounding_chars(&mut s, 1, 1).unwrap()); + assert_eq!(data(&s, id).characters, "a你好d"); + assert_eq!(sel(&s), (1, 3), "the selection is kept, shifted by the deleted prefix"); + set_caret(&mut s, 0, false).unwrap(); + // A composition in progress survives a delete that finds nothing to remove. + preedit(&mut s, "x", None).unwrap(); + let revision = s.ds().unwrap().revision; + assert!(!delete_surrounding_chars(&mut s, 1, 0).unwrap()); + assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); + assert_eq!(s.ds().unwrap().revision, revision, "a no-op does not bump the revision"); + assert!(!delete_surrounding_bytes(&mut s, 1, 0).unwrap()); + assert_eq!(s.ds().unwrap().text_edit.as_ref().and_then(|te| te.preedit.as_ref()).map(|p| p.text.as_str()), Some("x")); + assert_eq!(s.ds().unwrap().revision, revision); +} + +/// Asserts a text layer in another document has a different IME owner. +#[test] +fn ime_owner_identity_changes_with_the_document() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + let first = ime_surrounding(&s).unwrap().owner; + end(&mut s).unwrap(); + s.execute("file.new", &json!({"name": "Second"})).unwrap(); + let r = s.execute("text.create", &json!({"text": "There", "x": 0, "y": 0})).unwrap(); + let other = crate::params::id_of(&r["id"]).unwrap(); + begin(&mut s, other, Some(5), false).unwrap(); + let second = ime_surrounding(&s).unwrap().owner; + assert_ne!(second.document, first.document, "another document is another IME owner"); + assert_eq!(second.node, other); +} + +/// Asserts a space committed after a line-start `-` becomes a bulleted list and reports +/// `Transformed`; a plain `"x"` commit reports `Plain`, and an empty commit reports `Nothing`. +#[test] +fn ime_commit_reports_list_autoformat_as_an_application_transformation() { + let (mut s, id) = setup("-"); + begin(&mut s, id, Some(1), false).unwrap(); + assert_eq!(insert_ime_commit(&mut s, " ").unwrap(), ImeInsertion::Transformed); + assert_eq!(data(&s, id).characters, ""); + assert_eq!(data(&s, id).para_list(0).list, ListStyle::Bulleted); + assert_eq!(insert_ime_commit(&mut s, "x").unwrap(), ImeInsertion::Plain); + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); +} + +/// Asserts CRLF normalization reports `Transformed`, control-only input reports `Nothing`, +/// and bare newline/CR commits are ignored while a live composition remains. +#[test] +fn ime_commit_normalisation_is_a_transformation_and_a_bare_newline_is_ignored() { + let (mut s, id) = setup("Hi"); + begin(&mut s, id, Some(2), false).unwrap(); + // CRLF is inserted as LF, but the editor changed the commit: application-origin. + assert_eq!(insert_ime_commit(&mut s, "a\r\nb").unwrap(), ImeInsertion::Transformed); + assert_eq!(data(&s, id).characters, "Hia\nb"); + // Control characters are dropped; the commit itself is a no-op. + assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); + assert_eq!(data(&s, id).characters, "Hia\nb"); + // Stock `TextEdit` ignores a bare newline/CR commit; typing Enter still inserts one. + // (It also leaves a live composition alone, unlike the non-ignored commit below.) + preedit(&mut s, "x", None).unwrap(); + assert_eq!(insert_ime_commit(&mut s, "\n").unwrap(), ImeInsertion::Nothing); + assert_eq!(insert_ime_commit(&mut s, "\r").unwrap(), ImeInsertion::Nothing); + assert_eq!(preedit_state(&s).map(|p| p.text), Some("x".into())); + preedit(&mut s, "", None).unwrap(); + assert_eq!(data(&s, id).characters, "Hia\nb"); + insert(&mut s, "\n").unwrap(); + assert_eq!(data(&s, id).characters, "Hia\nb\n"); +} + +/// Asserts an empty or control-only IME commit ends a live composition (bumping the revision +/// once), inserts nothing, and is a harmless no-op when no composition is active. +#[test] +fn an_empty_ime_commit_ends_the_composition_without_inserting_text() { + let (mut s, id) = setup("Hello"); + begin(&mut s, id, Some(5), false).unwrap(); + preedit(&mut s, "にほ", None).unwrap(); + assert_eq!(preedit_state(&s).map(|p| p.text), Some("にほ".into())); + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision + 1, "ending a live composition bumps the revision"); + assert!(preedit_state(&s).is_none(), "an empty commit ends the composition"); + assert!(preedit_doc(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello", "and inserts nothing"); + + // A control-only commit normalises to empty and ends the composition too. + preedit(&mut s, "x", None).unwrap(); + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "\u{0}\u{7}").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision + 1, "clearing a live composition bumps the revision"); + assert!(preedit_state(&s).is_none()); + assert_eq!(data(&s, id).characters, "Hello"); + + // With no composition (and not even while editing) an empty commit stays a harmless no-op. + let revision = s.ds().unwrap().revision; + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); + assert_eq!(s.ds().unwrap().revision, revision); + end(&mut s).unwrap(); + assert_eq!(insert_ime_commit(&mut s, "").unwrap(), ImeInsertion::Nothing); +} diff --git a/crates/engine/src/textpreview.rs b/crates/engine/src/canvas/textpreview.rs similarity index 100% rename from crates/engine/src/textpreview.rs rename to crates/engine/src/canvas/textpreview.rs diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs new file mode 100644 index 00000000..fce14f7c --- /dev/null +++ b/crates/engine/src/canvas/tools.rs @@ -0,0 +1,578 @@ +//! Pointer tools. The UI (or an agent through the control channel) feeds [`PointerEvent`]s in page +//! coordinates; tools turn gestures into document edits through interactions (one undo step per +//! gesture) and publish overlay hints (marquee, guides, size label, cursor) for the canvas. + +use std::collections::BTreeMap; + +use figcraft_color::{ImageScaleMode, PaintKind}; +use figcraft_doc::geometry::select_target; +use figcraft_doc::*; +use figcraft_geom::{Affine, Point, Rect, Vec2}; +use serde::Serialize; +use serde_json::json; + +use crate::arc_handles::{ArcHandle, ArcHint}; +use crate::cmd::arrange::move_world; +use crate::cmd::create::frame_at; +use crate::cmd::edit::roots; +use crate::shape_handles::{ShapeHandle, ShapeHint}; +use crate::snap::{Guide, Targets}; +use crate::{Result, Session, textedit}; + +mod click; +mod drag; +mod draw; +mod handles; +mod keys; +mod pen; +mod pointer; + +use handles::DRAG_SLOP_PX; +#[cfg(test)] +pub(crate) use handles::scale_factor; +pub use handles::{ + PENCIL_STROKE, SMALL_BOX_PX, anchor_name, anchor_of, box_sides_px, handle_at, handle_at_corners, label_at, locked_in, scale_anchor_point, + selection_box, selection_frame, +}; +pub(crate) use handles::{SNAP_PX, handle_cursor, readable_text_fills, resized_box}; +#[cfg(test)] +pub(crate) use pen::pressure_profile; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)] +pub enum ToolId { + #[default] + Move, + Scale, + Hand, + Frame, + Section, + Slice, + Rectangle, + Line, + Arrow, + Ellipse, + Polygon, + Star, + Pen, + Pencil, + Text, + TextOnPath, + Comment, + Eyedropper, + ShapeBuilder, + // Board tools. + Sticky, + BoardShape, + Connector, + Marker, + Highlighter, + Eraser, + Stamp, +} + +impl ToolId { + pub const ALL: [ToolId; 26] = [ + ToolId::Move, + ToolId::Scale, + ToolId::Hand, + ToolId::Frame, + ToolId::Section, + ToolId::Slice, + ToolId::Rectangle, + ToolId::Line, + ToolId::Arrow, + ToolId::Ellipse, + ToolId::Polygon, + ToolId::Star, + ToolId::Pen, + ToolId::Pencil, + ToolId::Text, + ToolId::TextOnPath, + ToolId::Comment, + ToolId::Eyedropper, + ToolId::ShapeBuilder, + ToolId::Sticky, + ToolId::BoardShape, + ToolId::Connector, + ToolId::Marker, + ToolId::Highlighter, + ToolId::Eraser, + ToolId::Stamp, + ]; + pub fn id(self) -> &'static str { + match self { + ToolId::Move => "move", + ToolId::Scale => "scale", + ToolId::Hand => "hand", + ToolId::Frame => "frame", + ToolId::Section => "section", + ToolId::Slice => "slice", + ToolId::Rectangle => "rectangle", + ToolId::Line => "line", + ToolId::Arrow => "arrow", + ToolId::Ellipse => "ellipse", + ToolId::Polygon => "polygon", + ToolId::Star => "star", + ToolId::Pen => "pen", + ToolId::Pencil => "pencil", + ToolId::Text => "text", + ToolId::TextOnPath => "textOnPath", + ToolId::Comment => "comment", + ToolId::Eyedropper => "eyedropper", + ToolId::ShapeBuilder => "shapeBuilder", + ToolId::Sticky => "sticky", + ToolId::BoardShape => "shape", + ToolId::Connector => "connector", + ToolId::Marker => "marker", + ToolId::Highlighter => "highlighter", + ToolId::Eraser => "eraser", + ToolId::Stamp => "stamp", + } + } + pub fn from_id(s: &str) -> Option { + ToolId::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s)) + } + pub fn label(self) -> &'static str { + match self { + ToolId::Move => "Move", + ToolId::Scale => "Scale", + ToolId::Hand => "Hand tool", + ToolId::Frame => "Frame", + ToolId::Section => "Section", + ToolId::Slice => "Slice", + ToolId::Rectangle => "Rectangle", + ToolId::Line => "Line", + ToolId::Arrow => "Arrow", + ToolId::Ellipse => "Ellipse", + ToolId::Polygon => "Polygon", + ToolId::Star => "Star", + ToolId::Pen => "Pen", + ToolId::Pencil => "Pencil", + ToolId::Text => "Text", + ToolId::TextOnPath => "Text on a path", + ToolId::Comment => "Comment", + ToolId::Eyedropper => "Pick color", + ToolId::ShapeBuilder => "Shape builder", + ToolId::Sticky => "Sticky note", + ToolId::BoardShape => "Shapes", + ToolId::Connector => "Connector", + ToolId::Marker => "Marker", + ToolId::Highlighter => "Highlighter", + ToolId::Eraser => "Eraser", + ToolId::Stamp => "Stamp", + } + } + + /// The tools of a board's toolbar (a design file's toolbar has the others). + pub fn on_boards(self) -> bool { + matches!( + self, + ToolId::Sticky | ToolId::BoardShape | ToolId::Connector | ToolId::Marker | ToolId::Highlighter | ToolId::Eraser | ToolId::Stamp + ) + } + /// The tool's key (from [`crate::keymap`]; empty when it has none). + pub fn shortcut(self) -> &'static str { + let id = self.id(); + crate::keymap::KEYS + .iter() + .find(|b| if self == ToolId::Eyedropper { b.id == "edit.pickColor" } else { b.id.strip_prefix("tool.") == Some(id) }) + .and_then(|b| b.keys(crate::keymap::Platform::current()).first().copied()) + .unwrap_or("") + } + + /// The size a click (or Enter, `tool.place`) makes with a shape or frame tool. + pub fn click_size(self) -> (f64, f64) { + match self { + ToolId::Line | ToolId::Arrow => (100.0, 0.0), + ToolId::Section => (400.0, 300.0), + _ => (100.0, 100.0), + } + } + + pub fn draws_shape(self) -> bool { + matches!( + self, + ToolId::Frame + | ToolId::Section + | ToolId::Slice + | ToolId::Rectangle + | ToolId::Ellipse + | ToolId::Polygon + | ToolId::Star + | ToolId::Line + | ToolId::Arrow + ) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub enum PointerKind { + Down, + Move, + Up, + /// Pointer moved without a button. + Hover, + DoubleClick, +} + +/// A pointer event in page coordinates (and screen coordinates for panning). +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct PointerEvent { + pub kind: PointerKind, + pub x: f64, + pub y: f64, + /// Screen position (logical pixels). + pub sx: f64, + pub sy: f64, + pub shift: bool, + pub alt: bool, + /// Cmd on macOS, Ctrl elsewhere. + pub cmd: bool, + pub ctrl: bool, + /// Screen pixels per page unit. + pub zoom: f64, + /// Pen pressure, 0..1, where the device reports it (`None`: a mouse, or a platform that doesn't). + pub pressure: Option, +} + +/// The pointer is still within [`DRAG_SLOP_PX`] of where it was pressed. +fn within_slop(ev: &PointerEvent, start: Point) -> bool { + (ev.p() - start).hypot() * ev.zoom.max(1e-6) < DRAG_SLOP_PX +} + +impl PointerEvent { + pub fn at(kind: PointerKind, x: f64, y: f64, zoom: f64) -> Self { + Self { kind, x, y, sx: x * zoom, sy: y * zoom, shift: false, alt: false, cmd: false, ctrl: false, zoom, pressure: None } + } + pub(crate) fn p(&self) -> Point { + Point::new(self.x, self.y) + } + fn tol(&self) -> f64 { + 1.0 / self.zoom.max(1e-6) + } +} + +/// Cursor hint for the UI. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Default)] +pub enum Cursor { + #[default] + Default, + Crosshair, + Move, + /// Resize along an angle (degrees, 0 = horizontal). + Resize(f64), + /// Rotate, turned to the corner it is near: the direction from the box's centre to the corner + /// (while rotating: to the pointer), in degrees, 0 = right and 90 = down. + Rotate(f64), + Grab, + Grabbing, + Text, + Pen(PenCursor), + /// A drag here makes a copy (Alt). + Copy, + /// Over a clickable part of a widget. + Pointer, + Eyedropper, + Comment, + /// What the gesture is doing will be undone when it ends (an edit only a main component can + /// make). + NotAllowed, +} + +/// What a click with the pen would do. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Default)] +pub enum PenCursor { + /// A new point. + #[default] + New, + /// On a point: closes the path, connects to the point or goes on from it. + Close, + /// On a segment: adds a point there. + Add, + /// Bends the segment or the point's handles (the Bend tool; Cmd/Ctrl in vector edit mode). + Bend, +} + +impl ToolId { + /// The tool's cursor over empty canvas. + pub fn cursor(self) -> Cursor { + match self { + ToolId::Hand => Cursor::Grab, + ToolId::Pen => Cursor::Pen(PenCursor::New), + ToolId::Comment => Cursor::Comment, + ToolId::Eyedropper => Cursor::Eyedropper, + // Text on a path acts only over a path, where it shows the I-beam. + ToolId::Move | ToolId::Scale | ToolId::TextOnPath => Cursor::Default, + // The other tools make something where they are pressed: shapes, frames, slices, pencil + // strokes, a new text (over a text the Text tool shows the I-beam instead). + _ => Cursor::Crosshair, + } + } +} + +/// Selection handle kinds: corners (0 tl, 1 tr, 2 br, 3 bl), edges (0 t, 1 r, 2 b, 3 l) and the +/// rotation zones outside the corners. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub enum Handle { + Corner(u8), + Edge(u8), + Rotate(u8), +} + +/// What the canvas should draw on top of the scene. +#[derive(Clone, Debug, Default, Serialize)] +pub struct Overlay { + pub marquee: Option<[f64; 4]>, + pub guides: Vec, + pub cursor: Cursor, + /// Pen tool: the in-progress path's anchor points and the rubber band to the pointer. + pub pen: Vec<[f64; 2]>, + pub rubber: Option<[f64; 4]>, + /// Pen tool: the handles shaping the segments at the pen (anchor x, y, handle x, y): the last + /// anchor's in and out handles and the one before's out handle. + pub pen_handles: Vec<[f64; 4]>, + /// Pen tool: where the last anchor's out handle ends, when it has one: the rubber band is the + /// curve a click would make, leaving along it. + pub rubber_handle: Option<[f64; 2]>, + /// Frame/section the pointer is drawing or dropping into (highlighted). + pub drop_target: Option, + /// Auto layout insertion indicator (x0, y0, x1, y1). + pub insert_line: Option<[f64; 4]>, + /// The instance an Alt-drop of a component from the Assets panel would swap (highlighted). + pub swap_target: Option, + /// Auto layout grid cell a drag would drop into (x0, y0, x1, y1). + pub drop_cell: Option<[f64; 4]>, + /// Vector edit mode: what the pointer is over, the lasso being drawn and the closed area the + /// paint bucket would fill (page-space polygons). + pub vector_hover: Option, + /// The edited layer `vector_hover` is on (several layers can be in edit mode). + pub vector_hover_on: Option, + /// Vector edit mode: the point a press on the outline would add (the pen's ghost anchor where + /// it is over a segment, or the midpoint of the segment under the Move tool). + pub vector_add: Option, + /// Vector edit mode, Variable width tool: the width point, handle or path under the pointer. + pub width_hover: Option, + pub lasso: Vec<[f64; 2]>, + /// The eraser brush under the pointer (x, y, radius in page units). + pub brush: Option<[f64; 3]>, + pub face: Vec>, + /// Shape builder: the regions under the pointer or collected by the drag (indexes into + /// [`crate::shapebuilder::current`]) and whether releasing removes them. + pub builder_regions: Vec, + pub builder_remove: bool, + /// Measuring (Alt): the box of the layer or frame measured to (x0, y0, x1, y1), outlined. + pub measure_box: Option<[f64; 4]>, + /// Scale tool drag: the factor so far and the point that stays put. + pub scale: Option, + /// The selected ellipse's arc handle under the pointer, and the readout while one is dragged + /// (see [`crate::arc_handles`]). + pub arc_hover: Option, + pub arc: Option, + /// The selected shape's corner radius or star ratio handle under the pointer, and the readout + /// while one is dragged (see [`crate::shape_handles`]). + pub shape_hover: Option, + pub shape: Option, +} + +/// A point a press would add to the edited vector's outline: page position, whether it is a +/// segment's midpoint (the Move tool) rather than the pen's place on its segment, and whether the +/// pointer is on it (a press adds it; otherwise it just shows where one would go). +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct VectorAdd { + pub at: [f64; 2], + pub midpoint: bool, + pub on: bool, +} + +/// The Scale tool's live readout while dragging a handle. +#[derive(Clone, Copy, Debug, PartialEq, Serialize)] +pub struct ScaleHint { + pub factor: f64, + pub pivot: [f64; 2], +} + +#[derive(Clone, Debug)] +pub(crate) struct ResizeStart { + /// Node id → (world transform, w, h) at the start. + nodes: Vec<(NodeId, Affine, f64, f64)>, + /// The handle box: page transform of its top-left corner (axes along the box) and its size. + frame: (Affine, f64, f64), + /// One layer (resized in place) rather than several (mapped from the old box to the new). + single: bool, + handle: Handle, +} + +#[derive(Clone, Debug, Default)] +pub(crate) enum Gesture { + #[default] + None, + Marquee { + start: Point, + /// Shift: toggle what it touches in the selection it started from (`base`). + additive: bool, + base: Vec, + within: Option, + }, + Move { + start: Point, + ids: Vec, + targets: Targets, + duplicate: bool, + moved: bool, + /// A Shift-press on this selected layer: it leaves the selection on release, unless the + /// press became a drag. + deselect: Option, + }, + Resize { + start: Point, + r: Box, + scale_tool: bool, + moved: bool, + }, + Rotate { + center: Point, + start_angle: f64, + ids: Vec, + }, + Draw { + start: Point, + id: Option, + parent: NodeId, + targets: Targets, + }, + Pan { + sx: f64, + sy: f64, + cx: f64, + cy: f64, + }, + Pencil { + points: Vec, + /// The pen pressure at each point, where the device reports it. + pressure: Vec>, + }, + TextSelect { + id: NodeId, + }, + /// A new text from the Text tool, still pressed: a drag makes it a box the size of the drag + /// (at least `line`, the height of its first line, tall). + TextBox { + id: NodeId, + start: Point, + line: f64, + }, + PenDrag { + anchor: Point, + }, + /// Vector edit mode gestures (handled in `vectoredit`). + Vector(crate::vectoredit::VGesture), + /// Dragging an ellipse's arc handle: the arc so far (see [`crate::arc_handles`]). + Arc { + id: NodeId, + handle: ArcHandle, + arc: crate::arc_handles::Arc, + }, + /// Dragging a shape's corner radius or star ratio handle (see [`crate::shape_handles`]). + Shape { + id: NodeId, + handle: ShapeHandle, + }, + /// Dragging the rotation origin handle (see [`crate::rotation_origin`]). + Origin, + /// The connector tool: dragging from `from` (pressed at `start`). + Connect { + from: figcraft_doc::End, + start: Point, + }, + /// Dragging the selected connector `id`'s end (`from`: its start, pressed at `start`) to + /// attach it elsewhere. + Reattach { + id: NodeId, + from: bool, + start: Point, + }, + /// The eraser: the strokes it took so far. + Erase { + gone: Vec, + }, + /// Shape builder: regions collected so far (handled in `shapebuilder`). + ShapeBuild { + picked: Vec, + remove: bool, + start: Point, + last: Point, + }, +} + +/// Where Shift puts the Pen's next point: as far from `last` as `p` is, at the nearest 45° step. +fn at_45_degrees(last: Point, p: Point) -> Point { + let v = p - last; + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + last + Vec2::from_angle(ang) * v.hypot() +} + +/// The handles a pen path shows while it is drawn ([`Overlay::pen_handles`]): the last anchor's in +/// and out handles and the one before's out handle, those that aren't zero. +fn pen_handles(points: &[(Point, Vec2, Vec2)]) -> Vec<[f64; 4]> { + let last = points.last().into_iter().flat_map(|(p, i, o)| [(p, i), (p, o)]); + let before = points.len().checked_sub(2).and_then(|k| points.get(k)).into_iter().map(|(p, _, o)| (p, o)); + before.chain(last).filter(|(_, h)| h.hypot() > 0.0).map(|(p, h)| [p.x, p.y, p.x + h.x, p.y + h.y]).collect() +} + +/// The pen tool's in-progress path (page coordinates). +#[derive(Clone, Debug, Default)] +pub struct PenPath { + pub id: Option, + pub parent: Option, + /// Anchors with (in, out) handle offsets. + pub points: Vec<(Point, Vec2, Vec2)>, + pub closed: bool, + /// The path ends connected to this anchor of its own (a vector network). + pub join: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct ToolState { + pub tool: ToolId, + pub(crate) gesture: Gesture, + pub overlay: Overlay, + pub pen: Option, + /// Tool to return to after a temporary tool (space → hand). + pub temp_from: Option, + /// Last click (for double-click detection by agents that only send downs). + pub last_hover: Option, + /// Shape builder: the cached arrangement of the selection, and Remove mode (a plain click + /// removes; Alt merges). + pub shape_builder: Option>, + pub builder_remove: bool, + /// Dev Mode: the canvas inspects. Hovering shows distances from the selection without Alt, and + /// presses select, pan and comment but never edit the design (no moving, resizing, drawing, + /// or editing text or vectors). + pub dev_mode: bool, + /// Pen snap targets, kept while hovering (see `vectoredit::point_snap`). + pub(crate) pen_targets: Option, + /// The Scale tool's anchor: column and row (0 start, 1 middle, 2 end) of the point of the + /// selection box that stays put (`tool.scaleAnchor`). + pub scale_anchor: (u8, u8), + /// The last drag move handled (a held button repeats it every frame). + pub(crate) last_move: Option, + /// The name labels of top-level frames and sections as the canvas last drew them (each label's + /// corners on the page, turned with its frame): the Move and Scale tools treat a press or + /// hover on one as on its frame. + pub labels: Vec<(NodeId, [Point; 4])>, + /// The brush the Pencil draws with (`tool.pencilBrush`; `None`: a plain stroke). + pub pencil_brush: Option, + /// The board tools' colours and choices. + pub board: crate::board::BoardTool, + /// The eyedropper samples for a colour picker (`edit.pickColor` with `apply: false`): the + /// colour goes to `picked`, for the picker to write to the paint it edits, not to the fill. + pub pick_only: bool, + /// The colour the eyedropper sampled for a colour picker, until the picker takes it. + pub picked: Option, +} + +impl ToolState { + pub fn busy(&self) -> bool { + !matches!(self.gesture, Gesture::None) + } +} diff --git a/crates/engine/src/canvas/tools/click.rs b/crates/engine/src/canvas/tools/click.rs new file mode 100644 index 00000000..8f21aaca --- /dev/null +++ b/crates/engine/src/canvas/tools/click.rs @@ -0,0 +1,344 @@ +use super::handles::{PATH_REACH_PX, label_at, locked_in, path_candidates, readable_text_fills}; +use super::*; + +impl Session { + /// Handle a double-click gesture: finish a pen path, rename or click labels/widgets, + /// cancel an in-progress move/resize/draw/marquee, handle board double-clicks, enter text + /// editing (select-all on entry, word-select inside the running edit), enter vector editing + /// for selected shapes, and step groups/frames one level deeper. + /// A double-click on `leaf` that a board object or a widget's editable field takes (typing into + /// a sticky or a shape on a board; editing the field like any text, its plugin told when editing + /// ends). Whether it was taken. + fn object_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId], p: Point, edits: bool) -> Result { + if matches!(self.tool.tool, ToolId::Move | ToolId::Scale) && self.board_double_click(doc, leaf, sel)? { + return Ok(true); + } + match crate::widgets::field_at(doc, leaf, p).filter(|_| edits) { + Some(field) => textedit::begin(self, field, None, false).map(|()| true), + None => Ok(false), + } + } + + pub(super) fn double_click(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + if self.tool.tool == ToolId::Pen { + return self.pen_finish(); + } + let edits = !self.tool.dev_mode; + // A frame's or section's name label: rename it there (the canvas puts a field over it). + // Labels come before widgets, as on a press; the second press began a move: undo it. + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && let Some(id) = label_at(&doc, page, &self.tool.labels, ev.p()) + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + if sel != [id] { + self.set_selection(vec![id]); + } + self.request_ui("canvas.renameLabel", serde_json::json!({"id": id})); + return Ok(()); + } + // Quick clicks on a widget's clickable part are clicks, one by one. + if edits + && matches!(self.tool.tool, ToolId::Move | ToolId::Scale) + && !(ev.shift || ev.cmd || ev.alt) + && self.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|hit| crate::widgets::target_at(&doc, hit, ev.p())).is_some() + { + self.tool.gesture = Gesture::None; + self.cancel_interaction()?; + self.widget_click(ev.p(), ev.tol() * 2.0)?; + return Ok(()); + } + // The second press began a move, a resize, a shape or a marquee: a double-click isn't one. + if matches!(self.tool.gesture, Gesture::Move { .. } | Gesture::Resize { .. } | Gesture::Draw { .. } | Gesture::Marquee { .. }) { + self.tool.gesture = Gesture::None; + self.tool.overlay.marquee = None; + self.cancel_interaction()?; + } + let hit_here = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + // A double-click anywhere on the one selected shape edits its points, like Enter: also + // inside a hollow (unfilled) closed shape, where the hit test finds nothing under the + // pointer (an open path or a line has no inside: only its stroke counts). + if edits + && let [id] = sel[..] + && doc.node(id).is_some_and(|n| n.kind.is_shape() && figcraft_doc::geometry::is_closed(n)) + && doc.world_bounds(id).is_some_and(|b| b.contains(ev.p())) + && hit_here.is_none_or(|h| h == id || doc.is_ancestor(id, h)) + { + return crate::vectoredit::begin_many(self, &[id]); + } + let Some(leaf) = hit_here else { return Ok(()) }; + if self.object_double_click(&doc, leaf, &sel, ev.p(), edits)? { + return Ok(()); + } + let target = select_target(&doc, leaf, &sel, true); + // Text: edit at the click. + if edits + && doc.node(target).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) + { + // Entering a text selects all of it; in the text being edited, the word. A + // double-click inside the running edit is an ordinary edit: starting a new session + // here would replace the IME owner's epoch and drop a composition on a plain gesture. + let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| te.id == target); + let idx = self.text_index_at(target, ev.p())?; + if editing { + textedit::select_word(self, idx)?; + } else { + textedit::begin(self, target, Some(idx), false)?; + textedit::key(self, "SelectAll", false, false, false)?; + } + return Ok(()); + } + // Shapes and vectors: enter vector edit mode (every selected shape, the clicked one active). + // A shape clicked on its own edits even when the click that selects it and the double-click + // arrived in one frame (a press and a release the canvas took together), so no selection was + // made yet; another layer's shapes are reached one level at a time, as below. + if edits && doc.node(target).is_some_and(|n| n.kind.is_shape()) && (sel.contains(&target) || select_target(&doc, leaf, &sel, false) == target) + { + let mut ids = if sel.contains(&target) { crate::vectoredit::enter_targets(&doc, &sel).unwrap_or_default() } else { vec![target] }; + ids.retain(|i| *i != target); + ids.insert(0, target); + return crate::vectoredit::begin_many(self, &ids); + } + // Groups, frames and boolean groups: select one level deeper (the layer under the pointer). + let deeper = figcraft_doc::geometry::enter_target(&doc, leaf, &sel); + if deeper != target || !sel.contains(&deeper) { + self.set_selection(vec![deeper]); + } + Ok(()) + } + + /// A plain click at `p` on a clickable part of a widget calls its plugin back (a failure is + /// shown as a message). + pub(super) fn widget_click(&mut self, p: Point, tol: f64) -> Result<()> { + let (doc, page) = (self.doc()?.clone(), self.page()?); + let Some((id, target)) = self.hit(&doc, page, p, tol).and_then(|hit| crate::widgets::target_at(&doc, hit, p)) else { return Ok(()) }; + if let Err(e) = self.execute("widget.click", &serde_json::json!({"id": id, "target": target})) { + self.message = Some(e.to_string()); + } + Ok(()) + } + + /// The char index in text node `id` at page point `p`. + pub fn text_index_at(&self, id: NodeId, p: Point) -> Result { + let doc = self.doc()?; + let n = doc.get(id)?; + let local = doc.world_transform(id).inverse() * p; + let Some((layout, dy)) = figcraft_text::layout_node(n) else { return Ok(0) }; + Ok(layout.hit(local.x, local.y - dy)) + } + + pub(super) fn text_click(&mut self, id: NodeId, ev: &PointerEvent, extend: bool) -> Result<()> { + let idx = self.text_index_at(id, ev.p())?; + textedit::set_caret(self, idx, extend)?; + self.tool.gesture = Gesture::TextSelect { id }; + Ok(()) + } + + pub(super) fn text_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + if let Some(te) = ds.text_edit.clone() { + if doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + // Clicking existing text edits it. + if let Some(leaf) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0) + && doc.node(leaf).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))) + { + let idx = self.text_index_at(leaf, ev.p())?; + textedit::begin(self, leaf, Some(idx), false)?; + self.tool.gesture = Gesture::TextSelect { id: leaf }; + return Ok(()); + } + // New text at the click (Figma puts the baseline-ish top-left there). + let parent = frame_at(&doc, page, ev.p(), &[]).unwrap_or(page); + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", 1.0, 14.0); + let pw = d.world_transform(parent); + let at = Point::new(ev.x.round(), (ev.y - 7.0).round()); + n.transform = pw.inverse() * Affine::translate(at.to_vec2()); + n.name = String::new(); + if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + if let Some((w, h)) = figcraft_text::auto_size(&n) { + n.width = w.max(1.0); + n.height = h; + } + let line = n.height; + d.insert(n, parent, None)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + self.tool.gesture = Gesture::TextBox { id, start: ev.p(), line }; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + + /// The layer whose path is under `ev` for the Text on a path tool and where along it the press + /// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its + /// first subpath (the one text follows), not locked and not inside an instance. Layers in front + /// whose path is further away don't hide it. + pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> { + let reach = ev.tol() * PATH_REACH_PX; + let near = |id: NodeId| -> Option<(NodeId, f64)> { + let n = doc.node(id)?; + let shape = matches!( + n.kind, + NodeKind::Rectangle + | NodeKind::Ellipse { .. } + | NodeKind::Polygon { .. } + | NodeKind::Star { .. } + | NodeKind::Line + | NodeKind::Vector { .. } + ); + if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) { + return None; + } + let geom = figcraft_doc::geometry::stroke_geometry(n)?; + let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?; + // Measured on the page (a layer scaled more one way than the other is still reached + // where it shows), the start by length along the layer's own path. + let world = doc.world_transform(id); + let p = ev.p(); + let mut best: Option<(f64, f64)> = None; + for (i, w) in line.points.windows(2).enumerate() { + let (a, b) = (world * w[0], world * w[1]); + let d = b - a; + let l2 = d.hypot2(); + let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let dist = (a.lerp(b, u) - p).hypot(); + if best.is_none_or(|(_, bd)| dist < bd) { + let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?); + best = Some((la + u * (lb - la), dist)); + } + } + let (along, dist) = best?; + let len = line.length(); + (len > 1e-9 && dist <= reach).then_some((id, along / len)) + }; + let mut candidates = Vec::new(); + path_candidates(doc, page, ev.p(), reach, 0, &mut candidates); + candidates.into_iter().find_map(near) + } + + /// A press with the Text on a path tool: on a path, a new empty text layer goes onto it, + /// starting where it was pressed, and typing starts. The text takes the path layer's place, + /// geometry, transform, constraints and effects, and its fills when one of them shows; the path + /// layer is removed. Ending the edit with nothing typed puts the path back without an undo step + /// ([`textedit::end`]). Elsewhere a press does nothing. + pub(super) fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> { + if let Some(te) = self.ds()?.text_edit.clone() { + if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) }; + let Some(pn) = doc.node(path) else { return Ok(()) }; + let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) }; + let index = doc.index_in_parent(path); + self.begin_interaction("Text on a path")?; + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height); + n.name = String::new(); + n.transform = pn.transform; + n.constraints = pn.constraints; + n.child_layout = pn.child_layout.clone(); + let effects = !pn.effects.is_empty(); + if effects { + n.effects = pn.effects.clone(); + n.styles.effect = pn.styles.effect.clone(); + } + // A path drawn with a stroke only has no fill to give: the text gets the usual one. + let filled = pn.fills.iter().any(|f| f.visible); + if filled { + n.fills = pn.fills.clone(); + n.styles.fill = pn.styles.fill.clone(); + } else if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + // As `text.onPath`: a binding goes with the list it binds into (`fill`, `fills`, `fills/N/…`). + let carried = |k: &str| match crate::cmd::lists::list_binding(k) { + Some((crate::cmd::lists::BoundList::Fills, ..)) => filled, + Some((crate::cmd::lists::BoundList::Effects, ..)) => effects, + _ => false, + }; + n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + if let NodeKind::Text(t) = &mut n.kind { + let mut tp = TextPath::new(&geom); + tp.start = start; + t.path = Some(Box::new(tp)); + } + d.insert(n, parent, index)?; + d.remove(path)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line + /// tall) and its top-left, and that size is fixed. + pub(super) fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { + if within_slop(ev, start) { + return Ok(()); + } + let r = Rect::from_points(start, ev.p()); + let d = self.ds_mut()?.doc_mut(); + let pw = d.node(id).and_then(|n| n.parent).map(|p| d.world_transform(p)).unwrap_or_default(); + let n = d.get_mut(id)?; + n.transform = pw.inverse() * Affine::translate((r.x0.round(), r.y0.round())); + n.width = r.width().round().max(1.0); + n.height = r.height().round().max(line); + n.child_layout.sizing_h = Sizing::Fixed; + n.child_layout.sizing_v = Sizing::Fixed; + if let NodeKind::Text(t) = &mut n.kind { + t.auto_resize = TextAutoResize::None; + } + self.ds_mut()?.revision += 1; + Ok(()) + } + + pub(super) fn eyedrop(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let doc = ds.doc.clone(); + let view = figcraft_render::View { origin: (ev.x, ev.y), scale: ev.zoom.max(0.01), width: 1, height: 1 }; + let Some(pix) = figcraft_render::render_view(&doc, ds.page, &view, &Default::default()) else { return Ok(()) }; + let rgba = figcraft_render::to_rgba8(&pix); + let c = figcraft_color::Color::from_rgba8( + rgba.first().copied().unwrap_or(0), + rgba.get(1).copied().unwrap_or(0), + rgba.get(2).copied().unwrap_or(0), + 255, + ); + let sel = ds.selection.clone(); + if self.tool.pick_only { + self.tool.picked = Some(c); + } else if !sel.is_empty() { + self.execute("node.set", &json!({"ids": sel, "props": {"fill": c.to_css_hex()}}))?; + } + self.recent_colors.retain(|x| *x != c); + self.recent_colors.insert(0, c); + self.recent_colors.truncate(16); + self.message = Some(format!("Picked #{}", c.to_hex())); + self.set_tool(ToolId::Move); + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs new file mode 100644 index 00000000..0d406673 --- /dev/null +++ b/crates/engine/src/canvas/tools/drag.rs @@ -0,0 +1,605 @@ +use super::handles::{ + SNAP_PX, handle_at, in_quad, label_at, locked_in, on_corner_square, paints_below, resized_box, scale_factor, selection_box, selection_frame, + shape_hint, +}; +use super::*; + +impl Session { + pub(super) fn move_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + // Clicking outside the text being edited ends editing. + if let Some(te) = ds.text_edit.clone() { + let inside = doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p())); + if inside { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + // Handles first: a selected connector's ends, a selected ellipse's arc handles (drawn over + // the box), then the box's. + let edits = !self.tool.dev_mode; + if edits && let Some((id, from)) = crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom) { + self.tool.gesture = Gesture::Reattach { id, from, start: ev.p() }; + return Ok(()); + } + if self.arc_handles_shown() + && let Some((id, handle)) = crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom) + && let Some(arc) = crate::arc_handles::arc_of(&doc, id) + { + self.begin_interaction("Arc")?; + self.tool.gesture = Gesture::Arc { id, handle, arc }; + self.tool.overlay.arc = + crate::arc_handles::handle_at(&doc, id, handle, arc, ev.zoom).map(|at| ArcHint { handle, label: arc.label(handle), at }); + return Ok(()); + } + if self.arc_handles_shown() + && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) + && let Some((id, handle)) = crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom) + { + self.begin_interaction("Corner radius")?; + self.tool.gesture = Gesture::Shape { id, handle }; + self.tool.overlay.shape = shape_hint(&doc, id, handle, ev.zoom); + return Ok(()); + } + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| edits) { + let ids = roots(&doc, &sel); + if let Handle::Rotate(_) = h { + let Some(b) = selection_box(&doc, &ids) else { return Ok(()) }; + let center = self + .ds()? + .moved_origin() + .unwrap_or_else(|| Point::new(b.iter().map(|c| c.x).sum::() / 4.0, b.iter().map(|c| c.y).sum::() / 4.0)); + self.begin_interaction("Rotate")?; + self.tool.gesture = Gesture::Rotate { center, start_angle: (ev.p() - center).atan2(), ids }; + return Ok(()); + } + let nodes: Vec<(NodeId, Affine, f64, f64)> = + ids.iter().filter_map(|i| doc.node(*i).map(|n| (*i, doc.world_transform(*i), n.width, n.height))).collect(); + let Some(frame) = selection_frame(&doc, &ids) else { return Ok(()) }; + let single = ids.len() == 1; + self.begin_interaction(if self.tool.tool == ToolId::Scale { "Scale" } else { "Resize" })?; + self.tool.gesture = Gesture::Resize { + start: ev.p(), + r: Box::new(ResizeStart { nodes, frame, single, handle: h }), + scale_tool: self.tool.tool == ToolId::Scale, + moved: false, + }; + return Ok(()); + } + // A frame's name label picks the frame itself. The background of a top-level frame with + // layers in it is like the canvas (a press starts a marquee inside it) unless the frame is + // selected; an empty frame (or a widget, or a board object such as a sticky) is picked and + // dragged. + let board_background = |leaf: NodeId| { + !sel.contains(&leaf) + && doc.node(leaf).is_some_and(|n| { + n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n) && !n.is_board_atom() + }) + }; + let label = label_at(&doc, page, &self.tool.labels, ev.p()); + let target = label.or_else(|| { + self.hit(&doc, page, ev.p(), ev.tol() * 2.0) + .filter(|leaf| ev.cmd || !board_background(*leaf)) + .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) + }); + // A press inside the selection's box that reaches nothing above it (an unfilled shape's + // inside, the frame or canvas behind it, or a layer stacked below the selected layer the + // press is in) drags the selection. In a gap between selected layers, what's there is hit. + let in_own_box = |s: NodeId| selection_box(&doc, &[s]).is_some_and(|q| in_quad(&q, ev.p())); + let behind_sel = |t: Option| { + t.is_none_or(|t| sel.iter().any(|s| *s != t && (doc.is_ancestor(t, *s) || (in_own_box(*s) && paints_below(&doc, t, *s))))) + }; + let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { + Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { + Some(first) + } + _ => target, + }; + match target { + Some(target) => { + // Clicking inside the current selection's box keeps it (drag them all). + let in_sel_box = sel.len() > 1 && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) && !ev.shift; + // Shift on a selected layer takes it out on release if the press doesn't become a + // drag; a drag moves the selection with it. Where no drag can start, it goes now. + let deselect = (ev.shift && sel.contains(&target)).then_some(target); + let without_target = || sel.iter().copied().filter(|x| *x != target).collect::>(); + let new_sel = if in_sel_box && (sel.contains(&target) || sel.iter().any(|s| doc.is_ancestor(*s, target))) { + sel.clone() + } else if ev.shift && deselect.is_none() { + let mut s2 = sel.clone(); + s2.push(target); + s2 + } else if sel.contains(&target) { + sel.clone() + } else { + vec![target] + }; + self.set_selection(new_sel.clone()); + // Dev Mode only selects (Shift-click still deselects): nothing moves. + if !edits { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Ok(()); + } + // Locked layers selected in the layers panel stay put. + let ids: Vec = roots(&doc, &new_sel).into_iter().filter(|i| !locked_in(&doc, *i)).collect(); + if new_sel.is_empty() || doc.node(target).is_some_and(|n| n.locked) || ids.is_empty() { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Ok(()); + } + let mut targets = Targets::gather(&doc, page, &ids); + // A copy dragged out with Alt snaps to the layers it came from too (they stay put). + if ev.alt + && let Some(b) = targets.moving + { + targets.add_box(b); + } + if let Err(e) = self.begin_interaction(if ev.alt { "Duplicate" } else { "Move" }) { + if deselect.is_some() { + self.set_selection(without_target()); + } + return Err(e); + } + self.tool.gesture = Gesture::Move { start: ev.p(), ids, targets, duplicate: ev.alt, moved: false, deselect }; + self.tool.overlay.cursor = if ev.alt { Cursor::Copy } else { Cursor::Default }; + } + None => { + let within = self.marquee_within(ev.p())?; + if !ev.shift { + self.set_selection(vec![]); + } + self.tool.gesture = Gesture::Marquee { start: ev.p(), additive: ev.shift, base: if ev.shift { sel } else { vec![] }, within }; + } + } + Ok(()) + } + + pub(super) fn drag(&mut self, ev: &PointerEvent) -> Result<()> { + let g = std::mem::take(&mut self.tool.gesture); + let res = match &g { + Gesture::None => return self.hover(ev), + Gesture::Marquee { start, additive, base, within } => { + let r = Rect::from_points(*start, ev.p()); + self.tool.overlay.marquee = Some([r.x0, r.y0, r.x1, r.y1]); + // Cmd/Ctrl: the nested layers it touches. + let sel = self.marquee_selection(r, *within, ev.cmd, base, *additive)?; + self.set_selection(sel); + Ok(()) + } + Gesture::Move { start, moved: false, .. } | Gesture::Resize { start, moved: false, .. } if within_slop(ev, *start) => Ok(()), + Gesture::Move { start, ids, targets, duplicate, .. } => { + let mut d = ev.p() - *start; + if ev.shift { + if d.x.abs() > d.y.abs() { + d.y = 0.0; + } else { + d.x = 0.0; + } + } + // Cmd/Ctrl (and Control on macOS) held while dragging suspends snapping. + let snap_on = self.ds().map(|ds| ds.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; + let mut guides = Vec::new(); + if snap_on { + let (c, g2) = targets.snap_move(d, SNAP_PX / ev.zoom.max(1e-6)); + d += c; + guides = g2; + } + if self.ds().map(|ds| ds.view.snap_pixel).unwrap_or(true) { + // Keep the box's origin on whole pixels (a rotated parent frame's, inside one). + d = targets.round_move(d); + } + let ids = ids.clone(); + let dup = *duplicate; + let page = self.page()?; + let mut drop_cell = None; + let mut drop_target = None; + let mut in_flow = false; + // Placed in a row or column: (is it a column, its gap) for the insertion line. + let mut slot_axis: Option<(bool, f64)> = None; + self.preview(|doc, sel| { + let mut moving = ids.clone(); + if dup { + let mut copies = Vec::new(); + for i in &ids { + let parent = doc.get(*i)?.parent.unwrap_or(page); + let idx = doc.index_in_parent(*i).map(|x| x + 1); + copies.push(crate::components::copy_in_file(doc, *i, parent, idx)?); + } + // Connectors copied with what they join join the copies. + let map = crate::board::copy_map(doc, &ids, &copies); + crate::board::rejoin(doc, &crate::board::copied_items(doc, &map), &map, false); + moving = copies; + *sel = moving.clone(); + } + let p = ev.p(); + let home = moving.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent); + // Absolute children of an auto layout frame stay in it, and so do the layers of + // groups, boolean groups and instances ([`crate::drop::keeps_layers`]); the + // rest may change parent. + let flow = moving.iter().all(|i| doc.node(*i).is_some_and(|n| !n.child_layout.absolute)) + && !home.is_some_and(|h| crate::drop::keeps_layers(doc, h)); + let target = if flow { crate::drop::drag_parent(doc, page, p, &moving) } else { home.unwrap_or(page) }; + let mode = doc.node(target).and_then(|n| n.layout.as_ref()).map(|l| l.mode); + if let (true, Some(mode)) = (flow, mode) { + // Into (or within) an auto layout frame: the layers take the slot (a grid: the + // cell) under the pointer, as the frame shows it now. A frame other than the + // one they come from is outlined too. + let cell = if mode == LayoutMode::Grid { crate::drop::grid_cell_at(doc, target, p) } else { None }; + let idx = + if mode == LayoutMode::Grid { 0 } else { crate::drop::auto_layout_slot(doc, target, p, &moving).map_or(0, |(i, _)| i) }; + for m in &moving { + if doc.get(*m)?.parent != Some(target) { + doc.reparent(*m, target, None)?; + } + } + if let (Some(first), Some((col, row, rect))) = (moving.first().copied(), cell) { + let cl = &mut doc.get_mut(first)?.child_layout; + cl.grid_column = Some(col); + cl.grid_row = Some(row); + drop_cell = Some(rect); + } else if mode != LayoutMode::Grid { + let mut kids: Vec = doc.get(target)?.children.iter().copied().filter(|c| !moving.contains(c)).collect(); + for (k, m) in moving.iter().enumerate() { + kids.insert((idx + k).min(kids.len()), *m); + } + let gap = doc.node(target).and_then(|n| n.layout.as_ref()).map_or(0.0, |l| l.gap); + doc.get_mut(target)?.children = kids; + slot_axis = Some((mode == LayoutMode::Vertical, gap)); + } + drop_target = (Some(target) != home).then_some(target); + in_flow = true; + return Ok(()); + } + // A free move. Layers dragged out of an auto layout flow leave it (onto the page + // while dragging; the drop puts them in the frame under the pointer). + for m in &moving { + let in_al = doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); + if flow && in_al { + doc.reparent(*m, page, None)?; + } + move_world(doc, *m, d.x, d.y)?; + } + drop_target = (flow && target != page).then_some(target); + Ok(()) + })?; + let placed = if dup { self.selection() } else { ids }; + // Snapping guides mean nothing while the layout places the layers; with Alt held, + // live distances to the neighbours replace them. + self.tool.overlay.guides = if in_flow { + Vec::new() + } else if ev.alt { + let doc = self.doc()?; + doc.bounds_of(&placed).map(|b| crate::distance::neighbours(doc, page, &placed, b)).unwrap_or_default() + } else { + guides + }; + // The preview already shows the layers in their slot: the line marks it there, in + // the middle of the gap before them (clear of the selection box). + self.tool.overlay.insert_line = slot_axis.and_then(|(vertical, gap)| { + let off = if gap.is_finite() { (gap / 2.0).clamp(3.0, 24.0) } else { 3.0 }; + self.doc().ok()?.bounds_of(&placed).map(|b| crate::drop::line_before(b, vertical, off)) + }); + self.tool.overlay.drop_cell = drop_cell; + self.tool.overlay.drop_target = drop_target; + self.tool.overlay.cursor = if self.refused(&placed, true) { + Cursor::NotAllowed + } else if dup { + Cursor::Copy + } else { + Cursor::Default + }; + self.tool.gesture = g.clone(); + if let Gesture::Move { moved, .. } = &mut self.tool.gesture { + *moved = true; + } + return Ok(()); + } + Gesture::Resize { start, r, scale_tool, .. } => { + let res = self.resize_drag(ev, *start, r, *scale_tool); + self.tool.gesture = Gesture::Resize { start: *start, r: r.clone(), scale_tool: *scale_tool, moved: true }; + if self.refused(&self.selection(), false) { + self.tool.overlay.cursor = Cursor::NotAllowed; + } + return res; + } + Gesture::Rotate { center, start_angle, ids } => { + let mut a = ((ev.p() - *center).atan2() - start_angle).to_degrees(); + if ev.shift { + a = (a / 15.0).round() * 15.0; + } + let (c, ids) = (*center, ids.clone()); + self.preview(|doc, _| { + for i in &ids { + crate::cmd::arrange::rotate_world(doc, *i, c, -a)?; + } + Ok(()) + })?; + // The cursor turns with the pointer about the centre. + self.tool.overlay.cursor = + if self.refused(&ids, false) { Cursor::NotAllowed } else { Cursor::Rotate((ev.p() - c).atan2().to_degrees()) }; + Ok(()) + } + Gesture::Draw { start, id, parent, targets } => { + let (start, id, parent, targets) = (*start, *id, *parent, targets.clone()); + self.tool.gesture = g; + return self.draw_drag(ev, start, id, parent, &targets); + } + Gesture::TextBox { id, start, line } => { + let (id, start, line) = (*id, *start, *line); + self.tool.gesture = g; + return self.text_box_drag(ev, id, start, line); + } + Gesture::Arc { id, handle, arc } => { + let (id, handle, arc) = (*id, *handle, *arc); + self.tool.gesture = g; + return self.arc_drag(ev, id, handle, arc); + } + Gesture::Shape { id, handle } => { + let (id, handle) = (*id, *handle); + let doc = self.doc()?.clone(); + if let Some(props) = crate::shape_handles::drag(&doc, id, handle, ev.p(), ev.alt) { + self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; + } + self.tool.overlay.shape = shape_hint(self.doc()?, id, handle, ev.zoom); + self.tool.overlay.shape_hover = Some(handle); + Ok(()) + } + Gesture::Pan { sx, sy, cx, cy } => { + let ds = self.ds_mut()?; + let z = ds.view.zoom.max(1e-6); + ds.view.cx = cx - (ev.sx - sx) / z; + ds.view.cy = cy - (ev.sy - sy) / z; + ds.revision += 1; + Ok(()) + } + Gesture::Pencil { points, pressure } => { + let (mut pts, mut pressure) = (points.clone(), pressure.clone()); + if pts.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { + pts.push(ev.p()); + pressure.push(ev.pressure); + } + self.tool.overlay.pen = pts.iter().map(|p| [p.x, p.y]).collect(); + self.tool.gesture = Gesture::Pencil { points: pts, pressure }; + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + return Ok(()); + } + Gesture::TextSelect { id } => { + let id = *id; + let idx = self.text_index_at(id, ev.p())?; + textedit::set_caret(self, idx, true)?; + Ok(()) + } + Gesture::Vector(_) | Gesture::ShapeBuild { .. } | Gesture::Origin => Ok(()), + Gesture::Connect { from, start } => { + let (from, start) = (*from, *start); + self.connect_drag(ev, from, start); + return Ok(()); + } + Gesture::Erase { gone } => { + self.tool.gesture = Gesture::Erase { gone: gone.clone() }; + return self.erase_at(ev); + } + Gesture::Reattach { id, from, start } => { + let (id, from, start) = (*id, *from, *start); + self.reattach_drag(ev, id, from, start); + return Ok(()); + } + Gesture::PenDrag { anchor } => { + // A click's wobble pulls out no handle. + let out = if within_slop(ev, *anchor) { Vec2::ZERO } else { ev.p() - *anchor }; + if let Some(pen) = self.tool.pen.as_mut() + && let Some(last) = pen.points.last_mut() + { + last.2 = out; + last.1 = -out; + } + self.pen_update()?; + Ok(()) + } + }; + self.tool.gesture = g; + res + } + + /// An arc handle drag: the new arc, written through `node.set`'s keys. + fn arc_drag(&mut self, ev: &PointerEvent, id: NodeId, handle: ArcHandle, arc: crate::arc_handles::Arc) -> Result<()> { + let doc = self.doc()?.clone(); + let Some(next) = crate::arc_handles::drag(&doc, id, handle, arc, ev.p(), ev.shift, ev.zoom) else { return Ok(()) }; + let props = crate::arc_handles::props(next); + self.preview(|doc, _| crate::cmd::props::apply("node.set", doc, id, &props))?; + self.tool.gesture = Gesture::Arc { id, handle, arc: next }; + let at = crate::arc_handles::handle_at(self.doc()?, id, handle, next, ev.zoom); + self.tool.overlay.arc = at.map(|at| ArcHint { handle, label: next.label(handle), at }); + self.tool.overlay.arc_hover = Some(handle); + Ok(()) + } + + fn resize_drag(&mut self, ev: &PointerEvent, start: Point, r: &ResizeStart, scale_tool: bool) -> Result<()> { + let page = self.page()?; + let snap = self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl; + let ids: Vec = r.nodes.iter().map(|n| n.0).collect(); + let targets = if snap { Some(Targets::gather(self.doc()?, page, &ids)) } else { None }; + let mut p = ev.p(); + let mut guides = Vec::new(); + if let Some(t) = &targets { + let (dx, dy, g) = t.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); + p.x += dx; + p.y += dy; + guides = g; + } + let _ = start; + // In the handle box's own axes (one layer: its local space). + let (frame, w0, h0) = r.frame; + // The pixel grid: an upright box's dragged sides land on whole pixels (the Scale tool + // scales by the factor as dragged). + if !scale_tool && self.ds().map(|d| d.view.snap_pixel).unwrap_or(true) && crate::guides::axis_aligned(frame) { + p = Point::new(p.x.round(), p.y.round()); + } + let q = frame.inverse() * p; + let is_line = r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| matches!(n.kind, NodeKind::Line))).unwrap_or(false); + if scale_tool { + return self.scale_drag(ev, r, q, guides); + } + if is_line { + // Line end handles: move the end point freely (length + angle). + let (a, b) = match r.handle { + Handle::Corner(0) => (p, frame * Point::new(w0, 0.0)), + _ => (frame * Point::new(0.0, 0.0), p), + }; + let id = ids.first().copied().unwrap_or(NodeId(0)); + self.preview(|doc, _| { + let mut v = b - a; + if ev.shift { + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + v = Vec2::from_angle(ang) * v.hypot(); + } + let len = v.hypot(); + let world = Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); + doc.set_world_transform(id, world)?; + figcraft_layout::set_size(doc, id, len, 0.0); + Ok(()) + })?; + self.tool.overlay.guides = guides; + return Ok(()); + } + // Constrain proportions (Shift, or the node's lock). + let keep = ev.shift || r.single && ids.first().and_then(|i| self.doc().ok()?.node(*i).map(|n| n.constrain_proportions)).unwrap_or(false); + let (x0, y0, x1, y1) = resized_box(r.handle, q, w0, h0, keep, ev.alt); + let (flip_x, flip_y) = (x1 < x0, y1 < y0); + let (nx0, ny0) = (x0.min(x1), y0.min(y1)); + let (nw, nh) = ((x1 - x0).abs(), (y1 - y0).abs()); + let nodes = r.nodes.clone(); + let single = r.single; + let handle = r.handle; + // What's in a section stays where it is on the page, and so does what's in a frame resized + // with Cmd/Ctrl held (its constraints are ignored). + let keeps = |doc: &Document, id: NodeId| { + doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }) || (ev.cmd && single && n.kind.is_frame_like())) + }; + self.preview(|doc, _| { + if single { + let Some((id, world, _, _)) = nodes.first() else { return Ok(()) }; + let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; + let mut t = *world * Affine::translate((nx0, ny0)); + if flip_x { + t *= Affine::new([-1.0, 0.0, 0.0, 1.0, nw, 0.0]); + } + if flip_y { + t *= Affine::new([1.0, 0.0, 0.0, -1.0, 0.0, nh]); + } + // Text reflow and auto layout can recompute this box after the preview (a size bound + // to a variable lets go of it: the resize is an explicit edit). + let n = doc.get(*id)?; + let crop_images = ev.cmd + && !matches!(n.kind, NodeKind::Text(_)) + && !std::iter::once(*id).chain(doc.ancestors(*id)).any(|a| doc.node(a).is_some_and(|n| n.layout.is_some())); + if crop_images { + // Crop the layer's image fills without moving their pixels on the page. + // Work from the interaction snapshot, just like the geometry on every move. + let (w, h) = (n.width.max(1e-6), n.height.max(1e-6)); + let to_old = + Affine::scale_non_uniform(1.0 / w, 1.0 / h) * world.inverse() * t * Affine::scale_non_uniform(nw.max(0.01), nh.max(0.01)); + let mut fills = n.fills.clone(); + for paint in &mut fills { + let PaintKind::Image(img) = &mut paint.kind else { continue }; + // A repeating image cannot be represented by a single Crop placement. + if img.scale_mode == ImageScaleMode::Tile { + continue; + } + let Some(data) = doc.images.get(&img.image) else { continue }; + let (iw, ih) = (f64::from(data.width), f64::from(data.height)); + let crop = if img.scale_mode == ImageScaleMode::Crop { + img.effective_crop(iw, ih, w, h) + } else { + let (rw, rh) = img.rotated_size(iw.max(1.0), ih.max(1.0)); + let s = if img.scale_mode == ImageScaleMode::Fit { (w / rw).min(h / rh) } else { (w / rw).max(h / rh) }; + [w / (rw * s), 0.0, 0.0, h / (rh * s), (rw * s - w) / (2.0 * rw * s), (rh * s - h) / (2.0 * rh * s)] + }; + // A degenerate (zero-scale) layer has no inverse: its image keeps its placement. + let crop = (Affine::new(crop) * to_old).as_coeffs(); + if !crop.iter().all(|c| c.is_finite()) { + continue; + } + img.crop = Some(crop); + img.scale_mode = ImageScaleMode::Crop; + } + doc.get_mut(*id)?.fills = fills; + } + doc.set_world_transform(*id, t)?; + if kept.is_empty() { + crate::cmd::props::resize_node(doc, *id, nw.max(0.01), nh.max(0.01))?; + } else { + figcraft_layout::set_size(doc, *id, nw.max(0.01), nh.max(0.01)); + crate::cmd::props::restore_worlds(doc, &kept)?; + } + let n = doc.get_mut(*id)?; + if matches!(handle, Handle::Corner(_) | Handle::Edge(1) | Handle::Edge(3)) { + n.child_layout.sizing_h = Sizing::Fixed; + } + if matches!(handle, Handle::Corner(_) | Handle::Edge(0) | Handle::Edge(2)) { + n.child_layout.sizing_v = Sizing::Fixed; + } + if let NodeKind::Text(t) = &mut n.kind { + match handle { + Handle::Edge(1) | Handle::Edge(3) if t.auto_resize == TextAutoResize::Truncate => {} + Handle::Edge(1) | Handle::Edge(3) => t.auto_resize = TextAutoResize::Height, + _ => t.fix_size(true, true), + } + } + return Ok(()); + } + // Several layers: map the old box onto the new one, in the box's axes. Each layer's + // centre follows the box (mirrored when it flips); each keeps its rotation and scales + // along its own axes (see `axis_factors`), turning over with the box. + let (sx, sy) = (if w0 > 1e-9 { (x1 - x0) / w0 } else { 1.0 }, if h0 > 1e-9 { (y1 - y0) / h0 } else { 1.0 }); + let mirror = Affine::new([sx.signum(), 0.0, 0.0, sy.signum(), 0.0, 0.0]); + let inv = frame.inverse(); + for (id, world, w, h) in &nodes { + let local = inv * *world; + let center = local * Point::new(w / 2.0, h / 2.0); + let (kw, kh) = figcraft_layout::axis_factors(local, sx, sy); + let (w2, h2) = (w * kw, h * kh); + let lin = (mirror * local).with_translation(Vec2::ZERO); + let origin = Point::new(x0 + center.x * sx, y0 + center.y * sy) - lin * Point::new(w2 / 2.0, h2 / 2.0); + let kept = if keeps(doc, *id) { crate::cmd::props::children_worlds(doc, *id) } else { Vec::new() }; + doc.set_world_transform(*id, frame * lin.with_translation(origin))?; + crate::cmd::props::resize_node(doc, *id, w2, h2)?; + crate::cmd::props::restore_worlds(doc, &kept)?; + } + Ok(()) + })?; + self.tool.overlay.guides = guides; + Ok(()) + } + + /// Scale tool handle drag (`q`: the snapped pointer in the handle box's axes): everything + /// scales by one factor about the anchor point (Alt: the centre), strokes, effects, radii and + /// text included, children by the same factor whatever their constraints. + fn scale_drag(&mut self, ev: &PointerEvent, r: &ResizeStart, q: Point, guides: Vec) -> Result<()> { + let (frame, w0, h0) = r.frame; + let Some((k, pivot)) = scale_factor(r.handle, q, w0, h0, self.tool.scale_anchor, ev.alt) else { return Ok(()) }; + let nodes = r.nodes.clone(); + let inv = frame.inverse(); + // A layer's place in the box scales about the pivot; a negative factor turns it over. + let about = frame * Affine::translate(pivot.to_vec2() * (1.0 - k)); + let sign = k.signum(); + self.preview(|doc, _| { + for (id, world, _, _) in &nodes { + let c = (inv * *world).as_coeffs(); + doc.set_world_transform(*id, about * Affine::new([sign * c[0], sign * c[1], sign * c[2], sign * c[3], k * c[4], k * c[5]]))?; + crate::cmd::arrange::scale_node(doc, *id, k.abs())?; + } + Ok(()) + })?; + let p = frame * pivot; + self.tool.overlay.scale = Some(ScaleHint { factor: k, pivot: [p.x, p.y] }); + self.tool.overlay.guides = guides; + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs new file mode 100644 index 00000000..b594ae27 --- /dev/null +++ b/crates/engine/src/canvas/tools/draw.rs @@ -0,0 +1,229 @@ +use super::handles::SNAP_PX; +use super::pen::take_enclosed; +use super::*; + +impl Session { + pub(super) fn draw_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let doc = ds.doc.clone(); + let page = ds.page; + if ds.text_edit.is_some() { + textedit::end(self)?; + } + // Frames drawn inside frames nest; sections only at the top or in sections. + let parent = match self.tool.tool { + ToolId::Section => { + frame_at(&doc, page, ev.p(), &[]).filter(|f| doc.node(*f).is_some_and(|n| matches!(n.kind, NodeKind::Section { .. }))).unwrap_or(page) + } + _ => frame_at(&doc, page, ev.p(), &[]).unwrap_or(page), + }; + let targets = Targets::gather(&doc, page, &[]); + self.begin_interaction(self.tool.tool.label())?; + self.tool.gesture = Gesture::Draw { start: ev.p(), id: None, parent, targets }; + Ok(()) + } + + pub(super) fn draw_drag(&mut self, ev: &PointerEvent, start: Point, id: Option, parent: NodeId, targets: &Targets) -> Result<()> { + let tool = self.tool.tool; + let mut p = ev.p(); + let mut guides = Vec::new(); + if self.ds().map(|d| d.view.snap_objects).unwrap_or(true) && !ev.cmd && !ev.ctrl { + let (dx, dy, g) = targets.snap_point(p.x, p.y, SNAP_PX / ev.zoom.max(1e-6)); + p.x += dx; + p.y += dy; + guides = g; + } + let pixel = self.ds().map(|d| d.view.snap_pixel).unwrap_or(true); + let mut created = id; + let page = self.page()?; + self.preview(|doc, sel| { + let (mut a, mut b) = (start, p); + if pixel { + a = Point::new(a.x.round(), a.y.round()); + b = Point::new(b.x.round(), b.y.round()); + } + let pw = doc.world_transform(parent); + let (kind, base) = match tool { + ToolId::Frame => (NodeKind::Frame, "Frame"), + ToolId::Section => (NodeKind::Section { contents_hidden: false }, "Section"), + ToolId::Slice => (NodeKind::Slice, "Slice"), + ToolId::Rectangle => (NodeKind::Rectangle, "Rectangle"), + ToolId::Ellipse => (NodeKind::Ellipse { start: 0.0, sweep: std::f64::consts::TAU, inner: 0.0 }, "Ellipse"), + ToolId::Polygon => (NodeKind::Polygon { count: 3 }, "Polygon"), + ToolId::Star => (NodeKind::Star { count: 5, inner_ratio: 0.38 }, "Star"), + ToolId::Line => (NodeKind::Line, "Line"), + ToolId::Arrow => (NodeKind::Line, "Arrow"), + _ => return Ok(()), + }; + let name = match tool { + ToolId::Frame | ToolId::Section | ToolId::Slice => doc.next_name(base), + _ => base.to_string(), + }; + let nid = doc.alloc_id(); + let mut n = if tool == ToolId::Section { Node::new_section(nid, name, 0.0, 0.0) } else { Node::new(nid, kind, name, 0.0, 0.0) }; + if tool == ToolId::Arrow { + n.stroke.cap_end = StrokeCap::LineArrow; + } + if matches!(tool, ToolId::Line | ToolId::Arrow) { + let mut v = b - a; + if ev.shift { + let ang = (v.atan2() / (std::f64::consts::PI / 4.0)).round() * std::f64::consts::PI / 4.0; + v = Vec2::from_angle(ang) * v.hypot(); + } + // Alt draws from the centre, as for the other shapes. + let (a, v) = if ev.alt { (a - v, v * 2.0) } else { (a, v) }; + n.width = v.hypot(); + n.transform = pw.inverse() * Affine::translate(a.to_vec2()) * Affine::rotate(v.atan2()); + } else { + let mut d = b - a; + if ev.shift { + let m = d.x.abs().max(d.y.abs()); + d = Vec2::new(m * d.x.signum(), m * d.y.signum()); + } + let (x0, y0, x1, y1) = if ev.alt { (a.x - d.x, a.y - d.y, a.x + d.x, a.y + d.y) } else { (a.x, a.y, a.x + d.x, a.y + d.y) }; + let r = Rect::new(x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)); + n.width = r.width(); + n.height = r.height(); + n.transform = pw.inverse() * Affine::translate((r.x0, r.y0)); + } + doc.insert(n, parent, None)?; + *sel = vec![nid]; + created = Some(nid); + let _ = page; + Ok(()) + })?; + self.tool.overlay.guides = guides; + self.tool.gesture = Gesture::Draw { start, id: created, parent, targets: targets.clone() }; + Ok(()) + } + + pub(super) fn up(&mut self, ev: &PointerEvent) -> Result<()> { + let g = std::mem::take(&mut self.tool.gesture); + self.tool.overlay.marquee = None; + self.tool.overlay.guides.clear(); + self.tool.overlay.scale = None; + self.tool.overlay.arc = None; + self.tool.overlay.shape = None; + self.tool.overlay.measure_box = None; + self.tool.overlay.insert_line = None; + self.tool.overlay.drop_cell = None; + let drop_target = self.tool.overlay.drop_target.take(); + match g { + Gesture::Move { ids, moved, duplicate, start, deselect, .. } => { + if moved { + // A free move lands in the frame under the pointer (or the page) where it was + // dropped; layers in an auto layout frame already sit in their slot. + let page = self.page()?; + let sel = self.selection(); + let sources = if duplicate { ids.clone() } else { Vec::new() }; + let moving = if duplicate { sel } else { ids }; + let doc = self.doc()?.clone(); + let in_al = + moving.iter().any(|m| doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let target = drop_target.unwrap_or(page); + let current = moving.first().and_then(|m| doc.node(*m)).and_then(|n| n.parent); + if !in_al && current != Some(target) { + let ds = self.ds_mut()?; + let before = ds.doc.clone(); + let d = ds.doc_mut(); + for m in &moving { + // Group, boolean group and instance layers stay put: they only move. + let from = d.node(*m).and_then(|n| n.parent); + if from.is_some_and(|f| crate::drop::keeps_layers(d, f)) || crate::components::structure_locked(d, target) { + continue; + } + // A layer can't go into itself; it stays where it is. + let _ = d.reparent(*m, target, None); + } + crate::normalize(ds.doc_mut(), &before); + } + // Reordering or dragging layers out of an instance (outside its slots), or + // moving layers inside one, are edits only its main can make: the gesture is + // undone. + if self.refused(&moving, true) { + self.cancel_interaction()?; + } else { + self.commit_interaction()?; + // Duplicate repeats a free Alt-drag's step (an auto layout places its own). + if duplicate && !in_al && moving.len() == sources.len() { + self.ds_mut()?.last_duplicate = Some(crate::cmd::edit::Duplicated { sources, copies: moving }); + } + } + } else { + self.cancel_interaction()?; + if let Some(d) = deselect { + let sel = self.selection().into_iter().filter(|x| *x != d).collect(); + self.set_selection(sel); + } + if deselect.is_none() && !(ev.shift || ev.cmd || duplicate) { + self.widget_click(start, ev.tol() * 2.0)?; + } + } + } + Gesture::Resize { .. } | Gesture::Rotate { .. } | Gesture::Arc { .. } | Gesture::Shape { .. } => { + // Layers inside an instance keep their main's size and rotation. + if self.refused(&self.selection(), false) { + self.cancel_interaction()?; + } else { + self.commit_interaction()?; + } + } + Gesture::Draw { start, id, parent, .. } => { + let tiny = (ev.p() - start).hypot() * ev.zoom < 3.0; + if tiny || id.is_none() { + // Click without drag: Figma makes a 100×100 shape (frames 100×100 too). + self.cancel_interaction()?; + self.begin_interaction(self.tool.tool.label())?; + let (w, h) = self.tool.tool.click_size(); + let target = start + Vec2::new(w, h); + let mut fake = ev.clone(); + fake.x = target.x; + fake.y = target.y; + fake.shift = false; + fake.alt = false; + self.tool.gesture = Gesture::None; + let targets = Targets::default(); + self.draw_drag(&fake, start, None, parent, &targets)?; + self.tool.gesture = Gesture::None; + } + if matches!(self.tool.tool, ToolId::Frame | ToolId::Section) + && let Some(drawn) = self.selection().first().copied() + { + let ds = self.ds_mut()?; + let before = ds.doc.clone(); + take_enclosed(ds.doc_mut(), drawn)?; + crate::normalize(ds.doc_mut(), &before); + } + self.commit_interaction()?; + if !self.prefs.keep_tool { + self.set_tool(ToolId::Move); + } + } + Gesture::Pan { .. } => { + self.tool.overlay.cursor = Cursor::Grab; + if let Some(t) = self.tool.temp_from.take() { + self.tool.tool = t; + } + } + Gesture::Pencil { mut points, mut pressure } => { + self.tool.overlay.pen.clear(); + // The stroke ends where the button came up, even without a move to get there. + if points.last().is_none_or(|l| (*l - ev.p()).hypot() > 0.5 / ev.zoom.max(1e-6)) { + points.push(ev.p()); + pressure.push(ev.pressure); + } + self.pencil_finish(&points, &pressure)?; + } + Gesture::Connect { from, start } => self.connect_up(ev, from, start)?, + Gesture::Erase { .. } => self.erase_up()?, + Gesture::Reattach { id, from, start } => self.reattach_up(ev, id, from, start)?, + Gesture::TextSelect { .. } | Gesture::TextBox { .. } | Gesture::Marquee { .. } | Gesture::None => {} + Gesture::PenDrag { .. } | Gesture::ShapeBuild { .. } | Gesture::Origin => {} + Gesture::Vector(_) => self.commit_interaction()?, + } + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs new file mode 100644 index 00000000..fd765f66 --- /dev/null +++ b/crates/engine/src/canvas/tools/handles.rs @@ -0,0 +1,426 @@ +use super::*; + +/// The frame whose name label (see [`ToolState::labels`]) is under `p` on `page`: the topmost +/// one drawn, if it can be selected (still there, on the page, visible and unlocked). +pub fn label_at(doc: &Document, page: NodeId, labels: &[(NodeId, [Point; 4])], p: Point) -> Option { + labels + .iter() + .rev() + .find(|(id, q)| in_quad(q, p) && doc.page_of(*id) == Some(page) && doc.node(*id).is_some_and(|n| n.visible && !n.locked)) + .map(|(id, _)| *id) +} + +/// Whether `p` is inside the convex quadrilateral `q` (either winding; edges included). +pub(super) fn in_quad(q: &[Point; 4], p: Point) -> bool { + let (mut left, mut right) = (false, false); + for (a, b) in q.iter().zip(q.iter().cycle().skip(1)) { + let side = (*b - *a).cross(p - *a); + if !side.is_finite() { + return false; + } + left |= side < 0.0; + right |= side > 0.0; + } + // On one side of every edge (a point can't be on all four edges of a quad with an area). + left != right +} + +/// An anchor name (`tl`, `t`, `tr`, `l`, `c`, `r`, `bl`, `b`, `br`) as column and row (0 start, +/// 1 middle, 2 end). +pub fn anchor_of(name: &str) -> Option<(u8, u8)> { + ANCHORS.iter().position(|a| a.eq_ignore_ascii_case(name.trim())).map(|i| ((i % 3) as u8, (i / 3) as u8)) +} + +/// The name of anchor `(column, row)`. +pub fn anchor_name(a: (u8, u8)) -> &'static str { + ANCHORS.get(usize::from(a.1.min(2)) * 3 + usize::from(a.0.min(2))).copied().unwrap_or("tl") +} + +/// The fill new text takes to be readable where it is drawn, in `parent`: white on a dark +/// background (None: the model's default, black). The background is the nearest layer from `parent` +/// out whose top visible fill is a mostly opaque solid colour, else the page (#362). For text made +/// in the app: by the Text tool, or by a command that names no fill while the interface is up. +pub(crate) fn readable_text_fills(doc: &Document, parent: NodeId) -> Option> { + let dark = std::iter::once(parent).chain(doc.ancestors(parent)).filter_map(|id| doc.node(id)).find_map(|n| match &n.kind { + NodeKind::Page { background, .. } => Some(background.is_dark()), + _ => n.fills.iter().rev().find(|p| p.visible).and_then(|p| p.solid_color()).filter(|c| c.a >= 0.5).map(|c| c.is_dark()), + }); + dark.unwrap_or(false).then(|| vec![figcraft_color::Paint::solid(figcraft_color::Color::WHITE)]) +} + +const ANCHORS: [&str; 9] = ["tl", "t", "tr", "l", "c", "r", "bl", "b", "br"]; + +const HANDLE_PX: f64 = 5.0; + +const ROTATE_PX: f64 = 18.0; + +/// Snap distance in screen pixels. +pub(crate) const SNAP_PX: f64 = 5.0; + +/// Screen pixels the pointer travels after a press before a move or resize starts: a click or a +/// double-click made with an unsteady hand doesn't drag what it lands on. +pub(super) const DRAG_SLOP_PX: f64 = 3.0; + +/// Screen pixels from a path within which the Text on a path tool takes it. +pub(super) const PATH_REACH_PX: f64 = 6.0; + +/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most +/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a +/// mask hides at `p` and the operands of boolean groups are left out. +pub(super) fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec) { + let Some(n) = doc.node(id) else { return }; + if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) { + return; + } + if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) { + return; + } + for c in n.paint_order().rev() { + let before = out.len(); + path_candidates(doc, c, p, reach, depth + 1, out); + if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) { + out.truncate(before); + } + } + if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) { + out.push(id); + } +} + +/// Corners of the selection's handle box in page coordinates (tl, tr, br, bl). +pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> { + let (f, w, h) = selection_frame(doc, sel)?; + Some([f * Point::ZERO, f * Point::new(w, 0.0), f * Point::new(w, h), f * Point::new(0.0, h)]) +} + +/// The selection's handle box: the page transform of its top-left corner (axes along the box) and +/// its size. One layer: its own box; several sharing a rotation: a box turned with them; +/// otherwise the page-aligned bounds. +pub fn selection_frame(doc: &Document, sel: &[NodeId]) -> Option<(Affine, f64, f64)> { + let ids = roots(doc, sel); + if let [id] = ids.as_slice() { + let n = doc.node(*id)?; + return Some((doc.world_transform(*id), n.width, n.height)); + } + let Some(angle) = shared_rotation(doc, &ids) else { + let b = doc.bounds_of(&ids)?; + return Some((Affine::translate((b.x0, b.y0)), b.width(), b.height())); + }; + let rot = Affine::rotate(angle); + let inv = rot.inverse(); + let b = ids + .iter() + .filter_map(|i| Some(figcraft_geom::transform_rect_bbox(inv * doc.world_transform(*i), doc.node(*i)?.local_rect()))) + .reduce(|a, b| a.union(b))?; + Some((rot * Affine::translate((b.x0, b.y0)), b.width(), b.height())) +} + +/// The rotation (radians) every layer in `ids` shares, unless it's a multiple of 90 degrees (the +/// page-aligned box is then the same box). Flipped layers count by their axis, not its direction. +fn shared_rotation(doc: &Document, ids: &[NodeId]) -> Option { + use std::f64::consts::{FRAC_PI_2, PI}; + const EPS: f64 = 1e-4; + let mut angles = ids.iter().map(|i| { + let c = doc.world_transform(*i).as_coeffs(); + c[1].atan2(c[0]) + }); + let a = angles.next()?; + let same = |b: f64| { + let d = (b - a).rem_euclid(PI); + d.min(PI - d) < EPS + }; + let axis_aligned = (a - (a / FRAC_PI_2).round() * FRAC_PI_2).abs() < EPS; + (a.is_finite() && !axis_aligned && angles.all(same)).then_some(a) +} + +/// Whether layer `id` is locked or inside a locked layer: it can be selected in the layers panel +/// but not moved or resized on the canvas. +pub fn locked_in(doc: &Document, id: NodeId) -> bool { + std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked)) +} + +/// Whether layer `t` paints below layer `s`: it holds `s`, or it is behind `s` in the stack of the +/// nearest layer holding both. +pub(super) fn paints_below(doc: &Document, t: NodeId, s: NodeId) -> bool { + let chain = |id: NodeId| { + let mut c: Vec = std::iter::once(id).chain(doc.ancestors(id)).collect(); + c.reverse(); + c + }; + let (ct, cs) = (chain(t), chain(s)); + let shared = ct.iter().zip(&cs).take_while(|(a, b)| a == b).count(); + match (ct.get(shared), cs.get(shared)) { + (None, Some(_)) => true, + (Some(a), Some(b)) => shared.checked_sub(1).and_then(|i| ct.get(i)).and_then(|p| doc.node(*p)).is_some_and(|p| { + let order: Vec = p.paint_order().collect(); + order.iter().position(|c| c == a) < order.iter().position(|c| c == b) + }), + _ => false, + } +} + +/// Which handle (if any) is under `p`. A selection with a locked layer has none. +pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option { + if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) { + return None; + } + // A connector's handles are its ends (see `board::end_handle_at`): no box to resize or turn. + if crate::board::selected_connector_ends(doc, sel).is_some() { + return None; + } + let corners = selection_box(doc, sel)?; + // Lines (zero height) only get end handles. + let is_line = sel.len() == 1 && sel.first().and_then(|i| doc.node(*i)).is_some_and(|n| matches!(n.kind, NodeKind::Line)); + handle_at_corners(&corners, p, zoom, is_line) +} + +/// Half the side of the square the canvas draws on each corner of the handle box (screen pixels, +/// upright whatever the box's rotation). +const CORNER_SQUARE_PX: f64 = 4.0; + +/// Whether `p` is on a corner square of the selection's handle box as drawn: there the corner +/// resizes, although a corner-radius handle 10 px in reaches that far too. +pub(super) fn on_corner_square(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> bool { + let reach = CORNER_SQUARE_PX / zoom.max(1e-6); + matches!(handle_at(doc, sel, p, zoom), Some(Handle::Corner(_))) + && selection_box(doc, sel).is_some_and(|c| c.iter().any(|c| (c.x - p.x).abs() <= reach && (c.y - p.y).abs() <= reach)) +} + +/// A box whose sides are both shorter than this on screen (pixels) would be covered by its +/// handles: the canvas draws just its outline (the handles still answer just outside it). +pub const SMALL_BOX_PX: f64 = 16.0; + +/// The stroke the Pencil draws: its colour and weight (the canvas previews it the same). +pub const PENCIL_STROKE: (figcraft_color::Color, f64) = (figcraft_color::Color::BLACK, 2.0); + +/// The shorter and longer sides, in screen pixels, of the box with `corners` (tl, tr, br, bl). +pub fn box_sides_px(corners: &[Point; 4], zoom: f64) -> (f64, f64) { + let [a, b, _, d] = corners; + let (w, h) = ((*b - *a).hypot() * zoom, (*d - *a).hypot() * zoom); + (w.min(h), w.max(h)) +} + +/// Which handle of the box with `corners` (tl, tr, br, bl in page coordinates) is under `p`; +/// `is_line` keeps only the two end handles. +pub fn handle_at_corners(corners: &[Point; 4], p: Point, zoom: f64, is_line: bool) -> Option { + let tol = HANDLE_PX / zoom.max(1e-6); + // Inside the box, handles reach in at most a quarter of its shorter side, so that a press in + // the middle of a small layer moves it; outside it they reach as far as ever. + let inner = if !is_line && in_quad(corners, p) { box_sides_px(corners, zoom).0 / 4.0 / zoom.max(1e-6) } else { f64::INFINITY }; + let (corner_reach, edge_reach) = ((tol * 1.6).min(inner), tol.min(inner)); + // The nearest corner, then the nearest edge, in reach (a small box's reaches overlap). + let nearest = |hits: &mut dyn Iterator| hits.min_by(|a, b| a.0.total_cmp(&b.0)).map(|(_, h)| h); + let corner = nearest( + &mut corners + .iter() + .enumerate() + .filter(|(i, _)| !(is_line && *i >= 2)) + .map(|(i, c)| ((*c - p).hypot(), Handle::Corner(i as u8))) + .filter(|(d, _)| *d <= corner_reach), + ); + if corner.is_some() { + return corner; + } + if !is_line { + use figcraft_geom::kurbo::ParamCurveNearest; + let edge = nearest(&mut (0..4).filter_map(|i| { + let (a, b) = (corners.get(i)?, corners.get((i + 1) % 4)?); + let near = figcraft_geom::kurbo::Line::new(*a, *b).nearest(p, 0.001); + let d = near.distance_sq.sqrt(); + (d <= edge_reach && near.t > 0.05 && near.t < 0.95).then_some((d, Handle::Edge(i as u8))) + })); + if edge.is_some() { + return edge; + } + } + // Rotation zones: just outside each corner. + let center = Point::new(corners.iter().map(|c| c.x).sum::() / 4.0, corners.iter().map(|c| c.y).sum::() / 4.0); + let rot = ROTATE_PX / zoom.max(1e-6); + for (i, c) in corners.iter().enumerate() { + let d = (*c - p).hypot(); + let outward = (p - *c).dot(*c - center) > 0.0; + if d <= rot && d > tol && outward { + return Some(Handle::Rotate(i as u8)); + } + } + None +} + +pub(super) fn resize_cursor(doc: &Document, sel: &[NodeId], h: Handle) -> Cursor { + selection_box(doc, sel).map_or(Cursor::Default, |c| handle_cursor(&c, h)) +} + +/// The cursor over handle `h` of the box with corners `c`. +pub(crate) fn handle_cursor(c: &[Point; 4], h: Handle) -> Cursor { + let angle = |a: Point, b: Point| (b - a).atan2().to_degrees(); + match h { + Handle::Corner(i) => { + let a = c.get(i as usize).copied().unwrap_or_default(); + let b = c.get((i as usize + 2) % 4).copied().unwrap_or_default(); + // Along the box's own axes at 45°, not the diagonal, so wide or tall boxes still get a + // diagonal cursor. A box with no width or height falls back to the diagonal. + let unit = |v: figcraft_geom::kurbo::Vec2| { + let len = v.hypot(); + (len.is_finite() && len > 1e-9).then(|| v / len) + }; + let next = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); + let prev = c.get((i as usize + 3) % 4).copied().unwrap_or_default(); + match (unit(next - a), unit(prev - a)) { + (Some(u), Some(v)) => Cursor::Resize((u + v).atan2().to_degrees()), + _ => Cursor::Resize(angle(a, b)), + } + } + Handle::Edge(i) => { + let a = c.get(i as usize).copied().unwrap_or_default(); + let b = c.get((i as usize + 1) % 4).copied().unwrap_or_default(); + Cursor::Resize(angle(a, b) + 90.0) + } + Handle::Rotate(i) => { + let centre = Point::new(c.iter().map(|p| p.x).sum::() / 4.0, c.iter().map(|p| p.y).sum::() / 4.0); + Cursor::Rotate(angle(centre, c.get(i as usize).copied().unwrap_or_default())) + } + } +} + +/// The box (x0, y0, x1, y1) a `w0`×`h0` handle box becomes when `handle` is dragged to `q`, all in +/// the box's own axes: `keep` constrains proportions, `from_center` resizes about the centre (the +/// opposite side mirrors the dragged one). Edges that cross over come back reversed (x1 < x0 or +/// y1 < y0: a flip). +pub(crate) fn resized_box(handle: Handle, q: Point, w0: f64, h0: f64, keep: bool, from_center: bool) -> (f64, f64, f64, f64) { + let (mut x0, mut y0, mut x1, mut y1) = (0.0, 0.0, w0, h0); + // The dragged sides: x0 or x1 (`Some(false)` / `Some(true)`), y0 or y1. + let (sx, sy) = match handle { + Handle::Corner(0) => (Some(false), Some(false)), + Handle::Corner(1) => (Some(true), Some(false)), + Handle::Corner(2) => (Some(true), Some(true)), + Handle::Corner(_) => (Some(false), Some(true)), + Handle::Edge(0) => (None, Some(false)), + Handle::Edge(1) => (Some(true), None), + Handle::Edge(2) => (None, Some(true)), + Handle::Edge(_) => (Some(false), None), + Handle::Rotate(_) => (None, None), + }; + match sx { + Some(false) => (x0, x1) = (q.x, if from_center { w0 - q.x } else { w0 }), + Some(true) => (x0, x1) = (if from_center { w0 - q.x } else { 0.0 }, q.x), + None => {} + } + match sy { + Some(false) => (y0, y1) = (q.y, if from_center { h0 - q.y } else { h0 }), + Some(true) => (y0, y1) = (if from_center { h0 - q.y } else { 0.0 }, q.y), + None => {} + } + if keep && w0 > 1e-9 && h0 > 1e-9 && !matches!(handle, Handle::Rotate(_)) { + // One factor for both axes: the dragged axis's (a corner: the one that moved the most), + // about the point that stays put (the opposite handle, or the centre). + let (kx, ky) = ((x1 - x0) / w0, (y1 - y0) / h0); + let k = match (sx, sy) { + (None, _) => ky, + (_, None) => kx, + _ if kx.abs() > ky.abs() => kx, + _ => ky, + }; + let pivot = if from_center { Point::new(w0 / 2.0, h0 / 2.0) } else { handle_point(opposite(handle), w0, h0) }; + (x0, x1) = (pivot.x * (1.0 - k), pivot.x + (w0 - pivot.x) * k); + (y0, y1) = (pivot.y * (1.0 - k), pivot.y + (h0 - pivot.y) * k); + } + (x0, y0, x1, y1) +} + +/// A handle's point in its box's axes (`w0`×`h0`): a corner or an edge's middle. +fn handle_point(h: Handle, w0: f64, h0: f64) -> Point { + match h { + Handle::Corner(0) | Handle::Rotate(0) => Point::new(0.0, 0.0), + Handle::Corner(1) | Handle::Rotate(1) => Point::new(w0, 0.0), + Handle::Corner(2) | Handle::Rotate(2) => Point::new(w0, h0), + Handle::Corner(_) | Handle::Rotate(_) => Point::new(0.0, h0), + Handle::Edge(0) => Point::new(w0 / 2.0, 0.0), + Handle::Edge(1) => Point::new(w0, h0 / 2.0), + Handle::Edge(2) => Point::new(w0 / 2.0, h0), + Handle::Edge(_) => Point::new(0.0, h0 / 2.0), + } +} + +/// Anchor `(column, row)` of a `w`×`h` box, in its axes. +fn anchor_in_box(anchor: (u8, u8), w: f64, h: f64) -> Point { + Point::new(w * f64::from(anchor.0.min(2)) / 2.0, h * f64::from(anchor.1.min(2)) / 2.0) +} + +/// The Scale tool's anchor point on the selection's handle box, in page coordinates. +pub fn scale_anchor_point(doc: &Document, sel: &[NodeId], anchor: (u8, u8)) -> Option { + let (f, w, h) = selection_frame(doc, sel)?; + Some(f * anchor_in_box(anchor, w, h)) +} + +/// The handle across the box from `h`. +fn opposite(h: Handle) -> Handle { + match h { + Handle::Corner(i) => Handle::Corner((i + 2) % 4), + Handle::Edge(i) => Handle::Edge((i + 2) % 4), + Handle::Rotate(i) => Handle::Rotate((i + 2) % 4), + } +} + +/// The Scale tool's factor and pivot (in the box's axes) for dragging `handle` of a `w0`×`h0` box +/// to `q`. The pivot is the anchor point (`anchor`: column, row), or the opposite handle when the +/// anchor lies on the side being dragged, or the centre (`from_center`). Corners follow the axis +/// that moved the most; a negative factor flips. None when the box has no size to scale by. +pub(crate) fn scale_factor(handle: Handle, q: Point, w0: f64, h0: f64, anchor: (u8, u8), from_center: bool) -> Option<(f64, Point)> { + const EPS: f64 = 1e-9; + if !(q.x.is_finite() && q.y.is_finite() && w0.is_finite() && h0.is_finite()) || matches!(handle, Handle::Rotate(_)) { + return None; + } + let h = handle_point(handle, w0, h0); + let anchor_pt = anchor_in_box(anchor, w0, h0); + // The axes the handle moves along. + let (ax_x, ax_y) = match handle { + Handle::Edge(0) | Handle::Edge(2) => (false, true), + Handle::Edge(_) => (true, false), + _ => (true, true), + }; + let on_side = (ax_x && (anchor_pt.x - h.x).abs() < EPS) || (ax_y && (anchor_pt.y - h.y).abs() < EPS); + let pivot = if from_center { + Point::new(w0 / 2.0, h0 / 2.0) + } else if on_side { + handle_point(opposite(handle), w0, h0) + } else { + anchor_pt + }; + let ratio = |on: bool, to: f64, from: f64, p: f64| (on && (from - p).abs() > EPS).then(|| (to - p) / (from - p)); + let kx = ratio(ax_x, q.x, h.x, pivot.x); + let ky = ratio(ax_y, q.y, h.y, pivot.y); + let k = match (kx, ky) { + (Some(a), Some(b)) => { + if a.abs() > b.abs() { + a + } else { + b + } + } + (Some(a), None) | (None, Some(a)) => a, + (None, None) => return None, + }; + if !k.is_finite() { + return None; + } + // Never collapse to nothing (or blow up past a usable size); 0 counts as positive. + Some((k.signum() * k.abs().clamp(0.01, 1000.0), pivot)) +} + +/// The readout of a shape handle drag on `id`. +pub(super) fn shape_hint(doc: &Document, id: NodeId, handle: ShapeHandle, zoom: f64) -> Option { + Some(ShapeHint { handle, value: crate::shape_handles::value(doc, id, handle)?, at: crate::shape_handles::handle_at(doc, id, handle, zoom)? }) +} + +#[allow(dead_code)] +fn unused(_: BTreeMap) {} + +/// An end point of a selected open vector within `tol` of page point `p`: (layer, point). +pub(super) fn selected_end_at(doc: &Document, sel: &[NodeId], p: Point, tol: f64) -> Option<(NodeId, usize)> { + sel.iter().find_map(|id| { + let net = crate::vectoredit::network(doc, *id)?; + let w = doc.world_transform(*id); + let near = |v: &usize| net.vertices.get(*v).is_some_and(|x| (w * x.point() - p).hypot() <= tol); + (0..net.vertices.len()).filter(near).find(|v| net.incident(*v).len() == 1).map(|v| (*id, v)) + }) +} diff --git a/crates/engine/src/canvas/tools/keys.rs b/crates/engine/src/canvas/tools/keys.rs new file mode 100644 index 00000000..591f1479 --- /dev/null +++ b/crates/engine/src/canvas/tools/keys.rs @@ -0,0 +1,174 @@ +use super::*; + +impl Session { + /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point + /// keys, Enter to finish the shape builder). Returns whether the key was used. + pub fn tool_key(&mut self, name: &str, shift: bool, alt: bool, cmd: bool) -> Result { + // Dev Mode edits nothing: keys go to the commands (which it refuses in the app). + if self.tool.dev_mode { + return Ok(false); + } + if self.ds().is_ok_and(|d| d.vector_edit.is_some()) { + return crate::vectoredit::key(self, name, shift, alt, cmd); + } + if !alt && !cmd && self.guide_key(name, shift)? { + return Ok(true); + } + crate::shapebuilder::key(self, name) + } + + /// Keys for a selected ruler guide: Delete removes it, arrows across it nudge it (Shift: the big + /// nudge), Escape deselects it. + fn guide_key(&mut self, name: &str, shift: bool) -> Result { + let Some((owner, index)) = self.ds()?.selected_guide() else { return Ok(false) }; + // Backspace is Delete, `Left` is `ArrowLeft`, and so on. + let name = crate::keymap::normalize(name).map_or(name, |(k, _)| k); + let page = self.page()?; + let at = |mut p: serde_json::Value| { + if owner != page { + p["frame"] = json!(owner); + } + p + }; + let Some(g) = self.doc()?.node(owner).and_then(|n| n.guides().get(index).cloned()) else { return Ok(false) }; + let step = if shift { self.prefs.nudge_big } else { self.prefs.nudge_small }; + let d = match (name, g.vertical) { + ("Delete", _) => { + self.execute("guide.remove", &at(json!({"index": index})))?; + return Ok(true); + } + ("Escape", _) => { + self.execute("guide.select", &json!({}))?; + return Ok(true); + } + ("ArrowLeft", true) | ("ArrowUp", false) => -step, + ("ArrowRight", true) | ("ArrowDown", false) => step, + _ => return Ok(false), + }; + self.execute("guide.move", &at(json!({"index": index, "offset": g.offset + d})))?; + Ok(true) + } + + /// The layer under `p` for a click: the topmost on `page`, or in the focus view the focused + /// frame's. Boolean groups are hit by their results ([`crate::booleans::result_outline`]); in + /// outline mode shapes only on their outlines. + pub fn hit(&self, doc: &Document, page: NodeId, p: Point, tol: f64) -> Option { + let sel = self.selection(); + let result = crate::booleans::result_outline(doc, &sel); + let focus = self.ds().ok().and_then(crate::DocState::focused); + // Outline mode draws no fills: shapes are clicked on their outlines, reaching what's behind. + if let Some(v) = self.ds().ok().map(|d| &d.view).filter(|v| v.outlines) { + // What it draws besides the shapes can be clicked too. + let also = figcraft_doc::geometry::OutlineHits { hidden: v.outline_hidden, bounds: v.outline_bounds }; + return figcraft_doc::geometry::hit_outlines(doc, focus.unwrap_or(page), p, tol, Some(&result), also); + } + match focus { + Some(f) => figcraft_doc::geometry::hit_in(doc, f, p, tol, Some(&result)), + None => doc.hit_test(page, p, tol, Some(&result)), + } + } + + /// Enter with a shape or frame tool (`tool.place`): what a click with it makes, centred on `at` + /// (default the middle of the view) and selected. + pub fn place(&mut self, at: Option) -> Result<()> { + let tool = self.tool.tool; + let refuse = |why: &str| Err(crate::EngineError::Disabled("tool.place".into(), why.into())); + if !tool.draws_shape() { + return refuse("choose a shape or frame tool first"); + } + if self.tool.busy() { + return refuse("finish the drag first"); + } + let v = &self.ds()?.view; + let (c, zoom) = (at.unwrap_or(Point::new(v.cx, v.cy)), v.zoom); + let (w, h) = tool.click_size(); + let ev = |kind| PointerEvent::at(kind, c.x - w / 2.0, c.y - h / 2.0, zoom); + let r = self.pointer(&ev(PointerKind::Down)).and_then(|()| self.pointer(&ev(PointerKind::Up))); + if r.is_err() { + self.tool.gesture = Gesture::None; + let _ = self.cancel_interaction(); + } + r + } + + /// Keyboard Escape: cancel gestures, finish pens, leave text editing, then deselect (in the + /// focus view, once the focused frame or nothing is selected, it ends the focus view). + pub fn escape(&mut self) -> Result<()> { + // Following someone in a live session: Escape stops that first. + if self.live_unfollow() { + return Ok(()); + } + if self.tool.pen.is_some() { + return self.pen_end(); + } + if self.tool.busy() { + self.tool.gesture = Gesture::None; + self.tool.overlay = Overlay::default(); + return self.cancel_interaction(); + } + if self.end_origin_edit() { + return Ok(()); + } + if self.ds()?.text_edit.is_some() { + return textedit::end(self); + } + if self.ds()?.vector_edit.is_some() { + return crate::vectoredit::escape(self); + } + if self.tool.tool != ToolId::Move { + self.set_tool(ToolId::Move); + return Ok(()); + } + let sel = self.selection(); + if let Some(f) = self.ds()?.focused() + && (sel.is_empty() || sel == [f]) + { + self.ds_mut()?.focus = None; + return Ok(()); + } + // Escape selects the parent, then nothing. + let doc = self.doc()?; + let parents: Vec = + sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect(); + if !parents.is_empty() { + let mut uniq = Vec::new(); + for p in parents { + if !uniq.contains(&p) { + uniq.push(p); + } + } + self.set_selection(uniq); + } else { + self.set_selection(vec![]); + } + Ok(()) + } + + /// Whether the Move tool shows arc and shape handles now (not while another gesture runs, nor + /// in vector or text edit mode). + pub(super) fn arc_handles_shown(&self) -> bool { + let arc_drag = matches!(self.tool.gesture, Gesture::Arc { .. } | Gesture::Shape { .. }); + self.tool.tool == ToolId::Move + && !self.tool.dev_mode + && (arc_drag || !self.tool.busy()) + && self.ds().is_ok_and(|ds| ds.vector_edit.is_none() && ds.text_edit.is_none()) + } + + /// The selected ellipse's arc handles as the canvas shows them at the current zoom. + pub fn arc_handles(&self) -> Option { + let ds = self.ds().ok()?; + self.arc_handles_shown().then(|| crate::arc_handles::handles(&ds.doc, &ds.selection, ds.view.zoom)).flatten() + } + + /// The selected shape's corner radius and star ratio handles as the canvas shows them now. + pub fn shape_handles(&self) -> Vec<(ShapeHandle, [f64; 2])> { + let Ok(ds) = self.ds() else { return vec![] }; + if self.arc_handles_shown() { crate::shape_handles::handles(&ds.doc, &ds.selection, ds.view.zoom) } else { vec![] } + } + + /// Snapshot of tool state for the UI / inspect. + pub fn tool_info(&self) -> serde_json::Value { + let shapes: Vec = self.shape_handles().into_iter().map(|(h, at)| json!({"handle": h, "at": at})).collect(); + json!({"tool": self.tool.tool.id(), "busy": self.tool.busy(), "overlay": self.tool.overlay, "arcHandles": self.arc_handles(), "shapeHandles": shapes}) + } +} diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs new file mode 100644 index 00000000..319c11b7 --- /dev/null +++ b/crates/engine/src/canvas/tools/pen.rs @@ -0,0 +1,276 @@ +use super::handles::{PENCIL_STROKE, selected_end_at}; +use super::*; + +/// Pen pressure along drawn samples as width profile points `[t, scale]`: `t` by length, at most +/// 32 points, medium pressure (0.5) at the stroke weight, from a tenth to twice it. `None` when no +/// sample has pressure. +pub(crate) fn pressure_profile(points: &[Point], pressure: &[Option]) -> Option> { + if pressure.iter().all(Option::is_none) { + return None; + } + let mut lengths = vec![0.0]; + for w in points.windows(2) { + let (Some(a), Some(b)) = (w.first(), w.get(1)) else { continue }; + lengths.push(lengths.last().copied().unwrap_or(0.0) + (*b - *a).hypot()); + } + let total = lengths.last().copied().unwrap_or(0.0); + if !(total.is_finite() && total > 0.0) { + return None; + } + let at = |i: usize| f64::from(pressure.get(i).copied().flatten().filter(|p| p.is_finite()).unwrap_or(0.5).clamp(0.0, 1.0)); + let n = points.len().clamp(2, 32); + let profile = (0..n).map(|k| { + let t = k as f64 / (n - 1) as f64; + let s = t * total; + let i = lengths.partition_point(|l| *l < s).min(lengths.len().saturating_sub(1)); + let p = match (i.checked_sub(1).and_then(|j| lengths.get(j)), lengths.get(i)) { + (Some(a), Some(b)) if b > a => at(i - 1) + (at(i) - at(i - 1)) * ((s - a) / (b - a)), + _ => at(i), + }; + [t, (p / 0.5).clamp(0.1, 2.0)] + }); + Some(profile.collect()) +} + +/// A frame or section drawn around layers takes those of its siblings that lie fully inside it +/// (visible and unlocked ones, in their order; a frame never takes a section). Not in auto layout, +/// where the new frame is one more item of the flow. +pub(super) fn take_enclosed(doc: &mut Document, drawn: NodeId) -> Result<()> { + let Some(n) = doc.node(drawn) else { return Ok(()) }; + let section = matches!(n.kind, NodeKind::Section { .. }); + let (Some(parent), Some(area)) = (n.parent.and_then(|p| doc.node(p)), doc.world_bounds(drawn)) else { return Ok(()) }; + if parent.layout.is_some() { + return Ok(()); + } + let inside: Vec = parent + .children + .iter() + .copied() + .filter(|c| *c != drawn && doc.node(*c).is_some_and(|k| k.visible && !k.locked && (section || !matches!(k.kind, NodeKind::Section { .. })))) + .filter(|c| doc.world_bounds(*c).is_some_and(|b| area.contains(b.origin()) && area.contains(Point::new(b.x1, b.y1)))) + .collect(); + for c in inside { + doc.reparent(c, drawn, None)?; + } + Ok(()) +} + +impl Session { + /// The pen tool's snapped position for `ev`: its path's vector (or, before it has one, its first + /// point) counts among the targets. + pub(super) fn snap_pen_tool(&mut self, ev: &PointerEvent) -> Result<(Point, Vec)> { + let (id, extra) = match &self.tool.pen { + Some(pen) if pen.id.is_some() => (pen.id, Vec::new()), + Some(pen) => (None, pen.points.iter().map(|(p, _, _)| *p).collect()), + None => (None, Vec::new()), + }; + self.snap_pen(ev, id, &extra) + } + + pub(super) fn pen_down(&mut self, ev: &PointerEvent) -> Result<()> { + self.tool.overlay.guides.clear(); + // The segment being placed shows as itself while the press lasts. + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; + let p = if ev.shift { ev.p() } else { self.snap_pen_tool(ev)?.0 }; + let (doc, page) = { + let ds = self.ds()?; + (ds.doc.clone(), ds.page) + }; + // With an open path selected, a click on one of its end points continues it in edit mode. + if self.tool.pen.is_none() + && let Some((id, v)) = selected_end_at(&doc, &self.selection(), ev.p(), 8.0 / ev.zoom.max(1e-6)) + { + return self.pen_edit(id, Some(v)); + } + let pen = self.tool.pen.get_or_insert_with(PenPath::default); + // Clicking the first anchor closes the path. + if pen.points.len() > 2 + && let Some((first, _, _)) = pen.points.first() + && (*first - p).hypot() * ev.zoom < 8.0 + { + pen.closed = true; + self.pen_update()?; + return self.pen_finish(); + } + // Clicking another of its own anchors (not the last) connects there and ends the path. + let near = |q: &Point| (*q - p).hypot() * ev.zoom < 8.0; + if let Some(k) = (1..pen.points.len().saturating_sub(1)).find(|k| pen.points.get(*k).is_some_and(|(q, _, _)| near(q))) { + pen.join = Some(k); + self.pen_update()?; + return self.pen_finish(); + } + if pen.parent.is_none() { + pen.parent = Some(frame_at(&doc, page, p, &[]).unwrap_or(page)); + } + let mut p = p; + if ev.shift + && let Some((last, _, _)) = pen.points.last() + { + p = at_45_degrees(*last, p); + } + pen.points.push((p, Vec2::ZERO, Vec2::ZERO)); + self.tool.gesture = Gesture::PenDrag { anchor: p }; + self.pen_update() + } + + /// Rebuild the pen's vector node from its anchors. + pub(super) fn pen_update(&mut self) -> Result<()> { + let Some(pen) = self.tool.pen.clone() else { return Ok(()) }; + self.tool.overlay.pen = pen.points.iter().map(|(p, _, _)| [p.x, p.y]).collect(); + self.tool.overlay.pen_handles = pen_handles(&pen.points); + if pen.points.len() < 2 { + if let Ok(ds) = self.ds_mut() { + ds.revision += 1; + } + return Ok(()); + } + let page = self.page()?; + let parent = pen.parent.unwrap_or(page); + if pen.id.is_none() { + self.begin_interaction("Pen")?; + } + let ds = self.ds_mut()?; + let doc = ds.doc_mut(); + let pw = doc.world_transform(parent); + let inv = pw.inverse(); + // Anchors and handles into the parent's space. + let pts: Vec<(Point, Point, Point)> = pen.points.iter().map(|(p, i, o)| (inv * *p, inv * (*p + *i), inv * (*p + *o))).collect(); + let mut path = figcraft_geom::BezPath::new(); + for (k, (p, _, _)) in pts.iter().enumerate() { + if k == 0 { + path.move_to(*p); + } else if let (Some(prev), Some(cur)) = (pts.get(k - 1), pts.get(k)) { + path.curve_to(prev.2, cur.1, *p); + } + } + if pen.closed + && let (Some(last), Some(first)) = (pts.last(), pts.first()) + { + path.curve_to(last.2, first.1, first.0); + path.close_path(); + } + use figcraft_geom::Shape; + let bb = path.bounding_box(); + path.apply_affine(Affine::translate((-bb.x0, -bb.y0))); + let mut net = VectorNetwork::from_path(&path); + // Straight segments stay straight. + for s in &mut net.segments { + if (s.ta.0.abs() + s.ta.1.abs()) < 1e-9 && (s.tb.0.abs() + s.tb.1.abs()) < 1e-9 { + s.ta = (0.0, 0.0); + s.tb = (0.0, 0.0); + } + } + if !pen.closed { + net.regions.clear(); + } + // Anchor k is vertex k of the open path. + if let (Some(k), Some((last, _, out))) = (pen.join, pts.last()) { + let from = pts.len() - 1; + crate::vectoredit::ops::connect(&mut net, from, k, (out.x - last.x, out.y - last.y), (0.0, 0.0)); + } + let id = match pen.id { + Some(id) if doc.contains(id) => id, + _ => { + let id = doc.alloc_id(); + let mut n = Node::new(id, NodeKind::Vector { network: VectorNetwork::default() }, "Vector", 0.0, 0.0); + n.fills.clear(); + n.strokes = vec![figcraft_color::Paint::solid(figcraft_color::Color::BLACK)]; + n.stroke.align = StrokeAlign::Center; + doc.insert(n, parent, None)?; + id + } + }; + let n = doc.get_mut(id)?; + n.kind = NodeKind::Vector { network: net }; + n.width = bb.width(); + n.height = bb.height(); + n.transform = Affine::translate((bb.x0, bb.y0)); + if pen.closed && n.fills.is_empty() { + n.fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE)]; + } + ds.selection = vec![id]; + ds.revision += 1; + if let Some(p) = self.tool.pen.as_mut() { + p.id = Some(id); + } + Ok(()) + } + + /// Edit `id` as a vector with the pen, going on from point `anchor` (or, without one, starting + /// a new path in the same layer at the next click). + fn pen_edit(&mut self, id: NodeId, anchor: Option) -> Result<()> { + self.set_tool(ToolId::Move); + crate::vectoredit::begin(self, id)?; + crate::vectoredit::set_tool(self, crate::vectoredit::VTool::Pen)?; + crate::vectoredit::set_state(self, |e| { + e.anchor = anchor; + e.vertices = anchor.into_iter().collect(); + }) + } + + /// Enter/Escape while the pen draws: the path ends but its layer stays in edit mode with the + /// pen, so the next path joins the same vector (another Escape leaves edit mode). + pub fn pen_end(&mut self) -> Result<()> { + let id = self.tool.pen.as_ref().and_then(|p| p.id); + self.pen_finish()?; + match id { + Some(id) if self.doc()?.contains(id) => self.pen_edit(id, None), + _ => Ok(()), + } + } + + /// Finish the pen path (and keep the pen tool: double-click, Delete, a tool switch). + pub fn pen_finish(&mut self) -> Result<()> { + let pen = self.tool.pen.take(); + self.tool.overlay.pen.clear(); + self.tool.overlay.pen_handles.clear(); + self.tool.overlay.rubber = None; + self.tool.overlay.rubber_handle = None; + self.tool.gesture = Gesture::None; + if pen.as_ref().is_some_and(|p| p.id.is_some()) { + self.commit_interaction()?; + } + Ok(()) + } + + pub(super) fn pencil_finish(&mut self, points: &[Point], pressure: &[Option]) -> Result<()> { + if points.len() < 2 { + return Ok(()); + } + // Smooth: fit a curve through the samples. + let mut poly = figcraft_geom::BezPath::new(); + for (k, p) in points.iter().enumerate() { + if k == 0 { + poly.move_to(*p); + } else { + poly.line_to(*p); + } + } + let fitted = figcraft_geom::kurbo::simplify::simplify_bezpath(poly.iter(), 0.75, &Default::default()); + let d = fitted.to_svg(); + if matches!(self.tool.tool, ToolId::Marker | ToolId::Highlighter) { + return self.ink_finish(&d); + } + let page = self.page()?; + let parent = frame_at(self.doc()?, page, points.first().copied().unwrap_or_default(), &[]).unwrap_or(page); + let pw = self.doc()?.world_transform(parent); + let mut local = fitted.clone(); + local.apply_affine(pw.inverse()); + let _ = d; + let (color, weight) = PENCIL_STROKE; + let mut props = json!({"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center"}); + // Pen pressure becomes the stroke's width profile (brushes follow it too). + if let Some(points) = pressure_profile(points, pressure) { + props["widthProfile"] = json!(points); + } + if let Some(b) = &self.tool.pencil_brush { + props["brush"] = serde_json::to_value(b).unwrap_or_default(); + } + self.execute( + "node.create", + &json!({"type": "vector", "path": local.to_svg(), "closed": false, "parent": parent, "name": "Vector", "props": props}), + )?; + Ok(()) + } +} diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs new file mode 100644 index 00000000..985e3326 --- /dev/null +++ b/crates/engine/src/canvas/tools/pointer.rs @@ -0,0 +1,292 @@ +use super::handles::{handle_at, label_at, locked_in, on_corner_square, resize_cursor, selected_end_at}; +use super::*; + +impl Session { + /// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode + /// measured and the box measured to, a vector region's highlight), unless a gesture holds it. + pub fn pointer_left(&mut self) { + if !self.tool.busy() { + self.tool.overlay.guides.clear(); + self.tool.overlay.measure_box = None; + } + self.vector_pointer_left(); + } + + /// What a marquee starting at page point `p` selects inside: the top-level frame it starts in + /// (its children), or the focus view's frame. + pub(crate) fn marquee_within(&self, p: Point) -> Result> { + let (doc, page) = (self.doc()?, self.page()?); + let within = frame_at(doc, page, p, &[]).filter(|f| doc.node(*f).is_some_and(|n| n.parent == Some(page))); + let within = within.filter(|f| doc.world_bounds(*f).is_some_and(|b| b.contains(p))); + Ok(self.ds()?.focused().or(within)) + } + + /// The selection a marquee over page rect `r` makes from `base` (what was selected before): + /// the layers it touches (`deep`: nested ones too); `additive` (Shift) toggles them. + pub(crate) fn marquee_selection(&self, r: Rect, within: Option, deep: bool, base: &[NodeId], additive: bool) -> Result> { + let (doc, page) = (self.doc()?, self.page()?); + let mut hits = + if deep { figcraft_doc::geometry::marquee_deep(doc, page, r) } else { figcraft_doc::geometry::marquee_select(doc, page, r, within) }; + if let Some(f) = self.ds()?.focused() { + hits.retain(|h| *h != f && doc.is_ancestor(f, *h)); + } + // A marquee over a frame from outside selects the frame only when it covers it fully. + if within.is_none() && !deep { + hits.retain(|h| { + let full = doc.world_bounds(*h).is_some_and(|b| r.contains(b.origin()) && r.contains(Point::new(b.x1, b.y1))); + full || !doc.node(*h).is_some_and(|n| n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) + }); + } + // Shift toggles: what was selected and is touched again comes out. + let mut sel: Vec = base.iter().copied().filter(|b| !(additive && hits.contains(b))).collect(); + for h in hits { + if !base.contains(&h) && !sel.contains(&h) { + sel.push(h); + } + } + Ok(sel) + } + + pub fn set_tool(&mut self, t: ToolId) { + if self.tool.tool == ToolId::Pen && t != ToolId::Pen { + let _ = self.pen_finish(); + } + if !matches!(t, ToolId::Move | ToolId::Scale | ToolId::Hand) && self.ds().is_ok_and(|d| d.vector_edit.is_some()) { + let _ = crate::vectoredit::end(self); + } + self.tool.tool = t; + self.tool.gesture = Gesture::None; + self.tool.pick_only = false; + // The tool's cursor shows before the pointer moves. + self.tool.overlay = + Overlay { builder_remove: self.tool.builder_remove && t == ToolId::ShapeBuilder, cursor: t.cursor(), ..Overlay::default() }; + self.tool.shape_builder = None; + if t == ToolId::ShapeBuilder { + // Regions show straight away, before the pointer moves. + let sel = self.selection(); + let _ = crate::shapebuilder::arrangement(self, &sel); + } + if let Ok(ds) = self.ds_mut() { + if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) { + ds.text_edit = None; + } + ds.revision += 1; + } + } + + /// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects. + fn acting_tool(&self) -> ToolId { + match self.tool.tool { + t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move, + t => t, + } + } + + /// Drop the gesture under way (a drag, a resize, a shape being drawn), restoring the document + /// as Escape does; the UI calls it when Dev Mode starts, where nothing may move. + pub fn drop_gesture(&mut self) -> Result<()> { + self.tool.gesture = Gesture::None; + self.cancel_interaction() + } + + /// Feed one pointer event to the current tool. + pub fn pointer(&mut self, ev: &PointerEvent) -> Result<()> { + // A held button sends the same move every frame: running the gesture again would only + // rebuild the same preview (a new document, and a canvas render, every frame). + if ev.kind != PointerKind::Move { + self.tool.last_move = None; + } else if self.tool.busy() { + if self.tool.last_move.as_ref() == Some(ev) { + return Ok(()); + } + self.tool.last_move = Some(ev.clone()); + } + let tool = self.acting_tool(); + let edits = matches!(tool, ToolId::Move | ToolId::Scale) && !self.tool.dev_mode; + if edits && self.origin_pointer(ev)? { + return Ok(()); + } + if edits && self.ds().is_ok_and(|d| d.vector_edit.is_some()) && self.vector_pointer(ev)? { + return Ok(()); + } + if tool == ToolId::ShapeBuilder { + return self.shape_builder_pointer(ev); + } + match ev.kind { + PointerKind::Hover => self.hover(ev), + PointerKind::Down => match tool { + ToolId::Move | ToolId::Scale => { + // While a board's vote runs, a click on an object votes. + if tool == ToolId::Move && !self.tool.dev_mode && self.vote_down(ev)? { + return Ok(()); + } + // A click on the canvas deselects a ruler guide (the UI selects guides itself). + self.ds_mut()?.guide = None; + self.move_down(ev) + } + ToolId::Hand => { + let v = &self.ds()?.view; + self.tool.gesture = Gesture::Pan { sx: ev.sx, sy: ev.sy, cx: v.cx, cy: v.cy }; + self.tool.overlay.cursor = Cursor::Grabbing; + Ok(()) + } + ToolId::Text => self.text_down(ev), + ToolId::TextOnPath => self.text_on_path_down(ev), + ToolId::Pen => self.pen_down(ev), + ToolId::Pencil => { + self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; + Ok(()) + } + ToolId::Comment => { + self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y})); + Ok(()) + } + ToolId::Eyedropper => self.eyedrop(ev), + t if t.on_boards() => self.board_down(ev), + t if t.draws_shape() => self.draw_down(ev), + _ => Ok(()), + }, + PointerKind::Move => self.drag(ev), + PointerKind::Up => self.up(ev), + PointerKind::DoubleClick => self.double_click(ev), + } + } + + /// The rotation origin handle while it shows: a press drags it, a double-click puts it back + /// at the centre. True when the event was the handle's. + fn origin_pointer(&mut self, ev: &PointerEvent) -> Result { + let dragging = matches!(self.tool.gesture, Gesture::Origin); + if !dragging && !self.ds()?.on_origin_handle(ev.p(), ev.zoom) { + return Ok(false); + } + match ev.kind { + PointerKind::Down if !dragging => self.tool.gesture = Gesture::Origin, + PointerKind::Move if dragging => self.drag_origin_to(ev.p(), ev.zoom)?, + PointerKind::Up if dragging => self.tool.gesture = Gesture::None, + PointerKind::DoubleClick => self.set_rotation_origin(None)?, + PointerKind::Hover => {} + _ => return Ok(false), + } + self.tool.overlay.cursor = Cursor::Move; + Ok(true) + } + + pub(super) fn hover(&mut self, ev: &PointerEvent) -> Result<()> { + let tool = self.acting_tool(); + let ds = self.ds()?; + let (doc, sel, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + let mut cursor = tool.cursor(); + // The text being edited, and with the Text tool any text, takes the I-beam: a click there + // puts the caret. + let on_edited = ds + .text_edit + .as_ref() + .is_some_and(|te| doc.world_bounds(te.id).is_some_and(|b| b.inflate(ev.tol() * 4.0, ev.tol() * 4.0).contains(ev.p()))); + let on_text = + |s: &Self| s.hit(&doc, page, ev.p(), ev.tol() * 2.0).and_then(|l| doc.node(l)).is_some_and(|n| matches!(n.kind, NodeKind::Text(_))); + if tool == ToolId::Text && (on_edited || on_text(self)) { + cursor = Cursor::Text; + } + if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) { + cursor = Cursor::Text; + } + let mut hover = None; + let mut guides = Vec::new(); + let mut measure_box = None; + let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; + self.tool.overlay.arc_hover = arc_hover; + let shape_hover = if self.arc_handles_shown() && !on_corner_square(&doc, &sel, ev.p(), ev.zoom) { + crate::shape_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) + } else { + None + }; + self.tool.overlay.shape_hover = shape_hover; + if matches!(tool, ToolId::Move | ToolId::Scale) && on_edited { + cursor = Cursor::Text; + } else if matches!(tool, ToolId::Move | ToolId::Scale) && arc_hover.is_none() && shape_hover.is_none() { + if let Some(h) = handle_at(&doc, &sel, ev.p(), ev.zoom).filter(|_| !self.tool.dev_mode) { + cursor = resize_cursor(&doc, &sel, h); + } else { + let leaf = self.hit(&doc, page, ev.p(), ev.tol() * 2.0); + if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() { + cursor = Cursor::Pointer; + } + hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); + // Alt over a selected layer: a drag from here duplicates it. + if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { + cursor = Cursor::Copy; + } + if ev.alt || self.tool.dev_mode { + use crate::distance::{Hover, guide_at, hover_lines, padding_frame, parent_of}; + // Shift+Alt over an auto layout frame: its padding and gaps. Otherwise from the + // selection to a ruler guide, the hovered layer or (over empty canvas) the parent. + let around: Vec = sel.iter().copied().chain(hover).collect(); + let what = hover.filter(|_| ev.alt && ev.shift).and_then(|t| padding_frame(&doc, t)).map(Hover::Padding).or_else(|| { + if sel.is_empty() { + return None; + } + let guide = guide_at(&doc, page, &around, ev.p(), ev.tol() * 4.0).map(|(vertical, at)| Hover::Guide { vertical, at }); + guide.or_else(|| hover.or_else(|| parent_of(&doc, page, &sel)).map(Hover::Layer)) + }); + if let Some(what) = what { + let (g, b) = hover_lines(&doc, page, &sel, what); + guides = g; + measure_box = b.map(|b| [b.x0, b.y0, b.x1, b.y1]); + } + } + } + } + if tool == ToolId::Move && !self.tool.dev_mode && crate::board::end_handle_at(&doc, &sel, ev.p(), ev.zoom).is_some() { + cursor = Cursor::Grab; + } + self.tool.overlay.measure_box = measure_box; + self.tool.overlay.cursor = cursor; + if tool == ToolId::Pen { + // Where a click would land (snapped), with its guides. + let (to, g) = if ev.shift { (ev.p(), Vec::new()) } else { self.snap_pen_tool(ev)? }; + guides = g; + self.tool.overlay.cursor = Cursor::Pen(self.pen_cursor(&doc, &sel, to, ev.zoom)); + if let Some((last, _, out)) = self.tool.pen.as_ref().and_then(|pen| pen.points.last()) { + // With Shift the band ends where a click puts the point (#970). + let to = if ev.shift { at_45_degrees(*last, to) } else { to }; + self.tool.overlay.rubber = Some([last.x, last.y, to.x, to.y]); + self.tool.overlay.rubber_handle = (out.hypot() > 0.0).then_some([last.x + out.x, last.y + out.y]); + } + } + self.tool.overlay.guides = guides; + let ds = self.ds_mut()?; + if ds.hover != hover { + ds.hover = hover; + ds.revision += 1; + } + Ok(()) + } + + /// What a click with the Pen tool at `p` (where it snaps) would do: close the path on its first + /// point, connect to another of its points, go on from a selected open path's end, or add a + /// new point (the same tests as [`Self::pen_down`]). + fn pen_cursor(&self, doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> PenCursor { + let near = |q: &Point| (*q - p).hypot() * zoom < 8.0; + let on_point = match &self.tool.pen { + None => selected_end_at(doc, sel, p, 8.0 / zoom.max(1e-6)).is_some(), + Some(pen) => { + let n = pen.points.len(); + (n > 2 && pen.points.first().is_some_and(|(q, _, _)| near(q))) + || (1..n.saturating_sub(1)).any(|k| pen.points.get(k).is_some_and(|(q, _, _)| near(q))) + } + }; + if on_point { PenCursor::Close } else { PenCursor::New } + } + + /// Whether what the gesture did to `ids` so far is an edit only a main component can make + /// (`moved`: their place among the layers counts too): it is undone when the gesture ends. + pub(super) fn refused(&self, ids: &[NodeId], moved: bool) -> bool { + let Ok(ds) = self.ds() else { return false }; + ds.interaction.as_ref().is_some_and(|it| { + ids.iter().any(|m| { + (moved && !crate::components::move_allowed(&it.doc, &ds.doc, *m)) + || crate::components::refused_override(&it.doc, &ds.doc, *m).is_some() + }) + }) + } +} diff --git a/crates/engine/src/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs similarity index 100% rename from crates/engine/src/vectoredit.rs rename to crates/engine/src/canvas/vectoredit.rs diff --git a/crates/engine/src/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs similarity index 100% rename from crates/engine/src/vectoredit/ops.rs rename to crates/engine/src/canvas/vectoredit/ops.rs diff --git a/crates/engine/src/vectoredit/point_snap.rs b/crates/engine/src/canvas/vectoredit/point_snap.rs similarity index 100% rename from crates/engine/src/vectoredit/point_snap.rs rename to crates/engine/src/canvas/vectoredit/point_snap.rs diff --git a/crates/engine/src/vectoredit/pointer.rs b/crates/engine/src/canvas/vectoredit/pointer.rs similarity index 100% rename from crates/engine/src/vectoredit/pointer.rs rename to crates/engine/src/canvas/vectoredit/pointer.rs diff --git a/crates/engine/src/vectoredit/regions.rs b/crates/engine/src/canvas/vectoredit/regions.rs similarity index 100% rename from crates/engine/src/vectoredit/regions.rs rename to crates/engine/src/canvas/vectoredit/regions.rs diff --git a/crates/engine/src/vectoredit/regions_tests.rs b/crates/engine/src/canvas/vectoredit/regions_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/regions_tests.rs rename to crates/engine/src/canvas/vectoredit/regions_tests.rs diff --git a/crates/engine/src/vectoredit/tests.rs b/crates/engine/src/canvas/vectoredit/tests.rs similarity index 100% rename from crates/engine/src/vectoredit/tests.rs rename to crates/engine/src/canvas/vectoredit/tests.rs diff --git a/crates/engine/src/vectoredit/transform.rs b/crates/engine/src/canvas/vectoredit/transform.rs similarity index 100% rename from crates/engine/src/vectoredit/transform.rs rename to crates/engine/src/canvas/vectoredit/transform.rs diff --git a/crates/engine/src/vectoredit/transform_tests.rs b/crates/engine/src/canvas/vectoredit/transform_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/transform_tests.rs rename to crates/engine/src/canvas/vectoredit/transform_tests.rs diff --git a/crates/engine/src/vectoredit/width.rs b/crates/engine/src/canvas/vectoredit/width.rs similarity index 100% rename from crates/engine/src/vectoredit/width.rs rename to crates/engine/src/canvas/vectoredit/width.rs diff --git a/crates/engine/src/vectoredit/width_sides_tests.rs b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/width_sides_tests.rs rename to crates/engine/src/canvas/vectoredit/width_sides_tests.rs diff --git a/crates/engine/src/vectoredit/width_tests.rs b/crates/engine/src/canvas/vectoredit/width_tests.rs similarity index 100% rename from crates/engine/src/vectoredit/width_tests.rs rename to crates/engine/src/canvas/vectoredit/width_tests.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd.rs similarity index 100% rename from crates/engine/src/cmd/mod.rs rename to crates/engine/src/cmd.rs diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs index 428738d1..5abe79b8 100644 --- a/crates/engine/src/cmd/component.rs +++ b/crates/engine/src/cmd/component.rs @@ -11,6 +11,17 @@ use crate::components::*; use crate::params::*; use crate::{EngineError, Result, Session, bad, spec}; +mod instance; +mod props; +mod variants; + +use instance::{detach, make_slot, push_changes, remove_slot, reset_instance, reset_override, reset_slot, restore_main, slot_insert, swap}; +pub(crate) use instance::{swap_at, swap_instance}; +use props::{add_prop, bind_prop, delete_prop, edit_prop, set_props}; +pub(crate) use props::{apply_prop_bindings, check_prop_binding, describe_props}; +use variants::{add_variant, combine}; +pub use variants::{can_add_variant, can_combine_variants}; + /// `component.hideWhenPublishing`: the leading `.` of the names of this file's components and /// sets (those `ids` are or are in), put on or taken off. fn hide_when_publishing(s: &mut Session, p: &Value) -> Result { @@ -343,1185 +354,3 @@ fn set_name_from(variant: &str) -> Option { let base = variant.rsplit_once('/').map_or(variant, |(path, _)| path).trim(); (!base.is_empty() && !base.contains('=')).then(|| base.to_string()) } - -/// Wrap components in a new component set (named after the first) placed around them. -fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { - let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; - let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?; - if inside_instance(doc, top) || owning_instance(doc, parent).is_some() { - return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into())); - } - let bounds = doc.bounds_of(ids).unwrap_or_default(); - let pw = doc.world_transform(parent); - let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0)); - let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component")); - let set = doc.alloc_id(); - let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height()); - n.transform = figcraft_geom::Affine::translate((local.x0, local.y0)); - n.fills.clear(); - doc.insert(n, parent, doc.index_in_parent(top).map(|i| i + 1))?; - for i in ids { - doc.reparent(*i, set, None)?; - } - Ok(set) -} - -const INSIDE_INSTANCE: &str = "components inside an instance can't become variants"; - -/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they -/// can: anything but main components, components that are variants already (combining them would -/// put a set in a set), library copies, components inside an instance, and a component inside -/// another one of `ids`. -fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> { - if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { - Some("select components only") - } else if ids.iter().any(|i| variant_set(doc, *i).is_some()) { - Some("components that are variants already can't be combined again") - } else if ids.iter().any(|i| doc.is_remote(*i)) { - Some(crate::library::READ_ONLY) - } else if ids.iter().any(|i| inside_instance(doc, *i)) { - Some(INSIDE_INSTANCE) - } else if roots(doc, ids).len() != ids.len() { - Some("a component can't be combined with one inside it") - } else { - None - } -} - -/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more -/// main components that [`combine_refusal`] lets through. -pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool { - ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::>().len() == ids.len() -} - -/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main -/// component not in a set yet (it becomes one); never a library copy or inside an instance. -pub fn can_add_variant(doc: &Document, id: NodeId) -> bool { - let Some(n) = doc.node(id) else { return false }; - let local = !doc.is_remote(id) && !inside_instance(doc, id); - match n.kind { - NodeKind::ComponentSet { .. } => { - local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))) - } - NodeKind::Component { .. } => local, - _ => false, - } -} - -/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default", -/// "Property 1=VariantN" unless already in prop=value form. Returns the set. -fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { - let set = wrap_in_set(cmd, doc, ids)?; - for (k, i) in ids.iter().enumerate() { - let node = doc.get_mut(*i)?; - if !node.name.contains('=') { - node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) }); - } - } - Ok(set) -} - -fn combine(s: &mut Session, p: &Value) -> Result { - let cmd = "component.combineVariants"; - let mut seen = BTreeSet::new(); - let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect(); - let doc = s.doc_mut()?; - if let Some(why) = combine_refusal(doc, &ids) { - return Err(EngineError::Disabled(cmd.into(), why.into())); - } - let ids = z_sorted(doc, &ids); - let set = make_variant_set(cmd, doc, &ids)?; - s.set_selection(vec![set]); - Ok(json!({"id": set})) -} - -/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom). -fn fit_set(doc: &mut Document, set: NodeId) { - let Some(kids) = doc.node(set).map(|n| n.children.clone()) else { return }; - let (mut w, mut h) = (0.0f64, 0.0f64); - for c in kids { - if let Some(n) = doc.node(c) { - let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()); - w = w.max(b.x1 + 20.0); - h = h.max(b.y1 + 20.0); - } - } - if let Some(n) = doc.node_mut(set) { - n.width = n.width.max(w); - n.height = n.height.max(h); - } -} - -fn add_variant(s: &mut Session, p: &Value) -> Result { - let cmd = "component.addVariant"; - let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?; - let doc = s.doc_mut()?; - let (set, base) = match doc.get(t)?.kind { - NodeKind::ComponentSet { .. } => { - let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?; - (t, last) - } - NodeKind::Component { .. } => match variant_set(doc, t) { - Some(set) => (set, t), - // A main component on its own becomes a set of itself, then gains the new variant. - None => (make_variant_set(cmd, doc, &[t])?, t), - }, - _ => return Err(bad(cmd, "select a component set or component")), - }; - let options = variant_options(doc, set); - let mut pairs = variant_props(&doc.get(base)?.name); - let order: Vec = options.iter().map(|(k, _)| k.clone()).collect(); - let existing: BTreeSet> = - doc.get(set)?.children.iter().filter_map(|c| doc.node(*c)).map(|n| variant_props(&n.name)).collect(); - match (string(p, "property"), string(p, "value")) { - (Some(prop), Some(value)) => { - let (prop, value) = (prop.trim().to_string(), value.trim().to_string()); - if !options.iter().any(|(k, _)| *k == prop) { - return Err(bad(cmd, format!("unknown variant property `{prop}`"))); - } - if value.is_empty() || value.contains([',', '=']) { - return Err(bad(cmd, "`value` must be non-empty without ',' or '='")); - } - pairs.insert(prop, value); - if existing.contains(&pairs) { - return Err(bad(cmd, "that variant already exists")); - } - } - (Some(_), None) => return Err(bad(cmd, "`value` is required with `property`")), - _ => { - let prop = order.first().cloned().unwrap_or_else(|| "Property 1".into()); - let mut k = existing.len() + 1; - loop { - pairs.insert(prop.clone(), format!("Variant{k}")); - if !existing.contains(&pairs) || k > 10_000 { - break; - } - k += 1; - } - } - } - let name = variant_name(&pairs, &order); - let bottom = doc - .get(set)? - .children - .iter() - .filter_map(|c| doc.node(*c)) - .map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()).y1) - .fold(0.0f64, f64::max); - let dup = doc.duplicate_into(base, set, None)?; - let n = doc.get_mut(dup)?; - n.name = name; - let c = n.transform.as_coeffs(); - n.transform = figcraft_geom::Affine::new([c[0], c[1], c[2], c[3], c[4], bottom + 20.0]); - fit_set(doc, set); - s.set_selection(vec![dup]); - Ok(json!({"id": dup})) -} - -fn detach(s: &mut Session, p: &Value) -> Result { - let cmd = "component.detach"; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let mut out = Vec::new(); - for i in ids { - if !matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) { - continue; - } - if inside_instance(doc, i) { - return Err(EngineError::Disabled(cmd.into(), "a nested instance can't be detached on its own: detach the outer instance first".into())); - } - // Own layers lose their link (slot content stays as it is); nested instances stay - // instances of their own mains. - let nested = unlink_layers(doc, i, Some(i)); - keep_main_modes(doc, i)?; - let n = doc.get_mut(i)?; - n.kind = NodeKind::Frame; - n.source = None; - n.overrides.clear(); - n.prop_refs.remove("mainComponent"); - // No properties left to drive. - n.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); - for nn in nested { - rebase_instance(doc, nn); - } - out.push(i); - } - if out.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); - } - s.set_selection(out.clone()); - Ok(json!({"ids": out})) -} - -/// Make the variable modes instance `i` takes from its main its own (it's being detached). -fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> { - let modes = match doc.get(i)?.kind { - NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(), - _ => return Ok(()), - }; - let own = &mut doc.get_mut(i)?.var_modes; - for (c, m) in modes { - own.entry(c).or_insert(m); - } - Ok(()) -} - -fn reset_instance(s: &mut Session, p: &Value) -> Result { - let cmd = "component.resetInstance"; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let mut done = Vec::new(); - for i in ids { - let Some(inst) = owning_instance(doc, i) else { continue }; - let top = !inside_instance(doc, inst); - for d in doc.subtree(inst) { - let keep: Vec<&str> = if d == inst && top { vec!["size"] } else { vec![] }; - if let Some(n) = doc.node(d) - && n.overrides.iter().any(|g| !keep.contains(&g.as_str())) - && let Some(n) = doc.node_mut(d) - { - n.overrides.retain(|g| keep.contains(&g.as_str())); - } - } - // Property values go back to the defaults. - if top - && let Some(NodeKind::Instance { component, props }) = doc.node(inst).map(|n| n.kind.clone()) - && let Some(defaults) = doc.node(component).map(default_props) - && props != defaults - && let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind - { - *props = defaults; - } - // ...and stop following variables. - if top && doc.node(inst).is_some_and(|n| n.bound.keys().any(|k| k.starts_with(PROP_BINDING))) { - doc.get_mut(inst)?.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); - } - done.push(inst); - } - if done.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); - } - Ok(json!({"ids": done})) -} - -fn reset_override(s: &mut Session, p: &Value) -> Result { - let cmd = "component.resetOverride"; - let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer inside an instance"))?; - let group = string(p, "group"); - if let Some(g) = &group - && !OVERRIDE_GROUPS.contains(&g.as_str()) - { - return Err(bad(cmd, format!("unknown group `{g}` (known: {})", OVERRIDE_GROUPS.join(", ")))); - } - let doc = s.doc_mut()?; - if owning_instance(doc, i).is_none() || doc.get(i)?.source.is_none() { - return Err(bad(cmd, "not a layer of an instance")); - } - let top_root = matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) && !inside_instance(doc, i); - let had = doc.get(i)?.overrides.clone(); - let n = doc.get_mut(i)?; - match &group { - Some(g) => { - n.overrides.remove(g); - } - None => n.overrides.clear(), - } - let mut changed = had != doc.get(i)?.overrides; - // The instance's own property values reset with `props`. - if top_root - && group.as_deref().is_none_or(|g| g == "props") - && let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) - && let Some(defaults) = doc.node(component).map(default_props) - && props != defaults - && let NodeKind::Instance { props, .. } = &mut doc.get_mut(i)?.kind - { - *props = defaults; - changed = true; - } - Ok(json!({"changed": changed})) -} - -/// A property name for `owner` based on `base` (a layer name): valid and not taken yet. -fn fresh_prop_name(doc: &Document, owner: &Owner, base: &str) -> String { - let base: String = base.chars().filter(|c| !matches!(c, ',' | '=')).take(100).collect::().trim().to_string(); - let base = if base.is_empty() { "Slot".to_string() } else { base }; - let taken = |n: &str| { - owner.components(doc).iter().any(|c| prop_defs(doc, *c).contains_key(n)) - || matches!(owner, Owner::Set(s) if variant_options(doc, *s).iter().any(|(k, _)| k == n)) - }; - (1..10_000).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or(base) -} - -/// The `preferred` components of a slot property: component or component set ids. -fn preferred_arg(cmd: &str, doc: &Document, p: &Value) -> Result> { - let Some(v) = get(p, "preferred").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; - let list = v.as_array().ok_or_else(|| bad(cmd, "`preferred`: an array of component ids"))?; - let mut out = Vec::new(); - for x in list.iter().take(500) { - let c = id_of(x) - .filter(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))) - .ok_or_else(|| bad(cmd, format!("`preferred`: {x} is not a component or component set")))?; - if !out.contains(&c) { - out.push(c); - } - } - Ok(out) -} - -fn make_slot(s: &mut Session, p: &Value) -> Result { - let cmd = "component.makeSlot"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame inside a main component"))?; - let doc = s.doc()?; - let n = doc.get(id)?; - let inside_main = owning_component(doc, id).is_some_and(|c| c != id) && owning_instance(doc, id).is_none(); - if !matches!(n.kind, NodeKind::Frame) || !inside_main { - return Err(bad(cmd, "only a frame inside a main component (not inside an instance) can become a slot")); - } - if is_slot(doc, id) { - return Err(EngineError::Disabled(cmd.into(), format!("\"{}\" is already a slot", n.name))); - } - let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?; - let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name)); - let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null); - add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) -} - -fn remove_slot(s: &mut Session, p: &Value) -> Result { - let cmd = "component.removeSlot"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot frame of a main component"))?; - let doc = s.doc()?; - if owning_instance(doc, id).is_some() { - return Err(EngineError::Disabled(cmd.into(), "slots are removed on the main component".into())); - } - let name = slot_name(doc, id).ok_or_else(|| bad(cmd, "not a slot frame of a main component"))?; - delete_prop(s, &json!({"id": id, "name": name}))?; - Ok(json!({"id": id, "name": name})) -} - -/// The slot inside an instance a command acts on: `id` itself, or instance `id`'s slot `name`. -fn instance_slot(cmd: &str, doc: &Document, id: NodeId, name: Option<&str>) -> Result { - let slot = match name { - Some(nm) => slots_of(doc, id).into_iter().find(|(_, n)| n == nm).map(|(l, _)| l).ok_or_else(|| bad(cmd, format!("no slot named `{nm}`")))?, - None => id, - }; - if !is_slot(doc, slot) || owning_instance(doc, slot).is_none() { - return Err(bad(cmd, "not a slot of an instance (a main component's slot takes layers like any frame)")); - } - Ok(slot) -} - -fn slot_insert(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.slotInsert"; - let c = req_id(cmd, p, "component")?; - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; - let index = match get(p, "index").filter(|v| !v.is_null()) { - None => None, - Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a child index (0 = bottom)"))?), - }; - let doc = s.doc_mut()?; - let slot = instance_slot(cmd, doc, target, string(p, "slot").as_deref())?; - let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; - if boolean(p, "replace").unwrap_or(false) { - for k in doc.get(slot)?.children.clone() { - doc.remove(k)?; - } - } - let i = create_instance_at(doc, c, slot, 0.0, 0.0, index)?; - s.set_selection(vec![i]); - Ok(json!({"id": i, "slot": slot})) -} - -fn reset_slot(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.resetSlot"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; - let doc = s.doc_mut()?; - let slot = instance_slot(cmd, doc, id, string(p, "slot").as_deref())?; - let changed = slot_edited(doc, slot); - if changed { - doc.get_mut(slot)?.overrides.remove(SLOT); - } - Ok(json!({"id": slot, "changed": changed})) -} - -fn push_changes(s: &mut Session, p: &Value) -> Result { - let cmd = "component.pushChanges"; - let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; - let doc = s.doc_mut()?; - // Changes go to the outer instance's main (never into a nested instance's own main). - let inst = root_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; - let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return ok() }; - if !doc.contains(component) { - return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); - } - // Copy every overridden layer's values to its source in the main (geometry, resizing, swaps, - // property values and slot content stay with the instance). - let mut pushed = 0; - for d in doc.subtree(inst) { - let Some(n) = doc.node(d).cloned() else { continue }; - let Some(src) = (if d == inst { Some(component) } else { n.source }) else { continue }; - if n.overrides.is_empty() || !doc.contains(src) || !doc.is_ancestor(component, src) { - continue; - } - let Some(mut target) = doc.node(src).cloned() else { continue }; - let groups: BTreeSet = n - .overrides - .iter() - .filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name")) - .cloned() - .collect(); - for g in &groups { - copy_group(&n, &mut target, g); - } - if groups.is_empty() { - continue; - } - pushed += 1; - if let Some(slot) = doc.node_mut(src) { - *slot = target; - } - if let Some(m) = doc.node_mut(d) { - m.overrides.retain(|g| !groups.contains(g)); - } - } - Ok(json!({"layers": pushed})) -} - -fn restore_prop_defs(doc: &Document, props: &BTreeMap) -> BTreeMap { - let mut defs = BTreeMap::new(); - for (k, v) in props { - let def = match v { - Value::Bool(b) => PropDef::Boolean { default: *b }, - Value::String(t) => PropDef::Text { default: t.clone() }, - other => match id_of(other) { - Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() }, - _ => continue, - }, - }; - defs.insert(k.clone(), def); - } - defs -} - -fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap { - let mut map: BTreeMap = BTreeMap::new(); - let mut used: BTreeSet = BTreeSet::new(); - map.insert(i, component); - used.insert(component); - for d in sub.iter().filter(|d| **d != i) { - let src = doc.node(*d).and_then(|n| n.source); - let nid = match src { - Some(s) if !doc.contains(s) && used.insert(s) => s, - _ => doc.alloc_id(), - }; - map.insert(*d, nid); - } - map -} - -fn restore_main(s: &mut Session, p: &Value) -> Result { - let cmd = "component.restoreMain"; - let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; - let doc = s.doc_mut()?; - let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) else { - return Err(bad(cmd, "select an instance")); - }; - if doc.contains(component) { - return Err(EngineError::Disabled(cmd.into(), "the main component still exists".into())); - } - if inside_instance(doc, i) { - return Err(EngineError::Disabled(cmd.into(), "select the outer instance".into())); - } - let parent = doc.get(i)?.parent.ok_or_else(|| bad(cmd, "no parent"))?; - let index = doc.index_in_parent(i); - // The main comes back with its old id (other instances point to it) and its layers with the - // ids their copies mirror, so every instance re-links by source. - let sub = doc.subtree(i); - let map = restore_id_map(doc, i, component, &sub); - let mut nodes = Vec::new(); - let mut nested = Vec::new(); - for d in &sub { - let Some(mut n) = doc.node(*d).cloned() else { continue }; - if *d == i { - let defs = restore_prop_defs(doc, &props); - n.kind = NodeKind::Component { description: String::new(), props: defs }; - n.source = None; - n.overrides.clear(); - n.prop_refs.clear(); - } else { - let parent_inst = n.parent.and_then(|p| owning_instance(doc, p)); - if parent_inst != Some(i) { - nodes.push(n); - continue; - } - if matches!(n.kind, NodeKind::Instance { .. }) { - nested.push(*d); - } else { - n.source = None; - n.overrides.clear(); - } - } - nodes.push(n); - } - doc.remove(i)?; - doc.paste_nodes(&nodes, &map, i, parent, index)?; - for nn in nested { - if let Some(m) = map.get(&nn).copied() { - rebase_instance(doc, m); - } - } - s.set_selection(vec![component]); - Ok(json!({"id": component})) -} - -fn swap(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.swap"; - let c = req_id(cmd, p, "component")?; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; - for i in ids { - swap_instance(doc, i, c).map_err(|m| bad(cmd, m))?; - } - ok() -} - -/// Make instance `i` show main component `c`, keeping the values of properties with the same names -/// (the layers' overrides follow by name when the instance is rebuilt). -pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> { - if doc.is_ancestor(c, i) { - return Err("an instance can't show the component it is inside".into()); - } - let defaults = doc.node(c).map(default_props).unwrap_or_default(); - match doc.get_mut(i).map(|n| &mut n.kind) { - Ok(NodeKind::Instance { component, props }) => { - *component = c; - let old = std::mem::take(props); - *props = defaults.iter().map(|(k, d)| (k.clone(), old.get(k).cloned().unwrap_or_else(|| d.clone()))).collect(); - Ok(()) - } - _ => Err(format!("{i} is not an instance")), - } -} - -/// Swap the instance under a page point to a component (`instance.swapAt`). -pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.swapAt"; - let c = req_id(cmd, p, "component")?; - let pt = figcraft_geom::Point::new(req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); - let page = s.page()?; - let doc = s.doc()?; - let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; - let family = crate::drop::swap_family(doc, c); - let target = crate::drop::swap_target(doc, page, pt, family.as_deref(), Some(c)) - .ok_or_else(|| EngineError::Disabled(cmd.into(), "there is no instance at that point to swap".into()))?; - keep_text_overrides(s.doc_mut()?, target); - swap(s, &json!({"ids": [target], "component": c}))?; - s.set_selection(vec![target]); - Ok(json!({"id": target, "component": c})) -} - -/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a -/// boolean property takes a boolean variable, a text or variant property a string one). -pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: VariableType) -> std::result::Result<(), String> { - let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { - return Err(format!("{id} is not an instance (component properties are bound on instances)")); - }; - if variant_set(doc, *component).is_some_and(|set| variant_options(doc, set).iter().any(|(o, _)| o == name)) { - return if kind == VariableType::String { Ok(()) } else { Err(format!("`{name}` is a variant property: bind a string variable")) }; - } - match (prop_defs(doc, *component).get(name), kind) { - (Some(PropDef::Boolean { .. }), VariableType::Boolean) | (Some(PropDef::Text { .. }), VariableType::String) => Ok(()), - (Some(PropDef::Boolean { .. }), _) => Err(format!("`{name}` is a boolean property: bind a boolean variable")), - (Some(PropDef::Text { .. }), _) => Err(format!("`{name}` is a text property: bind a string variable")), - (Some(_), _) => Err(format!("`{name}`: only boolean and text properties can be bound to variables")), - (None, _) => Err(format!("the instance has no property named `{name}`")), - } -} - -/// Write bound variable values into instance `id`'s boolean and text property values, and switch -/// it to the variant its bound variant properties name (only what changed; the instance then -/// follows like after `instance.setProperties`). A value that names no variant changes nothing. -pub(crate) fn apply_prop_bindings(doc: &mut Document, id: NodeId, sets: &[(String, VariableValue)]) { - let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { return }; - let component = *component; - if let Some(set) = variant_set(doc, component) { - let options = variant_options(doc, set); - let current = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); - let mut wanted = current.clone(); - for (name, val) in sets { - if let VariableValue::String(t) = val - && options.iter().any(|(o, vals)| o == name && vals.contains(t)) - { - wanted.insert(name.clone(), t.clone()); - } - } - if wanted != current - && let Some(target) = find_variant(doc, set, &wanted) - && let Ok(n) = doc.get_mut(id) - && let NodeKind::Instance { component: c, .. } = &mut n.kind - { - *c = target; - } - } - let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return }; - let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|c| &c.kind) else { return }; - let mut next = props.clone(); - for (name, val) in sets { - let v = match (defs.get(name), val) { - (Some(PropDef::Boolean { .. }), VariableValue::Boolean(b)) => json!(b), - (Some(PropDef::Text { .. }), VariableValue::String(t)) => json!(t), - _ => continue, - }; - next.insert(name.clone(), v); - } - if next != *props - && let Ok(n) = doc.get_mut(id) - && let NodeKind::Instance { props, .. } = &mut n.kind - { - *props = next; - } -} - -/// Coerce a property value to the definition's type. -fn coerce(cmd: &str, doc: &Document, name: &str, def: &PropDef, v: &Value) -> Result { - match def { - PropDef::Boolean { .. } => match v { - Value::Bool(b) => Ok(json!(b)), - Value::String(t) if t == "true" || t == "false" => Ok(json!(t == "true")), - _ => Err(bad(cmd, format!("`{name}` is a boolean property"))), - }, - PropDef::Text { .. } => match v { - Value::String(t) => Ok(json!(t)), - Value::Number(n) => Ok(json!(n.to_string())), - _ => Err(bad(cmd, format!("`{name}` is a text property"))), - }, - PropDef::InstanceSwap { .. } => match id_of(v).and_then(|c| main_of(doc, c)) { - Some(c) => Ok(json!(c.0)), - None => Err(bad(cmd, format!("`{name}` takes a component id"))), - }, - PropDef::Variant { options, .. } => { - let t = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); - if options.is_empty() || options.contains(&t) { Ok(json!(t)) } else { Err(bad(cmd, format!("`{name}` must be one of {options:?}"))) } - } - PropDef::Slot { .. } => Err(bad(cmd, format!("`{name}` is a slot: fill it with instance.slotInsert or by adding layers to it"))), - } -} - -fn set_props(s: &mut Session, p: &Value) -> Result { - let cmd = "instance.setProperties"; - let ids = req_targets(cmd, s, p)?; - let props = get(p, "properties").and_then(Value::as_object).cloned().ok_or_else(|| bad(cmd, "`properties` object required"))?; - let doc = s.doc_mut()?; - for i in ids { - let Some(NodeKind::Instance { component, .. }) = doc.node(i).map(|n| n.kind.clone()) else { - return Err(bad(cmd, format!("{i} is not an instance"))); - }; - // Variant properties: switch to the matching sibling variant. - let set = variant_set(doc, component); - let options = set.map(|s| variant_options(doc, s)).unwrap_or_default(); - let mut wanted = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); - let mut target = component; - let mut rest = Vec::new(); - for (k, v) in &props { - if let Some((_, vals)) = options.iter().find(|(o, _)| o == k) { - let val = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); - if !vals.contains(&val) { - return Err(bad(cmd, format!("`{k}` must be one of {vals:?}"))); - } - wanted.insert(k.clone(), val); - } else { - rest.push((k.clone(), v.clone())); - } - } - if let Some(set) = set - && wanted != doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default() - { - target = find_variant(doc, set, &wanted).unwrap_or(component); - } - let defs = match doc.node(target).map(|n| &n.kind) { - Some(NodeKind::Component { props, .. }) => props.clone(), - _ => BTreeMap::new(), - }; - let mut values = Vec::new(); - for (k, v) in rest { - let def = defs.get(&k).ok_or_else(|| { - let known: Vec<&str> = defs.keys().map(String::as_str).chain(options.iter().map(|(o, _)| o.as_str())).collect(); - bad(cmd, format!("unknown property `{k}` (known: {})", known.join(", "))) - })?; - values.push((k.clone(), coerce(cmd, doc, &k, def, &v)?)); - } - if let NodeKind::Instance { component: c, props: ip } = &mut doc.get_mut(i)?.kind { - *c = target; - for (k, v) in values { - ip.insert(k, v); - } - for (k, d) in defs.iter().filter(|(_, d)| takes_value(d)) { - ip.entry(k.clone()).or_insert_with(|| prop_default_value(d)); - } - } - } - ok() -} - -/// Where component properties live: a set (its variants share them) or a lone component. -enum Owner { - Set(NodeId), - Comp(NodeId), -} - -impl Owner { - fn resolve(doc: &Document, id: NodeId) -> Option { - let n = doc.node(id)?; - match n.kind { - NodeKind::ComponentSet { .. } => Some(Owner::Set(id)), - _ => { - let c = owning_component(doc, id)?; - Some(match variant_set(doc, c) { - Some(s) => Owner::Set(s), - None => Owner::Comp(c), - }) - } - } - } - - fn components(&self, doc: &Document) -> Vec { - match self { - Owner::Comp(c) => vec![*c], - Owner::Set(s) => doc - .node(*s) - .map(|n| n.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|x| matches!(x.kind, NodeKind::Component { .. }))).collect()) - .unwrap_or_default(), - } - } - - fn id(&self) -> NodeId { - match self { - Owner::Set(s) | Owner::Comp(s) => *s, - } - } -} - -fn prop_defs(doc: &Document, c: NodeId) -> BTreeMap { - match doc.node(c).map(|n| &n.kind) { - Some(NodeKind::Component { props, .. }) => props.clone(), - _ => BTreeMap::new(), - } -} - -fn set_prop_defs(doc: &mut Document, c: NodeId, f: impl FnOnce(&mut BTreeMap)) -> Result<()> { - if let NodeKind::Component { props, .. } = &mut doc.get_mut(c)?.kind { - f(props); - } - Ok(()) -} - -fn valid_prop_name(cmd: &str, name: &str) -> Result { - let name = name.trim(); - if name.is_empty() || name.contains([',', '=']) || name.chars().count() > 200 { - return Err(bad(cmd, "property names must be non-empty, without ',' or '='")); - } - Ok(name.to_string()) -} - -fn add_prop(s: &mut Session, p: &Value) -> Result { - add_prop_for("component.addProperty", s, p) -} - -/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its -/// slot property here). -fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; - let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?; - let t = req_string(cmd, p, "type")?; - let doc = s.doc_mut()?; - if inside_instance(doc, target) { - return Err(bad(cmd, "select a component, not a layer of an instance")); - } - let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component, a component set or a layer inside a component"))?; - // A layer selected inside the component gets bound to the new property. - let layer = id(p, "layer") - .or_else(|| (target != owner.id() && !matches!(doc.node(target).map(|n| &n.kind), Some(NodeKind::Component { .. }))).then_some(target)); - let comps = owner.components(doc); - let variants = match &owner { - Owner::Set(s) => variant_options(doc, *s), - Owner::Comp(_) => Vec::new(), - }; - if variants.iter().any(|(k, _)| *k == name) || comps.iter().any(|c| prop_defs(doc, *c).contains_key(&name)) { - return Err(bad(cmd, format!("a property named `{name}` already exists"))); - } - let layer_node = layer.and_then(|l| doc.node(l)).cloned(); - let def = match t.to_ascii_lowercase().as_str() { - // Without a default, a bound layer keeps showing as it does: its visibility is the default. - "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) }, - "text" => PropDef::Text { - default: string(p, "default") - .or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone())) - .unwrap_or_else(|| name.clone()), - }, - "instanceswap" | "instance_swap" | "instance" => { - let d = id(p, "default") - .or_else(|| match layer_node.as_ref().map(|n| &n.kind) { - Some(NodeKind::Instance { component, .. }) => Some(*component), - _ => None, - }) - .and_then(|c| main_of(doc, c)) - .ok_or_else(|| bad(cmd, "default: a component id"))?; - PropDef::InstanceSwap { default: d, preferred: preferred_arg(cmd, doc, p)? } - } - "variant" => { - let dv = string(p, "default") - .map(|d| d.trim().to_string()) - .filter(|d| !d.is_empty() && !d.contains([',', '='])) - .unwrap_or_else(|| "Default".into()); - let set = match owner { - Owner::Set(s) => s, - Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?, - }; - for c in Owner::Set(set).components(doc) { - let cn = doc.get_mut(c)?; - cn.name = if cn.name.contains('=') { format!("{}, {name}={dv}", cn.name) } else { format!("{name}={dv}") }; - } - s.set_selection(vec![set]); - return Ok(json!({"id": set})); - } - "slot" => { - if !layer_node.as_ref().is_some_and(|n| matches!(n.kind, NodeKind::Frame)) { - return Err(bad(cmd, "a slot is a frame inside the component: select one (or pass `layer`)")); - } - PropDef::Slot { preferred: preferred_arg(cmd, doc, p)? } - } - _ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant|slot")), - }; - for c in &comps { - set_prop_defs(doc, *c, |m| { - m.insert(name.clone(), def.clone()); - })?; - // Existing instances get the default. - if !takes_value(&def) { - continue; - } - for inst in instances_of(doc, *c) { - if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind { - props.entry(name.clone()).or_insert_with(|| prop_default_value(&def)); - } - } - } - if let Some(l) = layer { - bind(cmd, doc, l, Some(&name), None)?; - } - Ok(json!({"id": owner.id(), "name": name})) -} - -/// Bind (or with `property: None` unbind) a layer's field to a property of its component. -fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, field: Option<&str>) -> Result<()> { - let n = doc.get(layer)?.clone(); - if inside_instance(doc, layer) { - return Err(bad(cmd, "layers inside an instance can't be bound (bind the nested instance itself, or edit its main)")); - } - let comp = owning_component(doc, layer).ok_or_else(|| bad(cmd, "the layer isn't inside a component"))?; - let Some(property) = property.filter(|p| !p.is_empty()) else { - let n = doc.get_mut(layer)?; - match field { - Some(f) => { - n.prop_refs.remove(f); - } - None => n.prop_refs.clear(), - } - return Ok(()); - }; - let defs = prop_defs(doc, comp); - let def = defs.get(property).ok_or_else(|| bad(cmd, format!("the component has no property `{property}`")))?; - let expected = match def { - PropDef::Boolean { .. } => "visible", - PropDef::Text { .. } => "characters", - PropDef::InstanceSwap { .. } => "mainComponent", - PropDef::Variant { .. } => return Err(bad(cmd, "variant properties pick a variant; they can't be bound to a layer")), - PropDef::Slot { .. } => SLOT, - }; - let field = field.unwrap_or(expected); - if field != expected { - return Err(bad(cmd, format!("`{property}` can only control `{expected}`"))); - } - match field { - "characters" if n.kind.text().is_none() => return Err(bad(cmd, "text properties bind to text layers")), - "mainComponent" if !matches!(n.kind, NodeKind::Instance { .. }) => return Err(bad(cmd, "instance swap properties bind to instances")), - "visible" if layer == comp => return Err(bad(cmd, "the component itself can't be hidden by its own property")), - SLOT if !matches!(n.kind, NodeKind::Frame) => return Err(bad(cmd, "slot properties bind to frames inside the component")), - SLOT if doc - .subtree(comp) - .into_iter() - .any(|d| d != layer && doc.node(d).is_some_and(|x| x.prop_refs.get(SLOT).map(String::as_str) == Some(property))) => - { - return Err(bad(cmd, format!("another frame of the component is already the slot `{property}`"))); - } - _ => {} - } - let n = doc.get_mut(layer)?; - n.prop_refs.insert(field.to_string(), property.to_string()); - // On the main, a boolean property's value is the visibility of the layers it controls. - if let PropDef::Boolean { default } = def { - n.visible = *default; - } - Ok(()) -} - -fn bind_prop(s: &mut Session, p: &Value) -> Result { - let cmd = "component.bindProperty"; - let layer = id(p, "layer").or_else(|| targets(s, p).first().copied()).ok_or_else(|| bad(cmd, "`layer` required"))?; - let property = string(p, "property"); - let field = string(p, "field"); - if let Some(f) = &field - && !matches!(f.as_str(), "visible" | "characters" | "mainComponent" | SLOT) - { - return Err(bad(cmd, "field: visible|characters|mainComponent|slot")); - } - let doc = s.doc_mut()?; - bind(cmd, doc, layer, property.as_deref(), field.as_deref())?; - ok() -} - -/// Rename `old` → `new` in the bindings of a component's layers and in its instances' values. -fn rename_prop_refs(doc: &mut Document, comp: NodeId, old: &str, new: Option<&str>) -> Result<()> { - for d in doc.subtree(comp) { - let refs = doc.node(d).map(|n| n.prop_refs.clone()).unwrap_or_default(); - if refs.values().any(|v| v == old) - && let Some(n) = doc.node_mut(d) - { - match new { - Some(nw) => n.prop_refs.values_mut().filter(|v| *v == old).for_each(|v| *v = nw.to_string()), - None => n.prop_refs.retain(|_, v| v != old), - } - } - } - // Instances' values and the variables bound to them follow (a deleted property drops both). - let old_key = format!("{PROP_BINDING}{old}"); - for inst in instances_of(doc, comp) { - let n = doc.get_mut(inst)?; - if let NodeKind::Instance { props, .. } = &mut n.kind - && let Some(v) = props.remove(old) - && let Some(nw) = new - { - props.insert(nw.to_string(), v); - } - if let Some(var) = n.bound.remove(&old_key) - && let Some(nw) = new - { - n.bound.insert(format!("{PROP_BINDING}{nw}"), var); - } - } - Ok(()) -} - -fn edit_prop(s: &mut Session, p: &Value) -> Result { - let cmd = "component.editProperty"; - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; - let name = req_string(cmd, p, "name")?; - let new_name = string(p, "newName").map(|n| valid_prop_name(cmd, &n)).transpose()?.filter(|n| *n != name); - let doc = s.doc_mut()?; - let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; - let comps = owner.components(doc); - if let Some(nn) = &new_name { - let taken = comps.iter().any(|c| prop_defs(doc, *c).contains_key(nn)) - || matches!(owner, Owner::Set(s) if variant_options(doc, s).iter().any(|(k, _)| k == nn)); - if taken { - return Err(bad(cmd, format!("a property named `{nn}` already exists"))); - } - } - // Variant property of a set. - if let Owner::Set(set) = owner - && let Some((_, values)) = variant_options(doc, set).into_iter().find(|(k, _)| *k == name) - { - let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); - let rename_value = match (string(p, "value"), string(p, "newValue")) { - (Some(v), Some(nv)) => { - let nv = nv.trim().to_string(); - if !values.contains(&v) { - return Err(bad(cmd, format!("`{name}` has no value `{v}`"))); - } - if nv.is_empty() || nv.contains([',', '=']) { - return Err(bad(cmd, "`newValue` must be non-empty, without ',' or '='")); - } - Some((v, nv)) - } - _ => None, - }; - let mut order: Vec = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect(); - // Reordered: every variant's name lists the properties in the new order. - if let Some(to) = get(p, "index").filter(|v| !v.is_null()) { - let to = to.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a property place (0 = first)"))?; - let key = new_name.clone().unwrap_or(name.clone()); - order.retain(|k| *k != key); - order.insert(to.min(order.len()), key); - } - for c in &comps { - let mut pairs = variant_props(&doc.get(*c)?.name); - if let Some((v, nv)) = &rename_value - && pairs.get(&name) == Some(v) - { - pairs.insert(name.clone(), nv.clone()); - } - if let Some(nn) = &new_name - && let Some(v) = pairs.remove(&name) - { - pairs.insert(nn.clone(), v); - } - doc.get_mut(*c)?.name = variant_name(&pairs, &order); - } - // The default variant is the top-left one: the variant with that value (and the default's - // other values, when there is one) trades places with it. - if let Some(dv) = string(p, "default") { - let key = new_name.clone().unwrap_or(name.clone()); - let current = default_variant(doc, set); - let mut wanted = current.and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); - wanted.insert(key.clone(), dv.clone()); - let props_of = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).unwrap_or_default(); - let pick = - comps.iter().copied().find(|c| props_of(c) == wanted).or_else(|| comps.iter().copied().find(|c| props_of(c).get(&key) == Some(&dv))); - let (Some(f), Some(d)) = (pick, current) else { return Err(bad(cmd, format!("no variant has {key}={dv}"))) }; - if f != d { - let place = |doc: &Document, c: NodeId| doc.node(c).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x, y)).unwrap_or_default(); - let (fp, dp) = (place(doc, f), place(doc, d)); - for (c, (x, y)) in [(f, dp), (d, fp)] { - let n = doc.get_mut(c)?; - let [a, b, cc, dd, ..] = n.transform.as_coeffs(); - n.transform = figcraft_geom::Affine::new([a, b, cc, dd, x, y]); - } - fit_set(doc, set); - } - } - return ok(); - } - let def = comps.iter().find_map(|c| prop_defs(doc, *c).get(&name).cloned()).ok_or_else(|| bad(cmd, format!("no property named `{name}`")))?; - let preferred = if get(p, "preferred").is_some() { - if !matches!(def, PropDef::Slot { .. } | PropDef::InstanceSwap { .. }) { - return Err(bad(cmd, "`preferred` is for instance swap and slot properties")); - } - Some(preferred_arg(cmd, doc, p)?) - } else { - None - }; - let mut new_def = match get(p, "default") { - Some(v) => match coerce(cmd, doc, &name, &def, v)? { - Value::Bool(b) => PropDef::Boolean { default: b }, - Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t }, - other => match (&def, id_of(&other)) { - (PropDef::InstanceSwap { preferred, .. }, Some(c)) => PropDef::InstanceSwap { default: c, preferred: preferred.clone() }, - _ => def.clone(), - }, - }, - None => def.clone(), - }; - if let (Some(list), PropDef::Slot { preferred } | PropDef::InstanceSwap { preferred, .. }) = (preferred, &mut new_def) { - *preferred = list; - } - for c in &comps { - let had = prop_defs(doc, *c).contains_key(&name); - if !had { - continue; - } - let key = new_name.clone().unwrap_or(name.clone()); - let nd = new_def.clone(); - let old = name.clone(); - set_prop_defs(doc, *c, move |m| { - m.remove(&old); - m.insert(key, nd); - })?; - if let Some(nn) = &new_name { - rename_prop_refs(doc, *c, &name, Some(nn))?; - } - } - // The main's bound layers show the new default. - if let PropDef::Boolean { default } = new_def { - set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default); - } - ok() -} - -fn delete_prop(s: &mut Session, p: &Value) -> Result { - let cmd = "component.deleteProperty"; - let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; - let name = req_string(cmd, p, "name")?; - let doc = s.doc_mut()?; - let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; - let comps = owner.components(doc); - if let Owner::Set(set) = owner - && variant_options(doc, set).iter().any(|(k, _)| *k == name) - { - let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); - for c in &comps { - let mut pairs = variant_props(&doc.get(*c)?.name); - pairs.remove(&name); - let n = doc.get_mut(*c)?; - n.name = if pairs.is_empty() { "Default".into() } else { variant_name(&pairs, &order) }; - } - return ok(); - } - let mut found = false; - for c in &comps { - if prop_defs(doc, *c).contains_key(&name) { - found = true; - set_prop_defs(doc, *c, |m| { - m.remove(&name); - })?; - rename_prop_refs(doc, *c, &name, None)?; - } - } - if !found { - return Err(bad(cmd, format!("no property named `{name}`"))); - } - ok() -} - -fn def_json(name: &str, d: &PropDef) -> Value { - match d { - PropDef::Boolean { default } => json!({"name": name, "type": "boolean", "default": default}), - PropDef::Text { default } => json!({"name": name, "type": "text", "default": default}), - PropDef::InstanceSwap { default, preferred } => json!({"name": name, "type": "instanceSwap", "default": default, "preferred": preferred}), - PropDef::Variant { options, default } => json!({"name": name, "type": "variant", "default": default, "options": options}), - PropDef::Slot { preferred } => json!({"name": name, "type": "slot", "preferred": preferred}), - } -} - -/// Properties of a component, set or instance as JSON (`component.properties`). -pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value { - let Some(n) = doc.node(id) else { return Value::Null }; - let (owner_comp, values) = match &n.kind { - NodeKind::Instance { component, props } => (Some(*component), Some(props.clone())), - _ => (main_of(doc, id).or_else(|| owning_component(doc, id)), None), - }; - let Some(comp) = owner_comp else { return json!({"properties": []}) }; - let mut list: Vec = Vec::new(); - if let Some(set) = variant_set(doc, comp) { - let current = doc.node(comp).map(|c| variant_props(&c.name)).unwrap_or_default(); - let defaults = default_variant(doc, set).and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); - for (k, vals) in variant_options(doc, set) { - let mut v = json!({"name": k, "type": "variant", "options": vals, "default": defaults.get(&k).or(vals.first())}); - if values.is_some() { - v["value"] = json!(current.get(&k)); - } - list.push(v); - } - } - // Slots: the frames (on a main) or this instance's copy, with whether it holds its own content. - let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) }); - for (k, d) in prop_defs(doc, comp) { - let mut v = def_json(&k, &d); - if !takes_value(&d) { - let layers: Vec = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect(); - if values.is_some() { - v["value"] = layers.first().map_or(Value::Null, |l| { - let count = doc.node(*l).map_or(0, |n| n.children.len()); - json!({"layer": l, "edited": slot_edited(doc, *l), "layers": count}) - }); - } else { - v["layers"] = json!(layers); - } - } else if let Some(vals) = &values { - v["value"] = vals.get(&k).cloned().unwrap_or_else(|| prop_default_value(&d)); - } - list.push(v); - } - let set = variant_set(doc, comp); - let conflicts = set.map(|s| variant_conflicts(doc, s)).unwrap_or_default(); - json!({"component": comp, "set": set, "properties": list, "conflicts": conflicts}) -} diff --git a/crates/engine/src/cmd/component/instance.rs b/crates/engine/src/cmd/component/instance.rs new file mode 100644 index 00000000..ffc5ec1c --- /dev/null +++ b/crates/engine/src/cmd/component/instance.rs @@ -0,0 +1,404 @@ +use super::props::{Owner, add_prop_for, delete_prop, prop_defs}; +use super::*; + +pub(super) fn detach(s: &mut Session, p: &Value) -> Result { + let cmd = "component.detach"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut out = Vec::new(); + for i in ids { + if !matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) { + continue; + } + if inside_instance(doc, i) { + return Err(EngineError::Disabled(cmd.into(), "a nested instance can't be detached on its own: detach the outer instance first".into())); + } + // Own layers lose their link (slot content stays as it is); nested instances stay + // instances of their own mains. + let nested = unlink_layers(doc, i, Some(i)); + keep_main_modes(doc, i)?; + let n = doc.get_mut(i)?; + n.kind = NodeKind::Frame; + n.source = None; + n.overrides.clear(); + n.prop_refs.remove("mainComponent"); + // No properties left to drive. + n.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + for nn in nested { + rebase_instance(doc, nn); + } + out.push(i); + } + if out.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); + } + s.set_selection(out.clone()); + Ok(json!({"ids": out})) +} + +/// Make the variable modes instance `i` takes from its main its own (it's being detached). +fn keep_main_modes(doc: &mut Document, i: NodeId) -> Result<()> { + let modes = match doc.get(i)?.kind { + NodeKind::Instance { component, .. } => doc.node(component).map(|m| m.var_modes.clone()).unwrap_or_default(), + _ => return Ok(()), + }; + let own = &mut doc.get_mut(i)?.var_modes; + for (c, m) in modes { + own.entry(c).or_insert(m); + } + Ok(()) +} + +pub(super) fn reset_instance(s: &mut Session, p: &Value) -> Result { + let cmd = "component.resetInstance"; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let mut done = Vec::new(); + for i in ids { + let Some(inst) = owning_instance(doc, i) else { continue }; + let top = !inside_instance(doc, inst); + for d in doc.subtree(inst) { + let keep: Vec<&str> = if d == inst && top { vec!["size"] } else { vec![] }; + if let Some(n) = doc.node(d) + && n.overrides.iter().any(|g| !keep.contains(&g.as_str())) + && let Some(n) = doc.node_mut(d) + { + n.overrides.retain(|g| keep.contains(&g.as_str())); + } + } + // Property values go back to the defaults. + if top + && let Some(NodeKind::Instance { component, props }) = doc.node(inst).map(|n| n.kind.clone()) + && let Some(defaults) = doc.node(component).map(default_props) + && props != defaults + && let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind + { + *props = defaults; + } + // ...and stop following variables. + if top && doc.node(inst).is_some_and(|n| n.bound.keys().any(|k| k.starts_with(PROP_BINDING))) { + doc.get_mut(inst)?.bound.retain(|k, _| !k.starts_with(PROP_BINDING)); + } + done.push(inst); + } + if done.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "select an instance".into())); + } + Ok(json!({"ids": done})) +} + +pub(super) fn reset_override(s: &mut Session, p: &Value) -> Result { + let cmd = "component.resetOverride"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer inside an instance"))?; + let group = string(p, "group"); + if let Some(g) = &group + && !OVERRIDE_GROUPS.contains(&g.as_str()) + { + return Err(bad(cmd, format!("unknown group `{g}` (known: {})", OVERRIDE_GROUPS.join(", ")))); + } + let doc = s.doc_mut()?; + if owning_instance(doc, i).is_none() || doc.get(i)?.source.is_none() { + return Err(bad(cmd, "not a layer of an instance")); + } + let top_root = matches!(doc.get(i)?.kind, NodeKind::Instance { .. }) && !inside_instance(doc, i); + let had = doc.get(i)?.overrides.clone(); + let n = doc.get_mut(i)?; + match &group { + Some(g) => { + n.overrides.remove(g); + } + None => n.overrides.clear(), + } + let mut changed = had != doc.get(i)?.overrides; + // The instance's own property values reset with `props`. + if top_root + && group.as_deref().is_none_or(|g| g == "props") + && let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) + && let Some(defaults) = doc.node(component).map(default_props) + && props != defaults + && let NodeKind::Instance { props, .. } = &mut doc.get_mut(i)?.kind + { + *props = defaults; + changed = true; + } + Ok(json!({"changed": changed})) +} + +/// A property name for `owner` based on `base` (a layer name): valid and not taken yet. +fn fresh_prop_name(doc: &Document, owner: &Owner, base: &str) -> String { + let base: String = base.chars().filter(|c| !matches!(c, ',' | '=')).take(100).collect::().trim().to_string(); + let base = if base.is_empty() { "Slot".to_string() } else { base }; + let taken = |n: &str| { + owner.components(doc).iter().any(|c| prop_defs(doc, *c).contains_key(n)) + || matches!(owner, Owner::Set(s) if variant_options(doc, *s).iter().any(|(k, _)| k == n)) + }; + (1..10_000).map(|k| if k == 1 { base.clone() } else { format!("{base} {k}") }).find(|n| !taken(n)).unwrap_or(base) +} + +/// The `preferred` components of a slot property: component or component set ids. +pub(super) fn preferred_arg(cmd: &str, doc: &Document, p: &Value) -> Result> { + let Some(v) = get(p, "preferred").filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let list = v.as_array().ok_or_else(|| bad(cmd, "`preferred`: an array of component ids"))?; + let mut out = Vec::new(); + for x in list.iter().take(500) { + let c = id_of(x) + .filter(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. }))) + .ok_or_else(|| bad(cmd, format!("`preferred`: {x} is not a component or component set")))?; + if !out.contains(&c) { + out.push(c); + } + } + Ok(out) +} + +pub(super) fn make_slot(s: &mut Session, p: &Value) -> Result { + let cmd = "component.makeSlot"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a frame inside a main component"))?; + let doc = s.doc()?; + let n = doc.get(id)?; + let inside_main = owning_component(doc, id).is_some_and(|c| c != id) && owning_instance(doc, id).is_none(); + if !matches!(n.kind, NodeKind::Frame) || !inside_main { + return Err(bad(cmd, "only a frame inside a main component (not inside an instance) can become a slot")); + } + if is_slot(doc, id) { + return Err(EngineError::Disabled(cmd.into(), format!("\"{}\" is already a slot", n.name))); + } + let owner = Owner::resolve(doc, id).ok_or_else(|| bad(cmd, "the frame isn't inside a component"))?; + let name = string(p, "name").unwrap_or_else(|| fresh_prop_name(doc, &owner, &n.name)); + let preferred = get(p, "preferred").cloned().unwrap_or(Value::Null); + add_prop_for(cmd, s, &json!({"id": id, "layer": id, "name": name, "type": "slot", "preferred": preferred})) +} + +pub(super) fn remove_slot(s: &mut Session, p: &Value) -> Result { + let cmd = "component.removeSlot"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot frame of a main component"))?; + let doc = s.doc()?; + if owning_instance(doc, id).is_some() { + return Err(EngineError::Disabled(cmd.into(), "slots are removed on the main component".into())); + } + let name = slot_name(doc, id).ok_or_else(|| bad(cmd, "not a slot frame of a main component"))?; + delete_prop(s, &json!({"id": id, "name": name}))?; + Ok(json!({"id": id, "name": name})) +} + +/// The slot inside an instance a command acts on: `id` itself, or instance `id`'s slot `name`. +fn instance_slot(cmd: &str, doc: &Document, id: NodeId, name: Option<&str>) -> Result { + let slot = match name { + Some(nm) => slots_of(doc, id).into_iter().find(|(_, n)| n == nm).map(|(l, _)| l).ok_or_else(|| bad(cmd, format!("no slot named `{nm}`")))?, + None => id, + }; + if !is_slot(doc, slot) || owning_instance(doc, slot).is_none() { + return Err(bad(cmd, "not a slot of an instance (a main component's slot takes layers like any frame)")); + } + Ok(slot) +} + +pub(super) fn slot_insert(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.slotInsert"; + let c = req_id(cmd, p, "component")?; + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; + let index = match get(p, "index").filter(|v| !v.is_null()) { + None => None, + Some(v) => Some(v.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a child index (0 = bottom)"))?), + }; + let doc = s.doc_mut()?; + let slot = instance_slot(cmd, doc, target, string(p, "slot").as_deref())?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + if boolean(p, "replace").unwrap_or(false) { + for k in doc.get(slot)?.children.clone() { + doc.remove(k)?; + } + } + let i = create_instance_at(doc, c, slot, 0.0, 0.0, index)?; + s.set_selection(vec![i]); + Ok(json!({"id": i, "slot": slot})) +} + +pub(super) fn reset_slot(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.resetSlot"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a slot inside an instance"))?; + let doc = s.doc_mut()?; + let slot = instance_slot(cmd, doc, id, string(p, "slot").as_deref())?; + let changed = slot_edited(doc, slot); + if changed { + doc.get_mut(slot)?.overrides.remove(SLOT); + } + Ok(json!({"id": slot, "changed": changed})) +} + +pub(super) fn push_changes(s: &mut Session, p: &Value) -> Result { + let cmd = "component.pushChanges"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; + let doc = s.doc_mut()?; + // Changes go to the outer instance's main (never into a nested instance's own main). + let inst = root_instance(doc, i).ok_or_else(|| bad(cmd, "not an instance"))?; + let Some(NodeKind::Instance { component, .. }) = doc.node(inst).map(|n| n.kind.clone()) else { return ok() }; + if !doc.contains(component) { + return Err(EngineError::Disabled(cmd.into(), "the main component was deleted (use component.restoreMain)".into())); + } + // Copy every overridden layer's values to its source in the main (geometry, resizing, swaps, + // property values and slot content stay with the instance). + let mut pushed = 0; + for d in doc.subtree(inst) { + let Some(n) = doc.node(d).cloned() else { continue }; + let Some(src) = (if d == inst { Some(component) } else { n.source }) else { continue }; + if n.overrides.is_empty() || !doc.contains(src) || !doc.is_ancestor(component, src) { + continue; + } + let Some(mut target) = doc.node(src).cloned() else { continue }; + let groups: BTreeSet = n + .overrides + .iter() + .filter(|g| !(matches!(g.as_str(), "size" | "resizing" | "position" | "component" | "props" | SLOT) || d == inst && g.as_str() == "name")) + .cloned() + .collect(); + for g in &groups { + copy_group(&n, &mut target, g); + } + if groups.is_empty() { + continue; + } + pushed += 1; + if let Some(slot) = doc.node_mut(src) { + *slot = target; + } + if let Some(m) = doc.node_mut(d) { + m.overrides.retain(|g| !groups.contains(g)); + } + } + Ok(json!({"layers": pushed})) +} + +fn restore_prop_defs(doc: &Document, props: &BTreeMap) -> BTreeMap { + let mut defs = BTreeMap::new(); + for (k, v) in props { + let def = match v { + Value::Bool(b) => PropDef::Boolean { default: *b }, + Value::String(t) => PropDef::Text { default: t.clone() }, + other => match id_of(other) { + Some(c) if doc.contains(c) => PropDef::InstanceSwap { default: c, preferred: Vec::new() }, + _ => continue, + }, + }; + defs.insert(k.clone(), def); + } + defs +} + +fn restore_id_map(doc: &mut Document, i: NodeId, component: NodeId, sub: &[NodeId]) -> BTreeMap { + let mut map: BTreeMap = BTreeMap::new(); + let mut used: BTreeSet = BTreeSet::new(); + map.insert(i, component); + used.insert(component); + for d in sub.iter().filter(|d| **d != i) { + let src = doc.node(*d).and_then(|n| n.source); + let nid = match src { + Some(s) if !doc.contains(s) && used.insert(s) => s, + _ => doc.alloc_id(), + }; + map.insert(*d, nid); + } + map +} + +pub(super) fn restore_main(s: &mut Session, p: &Value) -> Result { + let cmd = "component.restoreMain"; + let i = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select an instance"))?; + let doc = s.doc_mut()?; + let Some(NodeKind::Instance { component, props }) = doc.node(i).map(|n| n.kind.clone()) else { + return Err(bad(cmd, "select an instance")); + }; + if doc.contains(component) { + return Err(EngineError::Disabled(cmd.into(), "the main component still exists".into())); + } + if inside_instance(doc, i) { + return Err(EngineError::Disabled(cmd.into(), "select the outer instance".into())); + } + let parent = doc.get(i)?.parent.ok_or_else(|| bad(cmd, "no parent"))?; + let index = doc.index_in_parent(i); + // The main comes back with its old id (other instances point to it) and its layers with the + // ids their copies mirror, so every instance re-links by source. + let sub = doc.subtree(i); + let map = restore_id_map(doc, i, component, &sub); + let mut nodes = Vec::new(); + let mut nested = Vec::new(); + for d in &sub { + let Some(mut n) = doc.node(*d).cloned() else { continue }; + if *d == i { + let defs = restore_prop_defs(doc, &props); + n.kind = NodeKind::Component { description: String::new(), props: defs }; + n.source = None; + n.overrides.clear(); + n.prop_refs.clear(); + } else { + let parent_inst = n.parent.and_then(|p| owning_instance(doc, p)); + if parent_inst != Some(i) { + nodes.push(n); + continue; + } + if matches!(n.kind, NodeKind::Instance { .. }) { + nested.push(*d); + } else { + n.source = None; + n.overrides.clear(); + } + } + nodes.push(n); + } + doc.remove(i)?; + doc.paste_nodes(&nodes, &map, i, parent, index)?; + for nn in nested { + if let Some(m) = map.get(&nn).copied() { + rebase_instance(doc, m); + } + } + s.set_selection(vec![component]); + Ok(json!({"id": component})) +} + +pub(super) fn swap(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.swap"; + let c = req_id(cmd, p, "component")?; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + for i in ids { + swap_instance(doc, i, c).map_err(|m| bad(cmd, m))?; + } + ok() +} + +/// Make instance `i` show main component `c`, keeping the values of properties with the same names +/// (the layers' overrides follow by name when the instance is rebuilt). +pub(crate) fn swap_instance(doc: &mut Document, i: NodeId, c: NodeId) -> std::result::Result<(), String> { + if doc.is_ancestor(c, i) { + return Err("an instance can't show the component it is inside".into()); + } + let defaults = doc.node(c).map(default_props).unwrap_or_default(); + match doc.get_mut(i).map(|n| &mut n.kind) { + Ok(NodeKind::Instance { component, props }) => { + *component = c; + let old = std::mem::take(props); + *props = defaults.iter().map(|(k, d)| (k.clone(), old.get(k).cloned().unwrap_or_else(|| d.clone()))).collect(); + Ok(()) + } + _ => Err(format!("{i} is not an instance")), + } +} + +/// Swap the instance under a page point to a component (`instance.swapAt`). +pub(crate) fn swap_at(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.swapAt"; + let c = req_id(cmd, p, "component")?; + let pt = figcraft_geom::Point::new(req_num(cmd, p, "x")?, req_num(cmd, p, "y")?); + let page = s.page()?; + let doc = s.doc()?; + let c = main_of(doc, c).ok_or_else(|| bad(cmd, "`component` must be a component or component set"))?; + let family = crate::drop::swap_family(doc, c); + let target = crate::drop::swap_target(doc, page, pt, family.as_deref(), Some(c)) + .ok_or_else(|| EngineError::Disabled(cmd.into(), "there is no instance at that point to swap".into()))?; + keep_text_overrides(s.doc_mut()?, target); + swap(s, &json!({"ids": [target], "component": c}))?; + s.set_selection(vec![target]); + Ok(json!({"id": target, "component": c})) +} diff --git a/crates/engine/src/cmd/component/props.rs b/crates/engine/src/cmd/component/props.rs new file mode 100644 index 00000000..3f2d7002 --- /dev/null +++ b/crates/engine/src/cmd/component/props.rs @@ -0,0 +1,609 @@ +use super::instance::preferred_arg; +use super::variants::{fit_set, wrap_in_set}; +use super::*; + +/// Whether instance `id` has a component property `name` that a `kind` variable can drive (a +/// boolean property takes a boolean variable, a text or variant property a string one). +pub(crate) fn check_prop_binding(doc: &Document, id: NodeId, name: &str, kind: VariableType) -> std::result::Result<(), String> { + let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { + return Err(format!("{id} is not an instance (component properties are bound on instances)")); + }; + if variant_set(doc, *component).is_some_and(|set| variant_options(doc, set).iter().any(|(o, _)| o == name)) { + return if kind == VariableType::String { Ok(()) } else { Err(format!("`{name}` is a variant property: bind a string variable")) }; + } + match (prop_defs(doc, *component).get(name), kind) { + (Some(PropDef::Boolean { .. }), VariableType::Boolean) | (Some(PropDef::Text { .. }), VariableType::String) => Ok(()), + (Some(PropDef::Boolean { .. }), _) => Err(format!("`{name}` is a boolean property: bind a boolean variable")), + (Some(PropDef::Text { .. }), _) => Err(format!("`{name}` is a text property: bind a string variable")), + (Some(_), _) => Err(format!("`{name}`: only boolean and text properties can be bound to variables")), + (None, _) => Err(format!("the instance has no property named `{name}`")), + } +} + +/// Write bound variable values into instance `id`'s boolean and text property values, and switch +/// it to the variant its bound variant properties name (only what changed; the instance then +/// follows like after `instance.setProperties`). A value that names no variant changes nothing. +pub(crate) fn apply_prop_bindings(doc: &mut Document, id: NodeId, sets: &[(String, VariableValue)]) { + let Some(NodeKind::Instance { component, .. }) = doc.node(id).map(|n| &n.kind) else { return }; + let component = *component; + if let Some(set) = variant_set(doc, component) { + let options = variant_options(doc, set); + let current = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); + let mut wanted = current.clone(); + for (name, val) in sets { + if let VariableValue::String(t) = val + && options.iter().any(|(o, vals)| o == name && vals.contains(t)) + { + wanted.insert(name.clone(), t.clone()); + } + } + if wanted != current + && let Some(target) = find_variant(doc, set, &wanted) + && let Ok(n) = doc.get_mut(id) + && let NodeKind::Instance { component: c, .. } = &mut n.kind + { + *c = target; + } + } + let Some(NodeKind::Instance { component, props }) = doc.node(id).map(|n| &n.kind) else { return }; + let Some(NodeKind::Component { props: defs, .. }) = doc.node(*component).map(|c| &c.kind) else { return }; + let mut next = props.clone(); + for (name, val) in sets { + let v = match (defs.get(name), val) { + (Some(PropDef::Boolean { .. }), VariableValue::Boolean(b)) => json!(b), + (Some(PropDef::Text { .. }), VariableValue::String(t)) => json!(t), + _ => continue, + }; + next.insert(name.clone(), v); + } + if next != *props + && let Ok(n) = doc.get_mut(id) + && let NodeKind::Instance { props, .. } = &mut n.kind + { + *props = next; + } +} + +/// Coerce a property value to the definition's type. +fn coerce(cmd: &str, doc: &Document, name: &str, def: &PropDef, v: &Value) -> Result { + match def { + PropDef::Boolean { .. } => match v { + Value::Bool(b) => Ok(json!(b)), + Value::String(t) if t == "true" || t == "false" => Ok(json!(t == "true")), + _ => Err(bad(cmd, format!("`{name}` is a boolean property"))), + }, + PropDef::Text { .. } => match v { + Value::String(t) => Ok(json!(t)), + Value::Number(n) => Ok(json!(n.to_string())), + _ => Err(bad(cmd, format!("`{name}` is a text property"))), + }, + PropDef::InstanceSwap { .. } => match id_of(v).and_then(|c| main_of(doc, c)) { + Some(c) => Ok(json!(c.0)), + None => Err(bad(cmd, format!("`{name}` takes a component id"))), + }, + PropDef::Variant { options, .. } => { + let t = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); + if options.is_empty() || options.contains(&t) { Ok(json!(t)) } else { Err(bad(cmd, format!("`{name}` must be one of {options:?}"))) } + } + PropDef::Slot { .. } => Err(bad(cmd, format!("`{name}` is a slot: fill it with instance.slotInsert or by adding layers to it"))), + } +} + +pub(super) fn set_props(s: &mut Session, p: &Value) -> Result { + let cmd = "instance.setProperties"; + let ids = req_targets(cmd, s, p)?; + let props = get(p, "properties").and_then(Value::as_object).cloned().ok_or_else(|| bad(cmd, "`properties` object required"))?; + let doc = s.doc_mut()?; + for i in ids { + let Some(NodeKind::Instance { component, .. }) = doc.node(i).map(|n| n.kind.clone()) else { + return Err(bad(cmd, format!("{i} is not an instance"))); + }; + // Variant properties: switch to the matching sibling variant. + let set = variant_set(doc, component); + let options = set.map(|s| variant_options(doc, s)).unwrap_or_default(); + let mut wanted = doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default(); + let mut target = component; + let mut rest = Vec::new(); + for (k, v) in &props { + if let Some((_, vals)) = options.iter().find(|(o, _)| o == k) { + let val = v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string()); + if !vals.contains(&val) { + return Err(bad(cmd, format!("`{k}` must be one of {vals:?}"))); + } + wanted.insert(k.clone(), val); + } else { + rest.push((k.clone(), v.clone())); + } + } + if let Some(set) = set + && wanted != doc.node(component).map(|c| variant_props(&c.name)).unwrap_or_default() + { + target = find_variant(doc, set, &wanted).unwrap_or(component); + } + let defs = match doc.node(target).map(|n| &n.kind) { + Some(NodeKind::Component { props, .. }) => props.clone(), + _ => BTreeMap::new(), + }; + let mut values = Vec::new(); + for (k, v) in rest { + let def = defs.get(&k).ok_or_else(|| { + let known: Vec<&str> = defs.keys().map(String::as_str).chain(options.iter().map(|(o, _)| o.as_str())).collect(); + bad(cmd, format!("unknown property `{k}` (known: {})", known.join(", "))) + })?; + values.push((k.clone(), coerce(cmd, doc, &k, def, &v)?)); + } + if let NodeKind::Instance { component: c, props: ip } = &mut doc.get_mut(i)?.kind { + *c = target; + for (k, v) in values { + ip.insert(k, v); + } + for (k, d) in defs.iter().filter(|(_, d)| takes_value(d)) { + ip.entry(k.clone()).or_insert_with(|| prop_default_value(d)); + } + } + } + ok() +} + +/// Where component properties live: a set (its variants share them) or a lone component. +pub(super) enum Owner { + Set(NodeId), + Comp(NodeId), +} + +impl Owner { + pub(super) fn resolve(doc: &Document, id: NodeId) -> Option { + let n = doc.node(id)?; + match n.kind { + NodeKind::ComponentSet { .. } => Some(Owner::Set(id)), + _ => { + let c = owning_component(doc, id)?; + Some(match variant_set(doc, c) { + Some(s) => Owner::Set(s), + None => Owner::Comp(c), + }) + } + } + } + + pub(super) fn components(&self, doc: &Document) -> Vec { + match self { + Owner::Comp(c) => vec![*c], + Owner::Set(s) => doc + .node(*s) + .map(|n| n.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|x| matches!(x.kind, NodeKind::Component { .. }))).collect()) + .unwrap_or_default(), + } + } + + fn id(&self) -> NodeId { + match self { + Owner::Set(s) | Owner::Comp(s) => *s, + } + } +} + +pub(super) fn prop_defs(doc: &Document, c: NodeId) -> BTreeMap { + match doc.node(c).map(|n| &n.kind) { + Some(NodeKind::Component { props, .. }) => props.clone(), + _ => BTreeMap::new(), + } +} + +fn set_prop_defs(doc: &mut Document, c: NodeId, f: impl FnOnce(&mut BTreeMap)) -> Result<()> { + if let NodeKind::Component { props, .. } = &mut doc.get_mut(c)?.kind { + f(props); + } + Ok(()) +} + +fn valid_prop_name(cmd: &str, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() || name.contains([',', '=']) || name.chars().count() > 200 { + return Err(bad(cmd, "property names must be non-empty, without ',' or '='")); + } + Ok(name.to_string()) +} + +pub(super) fn add_prop(s: &mut Session, p: &Value) -> Result { + add_prop_for("component.addProperty", s, p) +} + +/// Add a component property for command `cmd`, which errors name (`component.makeSlot` adds its +/// slot property here). +pub(super) fn add_prop_for(cmd: &str, s: &mut Session, p: &Value) -> Result { + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; + let name = valid_prop_name(cmd, &req_string(cmd, p, "name")?)?; + let t = req_string(cmd, p, "type")?; + let doc = s.doc_mut()?; + if inside_instance(doc, target) { + return Err(bad(cmd, "select a component, not a layer of an instance")); + } + let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component, a component set or a layer inside a component"))?; + // A layer selected inside the component gets bound to the new property. + let layer = id(p, "layer") + .or_else(|| (target != owner.id() && !matches!(doc.node(target).map(|n| &n.kind), Some(NodeKind::Component { .. }))).then_some(target)); + let comps = owner.components(doc); + let variants = match &owner { + Owner::Set(s) => variant_options(doc, *s), + Owner::Comp(_) => Vec::new(), + }; + if variants.iter().any(|(k, _)| *k == name) || comps.iter().any(|c| prop_defs(doc, *c).contains_key(&name)) { + return Err(bad(cmd, format!("a property named `{name}` already exists"))); + } + let layer_node = layer.and_then(|l| doc.node(l)).cloned(); + let def = match t.to_ascii_lowercase().as_str() { + // Without a default, a bound layer keeps showing as it does: its visibility is the default. + "boolean" | "bool" => PropDef::Boolean { default: boolean(p, "default").or_else(|| layer_node.as_ref().map(|n| n.visible)).unwrap_or(true) }, + "text" => PropDef::Text { + default: string(p, "default") + .or_else(|| layer_node.as_ref().and_then(|n| n.kind.text()).map(|t| t.characters.clone())) + .unwrap_or_else(|| name.clone()), + }, + "instanceswap" | "instance_swap" | "instance" => { + let d = id(p, "default") + .or_else(|| match layer_node.as_ref().map(|n| &n.kind) { + Some(NodeKind::Instance { component, .. }) => Some(*component), + _ => None, + }) + .and_then(|c| main_of(doc, c)) + .ok_or_else(|| bad(cmd, "default: a component id"))?; + PropDef::InstanceSwap { default: d, preferred: preferred_arg(cmd, doc, p)? } + } + "variant" => { + let dv = string(p, "default") + .map(|d| d.trim().to_string()) + .filter(|d| !d.is_empty() && !d.contains([',', '='])) + .unwrap_or_else(|| "Default".into()); + let set = match owner { + Owner::Set(s) => s, + Owner::Comp(c) => wrap_in_set(cmd, doc, &[c])?, + }; + for c in Owner::Set(set).components(doc) { + let cn = doc.get_mut(c)?; + cn.name = if cn.name.contains('=') { format!("{}, {name}={dv}", cn.name) } else { format!("{name}={dv}") }; + } + s.set_selection(vec![set]); + return Ok(json!({"id": set})); + } + "slot" => { + if !layer_node.as_ref().is_some_and(|n| matches!(n.kind, NodeKind::Frame)) { + return Err(bad(cmd, "a slot is a frame inside the component: select one (or pass `layer`)")); + } + PropDef::Slot { preferred: preferred_arg(cmd, doc, p)? } + } + _ => return Err(bad(cmd, "type: boolean|text|instanceSwap|variant|slot")), + }; + for c in &comps { + set_prop_defs(doc, *c, |m| { + m.insert(name.clone(), def.clone()); + })?; + // Existing instances get the default. + if !takes_value(&def) { + continue; + } + for inst in instances_of(doc, *c) { + if let NodeKind::Instance { props, .. } = &mut doc.get_mut(inst)?.kind { + props.entry(name.clone()).or_insert_with(|| prop_default_value(&def)); + } + } + } + if let Some(l) = layer { + bind(cmd, doc, l, Some(&name), None)?; + } + Ok(json!({"id": owner.id(), "name": name})) +} + +/// Bind (or with `property: None` unbind) a layer's field to a property of its component. +fn bind(cmd: &str, doc: &mut Document, layer: NodeId, property: Option<&str>, field: Option<&str>) -> Result<()> { + let n = doc.get(layer)?.clone(); + if inside_instance(doc, layer) { + return Err(bad(cmd, "layers inside an instance can't be bound (bind the nested instance itself, or edit its main)")); + } + let comp = owning_component(doc, layer).ok_or_else(|| bad(cmd, "the layer isn't inside a component"))?; + let Some(property) = property.filter(|p| !p.is_empty()) else { + let n = doc.get_mut(layer)?; + match field { + Some(f) => { + n.prop_refs.remove(f); + } + None => n.prop_refs.clear(), + } + return Ok(()); + }; + let defs = prop_defs(doc, comp); + let def = defs.get(property).ok_or_else(|| bad(cmd, format!("the component has no property `{property}`")))?; + let expected = match def { + PropDef::Boolean { .. } => "visible", + PropDef::Text { .. } => "characters", + PropDef::InstanceSwap { .. } => "mainComponent", + PropDef::Variant { .. } => return Err(bad(cmd, "variant properties pick a variant; they can't be bound to a layer")), + PropDef::Slot { .. } => SLOT, + }; + let field = field.unwrap_or(expected); + if field != expected { + return Err(bad(cmd, format!("`{property}` can only control `{expected}`"))); + } + match field { + "characters" if n.kind.text().is_none() => return Err(bad(cmd, "text properties bind to text layers")), + "mainComponent" if !matches!(n.kind, NodeKind::Instance { .. }) => return Err(bad(cmd, "instance swap properties bind to instances")), + "visible" if layer == comp => return Err(bad(cmd, "the component itself can't be hidden by its own property")), + SLOT if !matches!(n.kind, NodeKind::Frame) => return Err(bad(cmd, "slot properties bind to frames inside the component")), + SLOT if doc + .subtree(comp) + .into_iter() + .any(|d| d != layer && doc.node(d).is_some_and(|x| x.prop_refs.get(SLOT).map(String::as_str) == Some(property))) => + { + return Err(bad(cmd, format!("another frame of the component is already the slot `{property}`"))); + } + _ => {} + } + let n = doc.get_mut(layer)?; + n.prop_refs.insert(field.to_string(), property.to_string()); + // On the main, a boolean property's value is the visibility of the layers it controls. + if let PropDef::Boolean { default } = def { + n.visible = *default; + } + Ok(()) +} + +pub(super) fn bind_prop(s: &mut Session, p: &Value) -> Result { + let cmd = "component.bindProperty"; + let layer = id(p, "layer").or_else(|| targets(s, p).first().copied()).ok_or_else(|| bad(cmd, "`layer` required"))?; + let property = string(p, "property"); + let field = string(p, "field"); + if let Some(f) = &field + && !matches!(f.as_str(), "visible" | "characters" | "mainComponent" | SLOT) + { + return Err(bad(cmd, "field: visible|characters|mainComponent|slot")); + } + let doc = s.doc_mut()?; + bind(cmd, doc, layer, property.as_deref(), field.as_deref())?; + ok() +} + +/// Rename `old` → `new` in the bindings of a component's layers and in its instances' values. +fn rename_prop_refs(doc: &mut Document, comp: NodeId, old: &str, new: Option<&str>) -> Result<()> { + for d in doc.subtree(comp) { + let refs = doc.node(d).map(|n| n.prop_refs.clone()).unwrap_or_default(); + if refs.values().any(|v| v == old) + && let Some(n) = doc.node_mut(d) + { + match new { + Some(nw) => n.prop_refs.values_mut().filter(|v| *v == old).for_each(|v| *v = nw.to_string()), + None => n.prop_refs.retain(|_, v| v != old), + } + } + } + // Instances' values and the variables bound to them follow (a deleted property drops both). + let old_key = format!("{PROP_BINDING}{old}"); + for inst in instances_of(doc, comp) { + let n = doc.get_mut(inst)?; + if let NodeKind::Instance { props, .. } = &mut n.kind + && let Some(v) = props.remove(old) + && let Some(nw) = new + { + props.insert(nw.to_string(), v); + } + if let Some(var) = n.bound.remove(&old_key) + && let Some(nw) = new + { + n.bound.insert(format!("{PROP_BINDING}{nw}"), var); + } + } + Ok(()) +} + +pub(super) fn edit_prop(s: &mut Session, p: &Value) -> Result { + let cmd = "component.editProperty"; + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; + let name = req_string(cmd, p, "name")?; + let new_name = string(p, "newName").map(|n| valid_prop_name(cmd, &n)).transpose()?.filter(|n| *n != name); + let doc = s.doc_mut()?; + let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; + let comps = owner.components(doc); + if let Some(nn) = &new_name { + let taken = comps.iter().any(|c| prop_defs(doc, *c).contains_key(nn)) + || matches!(owner, Owner::Set(s) if variant_options(doc, s).iter().any(|(k, _)| k == nn)); + if taken { + return Err(bad(cmd, format!("a property named `{nn}` already exists"))); + } + } + // Variant property of a set. + if let Owner::Set(set) = owner + && let Some((_, values)) = variant_options(doc, set).into_iter().find(|(k, _)| *k == name) + { + let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); + let rename_value = match (string(p, "value"), string(p, "newValue")) { + (Some(v), Some(nv)) => { + let nv = nv.trim().to_string(); + if !values.contains(&v) { + return Err(bad(cmd, format!("`{name}` has no value `{v}`"))); + } + if nv.is_empty() || nv.contains([',', '=']) { + return Err(bad(cmd, "`newValue` must be non-empty, without ',' or '='")); + } + Some((v, nv)) + } + _ => None, + }; + let mut order: Vec = order.into_iter().map(|k| if k == name { new_name.clone().unwrap_or(k) } else { k }).collect(); + // Reordered: every variant's name lists the properties in the new order. + if let Some(to) = get(p, "index").filter(|v| !v.is_null()) { + let to = to.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(cmd, "`index`: a property place (0 = first)"))?; + let key = new_name.clone().unwrap_or(name.clone()); + order.retain(|k| *k != key); + order.insert(to.min(order.len()), key); + } + for c in &comps { + let mut pairs = variant_props(&doc.get(*c)?.name); + if let Some((v, nv)) = &rename_value + && pairs.get(&name) == Some(v) + { + pairs.insert(name.clone(), nv.clone()); + } + if let Some(nn) = &new_name + && let Some(v) = pairs.remove(&name) + { + pairs.insert(nn.clone(), v); + } + doc.get_mut(*c)?.name = variant_name(&pairs, &order); + } + // The default variant is the top-left one: the variant with that value (and the default's + // other values, when there is one) trades places with it. + if let Some(dv) = string(p, "default") { + let key = new_name.clone().unwrap_or(name.clone()); + let current = default_variant(doc, set); + let mut wanted = current.and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); + wanted.insert(key.clone(), dv.clone()); + let props_of = |c: &NodeId| doc.node(*c).map(|n| variant_props(&n.name)).unwrap_or_default(); + let pick = + comps.iter().copied().find(|c| props_of(c) == wanted).or_else(|| comps.iter().copied().find(|c| props_of(c).get(&key) == Some(&dv))); + let (Some(f), Some(d)) = (pick, current) else { return Err(bad(cmd, format!("no variant has {key}={dv}"))) }; + if f != d { + let place = |doc: &Document, c: NodeId| doc.node(c).map(|n| n.transform.as_coeffs()).map(|[.., x, y]| (x, y)).unwrap_or_default(); + let (fp, dp) = (place(doc, f), place(doc, d)); + for (c, (x, y)) in [(f, dp), (d, fp)] { + let n = doc.get_mut(c)?; + let [a, b, cc, dd, ..] = n.transform.as_coeffs(); + n.transform = figcraft_geom::Affine::new([a, b, cc, dd, x, y]); + } + fit_set(doc, set); + } + } + return ok(); + } + let def = comps.iter().find_map(|c| prop_defs(doc, *c).get(&name).cloned()).ok_or_else(|| bad(cmd, format!("no property named `{name}`")))?; + let preferred = if get(p, "preferred").is_some() { + if !matches!(def, PropDef::Slot { .. } | PropDef::InstanceSwap { .. }) { + return Err(bad(cmd, "`preferred` is for instance swap and slot properties")); + } + Some(preferred_arg(cmd, doc, p)?) + } else { + None + }; + let mut new_def = match get(p, "default") { + Some(v) => match coerce(cmd, doc, &name, &def, v)? { + Value::Bool(b) => PropDef::Boolean { default: b }, + Value::String(t) if matches!(def, PropDef::Text { .. }) => PropDef::Text { default: t }, + other => match (&def, id_of(&other)) { + (PropDef::InstanceSwap { preferred, .. }, Some(c)) => PropDef::InstanceSwap { default: c, preferred: preferred.clone() }, + _ => def.clone(), + }, + }, + None => def.clone(), + }; + if let (Some(list), PropDef::Slot { preferred } | PropDef::InstanceSwap { preferred, .. }) = (preferred, &mut new_def) { + *preferred = list; + } + for c in &comps { + let had = prop_defs(doc, *c).contains_key(&name); + if !had { + continue; + } + let key = new_name.clone().unwrap_or(name.clone()); + let nd = new_def.clone(); + let old = name.clone(); + set_prop_defs(doc, *c, move |m| { + m.remove(&old); + m.insert(key, nd); + })?; + if let Some(nn) = &new_name { + rename_prop_refs(doc, *c, &name, Some(nn))?; + } + } + // The main's bound layers show the new default. + if let PropDef::Boolean { default } = new_def { + set_boolean_default(doc, &comps, new_name.as_deref().unwrap_or(&name), default); + } + ok() +} + +pub(super) fn delete_prop(s: &mut Session, p: &Value) -> Result { + let cmd = "component.deleteProperty"; + let target = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component"))?; + let name = req_string(cmd, p, "name")?; + let doc = s.doc_mut()?; + let owner = Owner::resolve(doc, target).ok_or_else(|| bad(cmd, "select a component or component set"))?; + let comps = owner.components(doc); + if let Owner::Set(set) = owner + && variant_options(doc, set).iter().any(|(k, _)| *k == name) + { + let order: Vec = variant_options(doc, set).into_iter().map(|(k, _)| k).collect(); + for c in &comps { + let mut pairs = variant_props(&doc.get(*c)?.name); + pairs.remove(&name); + let n = doc.get_mut(*c)?; + n.name = if pairs.is_empty() { "Default".into() } else { variant_name(&pairs, &order) }; + } + return ok(); + } + let mut found = false; + for c in &comps { + if prop_defs(doc, *c).contains_key(&name) { + found = true; + set_prop_defs(doc, *c, |m| { + m.remove(&name); + })?; + rename_prop_refs(doc, *c, &name, None)?; + } + } + if !found { + return Err(bad(cmd, format!("no property named `{name}`"))); + } + ok() +} + +fn def_json(name: &str, d: &PropDef) -> Value { + match d { + PropDef::Boolean { default } => json!({"name": name, "type": "boolean", "default": default}), + PropDef::Text { default } => json!({"name": name, "type": "text", "default": default}), + PropDef::InstanceSwap { default, preferred } => json!({"name": name, "type": "instanceSwap", "default": default, "preferred": preferred}), + PropDef::Variant { options, default } => json!({"name": name, "type": "variant", "default": default, "options": options}), + PropDef::Slot { preferred } => json!({"name": name, "type": "slot", "preferred": preferred}), + } +} + +/// Properties of a component, set or instance as JSON (`component.properties`). +pub(crate) fn describe_props(doc: &Document, id: NodeId) -> Value { + let Some(n) = doc.node(id) else { return Value::Null }; + let (owner_comp, values) = match &n.kind { + NodeKind::Instance { component, props } => (Some(*component), Some(props.clone())), + _ => (main_of(doc, id).or_else(|| owning_component(doc, id)), None), + }; + let Some(comp) = owner_comp else { return json!({"properties": []}) }; + let mut list: Vec = Vec::new(); + if let Some(set) = variant_set(doc, comp) { + let current = doc.node(comp).map(|c| variant_props(&c.name)).unwrap_or_default(); + let defaults = default_variant(doc, set).and_then(|d| doc.node(d)).map(|n| variant_props(&n.name)).unwrap_or_default(); + for (k, vals) in variant_options(doc, set) { + let mut v = json!({"name": k, "type": "variant", "options": vals, "default": defaults.get(&k).or(vals.first())}); + if values.is_some() { + v["value"] = json!(current.get(&k)); + } + list.push(v); + } + } + // Slots: the frames (on a main) or this instance's copy, with whether it holds its own content. + let slots = slots_of(doc, if values.is_some() { id } else { variant_set(doc, comp).unwrap_or(comp) }); + for (k, d) in prop_defs(doc, comp) { + let mut v = def_json(&k, &d); + if !takes_value(&d) { + let layers: Vec = slots.iter().filter(|(_, n)| *n == k).map(|(l, _)| *l).collect(); + if values.is_some() { + v["value"] = layers.first().map_or(Value::Null, |l| { + let count = doc.node(*l).map_or(0, |n| n.children.len()); + json!({"layer": l, "edited": slot_edited(doc, *l), "layers": count}) + }); + } else { + v["layers"] = json!(layers); + } + } else if let Some(vals) = &values { + v["value"] = vals.get(&k).cloned().unwrap_or_else(|| prop_default_value(&d)); + } + list.push(v); + } + let set = variant_set(doc, comp); + let conflicts = set.map(|s| variant_conflicts(doc, s)).unwrap_or_default(); + json!({"component": comp, "set": set, "properties": list, "conflicts": conflicts}) +} diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs new file mode 100644 index 00000000..f6e7ac6c --- /dev/null +++ b/crates/engine/src/cmd/component/variants.rs @@ -0,0 +1,175 @@ +use super::*; + +/// Wrap components in a new component set (named after the first) placed around them. +pub(super) fn wrap_in_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { + let top = *ids.last().ok_or_else(|| bad(cmd, "nothing selected"))?; + let parent = doc.get(top)?.parent.ok_or_else(|| bad(cmd, "page"))?; + if inside_instance(doc, top) || owning_instance(doc, parent).is_some() { + return Err(EngineError::Disabled(cmd.into(), INSIDE_INSTANCE.into())); + } + let bounds = doc.bounds_of(ids).unwrap_or_default(); + let pw = doc.world_transform(parent); + let local = figcraft_geom::transform_rect_bbox(pw.inverse(), bounds.inflate(20.0, 20.0)); + let base = ids.first().and_then(|i| doc.node(*i)).and_then(|n| set_name_from(&n.name)).unwrap_or_else(|| doc.next_name("Component")); + let set = doc.alloc_id(); + let mut n = Node::new(set, NodeKind::ComponentSet { description: String::new() }, base, local.width(), local.height()); + n.transform = figcraft_geom::Affine::translate((local.x0, local.y0)); + n.fills.clear(); + doc.insert(n, parent, doc.index_in_parent(top).map(|i| i + 1))?; + for i in ids { + doc.reparent(*i, set, None)?; + } + Ok(set) +} + +const INSIDE_INSTANCE: &str = "components inside an instance can't become variants"; + +/// Why `ids` (distinct) can't become the variants of a new component set, or `None` when they +/// can: anything but main components, components that are variants already (combining them would +/// put a set in a set), library copies, components inside an instance, and a component inside +/// another one of `ids`. +fn combine_refusal(doc: &Document, ids: &[NodeId]) -> Option<&'static str> { + if !ids.iter().all(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. }))) { + Some("select components only") + } else if ids.iter().any(|i| variant_set(doc, *i).is_some()) { + Some("components that are variants already can't be combined again") + } else if ids.iter().any(|i| doc.is_remote(*i)) { + Some(crate::library::READ_ONLY) + } else if ids.iter().any(|i| inside_instance(doc, *i)) { + Some(INSIDE_INSTANCE) + } else if roots(doc, ids).len() != ids.len() { + Some("a component can't be combined with one inside it") + } else { + None + } +} + +/// Whether "Combine as variants" is on offer for `ids` (menus and the Design panel): two or more +/// main components that [`combine_refusal`] lets through. +pub fn can_combine_variants(doc: &Document, ids: &[NodeId]) -> bool { + ids.len() >= 2 && combine_refusal(doc, ids).is_none() && ids.iter().collect::>().len() == ids.len() +} + +/// Whether "Add variant" is on offer for `id`: a component set with variants, a variant, or a main +/// component not in a set yet (it becomes one); never a library copy or inside an instance. +pub fn can_add_variant(doc: &Document, id: NodeId) -> bool { + let Some(n) = doc.node(id) else { return false }; + let local = !doc.is_remote(id) && !inside_instance(doc, id); + match n.kind { + NodeKind::ComponentSet { .. } => { + local && n.children.iter().any(|c| doc.node(*c).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))) + } + NodeKind::Component { .. } => local, + _ => false, + } +} + +/// Wrap `ids` (z-sorted main components) in a new component set, naming them "Property 1=Default", +/// "Property 1=VariantN" unless already in prop=value form. Returns the set. +fn make_variant_set(cmd: &str, doc: &mut Document, ids: &[NodeId]) -> Result { + let set = wrap_in_set(cmd, doc, ids)?; + for (k, i) in ids.iter().enumerate() { + let node = doc.get_mut(*i)?; + if !node.name.contains('=') { + node.name = format!("Property 1={}", if k == 0 { "Default".to_string() } else { format!("Variant{}", k + 1) }); + } + } + Ok(set) +} + +pub(super) fn combine(s: &mut Session, p: &Value) -> Result { + let cmd = "component.combineVariants"; + let mut seen = BTreeSet::new(); + let ids: Vec = req_targets(cmd, s, p)?.into_iter().filter(|i| seen.insert(*i)).collect(); + let doc = s.doc_mut()?; + if let Some(why) = combine_refusal(doc, &ids) { + return Err(EngineError::Disabled(cmd.into(), why.into())); + } + let ids = z_sorted(doc, &ids); + let set = make_variant_set(cmd, doc, &ids)?; + s.set_selection(vec![set]); + Ok(json!({"id": set})) +} + +/// Grow a component set so it wraps its variants with 20 px of padding (right and bottom). +pub(super) fn fit_set(doc: &mut Document, set: NodeId) { + let Some(kids) = doc.node(set).map(|n| n.children.clone()) else { return }; + let (mut w, mut h) = (0.0f64, 0.0f64); + for c in kids { + if let Some(n) = doc.node(c) { + let b = figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()); + w = w.max(b.x1 + 20.0); + h = h.max(b.y1 + 20.0); + } + } + if let Some(n) = doc.node_mut(set) { + n.width = n.width.max(w); + n.height = n.height.max(h); + } +} + +pub(super) fn add_variant(s: &mut Session, p: &Value) -> Result { + let cmd = "component.addVariant"; + let t = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a component set or component"))?; + let doc = s.doc_mut()?; + let (set, base) = match doc.get(t)?.kind { + NodeKind::ComponentSet { .. } => { + let last = doc.get(t)?.children.last().copied().ok_or_else(|| bad(cmd, "the component set is empty"))?; + (t, last) + } + NodeKind::Component { .. } => match variant_set(doc, t) { + Some(set) => (set, t), + // A main component on its own becomes a set of itself, then gains the new variant. + None => (make_variant_set(cmd, doc, &[t])?, t), + }, + _ => return Err(bad(cmd, "select a component set or component")), + }; + let options = variant_options(doc, set); + let mut pairs = variant_props(&doc.get(base)?.name); + let order: Vec = options.iter().map(|(k, _)| k.clone()).collect(); + let existing: BTreeSet> = + doc.get(set)?.children.iter().filter_map(|c| doc.node(*c)).map(|n| variant_props(&n.name)).collect(); + match (string(p, "property"), string(p, "value")) { + (Some(prop), Some(value)) => { + let (prop, value) = (prop.trim().to_string(), value.trim().to_string()); + if !options.iter().any(|(k, _)| *k == prop) { + return Err(bad(cmd, format!("unknown variant property `{prop}`"))); + } + if value.is_empty() || value.contains([',', '=']) { + return Err(bad(cmd, "`value` must be non-empty without ',' or '='")); + } + pairs.insert(prop, value); + if existing.contains(&pairs) { + return Err(bad(cmd, "that variant already exists")); + } + } + (Some(_), None) => return Err(bad(cmd, "`value` is required with `property`")), + _ => { + let prop = order.first().cloned().unwrap_or_else(|| "Property 1".into()); + let mut k = existing.len() + 1; + loop { + pairs.insert(prop.clone(), format!("Variant{k}")); + if !existing.contains(&pairs) || k > 10_000 { + break; + } + k += 1; + } + } + } + let name = variant_name(&pairs, &order); + let bottom = doc + .get(set)? + .children + .iter() + .filter_map(|c| doc.node(*c)) + .map(|n| figcraft_geom::transform_rect_bbox(n.transform, n.local_rect()).y1) + .fold(0.0f64, f64::max); + let dup = doc.duplicate_into(base, set, None)?; + let n = doc.get_mut(dup)?; + n.name = name; + let c = n.transform.as_coeffs(); + n.transform = figcraft_geom::Affine::new([c[0], c[1], c[2], c[3], c[4], bottom + 20.0]); + fit_set(doc, set); + s.set_selection(vec![dup]); + Ok(json!({"id": dup})) +} diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 60077efd..7ec63c69 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,6 +10,22 @@ use super::{CommandSpec, ok}; use crate::params::*; use crate::{BatchStep, EngineError, Result, Session, bad, spec}; +mod clipboard; +mod props; +mod select; + +pub use clipboard::{CLIP_CLOSE, CLIP_OPEN, copy_to_clipboard, design_clipboard_buffer, paste_into}; +use clipboard::{copy, copy_props, copy_props_of, cut, paste, paste_design_layers, paste_over, paste_props, paste_replace}; +use props::{ + batch_rename, effect_add, effect_copy, effect_paste, find_replace, paint_copy, paint_list, paint_paste, paint_set, pattern_fill, reparent, set, + stroke_brush, width_profile, +}; +pub use select::opacity_from_digits; +use select::{ + hide_others, layers_at, nudge, nudge_size, opacity_keys, select_all, select_area, select_children, select_inverse, select_matching, + select_parent, select_same, select_sibling, toggle, +}; + pub fn specs() -> Vec { vec![ spec!("edit.undo", "Undo", "{}", false, |s, _| Ok(json!({"undone": s.undo()?}))), @@ -536,1054 +552,3 @@ fn bump_name(n: &str) -> String { } format!("{n} 2") } - -/// The copied form of the selection. -pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { - let ds = s.ds()?; - // Pages are not layers: they can't be pasted anywhere. - let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); - if layers.is_empty() && !ids.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); - } - let (nodes, images, bounds) = copied(&ds.doc, &layers); - let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); - s.clipboard.source = Some(source); - s.clipboard.source_path = source_path; - s.clipboard.source_doc = uid; - s.clipboard.copied_components = nodes - .iter() - .filter(|(_, sub)| { - cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) - }) - .map(|(root, _)| *root) - .collect(); - s.clipboard.nodes = nodes; - s.clipboard.images = images; - s.clipboard.bounds = bounds; - s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1); - Ok(()) -} - -type Copied = (Vec<(NodeId, Vec)>, BTreeMap, Option); - -fn copied(doc: &Document, ids: &[NodeId]) -> Copied { - let ids = z_sorted(doc, &roots(doc, ids)); - let mut nodes = Vec::new(); - let mut images = BTreeMap::new(); - for i in &ids { - let world = doc.world_transform(*i); - let mut sub: Vec = doc.subtree(*i).into_iter().filter_map(|x| doc.node(x).cloned()).collect(); - // Store the root's transform in page space so pastes land where it was. - if let Some(r) = sub.first_mut() { - r.transform = world; - } - for n in &sub { - for p in n.fills.iter().chain(n.strokes.iter()) { - if let figcraft_color::PaintKind::Image(img) = &p.kind - && let Some(d) = doc.images.get(&img.image) - { - images.insert(img.image.clone(), d.clone()); - } - } - } - nodes.push((*i, sub)); - } - let bounds = doc.bounds_of(&ids); - (nodes, images, bounds) -} - -/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. -pub const CLIP_OPEN: &str = "(figma)"; -pub const CLIP_CLOSE: &str = "(/figma)"; -/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its -/// base64 is checked first, so neither the text nor the decoded bytes can pass it. -const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; -const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; - -/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. -fn clip_base64(text: &str) -> Option> { - use base64::Engine as _; - if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { - return None; - } - let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); - base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() -} - -/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in -/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML -/// holds no readable design data (it may just mention the marker). -pub fn design_clipboard_buffer(html: &str) -> Option> { - let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); - let len = html.get(start..)?.find(CLIP_CLOSE)?; - let bytes = clip_base64(html.get(start..start + len)?)?; - figcraft_format::fig::is_fig(&bytes).then_some(bytes) -} - -/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, -/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, -/// store them as a foreign-file clipboard source with a bumped layers generation, then -/// delegate to `paste` and append import warnings. -fn paste_design_layers(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "edit.pasteDesignLayers"; - let bytes = match (string(p, "html"), string(p, "data")) { - (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, - (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, - (None, None) => return Err(bad(CMD, "pass `html` or `data`")), - }; - let other = super::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; - let doc = std::sync::Arc::new(other.doc); - let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); - if tops.is_empty() { - return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); - } - let (nodes, images, bounds) = copied(&doc, &tops); - // A design-app paste replaces the copied layers, not the copied rows: a `fill.copy` before it - // still pastes afterwards. - let props = s.clipboard.props.take(); - let paint = s.clipboard.paint.take(); - let stroke = s.clipboard.stroke.take(); - let effect = s.clipboard.effect.take(); - // No open file has id 0. So paste treats this copy as a copy from a different file. - let layers_generation = s.clipboard.layers_generation.wrapping_add(1); - s.clipboard = crate::Clipboard { - nodes, - images, - props, - paint, - stroke, - effect, - bounds, - source_doc: 0, - source: Some(doc), - source_path: None, - copied_components: BTreeSet::new(), - layers_generation, - }; - let mut out = paste(s, p)?; - if !other.issues.is_empty() { - out["warnings"] = json!(other.issues); - } - Ok(out) -} - -fn copy(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("edit.copy", s, p)?; - copy_to_clipboard(s, "edit.copy", &ids)?; - Ok(json!({"copied": s.clipboard.nodes.len()})) -} - -fn cut(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("edit.cut", s, p)?; - copy_to_clipboard(s, "edit.cut", &ids)?; - let doc = s.doc()?; - let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); - delete(s, &json!({"ids": layers})) -} - -/// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). -pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Result> { - let clip = s.clipboard.clone(); - if clip.nodes.is_empty() { - return Err(EngineError::Disabled("edit.paste".into(), "the clipboard is empty".into())); - } - // From another file: its instances follow the mains this file can have. - let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); - let copy_local = s.ds()?.uid == clip.source_doc; - let doc = s.doc_mut()?; - for (k, v) in &clip.images { - doc.images.entry(k.clone()).or_insert_with(|| v.clone()); - } - // Sections go on the page (or in sections), never in the frame being pasted into. - let page = doc.page_of(parent).unwrap_or(parent); - let shift = match (at, clip.bounds) { - (Some((x, y)), Some(b)) => Affine::translate((x - b.x0, y - b.y0)), - _ => Affine::IDENTITY, - }; - let mut out = Vec::new(); - let mut all = BTreeMap::new(); - for (root, nodes) in &clip.nodes { - if copy_local - && clip.copied_components.contains(root) - && let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. })) - && doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) - { - let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?; - doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform; - out.push(instance); - continue; - } - let mut map = BTreeMap::new(); - for n in nodes { - map.insert(n.id, doc.alloc_id()); - } - let mut nodes = nodes.clone(); - let section = nodes.first().is_some_and(|r| matches!(r.kind, NodeKind::Section { .. })); - let into = if section && !doc.takes_sections(parent) { page } else { parent }; - if let Some(r) = nodes.first_mut() { - r.transform = doc.world_transform(into).inverse() * shift * r.transform; - } - out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?); - all.extend(map); - } - // Board connectors pasted with what they join join the pasted copies. - let originals: Vec<(NodeId, figcraft_doc::BoardItem)> = - clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect(); - crate::board::rejoin(doc, &originals, &all, !copy_local); - if let Some(src) = foreign { - crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?; - } - Ok(out) -} - -fn paste(s: &mut Session, p: &Value) -> Result { - let page = s.page()?; - // Figma pastes into the selected frame (if one is selected), at the same position when it fits, - // else in the frame's centre; otherwise in place on the page (or the viewport centre). - let sel = s.selection(); - let doc = s.doc()?; - let copy_local = s.ds()?.uid == s.clipboard.source_doc; - let target = id(p, "parent").or_else(|| { - sel.first() - .copied() - .filter(|i| { - sel.len() == 1 - && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom()) - }) - .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) - }); - let parent = target.unwrap_or(page); - let at = match (num(p, "x"), num(p, "y")) { - (Some(x), Some(y)) => Some((x, y)), - _ => { - let b = s.clipboard.bounds; - match (target, b) { - (Some(t), Some(b)) => { - let tb = doc.world_bounds(t); - match tb { - Some(tb) if tb.contains(b.origin()) && tb.contains(figcraft_geom::Point::new(b.x1, b.y1)) => None, - Some(tb) => Some((tb.center().x - b.width() / 2.0, tb.center().y - b.height() / 2.0)), - None => None, - } - } - (None, Some(b)) => { - // Off-screen originals paste at the viewport centre. - let v = &s.ds()?.view; - let (ox, oy) = v.origin(); - let vr = figcraft_geom::Rect::new(ox, oy, ox + v.vw / v.zoom.max(1e-9), oy + v.vh / v.zoom.max(1e-9)); - if vr.intersect(b).is_zero_area() { Some((v.cx - b.width() / 2.0, v.cy - b.height() / 2.0)) } else { None } - } - _ => None, - } - } - }; - let ids = paste_into(s, parent, at)?; - s.set_selection(ids.clone()); - Ok(json!({"ids": ids})) -} - -fn paste_over(s: &mut Session, _p: &Value) -> Result { - let sel = s.selection(); - let page = s.page()?; - let doc = s.doc()?; - let (parent, at) = match sel.first() { - Some(i) => { - let parent = doc.node(*i).and_then(|n| n.parent).unwrap_or(page); - (parent, doc.world_bounds(*i).map(|b| (b.x0, b.y0))) - } - None => (page, None), - }; - let ids = paste_into(s, parent, at)?; - s.set_selection(ids.clone()); - Ok(json!({"ids": ids})) -} - -fn paste_replace(s: &mut Session, p: &Value) -> Result { - let targets = req_targets("edit.pasteReplace", s, p)?; - let mut out = Vec::new(); - for t in targets { - let doc = s.doc()?; - let Some(n) = doc.node(t) else { continue }; - let parent = n.parent.ok_or_else(|| bad("edit.pasteReplace", "can't replace a page"))?; - let idx = doc.index_in_parent(t); - let at = doc.world_bounds(t).map(|b| (b.x0, b.y0)); - let ids = paste_into(s, parent, at)?; - let doc = s.doc_mut()?; - for (k, i) in ids.iter().enumerate() { - if let Some(ix) = idx { - let _ = doc.reorder(*i, ix + k); - } - } - doc.remove(t)?; - out.extend(ids); - } - s.set_selection(out.clone()); - Ok(json!({"ids": out})) -} - -fn copy_props_of(doc: &Document, id: NodeId) -> Value { - let Some(n) = doc.node(id) else { return Value::Null }; - let mut v = json!({ - "fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align, - "effects": n.effects, "opacity": n.opacity, "blend": n.blend, "cornerRadius": n.corners.radii, - "cornerSmoothing": n.corners.smoothing, - }); - if let NodeKind::Text(t) = &n.kind { - v["fontFamily"] = json!(t.style.font_family); - v["fontStyle"] = json!(t.style.font_style); - v["fontSize"] = json!(t.style.font_size); - v["fontVariations"] = json!(t.style.variations); - v["lineHeight"] = serde_json::to_value(t.style.line_height).unwrap_or(Value::Null); - v["letterSpacing"] = serde_json::to_value(t.style.letter_spacing).unwrap_or(Value::Null); - } - v -} - -fn copy_props(s: &mut Session, p: &Value) -> Result { - let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.copyProperties", "select one layer"))?; - let v = copy_props_of(s.doc()?, id); - s.clipboard.props = Some(v.clone()); - Ok(v) -} - -fn paste_props(s: &mut Session, p: &Value) -> Result { - let props = s.clipboard.props.clone().ok_or_else(|| EngineError::Disabled("edit.pasteProperties".into(), "no copied properties".into()))?; - let ids = req_targets("edit.pasteProperties", s, p)?; - let doc = s.doc_mut()?; - for i in ids { - let mut m = props.as_object().cloned().unwrap_or_default(); - let is_text = matches!(doc.get(i)?.kind, NodeKind::Text(_)); - if !is_text { - m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing"); - } - if !doc.get(i)?.kind.has_corner_radius() { - m.remove("cornerRadius"); - m.remove("cornerSmoothing"); - } - super::props::apply("edit.pasteProperties", doc, i, &m)?; - } - ok() -} - -fn layers_at(s: &mut Session, p: &Value) -> Result { - let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?); - let doc = s.doc()?; - let zoom = s.ds()?.view.zoom.max(1e-6); - let sel = s.selection(); - let result = crate::booleans::result_outline(doc, &sel); - let layers: Vec = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result)) - .into_iter() - .filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth}))) - .collect(); - Ok(json!({"layers": layers})) -} - -fn select_all(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - // Select all siblings of the current selection, else the page's top level. - let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); - let ids: Vec = doc.get(parent)?.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -/// What Select all would select, less the selection (hidden and locked layers neither). -fn select_inverse(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); - let ids: Vec = - doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -/// `select.area`: what a marquee from (x, y) over the box selects; no box asks the app for one. -fn select_area(s: &mut Session, p: &Value) -> Result { - let cmd = "select.area"; - let keys = ["x", "y", "width", "height"]; - if keys.iter().all(|k| p.get(*k).is_none()) { - s.request_ui("selectArea", json!({})); - return ok(); - } - let [x, y, w, h] = keys.map(|k| num(p, k)); - let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad(cmd, "pass x, y, width and height (numbers)")) }; - if ![x, y, w, h].iter().all(|v| v.is_finite() && v.abs() < 1e9) { - return Err(bad(cmd, "the box is out of range")); - } - let start = figcraft_geom::Point::new(x, y); - let r = figcraft_geom::Rect::from_points(start, figcraft_geom::Point::new(x + w, y + h)); - let add = boolean(p, "add") == Some(true); - let base = if add { s.selection() } else { Vec::new() }; - let within = s.marquee_within(start)?; - let sel = s.marquee_selection(r, within, boolean(p, "deep") == Some(true), &base, add)?; - s.set_selection(sel.clone()); - Ok(json!({"selection": sel})) -} - -fn select_children(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let mut ids = Vec::new(); - for i in s.selection() { - if let Some(n) = doc.node(i) { - ids.extend(n.children.iter().copied()); - } - } - if ids.is_empty() { - return Ok(json!({"selection": s.selection()})); - } - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -fn select_parent(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let mut ids: Vec = Vec::new(); - for i in s.selection() { - if let Some(p) = doc.node(i).and_then(|n| n.parent) - && !doc.node(p).is_some_and(Node::is_page) - && !ids.contains(&p) - { - ids.push(p); - } - } - if ids.is_empty() { - return Ok(json!({"selection": s.selection()})); - } - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -fn select_sibling(s: &mut Session, dir: i64) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - let Some(cur) = sel.last().copied() else { - let first = doc.get(s.page()?)?.children.last().copied(); - s.set_selection(first.into_iter().collect()); - return Ok(json!({"selection": s.selection()})); - }; - let parent = doc.get(cur)?.parent.ok_or_else(|| bad("select.next", "no parent"))?; - let kids = &doc.get(parent)?.children; - let idx = kids.iter().position(|c| *c == cur).unwrap_or(0) as i64; - let n = kids.len() as i64; - // Layers panel order is reversed (top of the list = last child): Tab goes down the list. - let next = (idx - dir).rem_euclid(n.max(1)) as usize; - let id = kids.get(next).copied().unwrap_or(cur); - s.set_selection(vec![id]); - Ok(json!({"selection": [id]})) -} - -fn select_matching(s: &mut Session, _p: &Value) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - let Some(first) = sel.first().and_then(|i| doc.node(*i)) else { return Ok(json!({"selection": []})) }; - let (name, ty) = (first.name.clone(), first.kind.type_name()); - let page = s.page()?; - let ids: Vec = - doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.name == name && n.kind.type_name() == ty)).collect(); - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -fn select_same(s: &mut Session, same: impl Fn(&Node, &Node) -> bool) -> Result { - let doc = s.doc()?; - let sel = s.selection(); - let Some(first) = sel.first().and_then(|i| doc.node(*i)).cloned() else { return Ok(json!({"selection": []})) }; - let page = s.page()?; - let ids: Vec = doc.subtree(page).into_iter().filter(|i| *i != page && doc.node(*i).is_some_and(|n| same(&first, n))).collect(); - s.set_selection(ids.clone()); - Ok(json!({"selection": ids})) -} - -/// The opacity number keys give: one digit is tens of percent (0 is 100%), two digits typed in -/// quick succession the exact percentage. -pub fn opacity_from_digits(digits: &str) -> Option { - let mut n = 0u32; - let mut count = 0; - for c in digits.chars() { - // Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps. - n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?; - count += 1; - } - match count { - 1 if n == 0 => Some(1.0), - 1 => Some(n as f32 / 10.0), - 2 => Some(n as f32 / 100.0), - _ => None, - } -} - -fn opacity_keys(s: &mut Session, p: &Value) -> Result { - let digits = req_string("object.opacityKeys", p, "digits")?; - let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?; - let ids = req_targets("object.opacityKeys", s, p)?; - let doc = s.doc_mut()?; - let before = super::styles::bound_before(doc, &ids); - for i in ids { - doc.get_mut(i)?.opacity = o; - } - super::styles::detach_edited(doc, &before); - Ok(json!({"opacity": o})) -} - -fn nudge(s: &mut Session, p: &Value) -> Result { - let big = boolean(p, "big").unwrap_or(false); - let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; - let (ux, uy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0)); - let (dx, dy) = (ux * k, uy * k); - let ids = req_targets("transform.nudge", s, p)?; - // Edit a copy and keep it only if something moved, so an arrow that can't move anything adds - // no undo step. - let mut work = s.doc()?.clone(); - let doc = &mut work; - let ids = roots(doc, &ids); - let mut changed = false; - // Children in an auto layout flow move one place instead (see `al_keys`). - for i in &ids { - if crate::al_keys::flowing_parent(doc, *i).is_none() && (dx, dy) != (0.0, 0.0) { - let n = doc.get_mut(*i)?; - n.transform = Affine::translate((dx, dy)) * n.transform; - changed = true; - } - } - for dir in crate::al_keys::Dir::from_units(ux, uy) { - // Grouped per arrow: a move into a neighbouring frame changes the parent. - let mut groups: BTreeMap> = BTreeMap::new(); - for i in &ids { - if let Some(parent) = crate::al_keys::flowing_parent(doc, *i) { - groups.entry(parent).or_default().push(*i); - } - } - for (parent, block) in groups { - changed |= crate::al_keys::arrow_move(doc, parent, &block, dir)?; - } - } - if changed { - s.ds_mut()?.doc = std::sync::Arc::new(work); - } - ok() -} - -fn nudge_size(s: &mut Session, p: &Value) -> Result { - let big = boolean(p, "big").unwrap_or(false); - let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; - let (dw, dh) = (num_or(p, "dw", 0.0) * k, num_or(p, "dh", 0.0) * k); - let ids = req_targets("transform.nudgeSize", s, p)?; - let ids = roots(s.doc()?, &ids); - for i in ids { - let Some((w, h)) = s.doc()?.node(i).map(|n| (n.width, n.height)) else { continue }; - let mut q = serde_json::Map::new(); - q.insert("ids".into(), json!([i])); - // Only the axis that changes becomes fixed. - if dw != 0.0 { - q.insert("width".into(), json!((w + dw).max(0.0))); - } - if dh != 0.0 { - q.insert("height".into(), json!((h + dh).max(0.0))); - } - super::arrange::resize_cmd(s, &Value::Object(q))?; - } - ok() -} - -fn toggle(s: &mut Session, p: &Value, what: &str) -> Result { - let ids = req_targets("object.toggle", s, p)?; - let doc = s.doc_mut()?; - let explicit = boolean(p, what); - // Figma: if any is visible, hide all (and vice versa). - let value = explicit.unwrap_or_else(|| match what { - "visible" => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.visible)), - _ => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.locked)), - }); - for i in &ids { - let n = doc.get_mut(*i)?; - if what == "visible" { - n.visible = value; - } else { - n.locked = value; - } - } - if what == "locked" && value { - s.set_selection(vec![]); - } - Ok(json!({what: value})) -} - -fn hide_others(s: &mut Session, _p: &Value) -> Result { - let sel = s.selection(); - let page = s.page()?; - let doc = s.doc_mut()?; - let keep: BTreeSet = sel - .iter() - .flat_map(|i| { - let mut v = doc.ancestors(*i); - v.push(*i); - v - }) - .collect(); - let tops: Vec = doc.get(page)?.children.clone(); - for t in tops { - if !keep.contains(&t) { - doc.get_mut(t)?.visible = false; - } - } - ok() -} - -fn batch_rename(s: &mut Session, p: &Value) -> Result { - let cmd = "object.batchRename"; - let ids = req_targets(cmd, s, p)?; - let find = string(p, "find"); - let replace = string(p, "replace").unwrap_or_default(); - let pattern = string(p, "name"); - let rename = - crate::rename::BatchRename::new(find.as_deref(), boolean(p, "regex").unwrap_or(false), &replace, pattern.as_deref(), num(p, "start")) - .map_err(|e| bad(cmd, e))?; - let doc = s.doc_mut()?; - let names = rename.plan(doc, &ids); - for (i, name) in &names { - doc.get_mut(*i)?.name = name.clone(); - } - Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::>()})) -} - -/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`. -/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets. -fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option { - if needle.is_empty() { - return None; - } - (from..hay.len()) - .find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase())))) -} - -fn find_replace(s: &mut Session, p: &Value) -> Result { - let find = req_string("edit.findReplace", p, "find")?; - if find.is_empty() { - return Err(bad("edit.findReplace", "`find` is empty")); - } - // Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF). - let replace = string(p, "replace").map(|r| super::props::normalize_line_endings(&r)); - let names = boolean(p, "layerNames").unwrap_or(false); - let page = s.page()?; - let doc = s.doc_mut()?; - let mut hits = Vec::new(); - let needle: Vec = find.chars().collect(); - let r_chars: Vec = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default(); - for i in doc.subtree(page) { - let Some(n) = doc.node(i) else { continue }; - let text: Option> = match &n.kind { - NodeKind::Text(t) => Some(t.characters.chars().collect()), - _ => None, - }; - let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some()); - let name_hit = names && find_ci(&n.name.chars().collect::>(), &needle, 0).is_some(); - if !(text_hit || name_hit) || n.is_page() { - continue; - } - hits.push(i); - if let Some(r) = &replace { - let n = doc.get_mut(i)?; - if names && name_hit { - let chars: Vec = n.name.chars().collect(); - let mut out = String::new(); - let mut at = 0; - while let Some(pos) = find_ci(&chars, &needle, at) { - out.extend(chars.get(at..pos).unwrap_or_default()); - out.push_str(r); - at = pos + needle.len(); - } - out.extend(chars.get(at..).unwrap_or_default()); - n.name = out; - } - if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) { - // Replace occurrence by occurrence so styles shift correctly. The search resumes - // after each inserted replacement, so a replacement that matches `find` (in any - // case) neither loops forever nor stops the other occurrences. - let mut cursor = 0; - for _ in 0..10_000 { - let Some(start) = find_ci(&chars, &needle, cursor) else { break }; - t.replace_range(start, start + needle.len(), r); - chars.splice(start..start + needle.len(), r_chars.iter().copied()); - cursor = start + r_chars.len(); - } - } - } - } - if boolean(p, "select").unwrap_or(true) { - s.set_selection(hits.clone()); - } - Ok(json!({"matches": hits})) -} - -/// Keys only some layer types take, the test for those layers, and why the others skip them. -type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str); - -fn set(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("node.set", s, p)?; - let props = match get(p, "props") { - Some(Value::Object(m)) => m.clone(), - _ => { - // Allow flat params: {"id": 3, "fill": "#f00"}. - let mut m = p.as_object().cloned().unwrap_or_default(); - m.remove("id"); - m.remove("ids"); - m - } - }; - if props.is_empty() { - return Err(bad("node.set", "no properties given")); - } - // A new rotation for the selection turns it about its moved rotation origin: each layer's - // point under the origin stays put. - let ds = s.ds()?; - let pivot = - ds.moved_origin().filter(|_| props.contains_key("rotation") && !props.contains_key("x") && !props.contains_key("y") && ds.selection == ids); - let pinned: Vec<(NodeId, figcraft_geom::Point)> = match pivot { - Some(o) => roots(&ds.doc, &ids) - .into_iter() - .map(|i| (i, ds.doc.world_transform(i))) - .filter(|(_, w)| w.determinant().abs() > 1e-12) - .map(|(i, w)| (i, w.inverse() * o)) - .collect(), - None => Vec::new(), - }; - let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); - // Type-specific keys go to the layers that take them and are skipped, with a warning, on the - // others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets - // that take them, the first refuses them (`props::layout_mut`, `props::apply`). - let doc = &s.ds()?.doc; - let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| super::props::LAYOUT_KEYS.contains(k)).collect(); - let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect(); - let frame_like = |n: &Node| n.kind.is_frame_like(); - let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. }); - let typed: [TypedKeys; 2] = [ - (&layout_keys, &frame_like, "only frames, components and instances have auto layout"), - (&path_keys, &vector, "only vector layers have a path"), - ]; - let mut skipped: BTreeMap> = BTreeMap::new(); - for (keys, takes, why) in typed { - let takes = |i: &NodeId| doc.node(*i).is_some_and(takes); - if keys.is_empty() || !ids.iter().any(takes) { - continue; - } - let others: Vec = ids.iter().copied().filter(|i| !takes(i)).collect(); - if others.is_empty() { - continue; - } - for i in &others { - skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str())); - } - let names: Vec = - others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); - let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() }; - warnings.push(format!( - "{} skipped on {}{more}: {why}", - keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), - names.join(", ") - )); - } - let doc = s.doc_mut()?; - let before = super::styles::bound_before(doc, &ids); - for i in ids { - super::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?; - } - super::styles::detach_edited(doc, &before); - if let Some(o) = pivot { - for (i, q) in pinned { - let d = o - doc.world_transform(i) * q; - if d.hypot().is_finite() { - super::arrange::move_world(doc, i, d.x, d.y)?; - } - } - } - if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) } -} - -/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that -/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is -/// Bold), say which face it is. Families that aren't installed, and names that match a style or a -/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing. -fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map) -> Vec { - let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() }; - let mut families: Vec = match props.get("fontFamily").and_then(Value::as_str) { - Some(f) => vec![f.to_string()], - None => ids - .iter() - .filter_map(|i| doc.node(*i)) - .filter_map(|n| n.kind.text()) - .flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::>()) - .collect(), - }; - families.sort(); - families.dedup(); - let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], ""); - let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic")); - families - .into_iter() - .filter_map(|family| { - let styles = figcraft_text::family_styles(&family); - if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) { - return None; - } - let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?; - let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32); - if face.instance(style).is_some() || (w == want_w && face.italic == want_i) { - return None; - } - Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", "))) - }) - .collect() -} - -/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances -/// record the override). -fn width_profile(s: &mut Session, p: &Value) -> Result { - let cmd = "stroke.widthProfile"; - let v = get(p, "preset").or_else(|| get(p, "points")).cloned().unwrap_or(Value::Null); - let ids = req_targets(cmd, s, p)?; - let props = serde_json::Map::from_iter([("widthProfile".to_string(), v)]); - let doc = s.doc_mut()?; - for i in ids { - super::props::apply(cmd, doc, i, &props)?; - } - ok() -} - -fn stroke_brush(s: &mut Session, p: &Value) -> Result { - let cmd = "stroke.brush"; - let v = if get(p, "preset").is_none_or(Value::is_null) { Value::Null } else { p.clone() }; - let brush = super::props::brush_of(&v).ok_or_else(|| bad(cmd, super::props::BRUSH_HELP))?; - let ids = req_targets(cmd, s, p)?; - let doc = s.doc_mut()?; - for i in ids { - let n = doc.get_mut(i)?; - if brush.is_some() && n.strokes.is_empty() { - n.strokes.push(figcraft_color::Paint::solid(figcraft_color::Color::BLACK)); - } - n.stroke.brush = brush.clone(); - } - ok() -} - -fn reparent(s: &mut Session, p: &Value) -> Result { - let ids = req_targets("node.reparent", s, p)?; - let parent = req_id("node.reparent", p, "parent")?; - let index = num(p, "index").map(|i| i.max(0.0) as usize); - let doc = s.doc_mut()?; - let ids = z_sorted(doc, &roots(doc, &ids)); - // `index` is where the first moved layer ends up among the parent's children (bottom first), - // counted after the move, the way `node.reorder` counts it. The layers land together, keeping - // their order, under the sibling that stays put and ends up just after them (none: on top). - let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied()); - for i in &ids { - // Counted with the layer still in place, as `reparent` expects. - let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a)); - doc.reparent(*i, parent, at)?; - } - ok() -} - -fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result { - let cmd = if fills { "fill" } else { "stroke" }; - let ids = req_targets(cmd, s, p)?; - let index = if add || get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; - let doc = s.doc_mut()?; - for i in ids { - let n = doc.get_mut(i)?; - let frameish = n.kind.is_frame_like(); - let list = if fills { &mut n.fills } else { &mut n.strokes }; - if add { - let paint = match get(p, "paint").or_else(|| get(p, "color")) { - Some(v) => paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?, - None if fills && frameish => figcraft_color::Paint::solid(figcraft_color::Color::WHITE), - None if fills => figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE), - None => figcraft_color::Paint::solid(figcraft_color::Color::BLACK), - }; - list.push(paint); - } else { - let idx = index.unwrap_or(list.len().saturating_sub(1)); - // The paint's colour binding goes with it; later paints' move down one. - super::lists::remove_item(n, if fills { super::lists::BoundList::Fills } else { super::lists::BoundList::Strokes }, idx); - } - } - ok() -} - -fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { - let cmd = if fills { "fill.set" } else { "stroke.set" }; - let ids = req_targets(cmd, s, p)?; - let idx = opt_index(cmd, p, "index")?; - let new_paint = match get(p, "paint") { - Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), - None => None, - }; - let col = color(p, "color"); - let doc = s.doc_mut()?; - for i in ids { - let n = doc.get_mut(i)?; - let list = if fills { &mut n.fills } else { &mut n.strokes }; - let at = idx.unwrap_or(list.len().saturating_sub(1)); - if list.is_empty() { - list.push(figcraft_color::Paint::solid(if fills { figcraft_color::Color::DEFAULT_SHAPE } else { figcraft_color::Color::BLACK })); - } - let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) }; - let old = slot.clone(); - if let Some(np) = &new_paint { - *slot = np.clone(); - } - if let Some(c) = col { - slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) }; - if c.a < 1.0 { - slot.opacity = c.a; - slot.color_alpha = c.a; - } - } - if let Some(o) = num(p, "opacity") { - slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32; - slot.color_alpha = 1.0; - } - if let Some(v) = boolean(p, "visible") { - slot.visible = v; - } - if let Some(b) = blend(p, "blend") { - slot.blend = b; - } - super::lists::rebind_edited_paint(n, fills, at, &old); - if fills { - n.styles.fill = None; - } else { - n.styles.stroke = None; - } - } - ok() -} - -/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke -/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along. -fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result { - let cmd = if fills { "fill.copy" } else { "stroke.copy" }; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; - let at = opt_index(cmd, p, "index")?; - let (paint, stroke) = { - let doc = s.doc()?; - let n = doc.get(id)?; - let list = if fills { &n.fills } else { &n.strokes }; - let at = at.unwrap_or(list.len().saturating_sub(1)); - let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?; - (paint, (!fills).then(|| n.stroke.clone())) - }; - s.clipboard.paint = Some((fills, paint.clone())); - s.clipboard.stroke = stroke; - serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string())) -} - -/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A -/// stroke's copied settings go onto each target too. -fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result { - let cmd = if fills { "fill.paste" } else { "stroke.paste" }; - let Some((copied_fills, paint)) = s.clipboard.paint.clone() else { - return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into())); - }; - if copied_fills != fills { - let what = if fills { "stroke" } else { "fill" }; - return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}"))); - } - let ids = req_targets(cmd, s, p)?; - // A copied pattern pointing at a target or a layer inside it would make the layer pattern - // itself; `fill.pattern` refuses the same, and the renderer would drop the pattern. - if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind { - let doc = s.doc()?; - if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) { - return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it")); - } - } - let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?}); - let out = match opt_index(cmd, p, "index")? { - Some(at) => { - q["index"] = json!(at); - paint_set(s, &q, fills)? - } - None => paint_list(s, &q, fills, true)?, - }; - if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) { - for id in ids { - s.doc_mut()?.get_mut(id)?.stroke = stroke.clone(); - } - } - Ok(out) -} - -fn pattern_fill(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "fill.pattern"; - let src = req_id(CMD, p, "source")?; - let doc = s.doc()?; - if doc.node(src).is_none_or(|n| n.is_page()) { - return Err(bad(CMD, "`source` must be a layer")); - } - if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) { - return Err(bad(CMD, "a layer can't be filled with itself or a layer inside it")); - } - let mut pt = figcraft_color::PatternPaint::new(src.0); - pt.tile = parse(CMD, p, "tile")?.unwrap_or_default(); - pt.align = parse(CMD, p, "align")?.unwrap_or_default(); - pt.spacing = parse(CMD, p, "spacing")?.unwrap_or_default(); - let (lo, hi) = figcraft_color::PatternPaint::SCALE_RANGE; - pt.scale = num(p, "scale").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(lo, hi); - let mut q = if p.is_object() { p.clone() } else { json!({}) }; - q["paint"] = - serde_json::to_value(figcraft_color::Paint { kind: figcraft_color::PaintKind::Pattern(pt), ..figcraft_color::Paint::pattern(src.0) }) - .map_err(|e| bad(CMD, e.to_string()))?; - paint_set(s, &q, true) -} - -fn effect_add(s: &mut Session, p: &Value) -> Result { - let e: Effect = match super::props::effect_from("effect.add", p, "effect")? { - Some(e) => e, - None => { - let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into()); - Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))? - } - }; - for i in req_targets("effect.add", s, p)? { - s.doc_mut()?.get_mut(i)?.effects.push(e.clone()); - } - ok() -} - -/// Copy effect `index` (the top one by default) of the first target into the clipboard. -fn effect_copy(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "effect.copy"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?; - let at = opt_index(CMD, p, "index")?; - let e = { - let doc = s.doc()?; - let list = &doc.get(id)?.effects; - let at = at.unwrap_or(list.len().saturating_sub(1)); - list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))? - }; - s.clipboard.effect = Some(e.clone()); - serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string())) -} - -/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one. -fn effect_paste(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "effect.paste"; - let Some(e) = s.clipboard.effect.clone() else { - return Err(EngineError::Disabled(CMD.into(), "no copied effect".into())); - }; - let at = opt_index(CMD, p, "index")?; - for id in req_targets(CMD, s, p)? { - let n = s.doc_mut()?.get_mut(id)?; - match at { - Some(i) => { - if let Some(slot) = n.effects.get_mut(i) { - *slot = e.clone(); - } - } - None => n.effects.push(e.clone()), - } - } - ok() -} diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs new file mode 100644 index 00000000..d0e7b2dc --- /dev/null +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -0,0 +1,332 @@ +use super::*; + +/// The copied form of the selection. +pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> { + let ds = s.ds()?; + // Pages are not layers: they can't be pasted anywhere. + let layers: Vec = ids.iter().copied().filter(|i| !ds.doc.node(*i).is_some_and(Node::is_page)).collect(); + if layers.is_empty() && !ids.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "pages can't be copied; use page.duplicate".into())); + } + let (nodes, images, bounds) = copied(&ds.doc, &layers); + let (source, source_path, uid) = (ds.doc.clone(), ds.path.clone(), ds.uid); + s.clipboard.source = Some(source); + s.clipboard.source_path = source_path; + s.clipboard.source_doc = uid; + s.clipboard.copied_components = nodes + .iter() + .filter(|(_, sub)| { + cmd == "edit.copy" && sub.first().is_some_and(|n| matches!(n.kind, NodeKind::Component { .. } | NodeKind::ComponentSet { .. })) + }) + .map(|(root, _)| *root) + .collect(); + s.clipboard.nodes = nodes; + s.clipboard.images = images; + s.clipboard.bounds = bounds; + s.clipboard.layers_generation = s.clipboard.layers_generation.wrapping_add(1); + Ok(()) +} + +type Copied = (Vec<(NodeId, Vec)>, BTreeMap, Option); + +fn copied(doc: &Document, ids: &[NodeId]) -> Copied { + let ids = z_sorted(doc, &roots(doc, ids)); + let mut nodes = Vec::new(); + let mut images = BTreeMap::new(); + for i in &ids { + let world = doc.world_transform(*i); + let mut sub: Vec = doc.subtree(*i).into_iter().filter_map(|x| doc.node(x).cloned()).collect(); + // Store the root's transform in page space so pastes land where it was. + if let Some(r) = sub.first_mut() { + r.transform = world; + } + for n in &sub { + for p in n.fills.iter().chain(n.strokes.iter()) { + if let figcraft_color::PaintKind::Image(img) = &p.kind + && let Some(d) = doc.images.get(&img.image) + { + images.insert(img.image.clone(), d.clone()); + } + } + } + nodes.push((*i, sub)); + } + let bounds = doc.bounds_of(&ids); + (nodes, images, bounds) +} + +/// The markers around the copied layers in the clipboard's HTML, spelt as that format spells them. +pub const CLIP_OPEN: &str = "(figma)"; + +pub const CLIP_CLOSE: &str = "(/figma)"; + +/// Largest design data read from the clipboard: the `.fig` import's own canvas cap (256 MB). Its +/// base64 is checked first, so neither the text nor the decoded bytes can pass it. +const MAX_CLIP_DATA: usize = 256 * 1024 * 1024; + +const MAX_CLIP_BASE64: usize = MAX_CLIP_DATA.div_ceil(3) * 4; + +/// Base64 `text` (whitespace ignored, padding optional) when it fits [`MAX_CLIP_DATA`]. +fn clip_base64(text: &str) -> Option> { + use base64::Engine as _; + if text.bytes().filter(|b| !b.is_ascii_whitespace()).count() > MAX_CLIP_BASE64 { + return None; + } + let b64: String = text.chars().filter(|c| !c.is_ascii_whitespace()).collect(); + base64::engine::general_purpose::STANDARD.decode(&b64).or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(&b64)).ok() +} + +/// Layers copied from another design app travel in the clipboard's HTML as a `.fig` canvas in +/// base64 between [`CLIP_OPEN`] and [`CLIP_CLOSE`]. This gives those bytes, or `None` when the HTML +/// holds no readable design data (it may just mention the marker). +pub fn design_clipboard_buffer(html: &str) -> Option> { + let start = html.find(CLIP_OPEN)? + CLIP_OPEN.len(); + let len = html.get(start..)?.find(CLIP_CLOSE)?; + let bytes = clip_base64(html.get(start..start + len)?)?; + figcraft_format::fig::is_fig(&bytes).then_some(bytes) +} + +/// Paste design layers from clipboard HTML or base64 `data`: decode and reject missing, +/// invalid or oversized data, load a temporary `.fig`, take its top-level page layers, +/// store them as a foreign-file clipboard source with a bumped layers generation, then +/// delegate to `paste` and append import warnings. +pub(super) fn paste_design_layers(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "edit.pasteDesignLayers"; + let bytes = match (string(p, "html"), string(p, "data")) { + (Some(html), _) => design_clipboard_buffer(&html).ok_or_else(|| bad(CMD, "the HTML holds no copied design layers"))?, + (None, Some(d)) => clip_base64(&d).ok_or_else(|| bad(CMD, "`data` is not base64 of at most 256 MB"))?, + (None, None) => return Err(bad(CMD, "pass `html` or `data`")), + }; + let other = crate::cmd::file::load_fig(&bytes, "Pasted layers").ok_or_else(|| bad(CMD, "the data is not copied design layers"))??; + let doc = std::sync::Arc::new(other.doc); + let tops: Vec = doc.pages.iter().filter_map(|pg| doc.node(*pg)).flat_map(|pg| pg.children.iter().copied()).collect(); + if tops.is_empty() { + return Err(EngineError::Disabled(CMD.into(), "the copied layers came in empty".into())); + } + let (nodes, images, bounds) = copied(&doc, &tops); + // A design-app paste replaces the copied layers, not the copied rows: a `fill.copy` before it + // still pastes afterwards. + let props = s.clipboard.props.take(); + let paint = s.clipboard.paint.take(); + let stroke = s.clipboard.stroke.take(); + let effect = s.clipboard.effect.take(); + // No open file has id 0. So paste treats this copy as a copy from a different file. + let layers_generation = s.clipboard.layers_generation.wrapping_add(1); + s.clipboard = crate::Clipboard { + nodes, + images, + props, + paint, + stroke, + effect, + bounds, + source_doc: 0, + source: Some(doc), + source_path: None, + copied_components: BTreeSet::new(), + layers_generation, + }; + let mut out = paste(s, p)?; + if !other.issues.is_empty() { + out["warnings"] = json!(other.issues); + } + Ok(out) +} + +pub(super) fn copy(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("edit.copy", s, p)?; + copy_to_clipboard(s, "edit.copy", &ids)?; + Ok(json!({"copied": s.clipboard.nodes.len()})) +} + +pub(super) fn cut(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("edit.cut", s, p)?; + copy_to_clipboard(s, "edit.cut", &ids)?; + let doc = s.doc()?; + let layers: Vec = ids.into_iter().filter(|i| !doc.node(*i).is_some_and(Node::is_page)).collect(); + delete(s, &json!({"ids": layers})) +} + +/// Paste the clipboard under `parent` with the copied top-left moved to `at` (page coords). +pub fn paste_into(s: &mut Session, parent: NodeId, at: Option<(f64, f64)>) -> Result> { + let clip = s.clipboard.clone(); + if clip.nodes.is_empty() { + return Err(EngineError::Disabled("edit.paste".into(), "the clipboard is empty".into())); + } + // From another file: its instances follow the mains this file can have. + let foreign = clip.source.as_ref().filter(|_| s.ds().is_ok_and(|d| d.uid != clip.source_doc)); + let copy_local = s.ds()?.uid == clip.source_doc; + let doc = s.doc_mut()?; + for (k, v) in &clip.images { + doc.images.entry(k.clone()).or_insert_with(|| v.clone()); + } + // Sections go on the page (or in sections), never in the frame being pasted into. + let page = doc.page_of(parent).unwrap_or(parent); + let shift = match (at, clip.bounds) { + (Some((x, y)), Some(b)) => Affine::translate((x - b.x0, y - b.y0)), + _ => Affine::IDENTITY, + }; + let mut out = Vec::new(); + let mut all = BTreeMap::new(); + for (root, nodes) in &clip.nodes { + if copy_local + && clip.copied_components.contains(root) + && let Some(n) = nodes.first().filter(|n| matches!(n.kind, NodeKind::Component { .. })) + && doc.node(*root).is_some_and(|n| matches!(n.kind, NodeKind::Component { .. })) + { + let instance = crate::components::create_instance(doc, *root, parent, 0.0, 0.0)?; + doc.get_mut(instance)?.transform = doc.world_transform(parent).inverse() * shift * n.transform; + out.push(instance); + continue; + } + let mut map = BTreeMap::new(); + for n in nodes { + map.insert(n.id, doc.alloc_id()); + } + let mut nodes = nodes.clone(); + let section = nodes.first().is_some_and(|r| matches!(r.kind, NodeKind::Section { .. })); + let into = if section && !doc.takes_sections(parent) { page } else { parent }; + if let Some(r) = nodes.first_mut() { + r.transform = doc.world_transform(into).inverse() * shift * r.transform; + } + out.push(doc.paste_nodes(&nodes, &map, *root, into, None)?); + all.extend(map); + } + // Board connectors pasted with what they join join the pasted copies. + let originals: Vec<(NodeId, figcraft_doc::BoardItem)> = + clip.nodes.iter().flat_map(|(_, nodes)| nodes).filter_map(|n| Some((*all.get(&n.id)?, n.board.clone()?))).collect(); + crate::board::rejoin(doc, &originals, &all, !copy_local); + if let Some(src) = foreign { + crate::library::adopt_pasted(doc, src, clip.source_path.as_deref(), &all)?; + } + Ok(out) +} + +pub(super) fn paste(s: &mut Session, p: &Value) -> Result { + let page = s.page()?; + // Figma pastes into the selected frame (if one is selected), at the same position when it fits, + // else in the frame's centre; otherwise in place on the page (or the viewport centre). + let sel = s.selection(); + let doc = s.doc()?; + let copy_local = s.ds()?.uid == s.clipboard.source_doc; + let target = id(p, "parent").or_else(|| { + sel.first() + .copied() + .filter(|i| { + sel.len() == 1 + && doc.node(*i).is_some_and(|n| n.kind.is_frame_like() && !matches!(n.kind, NodeKind::Instance { .. }) && !n.is_board_atom()) + }) + .filter(|i| !copy_local || !s.clipboard.copied_components.iter().any(|root| doc.is_ancestor(*root, *i))) + }); + let parent = target.unwrap_or(page); + let at = match (num(p, "x"), num(p, "y")) { + (Some(x), Some(y)) => Some((x, y)), + _ => { + let b = s.clipboard.bounds; + match (target, b) { + (Some(t), Some(b)) => { + let tb = doc.world_bounds(t); + match tb { + Some(tb) if tb.contains(b.origin()) && tb.contains(figcraft_geom::Point::new(b.x1, b.y1)) => None, + Some(tb) => Some((tb.center().x - b.width() / 2.0, tb.center().y - b.height() / 2.0)), + None => None, + } + } + (None, Some(b)) => { + // Off-screen originals paste at the viewport centre. + let v = &s.ds()?.view; + let (ox, oy) = v.origin(); + let vr = figcraft_geom::Rect::new(ox, oy, ox + v.vw / v.zoom.max(1e-9), oy + v.vh / v.zoom.max(1e-9)); + if vr.intersect(b).is_zero_area() { Some((v.cx - b.width() / 2.0, v.cy - b.height() / 2.0)) } else { None } + } + _ => None, + } + } + }; + let ids = paste_into(s, parent, at)?; + s.set_selection(ids.clone()); + Ok(json!({"ids": ids})) +} + +pub(super) fn paste_over(s: &mut Session, _p: &Value) -> Result { + let sel = s.selection(); + let page = s.page()?; + let doc = s.doc()?; + let (parent, at) = match sel.first() { + Some(i) => { + let parent = doc.node(*i).and_then(|n| n.parent).unwrap_or(page); + (parent, doc.world_bounds(*i).map(|b| (b.x0, b.y0))) + } + None => (page, None), + }; + let ids = paste_into(s, parent, at)?; + s.set_selection(ids.clone()); + Ok(json!({"ids": ids})) +} + +pub(super) fn paste_replace(s: &mut Session, p: &Value) -> Result { + let targets = req_targets("edit.pasteReplace", s, p)?; + let mut out = Vec::new(); + for t in targets { + let doc = s.doc()?; + let Some(n) = doc.node(t) else { continue }; + let parent = n.parent.ok_or_else(|| bad("edit.pasteReplace", "can't replace a page"))?; + let idx = doc.index_in_parent(t); + let at = doc.world_bounds(t).map(|b| (b.x0, b.y0)); + let ids = paste_into(s, parent, at)?; + let doc = s.doc_mut()?; + for (k, i) in ids.iter().enumerate() { + if let Some(ix) = idx { + let _ = doc.reorder(*i, ix + k); + } + } + doc.remove(t)?; + out.extend(ids); + } + s.set_selection(out.clone()); + Ok(json!({"ids": out})) +} + +pub(super) fn copy_props_of(doc: &Document, id: NodeId) -> Value { + let Some(n) = doc.node(id) else { return Value::Null }; + let mut v = json!({ + "fills": n.fills, "strokes": n.strokes, "strokeWeight": n.stroke.weight, "strokeAlign": n.stroke.align, + "effects": n.effects, "opacity": n.opacity, "blend": n.blend, "cornerRadius": n.corners.radii, + "cornerSmoothing": n.corners.smoothing, + }); + if let NodeKind::Text(t) = &n.kind { + v["fontFamily"] = json!(t.style.font_family); + v["fontStyle"] = json!(t.style.font_style); + v["fontSize"] = json!(t.style.font_size); + v["fontVariations"] = json!(t.style.variations); + v["lineHeight"] = serde_json::to_value(t.style.line_height).unwrap_or(Value::Null); + v["letterSpacing"] = serde_json::to_value(t.style.letter_spacing).unwrap_or(Value::Null); + } + v +} + +pub(super) fn copy_props(s: &mut Session, p: &Value) -> Result { + let id = targets(s, p).first().copied().ok_or_else(|| bad("edit.copyProperties", "select one layer"))?; + let v = copy_props_of(s.doc()?, id); + s.clipboard.props = Some(v.clone()); + Ok(v) +} + +pub(super) fn paste_props(s: &mut Session, p: &Value) -> Result { + let props = s.clipboard.props.clone().ok_or_else(|| EngineError::Disabled("edit.pasteProperties".into(), "no copied properties".into()))?; + let ids = req_targets("edit.pasteProperties", s, p)?; + let doc = s.doc_mut()?; + for i in ids { + let mut m = props.as_object().cloned().unwrap_or_default(); + let is_text = matches!(doc.get(i)?.kind, NodeKind::Text(_)); + if !is_text { + m.retain(|k, _| !k.starts_with("font") && k != "lineHeight" && k != "letterSpacing"); + } + if !doc.get(i)?.kind.has_corner_radius() { + m.remove("cornerRadius"); + m.remove("cornerSmoothing"); + } + crate::cmd::props::apply("edit.pasteProperties", doc, i, &m)?; + } + ok() +} diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs new file mode 100644 index 00000000..f793b50c --- /dev/null +++ b/crates/engine/src/cmd/edit/props.rs @@ -0,0 +1,458 @@ +use super::*; + +pub(super) fn batch_rename(s: &mut Session, p: &Value) -> Result { + let cmd = "object.batchRename"; + let ids = req_targets(cmd, s, p)?; + let find = string(p, "find"); + let replace = string(p, "replace").unwrap_or_default(); + let pattern = string(p, "name"); + let rename = + crate::rename::BatchRename::new(find.as_deref(), boolean(p, "regex").unwrap_or(false), &replace, pattern.as_deref(), num(p, "start")) + .map_err(|e| bad(cmd, e))?; + let doc = s.doc_mut()?; + let names = rename.plan(doc, &ids); + for (i, name) in &names { + doc.get_mut(*i)?.name = name.clone(); + } + Ok(json!({"names": names.iter().map(|(i, n)| json!({"id": i, "name": n})).collect::>()})) +} + +/// Char index of the first case-insensitive occurrence of `needle` in `hay` at or after `from`. +/// Works on char indices (what `replace_range` takes), so lowercasing never shifts offsets. +fn find_ci(hay: &[char], needle: &[char], from: usize) -> Option { + if needle.is_empty() { + return None; + } + (from..hay.len()) + .find(|&i| hay.get(i..i + needle.len()).is_some_and(|w| w.iter().zip(needle).all(|(a, b)| a == b || a.to_lowercase().eq(b.to_lowercase())))) +} + +pub(super) fn find_replace(s: &mut Session, p: &Value) -> Result { + let find = req_string("edit.findReplace", p, "find")?; + if find.is_empty() { + return Err(bad("edit.findReplace", "`find` is empty")); + } + // Line breaks stored as the editor stores them (CRLF, CR, U+2028 and U+2029 become LF). + let replace = string(p, "replace").map(|r| crate::cmd::props::normalize_line_endings(&r)); + let names = boolean(p, "layerNames").unwrap_or(false); + let page = s.page()?; + let doc = s.doc_mut()?; + let mut hits = Vec::new(); + let needle: Vec = find.chars().collect(); + let r_chars: Vec = replace.as_deref().map(|r| r.chars().collect()).unwrap_or_default(); + for i in doc.subtree(page) { + let Some(n) = doc.node(i) else { continue }; + let text: Option> = match &n.kind { + NodeKind::Text(t) => Some(t.characters.chars().collect()), + _ => None, + }; + let text_hit = text.as_deref().is_some_and(|c| find_ci(c, &needle, 0).is_some()); + let name_hit = names && find_ci(&n.name.chars().collect::>(), &needle, 0).is_some(); + if !(text_hit || name_hit) || n.is_page() { + continue; + } + hits.push(i); + if let Some(r) = &replace { + let n = doc.get_mut(i)?; + if names && name_hit { + let chars: Vec = n.name.chars().collect(); + let mut out = String::new(); + let mut at = 0; + while let Some(pos) = find_ci(&chars, &needle, at) { + out.extend(chars.get(at..pos).unwrap_or_default()); + out.push_str(r); + at = pos + needle.len(); + } + out.extend(chars.get(at..).unwrap_or_default()); + n.name = out; + } + if let (NodeKind::Text(t), Some(mut chars)) = (&mut n.kind, text) { + // Replace occurrence by occurrence so styles shift correctly. The search resumes + // after each inserted replacement, so a replacement that matches `find` (in any + // case) neither loops forever nor stops the other occurrences. + let mut cursor = 0; + for _ in 0..10_000 { + let Some(start) = find_ci(&chars, &needle, cursor) else { break }; + t.replace_range(start, start + needle.len(), r); + chars.splice(start..start + needle.len(), r_chars.iter().copied()); + cursor = start + r_chars.len(); + } + } + } + } + if boolean(p, "select").unwrap_or(true) { + s.set_selection(hits.clone()); + } + Ok(json!({"matches": hits})) +} + +/// Keys only some layer types take, the test for those layers, and why the others skip them. +type TypedKeys<'a> = (&'a [&'a str], &'a dyn Fn(&Node) -> bool, &'a str); + +pub(super) fn set(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("node.set", s, p)?; + let props = match get(p, "props") { + Some(Value::Object(m)) => m.clone(), + _ => { + // Allow flat params: {"id": 3, "fill": "#f00"}. + let mut m = p.as_object().cloned().unwrap_or_default(); + m.remove("id"); + m.remove("ids"); + m + } + }; + if props.is_empty() { + return Err(bad("node.set", "no properties given")); + } + // A new rotation for the selection turns it about its moved rotation origin: each layer's + // point under the origin stays put. + let ds = s.ds()?; + let pivot = + ds.moved_origin().filter(|_| props.contains_key("rotation") && !props.contains_key("x") && !props.contains_key("y") && ds.selection == ids); + let pinned: Vec<(NodeId, figcraft_geom::Point)> = match pivot { + Some(o) => roots(&ds.doc, &ids) + .into_iter() + .map(|i| (i, ds.doc.world_transform(i))) + .filter(|(_, w)| w.determinant().abs() > 1e-12) + .map(|(i, w)| (i, w.inverse() * o)) + .collect(), + None => Vec::new(), + }; + let mut warnings = font_style_warnings(&s.ds()?.doc, &ids, &props); + // Type-specific keys go to the layers that take them and are skipped, with a warning, on the + // others: auto layout keys to frame-like layers, `path` to vectors. With none among the targets + // that take them, the first refuses them (`props::layout_mut`, `props::apply`). + let doc = &s.ds()?.doc; + let layout_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| crate::cmd::props::LAYOUT_KEYS.contains(k)).collect(); + let path_keys: Vec<&str> = props.keys().map(String::as_str).filter(|k| *k == "path").collect(); + let frame_like = |n: &Node| n.kind.is_frame_like(); + let vector = |n: &Node| matches!(n.kind, NodeKind::Vector { .. }); + let typed: [TypedKeys; 2] = [ + (&layout_keys, &frame_like, "only frames, components and instances have auto layout"), + (&path_keys, &vector, "only vector layers have a path"), + ]; + let mut skipped: BTreeMap> = BTreeMap::new(); + for (keys, takes, why) in typed { + let takes = |i: &NodeId| doc.node(*i).is_some_and(takes); + if keys.is_empty() || !ids.iter().any(takes) { + continue; + } + let others: Vec = ids.iter().copied().filter(|i| !takes(i)).collect(); + if others.is_empty() { + continue; + } + for i in &others { + skipped.entry(*i).or_insert_with(|| props.clone()).retain(|k, _| !keys.contains(&k.as_str())); + } + let names: Vec = + others.iter().take(10).filter_map(|i| doc.node(*i)).map(|n| format!("{} “{}” ({})", n.id, n.name, n.kind.type_name())).collect(); + let more = if others.len() > names.len() { format!(" and {} more", others.len() - names.len()) } else { String::new() }; + warnings.push(format!( + "{} skipped on {}{more}: {why}", + keys.iter().map(|k| format!("`{k}`")).collect::>().join(", "), + names.join(", ") + )); + } + let doc = s.doc_mut()?; + let before = crate::cmd::styles::bound_before(doc, &ids); + for i in ids { + crate::cmd::props::apply("node.set", doc, i, skipped.get(&i).unwrap_or(&props))?; + } + crate::cmd::styles::detach_edited(doc, &before); + if let Some(o) = pivot { + for (i, q) in pinned { + let d = o - doc.world_transform(i) * q; + if d.hypot().is_finite() { + crate::cmd::arrange::move_world(doc, i, d.x, d.y)?; + } + } + } + if warnings.is_empty() { ok() } else { Ok(json!({"ok": true, "warnings": warnings})) } +} + +/// A `fontStyle` draws in the family's face nearest to it, as a text layer resolves it. When that +/// face has another weight or slant than the name asks for ("Black" on a family whose heaviest is +/// Bold), say which face it is. Families that aren't installed, and names that match a style or a +/// named instance (spelled any way: "SemiBold", "Semi Bold"), say nothing. +fn font_style_warnings(doc: &figcraft_doc::Document, ids: &[NodeId], props: &serde_json::Map) -> Vec { + let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() }; + let mut families: Vec = match props.get("fontFamily").and_then(Value::as_str) { + Some(f) => vec![f.to_string()], + None => ids + .iter() + .filter_map(|i| doc.node(*i)) + .filter_map(|n| n.kind.text()) + .flat_map(|t| t.spans().into_iter().map(|(_, _, st)| st.font_family.clone()).collect::>()) + .collect(), + }; + families.sort(); + families.dedup(); + let norm = |s: &str| s.to_lowercase().replace([' ', '-', '_'], ""); + let (want_w, want_i) = (i32::from(figcraft_doc::style_weight(style)), style.to_lowercase().contains("italic")); + families + .into_iter() + .filter_map(|family| { + let styles = figcraft_text::family_styles(&family); + if styles.is_empty() || styles.iter().any(|x| norm(x) == norm(style)) { + return None; + } + let face = figcraft_text::find_face(&family, style).filter(|f| f.family.eq_ignore_ascii_case(&family))?; + let w = face.axis("wght").map_or(i32::from(face.weight), |a| a.clamp(f64::from(want_w)).round() as i32); + if face.instance(style).is_some() || (w == want_w && face.italic == want_i) { + return None; + } + Some(format!("{family} has no `{style}` style: it draws in {}, the nearest (it has: {})", face.style, styles.join(", "))) + }) + .collect() +} + +/// `stroke.widthProfile`: a preset or points (through `node.set`'s `widthProfile`, so instances +/// record the override). +pub(super) fn width_profile(s: &mut Session, p: &Value) -> Result { + let cmd = "stroke.widthProfile"; + let v = get(p, "preset").or_else(|| get(p, "points")).cloned().unwrap_or(Value::Null); + let ids = req_targets(cmd, s, p)?; + let props = serde_json::Map::from_iter([("widthProfile".to_string(), v)]); + let doc = s.doc_mut()?; + for i in ids { + crate::cmd::props::apply(cmd, doc, i, &props)?; + } + ok() +} + +pub(super) fn stroke_brush(s: &mut Session, p: &Value) -> Result { + let cmd = "stroke.brush"; + let v = if get(p, "preset").is_none_or(Value::is_null) { Value::Null } else { p.clone() }; + let brush = crate::cmd::props::brush_of(&v).ok_or_else(|| bad(cmd, crate::cmd::props::BRUSH_HELP))?; + let ids = req_targets(cmd, s, p)?; + let doc = s.doc_mut()?; + for i in ids { + let n = doc.get_mut(i)?; + if brush.is_some() && n.strokes.is_empty() { + n.strokes.push(figcraft_color::Paint::solid(figcraft_color::Color::BLACK)); + } + n.stroke.brush = brush.clone(); + } + ok() +} + +pub(super) fn reparent(s: &mut Session, p: &Value) -> Result { + let ids = req_targets("node.reparent", s, p)?; + let parent = req_id("node.reparent", p, "parent")?; + let index = num(p, "index").map(|i| i.max(0.0) as usize); + let doc = s.doc_mut()?; + let ids = z_sorted(doc, &roots(doc, &ids)); + // `index` is where the first moved layer ends up among the parent's children (bottom first), + // counted after the move, the way `node.reorder` counts it. The layers land together, keeping + // their order, under the sibling that stays put and ends up just after them (none: on top). + let above = index.and_then(|at| doc.get(parent).ok()?.children.iter().filter(|c| !ids.contains(c)).nth(at).copied()); + for i in &ids { + // Counted with the layer still in place, as `reparent` expects. + let at = above.and_then(|a| doc.node(parent)?.children.iter().position(|c| *c == a)); + doc.reparent(*i, parent, at)?; + } + ok() +} + +pub(super) fn paint_list(s: &mut Session, p: &Value, fills: bool, add: bool) -> Result { + let cmd = if fills { "fill" } else { "stroke" }; + let ids = req_targets(cmd, s, p)?; + let index = if add || get(p, "index").is_none() { None } else { Some(req_index(cmd, p, "index")?) }; + let doc = s.doc_mut()?; + for i in ids { + let n = doc.get_mut(i)?; + let frameish = n.kind.is_frame_like(); + let list = if fills { &mut n.fills } else { &mut n.strokes }; + if add { + let paint = match get(p, "paint").or_else(|| get(p, "color")) { + Some(v) => paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?, + None if fills && frameish => figcraft_color::Paint::solid(figcraft_color::Color::WHITE), + None if fills => figcraft_color::Paint::solid(figcraft_color::Color::DEFAULT_SHAPE), + None => figcraft_color::Paint::solid(figcraft_color::Color::BLACK), + }; + list.push(paint); + } else { + let idx = index.unwrap_or(list.len().saturating_sub(1)); + // The paint's colour binding goes with it; later paints' move down one. + crate::cmd::lists::remove_item(n, if fills { crate::cmd::lists::BoundList::Fills } else { crate::cmd::lists::BoundList::Strokes }, idx); + } + } + ok() +} + +pub(super) fn paint_set(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.set" } else { "stroke.set" }; + let ids = req_targets(cmd, s, p)?; + let idx = opt_index(cmd, p, "index")?; + let new_paint = match get(p, "paint") { + Some(v) => Some(paint_of(v).ok_or_else(|| bad(cmd, "invalid paint"))?), + None => None, + }; + let col = color(p, "color"); + let doc = s.doc_mut()?; + for i in ids { + let n = doc.get_mut(i)?; + let list = if fills { &mut n.fills } else { &mut n.strokes }; + let at = idx.unwrap_or(list.len().saturating_sub(1)); + if list.is_empty() { + list.push(figcraft_color::Paint::solid(if fills { figcraft_color::Color::DEFAULT_SHAPE } else { figcraft_color::Color::BLACK })); + } + let Some(slot) = list.get_mut(at) else { return Err(bad(cmd, format!("no paint at index {at}"))) }; + let old = slot.clone(); + if let Some(np) = &new_paint { + *slot = np.clone(); + } + if let Some(c) = col { + slot.kind = figcraft_color::PaintKind::Solid { color: c.with_alpha(1.0) }; + if c.a < 1.0 { + slot.opacity = c.a; + slot.color_alpha = c.a; + } + } + if let Some(o) = num(p, "opacity") { + slot.opacity = (if o > 1.0 { o / 100.0 } else { o }).clamp(0.0, 1.0) as f32; + slot.color_alpha = 1.0; + } + if let Some(v) = boolean(p, "visible") { + slot.visible = v; + } + if let Some(b) = blend(p, "blend") { + slot.blend = b; + } + crate::cmd::lists::rebind_edited_paint(n, fills, at, &old); + if fills { + n.styles.fill = None; + } else { + n.styles.stroke = None; + } + } + ok() +} + +/// Copy paint `index` (the top one by default) of the first target into the clipboard. A stroke +/// takes its node-wide settings (weight, alignment, sides, caps, joins, dashes) along. +pub(super) fn paint_copy(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.copy" } else { "stroke.copy" }; + let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; + let at = opt_index(cmd, p, "index")?; + let (paint, stroke) = { + let doc = s.doc()?; + let n = doc.get(id)?; + let list = if fills { &n.fills } else { &n.strokes }; + let at = at.unwrap_or(list.len().saturating_sub(1)); + let paint = list.get(at).cloned().ok_or_else(|| bad(cmd, format!("no {} at index {at}", if fills { "fill" } else { "stroke" })))?; + (paint, (!fills).then(|| n.stroke.clone())) + }; + s.clipboard.paint = Some((fills, paint.clone())); + s.clipboard.stroke = stroke; + serde_json::to_value(&paint).map_err(|e| bad(cmd, e.to_string())) +} + +/// Paste the copied paint onto the targets: replacing paint `index` there, or adding one. A +/// stroke's copied settings go onto each target too. +pub(super) fn paint_paste(s: &mut Session, p: &Value, fills: bool) -> Result { + let cmd = if fills { "fill.paste" } else { "stroke.paste" }; + let Some((copied_fills, paint)) = s.clipboard.paint.clone() else { + return Err(EngineError::Disabled(cmd.into(), "no copied fill or stroke".into())); + }; + if copied_fills != fills { + let what = if fills { "stroke" } else { "fill" }; + return Err(EngineError::Disabled(cmd.into(), format!("the copied paint is a {what}"))); + } + let ids = req_targets(cmd, s, p)?; + // A copied pattern pointing at a target or a layer inside it would make the layer pattern + // itself; `fill.pattern` refuses the same, and the renderer would drop the pattern. + if let figcraft_color::PaintKind::Pattern(pt) = &paint.kind { + let doc = s.doc()?; + if ids.iter().any(|t| doc.is_ancestor(*t, NodeId(pt.source))) { + return Err(bad(cmd, "a layer can't be filled with itself or a layer inside it")); + } + } + let mut q = json!({"ids": ids, "paint": serde_json::to_value(paint).map_err(|e| bad(cmd, e.to_string()))?}); + let out = match opt_index(cmd, p, "index")? { + Some(at) => { + q["index"] = json!(at); + paint_set(s, &q, fills)? + } + None => paint_list(s, &q, fills, true)?, + }; + if let Some(stroke) = s.clipboard.stroke.clone().filter(|_| !fills) { + for id in ids { + s.doc_mut()?.get_mut(id)?.stroke = stroke.clone(); + } + } + Ok(out) +} + +pub(super) fn pattern_fill(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "fill.pattern"; + let src = req_id(CMD, p, "source")?; + let doc = s.doc()?; + if doc.node(src).is_none_or(|n| n.is_page()) { + return Err(bad(CMD, "`source` must be a layer")); + } + if req_targets(CMD, s, p)?.iter().any(|t| doc.is_ancestor(*t, src)) { + return Err(bad(CMD, "a layer can't be filled with itself or a layer inside it")); + } + let mut pt = figcraft_color::PatternPaint::new(src.0); + pt.tile = parse(CMD, p, "tile")?.unwrap_or_default(); + pt.align = parse(CMD, p, "align")?.unwrap_or_default(); + pt.spacing = parse(CMD, p, "spacing")?.unwrap_or_default(); + let (lo, hi) = figcraft_color::PatternPaint::SCALE_RANGE; + pt.scale = num(p, "scale").filter(|v| v.is_finite()).unwrap_or(1.0).clamp(lo, hi); + let mut q = if p.is_object() { p.clone() } else { json!({}) }; + q["paint"] = + serde_json::to_value(figcraft_color::Paint { kind: figcraft_color::PaintKind::Pattern(pt), ..figcraft_color::Paint::pattern(src.0) }) + .map_err(|e| bad(CMD, e.to_string()))?; + paint_set(s, &q, true) +} + +pub(super) fn effect_add(s: &mut Session, p: &Value) -> Result { + let e: Effect = match crate::cmd::props::effect_from("effect.add", p, "effect")? { + Some(e) => e, + None => { + let ty = string(p, "type").unwrap_or_else(|| "dropShadow".into()); + Effect::of_type(&ty).ok_or_else(|| bad("effect.add", format!("unknown effect type `{ty}`")))? + } + }; + for i in req_targets("effect.add", s, p)? { + s.doc_mut()?.get_mut(i)?.effects.push(e.clone()); + } + ok() +} + +/// Copy effect `index` (the top one by default) of the first target into the clipboard. +pub(super) fn effect_copy(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.copy"; + let id = targets(s, p).first().copied().ok_or_else(|| bad(CMD, "select a layer"))?; + let at = opt_index(CMD, p, "index")?; + let e = { + let doc = s.doc()?; + let list = &doc.get(id)?.effects; + let at = at.unwrap_or(list.len().saturating_sub(1)); + list.get(at).cloned().ok_or_else(|| bad(CMD, format!("no effect at index {at}")))? + }; + s.clipboard.effect = Some(e.clone()); + serde_json::to_value(&e).map_err(|err| bad(CMD, err.to_string())) +} + +/// Paste the copied effect onto the targets: replacing effect `index` there, or adding one. +pub(super) fn effect_paste(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "effect.paste"; + let Some(e) = s.clipboard.effect.clone() else { + return Err(EngineError::Disabled(CMD.into(), "no copied effect".into())); + }; + let at = opt_index(CMD, p, "index")?; + for id in req_targets(CMD, s, p)? { + let n = s.doc_mut()?.get_mut(id)?; + match at { + Some(i) => { + if let Some(slot) = n.effects.get_mut(i) { + *slot = e.clone(); + } + } + None => n.effects.push(e.clone()), + } + } + ok() +} diff --git a/crates/engine/src/cmd/edit/select.rs b/crates/engine/src/cmd/edit/select.rs new file mode 100644 index 00000000..03bba890 --- /dev/null +++ b/crates/engine/src/cmd/edit/select.rs @@ -0,0 +1,267 @@ +use super::*; + +pub(super) fn layers_at(s: &mut Session, p: &Value) -> Result { + let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?); + let doc = s.doc()?; + let zoom = s.ds()?.view.zoom.max(1e-6); + let sel = s.selection(); + let result = crate::booleans::result_outline(doc, &sel); + let layers: Vec = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result)) + .into_iter() + .filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth}))) + .collect(); + Ok(json!({"layers": layers})) +} + +pub(super) fn select_all(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + // Select all siblings of the current selection, else the page's top level. + let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); + let ids: Vec = doc.get(parent)?.children.iter().copied().filter(|c| doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +/// What Select all would select, less the selection (hidden and locked layers neither). +pub(super) fn select_inverse(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + let parent = sel.first().and_then(|i| doc.node(*i)).and_then(|n| n.parent).unwrap_or(s.page()?); + let ids: Vec = + doc.get(parent)?.children.iter().copied().filter(|c| !sel.contains(c) && doc.node(*c).is_some_and(|n| n.visible && !n.locked)).collect(); + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +/// `select.area`: what a marquee from (x, y) over the box selects; no box asks the app for one. +pub(super) fn select_area(s: &mut Session, p: &Value) -> Result { + let cmd = "select.area"; + let keys = ["x", "y", "width", "height"]; + if keys.iter().all(|k| p.get(*k).is_none()) { + s.request_ui("selectArea", json!({})); + return ok(); + } + let [x, y, w, h] = keys.map(|k| num(p, k)); + let (Some(x), Some(y), Some(w), Some(h)) = (x, y, w, h) else { return Err(bad(cmd, "pass x, y, width and height (numbers)")) }; + if ![x, y, w, h].iter().all(|v| v.is_finite() && v.abs() < 1e9) { + return Err(bad(cmd, "the box is out of range")); + } + let start = figcraft_geom::Point::new(x, y); + let r = figcraft_geom::Rect::from_points(start, figcraft_geom::Point::new(x + w, y + h)); + let add = boolean(p, "add") == Some(true); + let base = if add { s.selection() } else { Vec::new() }; + let within = s.marquee_within(start)?; + let sel = s.marquee_selection(r, within, boolean(p, "deep") == Some(true), &base, add)?; + s.set_selection(sel.clone()); + Ok(json!({"selection": sel})) +} + +pub(super) fn select_children(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let mut ids = Vec::new(); + for i in s.selection() { + if let Some(n) = doc.node(i) { + ids.extend(n.children.iter().copied()); + } + } + if ids.is_empty() { + return Ok(json!({"selection": s.selection()})); + } + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +pub(super) fn select_parent(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let mut ids: Vec = Vec::new(); + for i in s.selection() { + if let Some(p) = doc.node(i).and_then(|n| n.parent) + && !doc.node(p).is_some_and(Node::is_page) + && !ids.contains(&p) + { + ids.push(p); + } + } + if ids.is_empty() { + return Ok(json!({"selection": s.selection()})); + } + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +pub(super) fn select_sibling(s: &mut Session, dir: i64) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + let Some(cur) = sel.last().copied() else { + let first = doc.get(s.page()?)?.children.last().copied(); + s.set_selection(first.into_iter().collect()); + return Ok(json!({"selection": s.selection()})); + }; + let parent = doc.get(cur)?.parent.ok_or_else(|| bad("select.next", "no parent"))?; + let kids = &doc.get(parent)?.children; + let idx = kids.iter().position(|c| *c == cur).unwrap_or(0) as i64; + let n = kids.len() as i64; + // Layers panel order is reversed (top of the list = last child): Tab goes down the list. + let next = (idx - dir).rem_euclid(n.max(1)) as usize; + let id = kids.get(next).copied().unwrap_or(cur); + s.set_selection(vec![id]); + Ok(json!({"selection": [id]})) +} + +pub(super) fn select_matching(s: &mut Session, _p: &Value) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + let Some(first) = sel.first().and_then(|i| doc.node(*i)) else { return Ok(json!({"selection": []})) }; + let (name, ty) = (first.name.clone(), first.kind.type_name()); + let page = s.page()?; + let ids: Vec = + doc.subtree(page).into_iter().filter(|i| doc.node(*i).is_some_and(|n| n.name == name && n.kind.type_name() == ty)).collect(); + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +pub(super) fn select_same(s: &mut Session, same: impl Fn(&Node, &Node) -> bool) -> Result { + let doc = s.doc()?; + let sel = s.selection(); + let Some(first) = sel.first().and_then(|i| doc.node(*i)).cloned() else { return Ok(json!({"selection": []})) }; + let page = s.page()?; + let ids: Vec = doc.subtree(page).into_iter().filter(|i| *i != page && doc.node(*i).is_some_and(|n| same(&first, n))).collect(); + s.set_selection(ids.clone()); + Ok(json!({"selection": ids})) +} + +/// The opacity number keys give: one digit is tens of percent (0 is 100%), two digits typed in +/// quick succession the exact percentage. +pub fn opacity_from_digits(digits: &str) -> Option { + let mut n = 0u32; + let mut count = 0; + for c in digits.chars() { + // Input-derived: a number that outgrows `u32` stops the scan (None); it never wraps. + n = n.checked_mul(10)?.checked_add(c.to_digit(10)?)?; + count += 1; + } + match count { + 1 if n == 0 => Some(1.0), + 1 => Some(n as f32 / 10.0), + 2 => Some(n as f32 / 100.0), + _ => None, + } +} + +pub(super) fn opacity_keys(s: &mut Session, p: &Value) -> Result { + let digits = req_string("object.opacityKeys", p, "digits")?; + let o = opacity_from_digits(&digits).ok_or_else(|| bad("object.opacityKeys", "`digits` must be one or two digits"))?; + let ids = req_targets("object.opacityKeys", s, p)?; + let doc = s.doc_mut()?; + let before = crate::cmd::styles::bound_before(doc, &ids); + for i in ids { + doc.get_mut(i)?.opacity = o; + } + crate::cmd::styles::detach_edited(doc, &before); + Ok(json!({"opacity": o})) +} + +pub(super) fn nudge(s: &mut Session, p: &Value) -> Result { + let big = boolean(p, "big").unwrap_or(false); + let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; + let (ux, uy) = (num_or(p, "dx", 0.0), num_or(p, "dy", 0.0)); + let (dx, dy) = (ux * k, uy * k); + let ids = req_targets("transform.nudge", s, p)?; + // Edit a copy and keep it only if something moved, so an arrow that can't move anything adds + // no undo step. + let mut work = s.doc()?.clone(); + let doc = &mut work; + let ids = roots(doc, &ids); + let mut changed = false; + // Children in an auto layout flow move one place instead (see `al_keys`). + for i in &ids { + if crate::al_keys::flowing_parent(doc, *i).is_none() && (dx, dy) != (0.0, 0.0) { + let n = doc.get_mut(*i)?; + n.transform = Affine::translate((dx, dy)) * n.transform; + changed = true; + } + } + for dir in crate::al_keys::Dir::from_units(ux, uy) { + // Grouped per arrow: a move into a neighbouring frame changes the parent. + let mut groups: BTreeMap> = BTreeMap::new(); + for i in &ids { + if let Some(parent) = crate::al_keys::flowing_parent(doc, *i) { + groups.entry(parent).or_default().push(*i); + } + } + for (parent, block) in groups { + changed |= crate::al_keys::arrow_move(doc, parent, &block, dir)?; + } + } + if changed { + s.ds_mut()?.doc = std::sync::Arc::new(work); + } + ok() +} + +pub(super) fn nudge_size(s: &mut Session, p: &Value) -> Result { + let big = boolean(p, "big").unwrap_or(false); + let k = if big { s.prefs.nudge_big } else { s.prefs.nudge_small }; + let (dw, dh) = (num_or(p, "dw", 0.0) * k, num_or(p, "dh", 0.0) * k); + let ids = req_targets("transform.nudgeSize", s, p)?; + let ids = roots(s.doc()?, &ids); + for i in ids { + let Some((w, h)) = s.doc()?.node(i).map(|n| (n.width, n.height)) else { continue }; + let mut q = serde_json::Map::new(); + q.insert("ids".into(), json!([i])); + // Only the axis that changes becomes fixed. + if dw != 0.0 { + q.insert("width".into(), json!((w + dw).max(0.0))); + } + if dh != 0.0 { + q.insert("height".into(), json!((h + dh).max(0.0))); + } + crate::cmd::arrange::resize_cmd(s, &Value::Object(q))?; + } + ok() +} + +pub(super) fn toggle(s: &mut Session, p: &Value, what: &str) -> Result { + let ids = req_targets("object.toggle", s, p)?; + let doc = s.doc_mut()?; + let explicit = boolean(p, what); + // Figma: if any is visible, hide all (and vice versa). + let value = explicit.unwrap_or_else(|| match what { + "visible" => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.visible)), + _ => !ids.iter().all(|i| doc.node(*i).is_some_and(|n| n.locked)), + }); + for i in &ids { + let n = doc.get_mut(*i)?; + if what == "visible" { + n.visible = value; + } else { + n.locked = value; + } + } + if what == "locked" && value { + s.set_selection(vec![]); + } + Ok(json!({what: value})) +} + +pub(super) fn hide_others(s: &mut Session, _p: &Value) -> Result { + let sel = s.selection(); + let page = s.page()?; + let doc = s.doc_mut()?; + let keep: BTreeSet = sel + .iter() + .flat_map(|i| { + let mut v = doc.ancestors(*i); + v.push(*i); + v + }) + .collect(); + let tops: Vec = doc.get(page)?.children.clone(); + for t in tops { + if !keep.contains(&t) { + doc.get_mut(t)?.visible = false; + } + } + ok() +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 13eb21d6..fed4bff1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -11,6 +11,36 @@ use super::{CommandSpec, ok}; use crate::params::*; use crate::{DocState, EngineError, Result, Session, bad, spec}; +mod export; +mod load; +mod open; +mod save; +mod version; + +pub(crate) use export::FORBIDDEN_FILE_NAME_CHARS; +#[cfg(test)] +use export::add_member; +pub(super) use export::export_setting; +pub use export::{ + ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes, + frames_pdf, jobs_for, safe_file_name, +}; +use export::{export, export_frames_pdf, export_layers, pdf_bytes}; +#[cfg(test)] +pub(crate) use load::{installed_family, sketch_line}; +pub use load::{load, load_bytes, load_path}; +pub(crate) use load::{load_fig, read_capped, recovered, variables_param, write}; +pub use open::MAX_RECENT; +#[cfg(test)] +use open::append_pages; +use open::{import, open, open_recent}; +pub(crate) use save::save_ends_preview; +pub use save::{FileSnapshot, file_bytes, thumbnail}; +use save::{color_profile, mark_saved, save, save_as}; +pub use version::MAX_AUTOSAVES; +pub(crate) use version::{add_version, keep_in_history, read_version, unique_id}; +use version::{autosave, restore_version, save_version, version_index, version_json, version_list}; + /// A new file of `kind` in a tab of its own. fn new_file(s: &mut Session, p: &Value, kind: FileKind) -> Result { let name = string(p, "name").unwrap_or_else(|| if kind == FileKind::Board { "Untitled board" } else { "Untitled" }.into()); @@ -295,987 +325,6 @@ pub fn specs() -> Vec { ] } -fn version_json(index: usize, v: &Version) -> Value { - let mut j = json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time}); - if v.auto { - j["auto"] = json!(true); - } - j -} - -/// Autosaves kept per file; older ones make room for new ones. -pub const MAX_AUTOSAVES: usize = 20; - -/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version. -fn autosave(s: &mut Session, _: &Value) -> Result { - let auto_save = s.prefs.auto_save; - let ds = s.ds_mut()?; - // A file that saves itself is rarely unsaved: edits since it was opened count then. - let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty()); - if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() { - return Ok(json!({"saved": false})); - } - Ok(match add_version(ds, AUTOSAVE, "", true) { - Some(v) => json!({"saved": true, "version": v}), - None => json!({"saved": false}), - }) -} - -/// What checkpoints are called. -const AUTOSAVE: &str = "Autosave"; - -/// Add a version of `ds`'s document as it is now (`auto`: a checkpoint) and return it. The oldest -/// autosave makes room when the autosaves or the history are full; versions the user named are -/// never dropped, so a history full of them takes nothing (None). -pub(crate) fn add_version(ds: &mut DocState, name: &str, description: &str, auto: bool) -> Option { - let autos = ds.versions.iter().filter(|v| v.auto).count(); - if ((auto && autos >= MAX_AUTOSAVES) || ds.versions.len() >= figcraft_format::MAX_VERSIONS) - && let Some(oldest) = ds.versions.iter().position(|v| v.auto) - { - ds.versions_mut().remove(oldest); - } - if ds.versions.len() >= figcraft_format::MAX_VERSIONS { - return None; - } - let mut v = snapshot_version(ds, name, description); - v.auto = auto; - let out = version_json(ds.versions.len(), &v); - ds.versions_mut().push(v); - ds.mark_checkpoint(); - Some(out) -} - -/// Before the document is replaced (a restore, a merge): keep it in the history as it is, unless -/// it is what the last version saved or restored holds. -pub(crate) fn keep_in_history(ds: &mut DocState) { - if ds.branch_of.is_none() && ds.changed_since_checkpoint() { - add_version(ds, AUTOSAVE, "", true); - } -} - -/// A version of `ds`'s document as it is now, opening on the page shown. -fn snapshot_version(ds: &DocState, name: &str, description: &str) -> Version { - let mut snap = ds.doc.clone(); - if snap.current_page != Some(ds.page) { - Arc::make_mut(&mut snap).current_page = Some(ds.page); - } - Version::new(unique_id(|id| ds.versions.iter().any(|v| v.id == id)), name, description, super::comment::now_secs(), snap) -} - -fn version_list(ds: &DocState) -> Value { - let previewing = ds.preview.as_ref().map(|p| p.id.as_str()); - Value::Array( - ds.versions - .iter() - .enumerate() - .map(|(i, v)| { - let mut j = version_json(i, v); - if previewing == Some(v.id.as_str()) { - j["previewing"] = json!(true); - } - j - }) - .collect(), - ) -} - -/// The version named by `id`, else by `index` (validated: never negative, never past the end). -fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result { - if let Some(id) = string(p, "id") { - return ds.versions.iter().position(|v| v.id == id).ok_or_else(|| bad(cmd, format!("no version `{id}`"))); - } - if get(p, "index").is_none() { - return Err(bad(cmd, "pass the version's `id` or `index`")); - } - let i = req_index(cmd, p, "index")?; - if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) } -} - -pub(crate) fn read_version(v: &Version) -> Result> { - v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name))) -} - -/// A new version or branch id: the time in milliseconds (hex), not `taken`. -pub(crate) fn unique_id(taken: impl Fn(&str) -> bool) -> String { - let mut n = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); - loop { - let id = format!("{n:x}"); - if !taken(&id) { - return id; - } - n = n.wrapping_add(1); - } -} - -/// `file.saveVersion`; on a branch tab the version goes into the branch's own history. -fn save_version(s: &mut Session, p: &Value) -> Result { - let ds = s.ds_mut()?; - if ds.versions.len() >= figcraft_format::MAX_VERSIONS { - return Err(EngineError::Disabled( - "file.saveVersion".into(), - format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), - )); - } - let name = - string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Version {}", ds.versions.len() + 1)); - let description = string(p, "description").unwrap_or_default(); - // The version opens on the page shown when it was saved. - add_version(ds, &name, description.trim(), false).ok_or_else(|| { - EngineError::Disabled( - "file.saveVersion".into(), - format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), - ) - }) -} - -fn restore_version(s: &mut Session, p: &Value) -> Result { - const CMD: &str = "version.restore"; - // Refused before anything goes into the history. - if let Some(why) = s.live_read_only(s.ds()?.uid) { - return Err(EngineError::Disabled(CMD.into(), why.into())); - } - let ds = s.ds_mut()?; - let i = version_index(CMD, ds, p)?; - let v = ds.versions.get(i).ok_or_else(|| bad(CMD, "no such version"))?; - let (id, mut doc) = (v.id.clone(), read_version(v)?); - // Restoring never loses work: the document as it is goes into the history first. - keep_in_history(ds); - let out = ds.versions.iter().enumerate().find(|(_, v)| v.id == id).map_or(Value::Null, |(i, v)| version_json(i, v)); - let d = Arc::make_mut(&mut doc); - d.name = ds.doc.name.clone(); - // What the file published belongs to the file, not to the version. - d.published = ds.doc.published.clone(); - // Comments are a conversation about the file, kept apart from its design history: they stay, - // and those on layers the version doesn't have stay where they were. - d.comments = ds.doc.comments.clone(); - super::comment::follow_anchors(d, &ds.doc); - // The snapshot is already consistent: nothing to re-normalise. - d.take_dirty(); - // Stay on the current page when the version has it. - ds.page = Some(ds.page) - .filter(|pg| d.pages.contains(pg)) - .or_else(|| d.current_page.filter(|pg| d.pages.contains(pg))) - .or_else(|| d.first_page()) - .unwrap_or(ds.page); - ds.doc = doc; - ds.selection.clear(); - ds.text_edit = None; - ds.vector_edit = None; - // The restored design is in the history already: restoring it again keeps nothing new. - ds.mark_checkpoint(); - Ok(out) -} - -/// The recovery copy at `path` as a document with unsaved changes and no path. -pub(crate) fn recovered(path: &str) -> Result { - let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?; - Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved()) -} - -#[cfg(not(target_arch = "wasm32"))] -fn read(path: &str) -> Result> { - crate::file_access::read(path) -} - -#[cfg(target_arch = "wasm32")] -fn read(path: &str) -> Result> { - Err(EngineError::Other(format!("{path}: no file system in the browser"))) -} - -/// The bytes of file `path`, refused when it's larger than `max`. -pub(crate) fn read_capped(path: &str, max: u64) -> Result> { - crate::file_access::read_capped(path, max) -} - -/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one -/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never -/// truncates `path`, and no file or symlink at a staging name is ever written through. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { - crate::file_access::write(path, bytes) -} - -#[cfg(target_arch = "wasm32")] -pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> { - Err(EngineError::Other(format!("{path}: no file system in the browser"))) -} - -fn ext(path: &str) -> String { - std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() -} - -fn file_stem(path: &str) -> String { - std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string() -} - -/// The counts the import report shows for a freshly loaded document. -fn import_summary(doc: &Document) -> Value { - json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()}) -} - -/// Load a document from any supported file (REST API file JSON images come from an `images` -/// folder next to the file). -pub fn load(path: &str) -> Result<(Document, Vec)> { - load_path(path, None).map(|(l, _)| (l.doc, l.issues)) -} - -pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec)> { - load_with(bytes, name_hint, None, &figcraft_format::rest::no_images).map(|(l, _)| (l.doc, l.issues)) -} - -/// [`load`] with the version history, also telling whether the file was REST API file JSON. A REST -/// import reads its variables from `variables`, else from a variables JSON next to the file. -pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_format::Loaded, bool)> { - let bytes = read(path)?; - let sibling = if variables.is_none() { sibling_variables(path) } else { None }; - load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path)) -} - -/// Load from bytes. Returns the document with its notes (repairs of a native file, or import -/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig` -/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON. -fn load_with( - bytes: &[u8], - name_hint: &str, - variables: Option<&Value>, - images: figcraft_format::rest::ImageResolver, -) -> Result<(figcraft_format::Loaded, bool)> { - match ext(name_hint).as_str() { - "svg" => { - let mut doc = Document::new(file_stem(name_hint)); - let page = doc.first_page().ok_or(EngineError::NoDocument)?; - let notes = figcraft_svg::import_svg_with_notes(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint)) - .map_err(|e| EngineError::Other(e.to_string()))? - .notes; - Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false)) - } - "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => { - let mut doc = Document::new(file_stem(name_hint)); - let page = doc.first_page().ok_or(EngineError::NoDocument)?; - let (hash, w, h) = figcraft_format::add_image(&mut doc, bytes.to_vec()).map_err(|e| EngineError::Other(e.to_string()))?; - let id = doc.create(page, NodeKind::Rectangle, &file_stem(name_hint), 0.0, 0.0, f64::from(w), f64::from(h))?; - doc.get_mut(id)?.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; - Ok((doc.into(), false)) - } - _ => { - if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { - return Ok((r?, true)); - } - if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) { - return Ok((r?, true)); - } - if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { - let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; - link_instances(&mut doc); - return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); - } - let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; - // Older files saved an untouched default title even after the user chose a filename. - if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" { - loaded.doc.name = file_stem(name_hint); - } - Ok((loaded, false)) - } - } -} - -/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try. -fn load_sketch(bytes: &[u8], name: &str) -> Option> { - if !figcraft_format::sketch::is_sketch(bytes) { - return None; - } - // The import and what follows read the installed fonts: the system's must be known first (the - // desktop app scans them in the background; this waits for that scan). - figcraft_text::fontdb::scan_system_fonts(); - let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face); - let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?; - Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { - match_font_families(&mut doc); - platform_tracking(&mut doc); - sketch_line_heights(&mut doc); - link_instances(&mut doc); - reflow_overridden_text(&mut doc); - figcraft_format::Loaded { issues: warnings, ..doc.into() } - })) -} - -/// Instances of a stack sized apart from their main are laid out at their size, and texts in -/// instances whose string an override changed are measured again with the auto layout around them -/// re-flowing (Sketch saves layouts measured with the master's string). The rest keeps the layout -/// Sketch saved. -fn reflow_overridden_text(doc: &mut Document) { - let changed: Vec = doc - .nodes() - .filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) { - (NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters, - _ => false, - }) - .map(|n| n.id) - .collect(); - let m = crate::measure::TextMeasure; - // Instances of a stack at another size than their main: the stack at their size, innermost first. - let mut resized: Vec<(usize, NodeId)> = doc - .nodes() - .filter(|n| n.layout.is_some()) - .filter(|n| match &n.kind { - NodeKind::Instance { component, .. } => { - doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6) - } - _ => false, - }) - .map(|n| (doc.ancestors(n.id).len(), n.id)) - .collect(); - resized.sort_by_key(|r| std::cmp::Reverse(r.0)); - for (_, id) in resized { - figcraft_layout::layout_frame(doc, id, &m); - } - for id in changed { - if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { - figcraft_layout::set_size(doc, id, w, h); - } - figcraft_layout::layout_up(doc, id, &m); - } -} - -/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from -/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and -/// case. -fn match_font_families(doc: &mut Document) { - let installed = figcraft_text::fontdb::family_names(); - let mut cache: std::collections::BTreeMap> = std::collections::BTreeMap::new(); - let mut fix = |style: &mut figcraft_doc::TextStyle| { - let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed)); - if let Some(f) = found { - style.font_family = f.clone(); - } - }; - let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); - for id in ids { - if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { - fix(&mut td.style); - for r in &mut td.runs { - fix(&mut r.style); - } - } - } - // Shared text styles too, so they stay like the layers linked to them. - for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { - fix(style); - } - } -} - -/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine -/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so -/// lines measure and break as they did there. A named style's optical size follows the text size -/// there too (FigCraft keeps the instance's), so the imported text carries it as an `opsz` value. -fn platform_tracking(doc: &mut Document) { - let mut cache: std::collections::BTreeMap<(String, String), Option>> = - std::collections::BTreeMap::new(); - let mut face = |style: &figcraft_doc::TextStyle| { - cache - .entry((style.font_family.clone(), style.font_style.clone())) - .or_insert_with(|| { - figcraft_text::fontdb::has_family(&style.font_family) - .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) - .flatten() - }) - .clone() - }; - let mut track = |style: &mut figcraft_doc::TextStyle| { - let Some(f) = face(style) else { return }; - let t = f.tracking(style.font_size); - if t != 0.0 && t.is_finite() { - let px = style.letter_spacing.resolve(style.font_size); - style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t); - } - if f.axis("opsz").is_some() && f.instance(&style.font_style).is_some() && style.font_size.is_finite() { - style.variations.entry("opsz".into()).or_insert(style.font_size); - } - }; - let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); - for id in ids { - if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { - track(&mut td.style); - for r in &mut td.runs { - track(&mut r.style); - } - } - } - for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { - track(style); - } - } -} - -/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their -/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this. -const TALL_LINE_FAMILIES: [&str; 11] = - ["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"]; - -/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its -/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch -/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth -/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres -/// the font's own ascent and descent in the line. `(0, 0)` without metrics. -pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) { - let k = size / upem; - let (a, d) = (ascent * k, descent * k); - if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 { - return (0.0, 0.0); - } - let natural = a.round() + d.round(); - let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural }; - let sketch_baseline = line - d.round(); - let figcraft_baseline = (line - (a + d)) / 2.0 + a; - (line, sketch_baseline - figcraft_baseline) -} - -/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line, -/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a -/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text -/// styles take the line too. -fn sketch_line_heights(doc: &mut Document) { - // Per family and style: the face's ascent, descent and units per em. - type Metrics = Option<(f64, f64, f64)>; - let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new(); - let mut set = |style: &mut TextStyle| -> f64 { - if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 { - return 0.0; - } - let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| { - figcraft_text::fontdb::has_family(&style.font_family) - .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) - .flatten() - .map(|f| (f.ascent, f.descent, f.upem)) - }); - let Some((a, d, upem)) = m else { return 0.0 }; - let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size); - if line > 0.0 { - style.line_height = LineHeight::Percent(line / style.font_size * 100.0); - } - down - }; - let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); - for id in ids { - // An auto layout places its texts itself (its next pass would take a move back). - let laid_out = - doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); - let Some(n) = doc.node_mut(id) else { continue }; - let NodeKind::Text(td) = &mut n.kind else { continue }; - let down = set(&mut td.style); - for r in &mut td.runs { - set(&mut r.style); - } - // Down in the layer's own frame (a rotated text moves along its own axis). - if !laid_out && down.is_finite() && down != 0.0 { - n.transform *= figcraft_doc::geom::Affine::translate((0.0, down)); - } - } - for st in &mut doc.styles { - if let StyleKind::Text { style } = &mut st.kind { - set(style); - } - } -} - -/// The installed family `family` means when it isn't installed under that exact name: the one -/// whose name is the same without spaces, ignoring case. -pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { - let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::(); - if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) { - return None; - } - let want = key(family); - installed.iter().find(|f| key(f) == want).cloned() -} - -/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. -pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { - let r = figcraft_format::fig::try_import(bytes, name)?; - Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { - link_instances(&mut doc); - figcraft_format::Loaded { issues: warnings, ..doc.into() } - })) -} - -/// Link imported instances to their mains (layer sources, overrides found by comparing values), -/// innermost first so outer instances settle last. -fn link_instances(doc: &mut Document) { - super::styles::unfold_imported_bound_alpha(doc); - crate::style_vars::after_load(doc); - super::styles::apply_paint_bindings_on_load(doc); - let mut insts: Vec<(usize, NodeId)> = - doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect(); - insts.sort_by_key(|a| std::cmp::Reverse(a.0)); - for (_, i) in insts { - crate::components::rebase_instance(doc, i); - } -} - -/// Largest image file read for a REST API file JSON import. -#[cfg(not(target_arch = "wasm32"))] -const MAX_IMAGE_FILE: u64 = 256 * 1024 * 1024; - -/// Image bytes for a REST API file JSON's `imageRef`: `images/[.png|.jpg|…]` next to -/// the file. -#[cfg(not(target_arch = "wasm32"))] -fn rest_images(path: &str) -> impl Fn(&str) -> Option> { - let dir = std::path::Path::new(path).parent().map(|d| d.join("images")); - move |r: &str| { - // Image refs are content hashes; anything else never becomes a path. - if r.is_empty() || r.len() > 128 || !r.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { - return None; - } - let dir = dir.as_ref()?; - ["", ".png", ".jpg", ".jpeg", ".gif", ".webp"] - .iter() - .find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok()) - } -} - -#[cfg(target_arch = "wasm32")] -fn rest_images(_path: &str) -> impl Fn(&str) -> Option> { - figcraft_format::rest::no_images -} - -/// Largest variables JSON read from disk. -#[cfg(not(target_arch = "wasm32"))] -const MAX_VARIABLES_FILE: u64 = 256 * 1024 * 1024; - -/// A local variables JSON next to a REST API file JSON: `.variables.json`, else -/// `variables.json`. -#[cfg(not(target_arch = "wasm32"))] -fn sibling_variables(path: &str) -> Option { - if ext(path) != "json" { - return None; - } - let file = std::path::Path::new(path); - let dir = file.parent()?; - [format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| { - let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?; - figcraft_format::rest::is_variables_json(&v).then_some(v) - }) -} - -#[cfg(target_arch = "wasm32")] -fn sibling_variables(_path: &str) -> Option { - None -} - -/// The local variables JSON a command passes as `variables`: an object, JSON text or a file path. -pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result> { - let parse = |text: &[u8]| serde_json::from_slice::(text).map_err(|e| bad(cmd, format!("variables: {e}"))); - let v = match get(p, "variables") { - None | Some(Value::Null) => return Ok(None), - Some(v @ Value::Object(_)) => v.clone(), - Some(Value::String(s)) if s.trim_start().starts_with('{') => parse(s.as_bytes())?, - Some(Value::String(path)) => parse(&read(path)?)?, - Some(_) => return Err(bad(cmd, "variables: the local variables JSON (an object, JSON text or a path)")), - }; - if !figcraft_format::rest::is_variables_json(&v) { - return Err(bad(cmd, "variables: expected the local variables JSON (`meta.variableCollections` and `meta.variables`)")); - } - Ok(Some(v)) -} - -/// How many files File ▸ Open recent keeps. -pub const MAX_RECENT: usize = 20; - -/// Put `path` at the top of the recent files. -fn remember(s: &mut Session, path: &str) { - let r = &mut s.prefs.recent_files; - r.retain(|p| p != path); - r.insert(0, path.to_string()); - r.truncate(MAX_RECENT); -} - -fn open_recent(s: &mut Session, p: &Value) -> Result { - use crate::file_access::{Access, allow, allowed}; - const CMD: &str = "file.openRecent"; - // Confined to automation roots, the recent files outside them are never listed, opened, removed - // or named: `index` counts the files listed. - let listed = |r: &String| allowed(r, Access::Read); - if p.get("clear").and_then(Value::as_bool) == Some(true) { - s.prefs.recent_files.retain(|r| !listed(r)); - } - if let Some(gone) = string(p, "remove") { - s.prefs.recent_files.retain(|r| *r != gone || !listed(r)); - } - let path = match (string(p, "path"), num(p, "index")) { - (Some(path), _) => { - allow(&path, Access::Read)?; - Some(path) - } - (None, Some(i)) => { - Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?) - } - _ => None, - }; - if let Some(path) = path { - if !crate::file_access::exists(&path) { - s.prefs.recent_files.retain(|r| *r != path); - return Err(bad(CMD, format!("{path} no longer exists"))); - } - return open(s, &json!({"path": path})); - } - let files: Vec = s - .prefs - .recent_files - .iter() - .filter(|r| listed(r)) - .map(|r| { - let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned()); - json!({"path": r, "name": name, "exists": crate::file_access::exists(r)}) - }) - .collect(); - Ok(json!({"files": files})) -} - -fn open(s: &mut Session, p: &Value) -> Result { - let variables = variables_param("file.open", p)?; - if string(p, "path").is_none() - && let Some(d) = string(p, "data") - { - return open_data(s, &d, p, variables.as_ref()); - } - let path = req_string("file.open", p, "path")?; - // Already open: switch to it. - if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { - s.active = i; - remember(s, &path); - return Ok(json!({"index": i})); - } - // Stamped before reading: a change made during the read makes the next save refuse. - let on_disk = crate::library::stat(&path).ok(); - let (loaded, rest) = load_path(&path, variables.as_ref())?; - remember(s, &path); - let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json"); - let warnings = rest || ext(&path) == "svg"; - // A native file that lost something (a damaged image, say): the app says so, not only the result. - if native && !loaded.issues.is_empty() { - s.message = Some(match loaded.issues.as_slice() { - [one] => format!("{}: {one}", file_stem(&path)), - more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]), - }); - } - let summary = import_summary(&loaded.doc); - let images_left_out = if native { loaded.images_past_budget } else { 0 }; - let mut ds = DocState::new(loaded.doc, native.then(|| path.clone())) - .with_versions(loaded.versions) - .with_branches(loaded.branches) - .with_images_left_in_file(&path, images_left_out); - ds.on_disk = on_disk.filter(|_| native); - s.docs.push(ds); - s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active, "summary": summary}); - out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); - Ok(out) -} - -/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells -/// the format. It opens as a new document with no path, so the first save asks where to put it. -fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result { - let name = req_string("file.open", p, "name")?; - let data = data.split_once(',').map_or(data, |(_, b)| b); - let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; - let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; - let warnings = rest || ext(&name) == "svg"; - let summary = import_summary(&loaded.doc); - s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); - s.active = s.docs.len() - 1; - let mut out = json!({"index": s.active, "summary": summary}); - out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); - Ok(out) -} - -/// Thumbnail PNG of the first page's content (for file browsers / the home screen). -pub fn thumbnail(doc: &Document) -> Option> { - let page = doc.first_page()?; - let b = doc.page_content_bounds(page)?; - let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0); - let view = figcraft_render::View { - origin: (b.x0, b.y0), - scale, - width: (b.width() * scale).ceil().max(1.0) as u32, - height: (b.height() * scale).ceil().max(1.0) as u32, - }; - let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?; - figcraft_render::encode_png(&pix).ok() -} - -/// The `.figcraft` file for a document: content, thumbnail and version history. -pub fn file_bytes(ds: &DocState) -> Result> { - FileSnapshot::of(ds).encode() -} - -/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the -/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then -/// [`save`](Self::save) the bytes back on the session's thread. -pub struct FileSnapshot { - uid: u64, - path: Option, - revision: u64, - doc: Arc, - /// The document as last saved when the snapshot was taken: another save since makes it stale. - saved: Arc, - page: NodeId, - versions: Vec, - branches: Vec, -} - -impl FileSnapshot { - pub fn of(ds: &DocState) -> Self { - Self { - uid: ds.uid, - path: ds.path.clone(), - revision: ds.revision, - doc: ds.doc.clone(), - saved: ds.saved_doc.clone(), - page: ds.page, - versions: ds.versions.clone(), - branches: ds.branches.clone(), - } - } - - /// The file's bytes: content, thumbnail and version history. - pub fn encode(&self) -> Result> { - let mut doc = (*self.doc).clone(); - doc.current_page = Some(self.page); - let thumb = thumbnail(&doc); - figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches) - .map_err(|e| EngineError::Other(e.to_string())) - } - - /// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved - /// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document - /// was closed, saved, reverted or given another path since: the bytes are out of date. - pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result { - let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else { - return Ok(false); - }; - let path = self.path.as_deref().ok_or(EngineError::NoDocument)?; - refuse_dropping_images(ds, path)?; - refuse_overwriting_changes(ds, path)?; - write(path, bytes)?; - ds.note_on_disk(); - // The history or branches may have changed since: then they still count as unsaved. - if ds.revision == self.revision { - ds.versions_changed = false; - } - ds.saved_doc = self.doc; - ds.revision += 1; - branch_tabs_saved(s, self.uid); - Ok(true) - } -} - -fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> { - match ds.images_lost_saving_to(path) { - Some(count) => Err(EngineError::Other(format!( - "{} wasn't saved: {count} of its images weren't loaded (together they pass what FigCraft reads from one file), and saving over it would delete them. Save it under another name; this file keeps them.", - file_stem(path) - ))), - None => Ok(()), - } -} - -/// Saving over the tab's own file would lose what another app wrote to it since (#1330). -fn refuse_overwriting_changes(ds: &DocState, path: &str) -> Result<()> { - match (&ds.on_disk, crate::library::stat(path)) { - (Some(was), Ok(now)) if now != *was => Err(EngineError::Other(format!( - "{} wasn't saved: the file changed on disk since it was opened or saved here (another app or figcraft-cli wrote to it), and saving would replace those changes. Revert to saved to load them, or Save as to keep this version as well.", - file_stem(path) - ))), - _ => Ok(()), - } -} - -/// The branch tabs of the file in tab `uid` were saved with it. -fn branch_tabs_saved(s: &mut Session, uid: u64) { - for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { - tab.mark_saved(); - } -} - -fn color_profile(s: &mut Session, p: &Value) -> Result { - let cmd = "file.colorProfile"; - let Some(name) = string(p, "profile") else { - s.request_ui("dialog.colorProfile", json!({})); - return Ok(json!({"profile": s.doc()?.color_profile.id(), "changed": false})); - }; - let profile = figcraft_color::ColorProfile::parse(&name).ok_or_else(|| bad(cmd, "profile: srgb or displayP3"))?; - let convert = boolean(p, "convert").unwrap_or(true); - let changed = s.doc()?.color_profile != profile && s.doc_mut()?.set_color_profile(profile, convert); - Ok(json!({"profile": profile.id(), "changed": changed})) -} - -/// Native saves act on the current document; a `.fig` copy exports the version shown, and a -/// pending Save As choice must leave it shown until its destination is known. -pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { - let path = - string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); - path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p)) -} - -/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download). -fn wants_data(p: &Value) -> bool { - p.get("data").and_then(Value::as_bool) == Some(true) -} - -/// Save the file of the active tab (the main file for a branch tab, with its branches), or export -/// the active document as an experimental `.fig` copy without changing its native save state. -/// Without a path the file comes back as base64 `data` for the caller to deliver (#969). -fn save_to(s: &mut Session, path: Option) -> Result { - if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") { - return export_fig(s, &json!({"path": path})); - } - let i = crate::branches::file_tab(s); - let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; - // A download (no path) writes over no file. - if let Some(path) = path.as_deref() { - refuse_dropping_images(ds, path)?; - } - let mut snapshot = FileSnapshot::of(ds); - let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); - if let Some(path) = path.as_deref() - && follows_path - && ds.path.as_deref() != Some(path) - { - Arc::make_mut(&mut snapshot.doc).name = file_stem(path); - } - let bytes = snapshot.encode()?; - let Some(path) = path else { - // The download can still be refused: the caller marks the file saved once it was delivered. - let name = format!("{}.figcraft", ds.doc.name); - return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); - }; - write(&path, &bytes)?; - ds.doc = snapshot.doc; - ds.path = Some(path.clone()); - ds.note_on_disk(); - mark_saved(s, &json!({}))?; - remember(s, &path); - Ok(json!({"path": path, "bytes": bytes.len()})) -} - -/// The file of the active tab (with its branch tabs) now matches what was written or delivered. -fn mark_saved(s: &mut Session, _: &Value) -> Result { - let i = crate::branches::file_tab(s); - let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; - ds.mark_saved(); - ds.revision += 1; - let uid = ds.uid; - branch_tabs_saved(s, uid); - Ok(json!({"saved": true})) -} - -fn save(s: &mut Session, p: &Value) -> Result { - if let Some(path) = string(p, "path") { - return save_to(s, Some(path)); - } - let ds = s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?; - match ds.path.clone() { - // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. - Some(path) if ext(&path) == "fig" => { - s.request_ui("dialog.saveAs", json!({})); - Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) - } - Some(path) => { - if p.get("overwrite").and_then(Value::as_bool) != Some(true) { - refuse_overwriting_changes(ds, &path)?; - } - save_to(s, Some(path)) - } - None => { - s.request_ui("dialog.saveAs", json!({})); - Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) - } - } -} - -fn save_as(s: &mut Session, p: &Value) -> Result { - match string(p, "path") { - Some(mut path) => { - if ext(&path).is_empty() { - path.push_str(".figcraft"); - } - save_to(s, Some(path)) - } - None if wants_data(p) => save_to(s, None), - None => { - s.request_ui("dialog.saveAs", json!({})); - Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into())) - } - } -} - -/// Encode `ids` (or the page) in `format` at `scale`. -pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result> { - export_bytes_with(doc, ids, &ExportSetting { format, ..Default::default() }, scale) -} - -/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own -/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing, -/// so they take no room next to visible ones (a hidden-only export keeps its own area). -pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option { - let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible)); - ids.iter() - .filter_map(|i| { - let node = doc.node(*i); - let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice)); - if setting.include_bounding_box || slice { - doc.world_bounds(*i) - } else if any_visible && node.is_some_and(|n| !n.visible) { - None - } else { - figcraft_render::export_bounds(doc, *i) - } - }) - .reduce(|a, b| a.union(b)) -} - -/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off) -/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing -/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a -/// slice anywhere else draws every top-level layer of its page. -pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { - let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else { - return super::edit::z_sorted(doc, ids); - }; - if !setting.contents_only { - return page.children.clone(); - } - let mut own = Vec::with_capacity(ids.len()); - for &i in ids { - if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) { - own.push(i); - continue; - } - match slice_container(doc, i) { - Some(c) => own.push(c), - None => return page.children.clone(), - } - } - own.sort_unstable(); - own.dedup(); - super::edit::z_sorted(doc, &super::edit::roots(doc, &own)) -} - -/// The frame or group a slice sits in: None on the page or in a section. -fn slice_container(doc: &Document, slice: NodeId) -> Option { - let parent = doc.node(slice)?.parent?; - doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent) -} - -/// [`export_bytes`] with an export setting's format and options (its size is `scale`: see -/// [`export_scale`]): contents only, include bounding box, the SVG, PDF and Vector Drawable options. -pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result> { - export_file(doc, ids, setting, scale).map(|(bytes, _)| bytes) -} - /// [`export_bytes_with`] and what the export changed or left out (Android Vector Drawable only; /// empty for the other formats). pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result<(Vec, Vec)> { @@ -1336,112 +385,6 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal } } -/// What Copy as PNG puts on the clipboard: the layers together, at 2x (in the document's colour -/// profile; the clipboard wants sRGB). -pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option { - let area = export_area(doc, ids, &ExportSetting::default())?; - figcraft_render::render_area(doc, &super::edit::z_sorted(doc, ids), area, 2.0) -} - -fn export(s: &mut Session, p: &Value) -> Result { - let cmd = "file.export"; - let fig = match string(p, "format") { - Some(f) => f.eq_ignore_ascii_case("fig"), - None => string(p, "path").is_some_and(|pp| ext(&pp) == "fig"), - }; - if fig { - return export_fig(s, p); - } - let doc = s.doc()?; - let mut ids = targets(s, p); - if ids.is_empty() { - ids = doc.get(s.page()?)?.children.clone(); - } - let mut setting = export_setting(cmd, ExportSetting::default(), p)?; - if string(p, "format").is_none() - && let Some(f) = string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp))) - { - setting.format = f; - } - let scale = match size_param(cmd, p)? { - Some(_) => export_scale(doc, &ids, &setting), - None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE), - }; - let format = setting.format; - let (bytes, warnings) = export_file(doc, &ids, &setting, scale)?; - let mut out = match string(p, "path") { - Some(path) => { - write(&path, &bytes)?; - json!({"path": path, "bytes": bytes.len(), "format": format.ext()}) - } - None => json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}), - }; - // Android Vector Drawables say what they changed or left out (an empty list when nothing). - if format == ExportFormat::VectorDrawable { - out["warnings"] = json!(warnings); - } - Ok(out) -} - -/// The whole document as a `.fig` file (experimental: only round trips through our own importer -/// are verified), with the warnings about what was left out. -fn export_fig(s: &mut Session, p: &Value) -> Result { - let doc = s.doc()?; - let (bytes, warnings) = fig_bytes(doc)?; - match string(p, "path") { - Some(path) => { - write(&path, &bytes)?; - Ok(json!({"path": path, "bytes": bytes.len(), "format": "fig", "warnings": warnings})) - } - None => Ok(json!({"format": "fig", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "warnings": warnings})), - } -} - -/// `doc` as a `.fig` file (with a thumbnail) and the warnings about what was left out. -pub fn fig_bytes(doc: &Document) -> Result<(Vec, Vec)> { - figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string())) -} - -/// The scale that gives `ids` (their export area together) `setting`'s export size. -pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 { - // Size-based exports ask for bounds before encoding. Let encoding report the depth error - // without entering a recursive bounds walk here; SVG/PDF do not use this raster scale. - if setting.format.is_vector() && figcraft_render::check_export_depth(doc, ids).is_err() { - return 1.0; - } - export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height())) -} - -/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix` and `format` that `p` sets. -pub(super) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Result { - if let Some(size) = size_param(cmd, p)? { - e.size = size; - } - if let Some(s) = string(p, "suffix") { - e.suffix = s.chars().filter(|c| !c.is_control()).take(200).collect(); - } - if let Some(f) = string(p, "format") { - e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp|xml"))?; - } - if let Some(c) = vd_compat_param(cmd, p)? { - e.vd_compat = c; - } - e.print = print_param(cmd, p, e.print)?; - for (key, slot) in [ - ("contentsOnly", &mut e.contents_only), - ("includeBoundingBox", &mut e.include_bounding_box), - ("svgOutlineText", &mut e.svg_outline_text), - ("svgIdAttribute", &mut e.svg_id_attribute), - ("svgSimplifyStroke", &mut e.svg_simplify_stroke), - ] { - match get(p, key) { - None | Some(Value::Null) => {} - Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, - } - } - Ok(e) -} - /// The `vdCompat` `p` passes: bestEffort, exact or ignore (anything else is an error). fn vd_compat_param(cmd: &str, p: &Value) -> Result> { match get(p, "vdCompat") { @@ -1450,345 +393,6 @@ fn vd_compat_param(cmd: &str, p: &Value) -> Result> { } } -/// Whether `p` sets any PDF print option. -fn has_print_param(p: &Value) -> bool { - ["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null())) -} - -/// `base` with the PDF print options `p` sets: `bleed` (points, or a length like "3mm" or -/// "0.125in"), `cropMarks`, `bleedMarks`, `cmyk`. -pub(super) fn print_param(cmd: &str, p: &Value, mut base: PrintSettings) -> Result { - match get(p, "bleed") { - None | Some(Value::Null) => {} - Some(v) => { - let pt = match v { - Value::String(s) => PrintSettings::parse_length(s), - v => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0), - }; - base.bleed = pt - .filter(|b| *b <= PrintSettings::MAX_BLEED) - .ok_or_else(|| bad(cmd, "bleed: points, or a length like \"3mm\" or \"0.125in\" (up to 144 pt, 2 in)"))?; - } - } - for (key, slot) in [("cropMarks", &mut base.crop_marks), ("bleedMarks", &mut base.bleed_marks), ("cmyk", &mut base.cmyk)] { - match get(p, key) { - None | Some(Value::Null) => {} - Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, - } - } - Ok(base) -} - -/// An export size passed as `size`: "2x", "512w", "512h", a number (scale) or `{by, value}`. -fn size_param(cmd: &str, p: &Value) -> Result> { - let size = match get(p, "size") { - None | Some(Value::Null) => return Ok(None), - Some(Value::String(s)) => ExportSize::parse(s), - Some(Value::Number(n)) => n.as_f64().and_then(|value| ExportSize::Scale { value }.checked()), - Some(v) => serde_json::from_value::(v.clone()).ok().and_then(ExportSize::checked), - }; - size.map(Some).ok_or_else(|| bad(cmd, "size: 2x, 512w or 512h (or {by: scale|width|height, value})")) -} - -/// Most files one `file.exportLayers` writes. -const MAX_EXPORT_FILES: usize = 1000; -/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy -/// are all in memory at once (in the browser too). -const MAX_ZIP_BYTES: usize = 256 << 20; - -/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together. -fn add_member<'a>(members: &mut Vec<(&'a str, Vec)>, name: &'a str, bytes: Vec, limit: usize) -> Result<()> { - let total = members.iter().map(|(_, b)| b.len()).sum::().saturating_add(bytes.len()); - if total > limit { - let mb = limit >> 20; - return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder"))); - } - members.push((name, bytes)); - Ok(()) -} - -/// One file of a layer export: the layer, its setting and the file name. -pub struct ExportJob { - pub id: NodeId, - pub setting: ExportSetting, - pub file: String, -} - -/// The files exporting `ids` with their export settings makes, with unique names (repeats get -/// " 2", " 3"…). Layers without settings make none. -pub fn export_jobs(doc: &Document, ids: &[NodeId]) -> Vec { - jobs_for(ids.iter().filter_map(|i| Some((*i, doc.node(*i)?.exports.clone()))), doc) -} - -/// The files exporting each layer with the settings beside it makes (see [`export_jobs`]). -pub fn jobs_for(items: impl IntoIterator)>, doc: &Document) -> Vec { - let mut used = std::collections::BTreeSet::new(); - let mut out = Vec::new(); - for (id, settings) in items { - let Some(n) = doc.node(id) else { continue }; - let base = export_path(&n.name); - for e in settings { - if out.len() >= MAX_EXPORT_FILES { - return out; - } - let stem = format!("{base}{}", safe_file_name(&e.file_suffix())); - let ext = e.format.ext(); - let file = - (1..).map(|k| if k == 1 { format!("{stem}.{ext}") } else { format!("{stem} {k}.{ext}") }).find(|f| used.insert(f.to_lowercase())); - if let Some(file) = file { - out.push(ExportJob { id, setting: e, file }); - } - } - } - out -} - -/// The file path a layer name exports to, without extension: each `/` makes a folder; `.` and -/// `..` segments are dropped, so it stays inside the export folder. -fn export_path(name: &str) -> String { - let parts: Vec = - name.split('/').map(|seg| safe_file_name(seg).trim().trim_end_matches('.').to_string()).filter(|seg| !seg.is_empty()).take(32).collect(); - if parts.is_empty() { "Untitled".into() } else { parts.join("/") } -} - -/// Characters no file name may contain (path separators and ones file systems reject). -pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|"; - -/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and -/// control characters become `_`); everything else is kept, up to the first 200 characters. -/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before -/// the number and extension it adds). -pub fn safe_file_name(name: &str) -> String { - name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect() -} - -/// Layers on `page` that have export settings (document order). -pub fn exportable_layers(doc: &Document, page: NodeId) -> Vec { - doc.subtree(page).into_iter().skip(1).filter(|i| doc.node(*i).is_some_and(|n| !n.exports.is_empty())).collect() -} - -fn export_layers(s: &mut Session, p: &Value) -> Result { - let cmd = "file.exportLayers"; - let doc = s.doc()?; - let assets = boolean(p, "assets").unwrap_or(false); - let mut ids = targets(s, p); - if ids.is_empty() { - ids = if assets { vec![s.page()?] } else { exportable_layers(doc, s.page()?) }; - } - let jobs = if assets { crate::devinspect::asset_jobs(doc, &ids) } else { export_jobs(doc, &ids) }; - if jobs.is_empty() { - return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); - } - let dir = string(p, "dir"); - // The browser saves one download at a time: several files go as one zip (#969). - let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false); - let mut members = Vec::new(); - // Print options passed here stand in for every PDF setting's own. - let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; - // And a compatibility mode for every Android Vector Drawable. - let compat = vd_compat_param(cmd, p)?; - let mut files = Vec::with_capacity(jobs.len()); - for job in &jobs { - let setting = - ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() }; - let scale = export_scale(doc, &[job.id], &setting); - let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?; - let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()}); - if setting.format == ExportFormat::VectorDrawable { - f["warnings"] = json!(warnings); - } - match &dir { - Some(d) => { - let path = std::path::Path::new(d).join(&job.file); - // A layer name with slashes exports into folders. - if let Some(parent) = path.parent() { - let _ = crate::file_access::create_dir_all(parent); - } - let path = path.to_string_lossy().to_string(); - write(&path, &bytes)?; - f["path"] = json!(path); - } - None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?, - None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), - } - files.push(f); - } - let with_settings: std::collections::BTreeSet = jobs.iter().map(|j| j.id).collect(); - let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}); - if zip { - let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?; - r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive)); - } - Ok(r) -} - -/// A PDF with one page per group of layers (an sRGB document), titled after the file; `area` -/// sizes a single page to that page area instead of the layers' bounds. -fn pdf_bytes(doc: &Document, groups: &[Vec], print: &PrintSettings, area: Option) -> Result> { - let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), print: *print, area, ..Default::default() }; - figcraft_pdf::export_groups(doc, groups, &opts).map_err(|e| EngineError::Other(e.to_string())) -} - -/// Every top-level frame of `page` (reading order) as one PDF page each, and the page count. -pub fn frames_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> Result<(Vec, usize)> { - let mut frames = doc.top_frames(page); - frames.retain(|f| doc.node(*f).is_some_and(|n| n.visible)); - if frames.is_empty() { - return Err(EngineError::Disabled("file.exportFramesPdf".into(), "the page has no visible frames".into())); - } - let groups: Vec> = frames.iter().map(|f| vec![*f]).collect(); - Ok((pdf_bytes(&doc.in_srgb(), &groups, print, None)?, frames.len())) -} - -/// Every top-level frame of a page (reading order), one PDF page each. -fn export_frames_pdf(s: &mut Session, p: &Value) -> Result { - let doc = s.doc()?; - let page = match id(p, "page") { - Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg, - Some(_) => return Err(bad("file.exportFramesPdf", "`page` is not a page")), - None => s.page()?, - }; - let print = print_param("file.exportFramesPdf", p, PrintSettings::default())?; - let (bytes, pages) = frames_pdf(doc, page, &print)?; - match string(p, "path") { - Some(path) => { - write(&path, &bytes)?; - Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) - } - None => Ok(json!({"format": "pdf", "pages": pages, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})), - } -} - -fn import(s: &mut Session, p: &Value) -> Result { - let path = string(p, "path"); - let variables = variables_param("file.import", p)?; - let (bytes, name) = if let Some(path) = &path { - (read(path)?, path.clone()) - } else if let Some(d) = string(p, "data") { - let d = d.split_once(',').map(|(_, b)| b.to_string()).unwrap_or(d); - ( - base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.import", e.to_string()))?, - string(p, "name").unwrap_or_else(|| "import.png".into()), - ) - } else { - return Err(bad("file.import", "pass `path` or `data`")); - }; - let page = s.page()?; - let (x, y) = (num(p, "x"), num(p, "y")); - let center = s.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); - match ext(&name).as_str() { - "svg" => { - let doc = s.doc_mut()?; - let (x, y) = (x.unwrap_or(center.0), y.unwrap_or(center.1)); - let imported = - figcraft_svg::import_svg_with_notes(doc, &bytes, page, x, y, &file_stem(&name)).map_err(|e| EngineError::Other(e.to_string()))?; - let id = imported.frame; - // Centred on the point (a paste lands in the middle of the view). - if boolean(p, "center").unwrap_or(false) - && let Some(n) = doc.node_mut(id) - { - n.transform = figcraft_geom::Affine::translate(((x - n.width / 2.0).round(), (y - n.height / 2.0).round())); - } - s.set_selection(vec![id]); - let mut out = json!({"id": id}); - if !imported.notes.is_empty() { - out["warnings"] = json!(imported.notes); - } - Ok(out) - } - "figcraft" | "json" | "fig" | "sketch" => { - let resolver = path.as_deref().map(rest_images); - let images: figcraft_format::rest::ImageResolver = match &resolver { - Some(r) => r, - None => &figcraft_format::rest::no_images, - }; - let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None }; - let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?; - let summary = import_summary(&other.doc); - let pages = append_pages(s.doc_mut()?, &other.doc)?; - let mut out = json!({"pages": pages, "summary": summary}); - if rest { - out["warnings"] = json!(other.issues); - } - Ok(out) - } - _ => { - let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); - super::create::place_image(s, &json!({"data": b64, "x": x.unwrap_or(center.0), "y": y.unwrap_or(center.1), "name": file_stem(&name)})) - } - } -} - -/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to -/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers -/// their source and interactions and flows their targets wherever those are. Styles, variables and -/// collections whose id the document already has are kept as they are. -fn append_pages(doc: &mut Document, other: &Document) -> Result> { - for (k, v) in &other.images { - doc.images.entry(k.clone()).or_insert_with(|| v.clone()); - } - for st in &other.styles { - if !doc.styles.iter().any(|s| s.id == st.id) { - doc.styles.push(st.clone()); - } - } - let have: std::collections::BTreeSet = doc.variables.collections.iter().map(|c| c.id.clone()).collect(); - doc.variables.collections.extend(other.variables.collections.iter().filter(|c| !have.contains(&c.id)).cloned()); - let have: std::collections::BTreeSet = doc.variables.variables.iter().map(|v| v.id.clone()).collect(); - doc.variables.variables.extend(other.variables.variables.iter().filter(|v| !have.contains(&v.id)).cloned()); - let mut map = std::collections::BTreeMap::new(); - for pg in &other.pages { - for id in other.subtree(*pg).into_iter().skip(1) { - map.insert(id, doc.alloc_id()); - } - } - // Library copies a .fig import keeps (cached definitions) have no library record to follow: - // the layers of its store come along into the store of `doc`. Recorded copies are left to - // `merge_store`, which shares them with copies `doc` already has. - let cached: Vec = other - .library_store() - .and_then(|s| other.node(s)) - .map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect()) - .unwrap_or_default(); - for root in &cached { - for id in other.subtree(*root) { - map.insert(id, doc.alloc_id()); - } - } - let mut new_pages = Vec::new(); - for pg in &other.pages { - let Some(pn) = other.node(*pg) else { continue }; - let np = doc.add_page(&pn.name); - let mut kind = pn.kind.clone(); - if let NodeKind::Page { flows, .. } = &mut kind { - flows.retain_mut(|f| map.get(&f.start).map(|n| f.start = *n).is_some()); - } - if let Some(n) = doc.node_mut(np) { - n.kind = kind; - } - for c in &pn.children { - let nodes: Vec = other.subtree(*c).into_iter().filter_map(|i| other.node(i).cloned()).collect(); - doc.paste_nodes(&nodes, &map, *c, np, None)?; - } - new_pages.push(np); - } - if !cached.is_empty() { - let store = doc.ensure_library_store(); - for root in &cached { - let nodes: Vec = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect(); - doc.paste_nodes(&nodes, &map, *root, store, None)?; - } - } - for new in map.values() { - let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied()); - if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) { - n.source = Some(src); - } - } - crate::library::merge_store(doc, other, &map)?; - Ok(new_pages) -} - fn close(s: &mut Session, p: &Value) -> Result { let i = if get(p, "index").is_none() { s.active } else { req_index("file.close", p, "index")? }; let Some(ds) = s.docs.get(i) else { return Err(bad("file.close", "no such document")) }; @@ -1807,26 +411,7 @@ fn close(s: &mut Session, p: &Value) -> Result { } #[cfg(test)] -mod tab_order_tests { - use super::*; - - #[test] - fn moving_and_closing_inactive_tabs_preserves_the_active_document() { - let mut session = Session::new(); - for name in ["Two", "Three", "Four"] { - session.execute("file.new", &json!({"name": name})).unwrap(); - } - session.execute("doc.switch", &json!({"index": 2})).unwrap(); - let active_uid = session.docs[session.active].uid; - let moved_uid = session.docs[0].uid; - session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap(); - assert_eq!(session.docs[2].uid, moved_uid); - assert_eq!(session.docs[session.active].uid, active_uid); - session.execute("file.close", &json!({"index": 0})).unwrap(); - assert_eq!(session.docs[session.active].uid, active_uid); - assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err()); - } -} +mod tab_order_tests; /// Ctrl+Tab / Ctrl+Shift+Tab: the next or previous open document, wrapping around. fn cycle_doc(s: &mut Session, dir: i64) -> Result { @@ -1839,331 +424,10 @@ fn cycle_doc(s: &mut Session, dir: i64) -> Result { } #[cfg(test)] -mod rest_import_tests { - use super::*; - - /// A synthetic REST API file JSON: a component with a text layer, an instance of it whose text - /// is overridden, and an image fill. - fn kit() -> Value { - let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]); - let label = |id: &str, text: &str| { - json!({"id": id, "name": "Label", "type": "TEXT", "relativeTransform": at(8.0, 8.0), "size": {"x": 40, "y": 20}, "characters": text, - "style": {"fontFamily": "Inter", "fontSize": 14}, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}) - }; - json!({"name": "Kit", "document": {"id": "0:0", "type": "DOCUMENT", "children": [ - {"id": "0:1", "name": "Main", "type": "CANVAS", "backgroundColor": {"r": 0.9, "g": 0.9, "b": 0.9, "a": 1}, "children": [ - {"id": "1:1", "name": "Button", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 40}, - "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("1:2", "OK")]}, - {"id": "2:1", "name": "Button", "type": "INSTANCE", "componentId": "1:1", "relativeTransform": at(0.0, 100.0), "size": {"x": 100, "y": 40}, - "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("I2:1;1:2", "Go")]}, - {"id": "3:1", "name": "Photo", "type": "RECTANGLE", "relativeTransform": at(200.0, 0.0), "size": {"x": 30, "y": 20}, - "fills": [{"type": "IMAGE", "imageRef": "photo1", "scaleMode": "FILL"}]} - ]} - ]}, "components": {"1:1": {"key": "k", "name": "Button", "description": ""}}, "styles": {}}) - } - - fn instance(doc: &Document) -> &Node { - doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap() - } - - /// What a .fig import with a cached library component leaves: the main in the hidden store, a - /// visible instance of it and a layer linked to the main's layer. - fn cached_import() -> Document { - let mut doc = Document::new("Cached"); - let page = doc.first_page().unwrap(); - let store = doc.ensure_library_store(); - let main = doc - .create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0) - .unwrap(); - let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap(); - let inst = - doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap(); - let mut copy = doc.node(body).unwrap().clone(); - copy.id = doc.alloc_id(); - copy.parent = None; - copy.source = Some(body); - let copy_id = copy.id; - doc.insert(copy, inst, None).unwrap(); - assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body)); - doc - } - - /// Pasting layers that use a cached main into another document leaves the main behind, as it - /// does for other mains that are not published: no link points at a layer of the target. - #[test] - fn pasting_instances_of_cached_components_leaves_no_dangling_links() { - let src = cached_import(); - let mut doc = Document::new("Target"); - let page = doc.first_page().unwrap(); - for i in 0..8 { - doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); - } - let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id; - let ids = src.subtree(inst); - let map: std::collections::BTreeMap = ids.iter().map(|i| (*i, doc.alloc_id())).collect(); - let nodes: Vec = ids.iter().filter_map(|i| src.node(*i).cloned()).collect(); - doc.paste_nodes(&nodes, &map, inst, page, None).unwrap(); - crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap(); - let pasted = doc.node(map[&inst]).unwrap(); - let NodeKind::Instance { component, .. } = pasted.kind else { panic!() }; - assert!(!doc.contains(component), "the main stays behind: {component:?}"); - assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s))); - } - - #[test] - fn appending_pages_with_cached_library_components_brings_the_components() { - let other = cached_import(); - let mut doc = Document::new("Target"); - let page = doc.first_page().unwrap(); - // Ids of the target that would collide with the other document's store. - for i in 0..8 { - doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); - } - append_pages(&mut doc, &other).unwrap(); - assert!(doc.validate().is_empty(), "{:?}", doc.validate()); - assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc)); - let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap(); - let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; - assert_eq!(doc.node(component).unwrap().name, "Cached main"); - assert!(doc.is_remote(component), "the main comes along into the store"); - let layer = doc.node(inst.children[0]).unwrap(); - assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied()); - } - - #[test] - fn rest_json_loads_with_linked_instances() { - let bytes = serde_json::to_vec(&kit()).unwrap(); - let (doc, warnings) = load_bytes(&bytes, "kit.json").unwrap(); - assert_eq!(doc.name, "Kit"); - assert!(warnings.iter().any(|w| w.contains("placeholder")), "{warnings:?}"); - let inst = instance(&doc); - let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; - assert_eq!(inst.source, Some(component)); - let main_label = doc.node(component).unwrap().children[0]; - let label = doc.node(inst.children[0]).unwrap(); - assert_eq!(label.source, Some(main_label)); - assert!(label.overrides.contains("characters"), "{:?}", label.overrides); - assert!(crate::components::mirror_issues(&doc).is_empty()); - assert!(thumbnail(&doc).is_some()); - } - - #[test] - fn open_takes_data_and_a_name_as_a_new_unsaved_document() { - let mut saved = Session::new(); - saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - let bytes = file_bytes(saved.ds().unwrap()).unwrap(); - let data = base64::engine::general_purpose::STANDARD.encode(&bytes); - let mut s = Session::new(); - let recent = s.prefs.recent_files.clone(); - let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap(); - let open = s.docs.len(); - assert_eq!(r["index"], open - 1, "a document of its own, made active"); - assert_eq!(s.ds().unwrap().path, None, "no path to save over"); - assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle))); - assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); - assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format"); - assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err()); - assert_eq!(s.docs.len(), open, "a refused open adds nothing"); - } - - #[test] - fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { - // The browser has no paths: Save as returns the file for a download (#969). - let mut s = Session::new(); - s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); - assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path"); - let recent = s.prefs.recent_files.clone(); - let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); - assert_eq!(r["name"], "Untitled.figcraft"); - assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); - s.execute("file.markSaved", &json!({})).unwrap(); - assert!(!s.ds().unwrap().is_dirty()); - assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); - assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); - let mut t = Session::new(); - t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap(); - assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen"); - } - - #[test] - fn open_and_import_rest_json() { - let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); - std::fs::create_dir_all(dir.join("images")).unwrap(); - let png = { - let mut d = Document::new("x"); - let p = d.first_page().unwrap(); - let id = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 3.0, 2.0).unwrap(); - export_bytes(&d, &[id], ExportFormat::Png, 1.0).unwrap() - }; - std::fs::write(dir.join("images").join("photo1.png"), &png).unwrap(); - let path = dir.join("kit.json"); - std::fs::write(&path, serde_json::to_vec(&kit()).unwrap()).unwrap(); - let path = path.to_string_lossy().to_string(); - - let mut s = Session::new(); - let r = s.execute("file.open", &json!({"path": path})).unwrap(); - assert!(r["warnings"].as_array().is_some_and(|w| w.iter().all(|w| !w.as_str().unwrap_or("").contains("placeholder"))), "{r}"); - assert!(r.get("repairs").is_none()); - assert_eq!(s.ds().unwrap().path, None, "an import is never saved over its JSON"); - let doc = s.doc().unwrap(); - let photo = doc.nodes().find(|n| n.name == "Photo").unwrap(); - let figcraft_color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!() }; - assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((3, 2)), "read from images/ next to the file"); - - // Import into another document: pages are appended and references follow the new ids. - let mut s = Session::new(); - let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&kit()).unwrap()); - let r = s.execute("file.import", &json!({"data": data, "name": "kit.json"})).unwrap(); - assert_eq!(r["pages"].as_array().map(Vec::len), Some(1)); - assert!(r["warnings"].is_array(), "{r}"); - let doc = s.doc().unwrap(); - assert_eq!(doc.pages.len(), 2); - let page = doc.node(doc.pages[1]).unwrap(); - assert!(matches!(page.kind, NodeKind::Page { background, .. } if (background.r - 0.9).abs() < 1e-6)); - let inst = instance(doc); - let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; - assert!(doc.node(component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))); - let label = doc.node(inst.children[0]).unwrap(); - assert!(label.source.is_some_and(|src| doc.is_ancestor(component, src))); - assert!(crate::components::mirror_issues(doc).is_empty()); - let _ = std::fs::remove_dir_all(&dir); - } -} +mod rest_import_tests; #[cfg(test)] -mod staging_write_tests { - use super::*; - - fn dir(name: &str) -> std::path::PathBuf { - let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id())); - std::fs::create_dir_all(&d).unwrap(); - d - } - - /// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). - #[cfg(unix)] - fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { - std::os::unix::fs::symlink(to, from).is_ok() - } - - #[cfg(windows)] - fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { - std::os::windows::fs::symlink_file(to, from).is_ok() - } - - #[test] - fn an_existing_staging_file_is_never_written_through() { - let d = dir("existing"); - let path = d.join("shot.svg"); - // The old deterministic staging name, held by an unrelated file: the write must pick its - // own staging name instead of truncating this one. - let old = d.join("shot.svg.tmp-figcraft"); - std::fs::write(&old, b"not yours").unwrap(); - write(&path.to_string_lossy(), b"").unwrap(); - assert_eq!(std::fs::read(&path).unwrap(), b""); - assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); - // Just the file and the untouched staging file: no new staging leftovers. - assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); - let _ = std::fs::remove_dir_all(&d); - } - - #[test] - fn a_staging_symlink_is_never_followed() { - let d = dir("symlink"); - let path = d.join("shot.svg"); - let victim = d.join("victim.txt"); - std::fs::write(&victim, b"innocent").unwrap(); - let old = d.join("shot.svg.tmp-figcraft"); - if !symlink(&old, &victim) { - return; // Making a symlink needs privileges on some Windows setups. - } - write(&path.to_string_lossy(), b"").unwrap(); - assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); - assert_eq!(std::fs::read(&path).unwrap(), b""); - let _ = std::fs::remove_dir_all(&d); - } - - #[test] - fn concurrent_writers_of_one_file_never_interleave() { - let d = dir("concurrent"); - let path = d.join("shot.svg"); - for round in 0..5u8 { - let mut handles = Vec::new(); - for w in 0u8..4 { - let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]); - handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes))); - } - let mut wins = 0; - for h in handles { - // On Windows a rename over a file another writer just replaced can lose the race. - if h.join().unwrap().is_ok() { - wins += 1; - } - } - assert!(wins > 0, "round {round}: every writer lost"); - // Whatever landed is one writer's complete payload, never a mix. - let got = std::fs::read(&path).unwrap(); - assert_eq!(got.len(), 8192, "round {round}: a complete payload landed"); - assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix"); - } - // No staging file was left stuck (a failed writer removes its own). - let left = std::fs::read_dir(&d).unwrap().count(); - assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory"); - let _ = std::fs::remove_dir_all(&d); - } - - #[test] - fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { - let d = dir("failure"); - // The destination is a directory: the rename fails, our staging file is removed and the - // directory is untouched. - let taken = d.join("over-a-directory"); - std::fs::create_dir_all(&taken).unwrap(); - assert!(write(&taken.to_string_lossy(), b"payload").is_err()); - assert!(taken.is_dir()); - assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); - // A missing parent directory fails without creating anything anywhere. - assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err()); - assert!(!d.join("no").exists()); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - // A read-only directory refuses the staging file, and the old destination stays. - let ro = d.join("read-only"); - std::fs::create_dir_all(&ro).unwrap(); - let dest = ro.join("keep.svg"); - std::fs::write(&dest, b"keep").unwrap(); - let mut perms = std::fs::metadata(&ro).unwrap().permissions(); - perms.set_mode(0o500); - std::fs::set_permissions(&ro, perms).unwrap(); - // Running as root would defeat the read-only directory: skip instead of failing. - let probe = ro.join(".probe"); - if std::fs::write(&probe, b"").is_ok() { - let _ = std::fs::remove_file(&probe); - } else { - assert!(write(&dest.to_string_lossy(), b"payload").is_err()); - assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); - assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); - } - let mut perms = std::fs::metadata(&ro).unwrap().permissions(); - perms.set_mode(0o700); - std::fs::set_permissions(&ro, perms).unwrap(); - } - let _ = std::fs::remove_dir_all(&d); - } -} +mod staging_write_tests; #[cfg(test)] -mod zip_cap_tests { - use super::add_member; - - #[test] - fn zip_members_stop_at_the_limit_and_keep_what_fit() { - let mut members = Vec::new(); - add_member(&mut members, "a.png", vec![0; 6], 10).unwrap(); - add_member(&mut members, "b.png", vec![0; 4], 10).unwrap(); - let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string(); - assert!(err.contains("as one zip"), "{err}"); - assert_eq!(members.iter().map(|(n, _)| *n).collect::>(), ["a.png", "b.png"]); - } -} +mod zip_cap_tests; diff --git a/crates/engine/src/cmd/file/export.rs b/crates/engine/src/cmd/file/export.rs new file mode 100644 index 00000000..005afe6f --- /dev/null +++ b/crates/engine/src/cmd/file/export.rs @@ -0,0 +1,383 @@ +use super::load::{ext, write}; +use super::save::thumbnail; +use super::*; + +/// Encode `ids` (or the page) in `format` at `scale`. +pub fn export_bytes(doc: &Document, ids: &[NodeId], format: ExportFormat, scale: f64) -> Result> { + export_bytes_with(doc, ids, &ExportSetting { format, ..Default::default() }, scale) +} + +/// The page area exporting `ids` with `setting` covers: what they paint (a clipping frame's own +/// area), or their boxes with "include bounding box" and for slices. Hidden layers paint nothing, +/// so they take no room next to visible ones (a hidden-only export keeps its own area). +pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Option { + let any_visible = ids.iter().any(|i| doc.node(*i).is_some_and(|n| n.visible)); + ids.iter() + .filter_map(|i| { + let node = doc.node(*i); + let slice = node.is_some_and(|n| matches!(n.kind, NodeKind::Slice)); + if setting.include_bounding_box || slice { + doc.world_bounds(*i) + } else if any_visible && node.is_some_and(|n| !n.visible) { + None + } else { + figcraft_render::export_bounds(doc, *i) + } + }) + .reduce(|a, b| a.union(b)) +} + +/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off) +/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing +/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a +/// slice anywhere else draws every top-level layer of its page. +pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { + let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else { + return crate::cmd::edit::z_sorted(doc, ids); + }; + if !setting.contents_only { + return page.children.clone(); + } + let mut own = Vec::with_capacity(ids.len()); + for &i in ids { + if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) { + own.push(i); + continue; + } + match slice_container(doc, i) { + Some(c) => own.push(c), + None => return page.children.clone(), + } + } + own.sort_unstable(); + own.dedup(); + crate::cmd::edit::z_sorted(doc, &crate::cmd::edit::roots(doc, &own)) +} + +/// The frame or group a slice sits in: None on the page or in a section. +fn slice_container(doc: &Document, slice: NodeId) -> Option { + let parent = doc.node(slice)?.parent?; + doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent) +} + +/// [`export_bytes`] with an export setting's format and options (its size is `scale`: see +/// [`export_scale`]): contents only, include bounding box, the SVG, PDF and Vector Drawable options. +pub fn export_bytes_with(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scale: f64) -> Result> { + export_file(doc, ids, setting, scale).map(|(bytes, _)| bytes) +} + +/// What Copy as PNG puts on the clipboard: the layers together, at 2x (in the document's colour +/// profile; the clipboard wants sRGB). +pub fn copy_png(doc: &Document, ids: &[NodeId]) -> Option { + let area = export_area(doc, ids, &ExportSetting::default())?; + figcraft_render::render_area(doc, &crate::cmd::edit::z_sorted(doc, ids), area, 2.0) +} + +pub(super) fn export(s: &mut Session, p: &Value) -> Result { + let cmd = "file.export"; + let fig = match string(p, "format") { + Some(f) => f.eq_ignore_ascii_case("fig"), + None => string(p, "path").is_some_and(|pp| ext(&pp) == "fig"), + }; + if fig { + return export_fig(s, p); + } + let doc = s.doc()?; + let mut ids = targets(s, p); + if ids.is_empty() { + ids = doc.get(s.page()?)?.children.clone(); + } + let mut setting = export_setting(cmd, ExportSetting::default(), p)?; + if string(p, "format").is_none() + && let Some(f) = string(p, "path").and_then(|pp| ExportFormat::from_ext(&ext(&pp))) + { + setting.format = f; + } + let scale = match size_param(cmd, p)? { + Some(_) => export_scale(doc, &ids, &setting), + None => num_or(p, "scale", 1.0).clamp(0.01, ExportSize::MAX_SCALE), + }; + let format = setting.format; + let (bytes, warnings) = export_file(doc, &ids, &setting, scale)?; + let mut out = match string(p, "path") { + Some(path) => { + write(&path, &bytes)?; + json!({"path": path, "bytes": bytes.len(), "format": format.ext()}) + } + None => json!({"format": format.ext(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)}), + }; + // Android Vector Drawables say what they changed or left out (an empty list when nothing). + if format == ExportFormat::VectorDrawable { + out["warnings"] = json!(warnings); + } + Ok(out) +} + +/// The whole document as a `.fig` file (experimental: only round trips through our own importer +/// are verified), with the warnings about what was left out. +pub(super) fn export_fig(s: &mut Session, p: &Value) -> Result { + let doc = s.doc()?; + let (bytes, warnings) = fig_bytes(doc)?; + match string(p, "path") { + Some(path) => { + write(&path, &bytes)?; + Ok(json!({"path": path, "bytes": bytes.len(), "format": "fig", "warnings": warnings})) + } + None => Ok(json!({"format": "fig", "data": base64::engine::general_purpose::STANDARD.encode(&bytes), "warnings": warnings})), + } +} + +/// `doc` as a `.fig` file (with a thumbnail) and the warnings about what was left out. +pub fn fig_bytes(doc: &Document) -> Result<(Vec, Vec)> { + figcraft_format::fig::export(doc, thumbnail(doc).as_deref()).map_err(|e| EngineError::Other(e.to_string())) +} + +/// The scale that gives `ids` (their export area together) `setting`'s export size. +pub fn export_scale(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> f64 { + // Size-based exports ask for bounds before encoding. Let encoding report the depth error + // without entering a recursive bounds walk here; SVG/PDF do not use this raster scale. + if setting.format.is_vector() && figcraft_render::check_export_depth(doc, ids).is_err() { + return 1.0; + } + export_area(doc, ids, setting).map_or(1.0, |b| setting.size.scale_for(b.width(), b.height())) +} + +/// `e` with the `size` ("2x", "512w", "512h" or `{by, value}`), `suffix` and `format` that `p` sets. +pub(in crate::cmd) fn export_setting(cmd: &str, mut e: ExportSetting, p: &Value) -> Result { + if let Some(size) = size_param(cmd, p)? { + e.size = size; + } + if let Some(s) = string(p, "suffix") { + e.suffix = s.chars().filter(|c| !c.is_control()).take(200).collect(); + } + if let Some(f) = string(p, "format") { + e.format = ExportFormat::from_ext(&f).ok_or_else(|| bad(cmd, "format: png|jpg|svg|pdf|webp|xml"))?; + } + if let Some(c) = vd_compat_param(cmd, p)? { + e.vd_compat = c; + } + e.print = print_param(cmd, p, e.print)?; + for (key, slot) in [ + ("contentsOnly", &mut e.contents_only), + ("includeBoundingBox", &mut e.include_bounding_box), + ("svgOutlineText", &mut e.svg_outline_text), + ("svgIdAttribute", &mut e.svg_id_attribute), + ("svgSimplifyStroke", &mut e.svg_simplify_stroke), + ] { + match get(p, key) { + None | Some(Value::Null) => {} + Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, + } + } + Ok(e) +} + +/// Whether `p` sets any PDF print option. +fn has_print_param(p: &Value) -> bool { + ["bleed", "cropMarks", "bleedMarks", "cmyk"].iter().any(|k| get(p, k).is_some_and(|v| !v.is_null())) +} + +/// `base` with the PDF print options `p` sets: `bleed` (points, or a length like "3mm" or +/// "0.125in"), `cropMarks`, `bleedMarks`, `cmyk`. +pub(in crate::cmd) fn print_param(cmd: &str, p: &Value, mut base: PrintSettings) -> Result { + match get(p, "bleed") { + None | Some(Value::Null) => {} + Some(v) => { + let pt = match v { + Value::String(s) => PrintSettings::parse_length(s), + v => v.as_f64().filter(|n| n.is_finite() && *n >= 0.0), + }; + base.bleed = pt + .filter(|b| *b <= PrintSettings::MAX_BLEED) + .ok_or_else(|| bad(cmd, "bleed: points, or a length like \"3mm\" or \"0.125in\" (up to 144 pt, 2 in)"))?; + } + } + for (key, slot) in [("cropMarks", &mut base.crop_marks), ("bleedMarks", &mut base.bleed_marks), ("cmyk", &mut base.cmyk)] { + match get(p, key) { + None | Some(Value::Null) => {} + Some(v) => *slot = bool_of(v).ok_or_else(|| bad(cmd, format!("{key}: true or false")))?, + } + } + Ok(base) +} + +/// An export size passed as `size`: "2x", "512w", "512h", a number (scale) or `{by, value}`. +fn size_param(cmd: &str, p: &Value) -> Result> { + let size = match get(p, "size") { + None | Some(Value::Null) => return Ok(None), + Some(Value::String(s)) => ExportSize::parse(s), + Some(Value::Number(n)) => n.as_f64().and_then(|value| ExportSize::Scale { value }.checked()), + Some(v) => serde_json::from_value::(v.clone()).ok().and_then(ExportSize::checked), + }; + size.map(Some).ok_or_else(|| bad(cmd, "size: 2x, 512w or 512h (or {by: scale|width|height, value})")) +} + +/// Most files one `file.exportLayers` writes. +const MAX_EXPORT_FILES: usize = 1000; + +/// Most bytes the files of one zip export add up to: the files, the archive and its base64 copy +/// are all in memory at once (in the browser too). +const MAX_ZIP_BYTES: usize = 256 << 20; + +/// Keep `bytes` as zip member `name`, unless the members would pass `limit` bytes together. +pub(super) fn add_member<'a>(members: &mut Vec<(&'a str, Vec)>, name: &'a str, bytes: Vec, limit: usize) -> Result<()> { + let total = members.iter().map(|(_, b)| b.len()).sum::().saturating_add(bytes.len()); + if total > limit { + let mb = limit >> 20; + return Err(bad("file.exportLayers", format!("the files would pass {mb} MB as one zip: export fewer layers at a time, or to a folder"))); + } + members.push((name, bytes)); + Ok(()) +} + +/// One file of a layer export: the layer, its setting and the file name. +pub struct ExportJob { + pub id: NodeId, + pub setting: ExportSetting, + pub file: String, +} + +/// The files exporting `ids` with their export settings makes, with unique names (repeats get +/// " 2", " 3"…). Layers without settings make none. +pub fn export_jobs(doc: &Document, ids: &[NodeId]) -> Vec { + jobs_for(ids.iter().filter_map(|i| Some((*i, doc.node(*i)?.exports.clone()))), doc) +} + +/// The files exporting each layer with the settings beside it makes (see [`export_jobs`]). +pub fn jobs_for(items: impl IntoIterator)>, doc: &Document) -> Vec { + let mut used = std::collections::BTreeSet::new(); + let mut out = Vec::new(); + for (id, settings) in items { + let Some(n) = doc.node(id) else { continue }; + let base = export_path(&n.name); + for e in settings { + if out.len() >= MAX_EXPORT_FILES { + return out; + } + let stem = format!("{base}{}", safe_file_name(&e.file_suffix())); + let ext = e.format.ext(); + let file = + (1..).map(|k| if k == 1 { format!("{stem}.{ext}") } else { format!("{stem} {k}.{ext}") }).find(|f| used.insert(f.to_lowercase())); + if let Some(file) = file { + out.push(ExportJob { id, setting: e, file }); + } + } + } + out +} + +/// The file path a layer name exports to, without extension: each `/` makes a folder; `.` and +/// `..` segments are dropped, so it stays inside the export folder. +fn export_path(name: &str) -> String { + let parts: Vec = + name.split('/').map(|seg| safe_file_name(seg).trim().trim_end_matches('.').to_string()).filter(|seg| !seg.is_empty()).take(32).collect(); + if parts.is_empty() { "Untitled".into() } else { parts.join("/") } +} + +/// Characters no file name may contain (path separators and ones file systems reject). +pub(crate) const FORBIDDEN_FILE_NAME_CHARS: &str = "/\\:*?\"<>|"; + +/// `name` without path separators and characters file systems reject (`/ \ : * ? " < > |` and +/// control characters become `_`); everything else is kept, up to the first 200 characters. +/// `figcraft-cli export` names its files by this rule too (and keeps at most 100 characters before +/// the number and extension it adds). +pub fn safe_file_name(name: &str) -> String { + name.chars().take(200).map(|c| if c.is_control() || FORBIDDEN_FILE_NAME_CHARS.contains(c) { '_' } else { c }).collect() +} + +/// Layers on `page` that have export settings (document order). +pub fn exportable_layers(doc: &Document, page: NodeId) -> Vec { + doc.subtree(page).into_iter().skip(1).filter(|i| doc.node(*i).is_some_and(|n| !n.exports.is_empty())).collect() +} + +pub(super) fn export_layers(s: &mut Session, p: &Value) -> Result { + let cmd = "file.exportLayers"; + let doc = s.doc()?; + let assets = boolean(p, "assets").unwrap_or(false); + let mut ids = targets(s, p); + if ids.is_empty() { + ids = if assets { vec![s.page()?] } else { exportable_layers(doc, s.page()?) }; + } + let jobs = if assets { crate::devinspect::asset_jobs(doc, &ids) } else { export_jobs(doc, &ids) }; + if jobs.is_empty() { + return Err(EngineError::Disabled(cmd.into(), "no export settings (add one with export.add)".into())); + } + let dir = string(p, "dir"); + // The browser saves one download at a time: several files go as one zip (#969). + let zip = dir.is_none() && boolean(p, "zip").unwrap_or(false); + let mut members = Vec::new(); + // Print options passed here stand in for every PDF setting's own. + let print = has_print_param(p).then(|| print_param(cmd, p, PrintSettings::default())).transpose()?; + // And a compatibility mode for every Android Vector Drawable. + let compat = vd_compat_param(cmd, p)?; + let mut files = Vec::with_capacity(jobs.len()); + for job in &jobs { + let setting = + ExportSetting { print: print.unwrap_or(job.setting.print), vd_compat: compat.unwrap_or(job.setting.vd_compat), ..job.setting.clone() }; + let scale = export_scale(doc, &[job.id], &setting); + let (bytes, warnings) = export_file(doc, &[job.id], &setting, scale)?; + let mut f = json!({"name": job.file, "id": job.id, "bytes": bytes.len()}); + if setting.format == ExportFormat::VectorDrawable { + f["warnings"] = json!(warnings); + } + match &dir { + Some(d) => { + let path = std::path::Path::new(d).join(&job.file); + // A layer name with slashes exports into folders. + if let Some(parent) = path.parent() { + let _ = crate::file_access::create_dir_all(parent); + } + let path = path.to_string_lossy().to_string(); + write(&path, &bytes)?; + f["path"] = json!(path); + } + None if zip => add_member(&mut members, job.file.as_str(), bytes, MAX_ZIP_BYTES)?, + None => f["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&bytes)), + } + files.push(f); + } + let with_settings: std::collections::BTreeSet = jobs.iter().map(|j| j.id).collect(); + let mut r = json!({"files": files, "skipped": ids.len().saturating_sub(with_settings.len())}); + if zip { + let archive = figcraft_format::zip_files(members.iter().map(|(n, b)| (*n, b.as_slice()))).map_err(|e| EngineError::Other(e.to_string()))?; + r["data"] = json!(base64::engine::general_purpose::STANDARD.encode(&archive)); + } + Ok(r) +} + +/// A PDF with one page per group of layers (an sRGB document), titled after the file; `area` +/// sizes a single page to that page area instead of the layers' bounds. +pub(super) fn pdf_bytes(doc: &Document, groups: &[Vec], print: &PrintSettings, area: Option) -> Result> { + let opts = figcraft_pdf::PdfOptions { title: Some(doc.name.clone()), print: *print, area, ..Default::default() }; + figcraft_pdf::export_groups(doc, groups, &opts).map_err(|e| EngineError::Other(e.to_string())) +} + +/// Every top-level frame of `page` (reading order) as one PDF page each, and the page count. +pub fn frames_pdf(doc: &Document, page: NodeId, print: &PrintSettings) -> Result<(Vec, usize)> { + let mut frames = doc.top_frames(page); + frames.retain(|f| doc.node(*f).is_some_and(|n| n.visible)); + if frames.is_empty() { + return Err(EngineError::Disabled("file.exportFramesPdf".into(), "the page has no visible frames".into())); + } + let groups: Vec> = frames.iter().map(|f| vec![*f]).collect(); + Ok((pdf_bytes(&doc.in_srgb(), &groups, print, None)?, frames.len())) +} + +/// Every top-level frame of a page (reading order), one PDF page each. +pub(super) fn export_frames_pdf(s: &mut Session, p: &Value) -> Result { + let doc = s.doc()?; + let page = match id(p, "page") { + Some(pg) if doc.node(pg).is_some_and(Node::is_page) => pg, + Some(_) => return Err(bad("file.exportFramesPdf", "`page` is not a page")), + None => s.page()?, + }; + let print = print_param("file.exportFramesPdf", p, PrintSettings::default())?; + let (bytes, pages) = frames_pdf(doc, page, &print)?; + match string(p, "path") { + Some(path) => { + write(&path, &bytes)?; + Ok(json!({"path": path, "bytes": bytes.len(), "pages": pages})) + } + None => Ok(json!({"format": "pdf", "pages": pages, "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})), + } +} diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs new file mode 100644 index 00000000..071c651e --- /dev/null +++ b/crates/engine/src/cmd/file/load.rs @@ -0,0 +1,416 @@ +use super::*; + +/// The recovery copy at `path` as a document with unsaved changes and no path. +pub(crate) fn recovered(path: &str) -> Result { + let loaded = read(path).and_then(|b| figcraft_format::load_bytes_with_versions(&b).map_err(|e| EngineError::Other(e.to_string())))?; + Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved()) +} + +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn read(path: &str) -> Result> { + crate::file_access::read(path) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn read(path: &str) -> Result> { + Err(EngineError::Other(format!("{path}: no file system in the browser"))) +} + +/// The bytes of file `path`, refused when it's larger than `max`. +pub(crate) fn read_capped(path: &str, max: u64) -> Result> { + crate::file_access::read_capped(path, max) +} + +/// Write `bytes` to `path` through a uniquely named, exclusively created staging file and one +/// atomic rename (see [`figcraft_format::write_file_atomic`]): a failed or interrupted write never +/// truncates `path`, and no file or symlink at a staging name is ever written through. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn write(path: &str, bytes: &[u8]) -> Result<()> { + crate::file_access::write(path, bytes) +} + +#[cfg(target_arch = "wasm32")] +pub(crate) fn write(path: &str, _bytes: &[u8]) -> Result<()> { + Err(EngineError::Other(format!("{path}: no file system in the browser"))) +} + +pub(super) fn ext(path: &str) -> String { + std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("").to_ascii_lowercase() +} + +pub(super) fn file_stem(path: &str) -> String { + std::path::Path::new(path).file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Untitled").to_string() +} + +/// The counts the import report shows for a freshly loaded document. +pub(super) fn import_summary(doc: &Document) -> Value { + json!({"name": doc.name, "pages": doc.pages.len(), "layers": doc.nodes().filter(|n| !n.is_page()).count(), "images": doc.images.len()}) +} + +/// Load a document from any supported file (REST API file JSON images come from an `images` +/// folder next to the file). +pub fn load(path: &str) -> Result<(Document, Vec)> { + load_path(path, None).map(|(l, _)| (l.doc, l.issues)) +} + +pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec)> { + load_with(bytes, name_hint, None, &figcraft_format::rest::no_images).map(|(l, _)| (l.doc, l.issues)) +} + +/// [`load`] with the version history, also telling whether the file was REST API file JSON. A REST +/// import reads its variables from `variables`, else from a variables JSON next to the file. +pub fn load_path(path: &str, variables: Option<&Value>) -> Result<(figcraft_format::Loaded, bool)> { + let bytes = read(path)?; + let sibling = if variables.is_none() { sibling_variables(path) } else { None }; + load_with(&bytes, path, variables.or(sibling.as_ref()), &rest_images(path)) +} + +/// Load from bytes. Returns the document with its notes (repairs of a native file, or import +/// warnings) and version history, and whether it was imported from REST API file JSON or a `.fig` +/// file (such a file is never saved over). `variables`: the local variables JSON for a REST API file JSON. +pub(super) fn load_with( + bytes: &[u8], + name_hint: &str, + variables: Option<&Value>, + images: figcraft_format::rest::ImageResolver, +) -> Result<(figcraft_format::Loaded, bool)> { + match ext(name_hint).as_str() { + "svg" => { + let mut doc = Document::new(file_stem(name_hint)); + let page = doc.first_page().ok_or(EngineError::NoDocument)?; + let notes = figcraft_svg::import_svg_with_notes(&mut doc, bytes, page, 0.0, 0.0, &file_stem(name_hint)) + .map_err(|e| EngineError::Other(e.to_string()))? + .notes; + Ok((figcraft_format::Loaded { issues: notes, ..doc.into() }, false)) + } + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "tif" | "tiff" => { + let mut doc = Document::new(file_stem(name_hint)); + let page = doc.first_page().ok_or(EngineError::NoDocument)?; + let (hash, w, h) = figcraft_format::add_image(&mut doc, bytes.to_vec()).map_err(|e| EngineError::Other(e.to_string()))?; + let id = doc.create(page, NodeKind::Rectangle, &file_stem(name_hint), 0.0, 0.0, f64::from(w), f64::from(h))?; + doc.get_mut(id)?.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; + Ok((doc.into(), false)) + } + _ => { + if let Some(r) = load_fig(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); + } + if let Some(r) = load_sketch(bytes, &file_stem(name_hint)) { + return Ok((r?, true)); + } + if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { + let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; + link_instances(&mut doc); + return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); + } + let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + // Older files saved an untouched default title even after the user chose a filename. + if loaded.doc.name.is_empty() || loaded.doc.name == "Untitled" { + loaded.doc.name = file_stem(name_hint); + } + Ok((loaded, false)) + } + } +} + +/// Gives `None` when `bytes` are not a `.sketch` file. Then other readers can try. +fn load_sketch(bytes: &[u8], name: &str) -> Option> { + if !figcraft_format::sketch::is_sketch(bytes) { + return None; + } + // The import and what follows read the installed fonts: the system's must be known first (the + // desktop app scans them in the background; this waits for that scan). + figcraft_text::fontdb::scan_system_fonts(); + let fonts: figcraft_format::sketch::FontLookup = Box::new(figcraft_text::fontdb::postscript_face); + let r = figcraft_format::sketch::try_import_with_fonts(bytes, name, Some(fonts))?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + match_font_families(&mut doc); + platform_tracking(&mut doc); + sketch_line_heights(&mut doc); + link_instances(&mut doc); + reflow_overridden_text(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + +/// Instances of a stack sized apart from their main are laid out at their size, and texts in +/// instances whose string an override changed are measured again with the auto layout around them +/// re-flowing (Sketch saves layouts measured with the master's string). The rest keeps the layout +/// Sketch saved. +fn reflow_overridden_text(doc: &mut Document) { + let changed: Vec = doc + .nodes() + .filter(|n| match (&n.kind, n.source.and_then(|s| doc.node(s)).map(|s| &s.kind)) { + (NodeKind::Text(t), Some(NodeKind::Text(main))) => t.characters != main.characters, + _ => false, + }) + .map(|n| n.id) + .collect(); + let m = crate::measure::TextMeasure; + // Instances of a stack at another size than their main: the stack at their size, innermost first. + let mut resized: Vec<(usize, NodeId)> = doc + .nodes() + .filter(|n| n.layout.is_some()) + .filter(|n| match &n.kind { + NodeKind::Instance { component, .. } => { + doc.node(*component).is_some_and(|c| (c.width - n.width).abs() > 1e-6 || (c.height - n.height).abs() > 1e-6) + } + _ => false, + }) + .map(|n| (doc.ancestors(n.id).len(), n.id)) + .collect(); + resized.sort_by_key(|r| std::cmp::Reverse(r.0)); + for (_, id) in resized { + figcraft_layout::layout_frame(doc, id, &m); + } + for id in changed { + if let Some((w, h)) = doc.node(id).and_then(figcraft_text::auto_size) { + figcraft_layout::set_size(doc, id, w, h); + } + figcraft_layout::layout_up(doc, id, &m); + } +} + +/// Font families a `.sketch` import guessed from PostScript names ("Ping Fang SC" from +/// "PingFangSC-Semibold") take the installed family's name when they differ only in spaces and +/// case. +fn match_font_families(doc: &mut Document) { + let installed = figcraft_text::fontdb::family_names(); + let mut cache: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut fix = |style: &mut figcraft_doc::TextStyle| { + let found = cache.entry(style.font_family.clone()).or_insert_with(|| installed_family(&style.font_family, &installed)); + if let Some(f) = found { + style.font_family = f.clone(); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + fix(&mut td.style); + for r in &mut td.runs { + fix(&mut r.style); + } + } + } + // Shared text styles too, so they stay like the layers linked to them. + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + fix(style); + } + } +} + +/// Sketch draws text with the tracking a font's `trak` table gives at its size (Apple's text engine +/// adds it; FigCraft's, like Figma's, doesn't): the imported text keeps it as letter spacing, so +/// lines measure and break as they did there. A named style's optical size follows the text size +/// there too (FigCraft keeps the instance's), so the imported text carries it as an `opsz` value. +fn platform_tracking(doc: &mut Document) { + let mut cache: std::collections::BTreeMap<(String, String), Option>> = + std::collections::BTreeMap::new(); + let mut face = |style: &figcraft_doc::TextStyle| { + cache + .entry((style.font_family.clone(), style.font_style.clone())) + .or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + }) + .clone() + }; + let mut track = |style: &mut figcraft_doc::TextStyle| { + let Some(f) = face(style) else { return }; + let t = f.tracking(style.font_size); + if t != 0.0 && t.is_finite() { + let px = style.letter_spacing.resolve(style.font_size); + style.letter_spacing = figcraft_doc::LetterSpacing::Pixels(px + t); + } + if f.axis("opsz").is_some() && f.instance(&style.font_style).is_some() && style.font_size.is_finite() { + style.variations.entry("opsz".into()).or_insert(style.font_size); + } + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + if let Some(NodeKind::Text(td)) = doc.node_mut(id).map(|n| &mut n.kind) { + track(&mut td.style); + for r in &mut td.runs { + track(&mut r.style); + } + } + } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + track(style); + } + } +} + +/// Families Sketch (Apple's text system) gives a fifth more than their ascent and descent as their +/// natural line: classic Mac fonts, found on `sketchtool` renders. Other fonts show none of this. +const TALL_LINE_FAMILIES: [&str; 11] = + ["Academy Engraved LET", "Courier", "Helvetica", "Hoefler Text", "Palatino", "Sathu", "Silom", "Skia", "Times", "Webdings", "Zapf Dingbats"]; + +/// Sketch's natural line for a font of `ascent` and `descent` in `upem` units at `size` px: its +/// height in px and how far down the text moves so its baseline sits where Sketch puts it. Sketch +/// rounds the ascent and descent to whole pixels each and leaves the font's line gap out (a fifth +/// more for [`TALL_LINE_FAMILIES`]), with the baseline above the rounded descent; FigCraft centres +/// the font's own ascent and descent in the line. `(0, 0)` without metrics. +pub(crate) fn sketch_line(ascent: f64, descent: f64, upem: f64, family: &str, size: f64) -> (f64, f64) { + let k = size / upem; + let (a, d) = (ascent * k, descent * k); + if !(a.is_finite() && d.is_finite()) || a + d <= 0.0 { + return (0.0, 0.0); + } + let natural = a.round() + d.round(); + let line = if TALL_LINE_FAMILIES.iter().any(|f| f.eq_ignore_ascii_case(family)) { (natural * 1.2).round() } else { natural }; + let sketch_baseline = line - d.round(); + let figcraft_baseline = (line - (a + d)) / 2.0 + a; + (line, sketch_baseline - figcraft_baseline) +} + +/// Text without a fixed line height takes the line Sketch drew it with (FigCraft's natural line, +/// like Figma's, counts the font's line gap and centres the font in it; Sketch's doesn't), as a +/// share of its size so it still follows the size, and moves to Sketch's baseline. Shared text +/// styles take the line too. +fn sketch_line_heights(doc: &mut Document) { + // Per family and style: the face's ascent, descent and units per em. + type Metrics = Option<(f64, f64, f64)>; + let mut cache: std::collections::BTreeMap<(String, String), Metrics> = std::collections::BTreeMap::new(); + let mut set = |style: &mut TextStyle| -> f64 { + if style.line_height != LineHeight::Auto || !style.font_size.is_finite() || style.font_size <= 0.0 { + return 0.0; + } + let m = *cache.entry((style.font_family.clone(), style.font_style.clone())).or_insert_with(|| { + figcraft_text::fontdb::has_family(&style.font_family) + .then(|| figcraft_text::fontdb::find_face(&style.font_family, &style.font_style)) + .flatten() + .map(|f| (f.ascent, f.descent, f.upem)) + }); + let Some((a, d, upem)) = m else { return 0.0 }; + let (line, down) = sketch_line(a, d, upem, &style.font_family, style.font_size); + if line > 0.0 { + style.line_height = LineHeight::Percent(line / style.font_size * 100.0); + } + down + }; + let ids: Vec = doc.nodes().filter(|n| matches!(n.kind, NodeKind::Text(_))).map(|n| n.id).collect(); + for id in ids { + // An auto layout places its texts itself (its next pass would take a move back). + let laid_out = + doc.node(id).is_some_and(|n| !n.child_layout.absolute && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some())); + let Some(n) = doc.node_mut(id) else { continue }; + let NodeKind::Text(td) = &mut n.kind else { continue }; + let down = set(&mut td.style); + for r in &mut td.runs { + set(&mut r.style); + } + // Down in the layer's own frame (a rotated text moves along its own axis). + if !laid_out && down.is_finite() && down != 0.0 { + n.transform *= figcraft_doc::geom::Affine::translate((0.0, down)); + } + } + for st in &mut doc.styles { + if let StyleKind::Text { style } = &mut st.kind { + set(style); + } + } +} + +/// The installed family `family` means when it isn't installed under that exact name: the one +/// whose name is the same without spaces, ignoring case. +pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option { + let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::(); + if installed.iter().any(|f| f.eq_ignore_ascii_case(family)) { + return None; + } + let want = key(family); + installed.iter().find(|f| key(f) == want).cloned() +} + +/// Gives `None` when `bytes` are not a `.fig` file. Then other readers can try. +pub(crate) fn load_fig(bytes: &[u8], name: &str) -> Option> { + let r = figcraft_format::fig::try_import(bytes, name)?; + Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { + link_instances(&mut doc); + figcraft_format::Loaded { issues: warnings, ..doc.into() } + })) +} + +/// Link imported instances to their mains (layer sources, overrides found by comparing values), +/// innermost first so outer instances settle last. +fn link_instances(doc: &mut Document) { + crate::cmd::styles::unfold_imported_bound_alpha(doc); + crate::style_vars::after_load(doc); + crate::cmd::styles::apply_paint_bindings_on_load(doc); + let mut insts: Vec<(usize, NodeId)> = + doc.nodes().filter(|n| matches!(n.kind, NodeKind::Instance { .. })).map(|n| (doc.ancestors(n.id).len(), n.id)).collect(); + insts.sort_by_key(|a| std::cmp::Reverse(a.0)); + for (_, i) in insts { + crate::components::rebase_instance(doc, i); + } +} + +/// Largest image file read for a REST API file JSON import. +#[cfg(not(target_arch = "wasm32"))] +const MAX_IMAGE_FILE: u64 = 256 * 1024 * 1024; + +/// Image bytes for a REST API file JSON's `imageRef`: `images/[.png|.jpg|…]` next to +/// the file. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn rest_images(path: &str) -> impl Fn(&str) -> Option> { + let dir = std::path::Path::new(path).parent().map(|d| d.join("images")); + move |r: &str| { + // Image refs are content hashes; anything else never becomes a path. + if r.is_empty() || r.len() > 128 || !r.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { + return None; + } + let dir = dir.as_ref()?; + ["", ".png", ".jpg", ".jpeg", ".gif", ".webp"] + .iter() + .find_map(|e| crate::file_access::read_capped(dir.join(format!("{r}{e}")), MAX_IMAGE_FILE).ok()) + } +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn rest_images(_path: &str) -> impl Fn(&str) -> Option> { + figcraft_format::rest::no_images +} + +/// Largest variables JSON read from disk. +#[cfg(not(target_arch = "wasm32"))] +const MAX_VARIABLES_FILE: u64 = 256 * 1024 * 1024; + +/// A local variables JSON next to a REST API file JSON: `.variables.json`, else +/// `variables.json`. +#[cfg(not(target_arch = "wasm32"))] +pub(super) fn sibling_variables(path: &str) -> Option { + if ext(path) != "json" { + return None; + } + let file = std::path::Path::new(path); + let dir = file.parent()?; + [format!("{}.variables.json", file_stem(path)), "variables.json".to_string()].iter().map(|n| dir.join(n)).filter(|c| c != file).find_map(|c| { + let v: Value = serde_json::from_slice(&crate::file_access::read_capped(c, MAX_VARIABLES_FILE).ok()?).ok()?; + figcraft_format::rest::is_variables_json(&v).then_some(v) + }) +} + +#[cfg(target_arch = "wasm32")] +pub(super) fn sibling_variables(_path: &str) -> Option { + None +} + +/// The local variables JSON a command passes as `variables`: an object, JSON text or a file path. +pub(crate) fn variables_param(cmd: &str, p: &Value) -> Result> { + let parse = |text: &[u8]| serde_json::from_slice::(text).map_err(|e| bad(cmd, format!("variables: {e}"))); + let v = match get(p, "variables") { + None | Some(Value::Null) => return Ok(None), + Some(v @ Value::Object(_)) => v.clone(), + Some(Value::String(s)) if s.trim_start().starts_with('{') => parse(s.as_bytes())?, + Some(Value::String(path)) => parse(&read(path)?)?, + Some(_) => return Err(bad(cmd, "variables: the local variables JSON (an object, JSON text or a path)")), + }; + if !figcraft_format::rest::is_variables_json(&v) { + return Err(bad(cmd, "variables: expected the local variables JSON (`meta.variableCollections` and `meta.variables`)")); + } + Ok(Some(v)) +} diff --git a/crates/engine/src/cmd/file/open.rs b/crates/engine/src/cmd/file/open.rs new file mode 100644 index 00000000..6b724ced --- /dev/null +++ b/crates/engine/src/cmd/file/open.rs @@ -0,0 +1,245 @@ +use super::load::{ext, file_stem, import_summary, load_path, load_with, read, rest_images, sibling_variables, variables_param}; +use super::*; + +/// How many files File ▸ Open recent keeps. +pub const MAX_RECENT: usize = 20; + +/// Put `path` at the top of the recent files. +pub(super) fn remember(s: &mut Session, path: &str) { + let r = &mut s.prefs.recent_files; + r.retain(|p| p != path); + r.insert(0, path.to_string()); + r.truncate(MAX_RECENT); +} + +pub(super) fn open_recent(s: &mut Session, p: &Value) -> Result { + use crate::file_access::{Access, allow, allowed}; + const CMD: &str = "file.openRecent"; + // Confined to automation roots, the recent files outside them are never listed, opened, removed + // or named: `index` counts the files listed. + let listed = |r: &String| allowed(r, Access::Read); + if p.get("clear").and_then(Value::as_bool) == Some(true) { + s.prefs.recent_files.retain(|r| !listed(r)); + } + if let Some(gone) = string(p, "remove") { + s.prefs.recent_files.retain(|r| *r != gone || !listed(r)); + } + let path = match (string(p, "path"), num(p, "index")) { + (Some(path), _) => { + allow(&path, Access::Read)?; + Some(path) + } + (None, Some(i)) => { + Some(s.prefs.recent_files.iter().filter(|r| listed(r)).nth(i.max(0.0) as usize).cloned().ok_or_else(|| bad(CMD, "no such recent file"))?) + } + _ => None, + }; + if let Some(path) = path { + if !crate::file_access::exists(&path) { + s.prefs.recent_files.retain(|r| *r != path); + return Err(bad(CMD, format!("{path} no longer exists"))); + } + return open(s, &json!({"path": path})); + } + let files: Vec = s + .prefs + .recent_files + .iter() + .filter(|r| listed(r)) + .map(|r| { + let name = std::path::Path::new(r).file_name().map_or_else(|| r.clone(), |f| f.to_string_lossy().into_owned()); + json!({"path": r, "name": name, "exists": crate::file_access::exists(r)}) + }) + .collect(); + Ok(json!({"files": files})) +} + +pub(super) fn open(s: &mut Session, p: &Value) -> Result { + let variables = variables_param("file.open", p)?; + if string(p, "path").is_none() + && let Some(d) = string(p, "data") + { + return open_data(s, &d, p, variables.as_ref()); + } + let path = req_string("file.open", p, "path")?; + // Already open: switch to it. + if let Some(i) = s.docs.iter().position(|d| d.path.as_deref() == Some(path.as_str())) { + s.active = i; + remember(s, &path); + return Ok(json!({"index": i})); + } + // Stamped before reading: a change made during the read makes the next save refuse. + let on_disk = crate::library::stat(&path).ok(); + let (loaded, rest) = load_path(&path, variables.as_ref())?; + remember(s, &path); + let native = !rest && matches!(ext(&path).as_str(), "figcraft" | "json"); + let warnings = rest || ext(&path) == "svg"; + // A native file that lost something (a damaged image, say): the app says so, not only the result. + if native && !loaded.issues.is_empty() { + s.message = Some(match loaded.issues.as_slice() { + [one] => format!("{}: {one}", file_stem(&path)), + more => format!("{}: {} problems were repaired or left out while opening it, e.g. {}", file_stem(&path), more.len(), more[0]), + }); + } + let summary = import_summary(&loaded.doc); + let images_left_out = if native { loaded.images_past_budget } else { 0 }; + let mut ds = DocState::new(loaded.doc, native.then(|| path.clone())) + .with_versions(loaded.versions) + .with_branches(loaded.branches) + .with_images_left_in_file(&path, images_left_out); + ds.on_disk = on_disk.filter(|_| native); + s.docs.push(ds); + s.active = s.docs.len() - 1; + let mut out = json!({"index": s.active, "summary": summary}); + out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); + Ok(out) +} + +/// [`open`] for a file given as base64 `data` (a file dropped without a path on disk): `name` tells +/// the format. It opens as a new document with no path, so the first save asks where to put it. +fn open_data(s: &mut Session, data: &str, p: &Value, variables: Option<&Value>) -> Result { + let name = req_string("file.open", p, "name")?; + let data = data.split_once(',').map_or(data, |(_, b)| b); + let bytes = base64::engine::general_purpose::STANDARD.decode(data.trim()).map_err(|e| bad("file.open", e.to_string()))?; + let (loaded, rest) = load_with(&bytes, &name, variables, &figcraft_format::rest::no_images)?; + let warnings = rest || ext(&name) == "svg"; + let summary = import_summary(&loaded.doc); + s.docs.push(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches)); + s.active = s.docs.len() - 1; + let mut out = json!({"index": s.active, "summary": summary}); + out[if warnings { "warnings" } else { "repairs" }] = json!(loaded.issues); + Ok(out) +} + +pub(super) fn import(s: &mut Session, p: &Value) -> Result { + let path = string(p, "path"); + let variables = variables_param("file.import", p)?; + let (bytes, name) = if let Some(path) = &path { + (read(path)?, path.clone()) + } else if let Some(d) = string(p, "data") { + let d = d.split_once(',').map(|(_, b)| b.to_string()).unwrap_or(d); + ( + base64::engine::general_purpose::STANDARD.decode(d.trim()).map_err(|e| bad("file.import", e.to_string()))?, + string(p, "name").unwrap_or_else(|| "import.png".into()), + ) + } else { + return Err(bad("file.import", "pass `path` or `data`")); + }; + let page = s.page()?; + let (x, y) = (num(p, "x"), num(p, "y")); + let center = s.ds().map(|d| (d.view.cx, d.view.cy)).unwrap_or((0.0, 0.0)); + match ext(&name).as_str() { + "svg" => { + let doc = s.doc_mut()?; + let (x, y) = (x.unwrap_or(center.0), y.unwrap_or(center.1)); + let imported = + figcraft_svg::import_svg_with_notes(doc, &bytes, page, x, y, &file_stem(&name)).map_err(|e| EngineError::Other(e.to_string()))?; + let id = imported.frame; + // Centred on the point (a paste lands in the middle of the view). + if boolean(p, "center").unwrap_or(false) + && let Some(n) = doc.node_mut(id) + { + n.transform = figcraft_geom::Affine::translate(((x - n.width / 2.0).round(), (y - n.height / 2.0).round())); + } + s.set_selection(vec![id]); + let mut out = json!({"id": id}); + if !imported.notes.is_empty() { + out["warnings"] = json!(imported.notes); + } + Ok(out) + } + "figcraft" | "json" | "fig" | "sketch" => { + let resolver = path.as_deref().map(rest_images); + let images: figcraft_format::rest::ImageResolver = match &resolver { + Some(r) => r, + None => &figcraft_format::rest::no_images, + }; + let sibling = if variables.is_none() { path.as_deref().and_then(sibling_variables) } else { None }; + let (other, rest) = load_with(&bytes, &name, variables.as_ref().or(sibling.as_ref()), images)?; + let summary = import_summary(&other.doc); + let pages = append_pages(s.doc_mut()?, &other.doc)?; + let mut out = json!({"pages": pages, "summary": summary}); + if rest { + out["warnings"] = json!(other.issues); + } + Ok(out) + } + _ => { + let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes); + crate::cmd::create::place_image( + s, + &json!({"data": b64, "x": x.unwrap_or(center.0), "y": y.unwrap_or(center.1), "name": file_stem(&name)}), + ) + } + } +} + +/// Append `other`'s pages (with their settings and flows, its images, styles and variables) to +/// `doc`. Ids are remapped across all pages at once, so instances keep their main, instance layers +/// their source and interactions and flows their targets wherever those are. Styles, variables and +/// collections whose id the document already has are kept as they are. +pub(super) fn append_pages(doc: &mut Document, other: &Document) -> Result> { + for (k, v) in &other.images { + doc.images.entry(k.clone()).or_insert_with(|| v.clone()); + } + for st in &other.styles { + if !doc.styles.iter().any(|s| s.id == st.id) { + doc.styles.push(st.clone()); + } + } + let have: std::collections::BTreeSet = doc.variables.collections.iter().map(|c| c.id.clone()).collect(); + doc.variables.collections.extend(other.variables.collections.iter().filter(|c| !have.contains(&c.id)).cloned()); + let have: std::collections::BTreeSet = doc.variables.variables.iter().map(|v| v.id.clone()).collect(); + doc.variables.variables.extend(other.variables.variables.iter().filter(|v| !have.contains(&v.id)).cloned()); + let mut map = std::collections::BTreeMap::new(); + for pg in &other.pages { + for id in other.subtree(*pg).into_iter().skip(1) { + map.insert(id, doc.alloc_id()); + } + } + // Library copies a .fig import keeps (cached definitions) have no library record to follow: + // the layers of its store come along into the store of `doc`. Recorded copies are left to + // `merge_store`, which shares them with copies `doc` already has. + let cached: Vec = other + .library_store() + .and_then(|s| other.node(s)) + .map(|s| s.children.iter().copied().filter(|root| other.libraries.import_of(*root).is_none()).collect()) + .unwrap_or_default(); + for root in &cached { + for id in other.subtree(*root) { + map.insert(id, doc.alloc_id()); + } + } + let mut new_pages = Vec::new(); + for pg in &other.pages { + let Some(pn) = other.node(*pg) else { continue }; + let np = doc.add_page(&pn.name); + let mut kind = pn.kind.clone(); + if let NodeKind::Page { flows, .. } = &mut kind { + flows.retain_mut(|f| map.get(&f.start).map(|n| f.start = *n).is_some()); + } + if let Some(n) = doc.node_mut(np) { + n.kind = kind; + } + for c in &pn.children { + let nodes: Vec = other.subtree(*c).into_iter().filter_map(|i| other.node(i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, *c, np, None)?; + } + new_pages.push(np); + } + if !cached.is_empty() { + let store = doc.ensure_library_store(); + for root in &cached { + let nodes: Vec = other.subtree(*root).into_iter().filter_map(|i| other.node(i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, *root, store, None)?; + } + } + for new in map.values() { + let src = doc.node(*new).and_then(|n| n.source).and_then(|s| map.get(&s).copied()); + if let (Some(src), Some(n)) = (src, doc.node_mut(*new)) { + n.source = Some(src); + } + } + crate::library::merge_store(doc, other, &map)?; + Ok(new_pages) +} diff --git a/crates/engine/src/cmd/file/rest_import_tests.rs b/crates/engine/src/cmd/file/rest_import_tests.rs new file mode 100644 index 00000000..189edc60 --- /dev/null +++ b/crates/engine/src/cmd/file/rest_import_tests.rs @@ -0,0 +1,187 @@ +use super::*; + +/// A synthetic REST API file JSON: a component with a text layer, an instance of it whose text +/// is overridden, and an image fill. +fn kit() -> Value { + let at = |x: f64, y: f64| json!([[1, 0, x], [0, 1, y]]); + let label = |id: &str, text: &str| { + json!({"id": id, "name": "Label", "type": "TEXT", "relativeTransform": at(8.0, 8.0), "size": {"x": 40, "y": 20}, "characters": text, + "style": {"fontFamily": "Inter", "fontSize": 14}, "fills": [{"type": "SOLID", "color": {"r": 0, "g": 0, "b": 0, "a": 1}}]}) + }; + json!({"name": "Kit", "document": {"id": "0:0", "type": "DOCUMENT", "children": [ + {"id": "0:1", "name": "Main", "type": "CANVAS", "backgroundColor": {"r": 0.9, "g": 0.9, "b": 0.9, "a": 1}, "children": [ + {"id": "1:1", "name": "Button", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 40}, + "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("1:2", "OK")]}, + {"id": "2:1", "name": "Button", "type": "INSTANCE", "componentId": "1:1", "relativeTransform": at(0.0, 100.0), "size": {"x": 100, "y": 40}, + "fills": [{"type": "SOLID", "color": {"r": 0.2, "g": 0.4, "b": 1, "a": 1}}], "children": [label("I2:1;1:2", "Go")]}, + {"id": "3:1", "name": "Photo", "type": "RECTANGLE", "relativeTransform": at(200.0, 0.0), "size": {"x": 30, "y": 20}, + "fills": [{"type": "IMAGE", "imageRef": "photo1", "scaleMode": "FILL"}]} + ]} + ]}, "components": {"1:1": {"key": "k", "name": "Button", "description": ""}}, "styles": {}}) +} + +fn instance(doc: &Document) -> &Node { + doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { .. })).unwrap() +} + +/// What a .fig import with a cached library component leaves: the main in the hidden store, a +/// visible instance of it and a layer linked to the main's layer. +fn cached_import() -> Document { + let mut doc = Document::new("Cached"); + let page = doc.first_page().unwrap(); + let store = doc.ensure_library_store(); + let main = doc + .create(store, NodeKind::Component { description: String::new(), props: Default::default() }, "Cached main", 0.0, 0.0, 20.0, 20.0) + .unwrap(); + let body = doc.create(main, NodeKind::Rectangle, "Body", 0.0, 0.0, 20.0, 20.0).unwrap(); + let inst = doc.create(page, NodeKind::Instance { component: main, props: Default::default() }, "Cached instance", 0.0, 0.0, 20.0, 20.0).unwrap(); + let mut copy = doc.node(body).unwrap().clone(); + copy.id = doc.alloc_id(); + copy.parent = None; + copy.source = Some(body); + let copy_id = copy.id; + doc.insert(copy, inst, None).unwrap(); + assert!(doc.is_remote(main) && doc.node(copy_id).unwrap().source == Some(body)); + doc +} + +/// Pasting layers that use a cached main into another document leaves the main behind, as it +/// does for other mains that are not published: no link points at a layer of the target. +#[test] +fn pasting_instances_of_cached_components_leaves_no_dangling_links() { + let src = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + let inst = src.nodes().find(|n| n.name == "Cached instance").unwrap().id; + let ids = src.subtree(inst); + let map: std::collections::BTreeMap = ids.iter().map(|i| (*i, doc.alloc_id())).collect(); + let nodes: Vec = ids.iter().filter_map(|i| src.node(*i).cloned()).collect(); + doc.paste_nodes(&nodes, &map, inst, page, None).unwrap(); + crate::library::adopt_pasted(&mut doc, &src, None, &map).unwrap(); + let pasted = doc.node(map[&inst]).unwrap(); + let NodeKind::Instance { component, .. } = pasted.kind else { panic!() }; + assert!(!doc.contains(component), "the main stays behind: {component:?}"); + assert!(doc.node(pasted.children[0]).unwrap().source.is_none_or(|s| !doc.contains(s))); +} + +#[test] +fn appending_pages_with_cached_library_components_brings_the_components() { + let other = cached_import(); + let mut doc = Document::new("Target"); + let page = doc.first_page().unwrap(); + // Ids of the target that would collide with the other document's store. + for i in 0..8 { + doc.create(page, NodeKind::Rectangle, &format!("Filler {i}"), 0.0, 0.0, 5.0, 5.0).unwrap(); + } + append_pages(&mut doc, &other).unwrap(); + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + assert!(crate::components::mirror_issues(&doc).is_empty(), "{:?}", crate::components::mirror_issues(&doc)); + let inst = doc.nodes().find(|n| n.name == "Cached instance").unwrap(); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert_eq!(doc.node(component).unwrap().name, "Cached main"); + assert!(doc.is_remote(component), "the main comes along into the store"); + let layer = doc.node(inst.children[0]).unwrap(); + assert_eq!(layer.source, doc.node(component).unwrap().children.first().copied()); +} + +#[test] +fn rest_json_loads_with_linked_instances() { + let bytes = serde_json::to_vec(&kit()).unwrap(); + let (doc, warnings) = load_bytes(&bytes, "kit.json").unwrap(); + assert_eq!(doc.name, "Kit"); + assert!(warnings.iter().any(|w| w.contains("placeholder")), "{warnings:?}"); + let inst = instance(&doc); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert_eq!(inst.source, Some(component)); + let main_label = doc.node(component).unwrap().children[0]; + let label = doc.node(inst.children[0]).unwrap(); + assert_eq!(label.source, Some(main_label)); + assert!(label.overrides.contains("characters"), "{:?}", label.overrides); + assert!(crate::components::mirror_issues(&doc).is_empty()); + assert!(thumbnail(&doc).is_some()); +} + +#[test] +fn open_takes_data_and_a_name_as_a_new_unsaved_document() { + let mut saved = Session::new(); + saved.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + let bytes = file_bytes(saved.ds().unwrap()).unwrap(); + let data = base64::engine::general_purpose::STANDARD.encode(&bytes); + let mut s = Session::new(); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.open", &json!({"data": data, "name": "Dropped.figcraft"})).unwrap(); + let open = s.docs.len(); + assert_eq!(r["index"], open - 1, "a document of its own, made active"); + assert_eq!(s.ds().unwrap().path, None, "no path to save over"); + assert!(s.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle))); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + assert!(s.execute("file.open", &json!({"data": data})).is_err(), "the name tells the format"); + assert!(s.execute("file.open", &json!({"data": "not base64!", "name": "x.svg"})).is_err()); + assert_eq!(s.docs.len(), open, "a refused open adds nothing"); +} + +#[test] +fn save_as_data_hands_back_the_file_and_marks_it_saved_once_delivered() { + // The browser has no paths: Save as returns the file for a download (#969). + let mut s = Session::new(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + assert!(s.execute("file.saveAs", &json!({})).is_err(), "no path and no data: still asks for a path"); + let recent = s.prefs.recent_files.clone(); + let r = s.execute("file.saveAs", &json!({"data": true})).unwrap(); + assert_eq!(r["name"], "Untitled.figcraft"); + assert!(s.ds().unwrap().is_dirty(), "not delivered yet: a refused download must not lose the edits"); + s.execute("file.markSaved", &json!({})).unwrap(); + assert!(!s.ds().unwrap().is_dirty()); + assert_eq!(s.ds().unwrap().path, None, "still no path to save over"); + assert_eq!(s.prefs.recent_files, recent, "nothing on disk to remember"); + let mut t = Session::new(); + t.execute("file.open", &json!({"data": r["data"], "name": r["name"]})).unwrap(); + assert!(t.doc().unwrap().nodes().any(|n| matches!(n.kind, NodeKind::Rectangle)), "the bytes reopen"); +} + +#[test] +fn open_and_import_rest_json() { + let dir = std::env::temp_dir().join(format!("figcraft-rest-{}", std::process::id())); + std::fs::create_dir_all(dir.join("images")).unwrap(); + let png = { + let mut d = Document::new("x"); + let p = d.first_page().unwrap(); + let id = d.create(p, NodeKind::Rectangle, "R", 0.0, 0.0, 3.0, 2.0).unwrap(); + export_bytes(&d, &[id], ExportFormat::Png, 1.0).unwrap() + }; + std::fs::write(dir.join("images").join("photo1.png"), &png).unwrap(); + let path = dir.join("kit.json"); + std::fs::write(&path, serde_json::to_vec(&kit()).unwrap()).unwrap(); + let path = path.to_string_lossy().to_string(); + + let mut s = Session::new(); + let r = s.execute("file.open", &json!({"path": path})).unwrap(); + assert!(r["warnings"].as_array().is_some_and(|w| w.iter().all(|w| !w.as_str().unwrap_or("").contains("placeholder"))), "{r}"); + assert!(r.get("repairs").is_none()); + assert_eq!(s.ds().unwrap().path, None, "an import is never saved over its JSON"); + let doc = s.doc().unwrap(); + let photo = doc.nodes().find(|n| n.name == "Photo").unwrap(); + let figcraft_color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!() }; + assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((3, 2)), "read from images/ next to the file"); + + // Import into another document: pages are appended and references follow the new ids. + let mut s = Session::new(); + let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&kit()).unwrap()); + let r = s.execute("file.import", &json!({"data": data, "name": "kit.json"})).unwrap(); + assert_eq!(r["pages"].as_array().map(Vec::len), Some(1)); + assert!(r["warnings"].is_array(), "{r}"); + let doc = s.doc().unwrap(); + assert_eq!(doc.pages.len(), 2); + let page = doc.node(doc.pages[1]).unwrap(); + assert!(matches!(page.kind, NodeKind::Page { background, .. } if (background.r - 0.9).abs() < 1e-6)); + let inst = instance(doc); + let NodeKind::Instance { component, .. } = inst.kind else { panic!() }; + assert!(doc.node(component).is_some_and(|c| matches!(c.kind, NodeKind::Component { .. }))); + let label = doc.node(inst.children[0]).unwrap(); + assert!(label.source.is_some_and(|src| doc.is_ancestor(component, src))); + assert!(crate::components::mirror_issues(doc).is_empty()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/cmd/file/save.rs b/crates/engine/src/cmd/file/save.rs new file mode 100644 index 00000000..593c4c21 --- /dev/null +++ b/crates/engine/src/cmd/file/save.rs @@ -0,0 +1,225 @@ +use super::export::export_fig; +use super::load::{ext, file_stem, write}; +use super::open::remember; +use super::*; + +/// Thumbnail PNG of the first page's content (for file browsers / the home screen). +pub fn thumbnail(doc: &Document) -> Option> { + let page = doc.first_page()?; + let b = doc.page_content_bounds(page)?; + let scale = (400.0 / b.width().max(b.height()).max(1.0)).min(2.0); + let view = figcraft_render::View { + origin: (b.x0, b.y0), + scale, + width: (b.width() * scale).ceil().max(1.0) as u32, + height: (b.height() * scale).ceil().max(1.0) as u32, + }; + let pix = figcraft_render::render_view(doc, page, &view, &Default::default())?; + figcraft_render::encode_png(&pix).ok() +} + +/// The `.figcraft` file for a document: content, thumbnail and version history. +pub fn file_bytes(ds: &DocState) -> Result> { + FileSnapshot::of(ds).encode() +} + +/// A document's file as it is at one moment, so the slow part of saving it (the thumbnail and the +/// encoding) can run on another thread while editing goes on: [`encode`](Self::encode) there, then +/// [`save`](Self::save) the bytes back on the session's thread. +pub struct FileSnapshot { + uid: u64, + path: Option, + revision: u64, + doc: Arc, + /// The document as last saved when the snapshot was taken: another save since makes it stale. + saved: Arc, + page: NodeId, + versions: Vec, + branches: Vec, +} + +impl FileSnapshot { + pub fn of(ds: &DocState) -> Self { + Self { + uid: ds.uid, + path: ds.path.clone(), + revision: ds.revision, + doc: ds.doc.clone(), + saved: ds.saved_doc.clone(), + page: ds.page, + versions: ds.versions.clone(), + branches: ds.branches.clone(), + } + } + + /// The file's bytes: content, thumbnail and version history. + pub fn encode(&self) -> Result> { + let mut doc = (*self.doc).clone(); + doc.current_page = Some(self.page); + let thumb = thumbnail(&doc); + figcraft_format::save_bytes_with_history(&doc, thumb.as_deref(), &self.versions, &self.branches) + .map_err(|e| EngineError::Other(e.to_string())) + } + + /// Write `bytes` (this snapshot's [`encode`](Self::encode)) to the document's file: it is saved + /// as of the snapshot, and edits since stay unsaved. False (nothing written) when the document + /// was closed, saved, reverted or given another path since: the bytes are out of date. + pub fn save(self, s: &mut Session, bytes: &[u8]) -> Result { + let Some(ds) = s.docs.iter_mut().find(|d| d.uid == self.uid && d.path == self.path && Arc::ptr_eq(&d.saved_doc, &self.saved)) else { + return Ok(false); + }; + let path = self.path.as_deref().ok_or(EngineError::NoDocument)?; + refuse_dropping_images(ds, path)?; + refuse_overwriting_changes(ds, path)?; + write(path, bytes)?; + ds.note_on_disk(); + // The history or branches may have changed since: then they still count as unsaved. + if ds.revision == self.revision { + ds.versions_changed = false; + } + ds.saved_doc = self.doc; + ds.revision += 1; + branch_tabs_saved(s, self.uid); + Ok(true) + } +} + +fn refuse_dropping_images(ds: &DocState, path: &str) -> Result<()> { + match ds.images_lost_saving_to(path) { + Some(count) => Err(EngineError::Other(format!( + "{} wasn't saved: {count} of its images weren't loaded (together they pass what FigCraft reads from one file), and saving over it would delete them. Save it under another name; this file keeps them.", + file_stem(path) + ))), + None => Ok(()), + } +} + +/// Saving over the tab's own file would lose what another app wrote to it since (#1330). +fn refuse_overwriting_changes(ds: &DocState, path: &str) -> Result<()> { + match (&ds.on_disk, crate::library::stat(path)) { + (Some(was), Ok(now)) if now != *was => Err(EngineError::Other(format!( + "{} wasn't saved: the file changed on disk since it was opened or saved here (another app or figcraft-cli wrote to it), and saving would replace those changes. Revert to saved to load them, or Save as to keep this version as well.", + file_stem(path) + ))), + _ => Ok(()), + } +} + +/// The branch tabs of the file in tab `uid` were saved with it. +fn branch_tabs_saved(s: &mut Session, uid: u64) { + for tab in s.docs.iter_mut().filter(|d| d.branch_of.as_ref().is_some_and(|(m, _)| *m == uid)) { + tab.mark_saved(); + } +} + +pub(super) fn color_profile(s: &mut Session, p: &Value) -> Result { + let cmd = "file.colorProfile"; + let Some(name) = string(p, "profile") else { + s.request_ui("dialog.colorProfile", json!({})); + return Ok(json!({"profile": s.doc()?.color_profile.id(), "changed": false})); + }; + let profile = figcraft_color::ColorProfile::parse(&name).ok_or_else(|| bad(cmd, "profile: srgb or displayP3"))?; + let convert = boolean(p, "convert").unwrap_or(true); + let changed = s.doc()?.color_profile != profile && s.doc_mut()?.set_color_profile(profile, convert); + Ok(json!({"profile": profile.id(), "changed": changed})) +} + +/// Native saves act on the current document; a `.fig` copy exports the version shown, and a +/// pending Save As choice must leave it shown until its destination is known. +pub(crate) fn save_ends_preview(s: &Session, cmd: &str, p: &Value) -> bool { + let path = + string(p, "path").or_else(|| if cmd == "file.save" { s.docs.get(crate::branches::file_tab(s)).and_then(|ds| ds.path.clone()) } else { None }); + path.is_some_and(|path| ext(&path) != "fig") || (cmd == "file.saveAs" && wants_data(p)) +} + +/// `file.saveAs` asked for the file's bytes instead of a path to write (the browser's download). +fn wants_data(p: &Value) -> bool { + p.get("data").and_then(Value::as_bool) == Some(true) +} + +/// Save the file of the active tab (the main file for a branch tab, with its branches), or export +/// the active document as an experimental `.fig` copy without changing its native save state. +/// Without a path the file comes back as base64 `data` for the caller to deliver (#969). +fn save_to(s: &mut Session, path: Option) -> Result { + if let Some(path) = path.as_deref().filter(|p| ext(p) == "fig") { + return export_fig(s, &json!({"path": path})); + } + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + // A download (no path) writes over no file. + if let Some(path) = path.as_deref() { + refuse_dropping_images(ds, path)?; + } + let mut snapshot = FileSnapshot::of(ds); + let follows_path = ds.path.as_deref().map_or(ds.doc.name == "Untitled", |old| ds.doc.name == file_stem(old)); + if let Some(path) = path.as_deref() + && follows_path + && ds.path.as_deref() != Some(path) + { + Arc::make_mut(&mut snapshot.doc).name = file_stem(path); + } + let bytes = snapshot.encode()?; + let Some(path) = path else { + // The download can still be refused: the caller marks the file saved once it was delivered. + let name = format!("{}.figcraft", ds.doc.name); + return Ok(json!({"name": name, "bytes": bytes.len(), "data": base64::engine::general_purpose::STANDARD.encode(&bytes)})); + }; + write(&path, &bytes)?; + ds.doc = snapshot.doc; + ds.path = Some(path.clone()); + ds.note_on_disk(); + mark_saved(s, &json!({}))?; + remember(s, &path); + Ok(json!({"path": path, "bytes": bytes.len()})) +} + +/// The file of the active tab (with its branch tabs) now matches what was written or delivered. +pub(super) fn mark_saved(s: &mut Session, _: &Value) -> Result { + let i = crate::branches::file_tab(s); + let ds = s.docs.get_mut(i).ok_or(EngineError::NoDocument)?; + ds.mark_saved(); + ds.revision += 1; + let uid = ds.uid; + branch_tabs_saved(s, uid); + Ok(json!({"saved": true})) +} + +pub(super) fn save(s: &mut Session, p: &Value) -> Result { + if let Some(path) = string(p, "path") { + return save_to(s, Some(path)); + } + let ds = s.docs.get(crate::branches::file_tab(s)).ok_or(EngineError::NoDocument)?; + match ds.path.clone() { + // Earlier builds wrote native data under a .fig name: never overwrite that file with an export. + Some(path) if ext(&path) == "fig" => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.save".into(), "the saved path ends in .fig: choose Save as or pass `path`".into())) + } + Some(path) => { + if p.get("overwrite").and_then(Value::as_bool) != Some(true) { + refuse_overwriting_changes(ds, &path)?; + } + save_to(s, Some(path)) + } + None => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.save".into(), "the file has no path yet: pass `path`".into())) + } + } +} + +pub(super) fn save_as(s: &mut Session, p: &Value) -> Result { + match string(p, "path") { + Some(mut path) => { + if ext(&path).is_empty() { + path.push_str(".figcraft"); + } + save_to(s, Some(path)) + } + None if wants_data(p) => save_to(s, None), + None => { + s.request_ui("dialog.saveAs", json!({})); + Err(EngineError::Disabled("file.saveAs".into(), "pass `path`".into())) + } + } +} diff --git a/crates/engine/src/cmd/file/staging_write_tests.rs b/crates/engine/src/cmd/file/staging_write_tests.rs new file mode 100644 index 00000000..5653ce78 --- /dev/null +++ b/crates/engine/src/cmd/file/staging_write_tests.rs @@ -0,0 +1,119 @@ +use super::*; + +fn dir(name: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("figcraft-engine-staging-{name}-{}", std::process::id())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A symlink `from` → `to`; false when the platform won't make one (Windows needs privileges). +#[cfg(unix)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::unix::fs::symlink(to, from).is_ok() +} + +#[cfg(windows)] +fn symlink(from: &std::path::Path, to: &std::path::Path) -> bool { + std::os::windows::fs::symlink_file(to, from).is_ok() +} + +#[test] +fn an_existing_staging_file_is_never_written_through() { + let d = dir("existing"); + let path = d.join("shot.svg"); + // The old deterministic staging name, held by an unrelated file: the write must pick its + // own staging name instead of truncating this one. + let old = d.join("shot.svg.tmp-figcraft"); + std::fs::write(&old, b"not yours").unwrap(); + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), b""); + assert_eq!(std::fs::read(&old).unwrap(), b"not yours", "an unrelated file at a staging name is untouched"); + // Just the file and the untouched staging file: no new staging leftovers. + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 2); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_staging_symlink_is_never_followed() { + let d = dir("symlink"); + let path = d.join("shot.svg"); + let victim = d.join("victim.txt"); + std::fs::write(&victim, b"innocent").unwrap(); + let old = d.join("shot.svg.tmp-figcraft"); + if !symlink(&old, &victim) { + return; // Making a symlink needs privileges on some Windows setups. + } + write(&path.to_string_lossy(), b"").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"innocent", "the symlink was never written through"); + assert_eq!(std::fs::read(&path).unwrap(), b""); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn concurrent_writers_of_one_file_never_interleave() { + let d = dir("concurrent"); + let path = d.join("shot.svg"); + for round in 0..5u8 { + let mut handles = Vec::new(); + for w in 0u8..4 { + let (path, bytes) = (path.clone(), vec![b'0' + w; 8192]); + handles.push(std::thread::spawn(move || write(&path.to_string_lossy(), &bytes))); + } + let mut wins = 0; + for h in handles { + // On Windows a rename over a file another writer just replaced can lose the race. + if h.join().unwrap().is_ok() { + wins += 1; + } + } + assert!(wins > 0, "round {round}: every writer lost"); + // Whatever landed is one writer's complete payload, never a mix. + let got = std::fs::read(&path).unwrap(); + assert_eq!(got.len(), 8192, "round {round}: a complete payload landed"); + assert!(got.windows(2).all(|w| w[0] == w[1]), "round {round}: one writer's payload, not a mix"); + } + // No staging file was left stuck (a failed writer removes its own). + let left = std::fs::read_dir(&d).unwrap().count(); + assert!(left <= if cfg!(windows) { 5 } else { 1 }, "{left} files left in the staging directory"); + let _ = std::fs::remove_dir_all(&d); +} + +#[test] +fn a_failed_write_leaves_the_destination_alone_and_cleans_up() { + let d = dir("failure"); + // The destination is a directory: the rename fails, our staging file is removed and the + // directory is untouched. + let taken = d.join("over-a-directory"); + std::fs::create_dir_all(&taken).unwrap(); + assert!(write(&taken.to_string_lossy(), b"payload").is_err()); + assert!(taken.is_dir()); + assert_eq!(std::fs::read_dir(&d).unwrap().count(), 1, "no staging file left behind"); + // A missing parent directory fails without creating anything anywhere. + assert!(write(&d.join("no").join("such.svg").to_string_lossy(), b"payload").is_err()); + assert!(!d.join("no").exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // A read-only directory refuses the staging file, and the old destination stays. + let ro = d.join("read-only"); + std::fs::create_dir_all(&ro).unwrap(); + let dest = ro.join("keep.svg"); + std::fs::write(&dest, b"keep").unwrap(); + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o500); + std::fs::set_permissions(&ro, perms).unwrap(); + // Running as root would defeat the read-only directory: skip instead of failing. + let probe = ro.join(".probe"); + if std::fs::write(&probe, b"").is_ok() { + let _ = std::fs::remove_file(&probe); + } else { + assert!(write(&dest.to_string_lossy(), b"payload").is_err()); + assert_eq!(std::fs::read(&dest).unwrap(), b"keep"); + assert_eq!(std::fs::read_dir(&ro).unwrap().count(), 1, "no staging file left behind"); + } + let mut perms = std::fs::metadata(&ro).unwrap().permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(&ro, perms).unwrap(); + } + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/cmd/file/tab_order_tests.rs b/crates/engine/src/cmd/file/tab_order_tests.rs new file mode 100644 index 00000000..f884ee42 --- /dev/null +++ b/crates/engine/src/cmd/file/tab_order_tests.rs @@ -0,0 +1,18 @@ +use super::*; + +#[test] +fn moving_and_closing_inactive_tabs_preserves_the_active_document() { + let mut session = Session::new(); + for name in ["Two", "Three", "Four"] { + session.execute("file.new", &json!({"name": name})).unwrap(); + } + session.execute("doc.switch", &json!({"index": 2})).unwrap(); + let active_uid = session.docs[session.active].uid; + let moved_uid = session.docs[0].uid; + session.execute("file.moveTab", &json!({"from": 0, "to": 2})).unwrap(); + assert_eq!(session.docs[2].uid, moved_uid); + assert_eq!(session.docs[session.active].uid, active_uid); + session.execute("file.close", &json!({"index": 0})).unwrap(); + assert_eq!(session.docs[session.active].uid, active_uid); + assert!(session.execute("file.moveTab", &json!({"from": 0, "to": 99})).is_err()); +} diff --git a/crates/engine/src/cmd/file/version.rs b/crates/engine/src/cmd/file/version.rs new file mode 100644 index 00000000..3d311a44 --- /dev/null +++ b/crates/engine/src/cmd/file/version.rs @@ -0,0 +1,172 @@ +use super::*; + +pub(super) fn version_json(index: usize, v: &Version) -> Value { + let mut j = json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time}); + if v.auto { + j["auto"] = json!(true); + } + j +} + +/// Autosaves kept per file; older ones make room for new ones. +pub const MAX_AUTOSAVES: usize = 20; + +/// `version.autosave`: a checkpoint of a document with unsaved changes since the last version. +pub(super) fn autosave(s: &mut Session, _: &Value) -> Result { + let auto_save = s.prefs.auto_save; + let ds = s.ds_mut()?; + // A file that saves itself is rarely unsaved: edits since it was opened count then. + let edited = ds.is_dirty() || (auto_save && !ds.history.undo.is_empty()); + if ds.preview.is_some() || !edited || !ds.changed_since_checkpoint() { + return Ok(json!({"saved": false})); + } + Ok(match add_version(ds, AUTOSAVE, "", true) { + Some(v) => json!({"saved": true, "version": v}), + None => json!({"saved": false}), + }) +} + +/// What checkpoints are called. +const AUTOSAVE: &str = "Autosave"; + +/// Add a version of `ds`'s document as it is now (`auto`: a checkpoint) and return it. The oldest +/// autosave makes room when the autosaves or the history are full; versions the user named are +/// never dropped, so a history full of them takes nothing (None). +pub(crate) fn add_version(ds: &mut DocState, name: &str, description: &str, auto: bool) -> Option { + let autos = ds.versions.iter().filter(|v| v.auto).count(); + if ((auto && autos >= MAX_AUTOSAVES) || ds.versions.len() >= figcraft_format::MAX_VERSIONS) + && let Some(oldest) = ds.versions.iter().position(|v| v.auto) + { + ds.versions_mut().remove(oldest); + } + if ds.versions.len() >= figcraft_format::MAX_VERSIONS { + return None; + } + let mut v = snapshot_version(ds, name, description); + v.auto = auto; + let out = version_json(ds.versions.len(), &v); + ds.versions_mut().push(v); + ds.mark_checkpoint(); + Some(out) +} + +/// Before the document is replaced (a restore, a merge): keep it in the history as it is, unless +/// it is what the last version saved or restored holds. +pub(crate) fn keep_in_history(ds: &mut DocState) { + if ds.branch_of.is_none() && ds.changed_since_checkpoint() { + add_version(ds, AUTOSAVE, "", true); + } +} + +/// A version of `ds`'s document as it is now, opening on the page shown. +fn snapshot_version(ds: &DocState, name: &str, description: &str) -> Version { + let mut snap = ds.doc.clone(); + if snap.current_page != Some(ds.page) { + Arc::make_mut(&mut snap).current_page = Some(ds.page); + } + Version::new(unique_id(|id| ds.versions.iter().any(|v| v.id == id)), name, description, crate::cmd::comment::now_secs(), snap) +} + +pub(super) fn version_list(ds: &DocState) -> Value { + let previewing = ds.preview.as_ref().map(|p| p.id.as_str()); + Value::Array( + ds.versions + .iter() + .enumerate() + .map(|(i, v)| { + let mut j = version_json(i, v); + if previewing == Some(v.id.as_str()) { + j["previewing"] = json!(true); + } + j + }) + .collect(), + ) +} + +/// The version named by `id`, else by `index` (validated: never negative, never past the end). +pub(super) fn version_index(cmd: &str, ds: &DocState, p: &Value) -> Result { + if let Some(id) = string(p, "id") { + return ds.versions.iter().position(|v| v.id == id).ok_or_else(|| bad(cmd, format!("no version `{id}`"))); + } + if get(p, "index").is_none() { + return Err(bad(cmd, "pass the version's `id` or `index`")); + } + let i = req_index(cmd, p, "index")?; + if i < ds.versions.len() { Ok(i) } else { Err(bad(cmd, format!("no version at index {i}"))) } +} + +pub(crate) fn read_version(v: &Version) -> Result> { + v.document().map_err(|e| EngineError::Other(format!("version “{}” can't be read: {e}", v.name))) +} + +/// A new version or branch id: the time in milliseconds (hex), not `taken`. +pub(crate) fn unique_id(taken: impl Fn(&str) -> bool) -> String { + let mut n = web_time::SystemTime::now().duration_since(web_time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0); + loop { + let id = format!("{n:x}"); + if !taken(&id) { + return id; + } + n = n.wrapping_add(1); + } +} + +/// `file.saveVersion`; on a branch tab the version goes into the branch's own history. +pub(super) fn save_version(s: &mut Session, p: &Value) -> Result { + let ds = s.ds_mut()?; + if ds.versions.len() >= figcraft_format::MAX_VERSIONS { + return Err(EngineError::Disabled( + "file.saveVersion".into(), + format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), + )); + } + let name = + string(p, "name").map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| format!("Version {}", ds.versions.len() + 1)); + let description = string(p, "description").unwrap_or_default(); + // The version opens on the page shown when it was saved. + add_version(ds, &name, description.trim(), false).ok_or_else(|| { + EngineError::Disabled( + "file.saveVersion".into(), + format!("the version history is full ({} versions): delete old versions first", figcraft_format::MAX_VERSIONS), + ) + }) +} + +pub(super) fn restore_version(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "version.restore"; + // Refused before anything goes into the history. + if let Some(why) = s.live_read_only(s.ds()?.uid) { + return Err(EngineError::Disabled(CMD.into(), why.into())); + } + let ds = s.ds_mut()?; + let i = version_index(CMD, ds, p)?; + let v = ds.versions.get(i).ok_or_else(|| bad(CMD, "no such version"))?; + let (id, mut doc) = (v.id.clone(), read_version(v)?); + // Restoring never loses work: the document as it is goes into the history first. + keep_in_history(ds); + let out = ds.versions.iter().enumerate().find(|(_, v)| v.id == id).map_or(Value::Null, |(i, v)| version_json(i, v)); + let d = Arc::make_mut(&mut doc); + d.name = ds.doc.name.clone(); + // What the file published belongs to the file, not to the version. + d.published = ds.doc.published.clone(); + // Comments are a conversation about the file, kept apart from its design history: they stay, + // and those on layers the version doesn't have stay where they were. + d.comments = ds.doc.comments.clone(); + crate::cmd::comment::follow_anchors(d, &ds.doc); + // The snapshot is already consistent: nothing to re-normalise. + d.take_dirty(); + // Stay on the current page when the version has it. + ds.page = Some(ds.page) + .filter(|pg| d.pages.contains(pg)) + .or_else(|| d.current_page.filter(|pg| d.pages.contains(pg))) + .or_else(|| d.first_page()) + .unwrap_or(ds.page); + ds.doc = doc; + ds.selection.clear(); + ds.text_edit = None; + ds.vector_edit = None; + // The restored design is in the history already: restoring it again keeps nothing new. + ds.mark_checkpoint(); + Ok(out) +} diff --git a/crates/engine/src/cmd/file/zip_cap_tests.rs b/crates/engine/src/cmd/file/zip_cap_tests.rs new file mode 100644 index 00000000..ee3e461b --- /dev/null +++ b/crates/engine/src/cmd/file/zip_cap_tests.rs @@ -0,0 +1,11 @@ +use super::add_member; + +#[test] +fn zip_members_stop_at_the_limit_and_keep_what_fit() { + let mut members = Vec::new(); + add_member(&mut members, "a.png", vec![0; 6], 10).unwrap(); + add_member(&mut members, "b.png", vec![0; 4], 10).unwrap(); + let err = add_member(&mut members, "c.png", vec![0; 1], 10).unwrap_err().to_string(); + assert!(err.contains("as one zip"), "{err}"); + assert_eq!(members.iter().map(|(n, _)| *n).collect::>(), ["a.png", "b.png"]); +} diff --git a/crates/engine/src/codegen.rs b/crates/engine/src/codegen.rs deleted file mode 100644 index 5064c64f..00000000 --- a/crates/engine/src/codegen.rs +++ /dev/null @@ -1,1755 +0,0 @@ -//! Code for a layer, as shown by the Inspect panel and returned by `inspect.code`: CSS for the -//! layer itself, HTML or React for its subtree, and SwiftUI, Jetpack Compose or ArkUI (HarmonyOS; -//! stacks for auto layout, absolute offsets otherwise). Components become components whose -//! properties are parameters and instances become calls passing their values (see -//! [`crate::component_codegen`]); variables bound to a layer become CSS custom properties. The -//! output is a starting point, not a pixel-exact translation. - -use figcraft_color::{Color, PaintKind}; -use figcraft_doc::*; -use serde_json::json; - -use crate::cmd::CommandSpec; -use crate::component_codegen::{Component, Param}; -use crate::params::*; -use crate::{bad, spec}; - -pub fn specs() -> Vec { - vec![spec!( - "inspect.code", - "Code for layer", - "{id?, language?: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui (default css; html, react, leptos and vue are the whole layer tree; components take their properties as parameters, instances pass their values; a component connected to code (codeConnect.set) gives its import and snippet instead; tailwind is React with Tailwind CSS v4 utilities for pages: fluid auto layout boxes, instances inlined with their overrides, bound variables as theme utilities), mobile? (tailwind: a mobile frame with the same layer names; the code is mobile first with the desktop layer's classes behind lg:), unit?: px|rem (CSS, HTML, React, Leptos and Vue; default px), rootFontSize? (rem: pixels per rem, default 16)} → {code, language, connected}", - false, - |s, p| { - let cmd = "inspect.code"; - let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer"))?; - let lang = match string(p, "language") { - Some(l) => CodeLang::from_id(&l).ok_or_else(|| bad(cmd, "language: css|html|react|leptos|vue|tailwind|swiftui|compose|arkui"))?, - None => CodeLang::Css, - }; - let rem = match string(p, "unit").map(|u| u.to_ascii_lowercase()).as_deref() { - None | Some("px") => None, - Some("rem") => Some(num_or(p, "rootFontSize", 16.0)).filter(|r| r.is_finite() && *r > 0.0 && *r <= 1000.0), - Some(_) => return Err(bad(cmd, "unit: px|rem")), - }; - if string(p, "unit").is_some_and(|u| u.eq_ignore_ascii_case("rem")) && rem.is_none() { - return Err(bad(cmd, "rootFontSize: a positive number of pixels (up to 1000)")); - } - let doc = s.doc()?; - if !doc.contains(id) { - return Err(bad(cmd, "no such layer")); - } - let connected = crate::code_connect::link(doc, id, lang).is_some(); - let mobile = match get(p, "mobile") { - None | Some(serde_json::Value::Null) => None, - Some(v) => Some(id_of(v).filter(|m| doc.contains(*m)).ok_or_else(|| bad(cmd, "mobile: the id of a mobile frame"))?), - }; - let code = match (lang, mobile) { - (CodeLang::Tailwind, Some(m)) => crate::tailwind::code(doc, id, Some(m)), - _ => code_for(doc, id, lang), - }; - let code = match rem { - Some(root) if lang.is_web() => px_to_rem(&code, root), - _ => code, - }; - Ok(json!({"code": code, "language": lang.id(), "connected": connected})) - } - )] -} - -/// Languages the Inspect panel offers. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] -pub enum CodeLang { - #[default] - Css, - /// The whole layer tree as nested HTML elements with a stylesheet. - Html, - /// A React function component (TSX) with its stylesheet. - React, - /// A Leptos component (`view!` markup) with its stylesheet. - Leptos, - /// A Vue single-file component (`