mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 03:46:50 -06:00
Merge pull request #1394 from aiongg/layout
refactor: shared module layout (craft-contrib#20)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
use super::journal::flush_log;
|
||||
use super::*;
|
||||
|
||||
impl Backend for Store {
|
||||
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
|
||||
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<Server, String> {
|
||||
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<String> {
|
||||
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<HostInfo> {
|
||||
Some(self.host_info(Some(file)))
|
||||
}
|
||||
|
||||
fn open_hosting(&mut self, _ip: IpAddr, file: &str, owner: &str) -> Result<HostInfo, String> {
|
||||
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<OpenFile>) -> 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<OpenFile>) -> 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
|
||||
}
|
||||
}
|
||||
@@ -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::<IpAddr>().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<Item = &(&str, Vec<_>, 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<IpAddr>,
|
||||
fits: &dyn Fn(u64) -> Result<(), String>,
|
||||
) -> Result<(FileInfo, NewLink, NewLink, Option<String>), 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<OpenFile>) -> Vec<FileRow> {
|
||||
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<String> {
|
||||
let t = now();
|
||||
self.next_sweep = t.saturating_add(SWEEP_EVERY);
|
||||
let due: Vec<String> =
|
||||
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<IpAddr, String> {
|
||||
let ip = ip_key(ip.trim().parse::<IpAddr>().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<String> = 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<String> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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<Server, String> {
|
||||
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<Log, RewriteError> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<u64, ()> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<u64>) -> Result<LibraryReply, String> {
|
||||
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<u64, String> {
|
||||
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<Option<&mut UsageCache>, 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<Vec<FileUsage>, 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) }
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
use super::*;
|
||||
|
||||
impl Store {
|
||||
/// The files kept.
|
||||
pub fn list(&self, open: &dyn Fn(&str) -> Option<OpenFile>) -> Vec<FileInfo> {
|
||||
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<Vec<LinkInfo>, 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<NewLink, String> {
|
||||
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<usize> {
|
||||
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<Item = Sealed>) {
|
||||
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<String, String> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
+15
-1038
File diff suppressed because it is too large
Load Diff
@@ -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<String>,
|
||||
/// An address was blocked ([`ip_key`]): its connections are dropped.
|
||||
pub blocked: Option<IpAddr>,
|
||||
}
|
||||
|
||||
impl From<AdminReply> 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<Admission, String>;
|
||||
|
||||
/// The session for a file that isn't open (after [`admit`](Self::admit) let someone in).
|
||||
fn open(&mut self, file: &str) -> Result<Server, String>;
|
||||
|
||||
/// 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<String> {
|
||||
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<OpenFile>) -> 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<HostInfo> {
|
||||
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<HostInfo, String> {
|
||||
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<OpenFile>) -> 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;
|
||||
}
|
||||
@@ -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<IpAddr> {
|
||||
let last = req.headers().get_all("x-forwarded-for").iter().next_back()?.to_str().ok()?;
|
||||
let entry = last.rsplit(',').next()?.trim();
|
||||
entry.parse::<IpAddr>().ok().or_else(|| entry.parse::<SocketAddr>().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<AtomicUsize>,
|
||||
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<AtomicUsize>,
|
||||
pub(super) readers: Readers,
|
||||
}
|
||||
|
||||
pub(super) fn accept(listener: &TcpListener, hub: &Sender<HubIn>, stop: &AtomicBool, open: &Arc<AtomicUsize>, 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<Bytes> {
|
||||
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<Duplex>, max: usize, deadline: Option<Instant>) -> Result<Option<(ClientMsg, usize)>, 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::<ClientMsg>(&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<HubIn>, 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<Response, ErrorResponse> {
|
||||
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::<Out>(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<Duplex>,
|
||||
conn: u64,
|
||||
hub: &Sender<HubIn>,
|
||||
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<Duplex>, rx: &Receiver<Out>, 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<Duplex>) {
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<JoinHandle<()>> {
|
||||
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<HubIn>,
|
||||
stop: Arc<AtomicBool>,
|
||||
open: Arc<AtomicUsize>,
|
||||
/// Bytes queued for every connection together, and in flight to the hub.
|
||||
queued: Arc<AtomicUsize>,
|
||||
in_flight: InFlight,
|
||||
blocks: Blocks,
|
||||
reading: Arc<AtomicUsize>,
|
||||
hub_thread: Option<JoinHandle<()>>,
|
||||
accept_thread: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl Hosting {
|
||||
/// Serve `backend`'s files on `listener`.
|
||||
pub fn serve<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits) -> io::Result<Hosting> {
|
||||
Self::start(listener, backend, limits, None)
|
||||
}
|
||||
|
||||
pub(crate) fn start<B: Backend>(listener: TcpListener, backend: B, limits: NetLimits, local: Option<Local>) -> io::Result<Hosting> {
|
||||
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<AtomicUsize>);
|
||||
|
||||
impl Drop for Open {
|
||||
fn drop(&mut self) {
|
||||
self.0.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<Mutex<HashMap<Block, usize>>>);
|
||||
|
||||
/// 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<Counted> {
|
||||
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<Vec<Counted>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Charge>),
|
||||
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<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Held {
|
||||
fn new(n: usize, counter: &Arc<AtomicUsize>) -> Arc<Held> {
|
||||
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<Arc<Held>>,
|
||||
pub(super) blocks: Vec<(usize, Arc<Held>)>,
|
||||
/// 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<AtomicUsize>,
|
||||
_all: Arc<Held>,
|
||||
_block: Option<Arc<Held>>,
|
||||
}
|
||||
|
||||
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<Out>,
|
||||
pub(crate) queued: Arc<AtomicUsize>,
|
||||
pub(crate) block: Arc<AtomicUsize>,
|
||||
pub(crate) all: Arc<AtomicUsize>,
|
||||
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<AtomicUsize>,
|
||||
pub(super) reading: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
/// The blocks with open connections; a block leaves when its last connection ends.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Blocks(pub(crate) Arc<Mutex<HashMap<IpAddr, BlockUse>>>);
|
||||
|
||||
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<AtomicUsize>);
|
||||
|
||||
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,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<NodeId> = 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<String> {
|
||||
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<NodeId> {
|
||||
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<NodeId> = 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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<String, Value> {
|
||||
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;
|
||||
@@ -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<Item = (&'a String, &'a String)> + '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<f64>; 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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<NodeId> = 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<NodeId> {
|
||||
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
|
||||
}
|
||||
@@ -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<NodeId>) -> Vec<NodeId> {
|
||||
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
|
||||
}
|
||||
@@ -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<NodeId, DocError> {
|
||||
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<usize>) -> Result<NodeId, DocError> {
|
||||
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<usize>) -> Result<NodeId, DocError> {
|
||||
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<NodeId, String> {
|
||||
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/<name>`): 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<Node>, 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<Node>)> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<NodeId> = 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<Node>)>) {
|
||||
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<NodeId> = 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<NodeId> = 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId>) {
|
||||
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<NodeId> = 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<NodeId> = 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<NodeId> = Vec::new();
|
||||
let mut covered: BTreeSet<NodeId> = 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<bool> = const { std::cell::Cell::new(false) };
|
||||
/// Tests: edits carried to the copies without rebuilding instances.
|
||||
pub(crate) static PATCHED: std::cell::Cell<usize> = 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<Vec<&'static str>> {
|
||||
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<NodeId, Vec<NodeId>> {
|
||||
let mut copies: BTreeMap<NodeId, Vec<NodeId>> = 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<Vec<NodeId>> {
|
||||
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<NodeId> = 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<NodeId, NodeId> = 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<NodeId> {
|
||||
let sub = doc.subtree(src);
|
||||
let map: BTreeMap<NodeId, NodeId> = 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<NodeId>) {
|
||||
if comps.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Outermost instances by the components their layers instantiate.
|
||||
let mut roots_of: BTreeMap<NodeId, BTreeSet<NodeId>> = 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<NodeId, usize> = comps.iter().map(|c| (*c, 0)).collect();
|
||||
let mut outer: BTreeMap<NodeId, Option<NodeId>> = BTreeMap::new();
|
||||
let mut frontier: Vec<NodeId> = 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId, Option<Vec<NodeId>>>,
|
||||
parents: BTreeMap<(NodeId, NodeId), Option<bool>>,
|
||||
main_changes: BTreeMap<NodeId, bool>,
|
||||
}
|
||||
|
||||
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<Vec<NodeId>> {
|
||||
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<bool> {
|
||||
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<NodeId> {
|
||||
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<Vec<(NodeId, NodeId)>> {
|
||||
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<NodeId> {
|
||||
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<String, NodeId> =
|
||||
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<String> {
|
||||
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<String> = 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<String> = 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);
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId, std::sync::Arc<Node>>) {
|
||||
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<NodeId, std::sync::Arc<Node>> = spare;
|
||||
let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new();
|
||||
let mut by_src: BTreeMap<NodeId, Option<NodeId>> = 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<BTreeMap<String, NodeId>> = None;
|
||||
let mut main_paths: Option<BTreeMap<NodeId, String>> = 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<NodeId> = doc.subtree(component).into_iter().filter(|i| *i != component).collect();
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
map.insert(component, inst);
|
||||
let mut used: BTreeSet<NodeId> = 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<NodeId> = BTreeSet::new();
|
||||
let mut skipped: BTreeSet<NodeId> = 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<NodeId, Vec<NodeId>> = BTreeMap::new();
|
||||
enclosing.insert(inst, vec![inst]);
|
||||
let mut inst_props: BTreeMap<NodeId, BTreeMap<String, Value>> = 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<NodeId> = 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<NodeId, std::sync::Arc<Node>> =
|
||||
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<NodeId, std::sync::Arc<Node>> = 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<NodeId, std::sync::Arc<Node>>,
|
||||
below: &mut BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
) {
|
||||
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<NodeId> {
|
||||
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<NodeId>) -> BTreeMap<String, std::sync::Arc<Node>> {
|
||||
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<String, std::sync::Arc<Node>>) {
|
||||
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<Node> {
|
||||
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<NodeId> {
|
||||
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(())
|
||||
}
|
||||
@@ -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<NodeId> {
|
||||
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<Vec<NodeId>> {
|
||||
let mut by: BTreeMap<BTreeMap<String, String>, Vec<NodeId>> = 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<String, String> {
|
||||
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<String, String>, 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::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Variant properties of a set: property → values, in first-seen order.
|
||||
pub fn variant_options(doc: &Document, set: NodeId) -> Vec<(String, Vec<String>)> {
|
||||
let mut out: Vec<(String, Vec<String>)> = 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<String, String>) -> Option<NodeId> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
fn walk(doc: &Document, inst: &Node, main: &Node, depth: usize, out: &mut Vec<String>) {
|
||||
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<NodeId> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<NodeId> = 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
|
||||
}
|
||||
@@ -1331,5 +1331,4 @@ pub fn published_variables(lib: &PublishedLibrary) -> impl Iterator<Item = &Vari
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library_tests.rs"]
|
||||
mod tests;
|
||||
@@ -212,5 +212,4 @@ impl Session {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library_server_tests.rs"]
|
||||
mod tests;
|
||||
@@ -245,5 +245,4 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "library_swap_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod batch;
|
||||
pub mod doc_urls;
|
||||
pub mod file_access;
|
||||
pub mod inspect;
|
||||
pub mod params;
|
||||
@@ -306,5 +306,4 @@ pub fn create_dir_all(path: impl AsRef<Path>) -> Result<()> {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "file_access_tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
pub mod board;
|
||||
pub mod board_table;
|
||||
pub mod board_templates;
|
||||
@@ -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<CommandSpec> {
|
||||
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<Value> {
|
||||
require(s, &format!("tool.{}", t.id()))?;
|
||||
crate::cmd::view::tool(s, t)
|
||||
}
|
||||
|
||||
fn shape_of(cmd: &str, v: &str) -> Result<BoardShape> {
|
||||
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> {
|
||||
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> {
|
||||
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<Option<StrokeCap>> {
|
||||
match get(p, key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => crate::cmd::props::enum_of::<StrokeCap>(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<Option<Color>> {
|
||||
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<NodeId> {
|
||||
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)
|
||||
}
|
||||
@@ -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<End> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<End> {
|
||||
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))
|
||||
}
|
||||
@@ -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<NodeId>) {
|
||||
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<NodeId>) {
|
||||
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<NodeId> = 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<NodeId>, 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<Point> {
|
||||
let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None };
|
||||
let w = doc.world_transform(id);
|
||||
let mut pts: Vec<Point> = 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<Rect> {
|
||||
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::<Vec<_>>(), 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<Vec<Point>> {
|
||||
let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n));
|
||||
let centre = |e: &End, b: Option<Rect>| 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::<Vec<_>>()),
|
||||
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<Point>) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = 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<Rect>, other: Point) -> Option<(Point, Option<Side>)> {
|
||||
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<Side>, p1: Point, s1: Option<Side>) -> Vec<Point> {
|
||||
let reach = (p1 - p0).hypot() / 2.0;
|
||||
let pull = |p: Point, s: Option<Side>| 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<Side>, p1: Point, s1: Option<Side>, boxes: &[Rect]) -> Vec<Point> {
|
||||
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<Side>| 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<Point>| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::<f64>() + 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<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
|
||||
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],
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId> {
|
||||
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<Option<NodeId>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<BoardShape>,
|
||||
routing: Option<Routing>,
|
||||
size: Option<f64>,
|
||||
start: Option<StrokeCap>,
|
||||
end: Option<StrokeCap>,
|
||||
}
|
||||
|
||||
impl Restyle {
|
||||
fn of(cmd: &str, p: &Value) -> Result<Restyle> {
|
||||
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<bool> {
|
||||
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<bool> {
|
||||
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<Value> {
|
||||
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<NodeId, NodeId> {
|
||||
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<NodeId, NodeId>) -> 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<NodeId, NodeId>, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<Value> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<bool> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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}))
|
||||
}
|
||||
@@ -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<bool> {
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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<Preedit> {
|
||||
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::<Vec<_>>(), ["opsz"]);
|
||||
// Undo restores the previous values.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(data(&s, id).style.variations.keys().collect::<Vec<_>>(), ["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;
|
||||
@@ -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<Preedit> {
|
||||
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::<Vec<_>>(), ["opsz"]);
|
||||
// Undo restores the previous values.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(data(&s, id).style.variations.keys().collect::<Vec<_>>(), ["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);
|
||||
}
|
||||
@@ -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> {
|
||||
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<f32>,
|
||||
}
|
||||
|
||||
/// 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<Guide>,
|
||||
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<NodeId>,
|
||||
/// 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<NodeId>,
|
||||
/// 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<crate::vectoredit::VHit>,
|
||||
/// The edited layer `vector_hover` is on (several layers can be in edit mode).
|
||||
pub vector_hover_on: Option<NodeId>,
|
||||
/// 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<VectorAdd>,
|
||||
/// Vector edit mode, Variable width tool: the width point, handle or path under the pointer.
|
||||
pub width_hover: Option<crate::vectoredit::WHit>,
|
||||
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<Vec<[f64; 2]>>,
|
||||
/// 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<usize>,
|
||||
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<ScaleHint>,
|
||||
/// The selected ellipse's arc handle under the pointer, and the readout while one is dragged
|
||||
/// (see [`crate::arc_handles`]).
|
||||
pub arc_hover: Option<ArcHandle>,
|
||||
pub arc: Option<ArcHint>,
|
||||
/// 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<ShapeHandle>,
|
||||
pub shape: Option<ShapeHint>,
|
||||
}
|
||||
|
||||
/// 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<NodeId>,
|
||||
within: Option<NodeId>,
|
||||
},
|
||||
Move {
|
||||
start: Point,
|
||||
ids: Vec<NodeId>,
|
||||
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<NodeId>,
|
||||
},
|
||||
Resize {
|
||||
start: Point,
|
||||
r: Box<ResizeStart>,
|
||||
scale_tool: bool,
|
||||
moved: bool,
|
||||
},
|
||||
Rotate {
|
||||
center: Point,
|
||||
start_angle: f64,
|
||||
ids: Vec<NodeId>,
|
||||
},
|
||||
Draw {
|
||||
start: Point,
|
||||
id: Option<NodeId>,
|
||||
parent: NodeId,
|
||||
targets: Targets,
|
||||
},
|
||||
Pan {
|
||||
sx: f64,
|
||||
sy: f64,
|
||||
cx: f64,
|
||||
cy: f64,
|
||||
},
|
||||
Pencil {
|
||||
points: Vec<Point>,
|
||||
/// The pen pressure at each point, where the device reports it.
|
||||
pressure: Vec<Option<f32>>,
|
||||
},
|
||||
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<NodeId>,
|
||||
},
|
||||
/// Shape builder: regions collected so far (handled in `shapebuilder`).
|
||||
ShapeBuild {
|
||||
picked: Vec<usize>,
|
||||
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<NodeId>,
|
||||
pub parent: Option<NodeId>,
|
||||
/// 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<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ToolState {
|
||||
pub tool: ToolId,
|
||||
pub(crate) gesture: Gesture,
|
||||
pub overlay: Overlay,
|
||||
pub pen: Option<PenPath>,
|
||||
/// Tool to return to after a temporary tool (space → hand).
|
||||
pub temp_from: Option<ToolId>,
|
||||
/// Last click (for double-click detection by agents that only send downs).
|
||||
pub last_hover: Option<NodeId>,
|
||||
/// Shape builder: the cached arrangement of the selection, and Remove mode (a plain click
|
||||
/// removes; Alt merges).
|
||||
pub shape_builder: Option<std::sync::Arc<crate::shapebuilder::Arrangement>>,
|
||||
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<crate::vectoredit::point_snap::PenTargets>,
|
||||
/// 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<PointerEvent>,
|
||||
/// 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<figcraft_doc::Brush>,
|
||||
/// 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<figcraft_color::Color>,
|
||||
}
|
||||
|
||||
impl ToolState {
|
||||
pub fn busy(&self) -> bool {
|
||||
!matches!(self.gesture, Gesture::None)
|
||||
}
|
||||
}
|
||||
@@ -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<bool> {
|
||||
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<usize> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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::<f64>() / 4.0, b.iter().map(|c| c.y).sum::<f64>() / 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<NodeId>| {
|
||||
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::<Vec<_>>();
|
||||
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<NodeId> = 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<NodeId> = 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<NodeId> = 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<Guide>) -> 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(())
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId>, 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(())
|
||||
}
|
||||
}
|
||||
@@ -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<NodeId> {
|
||||
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<Vec<figcraft_color::Paint>> {
|
||||
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<NodeId>) {
|
||||
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<f64> {
|
||||
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<NodeId> = 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<NodeId> = 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<Handle> {
|
||||
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<Handle> {
|
||||
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<Item = (f64, Handle)>| 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::<f64>() / 4.0, corners.iter().map(|c| c.y).sum::<f64>() / 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::<f64>() / 4.0, c.iter().map(|p| p.y).sum::<f64>() / 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<Point> {
|
||||
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<ShapeHint> {
|
||||
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<u8, u8>) {}
|
||||
|
||||
/// 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))
|
||||
})
|
||||
}
|
||||
@@ -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<bool> {
|
||||
// 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<bool> {
|
||||
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<NodeId> {
|
||||
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<Point>) -> 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<NodeId> =
|
||||
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<crate::arc_handles::ArcHandles> {
|
||||
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<serde_json::Value> = 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})
|
||||
}
|
||||
}
|
||||
@@ -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<f32>]) -> Option<Vec<[f64; 2]>> {
|
||||
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<NodeId> = 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<Guide>)> {
|
||||
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<usize>) -> 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<f32>]) -> 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(())
|
||||
}
|
||||
}
|
||||
@@ -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<Option<NodeId>> {
|
||||
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<NodeId>, deep: bool, base: &[NodeId], additive: bool) -> Result<Vec<NodeId>> {
|
||||
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<NodeId> = 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<bool> {
|
||||
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<NodeId> = 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()
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
+11
-1182
File diff suppressed because it is too large
Load Diff
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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::<String>().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<Vec<NodeId>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<NodeId> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<String> = 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<String, Value>) -> BTreeMap<String, PropDef> {
|
||||
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<NodeId, NodeId> {
|
||||
let mut map: BTreeMap<NodeId, NodeId> = BTreeMap::new();
|
||||
let mut used: BTreeSet<NodeId> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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}))
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<Value> {
|
||||
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<Owner> {
|
||||
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<NodeId> {
|
||||
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<String, PropDef> {
|
||||
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<String, PropDef>)) -> Result<()> {
|
||||
if let NodeKind::Component { props, .. } = &mut doc.get_mut(c)?.kind {
|
||||
f(props);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn valid_prop_name(cmd: &str, name: &str) -> Result<String> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<String> = 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<String> = 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<Value> {
|
||||
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<String> = 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<Value> = 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<NodeId> = 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})
|
||||
}
|
||||
@@ -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<NodeId> {
|
||||
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::<BTreeSet<_>>().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<NodeId> {
|
||||
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<Value> {
|
||||
let cmd = "component.combineVariants";
|
||||
let mut seen = BTreeSet::new();
|
||||
let ids: Vec<NodeId> = 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<Value> {
|
||||
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<String> = options.iter().map(|(k, _)| k.clone()).collect();
|
||||
let existing: BTreeSet<BTreeMap<String, String>> =
|
||||
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}))
|
||||
}
|
||||
+16
-1051
File diff suppressed because it is too large
Load Diff
@@ -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<NodeId> = 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<Node>)>, BTreeMap<String, ImageData>, Option<figcraft_geom::Rect>);
|
||||
|
||||
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<Node> = 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<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<Value> {
|
||||
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<NodeId> = 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<Value> {
|
||||
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<Value> {
|
||||
let ids = req_targets("edit.cut", s, p)?;
|
||||
copy_to_clipboard(s, "edit.cut", &ids)?;
|
||||
let doc = s.doc()?;
|
||||
let layers: Vec<NodeId> = 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<Vec<NodeId>> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn batch_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
/// 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<usize> {
|
||||
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<Value> {
|
||||
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<char> = find.chars().collect();
|
||||
let r_chars: Vec<char> = 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<Vec<char>> = 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::<Vec<_>>(), &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<char> = 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<Value> {
|
||||
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<NodeId, Map<String, Value>> = 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<NodeId> = 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<String> =
|
||||
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::<Vec<_>>().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<String, Value>) -> Vec<String> {
|
||||
let Some(style) = props.get("fontStyle").and_then(Value::as_str) else { return Vec::new() };
|
||||
let mut families: Vec<String> = 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::<Vec<_>>())
|
||||
.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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn layers_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> = 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<Value> {
|
||||
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<NodeId> = 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<Value> {
|
||||
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<NodeId> =
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let doc = s.doc()?;
|
||||
let mut ids: Vec<NodeId> = 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<Value> {
|
||||
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<Value> {
|
||||
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<NodeId> =
|
||||
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<Value> {
|
||||
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<NodeId> = 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<f32> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<NodeId, Vec<NodeId>> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let sel = s.selection();
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
let keep: BTreeSet<NodeId> = sel
|
||||
.iter()
|
||||
.flat_map(|i| {
|
||||
let mut v = doc.ancestors(*i);
|
||||
v.push(*i);
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let tops: Vec<NodeId> = doc.get(page)?.children.clone();
|
||||
for t in tops {
|
||||
if !keep.contains(&t) {
|
||||
doc.get_mut(t)?.visible = false;
|
||||
}
|
||||
}
|
||||
ok()
|
||||
}
|
||||
+34
-1770
File diff suppressed because it is too large
Load Diff
@@ -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<Vec<u8>> {
|
||||
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<figcraft_geom::Rect> {
|
||||
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<NodeId> {
|
||||
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<NodeId> {
|
||||
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<Vec<u8>> {
|
||||
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<figcraft_render::Pixmap> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<u8>, Vec<String>)> {
|
||||
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<ExportSetting> {
|
||||
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<PrintSettings> {
|
||||
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<Option<ExportSize>> {
|
||||
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::<ExportSize>(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<u8>)>, name: &'a str, bytes: Vec<u8>, limit: usize) -> Result<()> {
|
||||
let total = members.iter().map(|(_, b)| b.len()).sum::<usize>().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<ExportJob> {
|
||||
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<Item = (NodeId, Vec<ExportSetting>)>, doc: &Document) -> Vec<ExportJob> {
|
||||
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<String> =
|
||||
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<NodeId> {
|
||||
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<Value> {
|
||||
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<NodeId> = 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<NodeId>], print: &PrintSettings, area: Option<figcraft_geom::Rect>) -> Result<Vec<u8>> {
|
||||
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<u8>, 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<Vec<NodeId>> = 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<Value> {
|
||||
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)})),
|
||||
}
|
||||
}
|
||||
@@ -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<DocState> {
|
||||
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<Vec<u8>> {
|
||||
crate::file_access::read(path)
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) fn read(path: &str) -> Result<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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<String>)> {
|
||||
load_path(path, None).map(|(l, _)| (l.doc, l.issues))
|
||||
}
|
||||
|
||||
pub fn load_bytes(bytes: &[u8], name_hint: &str) -> Result<(Document, Vec<String>)> {
|
||||
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<Result<figcraft_format::Loaded>> {
|
||||
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<NodeId> = 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<String, Option<String>> = 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<NodeId> = 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::sync::Arc<figcraft_text::fontdb::Face>>> =
|
||||
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<NodeId> = 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<NodeId> = 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<String> {
|
||||
let key = |s: &str| s.chars().filter(|c| !c.is_whitespace()).flat_map(char::to_lowercase).collect::<String>();
|
||||
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<Result<figcraft_format::Loaded>> {
|
||||
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/<imageRef>[.png|.jpg|…]` next to
|
||||
/// the file.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn rest_images(path: &str) -> impl Fn(&str) -> Option<Vec<u8>> {
|
||||
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<Vec<u8>> {
|
||||
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: `<name>.variables.json`, else
|
||||
/// `variables.json`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn sibling_variables(path: &str) -> Option<Value> {
|
||||
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<Value> {
|
||||
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<Option<Value>> {
|
||||
let parse = |text: &[u8]| serde_json::from_slice::<Value>(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))
|
||||
}
|
||||
@@ -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<Value> {
|
||||
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<Value> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Vec<NodeId>> {
|
||||
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<String> = 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<String> = 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<NodeId> = 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<Node> = 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<Node> = 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)
|
||||
}
|
||||
@@ -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<NodeId, NodeId> = ids.iter().map(|i| (*i, doc.alloc_id())).collect();
|
||||
let nodes: Vec<Node> = 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);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user