mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 05:16:56 -06:00
Merge main into #1462
This commit is contained in:
@@ -62,6 +62,7 @@ code.
|
||||
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in shipped code
|
||||
(workspace clippy lints deny them; tests may use them). Return `Result`/`Option`; use `.get()` for
|
||||
data-derived indexes; slice strings only at char boundaries; check floats are finite; cap sizes.
|
||||
- New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
- `figcraft_engine::guard` turns a panicking command into `EngineError::Internal` and the document
|
||||
is rolled back; the desktop app also guards each UI frame and writes recovery copies. These are
|
||||
safety nets, not substitutes for `Result`.
|
||||
|
||||
Generated
+22
-22
@@ -1593,7 +1593,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"craft-update",
|
||||
@@ -1621,7 +1621,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1635,7 +1635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -1651,7 +1651,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-collab-net"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab",
|
||||
@@ -1674,7 +1674,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1683,7 +1683,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1695,7 +1695,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"craft-docpath",
|
||||
@@ -1727,7 +1727,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-pathops",
|
||||
@@ -1743,7 +1743,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1751,7 +1751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1759,7 +1759,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-collab-net",
|
||||
@@ -1775,7 +1775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1784,7 +1784,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1801,7 +1801,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-plugin"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"serde",
|
||||
@@ -1814,7 +1814,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1832,7 +1832,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-server"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"ctrlc",
|
||||
@@ -1856,7 +1856,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1872,7 +1872,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"core-text",
|
||||
"figcraft-doc",
|
||||
@@ -1888,7 +1888,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -1916,7 +1916,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-vectordrawable"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1930,7 +1930,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"eframe",
|
||||
@@ -6422,7 +6422,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
dependencies = [
|
||||
"figcraft-engine",
|
||||
"serde_json",
|
||||
|
||||
+5
-2
@@ -6,7 +6,7 @@ exclude = ["plugins"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.11.2"
|
||||
version = "0.12.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -102,6 +102,9 @@ webbrowser = "1"
|
||||
unsafe_code = "forbid"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
cognitive_complexity = "deny"
|
||||
too_many_arguments = "deny"
|
||||
wildcard_imports = "deny"
|
||||
dbg_macro = "warn"
|
||||
# Style lints from newer toolchains that fight index-heavy pixel code.
|
||||
needless_range_loop = "allow"
|
||||
@@ -130,7 +133,7 @@ lto = "thin"
|
||||
codegen-units = 4
|
||||
|
||||
# The browser build only (apps/figcraft-web/index.html: data-cargo-profile). Optimised for size:
|
||||
# packaging/web/package.sh fails a .wasm over 24 MiB (Cloudflare's per-file cap is 25 MiB).
|
||||
# packaging/web/package.sh fails a .wasm over 25 MiB - 512 KiB (Cloudflare's per-file cap is 25 MiB).
|
||||
# wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there.
|
||||
[profile.web-release]
|
||||
inherits = "release"
|
||||
|
||||
@@ -64,6 +64,16 @@ fn is_code(shown: &str) -> bool {
|
||||
shown.len() == 11 && matches!(code::parse(shown), Ok(code::Code::Online(_)))
|
||||
}
|
||||
|
||||
/// The links file keeps hashes only, never the codes or the token they wrap.
|
||||
fn only_hashes_on_disk(dir: &std::path::Path, file: &str, wrapped: &str, edit: &str, view: &str) {
|
||||
let links = std::fs::read_to_string(dir.join("files").join(file).join("links.json")).unwrap();
|
||||
for secret in [edit, view, &edit.replace('-', ""), &view.replace('-', "")] {
|
||||
assert!(!links.contains(secret), "{links}");
|
||||
}
|
||||
assert!(!links.contains(wrapped.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
|
||||
assert_eq!(links.matches("\"code\"").count(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
let dir = TempDir::new("codes-share");
|
||||
@@ -90,12 +100,7 @@ fn a_file_shared_online_is_joined_by_its_codes() {
|
||||
let before_stop = ids(dir.path());
|
||||
assert_eq!(before_stop.len(), 4);
|
||||
// On disk: hashes only, of the codes as of the links' tokens.
|
||||
let links = std::fs::read_to_string(dir.path().join("files").join(&p.file).join("links.json")).unwrap();
|
||||
for secret in [edit.as_str(), view.as_str(), &edit.replace('-', ""), &view.replace('-', "")] {
|
||||
assert!(!links.contains(secret), "{links}");
|
||||
}
|
||||
assert!(!links.contains(p.edit.rsplit('=').next().unwrap()), "the wrapped token isn't the token");
|
||||
assert_eq!(links.matches("\"code\"").count(), 2);
|
||||
only_hashes_on_disk(dir.path(), &p.file, &p.edit, &edit, &view);
|
||||
// Someone types the edit code, the way people type.
|
||||
let mut b = app("Bo", &url);
|
||||
assert_eq!(ex(&mut b, "collab.join", json!({"code": edit.to_lowercase().replace('-', " ")}))["status"], "connecting");
|
||||
|
||||
@@ -41,6 +41,36 @@ fn answer(raw: &mut Raw) -> LibraryReply {
|
||||
}
|
||||
}
|
||||
|
||||
/// Junk from an edit link is refused and the library unharmed; a wrong token isn't let in.
|
||||
fn junk_is_refused(addr: std::net::SocketAddr, edit: &Invite) {
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(edit));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
|
||||
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
|
||||
let mut rng = Rng::new(5);
|
||||
for round in 0..12 {
|
||||
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let i = rng.below(junk.len());
|
||||
junk[i] = rng.next_u64() as u8;
|
||||
}
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
|
||||
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
|
||||
}
|
||||
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Usage));
|
||||
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
|
||||
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
|
||||
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
|
||||
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_enable_update_and_usage() {
|
||||
let dir = TempDir::new("library");
|
||||
@@ -106,34 +136,8 @@ fn publish_enable_update_and_usage() {
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: vec![1, 2, 3] }));
|
||||
assert!(matches!(answer(&mut raw), LibraryReply::Failed { reason } if reason.contains("only view")));
|
||||
|
||||
// Junk from an edit link: refused, the library unharmed. A wrong token isn't let in.
|
||||
let edit = Invite::parse(&p.edit).unwrap();
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&edit));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Ready { access: Access::Edit });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: None, usage: None }));
|
||||
let LibraryReply::Library { version: 2, archive } = answer(&mut raw) else { panic!("no library") };
|
||||
let mut rng = Rng::new(5);
|
||||
for round in 0..12 {
|
||||
let mut junk = if round % 3 == 0 { vec![0x5A; 1 + rng.below(4096)] } else { archive.clone() };
|
||||
for _ in 0..1 + rng.below(8) {
|
||||
let i = rng.below(junk.len());
|
||||
junk[i] = rng.next_u64() as u8;
|
||||
}
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Publish { archive: junk }));
|
||||
assert!(matches!(answer(&mut raw), LibraryReply::Failed { .. }), "round {round}");
|
||||
}
|
||||
let flood = Usage { file: "\u{7}Spam\n".repeat(100), components: (0..5_000).map(|i| (NodeId(i), 1)).collect() };
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Fetch { have: Some(2), usage: Some(flood) }));
|
||||
assert_eq!(answer(&mut raw), LibraryReply::Unchanged { version: 2 });
|
||||
raw.send(&ClientMsg::Library(LibraryReq::Usage));
|
||||
let LibraryReply::Usage(files) = answer(&mut raw) else { panic!("no usage") };
|
||||
let spam = files.iter().find(|f| f.usage.file.starts_with("Spam")).unwrap();
|
||||
assert!(spam.usage.file.chars().count() <= 256 && !spam.usage.file.contains('\n'), "names are cleaned and capped");
|
||||
assert_eq!(spam.usage.components.len(), 1_000, "reports are capped");
|
||||
let mut raw = Raw::connect(addr);
|
||||
raw.send(&open(&Invite { token: "nope".into(), ..edit.clone() }));
|
||||
assert!(matches!(raw.next(), ServerMsg::Refused { .. }));
|
||||
junk_is_refused(addr, &edit);
|
||||
assert_eq!(ex(&mut b, "library.list", json!({}))["libraries"][0]["version"], 2);
|
||||
|
||||
// Kept on disk: the library is there after the server stops.
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::journal::flush_log;
|
||||
use super::*;
|
||||
use super::{
|
||||
Access, Action, Admin, AdminReply, Admission, BUSY_DOWNLOADS, Backend, ClientMsg, Closing, Handled, HostInfo, Instant, IpAddr, LibraryReply,
|
||||
LibraryReq, OpenFile, PluginReply, PluginReq, Server, Store, disk, now, same_secret,
|
||||
};
|
||||
|
||||
impl Backend for Store {
|
||||
fn admit(&mut self, file: &str, token: &str) -> Result<Admission, String> {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Access, Action, AdminReply, BLOCKED, Change, Entry, FILES, FileInfo, FileRow, HostInfo, Instant, IpAddr, KEY_ONLY, LINKS, LOG, LOG_HEADER,
|
||||
MAX_BLOCKED, MAX_OWNED, MAX_REPORTS, META, Meta, NewLink, OpenFile, OwnedFile, REPORTS, Replica, ReportInfo, SNAPSHOT, SWEEP_EVERY, Server,
|
||||
Store, clean_name, clean_reason, disk, fs, hash, ip_blocks, ip_key, new_link, now, same_secret, token, write_json,
|
||||
};
|
||||
|
||||
impl Store {
|
||||
/// Keep a document (its snapshot) as a new file with an edit link and a view link.
|
||||
@@ -222,7 +226,7 @@ impl Store {
|
||||
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)
|
||||
&& let Ok(f) = std::fs::File::open(path)
|
||||
{
|
||||
log.file = f;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
FORCED_COMPACTION_EVERY, Instant, LOG, LOG_HEADER, Log, MAX_PROBLEMS, META, OpenOptions, RewriteError, SNAPSHOT, Server, Store, Write, disk, fs,
|
||||
now, write_json,
|
||||
};
|
||||
|
||||
impl Store {
|
||||
/// Whether `file` has room for about `n` more bytes: its cap, and for a file published
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Action, BUSY_DOWNLOADS, Cursor, FILES, FileUsage, Instant, IpAddr, LIBRARY, LibraryMeta, LibraryReply, MAX_FIRST_LIBRARY_VERSION, MAX_REPORTED,
|
||||
MAX_USAGE_FILE, MAX_USERS, META, NO_PUBLIC_LIBRARY, Path, PathBuf, Store, USAGE, USAGE_WRITE_EVERY, Usage, UsageCache, clean_name, disk, fs, now,
|
||||
write_json,
|
||||
};
|
||||
|
||||
impl Store {
|
||||
/// A file's team library for `ip`, unless its version is `have`; within what one address and
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Access, Action, CODE_GONE, CODES_BUSY, Entry, FileInfo, Instant, IpAddr, LINKS, LinkInfo, MAX_GONE_CODES, NO_CODE, NewLink, OpenFile, Sealed,
|
||||
Store, clean_name, codes, fs, hash, new_link, now, same_secret, write_json,
|
||||
};
|
||||
|
||||
impl Store {
|
||||
/// The files kept.
|
||||
|
||||
@@ -7,3 +7,4 @@ too-many-arguments-threshold = 10
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::limits::Admission;
|
||||
use super::*;
|
||||
use super::{Access, Admin, AdminReply, ClientMsg, HostInfo, IpAddr, LibraryReply, LibraryReq, PluginReply, PluginReq, Server};
|
||||
|
||||
/// An admin request's reply, and what the hub must do about connections it affects.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use super::hosting::{Open, spawn};
|
||||
use super::limits::{PerAddress, ip_key};
|
||||
use super::queue::{Blocks, HubIn, InFlight, Line, Out, wide_block};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AtomicBool, AtomicUsize, Bucket, Bytes, ClientMsg, CloseCode, CloseFrame, Duplex, Duration, ErrorResponse, Instant, IpAddr, Message, Meter,
|
||||
NetLimits, Ordering, Read, Readers, Receiver, RecvTimeoutError, Request, Response, Role, Sender, ServerMsg, SocketAddr, TcpListener, TcpStream,
|
||||
WebSocket, Write, config, io, message, shutdown, sync_channel,
|
||||
};
|
||||
|
||||
/// How long a closing connection waits for the peer to hang up.
|
||||
const LINGER: Duration = Duration::from_secs(2);
|
||||
|
||||
@@ -2,7 +2,10 @@ use super::backend::Backend;
|
||||
use super::conn::{Shared, accept};
|
||||
use super::limits::{PerAddress, loopback};
|
||||
use super::queue::{Blocks, HubIn, InFlight};
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AtomicBool, AtomicUsize, ClientMsg, Duration, HashMap, Hub, Inbox, Instant, JoinHandle, MAX_NAME, NetLimits, Ordering, Presence, Readers,
|
||||
Room, Sender, Server, SocketAddr, TcpListener, TcpStream, channel, io,
|
||||
};
|
||||
|
||||
/// The person hosting an app session: in the file's session without a socket.
|
||||
pub(crate) struct Local {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Access, Arc, HashMap, IpAddr, Ipv6Addr, Mutex, PoisonError, SocketAddr};
|
||||
|
||||
/// What a link lets a hello do.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::limits::ip_blocks;
|
||||
use super::*;
|
||||
use super::{
|
||||
Arc, AtomicUsize, Bytes, ClientMsg, CloseCode, HashMap, IpAddr, Mutex, Ordering, PoisonError, Presence, SyncSender, TcpStream, TrySendError,
|
||||
};
|
||||
|
||||
/// A message for one connection's writer: bytes (with what they count against, released when
|
||||
/// they are sent or dropped), or the close.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Nodes: one struct with the properties every node can have, and a kind with per-type data.
|
||||
|
||||
use crate::NodeId;
|
||||
use crate::props::*;
|
||||
use crate::props::{
|
||||
AutoLayout, ChildLayout, Constraints, Corners, Effect, EffectKind, ExportSetting, LayoutGrid, SPREAD_MITER_LIMIT, StrokeAlign, StrokeCap,
|
||||
StrokeProps,
|
||||
};
|
||||
use crate::proto::{Interaction, Overflow, ScrollPosition};
|
||||
use crate::styles::{CollectionId, ModeId, StyleRefs, VariableId};
|
||||
use crate::text::TextData;
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
ChildLayout, ContentAlign, CrossAlign, DocError, Document, MainAlign, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData,
|
||||
};
|
||||
use figcraft_geom::Affine;
|
||||
use serde_json::Value;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::instances::PROP_BINDING;
|
||||
use super::*;
|
||||
use super::{Document, Node, NodeId, NodeKind, PropDef, Sizing, TextAutoResize, TextData, inside_instance, owning_component, variant_set};
|
||||
|
||||
/// 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).
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use super::diff::geometry_derived;
|
||||
use super::rebase::rebase_instance;
|
||||
use super::variants::instance_issues;
|
||||
use super::*;
|
||||
use super::{
|
||||
ContentAlign, CrossAlign, Document, MainAlign, Node, NodeId, NodeKind, SLOT, Sizing, enclosing_instance, inside_instance, is_instance,
|
||||
owning_instance, structure_locked,
|
||||
};
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::diff::{binding_group, bindings_of};
|
||||
use super::sync::{insert_linked, sync_instance_depth};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, BTreeMap, ChildLayout, DocError, Document, Node, NodeId, NodeKind, Value, default_instance_name, default_props, holds_content, is_slot,
|
||||
slot_edited,
|
||||
};
|
||||
|
||||
/// 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> {
|
||||
|
||||
@@ -2,7 +2,12 @@ use super::diff::{binding_group, bindings_of, diff_groups, follow_boolean_defaul
|
||||
use super::guard::{own_slot_content, relink_copy};
|
||||
use super::instances::{copy_group, field_group, sync_instance};
|
||||
use super::sync::insert_linked;
|
||||
use super::*;
|
||||
use super::{
|
||||
BTreeMap, BTreeSet, Document, MAX_DEPTH, Node, NodeId, NodeKind, default_instance_name, holds_content, inside_instance, owning_component,
|
||||
owning_instance, root_instance, scope, slot_edited, slot_scope,
|
||||
};
|
||||
|
||||
type OverrideGroups = (Vec<&'static str>, Vec<&'static str>, Vec<&'static str>);
|
||||
|
||||
/// After an edit: record overrides inside instances, then rebuild instances whose main changed,
|
||||
/// whose properties or main were switched, or whose overrides were reset.
|
||||
@@ -14,93 +19,9 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
adopt_slot_content(doc, before, changed);
|
||||
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);
|
||||
}
|
||||
}
|
||||
record_overrides(doc, before, changed, &mut resync);
|
||||
// 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();
|
||||
@@ -172,6 +93,105 @@ pub fn sync_after_edit(doc: &mut Document, before: &Document, changed: &[NodeId]
|
||||
rebuild_instances(doc, &comps);
|
||||
}
|
||||
|
||||
/// Slot content changes: layers added, removed or reordered in a slot become the instance's own.
|
||||
fn adopt_slot_content(doc: &mut Document, before: &Document, changed: &[NodeId]) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Overrides for layers edited inside instances; the instances to rebuild go into `resync`.
|
||||
fn record_overrides(doc: &mut Document, before: &Document, changed: &[NodeId], resync: &mut BTreeSet<NodeId>) {
|
||||
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, groups, cleared) = override_groups(doc, before, id, n, b, inst, top_root);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `(raw, groups, cleared)` of layer `id` edited from `b` to `n`: the raw changed groups, the ones
|
||||
/// that are now overrides and the overrides that were set back to the main's value.
|
||||
fn override_groups(doc: &Document, before: &Document, id: NodeId, n: &Node, b: &Node, inst: NodeId, top_root: bool) -> OverrideGroups {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
(raw, groups, cleared)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
/// Tests: rebuild instances even for edits that could be patched (to compare both ways).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::diff::{diff_groups, variable_groups};
|
||||
use super::instances::{field_group, path_map};
|
||||
use super::*;
|
||||
use super::{BTreeMap, BTreeSet, Document, MAX_DEPTH, Node, NodeId, NodeKind, default_instance_name, holds_content};
|
||||
|
||||
/// Bounded source and parent lookups while a rebase reads an unchanged document.
|
||||
pub(super) struct RebaseSources<'a> {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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::*;
|
||||
use super::{
|
||||
Affine, BTreeMap, BTreeSet, DocError, Document, MAX_DEPTH, Node, NodeId, NodeKind, SLOT, Sizing, Value, default_instance_name, default_props,
|
||||
holds_content, inside_instance, is_slot, owning_component, variant_set,
|
||||
};
|
||||
|
||||
/// `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.
|
||||
@@ -16,31 +19,10 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
let eff_props = effective_props(&comp, root_props);
|
||||
// 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 old, by_source, by_src) = index_old_layers(doc, inst, spare);
|
||||
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
|
||||
@@ -124,23 +106,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
apply_enclosing_props(doc, &mut n, orig, &enc, &inst_props);
|
||||
if let NodeKind::Instance { component: c, props } = &n.kind {
|
||||
inst_props.insert(nid, props.clone());
|
||||
let mut e = enc;
|
||||
@@ -167,6 +133,155 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
p.children.retain(|c| *c != id);
|
||||
}
|
||||
}
|
||||
let fresh = fresh_root(doc, &mut root, &comp, inst, component, &map);
|
||||
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();
|
||||
}
|
||||
follow_geometry_overrides(doc, &src, &map, &old, &mut remeasure);
|
||||
remeasure_text(doc, remeasure);
|
||||
rebuild_nested(doc, nested, &old, &used, depth);
|
||||
}
|
||||
|
||||
/// Geometry overrides of children win over constraints.
|
||||
fn follow_geometry_overrides(
|
||||
doc: &mut Document,
|
||||
src: &[NodeId],
|
||||
map: &BTreeMap<NodeId, NodeId>,
|
||||
old: &BTreeMap<NodeId, std::sync::Arc<Node>>,
|
||||
remeasure: &mut Vec<NodeId>,
|
||||
) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
fn rebuild_nested(doc: &mut Document, nested: Vec<NodeId>, old: &BTreeMap<NodeId, std::sync::Arc<Node>>, used: &BTreeSet<NodeId>, depth: usize) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_enclosing_props(doc: &Document, n: &mut Node, orig: &Node, enc: &[NodeId], inst_props: &BTreeMap<NodeId, BTreeMap<String, Value>>) {
|
||||
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, n, field, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text whose characters or style differ from the main re-measures.
|
||||
fn remeasure_text(doc: &mut Document, remeasure: Vec<NodeId>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The component's property defaults with the instance's values for the ones it has.
|
||||
fn effective_props(comp: &Node, root_props: &BTreeMap<String, Value>) -> BTreeMap<String, Value> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
eff_props
|
||||
}
|
||||
|
||||
type OldIndex = (BTreeMap<NodeId, std::sync::Arc<Node>>, BTreeMap<(NodeId, NodeId), NodeId>, BTreeMap<NodeId, Option<NodeId>>);
|
||||
|
||||
/// The old layers of `inst` (and `spare`) by id, by (parent, source) and by source alone (`None`
|
||||
/// when several layers mirror it).
|
||||
fn index_old_layers(doc: &Document, inst: NodeId, spare: BTreeMap<NodeId, std::sync::Arc<Node>>) -> OldIndex {
|
||||
let mut old_ids = old_layers(doc, inst);
|
||||
old_ids.extend(spare.keys().copied());
|
||||
old_ids.sort();
|
||||
old_ids.dedup();
|
||||
let mut old = 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);
|
||||
}
|
||||
(old, by_source, by_src)
|
||||
}
|
||||
|
||||
/// The rebuilt root of instance `inst` of `comp`.
|
||||
fn fresh_root(doc: &Document, root: &mut Node, comp: &Node, inst: NodeId, component: NodeId, map: &BTreeMap<NodeId, NodeId>) -> Node {
|
||||
// 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();
|
||||
@@ -175,7 +290,7 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
fresh.children = order;
|
||||
fresh.transform = root.transform;
|
||||
fresh.kind = root.kind.clone();
|
||||
carry_prop_bindings(&root, &mut fresh);
|
||||
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) };
|
||||
@@ -212,81 +327,12 @@ pub(super) fn sync_instance_depth(doc: &mut Document, inst: NodeId, depth: usize
|
||||
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);
|
||||
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);
|
||||
}
|
||||
carry_overrides(root, &mut fresh, Carry::Root);
|
||||
fresh
|
||||
}
|
||||
|
||||
/// Before swapped nested instance `n` is rebuilt from its new main: each of its old layers (the
|
||||
|
||||
@@ -383,6 +383,22 @@ fn resized_instances_follow_constraints_and_auto_layout() {
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
/// The tail of `component_properties_apply_to_instances`: unbind, delete, then a variant property.
|
||||
fn unbind_delete_and_vary(s: &mut Session, c: NodeId, i: NodeId, title: NodeId, dot: NodeId) {
|
||||
// Unbind and delete.
|
||||
ex(s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"}));
|
||||
assert!(node(s, copy_of(s, i, dot)).visible);
|
||||
ex(s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
|
||||
assert!(node(s, title).prop_refs.is_empty());
|
||||
assert!(matches!(&node(s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
|
||||
let desc = ex(s, "component.properties", json!({"id": i}));
|
||||
assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
|
||||
// A variant property on a lone component makes it a component set.
|
||||
let set = idv(&ex(s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
|
||||
assert!(matches!(node(s, set).kind, NodeKind::ComponentSet { .. }));
|
||||
assert_eq!(node(s, c).name, "Theme=Light");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_properties_apply_to_instances() {
|
||||
let mut s = Session::new();
|
||||
@@ -414,18 +430,7 @@ fn component_properties_apply_to_instances() {
|
||||
assert!(s.execute("component.bindProperty", &json!({"layer": dot, "property": "Heading"})).is_err());
|
||||
assert!(s.execute("component.bindProperty", &json!({"layer": ititle, "property": "Heading"})).is_err());
|
||||
assert!(s.execute("component.addProperty", &json!({"id": c, "name": "Heading", "type": "text"})).is_err());
|
||||
// Unbind and delete.
|
||||
ex(&mut s, "component.bindProperty", json!({"layer": dot, "property": null, "field": "visible"}));
|
||||
assert!(node(&s, copy_of(&s, i, dot)).visible);
|
||||
ex(&mut s, "component.deleteProperty", json!({"id": c, "name": "Heading"}));
|
||||
assert!(node(&s, title).prop_refs.is_empty());
|
||||
assert!(matches!(&node(&s, i).kind, NodeKind::Instance { props, .. } if !props.contains_key("Heading")));
|
||||
let desc = ex(&mut s, "component.properties", json!({"id": i}));
|
||||
assert_eq!(desc["properties"].as_array().unwrap().len(), 1);
|
||||
// A variant property on a lone component makes it a component set.
|
||||
let set = idv(&ex(&mut s, "component.addProperty", json!({"id": c, "name": "Theme", "type": "variant", "default": "Light"})));
|
||||
assert!(matches!(node(&s, set).kind, NodeKind::ComponentSet { .. }));
|
||||
assert_eq!(node(&s, c).name, "Theme=Light");
|
||||
unbind_delete_and_vary(&mut s, c, i, title, dot);
|
||||
healthy(&s);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{BTreeMap, Document, MAX_DEPTH, Node, NodeId, NodeKind, holds_content, inside_instance};
|
||||
|
||||
/// A set's default variant: the one in its top-left corner (nearest to it, then the top-most, then
|
||||
/// the left-most).
|
||||
|
||||
@@ -15,7 +15,11 @@ use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::proto::Action;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
Collection, CollectionId, Document, ImportedComponent, ImportedRecord, ImportedStyle, LibraryAsset, LibraryCollection, LibraryLink,
|
||||
LibraryManifest, LibraryStyle, LibraryVariable, Node, NodeId, NodeKind, PropDef, PublishedLibrary, Style, StyleKind, Variable, VariableId,
|
||||
VariableValue, Variables, node_images, paint_images, used_images,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::components::inside_instance;
|
||||
|
||||
@@ -107,6 +107,49 @@ fn publish_to(s: &mut Session, path: &str) -> String {
|
||||
r["id"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
/// `publish_manifest_roundtrip`: the file saved and read back keeps the manifest.
|
||||
fn saved_and_read_back(doc: &Document, lib: &PublishedLibrary) {
|
||||
let m = &lib.manifest;
|
||||
// Saved and read back: the author's file and a reader of the library see the same manifest.
|
||||
let bytes = figcraft_format::save_bytes(doc, None).unwrap();
|
||||
let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
assert!(issues.is_empty(), "{issues:?}");
|
||||
assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m));
|
||||
let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
|
||||
assert_eq!(read.manifest, *m);
|
||||
assert_eq!(read.doc.node_count(), lib.doc.node_count());
|
||||
// Older files and files never published have no library; readers that don't know libraries
|
||||
// are unaffected (the entries are extra archive members).
|
||||
let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap();
|
||||
assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none());
|
||||
assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none());
|
||||
}
|
||||
|
||||
/// `publish_manifest_roundtrip`: a republish bumps only what changed.
|
||||
fn republish_bumps_touched(s: &mut Session, k: &Kit) {
|
||||
// Nothing changed: refused. A change bumps only what it touches (and what uses it).
|
||||
let e = s.execute("library.publish", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("no changes"), "{e}");
|
||||
ex(s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}}));
|
||||
let r = ex(s, "library.publish", json!({}));
|
||||
assert_eq!(r["version"], 2);
|
||||
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
||||
assert_eq!(m.component(k.card).map(|a| a.version), Some(2));
|
||||
assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version");
|
||||
assert_eq!(m.description, "Our kit", "the description carries over");
|
||||
// Excluding an asset; a used component still comes along (not offered).
|
||||
let r = ex(s, "library.publish", json!({"exclude": ["Button"]}));
|
||||
assert_eq!(r["components"], 1);
|
||||
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
||||
assert!(m.component(k.button).is_some_and(|a| !a.published));
|
||||
// Unknown assets are an error, not ignored.
|
||||
assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err());
|
||||
assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err());
|
||||
// Undo takes the publish back.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_manifest_roundtrip() {
|
||||
let mut s = Session::new();
|
||||
@@ -126,41 +169,74 @@ fn publish_manifest_roundtrip() {
|
||||
assert!(lib.doc.node(k.label).is_some());
|
||||
assert!(lib.doc.validate().is_empty());
|
||||
assert_eq!(lib.doc.styles.len(), 1);
|
||||
// Saved and read back: the author's file and a reader of the library see the same manifest.
|
||||
let bytes = figcraft_format::save_bytes(doc, None).unwrap();
|
||||
let (back, issues) = figcraft_format::load_bytes(&bytes).unwrap();
|
||||
assert!(issues.is_empty(), "{issues:?}");
|
||||
assert_eq!(back.published.as_ref().map(|p| &p.manifest), Some(m));
|
||||
let read = figcraft_format::read_library(std::io::Cursor::new(&bytes)).unwrap().unwrap();
|
||||
assert_eq!(read.manifest, *m);
|
||||
assert_eq!(read.doc.node_count(), lib.doc.node_count());
|
||||
// Older files and files never published have no library; readers that don't know libraries
|
||||
// are unaffected (the entries are extra archive members).
|
||||
let plain = figcraft_format::save_bytes(&Document::new("x"), None).unwrap();
|
||||
assert!(figcraft_format::read_library(std::io::Cursor::new(&plain)).unwrap().is_none());
|
||||
assert!(figcraft_format::load_bytes(&plain).unwrap().0.published.is_none());
|
||||
saved_and_read_back(doc, &lib);
|
||||
|
||||
// Nothing changed: refused. A change bumps only what it touches (and what uses it).
|
||||
let e = s.execute("library.publish", &json!({})).unwrap_err();
|
||||
assert!(e.to_string().contains("no changes"), "{e}");
|
||||
ex(&mut s, "node.set", json!({"id": k.card, "props": {"fill": "#eeeeee"}}));
|
||||
let r = ex(&mut s, "library.publish", json!({}));
|
||||
assert_eq!(r["version"], 2);
|
||||
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
||||
assert_eq!(m.component(k.card).map(|a| a.version), Some(2));
|
||||
assert_eq!(m.component(k.button).map(|a| a.version), Some(1), "untouched assets keep their version");
|
||||
assert_eq!(m.description, "Our kit", "the description carries over");
|
||||
// Excluding an asset; a used component still comes along (not offered).
|
||||
let r = ex(&mut s, "library.publish", json!({"exclude": ["Button"]}));
|
||||
assert_eq!(r["components"], 1);
|
||||
let m = s.doc().unwrap().published.clone().unwrap().manifest.clone();
|
||||
assert!(m.component(k.button).is_some_and(|a| !a.published));
|
||||
// Unknown assets are an error, not ignored.
|
||||
assert!(s.execute("library.publish", &json!({"include": ["Nope"]})).is_err());
|
||||
assert!(s.execute("library.publish", &json!({"include": "Button"})).is_err());
|
||||
// Undo takes the publish back.
|
||||
s.undo().unwrap();
|
||||
assert_eq!(s.doc().unwrap().published.as_ref().map(|p| p.manifest.version), Some(2));
|
||||
republish_bumps_touched(&mut s, &k);
|
||||
}
|
||||
|
||||
/// `add_place_and_update_keep_overrides`: library copies refuse every edit and roll back.
|
||||
fn assert_remote_read_only(s: &mut Session, b: NodeId, remote_button: NodeId) {
|
||||
let unchanged = s.doc().unwrap().clone();
|
||||
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
|
||||
assert!(e.to_string().contains("can't be edited"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
|
||||
for (cmd, args) in
|
||||
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
|
||||
{
|
||||
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
|
||||
}
|
||||
let style = s.doc().unwrap().styles[0].id.clone();
|
||||
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
||||
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
||||
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
|
||||
assert!(e.to_string().contains("library"), "{e}");
|
||||
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
|
||||
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
|
||||
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
|
||||
assert_eq!(fill(s, remote_button), "0000FF");
|
||||
}
|
||||
|
||||
/// `add_place_and_update_keep_overrides`: the library changes and publishes; the copies follow. Returns the Card's instance.
|
||||
fn library_updates_flow(s: &mut Session, lib: &mut Session, k: &Kit, lid: &str, [b, b_label, remote_button, main]: [NodeId; 4]) -> NodeId {
|
||||
// The library changes the Button (fill via its style, and its label) and publishes again.
|
||||
ex(lib, "style.update", json!({"style": "Brand", "value": "#00ff00"}));
|
||||
ex(lib, "text.set", json!({"id": k.label, "characters": "Go"}));
|
||||
ex(lib, "library.publish", json!({}));
|
||||
ex(lib, "file.save", json!({}));
|
||||
|
||||
let u = ex(s, "library.updates", json!({}));
|
||||
assert_eq!(u["count"], 3, "Button, Card and the style: {u}");
|
||||
let label_ids: Vec<NodeId> = s.doc().unwrap().subtree(b);
|
||||
ex(s, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(fill(s, b), "00FF00", "the instance follows the updated main");
|
||||
assert_eq!(chars(s, b_label), "Buy", "its override is kept");
|
||||
assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept");
|
||||
assert_eq!(fill(s, remote_button), "00FF00");
|
||||
let style = s.doc().unwrap().styles[0].clone();
|
||||
assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00"));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.libraries.link(lid).map(|l| l.version), Some(2));
|
||||
// The Card's nested Button follows too.
|
||||
let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap();
|
||||
let nested = node(s, card_inst).children[0];
|
||||
assert_eq!(fill(s, nested), "00FF00");
|
||||
assert_eq!(ex(s, "library.updates", json!({}))["count"], 0);
|
||||
healthy(s);
|
||||
// A structural change: the Button gains a layer (the Card's nested Button too).
|
||||
ex(lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}}));
|
||||
ex(lib, "library.publish", json!({}));
|
||||
ex(lib, "file.save", json!({}));
|
||||
ex(s, "library.acceptUpdates", json!({"ids": [lid]}));
|
||||
assert_eq!(node(s, b).children.len(), 2);
|
||||
assert_eq!(node(s, nested).children.len(), 2);
|
||||
assert_eq!(chars(s, b_label), "Buy", "overrides survive a structural update");
|
||||
assert_eq!(s.doc().unwrap().libraries.link(lid).map(|l| l.version), Some(3));
|
||||
healthy(s);
|
||||
// Overrides still work after the update; resetting shows the library's text.
|
||||
ex(s, "component.resetInstance", json!({"ids": [b]}));
|
||||
assert_eq!(chars(s, node(s, b).children[0]), "Go");
|
||||
card_inst
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -211,63 +287,9 @@ fn add_place_and_update_keep_overrides() {
|
||||
NodeKind::Instance { component, .. } => component,
|
||||
_ => panic!("not an instance"),
|
||||
};
|
||||
let unchanged = s.doc().unwrap().clone();
|
||||
let e = s.execute("node.set", &json!({"id": remote_button, "props": {"fill": "#ff0000"}})).unwrap_err();
|
||||
assert!(e.to_string().contains("can't be edited"), "{e}");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "a refused remote edit rolls back");
|
||||
for (cmd, args) in
|
||||
[("node.reparent", json!({"ids": [remote_button], "parent": s.page().unwrap()})), ("edit.delete", json!({"ids": [remote_button]}))]
|
||||
{
|
||||
assert!(s.execute(cmd, &args).is_err(), "{cmd} must preserve read-only copies");
|
||||
assert_eq!(s.doc().unwrap(), &unchanged, "refused {cmd} rolls back");
|
||||
}
|
||||
let style = s.doc().unwrap().styles[0].id.clone();
|
||||
assert!(s.execute("style.update", &json!({"style": style, "name": "Mine"})).is_err());
|
||||
assert!(s.execute("style.delete", &json!({"style": style})).is_err());
|
||||
let e = s.execute("component.goToMain", &json!({"id": b})).unwrap_err();
|
||||
assert!(e.to_string().contains("library"), "{e}");
|
||||
assert!(s.execute("select.set", &json!({"ids": [remote_button]})).is_err(), "library copies can't be selected");
|
||||
assert!(s.doc().unwrap().pages.contains(&s.page().unwrap()));
|
||||
assert!(s.execute("component.pushChanges", &json!({"id": b})).is_err());
|
||||
assert_eq!(fill(&s, remote_button), "0000FF");
|
||||
assert_remote_read_only(&mut s, b, remote_button);
|
||||
|
||||
// The library changes the Button (fill via its style, and its label) and publishes again.
|
||||
ex(&mut lib, "style.update", json!({"style": "Brand", "value": "#00ff00"}));
|
||||
ex(&mut lib, "text.set", json!({"id": k.label, "characters": "Go"}));
|
||||
ex(&mut lib, "library.publish", json!({}));
|
||||
ex(&mut lib, "file.save", json!({}));
|
||||
|
||||
let u = ex(&mut s, "library.updates", json!({}));
|
||||
assert_eq!(u["count"], 3, "Button, Card and the style: {u}");
|
||||
let label_ids: Vec<NodeId> = s.doc().unwrap().subtree(b);
|
||||
ex(&mut s, "library.acceptUpdates", json!({}));
|
||||
assert_eq!(fill(&s, b), "00FF00", "the instance follows the updated main");
|
||||
assert_eq!(chars(&s, b_label), "Buy", "its override is kept");
|
||||
assert_eq!(s.doc().unwrap().subtree(b), label_ids, "layer ids are kept");
|
||||
assert_eq!(fill(&s, remote_button), "00FF00");
|
||||
let style = s.doc().unwrap().styles[0].clone();
|
||||
assert!(matches!(&style.kind, StyleKind::Paint { paints } if paints[0].solid_color().unwrap().to_hex() == "00FF00"));
|
||||
let doc = s.doc().unwrap();
|
||||
assert_eq!(doc.libraries.link(&lid).map(|l| l.version), Some(2));
|
||||
// The Card's nested Button follows too.
|
||||
let card_inst = doc.nodes().find(|n| matches!(n.kind, NodeKind::Instance { component, .. } if component == main)).map(|n| n.id).unwrap();
|
||||
let nested = node(&s, card_inst).children[0];
|
||||
assert_eq!(fill(&s, nested), "00FF00");
|
||||
assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0);
|
||||
healthy(&s);
|
||||
// A structural change: the Button gains a layer (the Card's nested Button too).
|
||||
ex(&mut lib, "shape.ellipse", json!({"x": 80, "y": 10, "width": 8, "height": 8, "parent": k.button, "props": {"name": "Dot"}}));
|
||||
ex(&mut lib, "library.publish", json!({}));
|
||||
ex(&mut lib, "file.save", json!({}));
|
||||
ex(&mut s, "library.acceptUpdates", json!({"ids": [lid]}));
|
||||
assert_eq!(node(&s, b).children.len(), 2);
|
||||
assert_eq!(node(&s, nested).children.len(), 2);
|
||||
assert_eq!(chars(&s, b_label), "Buy", "overrides survive a structural update");
|
||||
assert_eq!(s.doc().unwrap().libraries.link(&lid).map(|l| l.version), Some(3));
|
||||
healthy(&s);
|
||||
// Overrides still work after the update; resetting shows the library's text.
|
||||
ex(&mut s, "component.resetInstance", json!({"ids": [b]}));
|
||||
assert_eq!(chars(&s, node(&s, b).children[0]), "Go");
|
||||
let card_inst = library_updates_flow(&mut s, &mut lib, &k, &lid, [b, b_label, remote_button, main]);
|
||||
|
||||
// Saved and reopened, the document keeps its links, copies and records.
|
||||
let user_path = path_str(&d.join("app.figcraft"));
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, ImportedComponent, ImportedRecord, LibraryManifest, NodeId, NodeKind, PublishedLibrary, VariableValue};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cmd::component::{main_of, swap_instance};
|
||||
@@ -41,6 +41,35 @@ fn b_main(doc: &Document, a_main: NodeId, a_root: NodeId, b_root: NodeId) -> Opt
|
||||
}
|
||||
}
|
||||
|
||||
/// Local style id → the library's style id, matched by name and type (the rest go to `out.unmatched`).
|
||||
fn match_styles(doc: &Document, recs: &[ImportedRecord], m: &LibraryManifest, out: &mut Swapped) -> BTreeMap<String, String> {
|
||||
let mut keys = BTreeMap::new();
|
||||
for r in recs {
|
||||
let Some(st) = doc.style(&r.local) else { continue };
|
||||
let kind = st.kind.type_name();
|
||||
match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) {
|
||||
Some(x) => {
|
||||
keys.insert(r.local.clone(), x.id.clone());
|
||||
}
|
||||
None => out.unmatched.push(format!("style {}", st.name)),
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// (local component root, the library's component id) by name; the rest go to `out.unmatched`.
|
||||
fn match_components(doc: &Document, records: &[ImportedComponent], m: &LibraryManifest, out: &mut Swapped) -> Vec<(NodeId, NodeId)> {
|
||||
let mut pairs = Vec::new();
|
||||
for r in records {
|
||||
let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default();
|
||||
match m.components.iter().find(|a| a.published && a.name == name) {
|
||||
Some(a) => pairs.push((r.root, a.id)),
|
||||
None => out.unmatched.push(format!("component {name}")),
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Swap the copies of library `from` in `doc` to library `to` by names (see the module docs).
|
||||
pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Result<Swapped> {
|
||||
let m = &to.manifest;
|
||||
@@ -64,14 +93,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
})
|
||||
.collect();
|
||||
let records: Vec<ImportedComponent> = doc.libraries.components.iter().filter(|r| r.library == from && shown.contains(&r.root)).cloned().collect();
|
||||
let mut pairs = Vec::new();
|
||||
for r in &records {
|
||||
let name = doc.node(r.root).map(|n| n.name.clone()).unwrap_or_default();
|
||||
match m.components.iter().find(|a| a.published && a.name == name) {
|
||||
Some(a) => pairs.push((r.root, a.id)),
|
||||
None => out.unmatched.push(format!("component {name}")),
|
||||
}
|
||||
}
|
||||
let pairs = match_components(doc, &records, m, &mut out);
|
||||
let keys: Vec<NodeId> = pairs.iter().map(|(_, k)| *k).collect();
|
||||
let map = if keys.is_empty() { BTreeMap::new() } else { import(doc, to, &keys, false)? };
|
||||
// A's mains (each variant) and roots → B's.
|
||||
@@ -121,17 +143,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
|
||||
// Styles the document's own layers link to: by name and type.
|
||||
let linked: BTreeSet<String> = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| style_links(n).cloned()).collect();
|
||||
let style_recs: Vec<ImportedRecord> = doc.libraries.styles.iter().filter(|r| r.library == from && linked.contains(&r.local)).cloned().collect();
|
||||
let mut style_keys: BTreeMap<String, String> = BTreeMap::new();
|
||||
for r in &style_recs {
|
||||
let Some(st) = doc.style(&r.local) else { continue };
|
||||
let kind = st.kind.type_name();
|
||||
match m.styles.iter().find(|x| x.published && x.name == st.name && x.kind == kind) {
|
||||
Some(x) => {
|
||||
style_keys.insert(r.local.clone(), x.id.clone());
|
||||
}
|
||||
None => out.unmatched.push(format!("style {}", st.name)),
|
||||
}
|
||||
}
|
||||
let style_keys = match_styles(doc, &style_recs, m, &mut out);
|
||||
let imported = import_styles(doc, to, &style_keys.values().cloned().collect(), false);
|
||||
let styles: BTreeMap<String, String> = style_keys.iter().filter_map(|(a, k)| imported.get(k).map(|b| (a.clone(), b.clone()))).collect();
|
||||
out.styles = styles.len();
|
||||
|
||||
@@ -8,7 +8,7 @@ use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::codegen::CodeLang;
|
||||
use crate::params::*;
|
||||
use crate::params::{get, id_of, num, string, targets};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// The component a playground for `target` shows: an instance's component, a component or a
|
||||
|
||||
@@ -8,7 +8,10 @@ use std::borrow::Cow;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_color::PaintKind;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
CollectionId, Document, EffectKind, LetterSpacing, LineHeight, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextStyle, VariableId,
|
||||
VariableType, VariableValue, Variables,
|
||||
};
|
||||
|
||||
use crate::cmd::lists::{BoundList, list_binding, stop_binding};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Document inspection as JSON, so agents can verify their work without screenshots.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeJoin, TextData, TextDecoration};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
@@ -8,7 +8,6 @@ use crate::Session;
|
||||
/// A node and (to `depth`) its children, with the properties a designer would check.
|
||||
pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
let Some(n) = doc.node(id) else { return Value::Null };
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
let mut v = json!({
|
||||
"id": n.id,
|
||||
"name": n.name,
|
||||
@@ -16,226 +15,259 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
});
|
||||
let Some(o) = v.as_object_mut() else { return v };
|
||||
if !n.is_page() {
|
||||
o.insert("x".into(), json!(round(c[4])));
|
||||
o.insert("y".into(), json!(round(c[5])));
|
||||
o.insert("width".into(), json!(round(n.width)));
|
||||
o.insert("height".into(), json!(round(n.height)));
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 1e-6 {
|
||||
o.insert("rotation".into(), json!(round(rot)));
|
||||
}
|
||||
if let Some(b) = doc.world_bounds(id) {
|
||||
o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
|
||||
}
|
||||
if !n.visible {
|
||||
o.insert("visible".into(), json!(false));
|
||||
}
|
||||
if n.locked {
|
||||
o.insert("locked".into(), json!(true));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
o.insert("opacity".into(), json!(n.opacity));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
o.insert("blend".into(), json!(n.blend));
|
||||
}
|
||||
if !n.fills.is_empty() {
|
||||
o.insert("fills".into(), paints(&n.fills));
|
||||
}
|
||||
if !n.strokes.is_empty() {
|
||||
o.insert("strokes".into(), paints(&n.strokes));
|
||||
o.insert("strokeWeight".into(), json!(n.stroke.weight));
|
||||
o.insert("strokeAlign".into(), json!(n.stroke.align));
|
||||
if n.stroke.sides.is_some() {
|
||||
o.insert("strokeSides".into(), json!(n.stroke.sides));
|
||||
}
|
||||
if !n.stroke.dashes.is_empty() {
|
||||
o.insert("dashes".into(), json!(n.stroke.dashes));
|
||||
}
|
||||
if n.stroke.join != StrokeJoin::Miter {
|
||||
o.insert("strokeJoin".into(), json!(n.stroke.join));
|
||||
} else if n.stroke.miter_limit != 4.0 {
|
||||
o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
|
||||
}
|
||||
if let Some(p) = n.stroke.profile() {
|
||||
let points = crate::cmd::props::width_points_json(Some(p));
|
||||
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
|
||||
}
|
||||
if let Some(b) = &n.stroke.brush {
|
||||
o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null)));
|
||||
}
|
||||
}
|
||||
if !n.effects.is_empty() {
|
||||
o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
|
||||
}
|
||||
if n.corners.radii.iter().any(|r| *r > 0.0) {
|
||||
o.insert(
|
||||
"cornerRadius".into(),
|
||||
match n.corners.uniform() {
|
||||
Some(r) => json!(r),
|
||||
None => json!(n.corners.radii),
|
||||
},
|
||||
);
|
||||
if n.corners.smoothing > 0.0 {
|
||||
o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
|
||||
}
|
||||
}
|
||||
if let Some(l) = &n.layout {
|
||||
o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
|
||||
}
|
||||
let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
|
||||
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
|
||||
o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
|
||||
}
|
||||
let cl = &n.child_layout;
|
||||
if cl.absolute {
|
||||
o.insert("absolute".into(), json!(true));
|
||||
}
|
||||
for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] {
|
||||
if let Some(v) = v {
|
||||
o.insert(k.into(), json!(v));
|
||||
}
|
||||
}
|
||||
if let Some(cell) = parent_al
|
||||
.then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
|
||||
.flatten()
|
||||
.and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
|
||||
{
|
||||
o.insert(
|
||||
"gridCell".into(),
|
||||
json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
|
||||
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}),
|
||||
);
|
||||
}
|
||||
if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
|
||||
o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
||||
}
|
||||
if n.kind.is_frame_like() {
|
||||
o.insert("clip".into(), json!(n.clip));
|
||||
}
|
||||
if !n.dev_status.is_none() {
|
||||
o.insert("devStatus".into(), json!(n.dev_status.id()));
|
||||
}
|
||||
if n.dev_changed {
|
||||
o.insert("devChanged".into(), json!(true));
|
||||
}
|
||||
if !n.dev_note.is_empty() {
|
||||
o.insert("devNote".into(), json!(n.dev_note));
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden } = n.kind {
|
||||
o.insert("sectionContentsHidden".into(), json!(contents_hidden));
|
||||
}
|
||||
if n.is_mask {
|
||||
o.insert("isMask".into(), json!(true));
|
||||
o.insert("maskType".into(), json!(n.mask_type.id()));
|
||||
}
|
||||
if !n.grids.is_empty() {
|
||||
o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.exports.is_empty() {
|
||||
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
||||
}
|
||||
if let Some(b) = &n.board {
|
||||
o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.annotations.is_empty() {
|
||||
o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.code_links.is_empty() {
|
||||
o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.measurements.is_empty() {
|
||||
o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
|
||||
}
|
||||
let dev = crate::dev_resources::list_json(doc, n.id);
|
||||
if dev.as_array().is_some_and(|a| !a.is_empty()) {
|
||||
o.insert("devResources".into(), dev);
|
||||
}
|
||||
if !n.interactions.is_empty() {
|
||||
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.styles.is_empty() {
|
||||
o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.bound.is_empty() {
|
||||
o.insert("boundVariables".into(), json!(n.bound));
|
||||
}
|
||||
if !n.overrides.is_empty() {
|
||||
o.insert("overrides".into(), json!(n.overrides));
|
||||
}
|
||||
if let Some(slot) = crate::components::slot_name(doc, n.id) {
|
||||
o.insert("slot".into(), json!(slot));
|
||||
}
|
||||
// An operand combining by an operation of its own.
|
||||
if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
|
||||
o.insert("operandOperation".into(), json!(op));
|
||||
}
|
||||
base_props(doc, n, id, o);
|
||||
paint_props(n, o);
|
||||
layout_props(doc, n, o);
|
||||
dev_props(doc, n, o);
|
||||
}
|
||||
// Layer data written with `node.set {props: {pluginData}}` (pages too).
|
||||
if !n.plugin_data.is_empty() {
|
||||
o.insert("pluginData".into(), json!(n.plugin_data));
|
||||
}
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
o.insert("characters".into(), json!(t.characters));
|
||||
o.insert("fontFamily".into(), json!(t.style.font_family));
|
||||
o.insert("fontStyle".into(), json!(t.style.font_style));
|
||||
o.insert("fontSize".into(), json!(t.style.font_size));
|
||||
if !t.style.variations.is_empty() {
|
||||
o.insert("fontVariations".into(), json!(t.style.variations));
|
||||
}
|
||||
o.insert("lineHeight".into(), json!(t.style.line_height.label()));
|
||||
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
|
||||
o.insert("textAlign".into(), json!(t.align_h));
|
||||
o.insert("textAutoResize".into(), json!(t.auto_resize));
|
||||
if t.max_lines > 0 {
|
||||
o.insert("maxLines".into(), json!(t.max_lines));
|
||||
}
|
||||
if let Some(tp) = &t.path {
|
||||
o.insert("textPath".into(), json!(tp));
|
||||
}
|
||||
if t.style.decoration != TextDecoration::None {
|
||||
o.insert("textDecoration".into(), json!(t.style.decoration));
|
||||
}
|
||||
if let Some(l) = t.style.decoration_look.as_deref() {
|
||||
o.insert("textDecorationStyle".into(), json!(l.style));
|
||||
o.insert("textDecorationThickness".into(), json!(l.thickness.label()));
|
||||
o.insert("textDecorationOffset".into(), json!(l.offset.label()));
|
||||
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
|
||||
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
|
||||
}
|
||||
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
|
||||
if t.paragraphs.is_empty() {
|
||||
if t.list != figcraft_doc::ListStyle::None {
|
||||
o.insert("listStyle".into(), json!(t.list));
|
||||
}
|
||||
} else {
|
||||
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
|
||||
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
|
||||
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
o.insert("listSpacing".into(), json!(t.list_spacing));
|
||||
}
|
||||
for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
|
||||
if on {
|
||||
o.insert(k.into(), json!(true));
|
||||
}
|
||||
}
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
o.insert("leadingTrim".into(), json!(t.leading_trim));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
}
|
||||
// Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`).
|
||||
let linked: Vec<Value> = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
|
||||
if !linked.is_empty() {
|
||||
o.insert("textStyleRanges".into(), Value::Array(linked));
|
||||
}
|
||||
NodeKind::Text(t) => text_props(t, o),
|
||||
_ => kind_props(doc, n, o),
|
||||
}
|
||||
if !n.children.is_empty() {
|
||||
if depth > 0 {
|
||||
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect()));
|
||||
} else {
|
||||
o.insert("childCount".into(), json!(n.children.len()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn base_props(doc: &Document, n: &Node, id: NodeId, o: &mut serde_json::Map<String, Value>) {
|
||||
let c = doc.frame_relative(id).as_coeffs();
|
||||
o.insert("x".into(), json!(round(c[4])));
|
||||
o.insert("y".into(), json!(round(c[5])));
|
||||
o.insert("width".into(), json!(round(n.width)));
|
||||
o.insert("height".into(), json!(round(n.height)));
|
||||
let rot = figcraft_geom::rotation_degrees(n.transform);
|
||||
if rot.abs() > 1e-6 {
|
||||
o.insert("rotation".into(), json!(round(rot)));
|
||||
}
|
||||
if let Some(b) = doc.world_bounds(id) {
|
||||
o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())}));
|
||||
}
|
||||
if !n.visible {
|
||||
o.insert("visible".into(), json!(false));
|
||||
}
|
||||
if n.locked {
|
||||
o.insert("locked".into(), json!(true));
|
||||
}
|
||||
if n.opacity < 1.0 {
|
||||
o.insert("opacity".into(), json!(n.opacity));
|
||||
}
|
||||
if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) {
|
||||
o.insert("blend".into(), json!(n.blend));
|
||||
}
|
||||
}
|
||||
|
||||
fn paint_props(n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
if !n.fills.is_empty() {
|
||||
o.insert("fills".into(), paints(&n.fills));
|
||||
}
|
||||
if !n.strokes.is_empty() {
|
||||
o.insert("strokes".into(), paints(&n.strokes));
|
||||
o.insert("strokeWeight".into(), json!(n.stroke.weight));
|
||||
o.insert("strokeAlign".into(), json!(n.stroke.align));
|
||||
if n.stroke.sides.is_some() {
|
||||
o.insert("strokeSides".into(), json!(n.stroke.sides));
|
||||
}
|
||||
if !n.stroke.dashes.is_empty() {
|
||||
o.insert("dashes".into(), json!(n.stroke.dashes));
|
||||
}
|
||||
if n.stroke.join != StrokeJoin::Miter {
|
||||
o.insert("strokeJoin".into(), json!(n.stroke.join));
|
||||
} else if n.stroke.miter_limit != 4.0 {
|
||||
o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0));
|
||||
}
|
||||
if let Some(p) = n.stroke.profile() {
|
||||
let points = crate::cmd::props::width_points_json(Some(p));
|
||||
o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points}));
|
||||
}
|
||||
if let Some(b) = &n.stroke.brush {
|
||||
o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null)));
|
||||
}
|
||||
}
|
||||
if !n.effects.is_empty() {
|
||||
o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null)));
|
||||
}
|
||||
if n.corners.radii.iter().any(|r| *r > 0.0) {
|
||||
o.insert(
|
||||
"cornerRadius".into(),
|
||||
match n.corners.uniform() {
|
||||
Some(r) => json!(r),
|
||||
None => json!(n.corners.radii),
|
||||
},
|
||||
);
|
||||
if n.corners.smoothing > 0.0 {
|
||||
o.insert("cornerSmoothing".into(), json!(n.corners.smoothing));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn layout_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
if let Some(l) = &n.layout {
|
||||
o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null)));
|
||||
}
|
||||
let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some());
|
||||
if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) {
|
||||
o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v}));
|
||||
}
|
||||
let cl = &n.child_layout;
|
||||
if cl.absolute {
|
||||
o.insert("absolute".into(), json!(true));
|
||||
}
|
||||
for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] {
|
||||
if let Some(v) = v {
|
||||
o.insert(k.into(), json!(v));
|
||||
}
|
||||
}
|
||||
if let Some(cell) = parent_al
|
||||
.then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p)))
|
||||
.flatten()
|
||||
.and_then(|g| g.cells.into_iter().find(|c| c.id == n.id))
|
||||
{
|
||||
o.insert(
|
||||
"gridCell".into(),
|
||||
json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span,
|
||||
"anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}),
|
||||
);
|
||||
}
|
||||
if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) {
|
||||
o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null));
|
||||
}
|
||||
if n.kind.is_frame_like() {
|
||||
o.insert("clip".into(), json!(n.clip));
|
||||
}
|
||||
}
|
||||
|
||||
fn dev_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
if !n.dev_status.is_none() {
|
||||
o.insert("devStatus".into(), json!(n.dev_status.id()));
|
||||
}
|
||||
if n.dev_changed {
|
||||
o.insert("devChanged".into(), json!(true));
|
||||
}
|
||||
if !n.dev_note.is_empty() {
|
||||
o.insert("devNote".into(), json!(n.dev_note));
|
||||
}
|
||||
if let NodeKind::Section { contents_hidden } = n.kind {
|
||||
o.insert("sectionContentsHidden".into(), json!(contents_hidden));
|
||||
}
|
||||
if n.is_mask {
|
||||
o.insert("isMask".into(), json!(true));
|
||||
o.insert("maskType".into(), json!(n.mask_type.id()));
|
||||
}
|
||||
if !n.grids.is_empty() {
|
||||
o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.exports.is_empty() {
|
||||
o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null));
|
||||
}
|
||||
if let Some(b) = &n.board {
|
||||
o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.annotations.is_empty() {
|
||||
o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.code_links.is_empty() {
|
||||
o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null)));
|
||||
}
|
||||
if !n.measurements.is_empty() {
|
||||
o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null));
|
||||
}
|
||||
let dev = crate::dev_resources::list_json(doc, n.id);
|
||||
if dev.as_array().is_some_and(|a| !a.is_empty()) {
|
||||
o.insert("devResources".into(), dev);
|
||||
}
|
||||
if !n.interactions.is_empty() {
|
||||
o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.styles.is_empty() {
|
||||
o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null));
|
||||
}
|
||||
if !n.bound.is_empty() {
|
||||
o.insert("boundVariables".into(), json!(n.bound));
|
||||
}
|
||||
if !n.overrides.is_empty() {
|
||||
o.insert("overrides".into(), json!(n.overrides));
|
||||
}
|
||||
if let Some(slot) = crate::components::slot_name(doc, n.id) {
|
||||
o.insert("slot".into(), json!(slot));
|
||||
}
|
||||
// An operand combining by an operation of its own.
|
||||
if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) {
|
||||
o.insert("operandOperation".into(), json!(op));
|
||||
}
|
||||
}
|
||||
|
||||
fn text_props(t: &TextData, o: &mut serde_json::Map<String, Value>) {
|
||||
o.insert("characters".into(), json!(t.characters));
|
||||
o.insert("fontFamily".into(), json!(t.style.font_family));
|
||||
o.insert("fontStyle".into(), json!(t.style.font_style));
|
||||
o.insert("fontSize".into(), json!(t.style.font_size));
|
||||
if !t.style.variations.is_empty() {
|
||||
o.insert("fontVariations".into(), json!(t.style.variations));
|
||||
}
|
||||
o.insert("lineHeight".into(), json!(t.style.line_height.label()));
|
||||
o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label()));
|
||||
o.insert("textAlign".into(), json!(t.align_h));
|
||||
o.insert("textAutoResize".into(), json!(t.auto_resize));
|
||||
if t.max_lines > 0 {
|
||||
o.insert("maxLines".into(), json!(t.max_lines));
|
||||
}
|
||||
if let Some(tp) = &t.path {
|
||||
o.insert("textPath".into(), json!(tp));
|
||||
}
|
||||
if t.style.decoration != TextDecoration::None {
|
||||
o.insert("textDecoration".into(), json!(t.style.decoration));
|
||||
}
|
||||
if let Some(l) = t.style.decoration_look.as_deref() {
|
||||
o.insert("textDecorationStyle".into(), json!(l.style));
|
||||
o.insert("textDecorationThickness".into(), json!(l.thickness.label()));
|
||||
o.insert("textDecorationOffset".into(), json!(l.offset.label()));
|
||||
o.insert("textDecorationSkipInk".into(), json!(l.skip_ink));
|
||||
o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex()))));
|
||||
}
|
||||
// Lists: one style for the layer, or each paragraph's type, nesting level and start.
|
||||
if t.paragraphs.is_empty() {
|
||||
if t.list != figcraft_doc::ListStyle::None {
|
||||
o.insert("listStyle".into(), json!(t.list));
|
||||
}
|
||||
} else {
|
||||
o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect());
|
||||
o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect());
|
||||
if t.paragraphs.iter().any(|p| p.start_offset > 0) {
|
||||
o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect());
|
||||
}
|
||||
}
|
||||
if t.list_spacing != 0.0 {
|
||||
o.insert("listSpacing".into(), json!(t.list_spacing));
|
||||
}
|
||||
for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] {
|
||||
if on {
|
||||
o.insert(k.into(), json!(true));
|
||||
}
|
||||
}
|
||||
if t.leading_trim != figcraft_doc::LeadingTrim::None {
|
||||
o.insert("leadingTrim".into(), json!(t.leading_trim));
|
||||
}
|
||||
if !t.runs.is_empty() {
|
||||
o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null));
|
||||
}
|
||||
// Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`).
|
||||
let linked: Vec<Value> = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect();
|
||||
if !linked.is_empty() {
|
||||
o.insert("textStyleRanges".into(), Value::Array(linked));
|
||||
}
|
||||
}
|
||||
|
||||
fn kind_props(doc: &Document, n: &Node, o: &mut serde_json::Map<String, Value>) {
|
||||
match &n.kind {
|
||||
NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => {
|
||||
o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner}));
|
||||
}
|
||||
@@ -276,14 +308,6 @@ pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value {
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !n.children.is_empty() {
|
||||
if depth > 0 {
|
||||
o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect()));
|
||||
} else {
|
||||
o.insert("childCount".into(), json!(n.children.len()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn round(v: f64) -> f64 {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use super::derived::{ends_now, place_route, route};
|
||||
use super::edit::set_label;
|
||||
use super::items::add;
|
||||
use super::*;
|
||||
use super::{
|
||||
BoardItem, Connector, Document, End, LINE, LINE_WEIGHT, NodeId, NodeKind, Paint, Point, Result, Session, Side, StrokeCap, StrokeJoin, Value,
|
||||
VectorNetwork, bad, cap_of, get, id_of, json, num, req_targets, require, routing_of, string,
|
||||
};
|
||||
|
||||
/// 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> {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use super::edit::set_font_size;
|
||||
use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, BTreeSet, BezPath, BoardItem, Connector, Document, End, NodeId, NodeKind, PathEl, Point, Rect, Routing, Shape, Side, Vec2, VectorNetwork,
|
||||
is_board,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Derived data (the normalize pass).
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use super::connectors::connector_of;
|
||||
use super::derived::ends_now;
|
||||
use super::items::{add, add_text, shape_padding, text_of};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, AutoLayout, BTreeMap, BoardItem, BoardShape, Color, Connector, DocError, Document, End, INK, NodeId, NodeKind, Paint, Result, Routing,
|
||||
Session, Sizing, StrokeCap, TextAlignH, TextAutoResize, TextData, Value, VectorNetwork, bad, cap_of, color_param, json, line_shade, num,
|
||||
req_string, req_targets, require, routing_of, shape_of, string,
|
||||
};
|
||||
|
||||
/// The label of connector `id`, if it has one.
|
||||
pub fn label_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Constraints, CrossAlign, Document, Effect, EffectKind, EngineError,
|
||||
HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY, STICKY_GAP,
|
||||
STICKY_PAD, Session, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, Value, VectorNetwork,
|
||||
author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of, stamp_of, string,
|
||||
textedit,
|
||||
};
|
||||
|
||||
/// A new layer at the top of `parent`, at `at` in it, made by `f`.
|
||||
pub(super) fn add(
|
||||
|
||||
@@ -2,7 +2,10 @@ use super::connectors::{end_json, other_end};
|
||||
use super::derived::ends_now;
|
||||
use super::edit::label_of;
|
||||
use super::items::edit_text;
|
||||
use super::*;
|
||||
use super::{
|
||||
BoardItem, Document, ERASER_PX, End, EngineError, Gesture, NodeId, Point, PointerEvent, Result, SHAPE, STICKY, Session, Side, ToolId, id_of,
|
||||
is_board, json,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The board tools.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Where something dropped at a page point lands: the frame under the point (or the page) and, in
|
||||
//! an auto layout frame, the child slot by pointer position plus the insertion line to draw.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, LayoutMode, NodeId, NodeKind};
|
||||
use figcraft_geom::Point;
|
||||
use serde::Serialize;
|
||||
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Corners, Document, Node, NodeId, NodeKind, StrokeProps, VectorNetwork, WindingRule};
|
||||
use figcraft_geom::kurbo::PathEl;
|
||||
use figcraft_geom::{Affine, BezPath, Point, Rect, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::cmd::edit::{roots, z_sorted};
|
||||
use crate::params::*;
|
||||
use crate::params::{f64_of, get, string, targets};
|
||||
use crate::tools::{Cursor, Gesture, PointerEvent, PointerKind, ToolId};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_color::{ImageScaleMode, PaintKind};
|
||||
use figcraft_doc::geometry::select_target;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, LayoutMode, Node, NodeId, NodeKind, Sizing, StrokeAlign, StrokeCap, TextAutoResize, TextData, TextPath, VectorNetwork};
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::handles::{PATH_REACH_PX, label_at, locked_in, path_candidates, readable_text_fills};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, Document, Gesture, Node, NodeId, NodeKind, Point, PointerEvent, Rect, Result, Session, Sizing, TextAutoResize, TextData, TextPath,
|
||||
ToolId, frame_at, json, select_target, textedit, within_slop,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
/// Handle a double-click gesture: finish a pen path, rename or click labels/widgets,
|
||||
|
||||
@@ -2,7 +2,11 @@ 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::*;
|
||||
use super::{
|
||||
Affine, ArcHandle, ArcHint, Cursor, Document, Gesture, Guide, Handle, ImageScaleMode, LayoutMode, NodeId, NodeKind, PaintKind, Point,
|
||||
PointerEvent, Rect, ResizeStart, Result, ScaleHint, Session, Sizing, Targets, TextAutoResize, ToolId, Vec2, move_world, roots, select_target,
|
||||
textedit, within_slop,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
pub(super) fn move_down(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::handles::SNAP_PX;
|
||||
use super::pen::take_enclosed;
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, Cursor, Gesture, Node, NodeId, NodeKind, Point, PointerEvent, Rect, Result, Session, StrokeCap, Targets, ToolId, Vec2, frame_at, textedit,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
pub(super) fn draw_down(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Affine, BTreeMap, Cursor, Document, Handle, NodeId, NodeKind, Point, ShapeHandle, ShapeHint, roots};
|
||||
|
||||
/// 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).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Document, Gesture, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Result, Session, ShapeHandle, ToolId, json, textedit};
|
||||
|
||||
impl Session {
|
||||
/// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::handles::{PENCIL_STROKE, selected_end_at};
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, Document, Gesture, Guide, Node, NodeId, NodeKind, PenPath, Point, PointerEvent, Result, Session, StrokeAlign, ToolId, Vec2,
|
||||
VectorNetwork, at_45_degrees, frame_at, json, pen_handles,
|
||||
};
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::handles::{handle_at, label_at, locked_in, on_corner_square, resize_cursor, selected_end_at};
|
||||
use super::*;
|
||||
use super::{
|
||||
Cursor, Document, Gesture, NodeId, NodeKind, Overlay, PenCursor, Point, PointerEvent, PointerKind, Rect, Result, Session, ToolId, at_45_degrees,
|
||||
frame_at, json, select_target,
|
||||
};
|
||||
|
||||
impl Session {
|
||||
/// The pointer left the canvas: what hovering there showed goes (the distances Alt or Dev Mode
|
||||
|
||||
@@ -24,14 +24,14 @@ mod width_sides_tests;
|
||||
#[cfg(test)]
|
||||
mod width_tests;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Corners, Document, HandleMirroring, NodeId, NodeKind, StrokeAlign, StrokeCap, StrokeJoin, VectorNetwork, Vertex, WindingRule};
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::cmd::props::enum_of;
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, f64_of, get, id, num, num_or, req_num, string, targets};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub(crate) use pointer::VGesture;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! deleting, cutting and joining. Pure functions on a [`VectorNetwork`]; the session wrappers in
|
||||
//! `vectoredit.rs` re-fit the node afterwards.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{HandleMirroring, Region, Segment, VectorNetwork, Vertex, WindingRule};
|
||||
use figcraft_geom::kurbo::{CubicBez, ParamCurve, ParamCurveNearest};
|
||||
use figcraft_geom::{Affine, Point, Vec2};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, HandleMirroring, NodeId, NodeKind, VectorNetwork, Vertex};
|
||||
use figcraft_geom::{Affine, Point, Rect, Vec2};
|
||||
|
||||
use super::point_snap;
|
||||
@@ -510,20 +510,25 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Another edited layer under the press (or, with the paint bucket, whose area it is in).
|
||||
fn pressed_layer(doc: &Document, ve: &VectorEdit, ev: &PointerEvent, tol: f64) -> Option<NodeId> {
|
||||
match hit_any(doc, ve, ev.p(), tol) {
|
||||
Some((id, _)) => Some(id),
|
||||
None if ve.tool == VTool::Paint => {
|
||||
let inside = |id: NodeId| network(doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some());
|
||||
(!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten()
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns false when the click left edit mode (the caller then runs the normal tool).
|
||||
fn vector_down(&mut self, ve: &VectorEdit, ev: &PointerEvent) -> Result<bool> {
|
||||
let doc = self.doc()?.clone();
|
||||
let tol = 6.0 / ev.zoom.max(1e-6);
|
||||
// A press on another edited layer's point, handle or segment (or, with the paint bucket,
|
||||
// area) makes it the active layer; a pen drawing a path keeps to its own layer.
|
||||
let other = match hit_any(&doc, ve, ev.p(), tol) {
|
||||
Some((id, _)) => Some(id),
|
||||
None if ve.tool == VTool::Paint => {
|
||||
let inside = |id: NodeId| network(&doc, id).is_some_and(|n| n.face_at(doc.world_transform(id).inverse() * ev.p()).is_some());
|
||||
(!inside(ve.id)).then(|| ve.others.iter().map(|o| o.id).find(|id| inside(*id))).flatten()
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let other = Self::pressed_layer(&doc, ve, ev, tol);
|
||||
let switched;
|
||||
let ve = match other {
|
||||
Some(id) if id != ve.id && !(ve.tool == VTool::Pen && ve.anchor.is_some()) => {
|
||||
|
||||
@@ -11,7 +11,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{VHit, VTool, VectorEdit, default_points, edit, editing, indices, network, ops, selected_points, selection_position};
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, f64_of, get, num, num_or, req_num};
|
||||
use crate::tools::{Handle, handle_at_corners};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! The points live in the stroke's [`WidthProfile`] and apply to every subpath (positions are
|
||||
//! fractions of each subpath's length).
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, NodeId, WidthProfile, WidthSide};
|
||||
use figcraft_geom::{Point, Vec2};
|
||||
use figcraft_pathops::width::{Polyline, nearest, polylines};
|
||||
use serde::Serialize;
|
||||
@@ -18,7 +18,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{editing, set_state};
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::params::*;
|
||||
use crate::params::{get, num, req_index, req_num, string};
|
||||
use crate::tools::{Gesture, PointerEvent};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ use serde_json::{Value, json};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::{ExportFormat, WidthSide};
|
||||
|
||||
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
|
||||
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
|
||||
|
||||
@@ -6,6 +6,7 @@ use serde_json::{Value, json};
|
||||
use super::*;
|
||||
use crate::EngineError;
|
||||
use crate::tools::{PointerEvent, PointerKind};
|
||||
use figcraft_doc::{ExportFormat, WidthPreset, WidthProfile, WidthSide};
|
||||
|
||||
fn ex(s: &mut Session, c: &str, p: Value) -> Value {
|
||||
s.execute(c, &p).unwrap_or_else(|e| panic!("{c}: {e}"))
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Arrange: align, distribute, tidy, z-order, flips, rotations and numeric transforms.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, EffectKind, LetterSpacing, LineHeight, Node, NodeId, NodeKind, Sizing, TextAutoResize, TextStyle};
|
||||
use figcraft_geom::{Affine, Point, Rect};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::edit::{roots, z_sorted};
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, get, num, num_or, req_id, req_num, req_targets, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
//! message). `author` is who is acting (default: the name from Preferences, `pref.userName`): only
|
||||
//! a message's author edits or deletes it, and only the thread's author deletes the thread.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Comment, Document, Message, Node, NodeId, NodeKind, geom};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, get, id, id_of, num, req_index, req_num, req_string, string};
|
||||
use crate::{Session, bad, spec};
|
||||
|
||||
/// The name comments are signed with until the user sets theirs.
|
||||
|
||||
@@ -2,13 +2,18 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, PropDef, VariableType, VariableValue};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::edit::{roots, z_sorted};
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::components::*;
|
||||
use crate::params::*;
|
||||
use crate::components::{
|
||||
OVERRIDE_GROUPS, PROP_BINDING, SLOT, copy_group, create_instance, create_instance_at, default_props, default_variant, exposed_copies,
|
||||
find_variant, inside_instance, instances_of, is_slot, keep_text_overrides, owning_component, owning_instance, prop_default_value,
|
||||
rebase_instance, root_instance, set_boolean_default, slot_edited, slot_name, slots_of, takes_value, unlink_layers, variant_conflicts,
|
||||
variant_name, variant_options, variant_props, variant_set,
|
||||
};
|
||||
use crate::params::{boolean, get, id, id_of, num, req_id, req_num, req_string, req_targets, string, targets};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
mod instance;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
use super::props::{Owner, add_prop_for, delete_prop, prop_defs};
|
||||
use super::*;
|
||||
use super::{
|
||||
BTreeMap, BTreeSet, Document, EngineError, NodeId, NodeKind, OVERRIDE_GROUPS, PROP_BINDING, PropDef, Result, SLOT, Session, Value, bad, boolean,
|
||||
copy_group, create_instance_at, default_props, get, id_of, inside_instance, is_slot, json, keep_text_overrides, main_of, ok, owning_component,
|
||||
owning_instance, rebase_instance, req_id, req_num, req_targets, root_instance, slot_edited, slot_name, slots_of, string, targets, unlink_layers,
|
||||
variant_options,
|
||||
};
|
||||
|
||||
pub(super) fn detach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "component.detach";
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
use super::instance::preferred_arg;
|
||||
use super::variants::{fit_set, wrap_in_set};
|
||||
use super::*;
|
||||
use super::{
|
||||
BTreeMap, Document, NodeId, NodeKind, PROP_BINDING, PropDef, Result, SLOT, Session, Value, VariableType, VariableValue, bad, boolean,
|
||||
default_variant, find_variant, get, id, id_of, inside_instance, instances_of, json, main_of, ok, owning_component, prop_default_value,
|
||||
req_string, req_targets, set_boolean_default, slot_edited, slots_of, string, takes_value, targets, variant_conflicts, variant_name,
|
||||
variant_options, variant_props, variant_set,
|
||||
};
|
||||
|
||||
/// 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).
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
BTreeMap, BTreeSet, Document, EngineError, Node, NodeId, NodeKind, Result, Session, Value, bad, inside_instance, json, owning_instance,
|
||||
req_targets, roots, set_name_from, string, targets, variant_name, variant_options, variant_props, variant_set, z_sorted,
|
||||
};
|
||||
|
||||
/// 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> {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Creating nodes.
|
||||
|
||||
use figcraft_color::{ImageScaleMode, Paint};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, Node, NodeId, NodeKind, StrokeCap, TextAutoResize, TextData, VectorNetwork};
|
||||
use figcraft_geom::Point;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, f64_of, get, id, num, num_or, opt_id, req_targets, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
/// Frame presets shown by the Frame tool (category, name, width, height).
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, Effect, ExportSetting, ImageData, LayoutGrid, Node, NodeId, NodeKind};
|
||||
use figcraft_geom::Affine;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{
|
||||
blend, boolean, color, get, id, id_of, num, num_or, opt_index, paint_of, parse, req_id, req_index, req_num, req_string, req_targets, string,
|
||||
targets,
|
||||
};
|
||||
use crate::{BatchStep, EngineError, Result, Session, bad, spec};
|
||||
|
||||
mod clipboard;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, BTreeMap, BTreeSet, Document, EngineError, ImageData, Node, NodeId, NodeKind, Result, Session, Value, bad, delete, id, json, num, ok,
|
||||
req_targets, roots, string, targets, z_sorted,
|
||||
};
|
||||
|
||||
/// The copied form of the selection.
|
||||
pub fn copy_to_clipboard(s: &mut Session, cmd: &str, ids: &[NodeId]) -> Result<()> {
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
BTreeMap, Effect, EngineError, Map, Node, NodeId, NodeKind, Result, Session, Value, bad, blend, boolean, color, get, json, num, ok, opt_index,
|
||||
paint_of, parse, req_id, req_index, req_string, req_targets, roots, string, targets, z_sorted,
|
||||
};
|
||||
|
||||
pub(super) fn batch_rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "object.batchRename";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Affine, BTreeMap, BTreeSet, Node, NodeId, Result, Session, Value, bad, boolean, json, num, num_or, ok, req_num, req_string, req_targets, roots,
|
||||
};
|
||||
|
||||
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")?);
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine as _;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
BOARD_PAGE, Document, ExportFormat, ExportSetting, ExportSize, FileKind, LineHeight, Node, NodeId, NodeKind, PrintSettings, StyleKind, TextStyle,
|
||||
VdCompat,
|
||||
};
|
||||
use figcraft_format::Version;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{bool_of, boolean, get, id, num, num_or, req_index, req_string, string, targets};
|
||||
use crate::{DocState, EngineError, Result, Session, bad, spec};
|
||||
|
||||
mod export;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{DocState, Document, EngineError, LineHeight, NodeId, NodeKind, Result, StyleKind, TextStyle, Value, bad, get, json};
|
||||
|
||||
/// The recovery copy at `path` as a document with unsaved changes and no path.
|
||||
pub(crate) fn recovered(path: &str) -> Result<DocState> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, DocState, Document, EngineError, Result, Session, Value, Version, bad, get, json, req_index, string};
|
||||
|
||||
pub(super) fn version_json(index: usize, v: &Version) -> Value {
|
||||
let mut j = json!({"id": v.id, "index": index, "name": v.name, "description": v.description, "time": v.time});
|
||||
|
||||
@@ -5,7 +5,7 @@ use figcraft_doc::{Document, ImagePreset};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{f64_of, get, num, req_index, req_string, req_targets, string, targets};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -6,7 +6,7 @@ use serde_json::Value;
|
||||
|
||||
use super::props::sizing;
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{get, num, req_num, req_targets, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -5,16 +5,19 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Collection, Document, ImportedComponent, ImportedRecord, LibraryLink, LibraryManifest, NodeId, PublishedLibrary, VariableId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use figcraft_collab::message::{LibraryReply, LibraryReq};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::library::*;
|
||||
use crate::library::{
|
||||
Pending, Selection, absolute_path, accept_updates, asset_root, default_selection, find_link, import, import_styles, import_variables,
|
||||
is_remote_variable, pending, prune, publish, publishable, published_collections, published_variables,
|
||||
};
|
||||
use crate::library_server::{archive, is_server_link, read_archive, shown_path};
|
||||
use crate::live::{NetReply, NetRequest, PublishOptions};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, get, id_of, num, req_string, string};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// Longest library description kept (chars).
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
//! Every list is stored bottom first: index 0 paints (or applies) first, the last one is on top;
|
||||
//! the Design panel shows them in reverse, the topmost row first.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Document, LayoutGrid, Node, NodeId};
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{req_index, req_targets};
|
||||
use crate::{Result, Session, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! Pages.
|
||||
|
||||
use figcraft_doc::proto::Flow;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Guide, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, color, id, req_index, req_num, req_string, string};
|
||||
use crate::{Session, bad, spec};
|
||||
|
||||
/// After a page edit: the file keeps a page that isn't a divider, and the canvas shows one (the
|
||||
|
||||
+598
-519
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,14 @@
|
||||
//! Prototype commands: interactions, flows, settings, presenting.
|
||||
|
||||
use figcraft_doc::proto::*;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::proto::{
|
||||
Action, DeviceRotation, Easing, Expr, Flow, Interaction, MAX_DELAY_MS, MAX_EXPR_DEPTH, OverlayPosition, PrototypeSettings, Transition, Trigger,
|
||||
walk_mut,
|
||||
};
|
||||
use figcraft_doc::{Document, NodeId, NodeKind, VariableValue, Variables};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, color, f64_of, get, id, num, parse, req_id, req_num, req_targets, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -498,6 +501,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::params::id_of;
|
||||
use figcraft_doc::proto::{Direction, Overflow, ScrollPosition};
|
||||
|
||||
fn frame(s: &mut Session, x: f64) -> NodeId {
|
||||
let r = s.execute("node.create", &json!({"type": "frame", "x": x, "y": 0, "width": 100, "height": 200})).unwrap();
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_doc::proto::*;
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::proto::{Action, ConditionalBlock, Expr, ExprOp, MAX_ACTION_DEPTH, walk};
|
||||
use figcraft_doc::{Variable, VariableType, VariableValue, Variables};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::params::*;
|
||||
use crate::params::{bool_of, f64_of, get, id, parse, req_index, req_targets, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
/// Most actions in one list (an interaction's, or a block's).
|
||||
|
||||
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::devinspect::{dev_items, has_status};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, get, req_targets, string, targets};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
/// Longest change reason kept.
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
//! Structure: groups, frames, masks, boolean groups, flatten, outline stroke, auto layout, pages.
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
AutoLayout, BoolOp, Corners, Document, LayoutMode, Node, NodeId, NodeKind, Region, Segment, Sizing, StrokeAlign, StrokeJoin, StrokeProps,
|
||||
VectorNetwork, WindingRule, proto,
|
||||
};
|
||||
use figcraft_geom::{Affine, BezPath, Rect, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::edit::{roots, z_sorted};
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{f64_of, get, id_of, num_or, req_id, req_index, req_num, req_targets, string, targets};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// The layer structure commands: group, frame, mask, boolean operations, flatten, auto layout
|
||||
|
||||
@@ -3,11 +3,14 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_color::{Color, Paint, PaintKind};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
Collection, CollectionId, Document, Effect, EffectKind, Mode, ModeId, Node, NodeId, NodeKind, Style, StyleId, StyleKind, TextData, TextStyle,
|
||||
Variable, VariableId, VariableType, VariableValue, Variables, trim_num,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{color_of, f64_of, get, id, num, paints_of, req_string, req_targets, string, targets};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
//! Text menu and text editing commands.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAutoResize, TextCase, TextData, TextDecoration, TextPath, TextStyle,
|
||||
VariableId,
|
||||
};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::props::{styled_name, weight_name};
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, get, id, num, req_id, req_num, req_string, req_targets, string, targets};
|
||||
use crate::{Result, Session, bad, spec, textedit};
|
||||
|
||||
fn finite(v: f64) -> f64 {
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
CODE_PLATFORMS, Collection, CollectionId, Document, ModeId, NodeId, NodeKind, Variable, VariableId, VariableType, VariableValue, Variables,
|
||||
normalize_scopes, scope_known,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::lists::BoundList;
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, get, num, req_string, string};
|
||||
use crate::{Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Collection, CollectionId, Document, Mode, ModeId, Variable, VariableId, VariableValue, Variables, trim_num};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::CommandSpec;
|
||||
use crate::codegen::{css_var_name, css_var_of};
|
||||
use crate::library::{is_remote_collection, local_collections};
|
||||
use crate::params::*;
|
||||
use crate::params::{get, string};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! View menu, preferences and tools. View state is per document and drivable like everything else.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, ok};
|
||||
use crate::params::*;
|
||||
use crate::params::{boolean, get, num, num_or, req_index, req_num, req_string, string};
|
||||
use crate::tools::ToolId;
|
||||
use crate::{EngineError, Result, Session, ViewState, bad, spec};
|
||||
|
||||
|
||||
@@ -65,37 +65,47 @@ fn connected_instances_show_the_real_snippet() {
|
||||
);
|
||||
assert_eq!(code(&mut s, i, "swiftui"), "import AcmeUI\n\nCard(title: \"Say \\\"hi\\\"\", style: .plain)\n");
|
||||
assert!(code(&mut s, i, "compose").contains("@Composable"), "other languages stay generated");
|
||||
let screen = leptos_arkui_and_trees(&mut s, c, i);
|
||||
rejects_bad_links(&mut s, c, screen);
|
||||
disconnect_then_undo(&mut s, c, i);
|
||||
}
|
||||
|
||||
fn leptos_arkui_and_trees(s: &mut Session, c: NodeId, i: NodeId) -> NodeId {
|
||||
// Leptos: a `use` line and `view!` markup (strings with escapes in braces).
|
||||
ex(
|
||||
&mut s,
|
||||
s,
|
||||
"codeConnect.set",
|
||||
json!({"id": c, "language": "leptos", "importPath": "crate::ui", "template": "<Card title={title} dotted={Show dot} />"}),
|
||||
);
|
||||
assert_eq!(code(&mut s, i, "leptos"), "use crate::ui::Card;\n\n<Card title={\"Say \\\"hi\\\"\"} dotted={false} />\n");
|
||||
assert_eq!(code(s, i, "leptos"), "use crate::ui::Card;\n\n<Card title={\"Say \\\"hi\\\"\"} dotted={false} />\n");
|
||||
// ArkUI: an ArkTS import and an object-literal call.
|
||||
ex(
|
||||
&mut s,
|
||||
s,
|
||||
"codeConnect.set",
|
||||
json!({"id": c, "language": "arkui", "importPath": "@acme/arkui", "template": "Card({ title: {title}, dotted: {Show dot} })"}),
|
||||
);
|
||||
assert_eq!(code(&mut s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n");
|
||||
let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "arkui"}));
|
||||
assert_eq!(code(s, i, "arkui"), "import { Card } from \"@acme/arkui\";\n\nCard({ title: \"Say \\\"hi\\\"\", dotted: false })\n");
|
||||
let v = ex(s, "inspect.componentCode", json!({"id": i, "language": "arkui"}));
|
||||
assert_eq!(v["usage"], "Card({ title: \"Say \\\"hi\\\"\", dotted: false })\n");
|
||||
assert_eq!(v["connected"]["import"], "import { Card } from \"@acme/arkui\";");
|
||||
assert!(s.execute("codeConnect.set", &json!({"id": c, "language": "leptos", "importPath": "crate/ui"})).is_err(), "a Rust path");
|
||||
ex(&mut s, "codeConnect.remove", json!({"id": c, "language": "leptos"}));
|
||||
ex(s, "codeConnect.remove", json!({"id": c, "language": "leptos"}));
|
||||
// inspect.componentCode: the snippet as the usage, and the link.
|
||||
let v = ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "swiftui"}));
|
||||
let v = ex(s, "inspect.componentCode", json!({"id": i, "language": "swiftui"}));
|
||||
assert_eq!(v["usage"], "Card(title: \"Say \\\"hi\\\"\", style: .plain)\n");
|
||||
assert_eq!(v["connected"]["import"], "import AcmeUI");
|
||||
assert_eq!(ex(&mut s, "inspect.componentCode", json!({"id": i, "language": "compose"}))["connected"], Value::Null);
|
||||
assert_eq!(ex(s, "inspect.componentCode", json!({"id": i, "language": "compose"}))["connected"], Value::Null);
|
||||
// Inside a generated layer tree, connected instances are their snippet.
|
||||
let screen = idv(&ex(&mut s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Screen"})));
|
||||
ex(&mut s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen}));
|
||||
assert!(code(&mut s, screen, "react").contains("<Card title=\"Hello\" dot={true} onClick={onClick}>•</Card>\n"));
|
||||
let screen = idv(&ex(s, "frame.create", json!({"x": 0, "y": 500, "width": 400, "height": 300, "name": "Screen"})));
|
||||
ex(s, "instance.create", json!({"component": c, "x": 20, "y": 30, "parent": screen}));
|
||||
assert!(code(s, screen, "react").contains("<Card title=\"Hello\" dot={true} onClick={onClick}>•</Card>\n"));
|
||||
// Literal braces stay; fields left out keep their value.
|
||||
ex(&mut s, "codeConnect.set", json!({"id": c, "template": "<Card style={{gap: 4}} label={title} />"}));
|
||||
assert_eq!(code(&mut s, c, "react"), "import { Card } from \"@acme/ui\";\n\n<Card style={{gap: 4}} label=\"Hello\" />\n");
|
||||
ex(s, "codeConnect.set", json!({"id": c, "template": "<Card style={{gap: 4}} label={title} />"}));
|
||||
assert_eq!(code(s, c, "react"), "import { Card } from \"@acme/ui\";\n\n<Card style={{gap: 4}} label=\"Hello\" />\n");
|
||||
screen
|
||||
}
|
||||
|
||||
fn rejects_bad_links(s: &mut Session, c: NodeId, screen: NodeId) {
|
||||
// Validation.
|
||||
for bad in [
|
||||
json!({"id": c, "template": "{title ? \"a\"}"}),
|
||||
@@ -109,13 +119,16 @@ fn connected_instances_show_the_real_snippet() {
|
||||
] {
|
||||
assert!(s.execute("codeConnect.set", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
fn disconnect_then_undo(s: &mut Session, c: NodeId, i: NodeId) {
|
||||
// Disconnect one language; undo brings it back.
|
||||
ex(&mut s, "codeConnect.remove", json!({"id": i, "language": "react"}));
|
||||
assert!(code(&mut s, i, "react").contains("export function Card({"));
|
||||
assert!(code(&mut s, i, "swiftui").starts_with("import AcmeUI"));
|
||||
ex(&mut s, "edit.undo", json!({}));
|
||||
assert!(code(&mut s, i, "react").starts_with("import { Card }"));
|
||||
assert_eq!(ex(&mut s, "codeConnect.remove", json!({"id": c}))["removed"], 3);
|
||||
ex(s, "codeConnect.remove", json!({"id": i, "language": "react"}));
|
||||
assert!(code(s, i, "react").contains("export function Card({"));
|
||||
assert!(code(s, i, "swiftui").starts_with("import AcmeUI"));
|
||||
ex(s, "edit.undo", json!({}));
|
||||
assert!(code(s, i, "react").starts_with("import { Card }"));
|
||||
assert_eq!(ex(s, "codeConnect.remove", json!({"id": c}))["removed"], 3);
|
||||
assert!(s.execute("codeConnect.remove", &json!({"id": c})).is_err(), "nothing left");
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use figcraft_color::{Color, Paint};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
AutoLayout, Collection, CollectionId, CrossAlign, DocError, DocResult, Document, LayoutMode, Mode, ModeId, Node, NodeId, NodeKind, PropDef,
|
||||
Sizing, Style, StyleKind, TextAutoResize, TextData, TextStyle, Variable, VariableType, VariableValue,
|
||||
};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::components::{create_instance, sync_instance};
|
||||
|
||||
@@ -6,7 +6,7 @@ use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::glyphs::{self, Glyph};
|
||||
use super::kit::*;
|
||||
use super::kit::{ACCENT, BORDER, BOTTOM_EDGE, CARD, H, ICON, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, RIGHT_EDGE, SOFT, V, merged};
|
||||
use crate::Result;
|
||||
|
||||
const NAV: [(&str, Glyph); 6] = [
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::kit::*;
|
||||
use super::kit::{H, Kit, TYPE, V, merged};
|
||||
use crate::Result;
|
||||
|
||||
const THEME: &str = "Theme";
|
||||
|
||||
@@ -6,7 +6,7 @@ use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
use super::glyphs;
|
||||
use super::kit::*;
|
||||
use super::kit::{BORDER, CARD, H, ICON, INK, Kit, MUTED, PAGE, V, merged};
|
||||
use crate::Result;
|
||||
|
||||
const STROKE: &str = "Icon/Stroke";
|
||||
|
||||
@@ -6,7 +6,7 @@ use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::glyphs::{self, Glyph};
|
||||
use super::kit::*;
|
||||
use super::kit::{BORDER, BOTTOM_EDGE, CARD, H, ICON, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, SOFT, SUNKEN, TOP_EDGE, V, merged, shadow};
|
||||
use crate::Result;
|
||||
|
||||
/// The widest the content gets.
|
||||
|
||||
@@ -6,7 +6,7 @@ use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
use super::glyphs::{self, Glyph};
|
||||
use super::kit::*;
|
||||
use super::kit::{BORDER, CARD, H, ICON, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, SUNKEN, TOP_EDGE, V, shadow};
|
||||
use crate::Result;
|
||||
|
||||
const W: f64 = 390.0;
|
||||
|
||||
@@ -6,7 +6,7 @@ use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::glyphs;
|
||||
use super::kit::*;
|
||||
use super::kit::{ACCENT, BORDER, BOTTOM_EDGE, CARD, DEEP, H, INK, Kit, MUTED, ON_BRAND, PAGE, PRIMARY, TOP_EDGE, V, merged};
|
||||
use crate::Result;
|
||||
|
||||
const W: f64 = 1920.0;
|
||||
|
||||
@@ -6,7 +6,7 @@ use figcraft_doc::NodeId;
|
||||
use serde_json::json;
|
||||
|
||||
use super::glyphs;
|
||||
use super::kit::*;
|
||||
use super::kit::{ACCENT, DEEP, H, INK, Kit, ON_BRAND, V};
|
||||
use crate::Result;
|
||||
|
||||
/// (name, width, height, x, y) on the canvas.
|
||||
|
||||
@@ -6,7 +6,7 @@ use figcraft_doc::NodeId;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::glyphs;
|
||||
use super::kit::*;
|
||||
use super::kit::{BOTTOM_EDGE, H, Kit, TOP_EDGE, TYPE, V, merged};
|
||||
use crate::Result;
|
||||
|
||||
const WIRE_INK: &str = "Wire/Ink";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::{Arc, BTreeSet, Document, Node, NodeId, board, cmd, components, measure, style_vars};
|
||||
|
||||
/// Nodes whose `Arc` differs from `before` (new or edited).
|
||||
pub fn changed_nodes(doc: &Document, before: &Document) -> Vec<NodeId> {
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{Annotation, AnnotationCategory, Document, NodeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::devinspect::{Prop, Section, properties};
|
||||
use crate::params::*;
|
||||
use crate::params::{color_of, get, id, req_index, req_string, string, targets};
|
||||
use crate::{bad, spec};
|
||||
|
||||
/// Most annotations on one layer.
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
//! keeps JSX's braces: `size={size}` → `size="Large"`, `{label}` → `{"Buy"}`), and
|
||||
//! `{prop ? "a" : "b"}` picks one of two strings. Any other `{…}` stays as written.
|
||||
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{CodeLink, Document, NodeId, NodeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::codegen::{CodeLang, Ctx};
|
||||
use crate::component_codegen::{Component, Param, ParamKind, describe, instance_values, js_string, main_of, value_expr};
|
||||
use crate::components::{slots_of, variant_set};
|
||||
use crate::params::*;
|
||||
use crate::params::{get, id_of, targets};
|
||||
use crate::{EngineError, Result, Session, bad, spec};
|
||||
|
||||
/// Longest template (chars).
|
||||
|
||||
@@ -6,12 +6,15 @@
|
||||
//! output is a starting point, not a pixel-exact translation.
|
||||
|
||||
use figcraft_color::{Color, PaintKind};
|
||||
use figcraft_doc::*;
|
||||
use figcraft_doc::{
|
||||
ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Shadow, Sizing,
|
||||
TextAlignH, TextCase, TextDecoration, Variable,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::cmd::CommandSpec;
|
||||
use crate::component_codegen::{Component, Param};
|
||||
use crate::params::*;
|
||||
use crate::params::{get, id_of, num_or, string, targets};
|
||||
use crate::{bad, spec};
|
||||
|
||||
mod arkui;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use super::swift::one_line;
|
||||
use super::*;
|
||||
use super::{
|
||||
CodeLang, Color, CrossAlign, Ctx, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, TextAlignH, TextCase, TextDecoration,
|
||||
drop_shadow, first_fill, indent, literal, num, radius, stroke_color,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ ArkUI (ArkTS)
|
||||
|
||||
@@ -37,8 +40,121 @@ pub(crate) fn arkui(ctx: &Ctx, id: NodeId, depth: usize, out: &mut String, extra
|
||||
arkui_layer(ctx, n, depth, out, extra);
|
||||
}
|
||||
|
||||
fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
|
||||
fn text_layer(ctx: &Ctx, n: &Node, t: &figcraft_doc::TextData, pad: &str, out: &mut String, mods: &mut Vec<String>) {
|
||||
let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident));
|
||||
out.push_str(&format!("{pad}Text({text})\n"));
|
||||
let s = &t.style;
|
||||
mods.push(format!(".fontSize({})", num(s.font_size)));
|
||||
mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900)));
|
||||
mods.push(format!(".fontFamily({})", literal(&s.font_family, false)));
|
||||
if s.italic() {
|
||||
mods.push(".fontStyle(FontStyle.Italic)".into());
|
||||
}
|
||||
if let Some(c) = first_fill(n) {
|
||||
mods.push(format!(".fontColor({})", ark_color(c)));
|
||||
}
|
||||
match s.line_height {
|
||||
LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))),
|
||||
LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))),
|
||||
LineHeight::Auto => {}
|
||||
}
|
||||
match s.letter_spacing {
|
||||
LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))),
|
||||
LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))),
|
||||
_ => {}
|
||||
}
|
||||
match t.align_h {
|
||||
TextAlignH::Left => {}
|
||||
TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()),
|
||||
TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()),
|
||||
TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()),
|
||||
}
|
||||
// Aligning needs a box to align in: the text layer's own width.
|
||||
if !matches!(t.align_h, TextAlignH::Left) {
|
||||
mods.push(format!(".width({})", num(n.width)));
|
||||
}
|
||||
match s.case {
|
||||
TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()),
|
||||
TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()),
|
||||
_ => {}
|
||||
}
|
||||
match s.decoration {
|
||||
TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()),
|
||||
TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()),
|
||||
TextDecoration::None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn frame_layer(ctx: &Ctx, n: &Node, depth: usize, pad: &str, out: &mut String, mods: &mut Vec<String>) {
|
||||
let doc = ctx.doc;
|
||||
let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
|
||||
Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)),
|
||||
Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)),
|
||||
None => "Stack({ alignContent: Alignment.TopStart })".to_string(),
|
||||
};
|
||||
out.push_str(&format!("{pad}{open} {{\n"));
|
||||
match ctx.param(n, "slot") {
|
||||
// A slot shows the instance's content, or else the main's (its default builder).
|
||||
Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)),
|
||||
None => {
|
||||
for c in ctx.kids(n) {
|
||||
// Free children sit at their position inside a Stack, and so does a child
|
||||
// that ignores its auto layout parent.
|
||||
let pos: Vec<String> = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) {
|
||||
Some(cn) => {
|
||||
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
|
||||
vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))]
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
arkui(ctx, c, depth + 1, out, &pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(&format!("{pad}}}\n"));
|
||||
if let Some(l) = &n.layout {
|
||||
layout_mods(l, mods);
|
||||
}
|
||||
mods.push(format!(".width({})", num(n.width)));
|
||||
mods.push(format!(".height({})", num(n.height)));
|
||||
if let Some(c) = first_fill(n) {
|
||||
mods.push(format!(".backgroundColor({})", ark_color(c)));
|
||||
}
|
||||
if radius(n) > 0.0 {
|
||||
mods.push(".clip(true)".into());
|
||||
}
|
||||
ark_radius(n, mods);
|
||||
}
|
||||
|
||||
fn layout_mods(l: &figcraft_doc::AutoLayout, mods: &mut Vec<String>) {
|
||||
let [t, r, b, lft] = l.padding;
|
||||
if [t, r, b, lft].iter().any(|v| *v > 0.0) {
|
||||
mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft)));
|
||||
}
|
||||
let justify = match l.main_align {
|
||||
MainAlign::Min => None,
|
||||
MainAlign::Center => Some("FlexAlign.Center"),
|
||||
MainAlign::Max => Some("FlexAlign.End"),
|
||||
MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"),
|
||||
MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"),
|
||||
MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"),
|
||||
};
|
||||
if let Some(j) = justify {
|
||||
mods.push(format!(".justifyContent({j})"));
|
||||
}
|
||||
let cross = match l.cross_align {
|
||||
CrossAlign::Min => None,
|
||||
CrossAlign::Center => Some("Center"),
|
||||
CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }),
|
||||
CrossAlign::Baseline => None,
|
||||
};
|
||||
if let Some(c) = cross {
|
||||
let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" };
|
||||
mods.push(format!(".alignItems({ty}.{c})"));
|
||||
}
|
||||
}
|
||||
|
||||
fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[String]) {
|
||||
let pad = indent(depth);
|
||||
if ctx.too_deep(depth) {
|
||||
out.push_str(&format!("{pad}// {} …\n", one_line(&n.name)));
|
||||
@@ -68,48 +184,7 @@ fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
|
||||
let mut mods: Vec<String> = Vec::new();
|
||||
match &n.kind {
|
||||
NodeKind::Text(t) => {
|
||||
let text = ctx.param(n, "characters").map_or_else(|| literal(&t.characters, false), |p| format!("this.{}", p.ident));
|
||||
out.push_str(&format!("{pad}Text({text})\n"));
|
||||
let s = &t.style;
|
||||
mods.push(format!(".fontSize({})", num(s.font_size)));
|
||||
mods.push(format!(".fontWeight({})", s.weight().clamp(100, 900)));
|
||||
mods.push(format!(".fontFamily({})", literal(&s.font_family, false)));
|
||||
if s.italic() {
|
||||
mods.push(".fontStyle(FontStyle.Italic)".into());
|
||||
}
|
||||
if let Some(c) = first_fill(n) {
|
||||
mods.push(format!(".fontColor({})", ark_color(c)));
|
||||
}
|
||||
match s.line_height {
|
||||
LineHeight::Pixels(v) => mods.push(format!(".lineHeight({})", num(v))),
|
||||
LineHeight::Percent(p) => mods.push(format!(".lineHeight('{}%')", num(p))),
|
||||
LineHeight::Auto => {}
|
||||
}
|
||||
match s.letter_spacing {
|
||||
LetterSpacing::Pixels(v) if v != 0.0 => mods.push(format!(".letterSpacing({})", num(v))),
|
||||
LetterSpacing::Percent(p) if p != 0.0 => mods.push(format!(".letterSpacing('{}%')", num(p))),
|
||||
_ => {}
|
||||
}
|
||||
match t.align_h {
|
||||
TextAlignH::Left => {}
|
||||
TextAlignH::Center => mods.push(".textAlign(TextAlign.Center)".into()),
|
||||
TextAlignH::Right => mods.push(".textAlign(TextAlign.End)".into()),
|
||||
TextAlignH::Justified => mods.push(".textAlign(TextAlign.JUSTIFY)".into()),
|
||||
}
|
||||
// Aligning needs a box to align in: the text layer's own width.
|
||||
if !matches!(t.align_h, TextAlignH::Left) {
|
||||
mods.push(format!(".width({})", num(n.width)));
|
||||
}
|
||||
match s.case {
|
||||
TextCase::Upper => mods.push(".textCase(TextCase.UpperCase)".into()),
|
||||
TextCase::Lower => mods.push(".textCase(TextCase.LowerCase)".into()),
|
||||
_ => {}
|
||||
}
|
||||
match s.decoration {
|
||||
TextDecoration::Underline => mods.push(".decoration({ type: TextDecorationType.Underline })".into()),
|
||||
TextDecoration::Strikethrough => mods.push(".decoration({ type: TextDecorationType.LineThrough })".into()),
|
||||
TextDecoration::None => {}
|
||||
}
|
||||
text_layer(ctx, n, t, &pad, out, &mut mods);
|
||||
}
|
||||
NodeKind::Ellipse { .. } => {
|
||||
out.push_str(&format!("{pad}Ellipse({{ width: {}, height: {} }})\n", num(n.width), num(n.height)));
|
||||
@@ -131,67 +206,7 @@ fn arkui_layer(ctx: &Ctx, n: &Node, depth: usize, out: &mut String, extra: &[Str
|
||||
mods.push(format!(".height({})", num(n.height)));
|
||||
}
|
||||
k if k.is_frame_like() || matches!(k, NodeKind::Group) => {
|
||||
let open = match n.layout.as_ref().map(|l| (l.mode, l)) {
|
||||
Some((LayoutMode::Vertical, l)) => format!("Column({{ space: {} }})", num(l.gap)),
|
||||
Some((_, l)) => format!("Row({{ space: {} }})", num(l.gap)),
|
||||
None => "Stack({ alignContent: Alignment.TopStart })".to_string(),
|
||||
};
|
||||
out.push_str(&format!("{pad}{open} {{\n"));
|
||||
match ctx.param(n, "slot") {
|
||||
// A slot shows the instance's content, or else the main's (its default builder).
|
||||
Some(p) => out.push_str(&format!("{pad} this.{}()\n", p.ident)),
|
||||
None => {
|
||||
for c in ctx.kids(n) {
|
||||
// Free children sit at their position inside a Stack, and so does a child
|
||||
// that ignores its auto layout parent.
|
||||
let pos: Vec<String> = match doc.node(c).filter(|cn| n.layout.is_none() || cn.child_layout.absolute) {
|
||||
Some(cn) => {
|
||||
let [_, _, _, _, x, y] = cn.transform.as_coeffs();
|
||||
vec![format!(".position({{ x: {}, y: {} }})", num(x), num(y))]
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
arkui(ctx, c, depth + 1, out, &pos);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(&format!("{pad}}}\n"));
|
||||
if let Some(l) = &n.layout {
|
||||
let [t, r, b, lft] = l.padding;
|
||||
if [t, r, b, lft].iter().any(|v| *v > 0.0) {
|
||||
mods.push(format!(".padding({{ top: {}, right: {}, bottom: {}, left: {} }})", num(t), num(r), num(b), num(lft)));
|
||||
}
|
||||
let justify = match l.main_align {
|
||||
MainAlign::Min => None,
|
||||
MainAlign::Center => Some("FlexAlign.Center"),
|
||||
MainAlign::Max => Some("FlexAlign.End"),
|
||||
MainAlign::SpaceBetween => Some("FlexAlign.SpaceBetween"),
|
||||
MainAlign::SpaceAround => Some("FlexAlign.SpaceAround"),
|
||||
MainAlign::SpaceEvenly => Some("FlexAlign.SpaceEvenly"),
|
||||
};
|
||||
if let Some(j) = justify {
|
||||
mods.push(format!(".justifyContent({j})"));
|
||||
}
|
||||
let cross = match l.cross_align {
|
||||
CrossAlign::Min => None,
|
||||
CrossAlign::Center => Some("Center"),
|
||||
CrossAlign::Max => Some(if l.mode == LayoutMode::Vertical { "End" } else { "Bottom" }),
|
||||
CrossAlign::Baseline => None,
|
||||
};
|
||||
if let Some(c) = cross {
|
||||
let ty = if l.mode == LayoutMode::Vertical { "HorizontalAlign" } else { "VerticalAlign" };
|
||||
mods.push(format!(".alignItems({ty}.{c})"));
|
||||
}
|
||||
}
|
||||
mods.push(format!(".width({})", num(n.width)));
|
||||
mods.push(format!(".height({})", num(n.height)));
|
||||
if let Some(c) = first_fill(n) {
|
||||
mods.push(format!(".backgroundColor({})", ark_color(c)));
|
||||
}
|
||||
if radius(n) > 0.0 {
|
||||
mods.push(".clip(true)".into());
|
||||
}
|
||||
ark_radius(n, &mut mods);
|
||||
frame_layer(ctx, n, depth, &pad, out, &mut mods);
|
||||
}
|
||||
_ => {
|
||||
out.push_str(&format!("{pad}// {}: export as an image asset\n{pad}Image($r('app.media.asset'))\n", n.kind.label()));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::swift::one_line;
|
||||
use super::*;
|
||||
use super::{CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, drop_shadow, first_fill, indent, literal, num, radius, stroke_color};
|
||||
|
||||
// ------------------------------------------------------------------ Jetpack Compose
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
ContentAlign, CrossAlign, Document, EffectKind, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, PaintKind, TextAlignH,
|
||||
TextCase, TextDecoration, Variable, css_borders, num,
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------ CSS
|
||||
|
||||
@@ -85,51 +88,7 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
||||
put("height", tok(doc, n, &["height"], px(n.height), t));
|
||||
}
|
||||
if let Some(l) = &n.layout {
|
||||
put("display", "flex".into());
|
||||
put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into());
|
||||
if l.wrap {
|
||||
put("flex-wrap", "wrap".into());
|
||||
}
|
||||
put("gap", tok(doc, n, &["gap"], px(l.gap), t));
|
||||
let [pt, pr, pb, pl] = l.padding;
|
||||
let sides = [
|
||||
tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t),
|
||||
tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t),
|
||||
tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t),
|
||||
tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t),
|
||||
];
|
||||
put("padding", sides.join(" "));
|
||||
let justify = match l.main_align {
|
||||
MainAlign::Min => "flex-start",
|
||||
MainAlign::Center => "center",
|
||||
MainAlign::Max => "flex-end",
|
||||
MainAlign::SpaceBetween => "space-between",
|
||||
MainAlign::SpaceAround => "space-around",
|
||||
MainAlign::SpaceEvenly => "space-evenly",
|
||||
};
|
||||
put("justify-content", justify.into());
|
||||
let align = match l.cross_align {
|
||||
CrossAlign::Min => "flex-start",
|
||||
CrossAlign::Center => "center",
|
||||
CrossAlign::Max => "flex-end",
|
||||
CrossAlign::Baseline => "baseline",
|
||||
};
|
||||
put("align-items", align.into());
|
||||
if l.wrap {
|
||||
// The lines: spread, or packed where the items align (baseline: at the start).
|
||||
let spread = l.content_align == ContentAlign::SpaceBetween;
|
||||
put(
|
||||
"align-content",
|
||||
if spread {
|
||||
"space-between"
|
||||
} else if align == "baseline" {
|
||||
"flex-start"
|
||||
} else {
|
||||
align
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
layout_decls(doc, n, l, t, &mut put);
|
||||
}
|
||||
if matches!(n.kind, NodeKind::Ellipse { .. }) {
|
||||
put("border-radius", "50%".into());
|
||||
@@ -169,6 +128,65 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
||||
for (key, value) in css_borders(doc, n, t) {
|
||||
put(&key, value);
|
||||
}
|
||||
effect_decls(n, &mut put);
|
||||
if let NodeKind::Text(tx) = &n.kind {
|
||||
text_decls(doc, n, tx, t, &mut put);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The flex declarations of auto layout `l` on `n`.
|
||||
fn layout_decls(doc: &Document, n: &Node, l: &figcraft_doc::AutoLayout, t: &mut Tokens, put: &mut impl FnMut(&str, String)) {
|
||||
let px = |v: f64| format!("{}px", num(v));
|
||||
put("display", "flex".into());
|
||||
put("flex-direction", if l.mode == LayoutMode::Vertical { "column" } else { "row" }.into());
|
||||
if l.wrap {
|
||||
put("flex-wrap", "wrap".into());
|
||||
}
|
||||
put("gap", tok(doc, n, &["gap"], px(l.gap), t));
|
||||
let [pt, pr, pb, pl] = l.padding;
|
||||
let sides = [
|
||||
tok(doc, n, &["paddingTop", "paddingVertical", "padding"], px(pt), t),
|
||||
tok(doc, n, &["paddingRight", "paddingHorizontal", "padding"], px(pr), t),
|
||||
tok(doc, n, &["paddingBottom", "paddingVertical", "padding"], px(pb), t),
|
||||
tok(doc, n, &["paddingLeft", "paddingHorizontal", "padding"], px(pl), t),
|
||||
];
|
||||
put("padding", sides.join(" "));
|
||||
let justify = match l.main_align {
|
||||
MainAlign::Min => "flex-start",
|
||||
MainAlign::Center => "center",
|
||||
MainAlign::Max => "flex-end",
|
||||
MainAlign::SpaceBetween => "space-between",
|
||||
MainAlign::SpaceAround => "space-around",
|
||||
MainAlign::SpaceEvenly => "space-evenly",
|
||||
};
|
||||
put("justify-content", justify.into());
|
||||
let align = match l.cross_align {
|
||||
CrossAlign::Min => "flex-start",
|
||||
CrossAlign::Center => "center",
|
||||
CrossAlign::Max => "flex-end",
|
||||
CrossAlign::Baseline => "baseline",
|
||||
};
|
||||
put("align-items", align.into());
|
||||
if l.wrap {
|
||||
// The lines: spread, or packed where the items align (baseline: at the start).
|
||||
let spread = l.content_align == ContentAlign::SpaceBetween;
|
||||
put(
|
||||
"align-content",
|
||||
if spread {
|
||||
"space-between"
|
||||
} else if align == "baseline" {
|
||||
"flex-start"
|
||||
} else {
|
||||
align
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shadows and blurs of `n`.
|
||||
fn effect_decls(n: &Node, put: &mut impl FnMut(&str, String)) {
|
||||
let shadows: Vec<String> = n
|
||||
.effects
|
||||
.iter()
|
||||
@@ -196,75 +214,82 @@ pub(crate) fn css_decls(doc: &Document, n: &Node, t: &mut Tokens, hug: (bool, bo
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let NodeKind::Text(tx) = &n.kind {
|
||||
let s = &tx.style;
|
||||
put("font-family", tok(doc, n, &["fontFamily"], format!("\"{}\"", s.font_family.replace('"', "'")), t));
|
||||
put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t));
|
||||
put("font-weight", s.weight().to_string());
|
||||
if s.italic() {
|
||||
put("font-style", "italic".into());
|
||||
}
|
||||
|
||||
/// The type declarations of text layer `n`.
|
||||
fn text_decls(doc: &Document, n: &Node, tx: &figcraft_doc::TextData, t: &mut Tokens, put: &mut impl FnMut(&str, String)) {
|
||||
let px = |v: f64| format!("{}px", num(v));
|
||||
let s = &tx.style;
|
||||
put("font-family", tok(doc, n, &["fontFamily"], format!("\"{}\"", s.font_family.replace('"', "'")), t));
|
||||
put("font-size", tok(doc, n, &["fontSize"], px(s.font_size), t));
|
||||
put("font-weight", s.weight().to_string());
|
||||
if s.italic() {
|
||||
put("font-style", "italic".into());
|
||||
}
|
||||
match s.line_height {
|
||||
LineHeight::Pixels(v) => put("line-height", tok(doc, n, &["lineHeight"], px(v), t)),
|
||||
LineHeight::Percent(p) => put("line-height", format!("{}%", num(p))),
|
||||
LineHeight::Auto => {}
|
||||
}
|
||||
match s.letter_spacing {
|
||||
LetterSpacing::Pixels(v) if v != 0.0 => put("letter-spacing", tok(doc, n, &["letterSpacing"], px(v), t)),
|
||||
LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))),
|
||||
_ => {}
|
||||
}
|
||||
match tx.align_h {
|
||||
TextAlignH::Left => {}
|
||||
TextAlignH::Center => put("text-align", "center".into()),
|
||||
TextAlignH::Right => put("text-align", "right".into()),
|
||||
TextAlignH::Justified => put("text-align", "justify".into()),
|
||||
}
|
||||
match s.case {
|
||||
TextCase::Original => {}
|
||||
TextCase::Upper => put("text-transform", "uppercase".into()),
|
||||
TextCase::Lower => put("text-transform", "lowercase".into()),
|
||||
TextCase::Title => put("text-transform", "capitalize".into()),
|
||||
TextCase::SmallCaps => put("font-variant", "small-caps".into()),
|
||||
}
|
||||
match s.decoration {
|
||||
TextDecoration::None => {}
|
||||
TextDecoration::Underline => put("text-decoration", "underline".into()),
|
||||
TextDecoration::Strikethrough => put("text-decoration", "line-through".into()),
|
||||
}
|
||||
decoration_decls(s, put);
|
||||
if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight {
|
||||
put("text-box", "trim-both cap alphabetic".into());
|
||||
}
|
||||
}
|
||||
|
||||
fn decoration_decls(s: &figcraft_doc::TextStyle, put: &mut impl FnMut(&str, String)) {
|
||||
let px = |v: f64| format!("{}px", num(v));
|
||||
if s.decoration != TextDecoration::None {
|
||||
let l = s.look();
|
||||
let len = |m: LineHeight| match m {
|
||||
LineHeight::Auto => None,
|
||||
LineHeight::Pixels(v) => Some(px(v)),
|
||||
LineHeight::Percent(p) => Some(format!("{}%", num(p))),
|
||||
};
|
||||
match l.style {
|
||||
figcraft_doc::DecorationStyle::Solid => {}
|
||||
figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()),
|
||||
figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()),
|
||||
}
|
||||
match s.line_height {
|
||||
LineHeight::Pixels(v) => put("line-height", tok(doc, n, &["lineHeight"], px(v), t)),
|
||||
LineHeight::Percent(p) => put("line-height", format!("{}%", num(p))),
|
||||
LineHeight::Auto => {}
|
||||
if let Some(v) = len(l.thickness) {
|
||||
put("text-decoration-thickness", v);
|
||||
}
|
||||
match s.letter_spacing {
|
||||
LetterSpacing::Pixels(v) if v != 0.0 => put("letter-spacing", tok(doc, n, &["letterSpacing"], px(v), t)),
|
||||
LetterSpacing::Percent(p) if p != 0.0 => put("letter-spacing", format!("{}em", num(p / 100.0))),
|
||||
_ => {}
|
||||
if let Some(c) = l.color {
|
||||
put("text-decoration-color", c.to_css());
|
||||
}
|
||||
match tx.align_h {
|
||||
TextAlignH::Left => {}
|
||||
TextAlignH::Center => put("text-align", "center".into()),
|
||||
TextAlignH::Right => put("text-align", "right".into()),
|
||||
TextAlignH::Justified => put("text-align", "justify".into()),
|
||||
}
|
||||
match s.case {
|
||||
TextCase::Original => {}
|
||||
TextCase::Upper => put("text-transform", "uppercase".into()),
|
||||
TextCase::Lower => put("text-transform", "lowercase".into()),
|
||||
TextCase::Title => put("text-transform", "capitalize".into()),
|
||||
TextCase::SmallCaps => put("font-variant", "small-caps".into()),
|
||||
}
|
||||
match s.decoration {
|
||||
TextDecoration::None => {}
|
||||
TextDecoration::Underline => put("text-decoration", "underline".into()),
|
||||
TextDecoration::Strikethrough => put("text-decoration", "line-through".into()),
|
||||
}
|
||||
if s.decoration != TextDecoration::None {
|
||||
let l = s.look();
|
||||
let len = |m: LineHeight| match m {
|
||||
LineHeight::Auto => None,
|
||||
LineHeight::Pixels(v) => Some(px(v)),
|
||||
LineHeight::Percent(p) => Some(format!("{}%", num(p))),
|
||||
};
|
||||
match l.style {
|
||||
figcraft_doc::DecorationStyle::Solid => {}
|
||||
figcraft_doc::DecorationStyle::Dotted => put("text-decoration-style", "dotted".into()),
|
||||
figcraft_doc::DecorationStyle::Wavy => put("text-decoration-style", "wavy".into()),
|
||||
if s.decoration == TextDecoration::Underline {
|
||||
if let Some(v) = len(l.offset) {
|
||||
put("text-underline-offset", v);
|
||||
}
|
||||
if let Some(v) = len(l.thickness) {
|
||||
put("text-decoration-thickness", v);
|
||||
// Browsers skip ink by default; the canvas only when asked.
|
||||
if !l.skip_ink {
|
||||
put("text-decoration-skip-ink", "none".into());
|
||||
}
|
||||
if let Some(c) = l.color {
|
||||
put("text-decoration-color", c.to_css());
|
||||
}
|
||||
if s.decoration == TextDecoration::Underline {
|
||||
if let Some(v) = len(l.offset) {
|
||||
put("text-underline-offset", v);
|
||||
}
|
||||
// Browsers skip ink by default; the canvas only when asked.
|
||||
if !l.skip_ink {
|
||||
put("text-decoration-skip-ink", "none".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if tx.leading_trim == figcraft_doc::LeadingTrim::CapHeight {
|
||||
put("text-box", "trim-both cap alphabetic".into());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A gradient fill on a `w`×`h` box as a CSS background: linear along its handles' direction
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user