diff --git a/CLAUDE.md b/CLAUDE.md
index b2384311..f61412ba 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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`.
diff --git a/Cargo.toml b/Cargo.toml
index 61bc7cf1..8c4142e6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/apps/figcraft-server/src/codes_tests.rs b/apps/figcraft-server/src/codes_tests.rs
index 1e440a32..1e58f7c2 100644
--- a/apps/figcraft-server/src/codes_tests.rs
+++ b/apps/figcraft-server/src/codes_tests.rs
@@ -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");
diff --git a/apps/figcraft-server/src/library_tests.rs b/apps/figcraft-server/src/library_tests.rs
index f4703f20..5dd45435 100644
--- a/apps/figcraft-server/src/library_tests.rs
+++ b/apps/figcraft-server/src/library_tests.rs
@@ -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.
diff --git a/apps/figcraft-server/src/store/backend.rs b/apps/figcraft-server/src/store/backend.rs
index af9b8bbb..95fd8948 100644
--- a/apps/figcraft-server/src/store/backend.rs
+++ b/apps/figcraft-server/src/store/backend.rs
@@ -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 {
diff --git a/apps/figcraft-server/src/store/files.rs b/apps/figcraft-server/src/store/files.rs
index 53ae9c87..565916ed 100644
--- a/apps/figcraft-server/src/store/files.rs
+++ b/apps/figcraft-server/src/store/files.rs
@@ -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;
}
diff --git a/apps/figcraft-server/src/store/journal.rs b/apps/figcraft-server/src/store/journal.rs
index e3983543..5f8904fc 100644
--- a/apps/figcraft-server/src/store/journal.rs
+++ b/apps/figcraft-server/src/store/journal.rs
@@ -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
diff --git a/apps/figcraft-server/src/store/library.rs b/apps/figcraft-server/src/store/library.rs
index adb592db..55ca50ad 100644
--- a/apps/figcraft-server/src/store/library.rs
+++ b/apps/figcraft-server/src/store/library.rs
@@ -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
diff --git a/apps/figcraft-server/src/store/links.rs b/apps/figcraft-server/src/store/links.rs
index ad30f458..6c279219 100644
--- a/apps/figcraft-server/src/store/links.rs
+++ b/apps/figcraft-server/src/store/links.rs
@@ -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.
diff --git a/clippy.toml b/clippy.toml
index 36cc87a4..305f94bd 100644
--- a/clippy.toml
+++ b/clippy.toml
@@ -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
diff --git a/crates/collab-net/src/hub/backend.rs b/crates/collab-net/src/hub/backend.rs
index 04c5363d..e594b0f1 100644
--- a/crates/collab-net/src/hub/backend.rs
+++ b/crates/collab-net/src/hub/backend.rs
@@ -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)]
diff --git a/crates/collab-net/src/hub/conn.rs b/crates/collab-net/src/hub/conn.rs
index 109dfed9..895218c4 100644
--- a/crates/collab-net/src/hub/conn.rs
+++ b/crates/collab-net/src/hub/conn.rs
@@ -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);
diff --git a/crates/collab-net/src/hub/hosting.rs b/crates/collab-net/src/hub/hosting.rs
index 8588e56a..df8fd975 100644
--- a/crates/collab-net/src/hub/hosting.rs
+++ b/crates/collab-net/src/hub/hosting.rs
@@ -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 {
diff --git a/crates/collab-net/src/hub/limits.rs b/crates/collab-net/src/hub/limits.rs
index 333ff165..66369790 100644
--- a/crates/collab-net/src/hub/limits.rs
+++ b/crates/collab-net/src/hub/limits.rs
@@ -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)]
diff --git a/crates/collab-net/src/hub/queue.rs b/crates/collab-net/src/hub/queue.rs
index b6bce39e..1c6a5dfc 100644
--- a/crates/collab-net/src/hub/queue.rs
+++ b/crates/collab-net/src/hub/queue.rs
@@ -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.
diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs
index 2a284b05..3146047a 100644
--- a/crates/doc/src/node.rs
+++ b/crates/doc/src/node.rs
@@ -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;
diff --git a/crates/engine/src/assets/components.rs b/crates/engine/src/assets/components.rs
index 3932efff..3192c3f1 100644
--- a/crates/engine/src/assets/components.rs
+++ b/crates/engine/src/assets/components.rs
@@ -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;
diff --git a/crates/engine/src/assets/components/diff.rs b/crates/engine/src/assets/components/diff.rs
index bf742fed..41c1a424 100644
--- a/crates/engine/src/assets/components/diff.rs
+++ b/crates/engine/src/assets/components/diff.rs
@@ -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).
diff --git a/crates/engine/src/assets/components/guard.rs b/crates/engine/src/assets/components/guard.rs
index b320b7c4..0424beff 100644
--- a/crates/engine/src/assets/components/guard.rs
+++ b/crates/engine/src/assets/components/guard.rs
@@ -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
diff --git a/crates/engine/src/assets/components/instances.rs b/crates/engine/src/assets/components/instances.rs
index 9fd4e2c2..ac7ff4ec 100644
--- a/crates/engine/src/assets/components/instances.rs
+++ b/crates/engine/src/assets/components/instances.rs
@@ -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 {
diff --git a/crates/engine/src/assets/components/patch.rs b/crates/engine/src/assets/components/patch.rs
index 8c61f0b8..8ae01bc1 100644
--- a/crates/engine/src/assets/components/patch.rs
+++ b/crates/engine/src/assets/components/patch.rs
@@ -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 = 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 = 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) {
+ 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).
diff --git a/crates/engine/src/assets/components/rebase.rs b/crates/engine/src/assets/components/rebase.rs
index 748a99d3..ea9cb6f3 100644
--- a/crates/engine/src/assets/components/rebase.rs
+++ b/crates/engine/src/assets/components/rebase.rs
@@ -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> {
diff --git a/crates/engine/src/assets/components/sync.rs b/crates/engine/src/assets/components/sync.rs
index c502322d..e81bf437 100644
--- a/crates/engine/src/assets/components/sync.rs
+++ b/crates/engine/src/assets/components/sync.rs
@@ -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> = spare;
- let mut by_source: BTreeMap<(NodeId, NodeId), NodeId> = BTreeMap::new();
- let mut by_src: BTreeMap> = BTreeMap::new();
- for i in &old_ids {
- let Some(n) = doc.node_arc(*i).or_else(|| old.get(i)).cloned() else { continue };
- if let Some(s) = n.source {
- if let Some(p) = n.parent {
- by_source.entry((p, s)).or_insert(*i);
- }
- by_src.entry(s).and_modify(|e| *e = None).or_insert(Some(*i));
- }
- old.insert(*i, n);
- }
+ let (mut old, by_source, by_src) = index_old_layers(doc, inst, spare);
let mut by_path: Option> = None;
let mut main_paths: Option> = None;
// Ids for the copies: reuse the matching old layer's id (a layer the main moved to another
@@ -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,
+ old: &BTreeMap>,
+ remeasure: &mut Vec,
+) {
+ 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, old: &BTreeMap>, used: &BTreeSet, depth: usize) {
+ let unused: BTreeMap> =
+ old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
+ for n in nested {
+ let mut below: BTreeMap> = unused
+ .iter()
+ .filter(|(_, x)| {
+ let mut cur = x.parent;
+ for _ in 0..=MAX_DEPTH * 4 {
+ match cur {
+ Some(p) if p == n => return true,
+ Some(p) => cur = unused.get(&p).and_then(|a| a.parent),
+ None => return false,
+ }
+ }
+ false
+ })
+ .map(|(i, x)| (*i, x.clone()))
+ .collect();
+ take_placing_overrides(doc, n, old, &mut below);
+ sync_instance_depth(doc, n, depth + 1, below);
+ }
+}
+
+fn apply_enclosing_props(doc: &Document, n: &mut Node, orig: &Node, enc: &[NodeId], inst_props: &BTreeMap>) {
+ 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) {
+ 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) -> BTreeMap {
+ 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>, BTreeMap<(NodeId, NodeId), NodeId>, BTreeMap>);
+
+/// 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>) -> 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> = 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) -> Node {
// Root: follows the main except its own placement, name, size and overrides.
let order: Vec = comp.children.iter().filter_map(|c| map.get(c).copied()).filter(|c| doc.contains(*c)).collect();
let mut fresh = comp.clone();
@@ -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> =
- old.iter().filter(|(i, _)| !used.contains(*i) && !doc.contains(**i)).map(|(i, x)| (*i, x.clone())).collect();
- for n in nested {
- let mut below: BTreeMap> = unused
- .iter()
- .filter(|(_, x)| {
- let mut cur = x.parent;
- for _ in 0..=MAX_DEPTH * 4 {
- match cur {
- Some(p) if p == n => return true,
- Some(p) => cur = unused.get(&p).and_then(|a| a.parent),
- None => return false,
- }
- }
- false
- })
- .map(|(i, x)| (*i, x.clone()))
- .collect();
- take_placing_overrides(doc, n, &old, &mut below);
- sync_instance_depth(doc, n, depth + 1, below);
- }
+ 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
diff --git a/crates/engine/src/assets/components/tests.rs b/crates/engine/src/assets/components/tests.rs
index 30254651..82aabc63 100644
--- a/crates/engine/src/assets/components/tests.rs
+++ b/crates/engine/src/assets/components/tests.rs
@@ -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);
}
diff --git a/crates/engine/src/assets/components/variants.rs b/crates/engine/src/assets/components/variants.rs
index 40ac23ea..bc0d25b7 100644
--- a/crates/engine/src/assets/components/variants.rs
+++ b/crates/engine/src/assets/components/variants.rs
@@ -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).
diff --git a/crates/engine/src/assets/library.rs b/crates/engine/src/assets/library.rs
index 56b0fc07..24699ef2 100644
--- a/crates/engine/src/assets/library.rs
+++ b/crates/engine/src/assets/library.rs
@@ -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;
diff --git a/crates/engine/src/assets/library/tests.rs b/crates/engine/src/assets/library/tests.rs
index 27c9e616..c8ef2e49 100644
--- a/crates/engine/src/assets/library/tests.rs
+++ b/crates/engine/src/assets/library/tests.rs
@@ -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 = 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 = 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"));
diff --git a/crates/engine/src/assets/library_swap.rs b/crates/engine/src/assets/library_swap.rs
index 1220d24c..1dfc1691 100644
--- a/crates/engine/src/assets/library_swap.rs
+++ b/crates/engine/src/assets/library_swap.rs
@@ -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 {
+ 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 {
let m = &to.manifest;
@@ -64,14 +93,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re
})
.collect();
let records: Vec = 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 = 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 = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| style_links(n).cloned()).collect();
let style_recs: Vec = doc.libraries.styles.iter().filter(|r| r.library == from && linked.contains(&r.local)).cloned().collect();
- let mut style_keys: BTreeMap = 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 = style_keys.iter().filter_map(|(a, k)| imported.get(k).map(|b| (a.clone(), b.clone()))).collect();
out.styles = styles.len();
diff --git a/crates/engine/src/assets/playground.rs b/crates/engine/src/assets/playground.rs
index 90a627b0..5c05c3b9 100644
--- a/crates/engine/src/assets/playground.rs
+++ b/crates/engine/src/assets/playground.rs
@@ -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
diff --git a/crates/engine/src/assets/style_vars.rs b/crates/engine/src/assets/style_vars.rs
index b2e5c182..7fb30fd0 100644
--- a/crates/engine/src/assets/style_vars.rs
+++ b/crates/engine/src/assets/style_vars.rs
@@ -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};
diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs
index 7dccd706..886d2d85 100644
--- a/crates/engine/src/automation/inspect.rs
+++ b/crates/engine/src/automation/inspect.rs
@@ -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 = 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) {
+ 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) {
+ 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) {
+ 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) {
+ 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) {
+ 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 = 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) {
+ 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 {
diff --git a/crates/engine/src/boards/board/connectors.rs b/crates/engine/src/boards/board/connectors.rs
index 753550e5..14760331 100644
--- a/crates/engine/src/boards/board/connectors.rs
+++ b/crates/engine/src/boards/board/connectors.rs
@@ -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 {
diff --git a/crates/engine/src/boards/board/derived.rs b/crates/engine/src/boards/board/derived.rs
index ac73df1e..9899632f 100644
--- a/crates/engine/src/boards/board/derived.rs
+++ b/crates/engine/src/boards/board/derived.rs
@@ -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).
diff --git a/crates/engine/src/boards/board/edit.rs b/crates/engine/src/boards/board/edit.rs
index 1770852c..9456256e 100644
--- a/crates/engine/src/boards/board/edit.rs
+++ b/crates/engine/src/boards/board/edit.rs
@@ -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 {
diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs
index 6d96d34b..b76fc898 100644
--- a/crates/engine/src/boards/board/items.rs
+++ b/crates/engine/src/boards/board/items.rs
@@ -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(
diff --git a/crates/engine/src/boards/board/tools.rs b/crates/engine/src/boards/board/tools.rs
index 2dc904c9..cb3fc005 100644
--- a/crates/engine/src/boards/board/tools.rs
+++ b/crates/engine/src/boards/board/tools.rs
@@ -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.
diff --git a/crates/engine/src/canvas/drop.rs b/crates/engine/src/canvas/drop.rs
index d5e8b3cf..9e054630 100644
--- a/crates/engine/src/canvas/drop.rs
+++ b/crates/engine/src/canvas/drop.rs
@@ -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;
diff --git a/crates/engine/src/canvas/shapebuilder.rs b/crates/engine/src/canvas/shapebuilder.rs
index 99d59b45..74a4a9d1 100644
--- a/crates/engine/src/canvas/shapebuilder.rs
+++ b/crates/engine/src/canvas/shapebuilder.rs
@@ -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};
diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs
index fce14f7c..b1dc3eff 100644
--- a/crates/engine/src/canvas/tools.rs
+++ b/crates/engine/src/canvas/tools.rs
@@ -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;
diff --git a/crates/engine/src/canvas/tools/click.rs b/crates/engine/src/canvas/tools/click.rs
index 8f21aaca..a8291f0f 100644
--- a/crates/engine/src/canvas/tools/click.rs
+++ b/crates/engine/src/canvas/tools/click.rs
@@ -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,
diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs
index 0d406673..d1db8f92 100644
--- a/crates/engine/src/canvas/tools/drag.rs
+++ b/crates/engine/src/canvas/tools/drag.rs
@@ -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<()> {
diff --git a/crates/engine/src/canvas/tools/draw.rs b/crates/engine/src/canvas/tools/draw.rs
index b594ae27..36a12bc2 100644
--- a/crates/engine/src/canvas/tools/draw.rs
+++ b/crates/engine/src/canvas/tools/draw.rs
@@ -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<()> {
diff --git a/crates/engine/src/canvas/tools/handles.rs b/crates/engine/src/canvas/tools/handles.rs
index fd765f66..7ccaca40 100644
--- a/crates/engine/src/canvas/tools/handles.rs
+++ b/crates/engine/src/canvas/tools/handles.rs
@@ -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).
diff --git a/crates/engine/src/canvas/tools/keys.rs b/crates/engine/src/canvas/tools/keys.rs
index 591f1479..a369463c 100644
--- a/crates/engine/src/canvas/tools/keys.rs
+++ b/crates/engine/src/canvas/tools/keys.rs
@@ -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
diff --git a/crates/engine/src/canvas/tools/pen.rs b/crates/engine/src/canvas/tools/pen.rs
index 319c11b7..4857e83c 100644
--- a/crates/engine/src/canvas/tools/pen.rs
+++ b/crates/engine/src/canvas/tools/pen.rs
@@ -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
diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs
index 985e3326..550c4a66 100644
--- a/crates/engine/src/canvas/tools/pointer.rs
+++ b/crates/engine/src/canvas/tools/pointer.rs
@@ -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
diff --git a/crates/engine/src/canvas/vectoredit.rs b/crates/engine/src/canvas/vectoredit.rs
index 03b22d22..fc263b4f 100644
--- a/crates/engine/src/canvas/vectoredit.rs
+++ b/crates/engine/src/canvas/vectoredit.rs
@@ -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;
diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs
index 935f033a..8832e05d 100644
--- a/crates/engine/src/canvas/vectoredit/ops.rs
+++ b/crates/engine/src/canvas/vectoredit/ops.rs
@@ -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};
diff --git a/crates/engine/src/canvas/vectoredit/pointer.rs b/crates/engine/src/canvas/vectoredit/pointer.rs
index d977ce87..ebd8e2e3 100644
--- a/crates/engine/src/canvas/vectoredit/pointer.rs
+++ b/crates/engine/src/canvas/vectoredit/pointer.rs
@@ -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 {
+ 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 {
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()) => {
diff --git a/crates/engine/src/canvas/vectoredit/transform.rs b/crates/engine/src/canvas/vectoredit/transform.rs
index 5efb5343..0f87a0bd 100644
--- a/crates/engine/src/canvas/vectoredit/transform.rs
+++ b/crates/engine/src/canvas/vectoredit/transform.rs
@@ -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};
diff --git a/crates/engine/src/canvas/vectoredit/width.rs b/crates/engine/src/canvas/vectoredit/width.rs
index ba51b043..13c7be0c 100644
--- a/crates/engine/src/canvas/vectoredit/width.rs
+++ b/crates/engine/src/canvas/vectoredit/width.rs
@@ -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};
diff --git a/crates/engine/src/canvas/vectoredit/width_sides_tests.rs b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs
index bf2dbeb0..937245ae 100644
--- a/crates/engine/src/canvas/vectoredit/width_sides_tests.rs
+++ b/crates/engine/src/canvas/vectoredit/width_sides_tests.rs
@@ -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}"))
diff --git a/crates/engine/src/canvas/vectoredit/width_tests.rs b/crates/engine/src/canvas/vectoredit/width_tests.rs
index 54b4b0e2..8c89d4c6 100644
--- a/crates/engine/src/canvas/vectoredit/width_tests.rs
+++ b/crates/engine/src/canvas/vectoredit/width_tests.rs
@@ -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}"))
diff --git a/crates/engine/src/cmd/arrange.rs b/crates/engine/src/cmd/arrange.rs
index 8f8e40d2..4806afb3 100644
--- a/crates/engine/src/cmd/arrange.rs
+++ b/crates/engine/src/cmd/arrange.rs
@@ -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 {
diff --git a/crates/engine/src/cmd/comment.rs b/crates/engine/src/cmd/comment.rs
index d1d360f4..8cff0c42 100644
--- a/crates/engine/src/cmd/comment.rs
+++ b/crates/engine/src/cmd/comment.rs
@@ -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.
diff --git a/crates/engine/src/cmd/component.rs b/crates/engine/src/cmd/component.rs
index 5abe79b8..0f59f41e 100644
--- a/crates/engine/src/cmd/component.rs
+++ b/crates/engine/src/cmd/component.rs
@@ -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;
diff --git a/crates/engine/src/cmd/component/instance.rs b/crates/engine/src/cmd/component/instance.rs
index ffc5ec1c..f6600c10 100644
--- a/crates/engine/src/cmd/component/instance.rs
+++ b/crates/engine/src/cmd/component/instance.rs
@@ -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 {
let cmd = "component.detach";
diff --git a/crates/engine/src/cmd/component/props.rs b/crates/engine/src/cmd/component/props.rs
index 3f2d7002..bb3dba4c 100644
--- a/crates/engine/src/cmd/component/props.rs
+++ b/crates/engine/src/cmd/component/props.rs
@@ -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).
diff --git a/crates/engine/src/cmd/component/variants.rs b/crates/engine/src/cmd/component/variants.rs
index f6e7ac6c..5f9e218a 100644
--- a/crates/engine/src/cmd/component/variants.rs
+++ b/crates/engine/src/cmd/component/variants.rs
@@ -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 {
diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs
index 414a2310..2e64ce04 100644
--- a/crates/engine/src/cmd/create.rs
+++ b/crates/engine/src/cmd/create.rs
@@ -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).
diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs
index 7ec63c69..2d51b958 100644
--- a/crates/engine/src/cmd/edit.rs
+++ b/crates/engine/src/cmd/edit.rs
@@ -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;
diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs
index d0e7b2dc..0df97532 100644
--- a/crates/engine/src/cmd/edit/clipboard.rs
+++ b/crates/engine/src/cmd/edit/clipboard.rs
@@ -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<()> {
diff --git a/crates/engine/src/cmd/edit/props.rs b/crates/engine/src/cmd/edit/props.rs
index f793b50c..91855c2e 100644
--- a/crates/engine/src/cmd/edit/props.rs
+++ b/crates/engine/src/cmd/edit/props.rs
@@ -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 {
let cmd = "object.batchRename";
diff --git a/crates/engine/src/cmd/edit/select.rs b/crates/engine/src/cmd/edit/select.rs
index 03bba890..ca1da3bb 100644
--- a/crates/engine/src/cmd/edit/select.rs
+++ b/crates/engine/src/cmd/edit/select.rs
@@ -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 {
let (x, y) = (req_num("select.layersAt", p, "x")?, req_num("select.layersAt", p, "y")?);
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index fed4bff1..7c901293 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -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;
diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs
index 071c651e..a033475e 100644
--- a/crates/engine/src/cmd/file/load.rs
+++ b/crates/engine/src/cmd/file/load.rs
@@ -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 {
diff --git a/crates/engine/src/cmd/file/version.rs b/crates/engine/src/cmd/file/version.rs
index 3d311a44..a46fab8e 100644
--- a/crates/engine/src/cmd/file/version.rs
+++ b/crates/engine/src/cmd/file/version.rs
@@ -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});
diff --git a/crates/engine/src/cmd/image.rs b/crates/engine/src/cmd/image.rs
index bb11f011..c82e30d1 100644
--- a/crates/engine/src/cmd/image.rs
+++ b/crates/engine/src/cmd/image.rs
@@ -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 {
diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs
index 5a0a4000..e22ca498 100644
--- a/crates/engine/src/cmd/layout.rs
+++ b/crates/engine/src/cmd/layout.rs
@@ -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 {
diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs
index 7892102d..0c7edbb2 100644
--- a/crates/engine/src/cmd/library.rs
+++ b/crates/engine/src/cmd/library.rs
@@ -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).
diff --git a/crates/engine/src/cmd/lists.rs b/crates/engine/src/cmd/lists.rs
index 287c9ddd..e53291b6 100644
--- a/crates/engine/src/cmd/lists.rs
+++ b/crates/engine/src/cmd/lists.rs
@@ -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 {
diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs
index 08e65b0b..44c7b367 100644
--- a/crates/engine/src/cmd/page.rs
+++ b/crates/engine/src/cmd/page.rs
@@ -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
diff --git a/crates/engine/src/cmd/props.rs b/crates/engine/src/cmd/props.rs
index 30a10393..469fcb01 100644
--- a/crates/engine/src/cmd/props.rs
+++ b/crates/engine/src/cmd/props.rs
@@ -1,11 +1,15 @@
//! Property editing: `node.set` and the shorthand keys every surface uses.
use figcraft_color::{Color, Paint};
-use figcraft_doc::*;
+use figcraft_doc::{
+ AutoLayout, Brush, ChildLayout, DecorationStyle, DevStatus, Document, Effect, GridTrack, LayoutMode, LetterSpacing, LineHeight, ListStyle,
+ MAX_LIST_INDENT, MaskType, Node, NodeId, NodeKind, Sizing, StrokeCap, StrokeProps, TextAutoResize, TextCase, TextData, TextDecoration,
+ WidthPoint, WidthPreset, WidthProfile, style_weight, valid_axis_tag, valid_feature_tag,
+};
use figcraft_geom::{Affine, Point};
use serde_json::{Map, Value, json};
-use crate::params::*;
+use crate::params::{bool_of, color_of, f64_of, get, paints_of};
use crate::{Result, bad};
/// The per-side stroke weight keys, in `StrokeProps::sides` order (top, right, bottom, left).
@@ -409,6 +413,587 @@ fn fix_axis(doc: &mut Document, id: NodeId, is_w: bool) -> Result<()> {
Ok(())
}
+/// The `geometry` properties of `apply`; `false` when `k` is not one of them.
+fn apply_geometry(
+ cmd: &str,
+ doc: &mut Document,
+ id: NodeId,
+ props: &Map,
+ k: &str,
+ v: &Value,
+ path_offset: &mut (f64, f64),
+) -> Result {
+ match k {
+ "path" => *path_offset = set_path(cmd, doc, id, v, props)?,
+ // A new path takes the size asked for as its scale (`set_path`); the size still fixes
+ // its auto layout axis.
+ "width" | "height" | "w" | "h" if props.contains_key("path") => fix_axis(doc, id, k.starts_with('w'))?,
+ "x" | "y" => {
+ let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?;
+ let val = val + if k == "x" { path_offset.0 } else { path_offset.1 };
+ // X/Y are the node's own origin (its rotated top-left for rotated nodes), in the
+ // frame it is in (groups have no space of their own).
+ let n = doc.get(id)?;
+ let group = n.parent.filter(|p| doc.node(*p).is_some_and(|pn| pn.kind.bounds_follow_children()));
+ let space = group.map_or(Affine::IDENTITY, |g| doc.frame_relative(g));
+ let c = (space * n.transform).as_coeffs();
+ let (mut tx, mut ty) = (c[4], c[5]);
+ if k == "x" {
+ tx = val;
+ } else {
+ ty = val;
+ }
+ doc.get_mut(id)?.transform = space.inverse() * Affine::new([c[0], c[1], c[2], c[3], tx, ty]);
+ }
+ "width" | "height" | "w" | "h" => {
+ let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
+ let is_w = k.starts_with('w');
+ // With the proportions locked (the Layout panel's link) and only one side set, the
+ // other side follows the node's ratio; both sides in one `node.set` stay as typed.
+ let (nw, nh) = {
+ let n = doc.get(id)?;
+ let (ow, oh) = (n.width, n.height);
+ let (other, other_short) = if is_w { ("height", "h") } else { ("width", "w") };
+ let linked = n.constrain_proportions && !props.contains_key(other) && !props.contains_key(other_short) && ow > 1e-9 && oh > 1e-9;
+ let derived = if is_w { oh * val / ow } else { ow * val / oh };
+ match (is_w, linked && derived.is_finite()) {
+ (true, true) => (val, derived),
+ (false, true) => (derived, val),
+ (true, false) => (val, oh),
+ (false, false) => (ow, val),
+ }
+ };
+ resize_node(doc, id, nw, nh)?;
+ // Explicit size makes text fixed in that dimension.
+ if let Some(t) = doc.get_mut(id)?.kind.text_mut() {
+ t.fix_size(is_w, !is_w);
+ }
+ fix_axis(doc, id, is_w)?;
+ }
+ "rotation" => {
+ let deg = f64_of(v).ok_or_else(|| bad(cmd, "`rotation` must be degrees"))?;
+ let n = doc.get_mut(id)?;
+ n.transform = figcraft_geom::set_rotation_about_center(n.transform, n.width, n.height, deg);
+ }
+ _ => return Ok(false),
+ }
+ Ok(true)
+}
+
+/// The `look` properties of `apply`; `false` when `k` is not one of them.
+fn apply_look(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result {
+ match k {
+ "name" => doc.get_mut(id)?.name = string_val(v).ok_or_else(|| bad(cmd, "`name` must be a string"))?,
+ "visible" => doc.get_mut(id)?.visible = v.as_bool().ok_or_else(|| bad(cmd, "`visible` must be a boolean"))?,
+ "locked" => doc.get_mut(id)?.locked = v.as_bool().ok_or_else(|| bad(cmd, "`locked` must be a boolean"))?,
+ "opacity" => {
+ let o = f64_of(v).ok_or_else(|| bad(cmd, "`opacity` must be a number"))?;
+ let o = if o > 1.0 { o / 100.0 } else { o };
+ doc.get_mut(id)?.opacity = o.clamp(0.0, 1.0) as f32;
+ }
+ "blend" | "blendMode" => {
+ let b = v.as_str().and_then(figcraft_color::BlendMode::from_name).ok_or_else(|| bad(cmd, "unknown blend mode"))?;
+ doc.get_mut(id)?.blend = b;
+ }
+ "fills" => set_paints(doc.get_mut(id)?, true, paints_of(v).ok_or_else(|| bad(cmd, "`fills` must be paints or colours"))?),
+ "fill" => {
+ if paint_none(v) {
+ set_paints(doc.get_mut(id)?, true, vec![]);
+ } else {
+ let c = color_of(v).ok_or_else(|| bad(cmd, "`fill` must be a colour, \"none\" or null"))?;
+ set_paints(doc.get_mut(id)?, true, vec![Paint::solid(c)]);
+ }
+ }
+ "strokes" => set_paints(doc.get_mut(id)?, false, paints_of(v).ok_or_else(|| bad(cmd, "`strokes` must be paints or colours"))?),
+ "stroke" => {
+ if paint_none(v) {
+ set_paints(doc.get_mut(id)?, false, vec![]);
+ } else {
+ let c = color_of(v).ok_or_else(|| bad(cmd, "`stroke` must be a colour, \"none\" or null"))?;
+ set_paints(doc.get_mut(id)?, false, vec![Paint::solid(c)]);
+ }
+ }
+ "strokeWeight" => doc.get_mut(id)?.stroke.weight = f64_of(v).ok_or_else(|| bad(cmd, "`strokeWeight` must be a number"))?.max(0.0),
+ "strokeAlign" => doc.get_mut(id)?.stroke.align = enum_of(v).ok_or_else(|| bad(cmd, "strokeAlign: inside|center|outside"))?,
+ "strokeSides" => {
+ doc.get_mut(id)?.stroke.sides = if v.is_null() { None } else { Some(four(v).ok_or_else(|| bad(cmd, "strokeSides: [t,r,b,l]"))?) }
+ }
+ "strokeTopWeight" | "strokeRightWeight" | "strokeBottomWeight" | "strokeLeftWeight" => {
+ let i = STROKE_SIDES.iter().position(|x| *x == k).unwrap_or(0);
+ let w = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
+ let st = &mut doc.get_mut(id)?.stroke;
+ // One side set on its own turns on individual strokes, the others at the weight.
+ let mut sides = st.sides.unwrap_or([st.weight; 4]);
+ if let Some(slot) = sides.get_mut(i) {
+ *slot = w;
+ }
+ st.sides = Some(sides);
+ }
+ "strokeCap" => {
+ let c: StrokeCap = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?;
+ let n = doc.get_mut(id)?;
+ n.stroke.cap_start = c;
+ n.stroke.cap_end = c;
+ }
+ "startCap" => doc.get_mut(id)?.stroke.cap_start = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
+ "endCap" => doc.get_mut(id)?.stroke.cap_end = enum_of(v).ok_or_else(|| bad(cmd, "unknown stroke cap"))?,
+ "strokeJoin" => doc.get_mut(id)?.stroke.join = enum_of(v).ok_or_else(|| bad(cmd, "strokeJoin: miter|bevel|round"))?,
+ "miterLimit" => doc.get_mut(id)?.stroke.miter_limit = f64_of(v).unwrap_or(4.0).clamp(1.0, 100.0),
+ "miterAngle" => {
+ let deg = f64_of(v).ok_or_else(|| bad(cmd, "`miterAngle` must be degrees"))?;
+ doc.get_mut(id)?.stroke.miter_limit = StrokeProps::miter_limit_for(deg);
+ }
+ "dashes" => {
+ let d: Vec = v.as_array().map(|a| a.iter().filter_map(f64_of).map(|x| x.max(0.0)).collect()).unwrap_or_default();
+ doc.get_mut(id)?.stroke.dashes = d;
+ }
+ "widthProfile" => doc.get_mut(id)?.stroke.width_profile = width_profile_of(v).ok_or_else(|| bad(cmd, WIDTH_PROFILE_HELP))?,
+ "brush" | "strokeBrush" => doc.get_mut(id)?.stroke.brush = brush_of(v).ok_or_else(|| bad(cmd, BRUSH_HELP))?,
+ _ => return Ok(false),
+ }
+ Ok(true)
+}
+
+/// The `corners` properties of `apply`; `false` when `k` is not one of them.
+fn apply_corners(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result {
+ match k {
+ "cornerRadius" => {
+ let r = four(v).ok_or_else(|| bad(cmd, "cornerRadius: n or [tl,tr,br,bl]"))?.map(|x| x.max(0.0));
+ let n = doc.get_mut(id)?;
+ n.corners.radii = r;
+ // A vector's corner radius rounds every point (edit mode then sets them one by one).
+ if let NodeKind::Vector { network } = &mut n.kind {
+ network.vertices.iter_mut().for_each(|p| p.radius = r[0]);
+ crate::vectoredit::refit(doc, id)?;
+ }
+ }
+ "topLeftRadius" | "topRightRadius" | "bottomRightRadius" | "bottomLeftRadius" => {
+ let i = ["topLeftRadius", "topRightRadius", "bottomRightRadius", "bottomLeftRadius"].iter().position(|x| *x == k).unwrap_or(0);
+ let r = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
+ if let Some(slot) = doc.get_mut(id)?.corners.radii.get_mut(i) {
+ *slot = r;
+ }
+ }
+ "cornerSmoothing" => doc.get_mut(id)?.corners.smoothing = f64_of(v).unwrap_or(0.0).clamp(0.0, 1.0),
+ "effects" => doc.get_mut(id)?.effects = effects_from(cmd, v)?,
+ "clip" | "clipsContent" => doc.get_mut(id)?.clip = v.as_bool().ok_or_else(|| bad(cmd, "`clip` must be a boolean"))?,
+ "isMask" => doc.get_mut(id)?.is_mask = v.as_bool().unwrap_or(false),
+ "devStatus" => {
+ let st = if v.is_null() {
+ Some(DevStatus::None)
+ } else {
+ v.as_str().and_then(|s| DevStatus::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s)))
+ };
+ let st = st.ok_or_else(|| bad(cmd, "devStatus: none|readyForDev|completed"))?;
+ if !st.is_none() && !crate::devinspect::has_status(doc, id) {
+ return Err(bad(cmd, "devStatus: only sections and frames at the top of a page or section take one"));
+ }
+ let n = doc.get_mut(id)?;
+ n.dev_status = st;
+ // Marking a layer (again) starts from its current contents.
+ n.dev_changed = false;
+ }
+ "maskType" => {
+ let t = v.as_str().and_then(|s| MaskType::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s)));
+ doc.get_mut(id)?.mask_type = t.ok_or_else(|| bad(cmd, "maskType: alpha|vector|luminance"))?;
+ }
+ _ => return Ok(false),
+ }
+ Ok(true)
+}
+
+/// The `layout` properties of `apply`; `false` when `k` is not one of them.
+fn apply_layout(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result {
+ match k {
+ "layout" | "layoutMode" => {
+ let n = doc.get_mut(id)?;
+ if !n.kind.is_frame_like() {
+ return Err(bad(cmd, "auto layout needs a frame"));
+ }
+ match v {
+ Value::Null => {
+ n.layout = None;
+ drop_layout_bindings(n);
+ }
+ Value::String(s) if s.eq_ignore_ascii_case("none") => {
+ n.layout = None;
+ drop_layout_bindings(n);
+ }
+ Value::String(s) => {
+ let mode = match s.to_ascii_lowercase().as_str() {
+ "horizontal" | "row" => LayoutMode::Horizontal,
+ "vertical" | "column" => LayoutMode::Vertical,
+ "grid" => LayoutMode::Grid,
+ _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid")),
+ };
+ ensure_layout(n).mode = mode;
+ }
+ Value::Object(_) => n.layout = Some(serde_json::from_value(snake(v)).map_err(|e| bad(cmd, format!("layout: {e}")))?),
+ _ => return Err(bad(cmd, "layout: none|horizontal|vertical|grid|{...}")),
+ }
+ }
+ "gap" | "itemSpacing" | "columnGap" => {
+ layout_mut(cmd, k, doc.get_mut(id)?)?.gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
+ }
+ "crossGap" | "counterAxisSpacing" | "rowGap" => {
+ layout_mut(cmd, k, doc.get_mut(id)?)?.cross_gap = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?
+ }
+ "padding" => {
+ let p = four(v).ok_or_else(|| bad(cmd, "padding: n or [t,r,b,l]"))?;
+ layout_mut(cmd, k, doc.get_mut(id)?)?.padding = p.map(|x| x.max(0.0));
+ }
+ "paddingTop" | "paddingRight" | "paddingBottom" | "paddingLeft" => {
+ let i = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"].iter().position(|x| *x == k).unwrap_or(0);
+ let val = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.max(0.0);
+ if let Some(slot) = layout_mut(cmd, k, doc.get_mut(id)?)?.padding.get_mut(i) {
+ *slot = val;
+ }
+ }
+ "paddingHorizontal" => {
+ let val = f64_of(v).unwrap_or(0.0).max(0.0);
+ let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
+ l.padding[1] = val;
+ l.padding[3] = val;
+ }
+ "paddingVertical" => {
+ let val = f64_of(v).unwrap_or(0.0).max(0.0);
+ let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
+ l.padding[0] = val;
+ l.padding[2] = val;
+ }
+ "mainAlign" | "primaryAxisAlignItems" => {
+ layout_mut(cmd, k, doc.get_mut(id)?)?.main_align =
+ enum_of(v).ok_or_else(|| bad(cmd, "mainAlign: min|center|max|spaceBetween|spaceAround|spaceEvenly"))?
+ }
+ "crossAlign" | "counterAxisAlignItems" => {
+ layout_mut(cmd, k, doc.get_mut(id)?)?.cross_align = enum_of(v).ok_or_else(|| bad(cmd, "crossAlign: min|center|max|baseline"))?
+ }
+ "contentAlign" | "counterAxisAlignContent" => {
+ layout_mut(cmd, k, doc.get_mut(id)?)?.content_align = enum_of(v).ok_or_else(|| bad(cmd, "contentAlign: auto|spaceBetween"))?
+ }
+ "wrap" => layout_mut(cmd, k, doc.get_mut(id)?)?.wrap = v.as_bool().unwrap_or(false),
+ "gridColumns" | "gridRows" => {
+ let count = f64_of(v).ok_or_else(|| bad(cmd, format!("`{k}` must be a number")))?.clamp(1.0, 1000.0) as u32;
+ let l = layout_mut(cmd, k, doc.get_mut(id)?)?;
+ // Tracks past the new count go away (new tracks are 1fr).
+ let (n, tracks) = if k == "gridColumns" { (&mut l.grid_columns, &mut l.column_tracks) } else { (&mut l.grid_rows, &mut l.row_tracks) };
+ *n = count;
+ tracks.truncate(count as usize);
+ }
+ "strokesIncluded" => layout_mut(cmd, k, doc.get_mut(id)?)?.strokes_included = v.as_bool().unwrap_or(false),
+ "reverseZ" => layout_mut(cmd, k, doc.get_mut(id)?)?.reverse_z = v.as_bool().unwrap_or(false),
+ _ => return Ok(false),
+ }
+ Ok(true)
+}
+
+/// The `sizing` properties of `apply`; `false` when `k` is not one of them.
+fn apply_sizing(cmd: &str, doc: &mut Document, id: NodeId, k: &str, v: &Value) -> Result {
+ match k {
+ "sizingH" | "layoutSizingHorizontal" => {
+ let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
+ set_sizing(cmd, doc, id, s, true)?;
+ }
+ "sizingV" | "layoutSizingVertical" => {
+ let s = sizing(v).ok_or_else(|| bad(cmd, "sizing: fixed|hug|fill"))?;
+ set_sizing(cmd, doc, id, s, false)?;
+ }
+ "absolute" | "ignoreAutoLayout" => {
+ let on = bool_of(v).ok_or_else(|| bad(cmd, "`absolute` must be a boolean"))?;
+ let cl = &mut doc.get_mut(id)?.child_layout;
+ cl.absolute = on;
+ // An absolute child can't fill: it keeps its current size.
+ if on {
+ for s in [&mut cl.sizing_h, &mut cl.sizing_v] {
+ if *s == Sizing::Fill {
+ *s = Sizing::Fixed;
+ }
+ }
+ }
+ }
+ "minWidth" | "maxWidth" | "minHeight" | "maxHeight" => {
+ let val = match v {
+ Value::Null => None,
+ other => Some(f64_of(other).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or null")))?.clamp(0.0, 1e7)),
+ };
+ set_limit(&mut doc.get_mut(id)?.child_layout, k, val);
+ }
+ "columnTracks" | "gridColumnTracks" | "rowTracks" | "gridRowTracks" => {
+ let a = v.as_array().ok_or_else(|| bad(cmd, format!("`{k}` must be an array of tracks")))?;
+ let tracks = a.iter().map(track_of).collect::
\n", open("p")));
}
_ => {
diff --git a/crates/engine/src/dev/codegen/swift.rs b/crates/engine/src/dev/codegen/swift.rs
index a9f8eaaa..9d0dfcf8 100644
--- a/crates/engine/src/dev/codegen/swift.rs
+++ b/crates/engine/src/dev/codegen/swift.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{CodeLang, Color, Ctx, LayoutMode, Node, NodeId, NodeKind, drop_shadow, first_fill, indent, literal, num, radius, stroke_color};
// ------------------------------------------------------------------ SwiftUI
diff --git a/crates/engine/src/dev/component_codegen.rs b/crates/engine/src/dev/component_codegen.rs
index c227fbdb..2e513cf6 100644
--- a/crates/engine/src/dev/component_codegen.rs
+++ b/crates/engine/src/dev/component_codegen.rs
@@ -7,13 +7,13 @@
//! used by `inspect.code`. A component connected to code (see [`crate::code_connect`]) shows its
//! real snippet instead.
-use figcraft_doc::*;
+use figcraft_doc::{Document, NodeId, NodeKind, PropDef};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::codegen::{CodeLang, Ctx, Markup, arkui, class_name, compose, indent, literal, swift};
use crate::components::{prop_default_value, slot_edited, slots_of, variant_options, variant_props, variant_set};
-use crate::params::*;
+use crate::params::{id_of, string, targets};
use crate::{bad, spec};
/// What a component property becomes in code.
diff --git a/crates/engine/src/dev/dev_resources.rs b/crates/engine/src/dev/dev_resources.rs
index 3da391a5..fc4e5b1a 100644
--- a/crates/engine/src/dev/dev_resources.rs
+++ b/crates/engine/src/dev/dev_resources.rs
@@ -7,12 +7,12 @@
//! capped) and never fetched or opened by the engine. A link added without a name is named after
//! its URL (no page title is looked up).
-use figcraft_doc::*;
+use figcraft_doc::{DevResource, Document, NodeId, NodeKind};
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
use crate::components::{inside_instance, variant_set};
-use crate::params::*;
+use crate::params::{get, req_index, targets};
use crate::{Result, Session, bad, spec};
/// Most dev resources on one layer.
diff --git a/crates/engine/src/dev/devcompare.rs b/crates/engine/src/dev/devcompare.rs
index ef40a2fb..6804f1aa 100644
--- a/crates/engine/src/dev/devcompare.rs
+++ b/crates/engine/src/dev/devcompare.rs
@@ -7,7 +7,7 @@
use std::collections::BTreeSet;
-use figcraft_doc::*;
+use figcraft_doc::{DevStatus, Document, MAX_SNAPSHOT_NODES, Node, NodeId, NodeKind};
use serde::Serialize;
use crate::devinspect::{Prop, Section, properties};
diff --git a/crates/engine/src/dev/devinspect.rs b/crates/engine/src/dev/devinspect.rs
index 78aa5553..dbf3ac91 100644
--- a/crates/engine/src/dev/devinspect.rs
+++ b/crates/engine/src/dev/devinspect.rs
@@ -3,12 +3,15 @@
//! one. The Inspect tab shows these, and `inspect.properties` returns them.
use figcraft_color::{Color, GradientKind, Hsb, Hsl, PaintKind};
-use figcraft_doc::*;
+use figcraft_doc::{
+ AutoLayout, ContentAlign, CrossAlign, DevStatus, Document, EffectKind, ExportFormat, ExportSetting, LayoutMode, MainAlign, Node, NodeId,
+ NodeKind, NoiseMode, StrokeAlign, StyleId, TextAlignH, TextData, TextDecoration, Variable,
+};
use serde::Serialize;
use serde_json::{Value, json};
use crate::cmd::CommandSpec;
-use crate::params::*;
+use crate::params::{get, id, id_of, string, targets};
use crate::spec;
/// One group of properties ("Layout", "Typography"…).
diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs
index 4f9509a7..a9cad9c9 100644
--- a/crates/engine/src/doc_state.rs
+++ b/crates/engine/src/doc_state.rs
@@ -1,5 +1,5 @@
use super::view_state::ViewState;
-use super::*;
+use super::{Arc, BTreeSet, Deserialize, Document, NodeId, Serialize, cmd, components, file_access, guides, library, rotation_origin, vectoredit};
/// One undo step.
#[derive(Clone, Debug)]
diff --git a/crates/engine/src/error.rs b/crates/engine/src/error.rs
index 7f2ab1d4..7f26258f 100644
--- a/crates/engine/src/error.rs
+++ b/crates/engine/src/error.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::NodeId;
#[derive(Debug, thiserror::Error)]
pub enum EngineError {
diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs
index f624ecfb..bdf57c9d 100644
--- a/crates/engine/src/keymap_tests.rs
+++ b/crates/engine/src/keymap_tests.rs
@@ -95,6 +95,11 @@ fn key_names_normalise() {
#[test]
fn lookups_follow_the_platform_and_context() {
+ lookups_on_every_platform();
+ lookups_that_differ_by_platform();
+}
+
+fn lookups_on_every_platform() {
let none = Mods::default();
let shift = mods(true, false, false, false);
for p in PLATFORMS {
@@ -119,6 +124,9 @@ fn lookups_follow_the_platform_and_context() {
assert_eq!(id_for(p, Context::Text, "V", none), None);
assert_eq!(id_for(p, Context::Text, "A", mods(false, true, false, false)), None);
}
+}
+
+fn lookups_that_differ_by_platform() {
let mac = Platform::Mac;
let other = Platform::Other;
// Use as mask: ⌃⌘M on macOS, Ctrl+Alt+M elsewhere.
@@ -133,6 +141,10 @@ fn lookups_follow_the_platform_and_context() {
assert_eq!(id_for(mac, Context::Canvas, "T", mods(false, true, false, true)), Some("arrange.tidy"));
assert_eq!(id_for(other, Context::Canvas, "T", mods(true, true, true, true)), Some("arrange.tidy"));
assert_eq!(id_for(other, Context::Canvas, "T", mods(false, true, true, true)), Some("text.alignCenter"));
+ macos_tells_ctrl_from_cmd(mac, other);
+}
+
+fn macos_tells_ctrl_from_cmd(mac: Platform, other: Platform) {
// macOS tells ⌃ from ⌘: ⌃G is layout grids, ⌘G groups; ⌃C picks a colour, ⌘C copies.
assert_eq!(id_for(mac, Context::Canvas, "G", mods(false, false, false, true)), Some("view.layoutGrids"));
assert_eq!(id_for(mac, Context::Canvas, "G", mods(false, false, true, false)), Some("object.group"));
diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs
index b4a4171a..f61bab29 100644
--- a/crates/engine/src/library_variables_tests.rs
+++ b/crates/engine/src/library_variables_tests.rs
@@ -122,6 +122,65 @@ fn publishing_collections_hides_and_versions_variables() {
assert!(empty.execute("library.publish", &json!({})).is_err());
}
+/// `bind_alias_mode_read_only_and_updates`: every edit of a copy's variable or collection is refused.
+fn assert_copies_read_only(s: &mut Session, local: &str) {
+ let coll = copy_of(s, "primary").collection;
+ for (cmd, p) in [
+ ("variable.set", json!({"variable": local, "value": "#ff0000"})),
+ ("variable.update", json!({"variable": local, "name": "mine"})),
+ ("variable.delete", json!({"variable": local})),
+ ("variable.duplicate", json!({"variable": local})),
+ ("variable.create", json!({"collection": coll, "name": "extra", "type": "number", "value": 1})),
+ ("variable.addMode", json!({"collection": coll, "name": "Mine"})),
+ ("variable.renameMode", json!({"collection": coll, "mode": "Dark", "name": "Night"})),
+ ("variable.renameCollection", json!({"collection": coll, "name": "Mine"})),
+ ("variable.deleteCollection", json!({"collection": coll})),
+ ] {
+ let e = s.execute(cmd, &p).unwrap_err();
+ assert!(e.to_string().contains("can't be edited"), "{cmd}: {e}");
+ }
+}
+
+/// `bind_alias_mode_read_only_and_updates`: saved and reopened, the file renders without the library.
+fn reopened_without_library(s: &mut Session, d: &std::path::Path, lib_path: &str, lid: &str, outer: NodeId) {
+ // Saved and reopened, the copies, records and bindings stay; the file renders without the library.
+ let user_path = path_str(&d.join("app.figcraft"));
+ ex(s, "file.saveAs", json!({"path": user_path}));
+ std::fs::remove_file(lib_path).unwrap();
+ let mut t = Session::empty();
+ ex(&mut t, "file.open", json!({"path": user_path}));
+ assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries);
+ assert_eq!(fill(&t, outer), "FF0000");
+ assert!(t.execute("library.bindVariable", &json!({"library": lid, "variable": "primary", "field": "fill", "ids": [outer]})).is_err());
+}
+
+/// `bind_alias_mode_read_only_and_updates`: the library changes its variables; the copies, bindings and alias follow.
+fn library_update_follows(s: &mut Session, lib: &mut Session, local: &str, [inner, outer]: [NodeId; 2]) {
+ let modes = Default::default();
+ // The library changes its variables and publishes again.
+ ex(lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#ff0000"}));
+ ex(lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#222222"}));
+ ex(lib, "variable.set", json!({"variable": "base", "value": "#00ffff"}));
+ ex(lib, "variable.renameMode", json!({"collection": "Theme", "mode": "Dark", "name": "Night"}));
+ republish(lib);
+ let u = ex(s, "library.updates", json!({}));
+ assert_eq!(u["count"], 3, "primary, base and the collection: {u}");
+ assert_eq!(u["libraries"][0]["variables"].as_array().map(Vec::len), Some(2));
+ let r = ex(s, "library.acceptUpdates", json!({}));
+ assert_eq!((r["variables"].as_u64(), r["collections"].as_u64()), (Some(2), Some(1)), "{r}");
+ // Same copies (same ids), new values: the bound layers and the alias follow.
+ assert_eq!(copy_of(s, "primary").id, local);
+ assert_eq!((fill(s, inner), fill(s, outer)), ("222222".into(), "FF0000".into()));
+ assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF"));
+ assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night");
+ assert_eq!(ex(s, "library.updates", json!({}))["count"], 0);
+ // Undo takes the update back.
+ ex(s, "edit.undo", json!({}));
+ assert_eq!(fill(s, outer), "0000FF");
+ ex(s, "edit.redo", json!({}));
+ healthy(s);
+}
+
#[test]
fn bind_alias_mode_read_only_and_updates() {
let d = dir("flow");
@@ -180,57 +239,14 @@ fn bind_alias_mode_read_only_and_updates() {
assert!(s.execute("library.aliasVariable", &json!({"library": lid, "alias": "primary", "variable": local})).is_err(), "copies are read-only");
// The copies are read-only.
- let coll = copy_of(&s, "primary").collection;
- for (cmd, p) in [
- ("variable.set", json!({"variable": local, "value": "#ff0000"})),
- ("variable.update", json!({"variable": local, "name": "mine"})),
- ("variable.delete", json!({"variable": local})),
- ("variable.duplicate", json!({"variable": local})),
- ("variable.create", json!({"collection": coll, "name": "extra", "type": "number", "value": 1})),
- ("variable.addMode", json!({"collection": coll, "name": "Mine"})),
- ("variable.renameMode", json!({"collection": coll, "mode": "Dark", "name": "Night"})),
- ("variable.renameCollection", json!({"collection": coll, "name": "Mine"})),
- ("variable.deleteCollection", json!({"collection": coll})),
- ] {
- let e = s.execute(cmd, &p).unwrap_err();
- assert!(e.to_string().contains("can't be edited"), "{cmd}: {e}");
- }
+ assert_copies_read_only(&mut s, &local);
// Local edits still work.
ex(&mut s, "variable.set", json!({"variable": "gap", "value": 3}));
healthy(&s);
- // The library changes its variables and publishes again.
- ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#ff0000"}));
- ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Dark", "value": "#222222"}));
- ex(&mut lib, "variable.set", json!({"variable": "base", "value": "#00ffff"}));
- ex(&mut lib, "variable.renameMode", json!({"collection": "Theme", "mode": "Dark", "name": "Night"}));
- republish(&mut lib);
- let u = ex(&mut s, "library.updates", json!({}));
- assert_eq!(u["count"], 3, "primary, base and the collection: {u}");
- assert_eq!(u["libraries"][0]["variables"].as_array().map(Vec::len), Some(2));
- let r = ex(&mut s, "library.acceptUpdates", json!({}));
- assert_eq!((r["variables"].as_u64(), r["collections"].as_u64()), (Some(2), Some(1)), "{r}");
- // Same copies (same ids), new values: the bound layers and the alias follow.
- assert_eq!(copy_of(&s, "primary").id, local);
- assert_eq!((fill(&s, inner), fill(&s, outer)), ("222222".into(), "FF0000".into()));
- assert!(matches!(s.doc().unwrap().variables.resolve("brand", &modes), Some(VariableValue::Color(c)) if c.to_hex() == "00FFFF"));
- assert_eq!(s.doc().unwrap().variables.collections[0].modes[1].name, "Night");
- assert_eq!(ex(&mut s, "library.updates", json!({}))["count"], 0);
- // Undo takes the update back.
- ex(&mut s, "edit.undo", json!({}));
- assert_eq!(fill(&s, outer), "0000FF");
- ex(&mut s, "edit.redo", json!({}));
- healthy(&s);
+ library_update_follows(&mut s, &mut lib, &local, [inner, outer]);
- // Saved and reopened, the copies, records and bindings stay; the file renders without the library.
- let user_path = path_str(&d.join("app.figcraft"));
- ex(&mut s, "file.saveAs", json!({"path": user_path}));
- std::fs::remove_file(&lib_path).unwrap();
- let mut t = Session::empty();
- ex(&mut t, "file.open", json!({"path": user_path}));
- assert_eq!(t.doc().unwrap().libraries, s.doc().unwrap().libraries);
- assert_eq!(fill(&t, outer), "FF0000");
- assert!(t.execute("library.bindVariable", &json!({"library": lid, "variable": "primary", "field": "fill", "ids": [outer]})).is_err());
+ reopened_without_library(&mut s, &d, &lib_path, &lid, outer);
// Removing the library keeps the copies in use and drops the rest.
ex(&mut s, "variable.bind", json!({"ids": [outer], "field": "cornerRadius", "variable": null}));
diff --git a/crates/engine/src/live_docs_tests.rs b/crates/engine/src/live_docs_tests.rs
index 7380cc9b..df805266 100644
--- a/crates/engine/src/live_docs_tests.rs
+++ b/crates/engine/src/live_docs_tests.rs
@@ -62,7 +62,6 @@ fn each_document_has_its_own_session() {
let ua = uid(&s);
// A new document isn't in the first one's session.
run(&mut s, "file.new", json!({}));
- let ub = uid(&s);
let off = run(&mut s, "collab.peers", json!({}));
for key in ["session", "role", "link", "url", "address", "fileName", "document", "me"] {
assert_eq!(off[key], Value::Null, "{key}: {off}");
@@ -78,8 +77,12 @@ fn each_document_has_its_own_session() {
assert!(s.live_mut().is_none() && s.active_read_only().is_none());
assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], false, "nothing to stop here");
assert_eq!(s.live_of(ua).status, Status::Live, "and the other file's session isn't stopped");
+ hosting_a_second_session(&mut s, &script, &a, ua);
+}
+
+fn hosting_a_second_session(s: &mut Session, script: &Arc>, a: &Value, ua: u64) {
// Hosting it too starts a second session; the first goes on.
- let b = run(&mut s, "collab.host", json!({}));
+ let b = run(s, "collab.host", json!({}));
assert_ne!(a["session"], b["session"]);
assert_ne!(a["link"], b["link"]);
assert_eq!((b["document"].clone(), b["sessions"].as_array().unwrap().len()), (json!(1), 2));
@@ -87,33 +90,38 @@ fn each_document_has_its_own_session() {
let sc = script.lock().unwrap();
assert_eq!((sc.hosted.len(), sc.left), (2, 0));
}
+ people_come_and_go(s, script, a, &b, ua);
+}
+
+fn people_come_and_go(s: &mut Session, script: &Arc>, a: &Value, b: &Value, ua: u64) {
+ let ub = uid(s);
// People come and go in their own session.
- let (a_id, b_id) = (id_of(&a), id_of(&b));
- feed(&mut s, &script, a_id, vec![Event::PeerJoined(peer(5, "Ada"))]);
- feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(6, "Bo")), Event::PeerJoined(peer(7, "Cy"))]);
+ let (a_id, b_id) = (id_of(a), id_of(b));
+ feed(s, script, a_id, vec![Event::PeerJoined(peer(5, "Ada"))]);
+ feed(s, script, b_id, vec![Event::PeerJoined(peer(6, "Bo")), Event::PeerJoined(peer(7, "Cy"))]);
assert_eq!((s.live_of(ua).peers.len(), s.live_of(ub).peers.len()), (1, 2));
let names = |v: &Value| v["peers"].as_array().unwrap().iter().map(|p| p["name"].as_str().unwrap().to_string()).collect::>();
let p = s.live_summary();
assert_eq!((names(&p), &p["link"]), (vec!["Bo".to_string(), "Cy".to_string()], &b["link"]));
assert_eq!((&p["sessions"][0]["peers"], &p["sessions"][1]["peers"]), (&json!(1), &json!(2)));
// The file in front decides what `collab.peers` and the commands are about.
- run(&mut s, "doc.switch", json!({"index": 0}));
- let p = run(&mut s, "collab.peers", json!({}));
+ run(s, "doc.switch", json!({"index": 0}));
+ let p = run(s, "collab.peers", json!({}));
assert_eq!((names(&p), &p["link"], &p["document"], &p["session"]), (vec!["Ada".to_string()], &a["link"], &json!(0), &a["session"]));
- assert_eq!(run(&mut s, "collab.follow", json!({"client": 5}))["follow"], 5);
+ assert_eq!(run(s, "collab.follow", json!({"client": 5}))["follow"], 5);
assert!(s.execute("collab.follow", &json!({"client": 6})).is_err(), "Bo is in the other file's session");
- run(&mut s, "doc.switch", json!({"index": 1}));
+ run(s, "doc.switch", json!({"index": 1}));
assert!(s.execute("collab.follow", &json!({"client": 5})).is_err());
assert_eq!((s.live().follow, s.live_of(ua).follow), (None, Some(5)));
// Stopping ends this file's session only.
- assert_eq!(run(&mut s, "collab.stop", json!({}))["ended"], true);
+ assert_eq!(run(s, "collab.stop", json!({}))["ended"], true);
assert_eq!(script.lock().unwrap().ended, [b_id]);
assert_eq!((s.live().status.clone(), s.live_of(ua).status.clone()), (Status::Off, Status::Live));
// Someone ending or leaving the other one doesn't bring this one back.
- feed(&mut s, &script, b_id, vec![Event::PeerJoined(peer(8, "Late"))]);
+ feed(s, script, b_id, vec![Event::PeerJoined(peer(8, "Late"))]);
assert_eq!((s.live.iter().count(), s.live_of(ua).peers.len()), (1, 1), "what a session still sent after it ended is dropped");
// Closing a file ends its session.
- run(&mut s, "file.close", json!({"index": 0, "force": true}));
+ run(s, "file.close", json!({"index": 0, "force": true}));
s.live_sync();
assert_eq!(script.lock().unwrap().ended, [b_id, a_id]);
assert_eq!(s.live.iter().count(), 0);
diff --git a/crates/engine/src/live_share_tests.rs b/crates/engine/src/live_share_tests.rs
index f486250f..452eb3e8 100644
--- a/crates/engine/src/live_share_tests.rs
+++ b/crates/engine/src/live_share_tests.rs
@@ -185,6 +185,38 @@ fn the_built_in_server_is_the_default_and_can_be_changed_or_turned_off() {
assert_eq!(old.publish_server, DEFAULT_SERVER);
}
+/// The middle of the online-sharing test: re-asking switches codes, and an offline stop stops nothing.
+fn asking_again_and_offline_stop(s: &mut Session, script: &Arc>) {
+ // Asking again only switches which code is the one to give out.
+ let r = ex(s, "collab.share", json!({"access": "view"}));
+ assert_eq!((&r["code"], &r["share"]["access"]), (&json!("4FH-J8R-2WN"), &json!("view")));
+ assert_eq!(ex(s, "collab.share", json!({"via": "online"}))["code"], "4FH-J8R-2WN", "nothing new starts");
+ assert_eq!(ex(s, "collab.share", json!({"access": "edit"}))["code"], "K7M-2QX-9TD");
+ assert_eq!(script.lock().unwrap().published.len(), 1);
+ assert!(err(s, "collab.share", json!({"via": "local"})).contains("stop sharing it first"));
+ assert!(err(s, "collab.share", json!({"access": "admin"})).contains("`access` must be"));
+ // Stopping while the server can't be reached stops nothing, and says so.
+ script.lock().unwrap().offline = true;
+ assert!(err(s, "collab.stop", json!({})).contains("couldn't stop sharing"));
+ assert_eq!(ex(s, "collab.peers", json!({}))["code"], "K7M-2QX-9TD");
+ script.lock().unwrap().offline = false;
+}
+
+/// The sharing-again step of the online-sharing test: new links and codes, nothing published twice.
+fn sharing_again(s: &mut Session, script: &Arc>) {
+ // Sharing again: new links and codes for the same file, nothing published twice.
+ let r = ex(s, "collab.share", json!({}));
+ assert_eq!((&r["code"], &r["share"]["shared"], &r["share"]["viewCode"]), (&json!("NEW-ED1-T01"), &json!(true), &json!("NEW-V1E-W01")));
+ assert!(r["share"]["editLink"].as_str().unwrap().contains("ShareeditShareedit01"));
+ {
+ let sc = script.lock().unwrap();
+ assert_eq!(sc.published.len(), 1);
+ assert_eq!(sc.asked[1], ("wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec![], true));
+ }
+ assert_eq!(ex(s, "collab.share", json!({}))["code"], "NEW-ED1-T01", "shared already: nothing more is asked");
+ assert_eq!(script.lock().unwrap().asked.len(), 2);
+}
+
#[test]
fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
let (mut s, script) = session();
@@ -212,19 +244,7 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
let (edit_link, own) = (r["share"]["editLink"].as_str().unwrap(), r["published"]["edit"].as_str().unwrap());
assert!(edit_link.contains("ShareeditShareedit00") && edit_link != own && r["link"] == own, "{edit_link}");
assert!(s.message.is_none(), "the popover shows the code: no toast about publishing");
- // Asking again only switches which code is the one to give out.
- let r = ex(&mut s, "collab.share", json!({"access": "view"}));
- assert_eq!((&r["code"], &r["share"]["access"]), (&json!("4FH-J8R-2WN"), &json!("view")));
- assert_eq!(ex(&mut s, "collab.share", json!({"via": "online"}))["code"], "4FH-J8R-2WN", "nothing new starts");
- assert_eq!(ex(&mut s, "collab.share", json!({"access": "edit"}))["code"], "K7M-2QX-9TD");
- assert_eq!(script.lock().unwrap().published.len(), 1);
- assert!(err(&mut s, "collab.share", json!({"via": "local"})).contains("stop sharing it first"));
- assert!(err(&mut s, "collab.share", json!({"access": "admin"})).contains("`access` must be"));
- // Stopping while the server can't be reached stops nothing, and says so.
- script.lock().unwrap().offline = true;
- assert!(err(&mut s, "collab.stop", json!({})).contains("couldn't stop sharing"));
- assert_eq!(ex(&mut s, "collab.peers", json!({}))["code"], "K7M-2QX-9TD");
- script.lock().unwrap().offline = false;
+ asking_again_and_offline_stop(&mut s, &script);
// Stopping: the server is asked, as the file's owner, to revoke the others' links. The file
// stays there and its owner stays in it: nothing is left, nothing is removed.
assert_eq!(ex(&mut s, "collab.stop", json!({})), json!({"ended": false, "shared": false}));
@@ -248,17 +268,7 @@ fn sharing_online_publishes_to_the_server_in_preferences_and_shows_its_code() {
);
assert!(r["published"]["owner"].is_string(), "still the owner's file on the server");
assert!(s.message.as_deref().unwrap_or_default().contains("stays on the server"));
- // Sharing again: new links and codes for the same file, nothing published twice.
- let r = ex(&mut s, "collab.share", json!({}));
- assert_eq!((&r["code"], &r["share"]["shared"], &r["share"]["viewCode"]), (&json!("NEW-ED1-T01"), &json!(true), &json!("NEW-V1E-W01")));
- assert!(r["share"]["editLink"].as_str().unwrap().contains("ShareeditShareedit01"));
- {
- let sc = script.lock().unwrap();
- assert_eq!(sc.published.len(), 1);
- assert_eq!(sc.asked[1], ("wss://share.example/".to_string(), "f1".to_string(), "OwnerOwnerOwnerOwner00".to_string(), vec![], true));
- }
- assert_eq!(ex(&mut s, "collab.share", json!({}))["code"], "NEW-ED1-T01", "shared already: nothing more is asked");
- assert_eq!(script.lock().unwrap().asked.len(), 2);
+ sharing_again(&mut s, &script);
// Stopping again revokes the new ones; then `collab.stop` on a file that isn't shared leaves
// its session like any other (the file stays on the server).
ex(&mut s, "collab.stop", json!({}));
diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs
index 6bfedcba..ced1793f 100644
--- a/crates/engine/src/prefs.rs
+++ b/crates/engine/src/prefs.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Arc, BTreeMap, Deserialize, Document, NodeId, Serialize, Value, cmd, live, online, plugins};
/// The smallest and largest interface size, relative to the system's display scale (1.0 is the
/// system's own size).
diff --git a/crates/engine/src/proto_conditional_tests.rs b/crates/engine/src/proto_conditional_tests.rs
index e40aea5d..b5441262 100644
--- a/crates/engine/src/proto_conditional_tests.rs
+++ b/crates/engine/src/proto_conditional_tests.rs
@@ -71,41 +71,51 @@ fn build_if_else_if_else_with_nested_actions() {
assert_eq!(shown(&s, bl[1].condition.as_ref().unwrap()), "open == false");
assert_eq!(shown(&s, bl[2].condition.as_ref().unwrap()), "label == \"Hi\"");
assert!(bl[3].condition.is_none(), "the else stays last");
+ actions_in_the_branches(&mut s, b, btn);
+ reorder_replace_and_remove(&mut s, b, btn);
+}
+
+fn actions_in_the_branches(s: &mut Session, b: NodeId, btn: NodeId) {
+ let ex = |s: &mut Session, c: &str, p: Value| s.execute(c, &p).unwrap();
// Actions inside the branches: a set variable with an expression in the if, a back in the else.
let r = ex(
- &mut s,
+ s,
"proto.action.add",
json!({"id": btn, "index": 0, "path": [1, 0, 0], "action": {"type": "setVariable", "variable": "count", "expression": "count + 1"}}),
);
assert_eq!(r["path"], json!([1, 0, 0]));
- ex(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 0], "action": {"type": "back"}}));
- ex(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "navigate", "dest": b}}));
- let list = actions(&s, btn);
+ ex(s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 0], "action": {"type": "back"}}));
+ ex(s, "proto.action.add", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "navigate", "dest": b}}));
+ let list = actions(s, btn);
let Some(Action::SetVariable { variable, expr: Some(e), .. }) = action_at(&list, &[1, 0, 0]) else { panic!("{list:?}") };
- assert_eq!(variable, &var(&s, "count"));
- assert_eq!(shown(&s, e), "count + 1");
+ assert_eq!(variable, &var(s, "count"));
+ assert_eq!(shown(s, e), "count + 1");
assert_eq!(action_at(&list, &[1, 3, 0]), Some(&Action::Back));
+}
+
+fn reorder_replace_and_remove(s: &mut Session, b: NodeId, btn: NodeId) {
+ let ex = |s: &mut Session, c: &str, p: Value| s.execute(c, &p).unwrap();
// Reorder inside the else, replace one, change a condition, remove a block.
- assert_eq!(ex(&mut s, "proto.action.move", json!({"id": btn, "index": 0, "path": [1, 3, 1], "to": 0}))["path"], json!([1, 3, 0]));
- assert!(matches!(action_at(&actions(&s, btn), &[1, 3, 0]), Some(Action::Navigate { .. })));
- ex(&mut s, "proto.action.set", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "closeOverlay"}}));
- assert_eq!(action_at(&actions(&s, btn), &[1, 3, 1]), Some(&Action::CloseOverlay));
- ex(&mut s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": [1], "block": 1, "expression": "not open and count < 10"}));
- assert_eq!(shown(&s, blocks(&actions(&s, btn)[1])[1].condition.as_ref().unwrap()), "(not open) and (count < 10)");
- ex(&mut s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 2}));
- assert_eq!(blocks(&actions(&s, btn)[1]).len(), 3);
- ex(&mut s, "proto.action.remove", json!({"id": btn, "index": 0, "path": [1, 2, 1]}));
- assert_eq!(blocks(&actions(&s, btn)[1])[2].actions, [Action::Navigate { dest: b, preserve_scroll: false }]);
+ assert_eq!(ex(s, "proto.action.move", json!({"id": btn, "index": 0, "path": [1, 3, 1], "to": 0}))["path"], json!([1, 3, 0]));
+ assert!(matches!(action_at(&actions(s, btn), &[1, 3, 0]), Some(Action::Navigate { .. })));
+ ex(s, "proto.action.set", json!({"id": btn, "index": 0, "path": [1, 3, 1], "action": {"type": "closeOverlay"}}));
+ assert_eq!(action_at(&actions(s, btn), &[1, 3, 1]), Some(&Action::CloseOverlay));
+ ex(s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": [1], "block": 1, "expression": "not open and count < 10"}));
+ assert_eq!(shown(s, blocks(&actions(s, btn)[1])[1].condition.as_ref().unwrap()), "(not open) and (count < 10)");
+ ex(s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 2}));
+ assert_eq!(blocks(&actions(s, btn)[1]).len(), 3);
+ ex(s, "proto.action.remove", json!({"id": btn, "index": 0, "path": [1, 2, 1]}));
+ assert_eq!(blocks(&actions(s, btn)[1])[2].actions, [Action::Navigate { dest: b, preserve_scroll: false }]);
// Every edit is one undo step.
assert!(s.undo().unwrap());
- assert_eq!(blocks(&actions(&s, btn)[1])[2].actions.len(), 2);
+ assert_eq!(blocks(&actions(s, btn)[1])[2].actions.len(), 2);
// Removing the if leaves the else if as the if; removing that leaves only the else: the
// conditional goes.
- let r = ex(&mut s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0}));
+ let r = ex(s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0}));
assert_eq!(r["removedAction"], false);
- let r = ex(&mut s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0}));
+ let r = ex(s, "proto.conditional.removeBlock", json!({"id": btn, "index": 0, "path": [1], "block": 0}));
assert_eq!(r["removedAction"], true);
- assert_eq!(actions(&s, btn), [Action::Navigate { dest: b, preserve_scroll: false }]);
+ assert_eq!(actions(s, btn), [Action::Navigate { dest: b, preserve_scroll: false }]);
}
#[test]
diff --git a/crates/engine/src/selection/selection_colors.rs b/crates/engine/src/selection/selection_colors.rs
index 56bc4b35..00c7389f 100644
--- a/crates/engine/src/selection/selection_colors.rs
+++ b/crates/engine/src/selection/selection_colors.rs
@@ -11,7 +11,7 @@ use serde_json::{Value, json};
use crate::cmd::lists::{BoundList, list_binding, rebind_edited_paint};
use crate::cmd::styles::rebind_paint_alpha;
-use crate::params::*;
+use crate::params::{get, paint_of, parse, req_string, targets};
use crate::{CommandSpec, Result, Session, bad, spec};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs
index acafd229..a31114e2 100644
--- a/crates/engine/src/session.rs
+++ b/crates/engine/src/session.rs
@@ -2,7 +2,10 @@ use super::derived::normalize;
use super::doc_state::{DocState, ENDS_PREVIEW, HistoryEntry, Interaction, PREVIEW_READ_ONLY};
use super::error::{EngineError, Result, bad};
use super::prefs::{Clipboard, Prefs, UiRequest};
-use super::*;
+use super::{
+ Arc, BatchScope, BatchStep, CommandSpec, Document, Node, NodeId, Session, Value, background, board, branches, cmd, components, figcraft_collab,
+ find_command, guard, json, library, live, params, plugins, textpreview, tools,
+};
mod access;
mod exec;
diff --git a/crates/engine/src/session/access.rs b/crates/engine/src/session/access.rs
index af9fd7f5..05bc45d5 100644
--- a/crates/engine/src/session/access.rs
+++ b/crates/engine/src/session/access.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{DocState, Document, EngineError, NodeId, Result, Session, Value, json};
impl Session {
pub fn ds(&self) -> Result<&DocState> {
diff --git a/crates/engine/src/session/exec.rs b/crates/engine/src/session/exec.rs
index add12ac5..00798b2c 100644
--- a/crates/engine/src/session/exec.rs
+++ b/crates/engine/src/session/exec.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Arc, CommandSpec, Document, ENDS_PREVIEW, EngineError, Node, NodeId, PREVIEW_READ_ONLY, Result, Session, Value, bad, board, branches, cmd,
+ components, find_command, guard, library, normalize, params, textpreview,
+};
/// Whether a command, or a batch wrapping one, imports a file passed as Base64 `data`.
fn command_carries_import_bytes(id: &str, params: &Value) -> bool {
diff --git a/crates/engine/src/session/new.rs b/crates/engine/src/session/new.rs
index 8790c83b..96bf0c2d 100644
--- a/crates/engine/src/session/new.rs
+++ b/crates/engine/src/session/new.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Arc, Clipboard, DocState, Document, Prefs, Session, background, cmd, library, live, plugins, tools};
impl Session {
/// A session with one new document.
diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs
index a354ea27..5fd0ec80 100644
--- a/crates/engine/src/session/undo.rs
+++ b/crates/engine/src/session/undo.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Arc, BatchScope, BatchStep, DocState, Document, EngineError, HistoryEntry, Interaction, NodeId, PREVIEW_READ_ONLY, Result, Session, UiRequest,
+ Value, cmd, figcraft_collab, normalize, textpreview,
+};
impl Session {
/// How many undo and redo steps the active document has (the user's own in a live session).
diff --git a/crates/engine/src/sharing/live/events.rs b/crates/engine/src/sharing/live/events.rs
index 5b6008f8..9d851cab 100644
--- a/crates/engine/src/sharing/live/events.rs
+++ b/crates/engine/src/sharing/live/events.rs
@@ -1,6 +1,9 @@
use super::host::{clean_hosting, clean_shared};
use super::types::{Event, PageInfo, Role, SessionId, Status};
-use super::*;
+use super::{
+ Access, Arc, Client, ClientMsg, DocState, Document, EngineError, Live, NodeId, PUBLISHING, Peer, Presence, Result, ServerMsg, Session, Signal,
+ ToolId, VIEW_ONLY, editing_hint, editing_of, sanitize,
+};
impl Session {
/// The active document (`None`: no document is open).
diff --git a/crates/engine/src/sharing/live/host.rs b/crates/engine/src/sharing/live/host.rs
index 685ab5c0..06f6df95 100644
--- a/crates/engine/src/sharing/live/host.rs
+++ b/crates/engine/src/sharing/live/host.rs
@@ -1,5 +1,8 @@
use super::types::{Bind, HostOptions, NetReply, NetRequest, ONLINE_OFF, PublishOptions, Role, SessionId, Share, Shared, SharedLink, Status, Via};
-use super::*;
+use super::{
+ Access, Admin, AdminReply, ClientMsg, EngineError, HostInfo, IpAddr, Live, Result, Session, Value, bad, code, display_name, get, json,
+ req_string, string,
+};
/// What a server says about its hosting, made safe to show: no control characters, short, and a
/// terms address only when it is a web page (`http(s)://`).
diff --git a/crates/engine/src/sharing/live/join.rs b/crates/engine/src/sharing/live/join.rs
index 972198ef..e08afcc6 100644
--- a/crates/engine/src/sharing/live/join.rs
+++ b/crates/engine/src/sharing/live/join.rs
@@ -1,6 +1,8 @@
use super::host::{host, hosting_request, manage_file, publish, report, server_info, share, share_answer, share_request, shared_now, unavailable};
use super::types::{Published, Role, SessionId, Share, Status, join_target};
-use super::*;
+use super::{
+ Admin, AdminReply, CommandSpec, EngineError, Live, Result, Resume, Session, Value, bad, display_name, get, json, num, req_string, spec, string,
+};
fn join(s: &mut Session, p: &Value) -> Result {
const CMD: &str = "collab.join";
diff --git a/crates/engine/src/sharing/live/sync.rs b/crates/engine/src/sharing/live/sync.rs
index f2fd3c54..3920bdca 100644
--- a/crates/engine/src/sharing/live/sync.rs
+++ b/crates/engine/src/sharing/live/sync.rs
@@ -1,6 +1,9 @@
use super::host::hosting_json;
use super::types::{NO_SESSION, SessionId, Share, SharedLink, Status};
-use super::*;
+use super::{
+ Arc, Client, EditingText, EngineError, EngineRecompute, FollowedView, Live, Result, Session, Value, ViewState, bad, busy, close, code,
+ editing_of, json,
+};
impl Session {
/// Your client id in document `uid`'s live session (`None`: it isn't in one). Refused for
diff --git a/crates/engine/src/sharing/live/types.rs b/crates/engine/src/sharing/live/types.rs
index 16a237d5..efeecac9 100644
--- a/crates/engine/src/sharing/live/types.rs
+++ b/crates/engine/src/sharing/live/types.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Access, Admin, AdminReply, Arc, ClientMsg, Code, Document, HostInfo, IpAddr, LibraryReply, LibraryReq, NO_TEAM_LIBRARIES, Peer, PluginReply,
+ PluginReq, Presence, Resume, Serialize, ServerMsg, Signal, code,
+};
/// The server files are shared through online unless Preferences name another: the only place
/// its address is written.
diff --git a/crates/engine/src/slot_tests.rs b/crates/engine/src/slot_tests.rs
index cbf824d9..01a5db08 100644
--- a/crates/engine/src/slot_tests.rs
+++ b/crates/engine/src/slot_tests.rs
@@ -99,6 +99,28 @@ fn slots_are_frames_of_a_main_with_a_slot_property() {
healthy(&s);
}
+/// `instances_fill_slots_with_their_own_content`: resetting a slot, or the whole instance, restores the default content.
+fn reset_slot_content(s: &mut Session, [a, aslot, ph, ichip, chip]: [NodeId; 5]) {
+ // Reset: back to the main's default content.
+ ex(s, "instance.resetSlot", json!({"id": aslot}));
+ healthy(s);
+ assert!(!slot_edited(s.doc().unwrap(), aslot));
+ assert_eq!(kids(s, aslot).len(), 2);
+ assert_eq!(fill(s, copy_of(s, a, ph)), "0000FF");
+ assert!(!s.doc().unwrap().contains(ichip));
+ s.undo().unwrap();
+ assert!(s.doc().unwrap().contains(ichip));
+ healthy(s);
+ // Reset all changes resets slots too; `replace` clears the default content first.
+ ex(s, "component.resetInstance", json!({"ids": [a]}));
+ assert!(!slot_edited(s.doc().unwrap(), aslot));
+ let only = idv(&ex(s, "instance.slotInsert", json!({"id": aslot, "component": chip, "replace": true})));
+ assert_eq!(kids(s, aslot), vec![only]);
+ ex(s, "component.resetOverride", json!({"id": aslot, "group": "slot"}));
+ assert_eq!(kids(s, aslot).len(), 2);
+ healthy(s);
+}
+
#[test]
fn instances_fill_slots_with_their_own_content() {
let mut s = Session::new();
@@ -158,23 +180,7 @@ fn instances_fill_slots_with_their_own_content() {
// Layers of the instance can't move into its slot either.
let atitle = copy_of(&s, a, title);
refused(&mut s, "node.reparent", json!({"ids": [atitle], "parent": aslot}));
- // Reset: back to the main's default content.
- ex(&mut s, "instance.resetSlot", json!({"id": aslot}));
- healthy(&s);
- assert!(!slot_edited(s.doc().unwrap(), aslot));
- assert_eq!(kids(&s, aslot).len(), 2);
- assert_eq!(fill(&s, copy_of(&s, a, ph)), "0000FF");
- assert!(!s.doc().unwrap().contains(ichip));
- s.undo().unwrap();
- assert!(s.doc().unwrap().contains(ichip));
- healthy(&s);
- // Reset all changes resets slots too; `replace` clears the default content first.
- ex(&mut s, "component.resetInstance", json!({"ids": [a]}));
- assert!(!slot_edited(s.doc().unwrap(), aslot));
- let only = idv(&ex(&mut s, "instance.slotInsert", json!({"id": aslot, "component": chip, "replace": true})));
- assert_eq!(kids(&s, aslot), vec![only]);
- ex(&mut s, "component.resetOverride", json!({"id": aslot, "group": "slot"}));
- assert_eq!(kids(&s, aslot).len(), 2);
+ reset_slot_content(&mut s, [a, aslot, ph, ichip, chip]);
healthy(&s);
}
diff --git a/crates/engine/src/vector_multi_behaviour_tests.rs b/crates/engine/src/vector_multi_behaviour_tests.rs
index 2afaf9a2..ef337af8 100644
--- a/crates/engine/src/vector_multi_behaviour_tests.rs
+++ b/crates/engine/src/vector_multi_behaviour_tests.rs
@@ -95,6 +95,14 @@ fn selected_in(s: &Session, id: NodeId) -> Vec {
#[test]
fn editing_several_vectors_at_once() {
+ several_vectors_enter_edit_mode();
+ let (mut s, a, b, atl, btl) = points_across_both_layers();
+ arrows_and_drags_move_every_layer(&mut s, a, b, atl, btl);
+ corner_radius_mirroring_and_delete(&mut s, a, b, atl, btl);
+ bend_select_all_pen_and_clicks(&mut s, a, b);
+}
+
+fn several_vectors_enter_edit_mode() {
// (how edit mode is entered with two shapes selected, derivation)
let rows: Vec<(&str, &str)> = vec![
("command", "the edit command takes every selected shape"),
@@ -135,8 +143,9 @@ fn editing_several_vectors_at_once() {
crate::vectoredit::enter_targets(s.doc().unwrap(), &[a, f]).is_none(),
"silent: a frame in the selection leaves Enter to select its children (pinned)"
);
+}
- // Points across both layers.
+fn points_across_both_layers() -> (Session, NodeId, NodeId, usize, usize) {
let mut s = Session::new();
let (a, b) = (rect(&mut s, 0.0), rect(&mut s, 200.0));
ex(&mut s, "vector.edit.begin", json!({"ids": [a, b]}));
@@ -161,64 +170,74 @@ fn editing_several_vectors_at_once() {
sb.sort_unstable();
assert_eq!((editing(&s).unwrap().id, selected_in(&s, a), selected_in(&s, b)), (active, sa, sb), "{why}");
}
+ (s, a, b, atl, btl)
+}
+
+fn arrows_and_drags_move_every_layer(s: &mut Session, a: NodeId, b: NodeId, atl: usize, btl: usize) {
// Arrows nudge every selected point, one undo step for all layers.
- ex(&mut s, "vector.edit.select", json!({"vertices": [atl]}));
- ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
- assert_eq!((selected_in(&s, a), selected_in(&s, b)), (vec![atl], vec![btl]), "the select command picks points in a named layer");
+ ex(s, "vector.edit.select", json!({"vertices": [atl]}));
+ ex(s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
+ assert_eq!((selected_in(s, a), selected_in(s, b)), (vec![atl], vec![btl]), "the select command picks points in a named layer");
assert!(s.tool_key("Right", false, false, false).unwrap());
- assert!(near(page_pos(&s, a, atl), (1.0, 0.0)) && near(page_pos(&s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer");
+ assert!(near(page_pos(s, a, atl), (1.0, 0.0)) && near(page_pos(s, b, btl), (201.0, 0.0)), "an arrow nudges the points in every layer");
s.undo().unwrap();
- assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "one undo step for every layer");
+ assert!(near(page_pos(s, a, atl), (0.0, 0.0)) && near(page_pos(s, b, btl), (200.0, 0.0)), "one undo step for every layer");
// Dragging one selected point drags them all (one on the transform box's edge: its corners
// scale, as with points of one layer).
- let atr = point_at(&s, a, (100.0, 0.0));
- ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true}));
- drag(&mut s, (100.0, 0.0), (110.0, 20.0), NONE);
+ let atr = point_at(s, a, (100.0, 0.0));
+ ex(s, "vector.edit.select", json!({"id": a, "vertices": [atr], "add": true}));
+ drag(s, (100.0, 0.0), (110.0, 20.0), NONE);
assert!(
- near(page_pos(&s, a, atl), (10.0, 20.0)) && near(page_pos(&s, a, atr), (110.0, 20.0)) && near(page_pos(&s, b, btl), (210.0, 20.0)),
+ near(page_pos(s, a, atl), (10.0, 20.0)) && near(page_pos(s, a, atr), (110.0, 20.0)) && near(page_pos(s, b, btl), (210.0, 20.0)),
"dragging one selected point moves them all"
);
s.undo().unwrap();
- assert!(near(page_pos(&s, a, atl), (0.0, 0.0)) && near(page_pos(&s, b, btl), (200.0, 0.0)), "the drag is one undo step");
- ex(&mut s, "vector.edit.select", json!({"vertices": [atl]}));
- ex(&mut s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
+ assert!(near(page_pos(s, a, atl), (0.0, 0.0)) && near(page_pos(s, b, btl), (200.0, 0.0)), "the drag is one undo step");
+ ex(s, "vector.edit.select", json!({"vertices": [atl]}));
+ ex(s, "vector.edit.select", json!({"id": b, "vertices": [btl], "add": true}));
+}
+
+fn corner_radius_mirroring_and_delete(s: &mut Session, a: NodeId, b: NodeId, atl: usize, btl: usize) {
// Corner radius, mirroring and deleting reach every layer's selected points.
- ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 10}));
- assert!(net(&s, a).vertices[atl].radius == 10.0 && net(&s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer");
- ex(&mut s, "vector.edit.mirroring", json!({"mode": "angle"}));
+ ex(s, "vector.edit.cornerRadius", json!({"radius": 10}));
+ assert!(net(s, a).vertices[atl].radius == 10.0 && net(s, b).vertices[btl].radius == 10.0, "the corner radius applies to every layer");
+ ex(s, "vector.edit.mirroring", json!({"mode": "angle"}));
assert!(
- net(&s, a).vertices[atl].mirror == Some(figcraft_doc::HandleMirroring::Angle)
- && net(&s, b).vertices[btl].mirror == Some(figcraft_doc::HandleMirroring::Angle),
+ net(s, a).vertices[atl].mirror == Some(figcraft_doc::HandleMirroring::Angle)
+ && net(s, b).vertices[btl].mirror == Some(figcraft_doc::HandleMirroring::Angle),
"mirroring applies to every layer"
);
assert!(s.tool_key("Delete", false, false, false).unwrap());
- assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (3, 3), "Delete removes the selected point in every layer");
+ assert_eq!((net(s, a).vertices.len(), net(s, b).vertices.len()), (3, 3), "Delete removes the selected point in every layer");
s.undo().unwrap();
- assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (4, 4), "one undo step");
+ assert_eq!((net(s, a).vertices.len(), net(s, b).vertices.len()), (4, 4), "one undo step");
+}
+
+fn bend_select_all_pen_and_clicks(s: &mut Session, a: NodeId, b: NodeId) {
// The Bend tool (held with Cmd/Ctrl) bends the segment under the pointer, in its own layer.
- drag(&mut s, (250.0, 100.0), (250.0, 130.0), CMD);
- assert!(net(&s, b).segments.iter().any(|sg| !sg.is_straight()), "the bend lands on the layer under the pointer");
- assert!(net(&s, a).segments.iter().all(|sg| sg.is_straight()), "other layers keep their segments");
+ drag(s, (250.0, 100.0), (250.0, 130.0), CMD);
+ assert!(net(s, b).segments.iter().any(|sg| !sg.is_straight()), "the bend lands on the layer under the pointer");
+ assert!(net(s, a).segments.iter().all(|sg| sg.is_straight()), "other layers keep their segments");
// Select all takes every edited layer's points.
- let r = ex(&mut s, "vector.edit.selectAll", json!({}));
+ let r = ex(s, "vector.edit.selectAll", json!({}));
assert_eq!(r["count"], json!(8), "silent: select all takes the points of every edited layer (pinned)");
// The pen draws in the active layer only.
- ex(&mut s, "vector.edit.select", json!({"id": a, "vertices": []}));
- ex(&mut s, "vector.edit.tool", json!({"tool": "pen"}));
- click(&mut s, (50.0, 300.0), NONE);
- assert_eq!((net(&s, a).vertices.len(), net(&s, b).vertices.len()), (5, 4), "silent: the pen adds to the active layer (pinned)");
- ex(&mut s, "vector.edit.penEnd", json!({}));
- ex(&mut s, "vector.edit.tool", json!({"tool": "move"}));
+ ex(s, "vector.edit.select", json!({"id": a, "vertices": []}));
+ ex(s, "vector.edit.tool", json!({"tool": "pen"}));
+ click(s, (50.0, 300.0), NONE);
+ assert_eq!((net(s, a).vertices.len(), net(s, b).vertices.len()), (5, 4), "silent: the pen adds to the active layer (pinned)");
+ ex(s, "vector.edit.penEnd", json!({}));
+ ex(s, "vector.edit.tool", json!({"tool": "move"}));
// A click on empty canvas inside an edited layer's area keeps edit mode; so does one far
// away, which only clears the points (a double-click or Escape leaves).
- click(&mut s, (250.0, 50.0), NONE);
- assert!(editing(&s).is_some(), "a click inside an edited layer clears the points and stays");
- let st = ex(&mut s, "vector.edit.state", json!({}));
+ click(s, (250.0, 50.0), NONE);
+ assert!(editing(s).is_some(), "a click inside an edited layer clears the points and stays");
+ let st = ex(s, "vector.edit.state", json!({}));
assert_eq!(st["layers"].as_array().map(Vec::len), Some(2), "the state lists every edited layer");
- click(&mut s, (900.0, 900.0), NONE);
- assert!(editing(&s).is_some(), "a click away from every edited layer keeps edit mode");
+ click(s, (900.0, 900.0), NONE);
+ assert!(editing(s).is_some(), "a click away from every edited layer keeps edit mode");
s.pointer(&ev(PointerKind::DoubleClick, (900.0, 900.0), NONE)).unwrap();
- assert!(editing(&s).is_none(), "a double-click away from every edited layer leaves edit mode");
+ assert!(editing(s).is_none(), "a double-click away from every edited layer leaves edit mode");
}
#[test]
diff --git a/crates/engine/src/view_state.rs b/crates/engine/src/view_state.rs
index e162f039..54102d6b 100644
--- a/crates/engine/src/view_state.rs
+++ b/crates/engine/src/view_state.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Deserialize, Serialize};
/// The canvas view of a document: what's at the centre of the viewport and the zoom.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs
index a271a908..cf115c8f 100644
--- a/crates/format/src/fig/assets_tests.rs
+++ b/crates/format/src/fig/assets_tests.rs
@@ -274,19 +274,30 @@ fn variables_modes_and_bindings_round_trip() {
#[test]
fn variables_are_written_as_nodes_on_a_hidden_page() {
let nodes = written(&themed());
- let hidden = named(&nodes, "Internal Only Canvas");
+ assert_hidden_theme(&nodes);
+ assert_variable_values(&nodes);
+ assert_variable_bindings(&nodes);
+}
+
+fn assert_hidden_theme(nodes: &[Value]) {
+ let hidden = named(nodes, "Internal Only Canvas");
assert_eq!((hidden["type"].as_str(), hidden["internalOnly"].as_bool(), hidden["visible"].as_bool()), (Some("CANVAS"), Some(true), Some(false)));
- let set = named(&nodes, "Theme");
+ let set = named(nodes, "Theme");
assert_eq!(set["type"], "VARIABLE_SET");
assert_eq!(set["parentIndex"]["guid"], hidden["guid"]);
let modes = set["variableSetModes"].as_array().unwrap();
assert_eq!(modes.iter().map(|m| m["name"].as_str().unwrap()).collect::>(), ["Light", "Dark"]);
- let bg = named(&nodes, "color/bg");
+}
+
+fn assert_variable_values(nodes: &[Value]) {
+ let set = named(nodes, "Theme");
+ let modes = set["variableSetModes"].as_array().unwrap();
+ let bg = named(nodes, "color/bg");
assert_eq!((bg["type"].as_str(), bg["variableResolvedType"].as_str()), (Some("VARIABLE"), Some("COLOR")));
assert_eq!(bg["variableSetID"]["guid"], set["guid"]);
assert_eq!(bg["description"], "Behind everything");
assert_eq!(bg["variableScopes"], json!(["FRAME_FILL", "SHAPE_FILL"]));
- assert_eq!(named(&nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"]));
+ assert_eq!(named(nodes, "color/fg")["variableScopes"], json!(["TEXT_FILL", "STROKE", "EFFECT_COLOR"]));
let values = bg["variableDataValues"]["entries"].as_array().unwrap();
assert_eq!(values.len(), 2);
assert_eq!(values[1]["modeID"], modes[1]["id"]);
@@ -295,19 +306,25 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {
(Some("COLOR"), Some(0.25))
);
// An alias names the variable's node; every value says the type it resolves to.
- let accent = &named(&nodes, "color/accent")["variableDataValues"]["entries"];
+ let accent = &named(nodes, "color/accent")["variableDataValues"]["entries"];
assert_eq!(accent[0]["variableData"]["dataType"], "ALIAS");
assert_eq!(accent[0]["variableData"]["resolvedDataType"], "COLOR");
- assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(&nodes, "color/fg")["guid"]);
- assert_eq!(named(&nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀");
- assert_eq!(named(&nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false);
- assert_eq!(named(&nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0);
+ assert_eq!(accent[0]["variableData"]["value"]["alias"]["guid"], named(nodes, "color/fg")["guid"]);
+ assert_eq!(named(nodes, "title")["variableDataValues"]["entries"][1]["variableData"]["value"]["textValue"], "Grüße 😀");
+ assert_eq!(named(nodes, "shown")["variableDataValues"]["entries"][1]["variableData"]["value"]["boolValue"], false);
+ assert_eq!(named(nodes, "space/gap")["variableDataValues"]["entries"][0]["variableData"]["value"]["floatValue"], 8.0);
+}
+
+fn assert_variable_bindings(nodes: &[Value]) {
+ let bg = named(nodes, "color/bg");
+ let set = named(nodes, "Theme");
+ let modes = set["variableSetModes"].as_array().unwrap();
// Bindings: on the paint, the gradient stop, the effect and the grid, and in the layer's map.
- let card = named(&nodes, "Card");
+ let card = named(nodes, "Card");
assert_eq!(card["fillPaints"][0]["colorVar"]["value"]["alias"]["guid"], bg["guid"]);
assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], set["guid"]);
assert_eq!(card["variableModeBySetMap"]["entries"][0]["variableModeID"], modes[1]["id"]);
- assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(&nodes, "space/gap")["guid"]);
+ assert_eq!(card["layoutGrids"][0]["gutterSizeVar"]["value"]["alias"]["guid"], named(nodes, "space/gap")["guid"]);
let fields: Vec<&str> =
card["variableConsumptionMap"]["entries"].as_array().unwrap().iter().map(|e| e["variableField"].as_str().unwrap()).collect();
for f in
@@ -315,7 +332,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {
{
assert!(fields.contains(&f), "{f} in {fields:?}");
}
- let swatch = named(&nodes, "Swatch");
+ let swatch = named(nodes, "Swatch");
assert!(swatch["fillPaints"][1]["stopsVar"][0].get("colorVar").is_none());
assert_eq!(swatch["fillPaints"][1]["stopsVar"][1]["colorVar"]["value"]["alias"]["guid"], bg["guid"]);
assert_eq!(swatch["fillPaints"][1]["stopsVar"].as_array().unwrap().len(), 2);
@@ -324,7 +341,7 @@ fn variables_are_written_as_nodes_on_a_hidden_page() {
assert_eq!(swatch["effects"][0][key]["dataType"], "ALIAS", "{key}");
}
// The page sets a mode too.
- assert_eq!(named(&nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(&nodes, "Spacing")["guid"]);
+ assert_eq!(named(nodes, "Page 1")["variableModeBySetMap"]["entries"][0]["variableSetID"]["guid"], named(nodes, "Spacing")["guid"]);
}
#[test]
diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs
index 00c84639..bd269556 100644
--- a/crates/format/src/fig/export/assets.rs
+++ b/crates/format/src/fig/export/assets.rs
@@ -1,5 +1,8 @@
use super::common::{layout_grid, node_field, prop_type, resolved_type, scope_name, variant_pairs};
-use super::*;
+use super::{
+ AUX, Action, Affine, Bound, Color, Exporter, Fields, Kv, LAYERS, MAX_NODES, Node, NodeId, NodeKind, Overlay, OverlayPosition, Paint, StyleKind,
+ TextData, VariableValue, arr, b, color, e, fl, guid, matrix, o, position, root_fields, s, u, xy,
+};
impl<'d> Exporter<'d> {
/// GUIDs for every layer (pages first, then depth-first), component property definitions,
diff --git a/crates/format/src/fig/export/common.rs b/crates/format/src/fig/export/common.rs
index 2853dd72..4a97eecb 100644
--- a/crates/format/src/fig/export/common.rs
+++ b/crates/format/src/fig/export/common.rs
@@ -1,4 +1,8 @@
-use super::*;
+use super::{
+ Bound, ExportSize, Fields, GAMEPAD_BUTTONS, GridAlign, GridKind, KeyMods, Kv, LetterSpacing, LineHeight, MAX_NETWORK, Node, NodeKind, Paint,
+ PropDef, SCHEMA, TextCase, TextDecoration, TextStyle, VariableType, VectorNetwork, WindingRule, arr, b, color, e, face_style, fl, guid, int, o,
+ s, xy,
+};
/// Longest kept description of such a layer (bytes of JSON).
const MAX_STASH: usize = 1 << 20;
diff --git a/crates/format/src/fig/export/component.rs b/crates/format/src/fig/export/component.rs
index 7d88dcc8..2342386b 100644
--- a/crates/format/src/fig/export/component.rs
+++ b/crates/format/src/fig/export/component.rs
@@ -1,7 +1,7 @@
use super::common::{
bool_data, changed, prop_type, resolved_alike, scaled, sort_position, symbol_data, text_data, text_value, var_data, variant_pairs,
};
-use super::*;
+use super::{AUX, BTreeMap, Exporter, Fields, Kv, MAX_NODES, Node, NodeId, NodeKind, PropDef, Value, arr, b, e, fl, guid, o, s};
impl<'d> Exporter<'d> {
/// A component property definition of `owner` (a component, or a set), `index`-th in its list.
diff --git a/crates/format/src/fig/export/layer.rs b/crates/format/src/fig/export/layer.rs
index 9925042f..55fd6dd9 100644
--- a/crates/format/src/fig/export/layer.rs
+++ b/crates/format/src/fig/export/layer.rs
@@ -1,5 +1,9 @@
use super::common::{export_setting, layout_grid, outlined, prop_type, variant_pairs};
-use super::*;
+use super::{
+ AUX, AutoLayout, BoolOp, Bound, ContentAlign, CrossAlign, Exporter, Fields, HConstraint, Kv, LAYERS, LayoutMode, MAX_DEPTH, MainAlign, MaskType,
+ Node, NodeId, NodeKind, PropDef, STASH, Sizing, TextData, TextStyle, VConstraint, WindingRule, arr, b, blend_name, color, e, fl, grid_sizing,
+ guid, int, matrix, o, position, root_fields, s, u, xy,
+};
impl<'d> Exporter<'d> {
/// Write layer `id` (child `index` of the layer with GUID `parent_guid`) and its subtree.
diff --git a/crates/format/src/fig/export/paint.rs b/crates/format/src/fig/export/paint.rs
index f0f40c18..26718e44 100644
--- a/crates/format/src/fig/export/paint.rs
+++ b/crates/format/src/fig/export/paint.rs
@@ -1,5 +1,8 @@
use super::common::one_sided;
-use super::*;
+use super::{
+ Affine, Bound, EffectKind, Exporter, Fields, GradientKind, ImageScaleMode, Kv, MAX_IMAGE, Node, NodeKind, NoiseMode, Paint, PaintKind,
+ StrokeAlign, StrokeCap, arr, b, blend_name, cap_name, color, e, fl, join_name, matrix, o, s, sha1_hex, xy,
+};
impl<'d> Exporter<'d> {
/// A paint list: a layer's fills (`fills`) or strokes with the variables `bound` to them.
diff --git a/crates/format/src/fig/export/prototype.rs b/crates/format/src/fig/export/prototype.rs
index 866b03c4..01c20db0 100644
--- a/crates/format/src/fig/export/prototype.rs
+++ b/crates/format/src/fig/export/prototype.rs
@@ -1,5 +1,8 @@
use super::common::key_codes;
-use super::*;
+use super::{
+ Action, Direction, Easing, Exporter, Fields, Interaction, Kv, Node, NodeId, Overflow, OverlayPosition, ScrollPosition, Transition, Trigger, arr,
+ b, color, e, fl, int, o, position, s,
+};
impl<'d> Exporter<'d> {
/// Interactions, scrolling, flow starts and the overlay settings this layer is opened with.
diff --git a/crates/format/src/fig/export/shape.rs b/crates/format/src/fig/export/shape.rs
index 7ef79a78..ba420527 100644
--- a/crates/format/src/fig/export/shape.rs
+++ b/crates/format/src/fig/export/shape.rs
@@ -1,5 +1,9 @@
use super::common::{style_fields, unsupported_decoration_look};
-use super::*;
+use super::{
+ Affine, BezPath, Bound, Exporter, Fields, Kv, ListStyle, MAX_CANVAS, MAX_CHARS, MAX_NETWORK, Node, NodeKind, Paint, StrokeAlign, StrokeCap,
+ StrokeJoin, TextAlignH, TextAlignV, TextAutoResize, TextData, TextStyle, VectorNetwork, WindingRule, arr, b, cap_name, e, finite, fl, int,
+ join_name, network, o, path_blob, s, u, xy,
+};
impl<'d> Exporter<'d> {
/// Text: its style, box settings, and characters with their styled runs. `fills`: the layer's
diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs
index 0f431082..82bdc6cd 100644
--- a/crates/format/src/fig/kiwi.rs
+++ b/crates/format/src/fig/kiwi.rs
@@ -400,7 +400,7 @@ impl<'s> Decoder<'s, '_> {
/// An encoder for the same format: the `.fig` export writes its schema and message with it (and
/// test fixtures are built with it). Values are checked against the schema; it never panics.
pub mod encode {
- use super::*;
+ use super::{BUILTINS, Field, Kind, KiwiError, Kv, MAX_DEPTH, Schema, Type, err};
#[derive(Default)]
pub struct Writer(pub Vec);
diff --git a/crates/format/src/fig/map/component.rs b/crates/format/src/fig/map/component.rs
index abc2225e..227c18aa 100644
--- a/crates/format/src/fig/map/component.rs
+++ b/crates/format/src/fig/map/component.rs
@@ -1,6 +1,6 @@
use super::layer::own_text;
use super::text::char_styles;
-use super::*;
+use super::{BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Rc, Value, View, guid, json};
/// An override row names a layer of the main by its current GUID or by the `overrideKey` the
/// layer had when the row was written: the key becomes the current GUID, unless the layer is
diff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs
index 172616dc..cd17680f 100644
--- a/crates/format/src/fig/map/connector.rs
+++ b/crates/format/src/fig/map/connector.rs
@@ -1,5 +1,5 @@
use super::layer::{cap_name, own_text, svg_path, valid_size};
-use super::*;
+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, guid, json, matrix, network, num, rows};
impl<'a> Mapper<'a> {
/// A connector's end caps, and when it has no saved outline or network a straight line
diff --git a/crates/format/src/fig/map/document.rs b/crates/format/src/fig/map/document.rs
index d31ef5c6..69f221c1 100644
--- a/crates/format/src/fig/map/document.rs
+++ b/crates/format/src/fig/map/document.rs
@@ -1,7 +1,7 @@
use super::component::{assigned, prop_guid};
use super::paint::color;
use super::variables::variable_value;
-use super::*;
+use super::{Affine, BTreeMap, BTreeSet, Kv, MAX_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, rows};
impl<'a> Mapper<'a> {
/// The DOCUMENT with its pages.
diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs
index e4793d48..e750a5e4 100644
--- a/crates/format/src/fig/map/layer.rs
+++ b/crates/format/src/fig/map/layer.rs
@@ -2,7 +2,10 @@ use super::component::{Entry, Inst, OverrideRow, prop_ref};
use super::connector::connector_has_saved_label;
use super::layout::{layout_grid, tracks};
use super::paint::{effect, export_setting, paint, paint_bound, stroke};
-use super::*;
+use super::{
+ Affine, BTreeMap, BTreeSet, Kv, MAX_COMMANDS, MAX_DEPTH, Mapper, Obj, Value, View, copy_num, copy_str, guid, json, matrix, network, num, rows,
+ size,
+};
/// How the layers of a resized container follow it when the file gives no derived layout for
/// them: a frame's by their constraints, from its main's size to its own; a group's scale with it.
@@ -61,6 +64,22 @@ impl Fit {
}
}
+struct LayerChildren<'s, 'a> {
+ id: &'s str,
+ inst: Option<&'s Inst<'a>>,
+ base: &'s Kv<'a>,
+ child_space: Affine,
+ view: &'s View<'a>,
+ object: &'s mut Obj,
+ ty: &'s str,
+ rest_id: &'s str,
+ connector_snapshot: bool,
+ connector_terminal: bool,
+ depth: usize,
+ included: Option<&'s BTreeSet>,
+ slot_content: Option,
+}
+
impl<'a> Mapper<'a> {
/// Layer `id` (inside instance `inst` when expanding one) and its subtree. `space`: from the
/// parent's coordinates to the enclosing frame's (groups and booleans place their children in
@@ -136,16 +155,7 @@ impl<'a> Mapper<'a> {
connector_snapshot && (!connector_has_saved_label(&view) || depth >= MAX_DEPTH || self.count.saturating_add(1) >= self.budget());
// A supported connector snapshot has its own note below instead of the generic stand-in one.
let stand = stand_in(own).filter(|_| !connector_snapshot);
- let ty = match own {
- "ROUNDED_RECTANGLE" => "RECTANGLE",
- "SYMBOL" => "COMPONENT",
- "FRAME" if view.flag("isStateGroup") == Some(true) => "COMPONENT_SET",
- "FRAME" if view.flag("resizeToFit") == Some(true) => "GROUP",
- "VARIABLE" | "VARIABLE_SET" => return None,
- "CONNECTOR" if connector_snapshot && connector_terminal => "VECTOR",
- "CONNECTOR" if connector_snapshot => "FRAME",
- t => stand.map_or(t, |s| s.0),
- };
+ let ty = Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)?;
if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") {
self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " ")));
}
@@ -225,18 +235,59 @@ impl<'a> Mapper<'a> {
source.insert("slotOverride".into(), json!(true));
}
let child_space = if matches!(ty, "GROUP" | "BOOLEAN_OPERATION") { space * view.transform().unwrap_or_default() } else { Affine::IDENTITY };
- let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(&view, &rest_id) } else { Vec::new() };
+ let mut kids = self.map_layer_children(LayerChildren {
+ id,
+ inst,
+ base,
+ child_space,
+ view: &view,
+ object: &mut o,
+ ty,
+ rest_id: &rest_id,
+ connector_snapshot,
+ connector_terminal,
+ depth,
+ included,
+ slot_content,
+ });
+ if stand.is_some_and(|s| s.0 == "FRAME") {
+ kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
+ kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
+ }
+ if !kids.is_empty() {
+ o.insert("children".into(), Value::Array(kids));
+ }
+ Some(Value::Object(o))
+ }
+
+ fn map_layer_children(&mut self, context: LayerChildren<'_, 'a>) -> Vec {
+ let LayerChildren {
+ id,
+ inst,
+ base,
+ child_space,
+ view,
+ object: o,
+ ty,
+ rest_id,
+ connector_snapshot,
+ connector_terminal,
+ depth,
+ included,
+ slot_content,
+ } = context;
+ let mut kids = if connector_snapshot && !connector_terminal { self.connector_snapshot_children(view, rest_id) } else { Vec::new() };
if connector_terminal {
// The saved vector is flattened into the connector node when no child slot remains.
} else if ty == "INSTANCE" {
- if let Some((sub, sym)) = self.instance(&mut o, &view, id, inst, &rest_id) {
+ if let Some((sub, sym)) = self.instance(o, view, id, inst, rest_id) {
let main = self.nodes.get(&sym).map(|m| View::of(m));
let fit = Fit::frame(main.as_ref().and_then(View::size), view.size());
// An instance that records no auto layout of its own has its main's.
let view = match main {
Some(m) if view.get("stackMode").is_none() && m.get("stackMode").is_some() => {
let layered = View { fitted: view.fitted, ..m.with(view.layers.iter().copied()) };
- self.auto_layout(&mut o, &layered);
+ self.auto_layout(o, &layered);
layered
}
_ => view.clone(),
@@ -252,7 +303,7 @@ impl<'a> Mapper<'a> {
content_inst.slot_content = true;
}
for c in self.children(&content) {
- kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(&view), None, depth + 1, included));
+ kids.extend(self.layer_filtered(&c, content_inst.as_ref(), child_space, Some(view), None, depth + 1, included));
}
} else {
// Inside an instance a layer's children are its main's: they follow it from the main's
@@ -263,17 +314,29 @@ impl<'a> Mapper<'a> {
(Some(_), _) => Fit::frame(View::of(base).size(), view.size()),
};
for c in self.children(id) {
- kids.extend(self.layer_filtered(&c, inst, child_space, Some(&view), fit, depth + 1, included));
+ kids.extend(self.layer_filtered(&c, inst, child_space, Some(view), fit, depth + 1, included));
}
}
- if stand.is_some_and(|s| s.0 == "FRAME") {
- kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id));
- kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY"));
+ kids
+ }
+
+ fn mapped_type<'s>(
+ own: &'s str,
+ view: &View<'a>,
+ connector_snapshot: bool,
+ connector_terminal: bool,
+ stand: Option<(&'static str, &'static str)>,
+ ) -> Option<&'s str> {
+ match own {
+ "ROUNDED_RECTANGLE" => Some("RECTANGLE"),
+ "SYMBOL" => Some("COMPONENT"),
+ "FRAME" if view.flag("isStateGroup") == Some(true) => Some("COMPONENT_SET"),
+ "FRAME" if view.flag("resizeToFit") == Some(true) => Some("GROUP"),
+ "VARIABLE" | "VARIABLE_SET" => None,
+ "CONNECTOR" if connector_snapshot && connector_terminal => Some("VECTOR"),
+ "CONNECTOR" if connector_snapshot => Some("FRAME"),
+ t => Some(stand.map_or(t, |s| s.0)),
}
- if !kids.is_empty() {
- o.insert("children".into(), Value::Array(kids));
- }
- Some(Value::Object(o))
}
/// A non-rectangular shape-with-text's shape, as a vector under its frame (which gives up its
diff --git a/crates/format/src/fig/map/layout.rs b/crates/format/src/fig/map/layout.rs
index f4d50891..7900c453 100644
--- a/crates/format/src/fig/map/layout.rs
+++ b/crates/format/src/fig/map/layout.rs
@@ -1,5 +1,5 @@
use super::paint::{bound, color};
-use super::*;
+use super::{BTreeMap, BTreeSet, Kv, MAX_GRID_TRACK_VALUE, MAX_GRID_TRACKS, Mapper, Obj, Value, View, copy_num, guid, json, num};
impl<'a> Mapper<'a> {
pub(super) fn auto_layout(&mut self, o: &mut Obj, v: &View<'a>) {
diff --git a/crates/format/src/fig/map/paint.rs b/crates/format/src/fig/map/paint.rs
index fe8cfa7b..808e6fc8 100644
--- a/crates/format/src/fig/map/paint.rs
+++ b/crates/format/src/fig/map/paint.rs
@@ -1,6 +1,6 @@
use super::layer::cap_name;
use super::variables::alias;
-use super::*;
+use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, copy_num, copy_str, guid, json, matrix, num, rows, vec2};
fn filter_number(value: &Kv) -> Option {
let number = match value {
diff --git a/crates/format/src/fig/map/proto.rs b/crates/format/src/fig/map/proto.rs
index d3f5c022..9238cafb 100644
--- a/crates/format/src/fig/map/proto.rs
+++ b/crates/format/src/fig/map/proto.rs
@@ -1,6 +1,6 @@
use super::paint::color;
use super::variables::variable_data;
-use super::*;
+use super::{Kv, MAX_ACTION_DEPTH, Mapper, Obj, Value, View, copy_str, guid, json, num, ref_guid};
impl<'a> Mapper<'a> {
pub(super) fn prototype(&mut self, o: &mut Obj, v: &View<'a>) {
diff --git a/crates/format/src/fig/map/text.rs b/crates/format/src/fig/map/text.rs
index 7431fbb5..6227c4d0 100644
--- a/crates/format/src/fig/map/text.rs
+++ b/crates/format/src/fig/map/text.rs
@@ -1,5 +1,5 @@
use super::paint::{paint, style_link};
-use super::*;
+use super::{Kv, Mapper, Obj, Value, View, copy_num, copy_str, json, network, num};
/// The value from the last override layer holding a valid one for an optional text setting; invalid instance rows fall through.
fn text_num(v: &View, key: &str) -> Option {
diff --git a/crates/format/src/fig/map/variables.rs b/crates/format/src/fig/map/variables.rs
index d27c834b..8d891ded 100644
--- a/crates/format/src/fig/map/variables.rs
+++ b/crates/format/src/fig/map/variables.rs
@@ -1,5 +1,5 @@
use super::paint::color;
-use super::*;
+use super::{Kv, MAX_EXPR_DEPTH, Mapper, Obj, Value, View, guid, json, num, ref_guid};
impl<'a> Mapper<'a> {
pub(super) fn variable_bindings(&mut self, o: &mut Obj, v: &View<'a>) {
diff --git a/crates/format/src/fig/source_identity_tests.rs b/crates/format/src/fig/source_identity_tests.rs
index 927b0227..09ec0c60 100644
--- a/crates/format/src/fig/source_identity_tests.rs
+++ b/crates/format/src/fig/source_identity_tests.rs
@@ -983,6 +983,11 @@ fn assert_external_namespace(doc: &Document) {
let copied_frame = child(doc, copied_body, 0);
assert_eq!(doc.node(frame).unwrap().source, None, "materialized ordinary layers are owned by the frame");
assert_eq!(doc.node(copied_frame).unwrap().source, Some(frame));
+ assert_external_slots(doc, frame, copied_frame, main);
+ assert_external_copy_state(doc, frame, copied_frame);
+}
+
+fn assert_external_slots(doc: &Document, frame: NodeId, copied_frame: NodeId, main: NodeId) {
for index in 0..2 {
let slot = child(doc, frame, index);
let copied_slot = child(doc, copied_frame, index);
@@ -999,6 +1004,9 @@ fn assert_external_namespace(doc: &Document) {
assert_eq!(doc.node(child(doc, current, 0)).unwrap().source, Some(child(doc, inherited, 0)));
assert_eq!(doc.node(child(doc, current, 0)).unwrap().transform.translation().x, 4.0);
}
+}
+
+fn assert_external_copy_state(doc: &Document, frame: NodeId, copied_frame: NodeId) {
let old = doc.node(child(doc, frame, 0)).unwrap();
let current = doc.node(child(doc, copied_frame, 0)).unwrap();
assert!(!old.visible && old.is_mask && old.opacity < 1.0);
diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs
index 35d8d08a..4ebb117f 100644
--- a/crates/format/src/fig/tests.rs
+++ b/crates/format/src/fig/tests.rs
@@ -630,63 +630,82 @@ fn imports_the_fixture() {
assert!(is_fig(&bytes));
let (doc, warnings) = import(&bytes, "fallback").unwrap();
assert_eq!(doc.name, "Fixture");
+ assert_fixture_layers(&doc);
+ assert_fixture_styles(&doc);
+ assert_fixture_variables(&doc);
+ assert_fixture_components(&doc);
+ assert_fixture_prototype(&doc, &warnings);
+}
+
+fn assert_fixture_layers(doc: &Document) {
// The internal page is skipped.
assert_eq!(doc.pages.len(), 1);
let page = doc.node(doc.pages[0]).unwrap();
assert_eq!(page.name, "Page A");
// Siblings by position: the frame comes first even though it's listed after the component.
assert_eq!(doc.node(page.children[0]).unwrap().name, "Screen");
- let screen = named(&doc, "Screen");
+ let screen = named(doc, "Screen");
let names: Vec<&str> = screen.children.iter().map(|c| doc.node(*c).unwrap().name.as_str()).collect();
assert_eq!(names, ["Photo", "Title", "Group", "Styled", "Card", "Button copy", "Note"]);
// Image fill from the archive.
- let photo = named(&doc, "Photo");
+ let photo = named(doc, "Photo");
let figcraft_doc::color::PaintKind::Image(img) = &photo.fills[0].kind else { panic!("not an image fill") };
assert_eq!(doc.images.get(&img.image).map(|i| (i.width, i.height)), Some((2, 1)));
assert_eq!((photo.corners.radii[0], photo.stroke.weight), (6.0, 2.0));
// Text with a bold run over "the".
- let NodeKind::Text(t) = &named(&doc, "Title").kind else { panic!("not text") };
+ let NodeKind::Text(t) = &named(doc, "Title").kind else { panic!("not text") };
assert_eq!(t.characters, "Hi there");
assert_eq!(t.style.font_size, 16.0);
assert_eq!(t.leading_trim, figcraft_doc::LeadingTrim::CapHeight, "vertical trim (#466)");
assert_eq!(t.runs.len(), 1);
assert_eq!((t.runs[0].start, t.runs[0].end, t.runs[0].style.font_style.as_str()), (3, 6, "Bold"));
// The group's children keep their place on the page (group at 100, 100).
- assert!(matches!(named(&doc, "Group").kind, NodeKind::Group));
- let tri = named(&doc, "Tri");
+ assert!(matches!(named(doc, "Group").kind, NodeKind::Group));
+ let tri = named(doc, "Tri");
assert!(matches!(&tri.kind, NodeKind::Vector { network } if !network.regions.is_empty()));
let abs = doc.world_transform(tri.id).translation();
assert!((abs.x - 105.0).abs() < 1e-6 && (abs.y - 106.0).abs() < 1e-6, "{abs:?}");
- assert!(matches!(named(&doc, "Star").kind, NodeKind::Star { count: 6, .. }));
- // The linked style supplies the paint.
- let styled = named(&doc, "Styled");
+ assert!(matches!(named(doc, "Star").kind, NodeKind::Star { count: 6, .. }));
+}
+
+fn assert_fixture_styles(doc: &Document) {
+ let styled = named(doc, "Styled");
assert_eq!(styled.styles.fill.as_deref(), Some("1:3"));
assert!(doc.styles.iter().any(|s| s.name == "Brand"));
let figcraft_doc::color::PaintKind::Solid { color } = styled.fills[0].kind else { panic!("not solid") };
assert_eq!((color.r, color.g, color.b), (0.0, 0.0, 1.0));
- // Variables, the fill binding and the explicit mode.
+}
+
+fn assert_fixture_variables(doc: &Document) {
+ let screen = named(doc, "Screen");
assert_eq!(doc.variables.collections.len(), 1);
assert_eq!(doc.variables.variables[0].kind, VariableType::Color);
- assert_eq!(named(&doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));
+ assert_eq!(named(doc, "Card").bound.get("fill").map(String::as_str), Some("1:5"));
assert_eq!(screen.var_modes.get("1:4").map(String::as_str), Some("1:91"));
- // The component keeps its property; the instance is expanded with the value applied.
- let button = named(&doc, "Button");
+}
+
+fn assert_fixture_components(doc: &Document) {
+ let button = named(doc, "Button");
assert!(matches!(&button.kind, NodeKind::Component { props, .. } if props.contains_key("Show icon")));
- let inst = named(&doc, "Button copy");
+ let inst = named(doc, "Button copy");
assert!(
matches!(&inst.kind, NodeKind::Instance { component, props } if *component == button.id && props.get("Show icon") == Some(&Value::Bool(false)))
);
let icon = doc.node(inst.children[0]).unwrap();
assert_eq!(icon.name, "Renamed icon");
assert!(!icon.visible);
- // Prototype: the click navigates to the component; the flow starts at the screen.
- let it = &photo.interactions[0];
+}
+
+fn assert_fixture_prototype(doc: &Document, warnings: &[String]) {
+ let button = named(doc, "Button");
+ let screen = named(doc, "Screen");
+ let it = &named(doc, "Photo").interactions[0];
assert!(matches!(it.actions[0], figcraft_doc::proto::Action::Navigate { dest, .. } if dest == button.id));
assert_eq!(it.duration_ms, 250);
let Some(NodeKind::Page { flows, .. }) = doc.node(doc.pages[0]).map(|p| &p.kind) else { panic!("no page") };
assert_eq!((flows[0].name.as_str(), flows[0].start), ("Main flow", screen.id));
// The sticky note comes in as a frame, with a warning.
- assert!(matches!(named(&doc, "Note").kind, NodeKind::Frame));
+ assert!(matches!(named(doc, "Note").kind, NodeKind::Frame));
assert!(warnings.iter().any(|w| w.contains("sticky layers import as frames")), "{warnings:?}");
}
diff --git a/crates/format/src/rest/proto_variable_tests.rs b/crates/format/src/rest/proto_variable_tests.rs
index cbaa3749..d2fdde9a 100644
--- a/crates/format/src/rest/proto_variable_tests.rs
+++ b/crates/format/src/rest/proto_variable_tests.rs
@@ -209,8 +209,14 @@ fn embedded_variables_match_separate_ones() {
#[test]
fn interactions_triggers_actions_and_transitions() {
let (doc, warnings) = import_with(&file_json(), Some(&variables_json()), &no_images).unwrap();
- let id = |n: &str| named(&doc, n).id;
- let button = named(&doc, "Button");
+ assert_interactions(&doc, &warnings);
+ assert_legacy_transition(&doc);
+ assert_interactions_without_variables();
+}
+
+fn assert_interactions(doc: &Document, warnings: &[String]) {
+ let id = |n: &str| named(doc, n).id;
+ let button = named(doc, "Button");
let its = &button.interactions;
assert_eq!(its.len(), 8, "{its:#?}");
@@ -239,18 +245,22 @@ fn interactions_triggers_actions_and_transitions() {
assert_eq!(its[7].trigger, Trigger::MouseUp);
assert_eq!(its[7].actions, [Action::ScrollTo { dest: id("Home"), offset: [0.0, 0.0] }], "unresolved destinations are dropped");
- // The legacy single transition.
- let details = named(&doc, "Details");
+ assert!(!has(warnings, "modifier keys"), "{warnings:?}");
+ for w in ["video triggers", "without a destination"] {
+ assert!(has(warnings, w), "{w}: {warnings:?}");
+ }
+}
+
+fn assert_legacy_transition(doc: &Document) {
+ let id = |n: &str| named(doc, n).id;
+ let details = named(doc, "Details");
assert_eq!(details.interactions.len(), 1);
let legacy = &details.interactions[0];
assert_eq!((&legacy.trigger, &legacy.actions[..]), (&Trigger::OnClick, &[Action::Navigate { dest: id("Home"), preserve_scroll: false }][..]));
assert_eq!((legacy.transition, legacy.duration_ms, legacy.easing), (Transition::Dissolve, 200, Easing::EaseIn));
+}
- assert!(!has(&warnings, "modifier keys"), "{warnings:?}");
- for w in ["video triggers", "without a destination"] {
- assert!(has(&warnings, w), "{w}: {warnings:?}");
- }
- // Without variables, set-variable actions have nothing to set.
+fn assert_interactions_without_variables() {
let (doc, warnings) = import(&file_json(), &no_images).unwrap();
assert_eq!(named(&doc, "Button").interactions.len(), 6);
assert!(has(&warnings, "set-variable actions") && has(&warnings, "set-variable-mode actions"), "{warnings:?}");
diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs
index 02e7bdaf..468a5536 100644
--- a/crates/layout/src/lib.rs
+++ b/crates/layout/src/lib.rs
@@ -333,6 +333,154 @@ struct Item {
base_off: f64,
}
+/// Split flow items into lines, preserving a single empty line when wrapping is off.
+fn flow_lines(items: &[Item], wraps: bool, gap: f64, inner_main: f64) -> Vec> {
+ if !wraps {
+ return vec![(0..items.len()).collect()];
+ }
+ let mut lines = Vec::new();
+ let mut current = Vec::new();
+ let mut used = 0.0;
+ for (i, item) in items.iter().enumerate() {
+ let size = if item.fill_main { item.min_main.unwrap_or(0.0) } else { item.main };
+ let added = if current.is_empty() { size } else { gap.max(0.0) + size };
+ if !current.is_empty() && used + added > inner_main + 1e-6 {
+ lines.push(std::mem::take(&mut current));
+ used = size;
+ } else {
+ used += added;
+ }
+ current.push(i);
+ }
+ if !current.is_empty() {
+ lines.push(current);
+ }
+ lines
+}
+
+/// Resolve each line's main extent and baseline-adjusted cross extent.
+fn flow_line_extents(items: &mut [Item], lines: &[Vec], gap: f64) -> Vec<(f64, f64)> {
+ let mut extents = Vec::with_capacity(lines.len());
+ for line in lines {
+ let gaps = gap * line.len().saturating_sub(1) as f64;
+ let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|item| item.baseline).fold(0.0, f64::max);
+ for i in line {
+ if let Some(item) = items.get_mut(*i) {
+ item.base_off = item.baseline.map(|baseline| line_base - baseline).unwrap_or(0.0);
+ }
+ }
+ let in_line = || line.iter().filter_map(|i| items.get(*i));
+ let main_used = in_line().map(|item| item.main).sum::() + gaps;
+ let all_fill = in_line().all(|item| item.fill_cross);
+ let cross = in_line().filter(|item| all_fill || !item.fill_cross).map(|item| item.base_off + item.cross).fold(0.0, f64::max);
+ extents.push((main_used, cross));
+ }
+ extents
+}
+
+/// Resolve cross-axis line spacing and alignment before positioning the flow items.
+fn align_flow_lines(items: &[Item], extents: &mut [(f64, f64)], al: &AutoLayout, inner_cross: f64, cross_gap: f64) -> (f64, f64) {
+ if extents.len() <= 1 {
+ return (0.0, cross_gap);
+ }
+ let lines_cross: f64 = extents.iter().map(|line| line.1).sum();
+ let between = (extents.len() - 1) as f64;
+ let spread = (inner_cross - lines_cross) / between;
+ if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 {
+ return (0.0, spread);
+ }
+ let cross_gap = if al.content_align == ContentAlign::SpaceBetween { 0.0 } else { cross_gap };
+ let free = inner_cross - lines_cross - cross_gap * between;
+ if free > 0.0 && items.iter().all(|item| item.fill_cross) {
+ let extra = free / extents.len() as f64;
+ extents.iter_mut().for_each(|line| line.1 += extra);
+ return (0.0, cross_gap);
+ }
+ let offset = match al.cross_align {
+ CrossAlign::Min | CrossAlign::Baseline => 0.0,
+ CrossAlign::Center => free / 2.0,
+ CrossAlign::Max => free,
+ };
+ (offset, cross_gap)
+}
+
+/// Collect the size and sizing constraints of the children in flow order.
+fn flow_items(doc: &Document, kids: &[NodeId], horizontal: bool, hug_main: bool, hug_cross: bool) -> Vec- {
+ let mut items = Vec::with_capacity(kids.len());
+ for child in kids {
+ let Some(node) = doc.node(*child) else { continue };
+ let (w, h) = outer_size(doc, *child);
+ let cl = &node.child_layout;
+ let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };
+ let (min_main, max_main, min_cross, max_cross) =
+ if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };
+ items.push(Item {
+ id: *child,
+ main: if horizontal { w } else { h },
+ cross: if horizontal { h } else { w },
+ // Fill needs room to fill: on an axis the frame hugs it reads as fixed.
+ fill_main: sm == Sizing::Fill && !hug_main,
+ fill_cross: sc == Sizing::Fill && !hug_cross,
+ min_main,
+ max_main,
+ min_cross,
+ max_cross,
+ baseline: None,
+ base_off: 0.0,
+ });
+ }
+ items
+}
+
+/// Position one resolved flow line, resizing wrapped fill children across the line.
+fn place_flow_line(doc: &mut Document, items: &[Item], line: &[usize], al: &AutoLayout, m: &dyn Measure, geometry: FlowLineGeometry) {
+ let FlowLineGeometry { horizontal, wraps, main_start, inner_main, cross_pos, line_cross, used, gap } = geometry;
+ let total = used - gap * line.len().saturating_sub(1) as f64;
+ let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap);
+ pos += main_start;
+ for i in line {
+ let Some(it) = items.get(*i) else { continue };
+ let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };
+ let off = match al.cross_align {
+ CrossAlign::Min => 0.0,
+ // Baseline applies to horizontal layouts; elsewhere it reads as top/left.
+ CrossAlign::Baseline => it.base_off,
+ CrossAlign::Center => (line_cross - cross_size) / 2.0,
+ CrossAlign::Max => line_cross - cross_size,
+ };
+ if it.fill_cross && wraps {
+ let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };
+ resize_child(doc, it.id, w, h, m);
+ }
+ let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };
+ place(doc, it.id, x, y);
+ pos += it.main + step_gap;
+ }
+}
+
+struct FlowLineGeometry {
+ horizontal: bool,
+ wraps: bool,
+ main_start: f64,
+ inner_main: f64,
+ cross_pos: f64,
+ line_cross: f64,
+ used: f64,
+ gap: f64,
+}
+
+/// Distribute a line's remaining main-axis space among its fill children.
+fn distribute_flow_lines(items: &mut [Item], lines: &[Vec], inner_main: f64, gap: f64, auto_gap: bool) {
+ for line in lines {
+ let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|item| !item.fill_main).map(|item| item.main).sum();
+ let gaps = gap * line.len().saturating_sub(1) as f64;
+ let fills: Vec = line.iter().copied().filter(|i| items.get(*i).is_some_and(|item| item.fill_main)).collect();
+ if !fills.is_empty() {
+ distribute(items, fills, inner_main - fixed - if auto_gap { 0.0 } else { gaps });
+ }
+ }
+}
+
/// Lay out one auto layout frame's children and size the frame if it hugs.
pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
let Some(_nested) = Nested::enter() else { return };
@@ -364,29 +512,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
let hug_cross = if horizontal { hug_h } else { hug_w };
let baseline = horizontal && al.cross_align == CrossAlign::Baseline;
- let mut items: Vec
- = Vec::with_capacity(kids.len());
- for c in &kids {
- let Some(n) = doc.node(*c) else { continue };
- let (w, h) = outer_size(doc, *c);
- let cl = &n.child_layout;
- let (sm, sc) = if horizontal { (cl.sizing_h, cl.sizing_v) } else { (cl.sizing_v, cl.sizing_h) };
- let (min_main, max_main, min_cross, max_cross) =
- if horizontal { (cl.min_w, cl.max_w, cl.min_h, cl.max_h) } else { (cl.min_h, cl.max_h, cl.min_w, cl.max_w) };
- items.push(Item {
- id: *c,
- main: if horizontal { w } else { h },
- cross: if horizontal { h } else { w },
- // Fill needs room to fill: on an axis the frame hugs it reads as fixed.
- fill_main: sm == Sizing::Fill && !hug_main,
- fill_cross: sc == Sizing::Fill && !hug_cross,
- min_main,
- max_main,
- min_cross,
- max_cross,
- baseline: None,
- base_off: 0.0,
- });
- }
+ let mut items = flow_items(doc, &kids, horizontal, hug_main, hug_cross);
let gap = finite_or_zero(al.gap);
let auto_gap = al.main_align.is_auto_gap();
@@ -405,37 +531,10 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
}
// Split into lines along the main axis (rows of a horizontal layout, columns of a vertical one).
- let mut lines: Vec> = Vec::new();
- if wraps {
- let mut cur: Vec = Vec::new();
- let mut used = 0.0;
- for (i, it) in items.iter().enumerate() {
- let sz = if it.fill_main { it.min_main.unwrap_or(0.0) } else { it.main };
- let add = if cur.is_empty() { sz } else { gap.max(0.0) + sz };
- if !cur.is_empty() && used + add > inner_main_fixed + 1e-6 {
- lines.push(std::mem::take(&mut cur));
- used = sz;
- } else {
- used += add;
- }
- cur.push(i);
- }
- if !cur.is_empty() {
- lines.push(cur);
- }
- } else {
- lines.push((0..items.len()).collect());
- }
+ let lines = flow_lines(&items, wraps, gap, inner_main_fixed);
// Share each line's free space among its fill children.
- for line in &lines {
- let fixed: f64 = line.iter().filter_map(|i| items.get(*i)).filter(|it| !it.fill_main).map(|it| it.main).sum();
- let gaps = gap * line.len().saturating_sub(1) as f64;
- let fills: Vec = line.iter().copied().filter(|i| items.get(*i).is_some_and(|it| it.fill_main)).collect();
- if !fills.is_empty() {
- distribute(&mut items, fills, inner_main_fixed - fixed - if auto_gap { 0.0 } else { gaps });
- }
- }
+ distribute_flow_lines(&mut items, &lines, inner_main_fixed, gap, auto_gap);
// Fill children take their main size now, so their cross size (wrapped text, a hugging frame)
// is final before the lines and the frame are sized.
for it in items.iter_mut().filter(|it| it.fill_main) {
@@ -452,23 +551,7 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
}
// Line extents: (main used, cross size).
- let mut line_extents: Vec<(f64, f64)> = Vec::new();
- for line in &lines {
- let gaps = gap * line.len().saturating_sub(1) as f64;
- // Baseline alignment: the line's baseline sits at the deepest first baseline.
- let line_base = line.iter().filter_map(|i| items.get(*i)).filter_map(|it| it.baseline).fold(0.0, f64::max);
- for i in line {
- if let Some(it) = items.get_mut(*i) {
- it.base_off = it.baseline.map(|b| line_base - b).unwrap_or(0.0);
- }
- }
- let in_line = || line.iter().filter_map(|i| items.get(*i));
- let main_used: f64 = in_line().map(|it| it.main).sum::() + gaps;
- // A line of nothing but fill-across children keeps their own size.
- let all_fill = in_line().all(|it| it.fill_cross);
- let cross: f64 = in_line().filter(|it| all_fill || !it.fill_cross).map(|it| it.base_off + it.cross).fold(0.0, f64::max);
- line_extents.push((main_used, cross));
- }
+ let mut line_extents = flow_line_extents(&mut items, &lines, gap);
// Container size (on a hugging axis fill children can't fill: they count with their own size).
let content_main = line_extents.iter().map(|l| l.0).fold(0.0, f64::max);
@@ -500,57 +583,15 @@ pub fn layout_frame(doc: &mut Document, frame: NodeId, m: &dyn Measure) {
// Position. Several lines share the free space across the flow: packed lines sit where the
// cross alignment says (lines of nothing but fill children stretch over it instead), or the
// space goes between them.
- let mut cross_pos = if horizontal { pt } else { pl };
+ let (cross_offset, cross_gap) = align_flow_lines(&items, &mut line_extents, &al, inner_cross, cross_gap);
+ let mut cross_pos = (if horizontal { pt } else { pl }) + cross_offset;
let single = lines.len() == 1;
- let mut cross_gap = cross_gap;
- if lines.len() > 1 {
- let lines_cross: f64 = line_extents.iter().map(|l| l.1).sum();
- let between = (lines.len() - 1) as f64;
- let spread = (inner_cross - lines_cross) / between;
- if al.content_align == ContentAlign::SpaceBetween && spread > 0.0 {
- cross_gap = spread;
- } else {
- if al.content_align == ContentAlign::SpaceBetween {
- cross_gap = 0.0;
- }
- let free = inner_cross - lines_cross - cross_gap * between;
- if free > 0.0 && items.iter().all(|it| it.fill_cross) {
- let extra = free / lines.len() as f64;
- line_extents.iter_mut().for_each(|l| l.1 += extra);
- } else {
- cross_pos += match al.cross_align {
- CrossAlign::Min | CrossAlign::Baseline => 0.0,
- CrossAlign::Center => free / 2.0,
- CrossAlign::Max => free,
- };
- }
- }
- }
for (li, line) in lines.iter().enumerate() {
let (used, line_cross) = line_extents.get(li).copied().unwrap_or((0.0, 0.0));
let line_cross = if single { inner_cross } else { line_cross };
- let total = used - gap * line.len().saturating_sub(1) as f64;
- let (mut pos, step_gap) = main_offsets(al.main_align, inner_main, total, line.len(), gap);
- pos += if horizontal { pl } else { pt };
- for i in line {
- let Some(it) = items.get(*i) else { continue };
- let cross_size = if it.fill_cross { clamp_size(line_cross, it.min_cross, it.max_cross) } else { it.cross };
- let off = match al.cross_align {
- CrossAlign::Min => 0.0,
- // Baseline applies to horizontal layouts; elsewhere it reads as top/left.
- CrossAlign::Baseline => it.base_off,
- CrossAlign::Center => (line_cross - cross_size) / 2.0,
- CrossAlign::Max => line_cross - cross_size,
- };
- // Fill across a wrapped line: its size is known only now.
- if it.fill_cross && wraps {
- let (w, h) = if horizontal { (it.main, cross_size) } else { (cross_size, it.main) };
- resize_child(doc, it.id, w, h, m);
- }
- let (x, y) = if horizontal { (pos, cross_pos + off) } else { (cross_pos + off, pos) };
- place(doc, it.id, x, y);
- pos += it.main + step_gap;
- }
+ let geometry =
+ FlowLineGeometry { horizontal, wraps, main_start: if horizontal { pl } else { pt }, inner_main, cross_pos, line_cross, used, gap };
+ place_flow_line(doc, &items, line, &al, m, geometry);
cross_pos += line_cross + cross_gap;
}
}
diff --git a/crates/pdf/src/draw.rs b/crates/pdf/src/draw.rs
index 1ff48409..95305390 100644
--- a/crates/pdf/src/draw.rs
+++ b/crates/pdf/src/draw.rs
@@ -22,6 +22,18 @@ pub(crate) const MAX_DEPTH: usize = figcraft_render::MAX_EXPORT_DEPTH;
/// Largest side of any raster made for the PDF, in pixels.
pub(crate) const MAX_RASTER_SIDE: f64 = 4096.0;
+#[derive(Clone, Copy)]
+pub(crate) struct PaintSize {
+ pub width: f64,
+ pub height: f64,
+}
+
+#[derive(Clone, Copy)]
+pub(crate) struct PaintFrame {
+ pub size: PaintSize,
+ pub transform: Affine,
+}
+
pub(crate) struct Exporter<'a> {
pub doc: &'a Document,
pub opts: &'a PdfOptions,
@@ -318,7 +330,7 @@ impl<'a> Exporter<'a> {
&& closed
{
for p in &n.fills {
- self.fill(s, path, rule(*r), p, w, h, xf);
+ self.fill(s, path, rule(*r), p, PaintFrame { size: PaintSize { width: w, height: h }, transform: xf });
}
}
self.place_inner(s, inner);
@@ -345,7 +357,7 @@ impl<'a> Exporter<'a> {
if let Some(g) = geom {
let area = figcraft_render::draw::stroke_area(n, &g, closed);
for p in &n.strokes {
- self.fill(s, &area, stroke_rule(n, closed), p, w, h, xf);
+ self.fill(s, &area, stroke_rule(n, closed), p, PaintFrame { size: PaintSize { width: w, height: h }, transform: xf });
}
}
}
@@ -411,8 +423,8 @@ impl<'a> Exporter<'a> {
}
/// Fill `path` (local space, mapped to the page by `xf`) with one paint on a w×h box.
- #[allow(clippy::too_many_arguments)]
- pub(crate) fn fill(&mut self, s: &mut Surface, path: &BezPath, r: FillRule, p: &Paint, w: f64, h: f64, xf: Affine) {
+ pub(crate) fn fill(&mut self, s: &mut Surface, path: &BezPath, r: FillRule, p: &Paint, frame: PaintFrame) {
+ let PaintFrame { size: PaintSize { width: w, height: h }, transform: xf } = frame;
if !p.visible || !above(p.opacity, 0.0) {
return;
}
@@ -423,15 +435,13 @@ impl<'a> Exporter<'a> {
}
s.push_transform(&to_xf(xf));
match &p.kind {
- PaintKind::Image(img) => self.image_fill(s, &kp, r, img, p.opacity.clamp(0.0, 1.0), w, h),
+ PaintKind::Image(img) => self.image_fill(s, &kp, r, img, p.opacity.clamp(0.0, 1.0), frame.size),
PaintKind::Pattern(pt) => {
- if !self.pattern_fill(s, &kp, r, pt, p.opacity.clamp(0.0, 1.0), w, h) {
- self.raster_paint(s, path, &kp, r, p, w, h, xf);
+ if !self.pattern_fill(s, &kp, r, pt, p.opacity.clamp(0.0, 1.0), frame.size) {
+ self.raster_paint(s, path, &kp, r, p, frame);
}
}
- PaintKind::Gradient(g) if !matches!(g.kind, GradientKind::Linear | GradientKind::Radial) => {
- self.raster_paint(s, path, &kp, r, p, w, h, xf)
- }
+ PaintKind::Gradient(g) if !matches!(g.kind, GradientKind::Linear | GradientKind::Radial) => self.raster_paint(s, path, &kp, r, p, frame),
_ => {
if let Some((paint, opacity)) = vector_paint(p, w, h, self.ink()) {
s.set_fill(Some(Fill { paint, opacity, rule: r }));
@@ -475,8 +485,8 @@ impl<'a> Exporter<'a> {
}
/// An image fill; the surface is in the node's local space.
- #[allow(clippy::too_many_arguments)]
- fn image_fill(&mut self, s: &mut Surface, kp: &Path, r: FillRule, img: &ImagePaint, op: f32, w: f64, h: f64) {
+ fn image_fill(&mut self, s: &mut Surface, kp: &Path, r: FillRule, img: &ImagePaint, op: f32, size: PaintSize) {
+ let PaintSize { width: w, height: h } = size;
let Some((image, iw, ih)) = self.image(img) else { return };
let t = figcraft_render::paint::image_transform(img, f64::from(iw), f64::from(ih), w, h);
let t = Affine::new([f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)]);
@@ -512,8 +522,8 @@ impl<'a> Exporter<'a> {
/// A pattern fill as a PDF tiling pattern of the source's vectors; false (draw it as an image
/// instead) when the source has effects, which the PDF draws as page-placed images.
- #[allow(clippy::too_many_arguments)]
- fn pattern_fill(&mut self, s: &mut Surface, kp: &Path, r: FillRule, pt: &PatternPaint, op: f32, w: f64, h: f64) -> bool {
+ fn pattern_fill(&mut self, s: &mut Surface, kp: &Path, r: FillRule, pt: &PatternPaint, op: f32, size: PaintSize) -> bool {
+ let PaintSize { width: w, height: h } = size;
let doc = self.doc;
let Some(src) = doc.node(NodeId(pt.source)).filter(|n| !n.is_page()) else { return true };
if doc.subtree(src.id).iter().filter_map(|i| doc.node(*i)).any(|n| n.effects.iter().any(|e| e.visible)) {
@@ -550,8 +560,8 @@ impl<'a> Exporter<'a> {
/// Angular and diamond gradients, and patterns of sources with effects: an image of the paint
/// over the path's bounds, clipped to it. The surface is in the node's local space; `xf` (local → page) sets the resolution.
- #[allow(clippy::too_many_arguments)]
- fn raster_paint(&mut self, s: &mut Surface, path: &BezPath, kp: &Path, r: FillRule, p: &Paint, w: f64, h: f64, xf: Affine) {
+ fn raster_paint(&mut self, s: &mut Surface, path: &BezPath, kp: &Path, r: FillRule, p: &Paint, frame: PaintFrame) {
+ let PaintFrame { size: PaintSize { width: w, height: h }, transform: xf } = frame;
let bb = path.bounding_box();
if !(bb.width() > 0.0 && bb.height() > 0.0 && crate::rect_finite(bb)) {
return;
diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs
index cb2ec7a2..abbb65b3 100644
--- a/crates/pdf/src/text.rs
+++ b/crates/pdf/src/text.rs
@@ -16,7 +16,7 @@ use krilla::text::{Font, GlyphId, KrillaGlyph};
use figcraft_color::{GradientKind, Paint, PaintKind};
-use crate::draw::{Exporter, above, affine_ok, blend, stroke_rule, to_xf, vector_paint};
+use crate::draw::{Exporter, PaintFrame, PaintSize, above, affine_ok, blend, stroke_rule, to_xf, vector_paint};
use crate::raster::Raster;
/// Glyphs of one face and size on one line, written as one text run.
@@ -33,6 +33,12 @@ struct Run {
last_chars: Option<(usize, usize)>,
}
+struct GlyphSelection<'a> {
+ layout: &'a TextLayout,
+ style: usize,
+ chars: &'a [char],
+}
+
/// Draw a text node; `xf` maps its local space to the page.
pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine, inner: &[Raster]) {
let NodeKind::Text(t) = &n.kind else { return };
@@ -45,6 +51,7 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
record_links(ex, &layout, gxf);
}
let (w, h) = (n.width, n.height);
+ let frame = PaintFrame { size: PaintSize { width: w, height: h }, transform: gxf };
let chars: Vec = t.characters.chars().collect();
let styles: BTreeSet = layout.glyphs.iter().map(|g| g.style).chain(layout.decorations.iter().map(|d| d.style)).collect();
// Every run's fills, then the inner shadows over them, then the strokes on top.
@@ -72,11 +79,11 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
if embeddable && !real_text_written && !ex.opts.outline_text && layout.on_path.is_none() {
// The first fill writes the text itself; the rest paint its outlines on top.
real_text_written = true;
- let mut rest = glyph_runs(ex, s, &layout, k, &chars, p, w, h, gxf);
+ let mut rest = glyph_runs(ex, s, GlyphSelection { layout: &layout, style: k, chars: &chars }, p, frame);
rest.extend(decorations.iter());
- ex.fill(s, &rest, FillRule::NonZero, p, w, h, gxf);
+ ex.fill(s, &rest, FillRule::NonZero, p, frame);
} else {
- ex.fill(s, &outlines, FillRule::NonZero, p, w, h, gxf);
+ ex.fill(s, &outlines, FillRule::NonZero, p, frame);
}
}
if n.has_visible_stroke() {
@@ -88,7 +95,7 @@ pub(crate) fn draw_text(ex: &mut Exporter, s: &mut Surface, n: &Node, xf: Affine
for outlines in &stroked {
let area = figcraft_render::draw::stroke_area(n, outlines, true);
for p in &n.strokes {
- ex.fill(s, &area, stroke_rule(n, true), p, w, h, gxf);
+ ex.fill(s, &area, stroke_rule(n, true), p, frame);
}
}
}
@@ -147,18 +154,8 @@ impl Exporter<'_> {
/// Write the glyphs of style `style` as real text filled with `p` (layout space, mapped to the
/// page by `gxf`). Returns the outlines of glyphs that can't be written as text.
-#[allow(clippy::too_many_arguments)]
-fn glyph_runs(
- ex: &mut Exporter,
- s: &mut Surface,
- layout: &TextLayout,
- style: usize,
- chars: &[char],
- p: &Paint,
- w: f64,
- h: f64,
- gxf: Affine,
-) -> BezPath {
+fn glyph_runs(ex: &mut Exporter, s: &mut Surface, selection: GlyphSelection<'_>, p: &Paint, frame: PaintFrame) -> BezPath {
+ let GlyphSelection { layout, style, chars } = selection;
let mut rest = BezPath::new();
for line in &layout.lines {
let mut run: Option = None;
@@ -175,7 +172,7 @@ fn glyph_runs(
let continues = run.as_ref().is_some_and(|r| r.face == g.face.id && (r.size - g.size).abs() < 1e-9);
if !continues {
if let Some(r) = run.take() {
- write_run(s, r, p, w, h, gxf, ex.ink());
+ write_run(s, r, p, frame, ex.ink());
}
run = Some(Run { face: g.face.id, font, size: g.size, x0: g.x, y0: g.y, glyphs: Vec::new(), text: String::new(), last_chars: None });
}
@@ -184,7 +181,7 @@ fn glyph_runs(
}
}
if let Some(r) = run.take() {
- write_run(s, r, p, w, h, gxf, ex.ink());
+ write_run(s, r, p, frame, ex.ink());
}
}
rest
@@ -205,12 +202,11 @@ fn push_glyph(r: &mut Run, g: &Glyph, chars: &[char]) {
r.glyphs.push((g.gid, g.x, g.y, g.advance, range));
}
-#[allow(clippy::too_many_arguments)]
-fn write_run(s: &mut Surface, r: Run, p: &Paint, w: f64, h: f64, gxf: Affine, ink: crate::print::Ink) {
+fn write_run(s: &mut Surface, r: Run, p: &Paint, frame: PaintFrame, ink: crate::print::Ink) {
if r.glyphs.is_empty() {
return;
}
- let Some((paint, opacity)) = vector_paint(p, w, h, ink) else { return };
+ let Some((paint, opacity)) = vector_paint(p, frame.size.width, frame.size.height, ink) else { return };
let size = r.size;
let glyphs: Vec = r
.glyphs
@@ -226,7 +222,7 @@ fn write_run(s: &mut Surface, r: Run, p: &Paint, w: f64, h: f64, gxf: Affine, in
if let Some(b) = pb {
s.push_blend_mode(b);
}
- s.push_transform(&to_xf(gxf));
+ s.push_transform(&to_xf(frame.transform));
s.set_stroke(None);
s.set_fill(Some(Fill { paint, opacity, rule: FillRule::NonZero }));
s.draw_glyphs(Point::from_xy(r.x0 as f32, r.y0 as f32), &glyphs, r.font, &r.text, size as f32, false);
diff --git a/crates/render/src/backdrop.rs b/crates/render/src/backdrop.rs
index bdd8405c..ef6994c6 100644
--- a/crates/render/src/backdrop.rs
+++ b/crates/render/src/backdrop.rs
@@ -19,6 +19,23 @@ use crate::shadow::{Outline, spread_alpha};
const MAX_DEPTH: usize = 256;
use crate::{View, fx, glass};
+/// The layer and backdrop geometry shared by backdrop effects.
+#[derive(Clone, Copy)]
+pub struct EffectFrame<'a> {
+ /// The layer's shape in local coordinates.
+ pub shape: &'a Outline,
+ /// Pixels behind the layer, padded beyond its bounds.
+ pub back: &'a Pixmap,
+ /// Backdrop padding in pixels on each side.
+ pub pad: u32,
+ /// Layer dimensions in pixels.
+ pub size: (u32, u32),
+ /// Local-to-layer pixel transform.
+ pub xf: Affine,
+ /// Device pixels per document unit.
+ pub scale: f64,
+}
+
/// One isolated surface of a render, and how it will be composited onto the surface below.
#[derive(Clone, Copy)]
pub struct Under<'a> {
@@ -124,8 +141,8 @@ pub fn pad(n: &Node, scale: f64) -> u32 {
/// A node's backdrop effects (background blurs and glass, in list order) for its `w` × `h` layer,
/// from `back`: its backdrop around the layer, `pad` pixels more each way. `shape` maps through
/// `xf` (local → layer pixels) at `scale` device pixels per unit. `None` when nothing shows.
-#[allow(clippy::too_many_arguments)]
-pub fn effects(n: &Node, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u32, xf: Affine, scale: f64) -> Option {
+pub fn effects(n: &Node, frame: EffectFrame<'_>) -> Option {
+ let EffectFrame { shape, back, pad, size: (w, h), xf, scale } = frame;
let pad_i = i32::try_from(pad).ok()?;
let mut out: Option = None;
let mut cov: Option = None;
@@ -143,7 +160,7 @@ pub fn effects(n: &Node, shape: &Outline, back: &Pixmap, pad: u32, w: u32, h: u3
fx::mask_by(&mut l, cov.as_ref()?, false);
l
}
- EffectKind::Glass(g) => glass::glass(g, shape, back, pad, w, h, xf, scale)?,
+ EffectKind::Glass(g) => glass::glass(g, frame)?,
_ => continue,
};
match out.as_mut() {
@@ -228,7 +245,17 @@ pub fn effects_beneath(doc: &Document, roots: &[NodeId], id: NodeId, view: &View
let (ox, oy) = around.origin;
keep_inside(&mut back, Rect::new((k.x0 - ox) * s, (k.y0 - oy) * s, (k.x1 - ox) * s, (k.y1 - oy) * s));
}
- effects(n, &shape, &back, pad, view.width, view.height, view.transform() * doc.world_transform(id), view.scale)
+ effects(
+ n,
+ EffectFrame {
+ shape: &shape,
+ back: &back,
+ pad,
+ size: (view.width, view.height),
+ xf: view.transform() * doc.world_transform(id),
+ scale: view.scale,
+ },
+ )
}
/// Clear the pixels of `p` outside `r` (pixel coordinates).
diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs
index 75efae60..fa0f98ba 100644
--- a/crates/render/src/draw.rs
+++ b/crates/render/src/draw.rs
@@ -659,7 +659,7 @@ fn backdrop_layer(ctx: &Ctx, pix: &Pixmap, n: &Node, sh: &shadow::Outline, at: (
let pad = backdrop::pad(n, ctx.scale);
let p = i32::try_from(pad).unwrap_or(0);
let back = backdrop::backdrop(pix, ctx.under, lx.saturating_sub(p), ly.saturating_sub(p), lw + pad * 2, lh + pad * 2)?;
- backdrop::effects(n, sh, &back, pad, lw, lh, lxf, ctx.scale)
+ backdrop::effects(n, backdrop::EffectFrame { shape: sh, back: &back, pad, size: (lw, lh), xf: lxf, scale: ctx.scale })
}
/// Node `n`'s layer blur, noise and texture on its `layer`, in list order.
diff --git a/crates/render/src/glass.rs b/crates/render/src/glass.rs
index 3555ff44..23301b01 100644
--- a/crates/render/src/glass.rs
+++ b/crates/render/src/glass.rs
@@ -22,6 +22,7 @@ use figcraft_geom::Affine;
use figcraft_geom::kurbo::Shape as _;
use tiny_skia::Pixmap;
+use crate::backdrop::EffectFrame;
use crate::fx;
use crate::shadow::{Outline, spread_alpha};
@@ -406,8 +407,8 @@ fn whole_shape(shape: &Outline, xf: Affine, m: u32, r: usize) -> Option<(Arc Option {
+pub fn glass(g: &Glass, frame: EffectFrame<'_>) -> Option {
+ let EffectFrame { shape, back, pad, size: (w, h), xf, scale } = frame;
let g = g.sanitized();
let depth = (g.depth * scale).clamp(1.0, 1024.0);
let band = (depth * (0.15 + 0.35 * g.splay)).max(1.0);
diff --git a/crates/render/src/retained.rs b/crates/render/src/retained.rs
index 61f11003..620b8f56 100644
--- a/crates/render/src/retained.rs
+++ b/crates/render/src/retained.rs
@@ -344,7 +344,7 @@ fn patch(last: Frame, doc: &Arc, page: NodeId, view: &View, px: (i64,
let mut pix = last.pix;
shift(&mut pix, dx, dy)?;
for d in &damage {
- render_into(doc, page, view, px, d, margin, opts, &mut pix)?;
+ render_into(FrameRender { doc, page, view, px, opts }, d, margin, &mut pix)?;
}
Some((pix, pixels))
}
@@ -399,9 +399,17 @@ fn merge(mut rects: Vec) -> Vec {
rects
}
+struct FrameRender<'a> {
+ doc: &'a Document,
+ page: NodeId,
+ view: &'a View,
+ px: (i64, i64),
+ opts: &'a Options,
+}
+
/// Render the frame pixels `d` (with `margin` more around, discarded) into `pix`.
-#[allow(clippy::too_many_arguments)]
-fn render_into(doc: &Document, page: NodeId, view: &View, px: (i64, i64), d: &Px, margin: i64, opts: &Options, pix: &mut Pixmap) -> Option<()> {
+fn render_into(frame: FrameRender<'_>, d: &Px, margin: i64, pix: &mut Pixmap) -> Option<()> {
+ let FrameRender { doc, page, view, px, opts } = frame;
let s = view.scale;
let (ox, oy) = (d.x0 - margin, d.y0 - margin);
let (rw, rh) = (u32::try_from(d.x1 - d.x0 + 2 * margin).ok()?, u32::try_from(d.y1 - d.y0 + 2 * margin).ok()?);
diff --git a/crates/svg/src/import/text.rs b/crates/svg/src/import/text.rs
index 32d37bf1..d2a6c46e 100644
--- a/crates/svg/src/import/text.rs
+++ b/crates/svg/src/import/text.rs
@@ -271,7 +271,10 @@ fn on_path(cx: &mut Ctx, t: &usvg::Text, world: Affine, parent: NodeId) -> bool
local.apply_affine(Affine::translate((-bb.x0, -bb.y0)));
td.path = Some(Box::new(figcraft_doc::TextPath { start: f64::from(tp.start_offset()) / len, ..figcraft_doc::TextPath::new(&local) }));
let transform = world * Affine::translate((bb.x0, bb.y0));
- add_layer(cx, t, &[line], td, transform, (bb.width().max(1.0), bb.height().max(1.0)), world, 1.0, parent);
+ add_layer(
+ cx,
+ TextLayer { source: t, block: &[line], data: td, transform, size: (bb.width().max(1.0), bb.height().max(1.0)), world, scale: 1.0, parent },
+ );
true
}
@@ -293,23 +296,24 @@ fn layer(cx: &mut Ctx, t: &usvg::Text, block: &[Line], m: Affine, s: f64, world:
let (w, h) = (lay.width.ceil().max(1.0), lay.height.max(1.0));
let baseline = lay.lines.first().map_or(0.0, |l| l.baseline);
let transform = m * Affine::translate((s * first.x - anchor_k(first.anchor) * w, s * first.y - baseline));
- add_layer(cx, t, block, td, transform, (w, h), world, s, parent);
+ add_layer(cx, TextLayer { source: t, block, data: td, transform, size: (w, h), world, scale: s, parent });
+}
+
+struct TextLayer<'text, 'block, 'span> {
+ source: &'text usvg::Text,
+ block: &'block [Line<'span>],
+ data: TextData,
+ transform: Affine,
+ size: (f64, f64),
+ world: Affine,
+ scale: f64,
+ parent: NodeId,
}
/// Add text layer `td` of `block` at `transform` with box `(w, h)`, with the spans' fills and the
/// first span's stroke (`world`: the SVG text's space; `s`: the scale folded into font sizes).
-#[allow(clippy::too_many_arguments)]
-fn add_layer(
- cx: &mut Ctx,
- t: &usvg::Text,
- block: &[Line],
- mut td: TextData,
- transform: Affine,
- (w, h): (f64, f64),
- world: Affine,
- s: f64,
- parent: NodeId,
-) {
+fn add_layer(cx: &mut Ctx, layer: TextLayer<'_, '_, '_>) {
+ let TextLayer { source: t, block, data: mut td, transform, size: (w, h), world, scale: s, parent } = layer;
let Some(first) = block.first() else { return };
let spans: Vec<&usvg::TextSpan> = block.iter().flat_map(|l| l.spans.iter().map(|sp| sp.2)).collect();
if spans.iter().all(|sp| !sp.is_visible()) {
diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs
index 233da218..25175d28 100644
--- a/crates/svg/src/lib.rs
+++ b/crates/svg/src/lib.rs
@@ -61,6 +61,18 @@ impl Default for SvgOptions {
}
}
+struct NodePaint<'a> {
+ node: &'a Node,
+ xf: Affine,
+ depth: usize,
+ paint_clip: String,
+ kid_clip: Option,
+ inherited: Option,
+ outline: Option<(BezPath, figcraft_render::FillRule)>,
+ grains: String,
+ size: (f64, f64),
+}
+
struct W<'a> {
doc: &'a Document,
/// The exported layers, in z-order (what backdrops see).
@@ -458,6 +470,12 @@ impl W<'_> {
NodeKind::Group | NodeKind::Page { .. } => None,
_ => figcraft_render::node_outline(self.doc, n),
};
+ self.render_node(NodePaint { node: n, xf, depth, paint_clip, kid_clip, inherited, outline, grains, size: (w, h) });
+ self.body.push_str("");
+ }
+
+ fn render_node(&mut self, paint: NodePaint<'_>) {
+ let NodePaint { node: n, xf, depth, paint_clip, kid_clip, inherited, outline, grains, size: (w, h) } = paint;
let closed = figcraft_doc::geometry::is_closed(n) || matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Text(_));
let live_text = if self.opts.outline_text { None } else { self.text_element(n, xf) };
let run_paths = if live_text.is_none() { text_run_paths(n) } else { None };
@@ -486,92 +504,117 @@ impl W<'_> {
}
// Inner shadows paint over the fills, under the children and strokes.
if let Some(o) = &outline {
- for e in n.effects.iter().filter(|e| e.visible) {
- if let EffectKind::InnerShadow(s) = &e.kind {
- self.inner_shadow(o, s, xf);
- }
- }
+ self.inner_shadows(n, o, xf);
}
if !matches!(n.kind, NodeKind::BooleanOp { .. }) && !n.children.is_empty() {
- let mut kid_clip = kid_clip;
- if n.clip
- && n.kind.is_frame_like()
- && let Some((p, _)) = &outline
- {
- // Its own clip, within the clips around it.
- let cid = self.id("clip");
- let mut cp = p.clone();
- cp.apply_affine(xf);
- let _ = write!(self.defs, "", clip_attr(&kid_clip), path_d(&cp));
- kid_clip = Some(cid);
- }
- self.clip = kid_clip;
- let kids = n.children.clone();
- for run in self.doc.paint_runs(n) {
- let Some(&c) = kids.get(run.start) else { continue };
- if self.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) {
- let kind = self.doc.node(c).map(|mn| mn.mask_type).unwrap_or_default();
- if kind == figcraft_doc::MaskType::Vector {
- // A vector mask is a clip path: its outline.
- let cid = self.id("clip");
- let outline =
- self.doc.node(c).and_then(|mn| Some((xf * mn.transform, figcraft_render::subtree_outline(self.doc, mn, depth + 1)?)));
- let (shape, cr) = match &outline {
- Some((mxf, (mp, rule))) => (
- format!(" transform=\"{}\" d=\"{}\"", matrix(*mxf), path_d(mp)),
- if *rule == figcraft_render::FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" },
- ),
- None => (" d=\"\"".to_string(), ""),
- };
- let _ = write!(self.defs, "");
- let _ = write!(self.body, "");
- } else {
- // The layer as drawn: its alpha or luminance masks.
- let mid = self.id("mask");
- let saved = std::mem::take(&mut self.body);
- let outer = self.mask.replace(c);
- let clip = self.clip.take();
- self.node(c, xf, depth + 1);
- self.clip = clip;
- self.mask = outer;
- let mask_body = std::mem::replace(&mut self.body, saved);
- let alpha = if kind == figcraft_doc::MaskType::Alpha { " style=\"mask-type:alpha\"" } else { "" };
- let _ = write!(self.defs, "{mask_body}");
- let _ = write!(self.body, "");
- }
- for k in kids.get(run.start + 1..run.end).unwrap_or(&[]) {
- self.node(*k, xf, depth + 1);
- }
- self.body.push_str("");
- } else {
- self.node(c, xf, depth + 1);
- }
- }
- self.clip = inherited;
+ self.children(n, xf, depth, outline.as_ref(), kid_clip, inherited);
}
if n.has_visible_stroke()
&& let Some(simple) = self.opts.simplify_stroke.then(|| self.simple_stroke(n, xf)).flatten()
{
self.body.push_str(&clipped(&paint_clip, simple));
} else if n.has_visible_stroke() {
- let geom = match &n.kind {
- NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.as_ref().map(|(p, _)| p.clone()),
- _ => figcraft_doc::geometry::stroke_geometry(n),
+ self.stroke_paths(n, xf, outline.as_ref(), closed, &paint_clip);
+ }
+ self.body.push_str(&grains);
+ }
+
+ /// Paints the children of `n`, each mask layer wrapping the layers it covers.
+ fn children(
+ &mut self,
+ n: &Node,
+ xf: Affine,
+ depth: usize,
+ outline: Option<&(BezPath, figcraft_render::FillRule)>,
+ mut kid_clip: Option,
+ inherited: Option,
+ ) {
+ if n.clip
+ && n.kind.is_frame_like()
+ && let Some((p, _)) = &outline
+ {
+ // Its own clip, within the clips around it.
+ let cid = self.id("clip");
+ let mut cp = p.clone();
+ cp.apply_affine(xf);
+ let _ = write!(self.defs, "", clip_attr(&kid_clip), path_d(&cp));
+ kid_clip = Some(cid);
+ }
+ self.clip = kid_clip;
+ let kids = n.children.clone();
+ for run in self.doc.paint_runs(n) {
+ let Some(&c) = kids.get(run.start) else { continue };
+ if self.doc.node(c).is_some_and(|cn| cn.is_mask && cn.visible) {
+ let kind = self.doc.node(c).map(|mn| mn.mask_type).unwrap_or_default();
+ self.open_mask(c, kind, xf, depth);
+ for k in kids.get(run.start + 1..run.end).unwrap_or(&[]) {
+ self.node(*k, xf, depth + 1);
+ }
+ self.body.push_str("");
+ } else {
+ self.node(c, xf, depth + 1);
+ }
+ }
+ self.clip = inherited;
+ }
+
+ /// The inner shadows of `n` over its `outline`.
+ fn inner_shadows(&mut self, n: &Node, outline: &Outline, xf: Affine) {
+ for e in n.effects.iter().filter(|e| e.visible) {
+ if let EffectKind::InnerShadow(s) = &e.kind {
+ self.inner_shadow(outline, s, xf);
+ }
+ }
+ }
+
+ /// Opens the `` that the mask layer `c` clips or masks the layers after it with.
+ fn open_mask(&mut self, c: NodeId, kind: figcraft_doc::MaskType, xf: Affine, depth: usize) {
+ if kind == figcraft_doc::MaskType::Vector {
+ // A vector mask is a clip path: its outline.
+ let cid = self.id("clip");
+ let outline = self.doc.node(c).and_then(|mn| Some((xf * mn.transform, figcraft_render::subtree_outline(self.doc, mn, depth + 1)?)));
+ let (shape, cr) = match &outline {
+ Some((mxf, (mp, rule))) => (
+ format!(" transform=\"{}\" d=\"{}\"", matrix(*mxf), path_d(mp)),
+ if *rule == figcraft_render::FillRule::EvenOdd { " clip-rule=\"evenodd\"" } else { "" },
+ ),
+ None => (" d=\"\"".to_string(), ""),
};
- if let Some(g) = geom {
- let area = figcraft_render::draw::stroke_area(n, &g, closed);
- let even = n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != figcraft_doc::StrokeAlign::Center);
- let fr = if even { " fill-rule=\"evenodd\"" } else { "" };
- for p in &n.strokes {
- if let Some(a) = self.paint_attr(p, w, h) {
- let el = format!("", matrix(xf), path_d(&area));
- self.body.push_str(&clipped(&paint_clip, el));
- }
+ let _ = write!(self.defs, "");
+ let _ = write!(self.body, "");
+ } else {
+ // The layer as drawn: its alpha or luminance masks.
+ let mid = self.id("mask");
+ let saved = std::mem::take(&mut self.body);
+ let outer = self.mask.replace(c);
+ let clip = self.clip.take();
+ self.node(c, xf, depth + 1);
+ self.clip = clip;
+ self.mask = outer;
+ let mask_body = std::mem::replace(&mut self.body, saved);
+ let alpha = if kind == figcraft_doc::MaskType::Alpha { " style=\"mask-type:alpha\"" } else { "" };
+ let _ = write!(self.defs, "{mask_body}");
+ let _ = write!(self.body, "");
+ }
+ }
+
+ /// The visible strokes of `n` as filled outline paths.
+ fn stroke_paths(&mut self, n: &Node, xf: Affine, outline: Option<&(BezPath, figcraft_render::FillRule)>, closed: bool, paint_clip: &str) {
+ let geom = match &n.kind {
+ NodeKind::BooleanOp { .. } | NodeKind::Text(_) => outline.map(|(p, _)| p.clone()),
+ _ => figcraft_doc::geometry::stroke_geometry(n),
+ };
+ if let Some(g) = geom {
+ let area = figcraft_render::draw::stroke_area(n, &g, closed);
+ let even = n.stroke.sides.is_some() || (n.stroke.dashes.is_empty() && closed && n.stroke.align != figcraft_doc::StrokeAlign::Center);
+ let fr = if even { " fill-rule=\"evenodd\"" } else { "" };
+ for p in &n.strokes {
+ if let Some(a) = self.paint_attr(p, n.width, n.height) {
+ let el = format!("", matrix(xf), path_d(&area));
+ self.body.push_str(&clipped(paint_clip, el));
}
}
}
- self.body.push_str(&grains);
- self.body.push_str("");
}
/// `name` as an `id` not used yet in this file (spaces as underscores; repeats get `_2`…).
diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs
index ca5f0362..8bef740c 100644
--- a/crates/text/src/layout.rs
+++ b/crates/text/src/layout.rs
@@ -322,9 +322,16 @@ pub(crate) fn visual_order(levels: &[u8]) -> Vec {
order
}
-/// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width
-/// pass `None`). `box_height` is used for truncation only.
-pub fn layout(text: &TextData, box_width: Option, box_height: Option) -> TextLayout {
+struct ShapedText {
+ chars: Vec,
+ styles: Vec,
+ faces: Vec>>,
+ fallbacks: Vec>,
+ items: Vec
- ,
+ bidi: Option,
+}
+
+fn shape_text(text: &TextData) -> ShapedText {
let chars: Vec = text.characters.chars().collect();
let spans = text.spans();
let mut styles: Vec = Vec::new();
@@ -365,7 +372,6 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option)
// Shape each span, split by paragraph, bidi run and fallback face.
let bidi = Bidi::of(&text.characters);
let level_at = |i: usize| bidi.as_ref().and_then(|b| b.levels.get(i)).copied().unwrap_or(0);
- let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false);
// A grapheme cluster (emoji sequence, letter with marks) is shaped with one face.
let cluster_start = crate::breaks::grapheme_starts(&text.characters);
let mut items: Vec
- = Vec::with_capacity(chars.len());
@@ -431,40 +437,21 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option)
}
}
- // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the
- // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent
- // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the
- // indent are on the right, the text ends where they start.
- let lists: Vec = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect();
- let markers = list_markers(&lists);
- let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref());
- let hang_list = if text.hanging_list { step } else { 0.0 };
- // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph).
- let indent_px = if text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 };
- let indent_at = |char_idx: usize| {
- let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left };
- if start_aligned { indent_px } else { 0.0 }
- };
- let list_of = |p: usize| lists.get(p).copied().unwrap_or_default();
- // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`).
- let line_indent_of = |p: usize, first: bool, ci: usize| {
- let pl = list_of(p);
- match pl.list {
- ListStyle::None if first => indent_at(ci),
- ListStyle::None => 0.0,
- _ => step * (f64::from(pl.indent) + 1.0) - hang_list,
- }
- };
- // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge
- // of right-aligned and justified ones (left-to-right paragraphs).
- let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified);
- let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified);
+ ShapedText { chars, styles, faces, fallbacks, items, bidi }
+}
- // Line breaking.
- let wrap = match text.auto_resize {
- TextAutoResize::WidthAndHeight => None,
- _ => box_width.filter(|w| w.is_finite() && *w > 0.0),
- };
+fn line_break_after(items: &[Item], end: usize) -> bool {
+ end.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|item| is_line_break(item.ch))
+}
+
+fn break_lines(
+ items: &[Item],
+ wrap: Option,
+ line_indent_of: &impl Fn(usize, bool, usize) -> f64,
+ rtl_para_at: &impl Fn(usize) -> bool,
+ hang_start: bool,
+ hang_end: bool,
+) -> Vec<(usize, usize, bool, usize)> {
let mut lines: Vec<(usize, usize, bool, usize)> = Vec::new(); // item range, paragraph end, paragraph
let mut line_start = 0;
let mut i = 0;
@@ -521,230 +508,49 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option)
}
i += 1;
}
- let break_after = |e: usize| e.checked_sub(1).and_then(|i| items.get(i)).is_some_and(|it| is_line_break(it.ch));
- if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || break_after(l.1)) {
+ if line_start < items.len() || lines.is_empty() || lines.last().is_some_and(|l| l.2 || line_break_after(items, l.1)) {
lines.push((line_start, items.len(), true, para));
}
- // Line metrics and positions.
- let default_style = styles.first().cloned().unwrap_or_default();
- let default_face = faces.first().cloned().flatten();
- let mut out = TextLayout { styles: styles.clone(), ..Default::default() };
- let mut y = 0.0;
- let mut para_start_line = true;
- let max_lines = if text.max_lines > 0 && text.truncates() { text.max_lines as usize } else { usize::MAX };
- // A style's vertical metrics (ascent, descent, line height, cap height) come from its own
- // font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't.
- let metrics = |st_idx: usize| -> [f64; 4] {
- let st = styles.get(st_idx).unwrap_or(&default_style);
- let size = sanitize_size(st.font_size);
- match faces.get(st_idx).and_then(Option::as_ref).or(default_face.as_ref()) {
- Some(f) => {
- let k = size / f.upem;
- [f.ascent * k, f.descent * k, whole_px(st.line_height.resolve(size, natural_ratio(f))), f.cap_height * k]
- }
- None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7],
- }
- };
- // An item's glyph with its pen position at `pen` on the line at `baseline` (none for a space
- // the font has no glyph for).
- let glyph_at = |it: &Item, pen: f64, baseline: f64| {
- let size = styles.get(it.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * it.scale;
- (it.gid != 0 || !it.is_space()).then(|| Glyph {
- face: it.face.clone(),
- coords: it.coords.clone(),
- gid: it.gid,
- x: pen + it.dx,
- y: baseline - it.dy,
- size,
- advance: it.adv,
- char_start: it.char_start,
- char_end: it.char_end,
- style: it.style,
- rtl: it.level % 2 == 1,
- warp: Affine::IDENTITY,
- })
- };
- let mut first_cap = 0.0;
- for (li, (s, e, para_end, para)) in lines.iter().enumerate() {
- if li >= max_lines {
- out.truncated = true;
- break;
- }
- // Metrics from the styles present on the line (or the style at the break for empty lines).
- let [mut asc, mut desc, mut lh, mut cap] = [0.0_f64; 4];
- let mut seen = usize::MAX;
- for st in items.get(*s..*e).unwrap_or(&[]).iter().map(|it| it.style) {
- if st != seen {
- seen = st;
- let m = metrics(st);
- (asc, desc, lh, cap) = (asc.max(m[0]), desc.max(m[1]), lh.max(m[2]), cap.max(m[3]));
- }
- }
- if seen == usize::MAX {
- [asc, desc, lh, cap] = metrics(items.get(s.saturating_sub(1)).map_or(0, |it| it.style));
- }
- if li == 0 {
- first_cap = cap;
- }
- if para_start_line && li > 0 {
- // Between two items of a list: the list spacing.
- let in_list = |p: usize| list_of(p).list != ListStyle::None;
- let gap = if in_list(*para) && para.checked_sub(1).is_some_and(in_list) { text.list_spacing } else { text.paragraph_spacing };
- y += if gap.is_finite() { gap.clamp(0.0, 10_000.0) } else { 0.0 };
- }
- let baseline = y + (lh - (asc + desc)) / 2.0 + asc;
- // Trailing spaces don't count for width/alignment.
- let mut vis_end = *e;
- while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) {
- vis_end -= 1;
- }
- // Letter spacing separates glyphs: none after the line's last visible one.
- let end_spacing = vis_end.checked_sub(1).filter(|i| *i >= *s).and_then(|i| items.get(i)).map_or(0.0, |it| it.spacing);
- let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum::() - end_spacing;
- let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0);
- let rtl_line = rtl_para_at(first_char);
- let line_indent = line_indent_of(*para, para_start_line, first_char);
- // Hanging punctuation at the line's edges (the visible text's first and last chars).
- let hang_l = items.get(*s).filter(|it| hang_start && !rtl_line && vis_end > *s && opens(it.ch)).map_or(0.0, |it| it.adv);
- let hang_r =
- items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv);
- let gstart = out.glyphs.len();
- // Justify: extra space per gap.
- let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count();
- let justify_extra = match (text.align_h, wrap) {
- (TextAlignH::Justified, Some(maxw)) if !para_end && !break_after(*e) && gaps > 0 => {
- ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0)
- }
- _ => 0.0,
- };
- // Where the visible text ends once justified.
- let text_end = width + justify_extra * gaps as f64;
- // A right-to-left line's trailing spaces sit at its visual left (rule L1, below) and hang
- // outside it, so its visible text spans [0, text_end] with the gutter and indent after it.
- let trailing: f64 = items
- .get(vis_end..*e)
- .unwrap_or(&[])
- .iter()
- .filter(|it| !is_line_break(it.ch))
- .map(|it| it.adv + if it.stretches() { justify_extra } else { 0.0 })
- .sum();
- let mut pen = if rtl_line { -trailing } else { line_indent - hang_l };
- // List marker (before the line's text; zero-length char range so carets skip it).
- let pl = list_of(*para);
- if para_start_line && pl.list != ListStyle::None {
- let marker = markers.get(*para).cloned().unwrap_or_default();
- let st_idx = items.get(*s).map(|it| it.style).unwrap_or(0);
- let at = items.get(*s).map(|it| it.char_start).unwrap_or(chars.len());
- // The marker takes the paragraph style's own font (not a fallback the line's first
- // char needed, e.g. for Hebrew), so every bullet and number of a list matches; a
- // nested bullet the font lacks comes from a fallback font.
- let has = |f: &Arc| marker.chars().all(|c| f.has_char(c));
- let own = faces.get(st_idx).cloned().flatten().filter(has);
- let marker_face = match (own, styles.get(st_idx)) {
- (Some(f), Some(st)) => {
- let coords = f.coords(&f.axis_values(st));
- Some((f, coords))
- }
- _ => fallbacks
- .iter()
- .find(|f| has(f))
- .map(|f| (f.clone(), no_coords()))
- .or_else(|| items.get(*s).map(|it| (it.face.clone(), it.coords.clone())))
- .or_else(|| default_face.clone().map(|f| (f, no_coords()))),
- };
- if let (Some(st), Some((face, coords))) = (styles.get(st_idx), marker_face) {
- let size = sanitize_size(st.font_size);
- let k = size / face.upem;
- let numbered = pl.list == ListStyle::Numbered;
- // Right to left, the number reads "1." with its full stop on the left (".1").
- let marker = if rtl_line && numbered { format!(".{}", marker.trim_end_matches('.')) } else { marker };
- let advance = marker_advance(&face, &coords, &marker, size);
- // Numbers end (start, right to left) half an em from the text; bullets sit at the
- // gutter's outer edge.
- let gutter = step * f64::from(pl.indent) - hang_list;
- let mut mx = match (rtl_line, numbered) {
- (false, true) => gutter + (step - size * 0.5 - advance).max(0.0),
- (false, false) => gutter,
- (true, true) => text_end + size * 0.5,
- (true, false) => text_end + (step - advance).max(0.0),
- };
- for c in marker.chars() {
- let gid = face.glyph_for(c);
- let adv = face.advance_at(gid, &coords) * k;
- out.glyphs.push(Glyph {
- face: face.clone(),
- coords: coords.clone(),
- gid,
- x: mx,
- y: baseline,
- size,
- advance: adv,
- char_start: at,
- char_end: at,
- style: st_idx,
- rtl: false,
- warp: Affine::IDENTITY,
- });
- mx += adv;
- }
- }
- }
- // Right-to-left runs are placed in visual order; trailing spaces take the paragraph's
- // direction (rule L1).
- let line_items = items.get(*s..*e).unwrap_or(&[]);
- let order: Vec = if bidi.is_some() {
- let para_level = u8::from(rtl_line);
- let levels: Vec = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect();
- visual_order(&levels)
- } else {
- (0..line_items.len()).collect()
- };
- for it in order.iter().filter_map(|k| line_items.get(*k)) {
- if is_line_break(it.ch) {
- continue;
- }
- out.glyphs.extend(glyph_at(it, pen, baseline));
- pen += it.adv;
- if it.stretches() {
- pen += justify_extra;
- }
- }
- let char_start = items.get(*s).map(|it| it.char_start).unwrap_or_else(|| chars.len());
- let char_end = items.get(e.saturating_sub(1)).map(|it| it.char_end).unwrap_or(char_start).max(char_start);
- out.lines.push(Line {
- glyph_start: gstart,
- glyph_end: out.glyphs.len(),
- char_start,
- char_end,
- top: y,
- height: lh,
- baseline,
- width: width + line_indent - hang_l - hang_r,
- x: 0.0,
- para_end: *para_end,
- break_after: break_after(*e),
- rtl: rtl_line,
- });
- y += lh;
- para_start_line = *para_end;
- }
- out.width = out.lines.iter().map(|l| l.width).fold(0.0, f64::max);
- // Vertical trim: the box starts at the first line's cap height and ends at the last baseline.
- let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none();
- if trim && let Some(first) = out.lines.first() {
- let shift = first.baseline - first_cap;
- for l in &mut out.lines {
- l.top -= shift;
- l.baseline -= shift;
- }
- for g in &mut out.glyphs {
- g.y -= shift;
- }
- }
- let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height };
- out.height = out.lines.last().map_or(y, bottom).max(0.0);
+ lines
+}
+#[derive(Clone, Copy)]
+struct LayoutFinish<'a> {
+ text: &'a TextData,
+ box_width: Option,
+ box_height: Option,
+ wrap: Option,
+ lines: &'a [(usize, usize, bool, usize)],
+ items: &'a [Item],
+ styles: &'a [TextStyle],
+ faces: &'a [Option>],
+ first_cap: f64,
+ flow_height: f64,
+}
+
+fn glyph_at(styles: &[TextStyle], item: &Item, pen: f64, baseline: f64) -> Option {
+ let size = styles.get(item.style).map(|st| sanitize_size(st.font_size)).unwrap_or(12.0) * item.scale;
+ (item.gid != 0 || !item.is_space()).then(|| Glyph {
+ face: item.face.clone(),
+ coords: item.coords.clone(),
+ gid: item.gid,
+ x: pen + item.dx,
+ y: baseline - item.dy,
+ size,
+ advance: item.adv,
+ char_start: item.char_start,
+ char_end: item.char_end,
+ style: item.style,
+ rtl: item.level % 2 == 1,
+ warp: Affine::IDENTITY,
+ })
+}
+
+fn truncate_layout(out: &mut TextLayout, finish: &LayoutFinish<'_>) {
+ let LayoutFinish { text, box_height, wrap, lines, items, styles, .. } = *finish;
+ let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none();
+ let bottom = |line: &Line| if trim { line.baseline } else { line.top + line.height };
// Truncation by height.
if matches!(text.auto_resize, TextAutoResize::Truncate)
&& let Some(h) = box_height
@@ -766,7 +572,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option)
let wrapped_on = last.and_then(|li| Some((out.lines.get(li)?, lines.get(li)?, lines.get(li + 1)?)));
if let (Some(maxw), Some((line, &(_, e, para_end, _), &(ns, ne, _, _)))) = (wrap, wrapped_on)
&& !para_end
- && !break_after(e)
+ && !line_break_after(items, e)
&& !line.rtl
{
let baseline = line.baseline;
@@ -776,7 +582,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option)
break;
}
if !is_line_break(it.ch) {
- out.glyphs.extend(glyph_at(it, pen, baseline));
+ out.glyphs.extend(glyph_at(styles, it, pen, baseline));
pen += it.adv;
}
}
@@ -785,7 +591,7 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option)
l.glyph_end = gend;
}
}
- add_ellipsis(&mut out, wrap);
+ add_ellipsis(out, wrap);
// Carets and selections reach every char the line shows, borrowed ones included.
if let Some(l) = out.lines.last() {
let shown =
@@ -795,6 +601,26 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option)
}
}
}
+}
+
+fn finish_layout(out: &mut TextLayout, finish: LayoutFinish<'_>) {
+ let LayoutFinish { text, box_width, faces, first_cap, flow_height, .. } = finish;
+ // Vertical trim: the box starts at the first line's cap height and ends at the last baseline.
+ let trim = text.leading_trim == LeadingTrim::CapHeight && text.path.is_none();
+ if trim && let Some(first) = out.lines.first() {
+ let shift = first.baseline - first_cap;
+ for l in &mut out.lines {
+ l.top -= shift;
+ l.baseline -= shift;
+ }
+ for g in &mut out.glyphs {
+ g.y -= shift;
+ }
+ }
+ let bottom = |l: &Line| if trim { l.baseline } else { l.top + l.height };
+ out.height = out.lines.last().map_or(flow_height, bottom).max(0.0);
+
+ truncate_layout(out, &finish);
// Horizontal alignment within the box.
let align_width = match text.auto_resize {
@@ -891,8 +717,240 @@ pub fn layout(text: &TextData, box_width: Option, box_height: Option)
out.decorations = decos;
out.styles.extend(deco_styles);
if let Some(tp) = &text.path {
- crate::onpath::place(&mut out, tp, text.align_h);
+ crate::onpath::place(out, tp, text.align_h);
}
+}
+
+/// Lay out `text` for a box of `box_width` (used when the text wraps or aligns; for auto width
+/// pass `None`). `box_height` is used for truncation only.
+pub fn layout(text: &TextData, box_width: Option, box_height: Option) -> TextLayout {
+ let ShapedText { chars, styles, faces, fallbacks, items, bidi } = shape_text(text);
+ let rtl_para_at = |i: usize| bidi.as_ref().and_then(|b| b.rtl_para.get(i)).copied().unwrap_or(false);
+
+ // List markers sit in a gutter (one more per nesting level) and wrapped lines hang at the
+ // text start; a hanging list puts the outermost gutter outside the box. Paragraph indent
+ // applies to plain paragraphs only. Right-to-left paragraphs mirror both: the gutter and the
+ // indent are on the right, the text ends where they start.
+ let lists: Vec = (0..text.paragraph_count()).map(|p| text.para_list(p)).collect();
+ let markers = list_markers(&lists);
+ let step = list_step(&lists, &markers, styles.first(), faces.first().cloned().flatten().as_ref());
+ let hang_list = if text.hanging_list { step } else { 0.0 };
+ // Paragraph indent applies to start-aligned text only (left, or right for a right-to-left paragraph).
+ let indent_px = if text.paragraph_indent.is_finite() { text.paragraph_indent.clamp(0.0, 10_000.0) } else { 0.0 };
+ let indent_at = |char_idx: usize| {
+ let start_aligned = if rtl_para_at(char_idx) { text.align_h == TextAlignH::Right } else { text.align_h == TextAlignH::Left };
+ if start_aligned { indent_px } else { 0.0 }
+ };
+ let list_of = |p: usize| lists.get(p).copied().unwrap_or_default();
+ // The start indent of a line of paragraph `p` (`first`: its first line, starting at char `ci`).
+ let line_indent_of = |p: usize, first: bool, ci: usize| {
+ let pl = list_of(p);
+ match pl.list {
+ ListStyle::None if first => indent_at(ci),
+ ListStyle::None => 0.0,
+ _ => step * (f64::from(pl.indent) + 1.0) - hang_list,
+ }
+ };
+ // Hanging punctuation: at the start edge of left-aligned and justified lines, at the end edge
+ // of right-aligned and justified ones (left-to-right paragraphs).
+ let hang_start = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Left | TextAlignH::Justified);
+ let hang_end = text.hanging_punctuation && matches!(text.align_h, TextAlignH::Right | TextAlignH::Justified);
+
+ let wrap = match text.auto_resize {
+ TextAutoResize::WidthAndHeight => None,
+ _ => box_width.filter(|w| w.is_finite() && *w > 0.0),
+ };
+ let lines = break_lines(&items, wrap, &line_indent_of, &rtl_para_at, hang_start, hang_end);
+ // Line metrics and positions.
+ let default_style = styles.first().cloned().unwrap_or_default();
+ let default_face = faces.first().cloned().flatten();
+ let mut out = TextLayout { styles: styles.clone(), ..Default::default() };
+ let mut y = 0.0;
+ let mut para_start_line = true;
+ let max_lines = if text.max_lines > 0 && text.truncates() { text.max_lines as usize } else { usize::MAX };
+ // A style's vertical metrics (ascent, descent, line height, cap height) come from its own
+ // font, never a fallback face, so lines mixing scripts keep the spacing of lines that don't.
+ let metrics = |st_idx: usize| -> [f64; 4] {
+ let st = styles.get(st_idx).unwrap_or(&default_style);
+ let size = sanitize_size(st.font_size);
+ match faces.get(st_idx).and_then(Option::as_ref).or(default_face.as_ref()) {
+ Some(f) => {
+ let k = size / f.upem;
+ [f.ascent * k, f.descent * k, whole_px(st.line_height.resolve(size, natural_ratio(f))), f.cap_height * k]
+ }
+ None => [size * 0.8, size * 0.2, whole_px(st.line_height.resolve(size, 1.2)), size * 0.7],
+ }
+ };
+ let mut first_cap = 0.0;
+ for (li, (s, e, para_end, para)) in lines.iter().enumerate() {
+ if li >= max_lines {
+ out.truncated = true;
+ break;
+ }
+ // Metrics from the styles present on the line (or the style at the break for empty lines).
+ let [mut asc, mut desc, mut lh, mut cap] = [0.0_f64; 4];
+ let mut seen = usize::MAX;
+ for st in items.get(*s..*e).unwrap_or(&[]).iter().map(|it| it.style) {
+ if st != seen {
+ seen = st;
+ let m = metrics(st);
+ (asc, desc, lh, cap) = (asc.max(m[0]), desc.max(m[1]), lh.max(m[2]), cap.max(m[3]));
+ }
+ }
+ if seen == usize::MAX {
+ [asc, desc, lh, cap] = metrics(items.get(s.saturating_sub(1)).map_or(0, |it| it.style));
+ }
+ if li == 0 {
+ first_cap = cap;
+ }
+ if para_start_line && li > 0 {
+ // Between two items of a list: the list spacing.
+ let in_list = |p: usize| list_of(p).list != ListStyle::None;
+ let gap = if in_list(*para) && para.checked_sub(1).is_some_and(in_list) { text.list_spacing } else { text.paragraph_spacing };
+ y += if gap.is_finite() { gap.clamp(0.0, 10_000.0) } else { 0.0 };
+ }
+ let baseline = y + (lh - (asc + desc)) / 2.0 + asc;
+ // Trailing spaces don't count for width/alignment.
+ let mut vis_end = *e;
+ while vis_end > *s && items.get(vis_end - 1).is_some_and(|it| it.is_space() || is_line_break(it.ch)) {
+ vis_end -= 1;
+ }
+ // Letter spacing separates glyphs: none after the line's last visible one.
+ let end_spacing = vis_end.checked_sub(1).filter(|i| *i >= *s).and_then(|i| items.get(i)).map_or(0.0, |it| it.spacing);
+ let width: f64 = items.get(*s..vis_end).unwrap_or(&[]).iter().map(|it| it.adv).sum::() - end_spacing;
+ let first_char = items.get(*s).map(|it| it.char_start).unwrap_or(0);
+ let rtl_line = rtl_para_at(first_char);
+ let line_indent = line_indent_of(*para, para_start_line, first_char);
+ // Hanging punctuation at the line's edges (the visible text's first and last chars).
+ let hang_l = items.get(*s).filter(|it| hang_start && !rtl_line && vis_end > *s && opens(it.ch)).map_or(0.0, |it| it.adv);
+ let hang_r =
+ items.get(vis_end.wrapping_sub(1)).filter(|it| hang_end && !rtl_line && vis_end > *s + 1 && closes(it.ch)).map_or(0.0, |it| it.adv);
+ let gstart = out.glyphs.len();
+ // Justify: extra space per gap.
+ let gaps = items.get(*s..vis_end).unwrap_or(&[]).iter().filter(|it| it.stretches()).count();
+ let justify_extra = match (text.align_h, wrap) {
+ (TextAlignH::Justified, Some(maxw)) if !para_end && !line_break_after(&items, *e) && gaps > 0 => {
+ ((maxw - line_indent + hang_l + hang_r - width) / gaps as f64).max(0.0)
+ }
+ _ => 0.0,
+ };
+ // Where the visible text ends once justified.
+ let text_end = width + justify_extra * gaps as f64;
+ // A right-to-left line's trailing spaces sit at its visual left (rule L1, below) and hang
+ // outside it, so its visible text spans [0, text_end] with the gutter and indent after it.
+ let trailing: f64 = items
+ .get(vis_end..*e)
+ .unwrap_or(&[])
+ .iter()
+ .filter(|it| !is_line_break(it.ch))
+ .map(|it| it.adv + if it.stretches() { justify_extra } else { 0.0 })
+ .sum();
+ let mut pen = if rtl_line { -trailing } else { line_indent - hang_l };
+ // List marker (before the line's text; zero-length char range so carets skip it).
+ let pl = list_of(*para);
+ if para_start_line && pl.list != ListStyle::None {
+ let marker = markers.get(*para).cloned().unwrap_or_default();
+ let st_idx = items.get(*s).map(|it| it.style).unwrap_or(0);
+ let at = items.get(*s).map(|it| it.char_start).unwrap_or(chars.len());
+ // The marker takes the paragraph style's own font (not a fallback the line's first
+ // char needed, e.g. for Hebrew), so every bullet and number of a list matches; a
+ // nested bullet the font lacks comes from a fallback font.
+ let has = |f: &Arc| marker.chars().all(|c| f.has_char(c));
+ let own = faces.get(st_idx).cloned().flatten().filter(has);
+ let marker_face = match (own, styles.get(st_idx)) {
+ (Some(f), Some(st)) => {
+ let coords = f.coords(&f.axis_values(st));
+ Some((f, coords))
+ }
+ _ => fallbacks
+ .iter()
+ .find(|f| has(f))
+ .map(|f| (f.clone(), no_coords()))
+ .or_else(|| items.get(*s).map(|it| (it.face.clone(), it.coords.clone())))
+ .or_else(|| default_face.clone().map(|f| (f, no_coords()))),
+ };
+ if let (Some(st), Some((face, coords))) = (styles.get(st_idx), marker_face) {
+ let size = sanitize_size(st.font_size);
+ let k = size / face.upem;
+ let numbered = pl.list == ListStyle::Numbered;
+ // Right to left, the number reads "1." with its full stop on the left (".1").
+ let marker = if rtl_line && numbered { format!(".{}", marker.trim_end_matches('.')) } else { marker };
+ let advance = marker_advance(&face, &coords, &marker, size);
+ // Numbers end (start, right to left) half an em from the text; bullets sit at the
+ // gutter's outer edge.
+ let gutter = step * f64::from(pl.indent) - hang_list;
+ let mut mx = match (rtl_line, numbered) {
+ (false, true) => gutter + (step - size * 0.5 - advance).max(0.0),
+ (false, false) => gutter,
+ (true, true) => text_end + size * 0.5,
+ (true, false) => text_end + (step - advance).max(0.0),
+ };
+ for c in marker.chars() {
+ let gid = face.glyph_for(c);
+ let adv = face.advance_at(gid, &coords) * k;
+ out.glyphs.push(Glyph {
+ face: face.clone(),
+ coords: coords.clone(),
+ gid,
+ x: mx,
+ y: baseline,
+ size,
+ advance: adv,
+ char_start: at,
+ char_end: at,
+ style: st_idx,
+ rtl: false,
+ warp: Affine::IDENTITY,
+ });
+ mx += adv;
+ }
+ }
+ }
+ // Right-to-left runs are placed in visual order; trailing spaces take the paragraph's
+ // direction (rule L1).
+ let line_items = items.get(*s..*e).unwrap_or(&[]);
+ let order: Vec = if bidi.is_some() {
+ let para_level = u8::from(rtl_line);
+ let levels: Vec = line_items.iter().enumerate().map(|(k, it)| if *s + k >= vis_end { para_level } else { it.level }).collect();
+ visual_order(&levels)
+ } else {
+ (0..line_items.len()).collect()
+ };
+ for it in order.iter().filter_map(|k| line_items.get(*k)) {
+ if is_line_break(it.ch) {
+ continue;
+ }
+ out.glyphs.extend(glyph_at(&styles, it, pen, baseline));
+ pen += it.adv;
+ if it.stretches() {
+ pen += justify_extra;
+ }
+ }
+ let char_start = items.get(*s).map(|it| it.char_start).unwrap_or_else(|| chars.len());
+ let char_end = items.get(e.saturating_sub(1)).map(|it| it.char_end).unwrap_or(char_start).max(char_start);
+ out.lines.push(Line {
+ glyph_start: gstart,
+ glyph_end: out.glyphs.len(),
+ char_start,
+ char_end,
+ top: y,
+ height: lh,
+ baseline,
+ width: width + line_indent - hang_l - hang_r,
+ x: 0.0,
+ para_end: *para_end,
+ break_after: line_break_after(&items, *e),
+ rtl: rtl_line,
+ });
+ y += lh;
+ para_start_line = *para_end;
+ }
+ out.width = out.lines.iter().map(|l| l.width).fold(0.0, f64::max);
+ finish_layout(
+ &mut out,
+ LayoutFinish { text, box_width, box_height, wrap, lines: &lines, items: &items, styles: &styles, faces: &faces, first_cap, flow_height: y },
+ );
+
out
}
diff --git a/crates/text/src/tail_tests.rs b/crates/text/src/tail_tests.rs
index 2d0ad22b..712ff6aa 100644
--- a/crates/text/src/tail_tests.rs
+++ b/crates/text/src/tail_tests.rs
@@ -182,8 +182,8 @@ fn hanging_punctuation_outside_the_aligned_edge() {
// quote stays on one line.
let mut t = text("\u{201C}ab cd").unwrap();
t.auto_resize = TextAutoResize::Height;
- let w = layout(&t, None, None).width;
- let room = w - plain.glyphs[0].advance + 0.01;
+ let unwrapped = layout(&t, None, None);
+ let room = unwrapped.width - unwrapped.glyphs[0].advance + 0.01;
assert_eq!(layout(&t, Some(room), None).lines.len(), 2, "without hanging it wraps");
t.hanging_punctuation = true;
assert_eq!(layout(&t, Some(room), None).lines.len(), 1, "with hanging it fits");
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
index c10b402c..8d3521c2 100644
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -2,7 +2,12 @@ use super::clock::{AUTO_SAVE_RETRY_SECS, AUTO_SAVE_SECS, AUTOSAVE_SECS, CJK_CHAN
use super::open::OPEN_EXTENSIONS;
use super::services::{DialogFuture, PickFuture, Picked, RepaintWaker, Services};
use super::state::{LEFT_PANEL, LeftTab, MOBILE_STRIP, MobilePanel, NARROW_WIDTH, PANEL_DEFAULT, RIGHT_PANEL, RightTab, TitleBar, fit_panels};
-use super::*;
+use super::{
+ Arc, Done, FigApp, NodeId, OsStr, Path, PathBuf, PendingAck, PendingDialog, Poll, Session, TaskContext, UiState, Value, Waker, a11y, branch_ui,
+ canvas, cjk, comments, cursors, dev_compare, dialogs, frame_stats, home, home_recents, i18n, icons, import_report, json, layers, live, menus,
+ missing_fonts, online, plugins_ui, present, render_worker, right, selection_spacing, shortcuts, style_picker, tabs, theme, toolbar, ui_scale,
+ updates_ui, variable_ui, versions, widgets, windows,
+};
mod clipboard;
mod commands;
diff --git a/crates/ui-egui/src/app/clipboard.rs b/crates/ui-egui/src/app/clipboard.rs
index 2280f9a1..c54e5f94 100644
--- a/crates/ui-egui/src/app/clipboard.rs
+++ b/crates/ui-egui/src/app/clipboard.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::FigApp;
impl FigApp {
/// Put `text` on the system clipboard. Input injected through the control channel never
diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs
index 38adf660..4ccb8939 100644
--- a/crates/ui-egui/src/app/commands.rs
+++ b/crates/ui-egui/src/app/commands.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Done, FigApp, LeftTab, MobilePanel, NodeId, PANEL_DEFAULT, RightTab, Value, comments, dev_compare, dialogs, home, i18n, import_report, json,
+ layers, live, menus, missing_fonts, plugins_ui, present, tabs, updates_ui, versions,
+};
impl FigApp {
/// Dismiss the composer after a successful switch away from the Comment tool.
diff --git a/crates/ui-egui/src/app/control.rs b/crates/ui-egui/src/app/control.rs
index bbbe9c61..7fd8f139 100644
--- a/crates/ui-egui/src/app/control.rs
+++ b/crates/ui-egui/src/app/control.rs
@@ -1,4 +1,6 @@
-use super::*;
+use super::{
+ FigApp, PendingAck, Value, cursors, dev_compare, home_recents, import_report, json, live, missing_fonts, online, present, tabs, windows,
+};
impl FigApp {
/// Queue raw input injected through the control channel for the window drawing next, tagged
diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs
index ae17b7bc..532cf8ab 100644
--- a/crates/ui-egui/src/app/files.rs
+++ b/crates/ui-egui/src/app/files.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ AUTO_SAVE_RETRY_SECS, AUTO_SAVE_SECS, AUTOSAVE_SECS, Arc, DialogFuture, FigApp, OPEN_EXTENSIONS, OsStr, Path, PathBuf, PendingDialog, PickFuture,
+ Picked, Poll, RepaintWaker, Services, TaskContext, Value, Waker, dialogs, i18n, json, repaint_in, windows,
+};
impl FigApp {
pub fn save_as_dialog(&mut self) {
diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs
index 96225f20..c44d457d 100644
--- a/crates/ui-egui/src/app/frame.rs
+++ b/crates/ui-egui/src/app/frame.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Arc, CJK_CHANGES, FigApp, LEFT_PANEL, MobilePanel, PANEL_DEFAULT, RIGHT_PANEL, RightTab, TOAST_SECS, ToastClock, Value, a11y, cjk, i18n, icons,
+ json, repaint_in, theme,
+};
impl FigApp {
/// Show what `command params` would do on the canvas as a live preview (`text.preview`) while
diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs
index df0a7fd2..803e6230 100644
--- a/crates/ui-egui/src/app/ime.rs
+++ b/crates/ui-egui/src/app/ime.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{FigApp, shortcuts};
impl FigApp {
/// The pass's one IME observation: the focused on-canvas editor, or an explicit idle.
diff --git a/crates/ui-egui/src/canvas/builder.rs b/crates/ui-egui/src/canvas/builder.rs
index bc2360da..779a580a 100644
--- a/crates/ui-egui/src/canvas/builder.rs
+++ b/crates/ui-egui/src/canvas/builder.rs
@@ -1,6 +1,6 @@
use super::outline::stroke;
use super::overlays::accent;
-use super::*;
+use super::{Color32, FigApp, Pos2, Shape, Tokens, pos2};
/// Width of the lines hatching the hovered and selected regions in vector edit mode (screen px).
const REGION_HATCH_WIDTH: f32 = 1.4;
diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs
index fc518d2f..a6bc55b5 100644
--- a/crates/ui-egui/src/canvas/input.rs
+++ b/crates/ui-egui/src/canvas/input.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{FigApp, Point, PointerKind, Pos2, Rect, Ui, json, pointer_event};
/// The zoom tool while Z is held (Alt: zoom out): a click zooms a step about the pointer, a drag
/// zooms to the dragged area. Takes the pointer from the other tools while it's active.
@@ -25,30 +25,7 @@ fn zoom_tool(app: &mut FigApp, ui: &Ui, resp: &egui::Response, pos: Pos2, mods:
true
}
-/// The pointer and keys on the canvas. False when something on it with a cursor of its own has
-/// the pointer (a comment pin, a ruler guide, a gradient or padding handle): the tool's cursor
-/// then doesn't show.
-pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
- if crate::proto_canvas::input(app, ui, resp) {
- // A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard.
- app.ui.pages_focus = None;
- return true;
- }
- let ctx = ui.ctx().clone();
- // Two fingers pan as well as pinch (a touch screen has no scroll wheel).
- let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| {
- let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |m| m.translation_delta);
- (i.modifiers, i.smooth_scroll_delta + pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))
- });
- let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
- let typing_elsewhere = crate::a11y::field_focused(&ctx);
- app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
- if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
- return false;
- }
- // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]);
- // a dropped document opens as a file of its own, as on the home screen. Dev Mode imports
- // nothing into the design, and says so once for the whole drop.
+fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option) {
let dropped = ctx.input(|i| i.raw.dropped_files.clone());
let mut refused = false;
for f in dropped {
@@ -75,6 +52,33 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo
};
app.ui.pending_opens.push(crate::pending_open::PendingOpen { label: name, params, command: "file.import", target: Some(target), read });
}
+}
+
+/// The pointer and keys on the canvas. False when something on it with a cursor of its own has
+/// the pointer (a comment pin, a ruler guide, a gradient or padding handle): the tool's cursor
+/// then doesn't show.
+pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool {
+ if crate::proto_canvas::input(app, ui, resp) {
+ // A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard.
+ app.ui.pages_focus = None;
+ return true;
+ }
+ let ctx = ui.ctx().clone();
+ // Two fingers pan as well as pinch (a touch screen has no scroll wheel).
+ let (mods, scroll, zoom_delta, hover_pos, space) = ctx.input(|i| {
+ let pan = i.multi_touch().map_or(egui::Vec2::ZERO, |m| m.translation_delta);
+ (i.modifiers, i.smooth_scroll_delta + pan, i.zoom_delta(), i.pointer.hover_pos(), i.key_down(egui::Key::Space))
+ });
+ let editing_text = app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false);
+ let typing_elsewhere = crate::a11y::field_focused(&ctx);
+ app.canvas.space_pan = space && !editing_text && !typing_elsewhere;
+ if !app.canvas.dragging && crate::selection_spacing::input(app, &ctx, hover_pos, resp.hovered(), app.canvas.space_pan) {
+ return false;
+ }
+ // Dropped files import onto the canvas, behind a card while they do ([`crate::pending_open`]);
+ // a dropped document opens as a file of its own, as on the home screen. Dev Mode imports
+ // nothing into the design, and says so once for the whole drop.
+ import_dropped(app, &ctx, hover_pos);
let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else {
// A drag released off the canvas, or off the window, ends where the pointer was last seen.
if app.canvas.dragging && !ctx.input(|i| i.pointer.primary_down()) {
diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs
index 58f5fd20..e5a589d8 100644
--- a/crates/ui-egui/src/canvas/outline.rs
+++ b/crates/ui-egui/src/canvas/outline.rs
@@ -1,4 +1,6 @@
-use super::*;
+use super::{
+ Arc, COMPONENT, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Point, Pos2, Rect, Shape, Stroke, pos2, regular, semibold, tr, vec2,
+};
pub(super) fn stroke(c: Color32, w: f32) -> Stroke {
Stroke::new(w, c)
diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs
index 675427c1..d88d6ef6 100644
--- a/crates/ui-egui/src/canvas/overlays.rs
+++ b/crates/ui-egui/src/canvas/overlays.rs
@@ -1,7 +1,10 @@
use super::builder::{builder_overlay, hatched, region_hatch};
use super::outline::{TextOnScreen, draw_outline, node_quad, outline_colour, quad, slot_badges, stroke, swap_highlight};
use super::rulers::{boards, fmt, rulers};
-use super::*;
+use super::{
+ Align2, COMPONENT, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Point, Pos2, Rect, Shape, Stroke, StrokeKind, Tokens, ToolId,
+ doc_c32, pos2, selection_box, selection_frame, semibold, tr, vec2,
+};
/// Everything drawn over the rendered page; returns the frame labels drawn (frame, corners on the
/// page) for the engine to hit-test.
@@ -15,22 +18,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
let view = ds.view.clone();
// Pixel grid at high zoom.
if view.pixel_grid_shown() {
- let r = app.canvas.rect;
- let p0 = app.to_page(r.min);
- let p1 = app.to_page(r.max);
- let col = Color32::from_black_alpha(18);
- let mut x = p0.x.floor();
- while x <= p1.x {
- let s = app.to_screen(x, 0.0).x;
- painter.line_segment([pos2(s, r.min.y), pos2(s, r.max.y)], stroke(col, 1.0));
- x += 1.0;
- }
- let mut y = p0.y.floor();
- while y <= p1.y {
- let s = app.to_screen(0.0, y).y;
- painter.line_segment([pos2(r.min.x, s), pos2(r.max.x, s)], stroke(col, 1.0));
- y += 1.0;
- }
+ pixel_grid(app, painter);
}
// Layout grids and frame labels for top-level frames (also those in sections); the focus view
// labels only its frame.
@@ -44,20 +32,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
continue;
}
if view.layout_grids && !n.grids.is_empty() && figcraft_layout::grids_shown(&doc, *c) {
- let w = doc.world_transform(*c);
- for g in n.grids.iter().filter(|g| g.visible) {
- let col = doc_c32(painter.ctx(), g.color);
- for r in figcraft_layout::grid_lines(g, n.width, n.height) {
- let a = w * Point::new(r.x0, r.y0);
- let b = w * Point::new(r.x1, r.y1);
- let (sa, sb) = (app.to_screen(a.x, a.y), app.to_screen(b.x, b.y));
- if g.kind == figcraft_doc::GridKind::Grid {
- painter.line_segment([sa, sb], stroke(col, 1.0));
- } else {
- painter.rect_filled(Rect::from_two_pos(sa, sb), CornerRadius::ZERO, col);
- }
- }
- }
+ layout_grids(app, painter, &doc, *c, n);
}
// The focus view's bar names its frame.
// Board objects (stickies, shapes with text, stamps) are frames without a name label.
@@ -85,19 +60,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
}
// Mask outlines (a mask doesn't draw itself), frame outlines and slices, as the View menu shows.
if view.mask_outlines || view.frame_outlines || view.show_slices {
- for id in doc.subtree(page) {
- let Some(n) = doc.node(id).filter(|n| n.visible) else { continue };
- if view.mask_outlines && n.is_mask {
- draw_outline(app, painter, &doc, id, stroke(accent(painter), 1.0));
- } else if view.frame_outlines && n.kind.is_frame_like() {
- draw_outline(app, painter, &doc, id, stroke(t.text_secondary, 1.0));
- } else if view.show_slices
- && matches!(n.kind, NodeKind::Slice)
- && let Some(q) = node_quad(app, &doc, id)
- {
- painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(t.text_secondary, 1.0), 4.0, 3.0));
- }
- }
+ view_outlines(app, painter, t, ds, &doc);
}
// Drop target / insertion indicator.
let ov = &app.session.tool.overlay;
@@ -148,82 +111,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
// Selection.
let editing = ds.text_edit.clone();
if !sel.is_empty() && editing.is_none() && ds.vector_edit.is_none() {
- let col = if sel.iter().any(|i| outline_colour(&doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) };
- for id in &sel {
- draw_outline(app, painter, &doc, *id, stroke(col, 1.0));
- }
- // The shape builder reshapes by regions: no transform box.
- if app.session.tool.tool != ToolId::ShapeBuilder
- && let Some(b) = selection_box(&doc, &sel)
- && let Some((_, w, h)) = selection_frame(&doc, &sel)
- {
- let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line));
- // A selected connector's handles are its two ends (drag one onto another object).
- let connector = figcraft_engine::board::selected_connector_ends(&doc, &sel).map(|(_, ends)| ends);
- let mut label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) };
- let scale = app.session.tool.overlay.scale;
- if let Some(hint) = scale {
- label = format!("{label} · {}x", fmt(hint.factor.abs()));
- }
- // A layer smaller than its handles on screen shows just its outline (they still answer
- // just outside it).
- let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX;
- // Measuring to another layer (Alt held) shows just the outlines: no handles or size.
- let measuring = app.session.tool.overlay.measure_box.is_some();
- // Locked layers (selected in the layers panel) can't be resized, nor anything in Dev
- // Mode: no handles either.
- let handles =
- if measuring || app.session.tool.dev_mode || connector.is_some() || sel.iter().any(|i| figcraft_engine::tools::locked_in(&doc, *i)) {
- 0
- } else if is_line {
- 2
- } else if tiny {
- 0
- } else {
- 4
- };
- size_label = transform_box(
- app,
- painter,
- b.map(|p| app.to_screen(p.x, p.y)),
- col,
- sel.len() > 1,
- handles,
- (!measuring && connector.is_none()).then_some(label),
- );
- for end in connector.into_iter().flatten().filter(|_| !app.session.tool.dev_mode) {
- let c = app.to_screen(end.x, end.y);
- painter.circle_filled(c, 5.0, Color32::WHITE);
- painter.circle_stroke(c, 5.0, stroke(col, 1.5));
- }
- if !measuring {
- arc_overlay(app, painter, col);
- shape_overlay(app, painter, col);
- }
- if app.session.tool.tool == ToolId::Scale {
- // The point that stays put: the dragged pivot, else the anchor.
- let pivot = scale
- .map(|h| Point::new(h.pivot[0], h.pivot[1]))
- .or_else(|| figcraft_engine::tools::scale_anchor_point(&doc, &sel, app.session.tool.scale_anchor));
- if let Some(p) = pivot {
- let c = app.to_screen(p.x, p.y);
- painter.circle_filled(c, 4.0, Color32::WHITE);
- painter.circle_stroke(c, 4.0, stroke(col, 1.5));
- painter.circle_filled(c, 1.5, col);
- }
- }
- // The rotation origin while it shows (Alt+R): a ring with a cross through it.
- if let Some(o) = ds.origin_handle() {
- let c = app.to_screen(o.x, o.y);
- painter.circle_filled(c, 5.0, Color32::WHITE);
- painter.circle_stroke(c, 5.0, stroke(col, 1.5));
- for d in [vec2(1.0, 0.0), vec2(0.0, 1.0)] {
- painter.line_segment([c - d * 10.0, c - d * 5.0], stroke(col, 1.5));
- painter.line_segment([c + d * 5.0, c + d * 10.0], stroke(col, 1.5));
- }
- painter.circle_filled(c, 1.5, col);
- }
- }
+ size_label = selection_overlay(app, painter, ds, &doc, &sel, zoom);
}
if app.session.tool.tool == ToolId::ShapeBuilder
&& let Some(arr) = figcraft_engine::shapebuilder::current(&app.session)
@@ -231,43 +119,8 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
builder_overlay(app, painter, t, &arr);
}
// Text editing: caret and selection, or the IME composition underlined at the caret.
- let preedit = app.preedit_view();
- let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(&doc);
- if let Some(te) = editing
- && let Some(text) = TextOnScreen::of(edoc, te.id)
- {
- let layout = &text.layout;
- if let Some(q) = node_quad(app, edoc, te.id) {
- quad(painter, q, stroke(accent(painter), 1.0));
- }
- let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor));
- if a != b && preedit.is_none() {
- for q in text.selection(app, a, b) {
- painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE));
- }
- }
- if let Some(p) = preedit {
- for r in layout.selection_rects(p.range.0, p.range.1) {
- // Underline just below the baseline, across the composed chars only.
- let Some(line) = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6) else { continue };
- let x1 = layout
- .glyphs
- .get(line.glyph_start..line.glyph_end)
- .unwrap_or(&[])
- .iter()
- .filter(|g| g.char_end > g.char_start && g.char_start >= p.range.0 && g.char_end <= p.range.1)
- .map(|g| g.x + g.advance)
- .fold(r.x0, f64::max);
- let y = line.baseline + (line.top + line.height - line.baseline) * 0.35;
- painter.line_segment([text.at(app, r.x0, y), text.at(app, x1, y)], stroke(t.text, 1.0));
- }
- }
- let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);
- let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0;
- if blink || a != b {
- painter.line_segment(text.caret(app, caret, zoom), stroke(t.text, 1.0));
- }
- painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
+ if let Some(te) = editing {
+ text_edit_overlay(app, painter, t, &doc, &te, zoom);
}
// Smart guides and measurements (their labels kept clear of the size label).
crate::measure_overlay::draw(app, painter, t, size_label);
@@ -278,6 +131,198 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
painter.rect_stroke(r, CornerRadius::ZERO, stroke(accent(painter), 1.0), StrokeKind::Inside);
}
// Pen path.
+ pen_overlay(app, painter, zoom);
+ // Eraser brush (vector edit mode).
+ if let Some([x, y, r]) = ov.brush {
+ let c = app.to_screen(x, y);
+ let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32;
+ painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0));
+ painter.circle_stroke(c, rs, stroke(accent(painter), 1.5));
+ }
+ // Lasso (vector edit mode).
+ if ov.lasso.len() > 1 {
+ let mut pts: Vec = ov.lasso.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
+ if let Some(first) = pts.first().copied() {
+ pts.push(first);
+ }
+ painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0));
+ }
+ // Guides (under the rulers).
+ crate::guides::draw(app, painter);
+ // Rulers.
+ if view.rulers {
+ rulers(app, painter, t, &doc, &sel);
+ }
+ labels
+}
+
+/// Pixel grid at high zoom.
+fn pixel_grid(app: &FigApp, painter: &egui::Painter) {
+ let r = app.canvas.rect;
+ let p0 = app.to_page(r.min);
+ let p1 = app.to_page(r.max);
+ let col = Color32::from_black_alpha(18);
+ let mut x = p0.x.floor();
+ while x <= p1.x {
+ let s = app.to_screen(x, 0.0).x;
+ painter.line_segment([pos2(s, r.min.y), pos2(s, r.max.y)], stroke(col, 1.0));
+ x += 1.0;
+ }
+ let mut y = p0.y.floor();
+ while y <= p1.y {
+ let s = app.to_screen(0.0, y).y;
+ painter.line_segment([pos2(r.min.x, s), pos2(r.max.x, s)], stroke(col, 1.0));
+ y += 1.0;
+ }
+}
+
+/// The layout grids of frame `c` (`n`).
+fn layout_grids(app: &FigApp, painter: &egui::Painter, doc: &Document, c: NodeId, n: &figcraft_doc::Node) {
+ let w = doc.world_transform(c);
+ for g in n.grids.iter().filter(|g| g.visible) {
+ let col = doc_c32(painter.ctx(), g.color);
+ for r in figcraft_layout::grid_lines(g, n.width, n.height) {
+ let a = w * Point::new(r.x0, r.y0);
+ let b = w * Point::new(r.x1, r.y1);
+ let (sa, sb) = (app.to_screen(a.x, a.y), app.to_screen(b.x, b.y));
+ if g.kind == figcraft_doc::GridKind::Grid {
+ painter.line_segment([sa, sb], stroke(col, 1.0));
+ } else {
+ painter.rect_filled(Rect::from_two_pos(sa, sb), CornerRadius::ZERO, col);
+ }
+ }
+ }
+}
+
+fn text_edit_overlay(app: &FigApp, painter: &egui::Painter, t: &Tokens, doc: &Document, te: &figcraft_engine::TextEdit, zoom: f64) {
+ let preedit = app.preedit_view();
+ let edoc: &Document = preedit.map(|p| &*p.doc).unwrap_or(doc);
+ let Some(text) = TextOnScreen::of(edoc, te.id) else { return };
+ let layout = &text.layout;
+ if let Some(q) = node_quad(app, edoc, te.id) {
+ quad(painter, q, stroke(accent(painter), 1.0));
+ }
+ let (a, b) = (te.caret.min(te.anchor), te.caret.max(te.anchor));
+ if a != b && preedit.is_none() {
+ for q in text.selection(app, a, b) {
+ painter.add(Shape::convex_polygon(q.to_vec(), accent(painter).gamma_multiply(0.3), Stroke::NONE));
+ }
+ }
+ if let Some(p) = preedit {
+ for r in layout.selection_rects(p.range.0, p.range.1) {
+ // Underline just below the baseline, across the composed chars only.
+ let Some(line) = layout.lines.iter().find(|l| (l.top - r.y0).abs() < 1e-6) else { continue };
+ let x1 = layout
+ .glyphs
+ .get(line.glyph_start..line.glyph_end)
+ .unwrap_or(&[])
+ .iter()
+ .filter(|g| g.char_end > g.char_start && g.char_start >= p.range.0 && g.char_end <= p.range.1)
+ .map(|g| g.x + g.advance)
+ .fold(r.x0, f64::max);
+ let y = line.baseline + (line.top + line.height - line.baseline) * 0.35;
+ painter.line_segment([text.at(app, r.x0, y), text.at(app, x1, y)], stroke(t.text, 1.0));
+ }
+ }
+ let caret = preedit.map(|p| p.cursor).unwrap_or(te.caret);
+ let blink = (painter.ctx().input(|i| i.time) * 2.0) as i64 % 2 == 0;
+ if blink || a != b {
+ painter.line_segment(text.caret(app, caret, zoom), stroke(t.text, 1.0));
+ }
+ painter.ctx().request_repaint_after(std::time::Duration::from_millis(500));
+}
+
+/// The selection's outlines, transform box and handles; returns the size label's box.
+fn selection_overlay(
+ app: &FigApp,
+ painter: &egui::Painter,
+ ds: &figcraft_engine::DocState,
+ doc: &Document,
+ sel: &[NodeId],
+ zoom: f64,
+) -> Option {
+ let mut size_label = None;
+ let col = if sel.iter().any(|i| outline_colour(doc, *i, accent(painter)) == COMPONENT) { COMPONENT } else { accent(painter) };
+ for id in sel {
+ draw_outline(app, painter, doc, *id, stroke(col, 1.0));
+ }
+ // The shape builder reshapes by regions: no transform box.
+ if app.session.tool.tool != ToolId::ShapeBuilder
+ && let Some(b) = selection_box(doc, sel)
+ && let Some((_, w, h)) = selection_frame(doc, sel)
+ {
+ let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line));
+ // A selected connector's handles are its two ends (drag one onto another object).
+ let connector = figcraft_engine::board::selected_connector_ends(doc, sel).map(|(_, ends)| ends);
+ let mut label = if is_line { fmt(w) } else { format!("{} × {}", fmt(w), fmt(h)) };
+ let scale = app.session.tool.overlay.scale;
+ if let Some(hint) = scale {
+ label = format!("{label} · {}x", fmt(hint.factor.abs()));
+ }
+ // A layer smaller than its handles on screen shows just its outline (they still answer
+ // just outside it).
+ let tiny = figcraft_engine::tools::box_sides_px(&b, zoom).1 < figcraft_engine::tools::SMALL_BOX_PX;
+ // Measuring to another layer (Alt held) shows just the outlines: no handles or size.
+ let measuring = app.session.tool.overlay.measure_box.is_some();
+ // Locked layers (selected in the layers panel) can't be resized, nor anything in Dev
+ // Mode: no handles either.
+ let handles =
+ if measuring || app.session.tool.dev_mode || connector.is_some() || sel.iter().any(|i| figcraft_engine::tools::locked_in(doc, *i)) {
+ 0
+ } else if is_line {
+ 2
+ } else if tiny {
+ 0
+ } else {
+ 4
+ };
+ size_label = transform_box(
+ app,
+ painter,
+ b.map(|p| app.to_screen(p.x, p.y)),
+ col,
+ sel.len() > 1,
+ handles,
+ (!measuring && connector.is_none()).then_some(label),
+ );
+ for end in connector.into_iter().flatten().filter(|_| !app.session.tool.dev_mode) {
+ let c = app.to_screen(end.x, end.y);
+ painter.circle_filled(c, 5.0, Color32::WHITE);
+ painter.circle_stroke(c, 5.0, stroke(col, 1.5));
+ }
+ if !measuring {
+ arc_overlay(app, painter, col);
+ shape_overlay(app, painter, col);
+ }
+ if app.session.tool.tool == ToolId::Scale {
+ // The point that stays put: the dragged pivot, else the anchor.
+ let pivot = scale
+ .map(|h| Point::new(h.pivot[0], h.pivot[1]))
+ .or_else(|| figcraft_engine::tools::scale_anchor_point(doc, sel, app.session.tool.scale_anchor));
+ if let Some(p) = pivot {
+ let c = app.to_screen(p.x, p.y);
+ painter.circle_filled(c, 4.0, Color32::WHITE);
+ painter.circle_stroke(c, 4.0, stroke(col, 1.5));
+ painter.circle_filled(c, 1.5, col);
+ }
+ }
+ // The rotation origin while it shows (Alt+R): a ring with a cross through it.
+ if let Some(o) = ds.origin_handle() {
+ let c = app.to_screen(o.x, o.y);
+ painter.circle_filled(c, 5.0, Color32::WHITE);
+ painter.circle_stroke(c, 5.0, stroke(col, 1.5));
+ for d in [vec2(1.0, 0.0), vec2(0.0, 1.0)] {
+ painter.line_segment([c - d * 10.0, c - d * 5.0], stroke(col, 1.5));
+ painter.line_segment([c + d * 5.0, c + d * 10.0], stroke(col, 1.5));
+ }
+ painter.circle_filled(c, 1.5, col);
+ }
+ }
+ size_label
+}
+
+fn pen_overlay(app: &FigApp, painter: &egui::Painter, zoom: f64) {
+ let ov = &app.session.tool.overlay;
if !ov.pen.is_empty() {
let pts: Vec = ov.pen.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
if app.session.tool.tool == ToolId::Pencil {
@@ -315,28 +360,24 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec
// A connector being drawn, or one of its ends dragged: from where it starts to the pointer.
painter.line_segment([app.to_screen(x0, y0), app.to_screen(x1, y1)], stroke(accent(painter), 1.5));
}
- // Eraser brush (vector edit mode).
- if let Some([x, y, r]) = ov.brush {
- let c = app.to_screen(x, y);
- let rs = (r * app.session.ds().map(|d| d.view.zoom).unwrap_or(1.0)) as f32;
- painter.circle_stroke(c, rs, stroke(Color32::WHITE, 3.0));
- painter.circle_stroke(c, rs, stroke(accent(painter), 1.5));
- }
- // Lasso (vector edit mode).
- if ov.lasso.len() > 1 {
- let mut pts: Vec = ov.lasso.iter().map(|[x, y]| app.to_screen(*x, *y)).collect();
- if let Some(first) = pts.first().copied() {
- pts.push(first);
+}
+
+/// Mask outlines, frame outlines and slices, as the View menu shows.
+fn view_outlines(app: &FigApp, painter: &egui::Painter, t: &Tokens, ds: &figcraft_engine::DocState, doc: &Document) {
+ let (view, page) = (&ds.view, ds.page);
+ for id in doc.subtree(page) {
+ let Some(n) = doc.node(id).filter(|n| n.visible) else { continue };
+ if view.mask_outlines && n.is_mask {
+ draw_outline(app, painter, doc, id, stroke(accent(painter), 1.0));
+ } else if view.frame_outlines && n.kind.is_frame_like() {
+ draw_outline(app, painter, doc, id, stroke(t.text_secondary, 1.0));
+ } else if view.show_slices
+ && matches!(n.kind, NodeKind::Slice)
+ && let Some(q) = node_quad(app, doc, id)
+ {
+ painter.extend(Shape::dashed_line(&[q[0], q[1], q[2], q[3], q[0]], stroke(t.text_secondary, 1.0), 4.0, 3.0));
}
- painter.add(Shape::dashed_line(&pts, stroke(accent(painter), 1.0), 4.0, 3.0));
}
- // Guides (under the rulers).
- crate::guides::draw(app, painter);
- // Rulers.
- if view.rulers {
- rulers(app, painter, t, &doc, &sel);
- }
- labels
}
/// A transform box (screen corners tl, tr, br, bl): its outline when `outline`, the first `handles`
diff --git a/crates/ui-egui/src/canvas/render.rs b/crates/ui-egui/src/canvas/render.rs
index 915e8ea5..c6c4702f 100644
--- a/crates/ui-egui/src/canvas/render.rs
+++ b/crates/ui-egui/src/canvas/render.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Arc, CanvasState, Done, FigApp, Job, NodeId, Worker};
#[derive(Clone, PartialEq)]
pub(super) struct JobKey {
diff --git a/crates/ui-egui/src/canvas/rulers.rs b/crates/ui-egui/src/canvas/rulers.rs
index a394ca7c..b43bcc5b 100644
--- a/crates/ui-egui/src/canvas/rulers.rs
+++ b/crates/ui-egui/src/canvas/rulers.rs
@@ -1,6 +1,6 @@
use super::outline::stroke;
use super::overlays::accent;
-use super::*;
+use super::{Align2, Color32, CornerRadius, Document, FigApp, NodeId, NodeKind, Pos2, Rect, Tokens, pos2, regular, semibold, tr, vec2};
/// The layers that get canvas labels and layout grids, in z-order: the page's visible top-level
/// layers and, inside sections whose contents show, their visible children (sections nest). A
diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs
index afef387d..23b7a63e 100644
--- a/crates/ui-egui/src/control.rs
+++ b/crates/ui-egui/src/control.rs
@@ -924,6 +924,414 @@ fn b(p: &Value, k: &str) -> bool {
p.get(k).and_then(Value::as_bool).unwrap_or(false)
}
+fn ui_key(app: &mut FigApp, ctx: &Context, p: &Value) -> Result {
+ let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?;
+ let m = figcraft_engine::keymap::Mods { shift: b(p, "shift"), alt: b(p, "alt"), cmd: b(p, "cmd"), ctrl: b(p, "ctrl") };
+ let platform = crate::shortcuts::platform();
+ use figcraft_engine::keymap::{Context, lookup, normalize};
+ // Text editing keys go to the editor (after the shortcuts live while editing).
+ if app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) {
+ if let Some(bd) = lookup(platform, Context::Text, key, m).filter(|_| m.cmd || m.alt || m.ctrl) {
+ crate::menus::invoke(app, bd.id);
+ return Ok(json!({"command": bd.id}));
+ }
+ let (word, line) = figcraft_engine::keymap::text_motion(platform, key, m);
+ figcraft_engine::textedit::key(&mut app.session, key, m.shift, word, line).map_err(|e| e.to_string())?;
+ return Ok(json!({"ok": true}));
+ }
+ // Vector edit mode and shape builder keys (tools, nudging points, delete, join, Enter).
+ if app.session.tool_key(key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())? {
+ return Ok(json!({"ok": true}));
+ }
+ // The Comment tool's arrows step through the comment threads.
+ if app.session.tool.tool == figcraft_engine::ToolId::Comment && m.is_none() && matches!(key, "ArrowUp" | "ArrowDown" | "Up" | "Down") {
+ let r = crate::comments::step(app, ctx, if matches!(key, "ArrowUp" | "Up") { -1 } else { 1 });
+ return Ok(json!({"comment": r.and_then(|v| v.get("id").cloned())}));
+ }
+ if let Some(bd) = lookup(platform, Context::Canvas, key, m) {
+ crate::shortcuts::run_binding(app, bd.id);
+ return Ok(json!({"command": bd.id}));
+ }
+ let (name, _) = normalize(key).ok_or_else(|| format!("unknown key `{key}`"))?;
+ match name {
+ "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => {
+ let (dx, dy) = match name {
+ "ArrowLeft" => (-1, 0),
+ "ArrowRight" => (1, 0),
+ "ArrowUp" => (0, -1),
+ _ => (0, 1),
+ };
+ let (cmd, params) = if m.cmd || m.ctrl {
+ ("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift}))
+ } else {
+ ("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift}))
+ };
+ app.session.execute(cmd, ¶ms).map_err(|e| e.to_string())
+ }
+ _ => {
+ // Number keys: opacity (two in quick succession make an exact percentage). A
+ // hidden window's logic tick has a stale `ctx.input(i.time)`.
+ let now = app.clock.now();
+ match egui::Key::from_name(name) {
+ Some(k) if m.is_none() && crate::shortcuts::opacity_digit(app, k, now) => Ok(json!({"opacity": true})),
+ _ => Err(format!("no shortcut `{key}`")),
+ }
+ }
+ }
+}
+
+fn ui_input(app: &mut FigApp, ctx: &Context, p: &Value) -> Result {
+ let list = p
+ .get("events")
+ .and_then(Value::as_array)
+ .ok_or("`events` required: [{move|down|up: [x, y]} | {key, shift?, alt?, cmd?} | {text} | {leave: true}]")?;
+ let pos = |v: &Value| -> Option {
+ let a = v.as_array()?;
+ let p = egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32);
+ p.is_finite().then_some(p)
+ };
+ let mut events = Vec::new();
+ let mut pads = Vec::new();
+ for e in list.iter().take(256) {
+ let button =
+ if e.get("button").and_then(Value::as_str) == Some("secondary") { egui::PointerButton::Secondary } else { egui::PointerButton::Primary };
+ let command = b(e, "cmd") || b(e, "ctrl");
+ let mods = egui::Modifiers { alt: b(e, "alt"), ctrl: command, shift: b(e, "shift"), mac_cmd: false, command };
+ if let Some(p) = e.get("move").and_then(pos) {
+ events.push(egui::Event::PointerMoved(p));
+ } else if let Some((p, pressed)) = e.get("down").and_then(pos).map(|p| (p, true)).or_else(|| e.get("up").and_then(pos).map(|p| (p, false))) {
+ events.push(egui::Event::PointerMoved(p));
+ events.push(egui::Event::PointerButton { pos: p, button, pressed, modifiers: mods });
+ } else if let Some(k) = e.get("key").and_then(Value::as_str) {
+ let key = egui::Key::from_name(k).ok_or_else(|| format!("unknown key `{k}`"))?;
+ for pressed in [true, false] {
+ events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: mods });
+ }
+ } else if let Some(t) = e.get("text").and_then(Value::as_str) {
+ events.push(egui::Event::Text(t.to_string()));
+ } else if let Some(d) = e.get("scroll").and_then(pos) {
+ // Content moves by `d` points (negative y scrolls down, like a wheel turned towards you).
+ events.push(egui::Event::MouseWheel {
+ unit: egui::MouseWheelUnit::Point,
+ source: egui::MouseWheelSource::Wheel,
+ delta: d.to_vec2(),
+ phase: egui::TouchPhase::Move,
+ modifiers: mods,
+ });
+ } else if let Some(g) = e.get("gamepad") {
+ let names = figcraft_doc::proto::GAMEPAD_BUTTONS.join(", ");
+ pads.push(pad_name(g).ok_or_else(|| format!("unknown controller button {g} (an index 0-16 or one of {names})"))?.to_string());
+ } else if b(e, "leave") {
+ events.push(egui::Event::PointerGone);
+ } else if let Some(h) = e.get("hold") {
+ let held = egui::Modifiers {
+ alt: b(h, "alt"),
+ ctrl: b(h, "cmd") || b(h, "ctrl"),
+ shift: b(h, "shift"),
+ mac_cmd: false,
+ command: b(h, "cmd") || b(h, "ctrl"),
+ };
+ events.push(egui::Event::ModifiersChanged(held));
+ } else {
+ return Err(format!("unknown input event {e}"));
+ }
+ }
+ // Controller buttons go straight to the prototype playing (its next frame takes them).
+ if !pads.is_empty() {
+ match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
+ (Some(p), _) => p.pads.extend(pads),
+ (None, Some(pane)) => pane.player.pads.extend(pads),
+ (None, None) => return Err("controller buttons go to a prototype that is playing: run proto.present or proto.preview".into()),
+ }
+ }
+ app.control_input_queued = app.push_control_input(events);
+ ctx.request_repaint();
+ Ok(json!({"queued": list.len().min(256)}))
+}
+
+fn ui_set_view(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<(), String> {
+ if let Some(t) = p.get("rightTab").and_then(Value::as_str) {
+ app.ui.right_tab = match t {
+ "prototype" => crate::RightTab::Prototype,
+ "inspect" => crate::RightTab::Inspect,
+ _ => crate::RightTab::Design,
+ };
+ }
+ if let Some(t) = p.get("leftTab").and_then(Value::as_str) {
+ app.ui.left_tab = if t == "assets" { crate::LeftTab::Assets } else { crate::LeftTab::File };
+ }
+ if let Some(t) = p.get("theme").and_then(Value::as_str) {
+ app.session.prefs.theme = t.to_string();
+ }
+ if let Some(h) = p.get("home").and_then(Value::as_bool) {
+ app.ui.home = h;
+ }
+ if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) {
+ app.ui.home_view.section = section;
+ }
+ if let Some(query) = p.get("homeSearch").and_then(Value::as_str) {
+ app.ui.home_view.file_query = query.chars().take(256).collect();
+ }
+ if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) {
+ view.store(&mut app.session.prefs);
+ }
+ if let Some(key) = p.get("homeRecentsSort").and_then(Value::as_str).and_then(crate::home_recents::SortKey::parse) {
+ crate::home_recents::Sort::by(key).store(&mut app.session.prefs);
+ }
+ if let Some(order) = p.get("homeRecentsOrder").and_then(Value::as_str).filter(|o| matches!(*o, "asc" | "desc")) {
+ let sort = crate::home_recents::Sort::of(&app.session.prefs);
+ crate::home_recents::Sort { descending: order == "desc", ..sort }.store(&mut app.session.prefs);
+ }
+ if let Some(h) = p.get("palette").and_then(Value::as_bool) {
+ crate::menus::set_palette(&mut app.ui, h);
+ }
+ // true / false, or a top-level menu to open it at ("Plugins").
+ match p.get("mainMenu") {
+ Some(Value::Bool(h)) => app.ui.main_menu = *h,
+ Some(Value::String(top)) => {
+ app.ui.main_menu = true;
+ crate::menus::open_main_menu(ctx, top);
+ }
+ _ => {}
+ }
+ Ok(())
+}
+
+fn ui_set_plugins(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<(), String> {
+ if let Some(h) = p.get("pluginsWindow").and_then(Value::as_bool) {
+ app.ui.plugins_window = h;
+ }
+ // The Plugins window's tab ("installed", "browse"), and the plugin of the listed
+ // directory whose install asks for confirmation (null: none).
+ if let Some(tab) = p.get("pluginsTab").and_then(Value::as_str) {
+ app.ui.plugins_manage.tab = if tab == "browse" { crate::plugins_ui::PluginsTab::Browse } else { crate::plugins_ui::PluginsTab::Installed };
+ }
+ match p.get("pluginConfirm") {
+ Some(Value::String(id)) => app.ui.plugins_manage.confirm = Some(id.clone()),
+ Some(Value::Null) => app.ui.plugins_manage.confirm = None,
+ _ => {}
+ }
+ // A plugin command's parameters form: {id, command?}; null closes it.
+ match p.get("pluginForm") {
+ Some(Value::Null) => app.ui.plugin_form = None,
+ Some(v @ Value::Object(_)) => {
+ let id = v.get("id").and_then(Value::as_str).ok_or("pluginForm: {id, command?}")?;
+ let command = v.get("command").and_then(Value::as_str).unwrap_or_default();
+ crate::plugins_ui::start(app, id, command);
+ }
+ _ => {}
+ }
+ // Give a region the keyboard as F6 would: toolbar, layers, canvas or properties.
+ if let Some(r) = p.get("focusRegion").and_then(Value::as_str) {
+ app.ui.nav.go(crate::a11y::Region::parse(r).ok_or_else(|| format!("unknown region `{r}`"))?);
+ }
+ // A right-click on the canvas at screen [sx, sy] (null closes the menu);
+ // `contextSelectLayer` opens its Select layer submenu.
+ match p.get("contextMenu") {
+ Some(Value::Array(at)) => {
+ let (x, y) = (at.first().and_then(Value::as_f64).unwrap_or(0.0), at.get(1).and_then(Value::as_f64).unwrap_or(0.0));
+ crate::canvas::open_context_menu(app, egui::pos2(x as f32, y as f32), b(p, "cmd"));
+ }
+ Some(Value::Null) => app.ui.context_menu = None,
+ _ => {}
+ }
+ if let Some(v) = p.get("contextSelectLayer").and_then(Value::as_bool) {
+ crate::menus::open_select_layer(ctx, v);
+ }
+ if let Some(h) = p.get("iconGallery").and_then(Value::as_bool) {
+ app.ui.icon_gallery = h;
+ }
+ Ok(())
+}
+
+fn ui_set_comments(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<(), String> {
+ // Comment thread popover: an id opens it, null closes it.
+ if let Some(v) = p.get("commentOpen") {
+ match v.as_u64() {
+ Some(id) => {
+ // Pins are drawn for the current page only: reveal the thread's page first.
+ let page = app.session.doc().ok().and_then(|d| d.comment(id)).map(|c| c.page);
+ if let Some(page) = page.filter(|p| app.session.page().is_ok_and(|cur| cur != *p)) {
+ app.session.execute("page.select", &json!({"id": page.0})).map_err(|e| e.to_string())?;
+ }
+ app.ui.comments.show(id);
+ }
+ None => app.ui.comments.close(),
+ }
+ }
+ if let Some(v) = p.get("commentDraft") {
+ match (v.get("x").and_then(Value::as_f64), v.get("y").and_then(Value::as_f64)) {
+ (Some(x), Some(y)) => crate::comments::start_draft(app, x, y),
+ _ => app.ui.comments.draft = None,
+ }
+ }
+ if let Some(v) = p.get("showResolvedComments").and_then(Value::as_bool) {
+ app.ui.comments.filter = if v { Filter::All } else { Filter::Open };
+ }
+ if let Some(v) = p.get("commentFilter").and_then(Value::as_str) {
+ app.ui.comments.filter = Filter::parse(v).ok_or("commentFilter: open|resolved|mine|unread|all")?;
+ }
+ if let Some(v) = p.get("commentSort").and_then(Value::as_str) {
+ app.ui.comments.sort = Sort::parse(v).ok_or("commentSort: date|page")?;
+ }
+ if let Some(v) = p.get("commentSearch").and_then(Value::as_str) {
+ app.ui.comments.search = v.to_string();
+ }
+ // The open thread's reply field, with the cursor at its end (an `@` there offers names).
+ if let Some(v) = p.get("commentReply").and_then(Value::as_str) {
+ app.ui.comments.reply = v.to_string();
+ if let Some(id) = app.ui.comments.open {
+ crate::comments::focus_reply(ctx, id, v.chars().count());
+ }
+ }
+ // Edit a message of the open thread: its index (0: the first message); null stops.
+ if let Some(v) = p.get("commentEdit") {
+ app.ui.comments.editing = match (app.ui.comments.open, v.as_u64()) {
+ (Some(id), Some(i)) => {
+ let text = app.session.doc().ok().and_then(|d| d.comment(id)).and_then(|c| c.message(i as usize)).map(|m| m.text.clone());
+ Some((id, i as usize, text.ok_or("commentEdit: no such message")?))
+ }
+ _ => None,
+ };
+ }
+ if let Some(v) = p.get("presentComments").and_then(Value::as_bool) {
+ app.ui.present_view.comments = v;
+ if let Some(pres) = app.ui.present.as_mut() {
+ pres.view.comments = v;
+ }
+ }
+ Ok(())
+}
+
+fn ui_set_panels(app: &mut FigApp, ctx: &Context, p: &Value) -> Result<(), String> {
+ // Colour picker on the selection's paint `{fills?, index?}`; null closes it.
+ if let Some(v) = p.get("colorPicker") {
+ app.ui.picker = v.is_object().then(|| crate::colorpicker::PickerTarget::Paint {
+ fills: v.get("fills").and_then(Value::as_bool).unwrap_or(true),
+ index: v.get("index").and_then(Value::as_u64).unwrap_or(0).min(1000) as usize,
+ ids: app.session.selection(),
+ });
+ }
+ // The Variables editor: true / a collection (id or name) / {library, collection?} (an
+ // enabled library's, by id or name) opens it, false closes it.
+ match p.get("variables") {
+ Some(Value::Bool(open)) => app.ui.variables = open.then(Default::default),
+ Some(Value::String(c)) => app.ui.variables = Some(crate::variables_panel::Editor::on(Some(c.clone()))),
+ Some(v @ Value::Object(_)) => {
+ let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
+ app.ui.variables = Some(match text("library") {
+ Some(lib) => crate::variables_panel::Editor::in_library(lib, text("collection")),
+ None => crate::variables_panel::Editor::on(text("collection")),
+ });
+ }
+ _ => {}
+ }
+ // Layers panel search: a string opens it with that filter, null closes it.
+ if let Some(v) = p.get("layerFilter") {
+ app.ui.layer_search = v.is_string();
+ app.ui.layer_filter = v.as_str().unwrap_or_default().chars().take(200).collect();
+ }
+ if p.get("collapseLayers").and_then(Value::as_bool) == Some(true) {
+ app.ui.expanded.clear();
+ }
+ // The interaction editor popover in the Prototype panel: [layer id, index]; null closes it.
+ match p.get("protoEditor") {
+ Some(Value::Null) => crate::proto_panel::open_editor(ctx, None),
+ Some(Value::Array(a)) => {
+ let id = a.first().and_then(Value::as_u64).ok_or("protoEditor: [layer id, interaction index]")?;
+ let k = a.get(1).and_then(Value::as_u64).unwrap_or(0) as usize;
+ crate::proto_panel::open_editor(ctx, Some((figcraft_doc::NodeId(id), k)));
+ }
+ _ => {}
+ }
+ // The prototype preview pane: true / a frame id opens it, false or null closes it.
+ match p.get("preview") {
+ Some(Value::Bool(false) | Value::Null) => app.ui.preview = None,
+ Some(v @ (Value::Bool(true) | Value::Number(_))) => {
+ let params = if v.is_number() { json!({"start": v}) } else { json!({}) };
+ app.session.execute("proto.preview", ¶ms).map_err(|e| e.to_string())?;
+ app.handle_ui_requests();
+ }
+ _ => {}
+ }
+ // Scroll the presented screen (the full-window player, else the preview pane) to [x, y].
+ if let Some(s) = p.get("presentScroll").and_then(Value::as_array) {
+ let at = |i: usize| s.get(i).and_then(Value::as_f64).unwrap_or(0.0);
+ let player = match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
+ (Some(p), _) => Some(p),
+ (None, Some(pane)) => Some(&mut pane.player),
+ _ => None,
+ };
+ player.ok_or("nothing is presented")?.scroll_to(at(0), at(1));
+ }
+ Ok(())
+}
+
+fn ui_set_styles(app: &mut FigApp, p: &Value) -> Result<(), String> {
+ // The style picker: a slot (fill, stroke, effect, grid, text) opens it for the selection, null
+ // closes it; `stylePickerEdit` (a style id or name) opens its editor; `stylePickerView`
+ // is `list` or `grid`.
+ if let Some(v) = p.get("stylePicker") {
+ match v.as_str().and_then(crate::style_picker::StyleSlot::parse) {
+ Some(slot) => crate::style_picker::open_for_selection(app, slot, p.get("stylePickerEdit").and_then(Value::as_str)),
+ None => app.ui.style_picker = None,
+ }
+ }
+ if let Some(v) = p.get("stylePickerView").and_then(Value::as_str) {
+ app.ui.style_grid = v == "grid";
+ }
+ // The Color profile dialog: a profile id opens it with that profile chosen, null closes it.
+ if let Some(v) = p.get("colorProfileDialog") {
+ app.ui.color_profile = v.as_str().and_then(figcraft_color::ColorProfile::parse);
+ }
+ if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) {
+ app.ui.export_thumb = v;
+ }
+ // Dev Mode's compare view: a frame or section opens it, null closes it; `devCompareVersion`
+ // the saved version it compares with (null: when it was marked ready for dev),
+ // `devCompareMode` is sideBySide or overlay, `devCompareSplit` the overlay slider (0–1).
+ if let Some(v) = p.get("devCompare") {
+ app.ui.dev.compare = figcraft_engine::params::id_of(v).map(|id| crate::dev_compare::CompareView::new(id, None));
+ }
+ if let Some(c) = app.ui.dev.compare.as_mut() {
+ if let Some(v) = p.get("devCompareVersion") {
+ c.version = v.as_str().map(str::to_string);
+ }
+ match p.get("devCompareMode").and_then(Value::as_str) {
+ Some("overlay") => c.mode = crate::dev_compare::Mode::Overlay,
+ Some("sideBySide") => c.mode = crate::dev_compare::Mode::SideBySide,
+ Some(_) => return Err("devCompareMode: sideBySide|overlay".into()),
+ None => {}
+ }
+ if let Some(x) = p.get("devCompareSplit").and_then(Value::as_f64).filter(|x| x.is_finite()) {
+ c.split = x.clamp(0.0, 1.0) as f32;
+ }
+ }
+ // The branch review dialog: a branch id opens it, null closes it; `branchReviewPicks`
+ // chooses sides ({: "branch" | "main"}).
+ if let Some(v) = p.get("branchReview") {
+ match v.as_str() {
+ Some(id) => crate::branch_ui::open(app, Some(id)),
+ None => app.ui.branch_review = None,
+ }
+ }
+ if let (Some(r), Some(picks)) = (app.ui.branch_review.as_mut(), p.get("branchReviewPicks").and_then(Value::as_object)) {
+ for (k, v) in picks {
+ let side = v.as_str().and_then(figcraft_engine::branches::Side::parse).ok_or("branchReviewPicks: \"branch\" or \"main\"")?;
+ r.picks.insert(k.clone(), side);
+ }
+ }
+ // Which annotations the canvas shows: all, none (uncategorised) or a category.
+ if let Some(v) = p.get("annotationFilter").and_then(Value::as_str) {
+ let doc = app.session.doc().map_err(|e| e.to_string())?;
+ app.ui.dev.categories = crate::dev_mode::CategoryFilter::parse(doc, v).ok_or("annotationFilter: all|none|a category id or name")?;
+ }
+ if b(p, "resetFrameStats") {
+ app.frame_stats.reset();
+ }
+ Ok(())
+}
+
/// Handle one control method (also used by tests and the headless snapshot tool).
pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Result {
match method {
@@ -972,134 +1380,8 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
Ok(v)
}
"ui.pointer" => pointer(app, p),
- "ui.key" => {
- let key = p.get("key").and_then(Value::as_str).ok_or("`key` required")?;
- let m = figcraft_engine::keymap::Mods { shift: b(p, "shift"), alt: b(p, "alt"), cmd: b(p, "cmd"), ctrl: b(p, "ctrl") };
- let platform = crate::shortcuts::platform();
- use figcraft_engine::keymap::{Context, lookup, normalize};
- // Text editing keys go to the editor (after the shortcuts live while editing).
- if app.session.ds().map(|d| d.text_edit.is_some()).unwrap_or(false) {
- if let Some(bd) = lookup(platform, Context::Text, key, m).filter(|_| m.cmd || m.alt || m.ctrl) {
- crate::menus::invoke(app, bd.id);
- return Ok(json!({"command": bd.id}));
- }
- let (word, line) = figcraft_engine::keymap::text_motion(platform, key, m);
- figcraft_engine::textedit::key(&mut app.session, key, m.shift, word, line).map_err(|e| e.to_string())?;
- return Ok(json!({"ok": true}));
- }
- // Vector edit mode and shape builder keys (tools, nudging points, delete, join, Enter).
- if app.session.tool_key(key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())? {
- return Ok(json!({"ok": true}));
- }
- // The Comment tool's arrows step through the comment threads.
- if app.session.tool.tool == figcraft_engine::ToolId::Comment && m.is_none() && matches!(key, "ArrowUp" | "ArrowDown" | "Up" | "Down") {
- let r = crate::comments::step(app, ctx, if matches!(key, "ArrowUp" | "Up") { -1 } else { 1 });
- return Ok(json!({"comment": r.and_then(|v| v.get("id").cloned())}));
- }
- if let Some(bd) = lookup(platform, Context::Canvas, key, m) {
- crate::shortcuts::run_binding(app, bd.id);
- return Ok(json!({"command": bd.id}));
- }
- let (name, _) = normalize(key).ok_or_else(|| format!("unknown key `{key}`"))?;
- match name {
- "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown" => {
- let (dx, dy) = match name {
- "ArrowLeft" => (-1, 0),
- "ArrowRight" => (1, 0),
- "ArrowUp" => (0, -1),
- _ => (0, 1),
- };
- let (cmd, params) = if m.cmd || m.ctrl {
- ("transform.nudgeSize", json!({"dw": dx, "dh": dy, "big": m.shift}))
- } else {
- ("transform.nudge", json!({"dx": dx, "dy": dy, "big": m.shift}))
- };
- app.session.execute(cmd, ¶ms).map_err(|e| e.to_string())
- }
- _ => {
- // Number keys: opacity (two in quick succession make an exact percentage). A
- // hidden window's logic tick has a stale `ctx.input(i.time)`.
- let now = app.clock.now();
- match egui::Key::from_name(name) {
- Some(k) if m.is_none() && crate::shortcuts::opacity_digit(app, k, now) => Ok(json!({"opacity": true})),
- _ => Err(format!("no shortcut `{key}`")),
- }
- }
- }
- }
- "ui.input" => {
- let list = p
- .get("events")
- .and_then(Value::as_array)
- .ok_or("`events` required: [{move|down|up: [x, y]} | {key, shift?, alt?, cmd?} | {text} | {leave: true}]")?;
- let pos = |v: &Value| -> Option {
- let a = v.as_array()?;
- let p = egui::pos2(a.first()?.as_f64()? as f32, a.get(1)?.as_f64()? as f32);
- p.is_finite().then_some(p)
- };
- let mut events = Vec::new();
- let mut pads = Vec::new();
- for e in list.iter().take(256) {
- let button = if e.get("button").and_then(Value::as_str) == Some("secondary") {
- egui::PointerButton::Secondary
- } else {
- egui::PointerButton::Primary
- };
- let command = b(e, "cmd") || b(e, "ctrl");
- let mods = egui::Modifiers { alt: b(e, "alt"), ctrl: command, shift: b(e, "shift"), mac_cmd: false, command };
- if let Some(p) = e.get("move").and_then(pos) {
- events.push(egui::Event::PointerMoved(p));
- } else if let Some((p, pressed)) =
- e.get("down").and_then(pos).map(|p| (p, true)).or_else(|| e.get("up").and_then(pos).map(|p| (p, false)))
- {
- events.push(egui::Event::PointerMoved(p));
- events.push(egui::Event::PointerButton { pos: p, button, pressed, modifiers: mods });
- } else if let Some(k) = e.get("key").and_then(Value::as_str) {
- let key = egui::Key::from_name(k).ok_or_else(|| format!("unknown key `{k}`"))?;
- for pressed in [true, false] {
- events.push(egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: mods });
- }
- } else if let Some(t) = e.get("text").and_then(Value::as_str) {
- events.push(egui::Event::Text(t.to_string()));
- } else if let Some(d) = e.get("scroll").and_then(pos) {
- // Content moves by `d` points (negative y scrolls down, like a wheel turned towards you).
- events.push(egui::Event::MouseWheel {
- unit: egui::MouseWheelUnit::Point,
- source: egui::MouseWheelSource::Wheel,
- delta: d.to_vec2(),
- phase: egui::TouchPhase::Move,
- modifiers: mods,
- });
- } else if let Some(g) = e.get("gamepad") {
- let names = figcraft_doc::proto::GAMEPAD_BUTTONS.join(", ");
- pads.push(pad_name(g).ok_or_else(|| format!("unknown controller button {g} (an index 0-16 or one of {names})"))?.to_string());
- } else if b(e, "leave") {
- events.push(egui::Event::PointerGone);
- } else if let Some(h) = e.get("hold") {
- let held = egui::Modifiers {
- alt: b(h, "alt"),
- ctrl: b(h, "cmd") || b(h, "ctrl"),
- shift: b(h, "shift"),
- mac_cmd: false,
- command: b(h, "cmd") || b(h, "ctrl"),
- };
- events.push(egui::Event::ModifiersChanged(held));
- } else {
- return Err(format!("unknown input event {e}"));
- }
- }
- // Controller buttons go straight to the prototype playing (its next frame takes them).
- if !pads.is_empty() {
- match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
- (Some(p), _) => p.pads.extend(pads),
- (None, Some(pane)) => pane.player.pads.extend(pads),
- (None, None) => return Err("controller buttons go to a prototype that is playing: run proto.present or proto.preview".into()),
- }
- }
- app.control_input_queued = app.push_control_input(events);
- ctx.request_repaint();
- Ok(json!({"queued": list.len().min(256)}))
- }
+ "ui.key" => ui_key(app, ctx, p),
+ "ui.input" => ui_input(app, ctx, p),
"ui.type" => {
let text = p.get("text").and_then(Value::as_str).ok_or("`text` required")?;
figcraft_engine::textedit::insert(&mut app.session, text).map_err(|e| e.to_string())?;
@@ -1111,270 +1393,11 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul
Ok(json!({"ok": true, "lastError": app.ui.last_error}))
}
"ui.set" => {
- if let Some(t) = p.get("rightTab").and_then(Value::as_str) {
- app.ui.right_tab = match t {
- "prototype" => crate::RightTab::Prototype,
- "inspect" => crate::RightTab::Inspect,
- _ => crate::RightTab::Design,
- };
- }
- if let Some(t) = p.get("leftTab").and_then(Value::as_str) {
- app.ui.left_tab = if t == "assets" { crate::LeftTab::Assets } else { crate::LeftTab::File };
- }
- if let Some(t) = p.get("theme").and_then(Value::as_str) {
- app.session.prefs.theme = t.to_string();
- }
- if let Some(h) = p.get("home").and_then(Value::as_bool) {
- app.ui.home = h;
- }
- if let Some(section) = p.get("homeSection").and_then(Value::as_str).and_then(crate::home::Section::parse) {
- app.ui.home_view.section = section;
- }
- if let Some(query) = p.get("homeSearch").and_then(Value::as_str) {
- app.ui.home_view.file_query = query.chars().take(256).collect();
- }
- if let Some(view) = p.get("homeRecentsView").and_then(Value::as_str).and_then(crate::home_recents::View::parse) {
- view.store(&mut app.session.prefs);
- }
- if let Some(key) = p.get("homeRecentsSort").and_then(Value::as_str).and_then(crate::home_recents::SortKey::parse) {
- crate::home_recents::Sort::by(key).store(&mut app.session.prefs);
- }
- if let Some(order) = p.get("homeRecentsOrder").and_then(Value::as_str).filter(|o| matches!(*o, "asc" | "desc")) {
- let sort = crate::home_recents::Sort::of(&app.session.prefs);
- crate::home_recents::Sort { descending: order == "desc", ..sort }.store(&mut app.session.prefs);
- }
- if let Some(h) = p.get("palette").and_then(Value::as_bool) {
- crate::menus::set_palette(&mut app.ui, h);
- }
- // true / false, or a top-level menu to open it at ("Plugins").
- match p.get("mainMenu") {
- Some(Value::Bool(h)) => app.ui.main_menu = *h,
- Some(Value::String(top)) => {
- app.ui.main_menu = true;
- crate::menus::open_main_menu(ctx, top);
- }
- _ => {}
- }
- if let Some(h) = p.get("pluginsWindow").and_then(Value::as_bool) {
- app.ui.plugins_window = h;
- }
- // The Plugins window's tab ("installed", "browse"), and the plugin of the listed
- // directory whose install asks for confirmation (null: none).
- if let Some(tab) = p.get("pluginsTab").and_then(Value::as_str) {
- app.ui.plugins_manage.tab =
- if tab == "browse" { crate::plugins_ui::PluginsTab::Browse } else { crate::plugins_ui::PluginsTab::Installed };
- }
- match p.get("pluginConfirm") {
- Some(Value::String(id)) => app.ui.plugins_manage.confirm = Some(id.clone()),
- Some(Value::Null) => app.ui.plugins_manage.confirm = None,
- _ => {}
- }
- // A plugin command's parameters form: {id, command?}; null closes it.
- match p.get("pluginForm") {
- Some(Value::Null) => app.ui.plugin_form = None,
- Some(v @ Value::Object(_)) => {
- let id = v.get("id").and_then(Value::as_str).ok_or("pluginForm: {id, command?}")?;
- let command = v.get("command").and_then(Value::as_str).unwrap_or_default();
- crate::plugins_ui::start(app, id, command);
- }
- _ => {}
- }
- // Give a region the keyboard as F6 would: toolbar, layers, canvas or properties.
- if let Some(r) = p.get("focusRegion").and_then(Value::as_str) {
- app.ui.nav.go(crate::a11y::Region::parse(r).ok_or_else(|| format!("unknown region `{r}`"))?);
- }
- // A right-click on the canvas at screen [sx, sy] (null closes the menu);
- // `contextSelectLayer` opens its Select layer submenu.
- match p.get("contextMenu") {
- Some(Value::Array(at)) => {
- let (x, y) = (at.first().and_then(Value::as_f64).unwrap_or(0.0), at.get(1).and_then(Value::as_f64).unwrap_or(0.0));
- crate::canvas::open_context_menu(app, egui::pos2(x as f32, y as f32), b(p, "cmd"));
- }
- Some(Value::Null) => app.ui.context_menu = None,
- _ => {}
- }
- if let Some(v) = p.get("contextSelectLayer").and_then(Value::as_bool) {
- crate::menus::open_select_layer(ctx, v);
- }
- if let Some(h) = p.get("iconGallery").and_then(Value::as_bool) {
- app.ui.icon_gallery = h;
- }
- // Comment thread popover: an id opens it, null closes it.
- if let Some(v) = p.get("commentOpen") {
- match v.as_u64() {
- Some(id) => {
- // Pins are drawn for the current page only: reveal the thread's page first.
- let page = app.session.doc().ok().and_then(|d| d.comment(id)).map(|c| c.page);
- if let Some(page) = page.filter(|p| app.session.page().is_ok_and(|cur| cur != *p)) {
- app.session.execute("page.select", &json!({"id": page.0})).map_err(|e| e.to_string())?;
- }
- app.ui.comments.show(id);
- }
- None => app.ui.comments.close(),
- }
- }
- if let Some(v) = p.get("commentDraft") {
- match (v.get("x").and_then(Value::as_f64), v.get("y").and_then(Value::as_f64)) {
- (Some(x), Some(y)) => crate::comments::start_draft(app, x, y),
- _ => app.ui.comments.draft = None,
- }
- }
- if let Some(v) = p.get("showResolvedComments").and_then(Value::as_bool) {
- app.ui.comments.filter = if v { Filter::All } else { Filter::Open };
- }
- if let Some(v) = p.get("commentFilter").and_then(Value::as_str) {
- app.ui.comments.filter = Filter::parse(v).ok_or("commentFilter: open|resolved|mine|unread|all")?;
- }
- if let Some(v) = p.get("commentSort").and_then(Value::as_str) {
- app.ui.comments.sort = Sort::parse(v).ok_or("commentSort: date|page")?;
- }
- if let Some(v) = p.get("commentSearch").and_then(Value::as_str) {
- app.ui.comments.search = v.to_string();
- }
- // The open thread's reply field, with the cursor at its end (an `@` there offers names).
- if let Some(v) = p.get("commentReply").and_then(Value::as_str) {
- app.ui.comments.reply = v.to_string();
- if let Some(id) = app.ui.comments.open {
- crate::comments::focus_reply(ctx, id, v.chars().count());
- }
- }
- // Edit a message of the open thread: its index (0: the first message); null stops.
- if let Some(v) = p.get("commentEdit") {
- app.ui.comments.editing = match (app.ui.comments.open, v.as_u64()) {
- (Some(id), Some(i)) => {
- let text = app.session.doc().ok().and_then(|d| d.comment(id)).and_then(|c| c.message(i as usize)).map(|m| m.text.clone());
- Some((id, i as usize, text.ok_or("commentEdit: no such message")?))
- }
- _ => None,
- };
- }
- if let Some(v) = p.get("presentComments").and_then(Value::as_bool) {
- app.ui.present_view.comments = v;
- if let Some(pres) = app.ui.present.as_mut() {
- pres.view.comments = v;
- }
- }
- // Colour picker on the selection's paint `{fills?, index?}`; null closes it.
- if let Some(v) = p.get("colorPicker") {
- app.ui.picker = v.is_object().then(|| crate::colorpicker::PickerTarget::Paint {
- fills: v.get("fills").and_then(Value::as_bool).unwrap_or(true),
- index: v.get("index").and_then(Value::as_u64).unwrap_or(0).min(1000) as usize,
- ids: app.session.selection(),
- });
- }
- // The Variables editor: true / a collection (id or name) / {library, collection?} (an
- // enabled library's, by id or name) opens it, false closes it.
- match p.get("variables") {
- Some(Value::Bool(open)) => app.ui.variables = open.then(Default::default),
- Some(Value::String(c)) => app.ui.variables = Some(crate::variables_panel::Editor::on(Some(c.clone()))),
- Some(v @ Value::Object(_)) => {
- let text = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
- app.ui.variables = Some(match text("library") {
- Some(lib) => crate::variables_panel::Editor::in_library(lib, text("collection")),
- None => crate::variables_panel::Editor::on(text("collection")),
- });
- }
- _ => {}
- }
- // Layers panel search: a string opens it with that filter, null closes it.
- if let Some(v) = p.get("layerFilter") {
- app.ui.layer_search = v.is_string();
- app.ui.layer_filter = v.as_str().unwrap_or_default().chars().take(200).collect();
- }
- if p.get("collapseLayers").and_then(Value::as_bool) == Some(true) {
- app.ui.expanded.clear();
- }
- // The interaction editor popover in the Prototype panel: [layer id, index]; null closes it.
- match p.get("protoEditor") {
- Some(Value::Null) => crate::proto_panel::open_editor(ctx, None),
- Some(Value::Array(a)) => {
- let id = a.first().and_then(Value::as_u64).ok_or("protoEditor: [layer id, interaction index]")?;
- let k = a.get(1).and_then(Value::as_u64).unwrap_or(0) as usize;
- crate::proto_panel::open_editor(ctx, Some((figcraft_doc::NodeId(id), k)));
- }
- _ => {}
- }
- // The prototype preview pane: true / a frame id opens it, false or null closes it.
- match p.get("preview") {
- Some(Value::Bool(false) | Value::Null) => app.ui.preview = None,
- Some(v @ (Value::Bool(true) | Value::Number(_))) => {
- let params = if v.is_number() { json!({"start": v}) } else { json!({}) };
- app.session.execute("proto.preview", ¶ms).map_err(|e| e.to_string())?;
- app.handle_ui_requests();
- }
- _ => {}
- }
- // Scroll the presented screen (the full-window player, else the preview pane) to [x, y].
- if let Some(s) = p.get("presentScroll").and_then(Value::as_array) {
- let at = |i: usize| s.get(i).and_then(Value::as_f64).unwrap_or(0.0);
- let player = match (app.ui.present.as_mut(), app.ui.preview.as_mut()) {
- (Some(p), _) => Some(p),
- (None, Some(pane)) => Some(&mut pane.player),
- _ => None,
- };
- player.ok_or("nothing is presented")?.scroll_to(at(0), at(1));
- }
- // The style picker: a slot (fill, stroke, effect, grid, text) opens it for the selection, null
- // closes it; `stylePickerEdit` (a style id or name) opens its editor; `stylePickerView`
- // is `list` or `grid`.
- if let Some(v) = p.get("stylePicker") {
- match v.as_str().and_then(crate::style_picker::StyleSlot::parse) {
- Some(slot) => crate::style_picker::open_for_selection(app, slot, p.get("stylePickerEdit").and_then(Value::as_str)),
- None => app.ui.style_picker = None,
- }
- }
- if let Some(v) = p.get("stylePickerView").and_then(Value::as_str) {
- app.ui.style_grid = v == "grid";
- }
- // The Color profile dialog: a profile id opens it with that profile chosen, null closes it.
- if let Some(v) = p.get("colorProfileDialog") {
- app.ui.color_profile = v.as_str().and_then(figcraft_color::ColorProfile::parse);
- }
- if let Some(v) = p.get("exportPreview").and_then(Value::as_bool) {
- app.ui.export_thumb = v;
- }
- // Dev Mode's compare view: a frame or section opens it, null closes it; `devCompareVersion`
- // the saved version it compares with (null: when it was marked ready for dev),
- // `devCompareMode` is sideBySide or overlay, `devCompareSplit` the overlay slider (0–1).
- if let Some(v) = p.get("devCompare") {
- app.ui.dev.compare = figcraft_engine::params::id_of(v).map(|id| crate::dev_compare::CompareView::new(id, None));
- }
- if let Some(c) = app.ui.dev.compare.as_mut() {
- if let Some(v) = p.get("devCompareVersion") {
- c.version = v.as_str().map(str::to_string);
- }
- match p.get("devCompareMode").and_then(Value::as_str) {
- Some("overlay") => c.mode = crate::dev_compare::Mode::Overlay,
- Some("sideBySide") => c.mode = crate::dev_compare::Mode::SideBySide,
- Some(_) => return Err("devCompareMode: sideBySide|overlay".into()),
- None => {}
- }
- if let Some(x) = p.get("devCompareSplit").and_then(Value::as_f64).filter(|x| x.is_finite()) {
- c.split = x.clamp(0.0, 1.0) as f32;
- }
- }
- // The branch review dialog: a branch id opens it, null closes it; `branchReviewPicks`
- // chooses sides ({: "branch" | "main"}).
- if let Some(v) = p.get("branchReview") {
- match v.as_str() {
- Some(id) => crate::branch_ui::open(app, Some(id)),
- None => app.ui.branch_review = None,
- }
- }
- if let (Some(r), Some(picks)) = (app.ui.branch_review.as_mut(), p.get("branchReviewPicks").and_then(Value::as_object)) {
- for (k, v) in picks {
- let side = v.as_str().and_then(figcraft_engine::branches::Side::parse).ok_or("branchReviewPicks: \"branch\" or \"main\"")?;
- r.picks.insert(k.clone(), side);
- }
- }
- // Which annotations the canvas shows: all, none (uncategorised) or a category.
- if let Some(v) = p.get("annotationFilter").and_then(Value::as_str) {
- let doc = app.session.doc().map_err(|e| e.to_string())?;
- app.ui.dev.categories = crate::dev_mode::CategoryFilter::parse(doc, v).ok_or("annotationFilter: all|none|a category id or name")?;
- }
- if b(p, "resetFrameStats") {
- app.frame_stats.reset();
- }
+ ui_set_view(app, ctx, p)?;
+ ui_set_plugins(app, ctx, p)?;
+ ui_set_comments(app, ctx, p)?;
+ ui_set_panels(app, ctx, p)?;
+ ui_set_styles(app, p)?;
Ok(app.inspect())
}
"ui.render" => {
diff --git a/crates/ui-egui/src/dialogs/batch_rename.rs b/crates/ui-egui/src/dialogs/batch_rename.rs
index b28055f2..e09c1151 100644
--- a/crates/ui-egui/src/dialogs/batch_rename.rs
+++ b/crates/ui-egui/src/dialogs/batch_rename.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Context, FigApp, NodeId, RichText, Tokens, json, tr, trn, window};
/// The Rename layers dialog's fields.
#[derive(Clone, Debug, Default, PartialEq)]
diff --git a/crates/ui-egui/src/dialogs/color_profile.rs b/crates/ui-egui/src/dialogs/color_profile.rs
index f818c50d..3cf941dc 100644
--- a/crates/ui-egui/src/dialogs/color_profile.rs
+++ b/crates/ui-egui/src/dialogs/color_profile.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{ColorProfile, Context, FigApp, RichText, Tokens, json, regular, tr, trf, window};
/// File ▸ Color profile: pick sRGB or Display P3, then keep the colours' appearance (convert the
/// values) or keep the values (reinterpret them in the new space).
diff --git a/crates/ui-egui/src/dialogs/export.rs b/crates/ui-egui/src/dialogs/export.rs
index e3a96e59..2581b91a 100644
--- a/crates/ui-egui/src/dialogs/export.rs
+++ b/crates/ui-egui/src/dialogs/export.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Align2, Context, CornerRadius, ExportFormat, ExportSetting, ExportSize, FigApp, Path, RichText, Sense, Stroke, Tokens, Value, export_jobs,
+ export_layers, exportable_layers, json, regular, save_one, suggested_name, tr, trf, trn, vec2, window,
+};
/// A `.fig` Save as result is an export; only native saves report that the document was saved.
pub(crate) fn file_saved(app: &mut FigApp, result: &Value) {
diff --git a/crates/ui-egui/src/dialogs/menu_input.rs b/crates/ui-egui/src/dialogs/menu_input.rs
index e815c532..86463465 100644
--- a/crates/ui-egui/src/dialogs/menu_input.rs
+++ b/crates/ui-egui/src/dialogs/menu_input.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Context, FigApp, Tokens, i18n, json, tr, window};
/// Values collected by a menu or Actions command before dispatching it to the engine.
#[derive(Clone, Debug, PartialEq)]
diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs
index 012dd923..fd7b529c 100644
--- a/crates/ui-egui/src/field_tests.rs
+++ b/crates/ui-egui/src/field_tests.rs
@@ -968,6 +968,25 @@ fn a_prepared_field_save_cannot_commit_into_a_same_pass_replacement_document() {
}
}
+/// The save shortcut's key events (and, routing by tab, a click on the tab at `tab_at`), with the modifiers held.
+fn save_events(route: &str, save_as: bool, tab_at: egui::Pos2) -> (Vec, Modifiers) {
+ let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) };
+ if save_as {
+ modifiers = modifiers.plus(Modifiers::SHIFT);
+ }
+ let mut events = vec![
+ Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers },
+ Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers },
+ ];
+ if route == "tab" {
+ events.push(Event::PointerMoved(tab_at));
+ for pressed in [true, false] {
+ events.push(Event::PointerButton { pos: tab_at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
+ }
+ }
+ (events, modifiers)
+}
+
/// One route/save-as/discard combination of the same-pass replacement test: a save prepared for A
/// must not commit A's cached X editor into B when routing presents B before the fields draw,
/// even though both panels use the same widget ID.
@@ -1031,21 +1050,9 @@ fn prepared_save_cannot_enter_a_replacement(route: &str, save_as: bool, discard:
} else {
None
};
- let mut modifiers = if cfg!(target_os = "macos") { Modifiers::COMMAND } else { Modifiers::COMMAND.plus(Modifiers::CTRL) };
- if save_as {
- modifiers = modifiers.plus(Modifiers::SHIFT);
- }
- let mut events = vec![
- Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: true, repeat: false, modifiers },
- Event::Key { key: Key::S, physical_key: Some(Key::S), pressed: false, repeat: false, modifiers },
- ];
- if route == "tab" {
- events.push(Event::PointerMoved(tab_at));
- for pressed in [true, false] {
- events.push(Event::PointerButton { pos: tab_at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
- }
- }
+ let (events, modifiers) = save_events(route, save_as, tab_at);
if discard {
+ let mut events = events;
events.insert(0, Event::ModifiersChanged(modifiers));
u.frame_raw_with_discard(egui::RawInput { events, ..Default::default() });
} else {
diff --git a/crates/ui-egui/src/home/home_recents.rs b/crates/ui-egui/src/home/home_recents.rs
index 3525b31f..72ef4713 100644
--- a/crates/ui-egui/src/home/home_recents.rs
+++ b/crates/ui-egui/src/home/home_recents.rs
@@ -813,7 +813,7 @@ fn grid(ui: &mut Ui, t: &Tokens, recents: &mut Recents, files: &[Shown], now: u6
};
let alpha = picture.as_ref().map_or(1.0, |(_, _, a)| if reduce_motion { 1.0 } else { *a });
fading |= alpha < 1.0;
- card(ui, t, rect, picture_h, &resp, selected, file, stat, picture, alpha, now);
+ card(CardPaint { ui, tokens: t, rect, picture_h, response: &resp, selected, file, stat, picture, alpha, now });
}
ui.add_space(GAP);
}
@@ -837,20 +837,22 @@ fn skeleton(ui: &Ui, t: &Tokens, rect: Rect, picture_h: f32) {
p.rect_stroke(rect, CornerRadius::same(8), Stroke::new(1.0, t.border), StrokeKind::Inside);
}
-#[allow(clippy::too_many_arguments)]
-fn card(
- ui: &Ui,
- t: &Tokens,
+struct CardPaint<'a> {
+ ui: &'a Ui,
+ tokens: &'a Tokens,
rect: Rect,
picture_h: f32,
- resp: &Response,
+ response: &'a Response,
selected: bool,
- file: &Shown,
+ file: &'a Shown,
stat: Stat,
picture: Option<(Option, Option, f32)>,
alpha: f32,
now: u64,
-) {
+}
+
+fn card(paint: CardPaint<'_>) {
+ let CardPaint { ui, tokens: t, rect, picture_h, response: resp, selected, file, stat, picture, alpha, now } = paint;
let p = ui.painter();
p.rect_filled(rect, CornerRadius::same(8), if resp.hovered() { t.hover } else { t.panel });
let area = Rect::from_min_size(rect.min, vec2(rect.width(), picture_h)).shrink(1.0);
diff --git a/crates/ui-egui/src/icons/draw.rs b/crates/ui-egui/src/icons/draw.rs
index 1d088c91..a96314cf 100644
--- a/crates/ui-egui/src/icons/draw.rs
+++ b/crates/ui-egui/src/icons/draw.rs
@@ -1,4 +1,6 @@
-use super::*;
+use super::{
+ Color32, FRAC_PI_2, FRAC_PI_4, Icon, Mesh, PI, Painter, Pos2, Rect, Shape, Stroke, TAU, Vec2, arc_pts, capsule, pos2, triangulate, vec2,
+};
struct P<'a> {
painter: &'a Painter,
@@ -190,6 +192,89 @@ pub fn paint(painter: &Painter, rect: Rect, icon: Icon, color: Color32) {
draw(&p, icon);
}
+/// An open hand: four fingers (the middle one tallest), then the palm and the thumb.
+fn hand(p: &P) {
+ for (x, top) in [(4.5, 5.0), (6.5, 3.5), (8.5, 4.0), (10.5, 5.5)] {
+ let mut f = vec![(x, 8.0)];
+ f.extend(arc_pts(x + 1.0, top, 1.0, PI, TAU));
+ f.push((x + 2.0, 8.0));
+ p.path(&f, false);
+ }
+ let mut h = vec![(12.5, 8.0), (12.5, 10.0)];
+ h.extend(arc_pts(9.5, 10.0, 3.0, 0.0, FRAC_PI_2));
+ h.extend([(8.0, 13.0), (3.0, 9.4)]);
+ h.extend(arc_pts(3.6, 8.6, 1.0, 2.2, 5.3));
+ h.push((4.5, 8.5));
+ p.path(&h, false);
+}
+
+/// A curve with a smooth point and its handle line.
+fn bend(p: &P) {
+ let pts: Vec<(f32, f32)> = (0..=16)
+ .map(|i| {
+ let t = i as f32 / 16.0;
+ let u = 1.0 - t;
+ (u * u * 2.5 + 2.0 * u * t * 8.0 + t * t * 13.5, u * u * 13.5 + 2.0 * u * t * 1.5 + t * t * 13.5)
+ })
+ .collect();
+ p.path(&pts, false);
+ p.line(3.0, 7.5, 13.0, 7.5);
+ p.dot(3.0, 7.5, 1.3);
+ p.dot(13.0, 7.5, 1.3);
+ p.fill_rect(7.0, 6.0, 9.0, 9.0, 0.0);
+}
+
+/// A five-pointed star, filled.
+fn star(p: &P) {
+ let pts: Vec<(f32, f32)> = (0..10)
+ .map(|i| {
+ let a = -FRAC_PI_2 + PI * i as f32 / 5.0;
+ let r = if i % 2 == 0 { 6.0 } else { 2.7 };
+ (8.0 + r * a.cos(), 8.6 + r * a.sin())
+ })
+ .collect();
+ p.path(&pts, true);
+}
+
+/// The Component icon.
+fn draw_component(p: &P) {
+ for (cx, cy) in [(8.0, 4.2), (11.8, 8.0), (8.0, 11.8), (4.2, 8.0)] {
+ p.fill(&[(cx, cy - 2.5), (cx + 2.5, cy), (cx, cy + 2.5), (cx - 2.5, cy)]);
+ }
+}
+
+/// The ComponentSet icon.
+fn draw_componentset(p: &P) {
+ for (cx, cy) in [(8.0, 4.3), (11.7, 8.0), (8.0, 11.7), (4.3, 8.0)] {
+ p.path(&[(cx, cy - 2.2), (cx + 2.2, cy), (cx, cy + 2.2), (cx - 2.2, cy)], true);
+ }
+}
+
+/// The Group icon.
+fn draw_group(p: &P) {
+ for (x, y, dx, dy) in [(2.5, 2.5, 1.0, 1.0), (13.5, 2.5, -1.0, 1.0), (2.5, 13.5, 1.0, -1.0), (13.5, 13.5, -1.0, -1.0)] {
+ p.poly(&[(x + 3.5 * dx, y), (x, y), (x, y + 3.5 * dy)], false);
+ }
+}
+
+/// The Grid/LayoutGrid icons.
+fn draw_grid_layoutgrid(p: &P) {
+ for i in [2.5, 6.5, 10.5] {
+ for j in [2.5, 6.5, 10.5] {
+ p.rect(i, j, i + 3.0, j + 3.0, 0.0);
+ }
+ }
+}
+
+/// The FlipH icon.
+fn draw_fliph(p: &P) {
+ for y in [1.0, 5.0, 9.0, 13.0] {
+ p.line(7.5, y, 7.5, y + 2.0);
+ }
+ p.poly(&[(5.5, 3.5), (5.5, 12.5), (1.5, 12.5)], true);
+ p.fill(&[(9.0, 3.0), (14.0, 13.0), (9.0, 13.0)]);
+}
+
/// Draw `icon` on the 16-unit grid through `p`, which maps grid units to the target box.
fn draw(p: &P, icon: Icon) {
use Icon::*;
@@ -201,19 +286,7 @@ fn draw(p: &P, icon: Icon) {
p.poly(&[(9.5, 2.5), (13.5, 2.5), (13.5, 6.5)], false);
}
Hand => {
- // An open hand: four fingers (the middle one tallest), then the palm and the thumb.
- for (x, top) in [(4.5, 5.0), (6.5, 3.5), (8.5, 4.0), (10.5, 5.5)] {
- let mut f = vec![(x, 8.0)];
- f.extend(arc_pts(x + 1.0, top, 1.0, PI, TAU));
- f.push((x + 2.0, 8.0));
- p.path(&f, false);
- }
- let mut h = vec![(12.5, 8.0), (12.5, 10.0)];
- h.extend(arc_pts(9.5, 10.0, 3.0, 0.0, FRAC_PI_2));
- h.extend([(8.0, 13.0), (3.0, 9.4)]);
- h.extend(arc_pts(3.6, 8.6, 1.0, 2.2, 5.3));
- h.push((4.5, 8.5));
- p.path(&h, false);
+ hand(p);
}
Frame => {
// Inset a unit from the box: four long hinted strokes read heavier than the
@@ -235,19 +308,7 @@ fn draw(p: &P, icon: Icon) {
p.fill(&capsule((2.8, 13.2), (5.9, 10.1), 1.3));
}
Bend => {
- // A curve with a smooth point and its handle line.
- let pts: Vec<(f32, f32)> = (0..=16)
- .map(|i| {
- let t = i as f32 / 16.0;
- let u = 1.0 - t;
- (u * u * 2.5 + 2.0 * u * t * 8.0 + t * t * 13.5, u * u * 13.5 + 2.0 * u * t * 1.5 + t * t * 13.5)
- })
- .collect();
- p.path(&pts, false);
- p.line(3.0, 7.5, 13.0, 7.5);
- p.dot(3.0, 7.5, 1.3);
- p.dot(13.0, 7.5, 1.3);
- p.fill_rect(7.0, 6.0, 9.0, 9.0, 0.0);
+ bend(p);
}
PaintBucket => {
p.poly(&[(2.5, 8.0), (7.5, 3.0), (12.5, 8.0), (7.5, 13.0)], true);
@@ -273,16 +334,7 @@ fn draw(p: &P, icon: Icon) {
}
Ellipse => p.circle(8.0, 8.0, 5.5),
Polygon => p.poly(&[(8.0, 2.5), (13.5, 12.5), (2.5, 12.5)], true),
- Star => {
- let pts: Vec<(f32, f32)> = (0..10)
- .map(|i| {
- let a = -FRAC_PI_2 + PI * i as f32 / 5.0;
- let r = if i % 2 == 0 { 6.0 } else { 2.7 };
- (8.0 + r * a.cos(), 8.6 + r * a.sin())
- })
- .collect();
- p.path(&pts, true);
- }
+ Star => star(p),
Image => {
p.rect(2.5, 3.5, 13.5, 12.5, 1.0);
p.poly(&[(2.5, 11.5), (6.0, 8.0), (9.0, 11.0), (10.5, 9.5), (13.5, 12.5)], false);
@@ -352,22 +404,10 @@ fn draw(p: &P, icon: Icon) {
p.arc(13.0, 4.5, 2.5, PI, TAU);
}
}
- Component => {
- for (cx, cy) in [(8.0, 4.2), (11.8, 8.0), (8.0, 11.8), (4.2, 8.0)] {
- p.fill(&[(cx, cy - 2.5), (cx + 2.5, cy), (cx, cy + 2.5), (cx - 2.5, cy)]);
- }
- }
- ComponentSet => {
- for (cx, cy) in [(8.0, 4.3), (11.7, 8.0), (8.0, 11.7), (4.3, 8.0)] {
- p.path(&[(cx, cy - 2.2), (cx + 2.2, cy), (cx, cy + 2.2), (cx - 2.2, cy)], true);
- }
- }
+ Component => draw_component(p),
+ ComponentSet => draw_componentset(p),
Instance => p.path(&[(8.0, 2.0), (14.0, 8.0), (8.0, 14.0), (2.0, 8.0)], true),
- Group => {
- for (x, y, dx, dy) in [(2.5, 2.5, 1.0, 1.0), (13.5, 2.5, -1.0, 1.0), (2.5, 13.5, 1.0, -1.0), (13.5, 13.5, -1.0, -1.0)] {
- p.poly(&[(x + 3.5 * dx, y), (x, y), (x, y + 3.5 * dy)], false);
- }
- }
+ Group => draw_group(p),
Vector => {
p.path(&[(3.0, 12.5), (6.0, 4.0), (10.0, 12.0), (13.0, 3.5)], false);
p.fill_rect(1.5, 11.0, 4.5, 14.0, 0.0);
@@ -413,13 +453,7 @@ fn draw(p: &P, icon: Icon) {
p.circle(7.0, 7.0, 4.5);
p.line(10.5, 10.5, 14.0, 14.0);
}
- Grid | LayoutGrid => {
- for i in [2.5, 6.5, 10.5] {
- for j in [2.5, 6.5, 10.5] {
- p.rect(i, j, i + 3.0, j + 3.0, 0.0);
- }
- }
- }
+ Grid | LayoutGrid => draw_grid_layoutgrid(p),
AlignLeft => {
p.line(2.5, 2.0, 2.5, 14.0);
p.fill_rect(4.0, 4.0, 13.0, 7.0, 0.0);
@@ -462,13 +496,7 @@ fn draw(p: &P, icon: Icon) {
}
Rotate => p.arc_arrow(8.0, 8.5, 5.0, -PI * 0.55, PI * 1.25),
Reset => p.arc_arrow(8.0, 8.5, 5.0, -PI * 0.45, -PI * 2.25),
- FlipH => {
- for y in [1.0, 5.0, 9.0, 13.0] {
- p.line(7.5, y, 7.5, y + 2.0);
- }
- p.poly(&[(5.5, 3.5), (5.5, 12.5), (1.5, 12.5)], true);
- p.fill(&[(9.0, 3.0), (14.0, 13.0), (9.0, 13.0)]);
- }
+ FlipH => draw_fliph(p),
FlipV => {
for x in [1.0, 5.0, 9.0, 13.0] {
p.line(x, 7.5, x + 2.0, 7.5);
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
index 549a55c0..b7785e08 100644
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -27,7 +27,7 @@ pub use select_layer::open_select_layer;
use select_layer::{SELECT_LAYER, select_layer_ask_id, select_layer_menu};
#[cfg(test)]
pub(crate) use tree::{AIM_REST, HOVER_DELAY, heading_for, in_triangle};
-use tree::{At, menu_frame, plain_row_hover, set_open, submenu_at};
+use tree::{At, SubmenuOptions, menu_frame, plain_row_hover, set_open, submenu_at};
pub(crate) use tree::{forget_tree, item, separator, submenu};
/// Commands the UI layer implements itself (dialogs and platform actions).
@@ -403,8 +403,12 @@ pub fn popups(app: &mut FigApp, ctx: &egui::Context) {
}
ui.add_space(6.0);
for top in TOP_MENUS {
- if submenu_at(ui, Id::unique(("menu", *top)), top, tr(top), 12.0, false, &t, |ui| menu_level(app, ui, top).then_some(()))
- .is_some()
+ if submenu_at(
+ ui,
+ SubmenuOptions { area: Id::unique(("menu", *top)), key: top, label: tr(top), gap: 12.0, constrain: false, tokens: &t },
+ |ui| menu_level(app, ui, top).then_some(()),
+ )
+ .is_some()
{
close = true;
}
diff --git a/crates/ui-egui/src/menus/clipboard.rs b/crates/ui-egui/src/menus/clipboard.rs
index dd775297..3089ee3e 100644
--- a/crates/ui-egui/src/menus/clipboard.rs
+++ b/crates/ui-egui/src/menus/clipboard.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{FigApp, dev_mode_refuses, json, trn};
/// Run a menu item: some open dialogs or use platform services, the rest go to the engine.
/// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied
diff --git a/crates/ui-egui/src/menus/palette.rs b/crates/ui-egui/src/menus/palette.rs
index 4890f8ad..d5f7e810 100644
--- a/crates/ui-egui/src/menus/palette.rs
+++ b/crates/ui-egui/src/menus/palette.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Align2, Color32, CornerRadius, FigApp, Id, MENU, Rect, SEP, Sense, Stroke, StrokeKind, Tokens, Ui, available, i18n, invoke, json, pos2,
+ pretty_shortcut, record_recent, regular, tr, vec2,
+};
/// The Actions palette's tab.
#[derive(Clone, Copy, PartialEq, Eq, Default)]
diff --git a/crates/ui-egui/src/menus/select_layer.rs b/crates/ui-egui/src/menus/select_layer.rs
index 59389c41..73571f38 100644
--- a/crates/ui-egui/src/menus/select_layer.rs
+++ b/crates/ui-egui/src/menus/select_layer.rs
@@ -1,5 +1,5 @@
-use super::tree::{indented_item, separator, submenu_at};
-use super::*;
+use super::tree::{SubmenuOptions, indented_item, separator, submenu_at};
+use super::{FigApp, Id, Tokens, Ui, json, tr};
/// The context menu's Select layer submenu (its key in the context menu's tree).
pub(super) const SELECT_LAYER: &str = "Select layer";
@@ -35,15 +35,19 @@ pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2,
return false;
}
let sel = ds.selection.clone();
- let picked = submenu_at(ui, Id::unique("ctx_select_layer"), SELECT_LAYER, tr("Select layer"), 4.0, true, t, |ui| {
- let mut picked = None;
- for (id, depth, name) in &layers {
- if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) {
- picked = Some(*id);
+ let picked = submenu_at(
+ ui,
+ SubmenuOptions { area: Id::unique("ctx_select_layer"), key: SELECT_LAYER, label: tr("Select layer"), gap: 4.0, constrain: true, tokens: t },
+ |ui| {
+ let mut picked = None;
+ for (id, depth, name) in &layers {
+ if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) {
+ picked = Some(*id);
+ }
}
- }
- picked
- });
+ picked
+ },
+ );
separator(ui);
match picked {
Some(id) => {
diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs
index 4f181b3f..f0c64550 100644
--- a/crates/ui-egui/src/menus/tree.rs
+++ b/crates/ui-egui/src/menus/tree.rs
@@ -1,10 +1,10 @@
-use super::*;
+use super::{Color32, CornerRadius, Icon, Id, Rect, Sense, Stroke, Tokens, Ui, a11y, i18n, icons, pos2, pretty_shortcut, regular, vec2};
/// A submenu row `label` in the menu being drawn; while it's open, `add` draws its items beside
/// it. `key` names the submenu (its menu path, unique in its menu tree). Returns what `add`
/// returned.
pub(crate) fn submenu(ui: &mut Ui, key: &str, label: &str, t: &Tokens, add: impl FnOnce(&mut Ui) -> Option) -> Option {
- submenu_at(ui, Id::unique(("submenu", key)), key, label, 4.0, false, t, add)
+ submenu_at(ui, SubmenuOptions { area: Id::unique(("submenu", key)), key, label, gap: 4.0, constrain: false, tokens: t }, add)
}
// ---------------------------------------------------------------------------------------------
@@ -240,17 +240,17 @@ pub(super) fn plain_row_hover(ui: &Ui, resp: &egui::Response) -> bool {
}
/// A submenu row whose panel (area `area`, `gap` right of the row) shows while it's open.
-#[allow(clippy::too_many_arguments)]
-pub(super) fn submenu_at(
- ui: &mut Ui,
- area: Id,
- key: &str,
- label: &str,
- gap: f32,
- constrain: bool,
- t: &Tokens,
- add: impl FnOnce(&mut Ui) -> Option,
-) -> Option {
+pub(super) struct SubmenuOptions<'a> {
+ pub(super) area: Id,
+ pub(super) key: &'a str,
+ pub(super) label: &'a str,
+ pub(super) gap: f32,
+ pub(super) constrain: bool,
+ pub(super) tokens: &'a Tokens,
+}
+
+pub(super) fn submenu_at(ui: &mut Ui, options: SubmenuOptions<'_>, add: impl FnOnce(&mut Ui) -> Option) -> Option {
+ let SubmenuOptions { area, key, label, gap, constrain, tokens: t } = options;
let at = level_at(ui);
let aim = aiming(ui);
let w = ui.available_width();
diff --git a/crates/ui-egui/src/open.rs b/crates/ui-egui/src/open.rs
index 4fe8b9bd..47b5d575 100644
--- a/crates/ui-egui/src/open.rs
+++ b/crates/ui-egui/src/open.rs
@@ -1,7 +1,7 @@
#[cfg(target_arch = "wasm32")]
use super::services::browser_can_read;
use super::services::{ReadFuture, unreadable};
-use super::*;
+use super::{OsStr, Path, theme};
/// The file types File ▸ Open and a drop on the home screen open.
pub(crate) const OPEN_EXTENSIONS: &[&str] = &["figcraft", "fig", "sketch", "json", "svg", "png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"];
diff --git a/crates/ui-egui/src/panels/component_panel.rs b/crates/ui-egui/src/panels/component_panel.rs
index 54bc0974..0ab3e5cc 100644
--- a/crates/ui-egui/src/panels/component_panel.rs
+++ b/crates/ui-egui/src/panels/component_panel.rs
@@ -11,7 +11,7 @@
//! (preferred ones first), select it, reset it to the main's default content.
use egui::{Id, Sense, Ui, vec2};
-use figcraft_doc::*;
+use figcraft_doc::{Document, Node, NodeId, NodeKind, PropDef, VariableType};
use figcraft_engine::cmd::component::{can_add_variant, can_combine_variants};
use figcraft_engine::components::{
PROP_BINDING, default_variant, inside_instance, owning_component, owning_instance, slot_def, slot_edited, slots_of, variant_conflicts,
diff --git a/crates/ui-egui/src/panels/export_panel.rs b/crates/ui-egui/src/panels/export_panel.rs
index 5a9c4ae2..cec8ebc0 100644
--- a/crates/ui-egui/src/panels/export_panel.rs
+++ b/crates/ui-egui/src/panels/export_panel.rs
@@ -6,7 +6,7 @@
use std::collections::BTreeSet;
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
-use figcraft_doc::*;
+use figcraft_doc::{Document, ExportFormat, ExportSetting, ExportSize, NodeId, NodeKind, PrintSettings, VdCompat};
use figcraft_engine::cmd::file::export_jobs;
use serde_json::{Value, json};
diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs
index b5217678..1be4d28c 100644
--- a/crates/ui-egui/src/panels/layers.rs
+++ b/crates/ui-egui/src/panels/layers.rs
@@ -728,6 +728,259 @@ fn search_field(ui: &mut Ui, t: &Tokens, w: f32, id: Id, hint: &str, text: &mut
}
}
+/// Dragging near the list's top or bottom edge scrolls it, faster closer to the edge.
+fn drag_autoscroll(ui: &mut Ui, dragging: bool) {
+ if dragging && let Some(p) = ui.input(|i| i.pointer.hover_pos()) {
+ const EDGE: f32 = 28.0;
+ let view = ui.clip_rect();
+ let dy = if p.y < view.min.y + EDGE {
+ (view.min.y + EDGE - p.y).min(EDGE)
+ } else if p.y > view.max.y - EDGE {
+ -(p.y - (view.max.y - EDGE)).min(EDGE)
+ } else {
+ 0.0
+ };
+ if dy != 0.0 && view.x_range().contains(p.x) {
+ ui.scroll_with_delta(vec2(0.0, dy * 0.5));
+ ui.ctx().request_repaint();
+ }
+ }
+}
+
+/// A row's icon or name colour: the component tint (faded when hidden), else `normal` or `faded`.
+fn row_color(component: bool, shown: bool, normal: Color32, faded: Color32) -> Color32 {
+ if component {
+ COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 })
+ } else if shown {
+ normal
+ } else {
+ faded
+ }
+}
+
+/// The pointer `p` over row `rects` = [row, padded row]: the drop zone it shows (-1 above, 0 into,
+/// 1 below), whether holding there should open the collapsed row, and whether the button was released.
+fn drop_zone(ui: &mut Ui, p: egui::Pos2, rects: [Rect; 2], x0: f32, id: NodeId, collapsed: bool, n: &figcraft_doc::Node) -> (i8, bool, bool) {
+ let [r, rr] = rects;
+ let mut open = false;
+ let rel = (p.y - r.min.y) / r.height();
+ let into = n.kind.is_container() && !matches!(n.kind, NodeKind::Instance { .. }) && (0.25..0.75).contains(&rel);
+ let zone: i8 = if into {
+ 0
+ } else if rel < 0.5 {
+ -1
+ } else {
+ 1
+ };
+ let y = match zone {
+ -1 => r.min.y,
+ 1 => r.max.y,
+ _ => r.center().y,
+ };
+ if zone == 0 {
+ ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside);
+ // Holding over a collapsed group or frame opens it, to drop deeper inside.
+ if collapsed {
+ open = hold_opens(ui, id);
+ ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
+ }
+ } else {
+ crate::widgets::insertion_line(ui.painter(), x0, rr.max.x, y);
+ }
+ (zone, open, ui.input(|i| i.pointer.any_released()))
+}
+
+/// True once the pointer has stayed over row `id` for 0.6 s (the timer then restarts).
+fn hold_opens(ui: &mut Ui, id: NodeId) -> bool {
+ let hold_id = Id::unique("layer_drag_hold");
+ let now = ui.input(|i| i.time);
+ match ui.data(|d| d.get_temp::<(NodeId, f64)>(hold_id)) {
+ Some((held, since)) if held == id => {
+ if now - since > 0.6 {
+ ui.data_mut(|d| d.remove::<(NodeId, f64)>(hold_id));
+ return true;
+ }
+ }
+ _ => {
+ ui.data_mut(|d| d.insert_temp(hold_id, (id, now)));
+ }
+ }
+ false
+}
+
+/// Applies a finished sweep across the eyes or padlocks, or keeps it going while the button is down.
+fn finish_sweep(app: &mut FigApp, ui: &mut Ui, sw: Sweep) {
+ let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.primary_down()));
+ if released {
+ let (cmd, key) = if sw.lock { ("object.toggleLock", "locked") } else { ("object.toggleVisible", "visible") };
+ // A sweep started in the design can end after a switch to Dev Mode.
+ if !crate::menus::dev_mode_refuses(app, cmd) {
+ app.run(cmd, json!({"ids": sw.ids, key: if sw.lock { sw.on } else { !sw.on }}));
+ }
+ }
+ // A sweep whose release the panel never saw (it wasn't shown) is dropped.
+ if released || !down {
+ ui.data_mut(|d| d.remove::(sweep_id()));
+ } else {
+ ui.data_mut(|d| d.insert_temp(sweep_id(), sw));
+ }
+}
+
+/// A click on row `id`: plain selects it, Shift extends from the anchor, Cmd (or Shift without an
+/// anchor) toggles it.
+fn select_clicked(app: &mut FigApp, rows: &[Row], sel: &[NodeId], id: NodeId, m: egui::Modifiers) {
+ app.ui.layers_focus = true;
+ app.ui.pages_focus = None;
+ let order: Vec = rows.iter().map(|r| r.id).collect();
+ let pos = |x: NodeId| order.iter().position(|o| *o == x);
+ let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos);
+ if m.shift
+ && let (Some(ai), Some(bi)) = (anchor, pos(id))
+ {
+ // Shift: the visible rows from the anchor to here (added to the selection with Cmd).
+ let (lo, hi) = (ai.min(bi), ai.max(bi));
+ let mut s: Vec = if m.command { sel.to_vec() } else { vec![] };
+ for x in order.get(lo..=hi).unwrap_or_default() {
+ if !s.contains(x) {
+ s.push(*x);
+ }
+ }
+ app.run("select.set", json!({"ids": s}));
+ } else if m.shift || m.command {
+ let mut s = sel.to_vec();
+ if let Some(pos) = s.iter().position(|x| *x == id) {
+ s.remove(pos);
+ } else {
+ s.push(id);
+ }
+ app.ui.layer_anchor = Some(id);
+ app.run("select.set", json!({"ids": s}));
+ } else {
+ app.ui.layer_anchor = Some(id);
+ app.run("select.set", json!({"ids": [id]}));
+ }
+ // A click in the panel needs no reveal scroll.
+ app.ui.layers_seen_sel = app.session.selection();
+ if let Ok(ds) = app.session.ds_mut()
+ && ds.text_edit.is_some()
+ {
+ let _ = figcraft_engine::textedit::end(&mut app.session);
+ }
+}
+
+/// Drops `moving` on `target` (`zone`: 0 into it, -1 above, 1 below), unless Dev Mode refuses
+/// (a drag started in the design can end after a switch to it).
+fn drop_layers(app: &mut FigApp, doc: &Document, moving: &[NodeId], target: NodeId, zone: i8) {
+ if crate::menus::dev_mode_refuses(app, "node.reparent") {
+ return;
+ }
+ let ids = moving.to_vec();
+ match zone {
+ 0 => {
+ app.run("node.reparent", json!({"ids": ids, "parent": target}));
+ }
+ z => {
+ // Above in the list = higher z = after the target among siblings.
+ if let Some(parent) = doc.node(target).and_then(|n| n.parent) {
+ let idx = doc.index_in_parent(target).unwrap_or(0);
+ let gap = if z < 0 { idx + 1 } else { idx };
+ // `index` is the final position: the dragged layers below the gap leave it.
+ let below = doc.node(parent).map_or(0, |p| p.children.iter().take(gap).filter(|c| ids.contains(c)).count());
+ app.run("node.reparent", json!({"ids": ids, "parent": parent, "index": gap - below}));
+ }
+ }
+ }
+}
+
+/// The eye and padlock of a row: painted when hovered (`over`, false in Dev Mode) or set, and, outside
+/// Dev Mode, clickable; a drag over them starts a sweep. Returns the layers whose eye / padlock was clicked.
+fn eye_lock(
+ ui: &mut Ui,
+ t: &Tokens,
+ rects: [Rect; 2],
+ row: &Row,
+ n: &figcraft_doc::Node,
+ over: bool,
+ dev: bool,
+ sweep: &mut Option,
+) -> (Option, Option) {
+ let [r, rr] = rects;
+ let (mut vis, mut lock) = (None, None);
+ // A sweep across the eyes or padlocks takes in the rows the pointer passes.
+ if let Some(sw) = sweep.as_mut()
+ && !sw.ids.contains(&row.id)
+ && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.y_range().contains(p.y))
+ {
+ sw.ids.push(row.id);
+ }
+ let swept = |lock: bool| sweep.as_ref().filter(|sw| sw.lock == lock && sw.ids.contains(&row.id)).map(|sw| sw.on);
+ // Lock / eye on hover (or when set); a layer in a locked one shows a faint padlock.
+ let hidden = swept(false).unwrap_or(!n.visible);
+ let locked = swept(true).unwrap_or(n.locked);
+ // In Dev Mode they only show what is set.
+ let show_lock = over || locked || row.locked_above;
+ let show_eye = over || hidden;
+ let er = Rect::from_center_size(pos2(rr.max.x - 14.0, r.center().y), vec2(20.0, 20.0));
+ let lr = Rect::from_center_size(pos2(rr.max.x - 36.0, r.center().y), vec2(20.0, 20.0));
+ if show_eye {
+ icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary);
+ }
+ if show_eye && !dev {
+ let eresp = ui.interact(er, ui.scope_id().with(("eye", row.id)), Sense::click_and_drag());
+ a11y::toggle(&eresp, tr("Hidden"), hidden);
+ if eresp.clicked() {
+ vis = Some(row.id);
+ }
+ if eresp.drag_started() {
+ *sweep = Some(Sweep { lock: false, on: n.visible, ids: vec![row.id] });
+ }
+ }
+ if show_lock {
+ let col = if locked || !row.locked_above { t.icon_secondary } else { t.text_tertiary };
+ icons::paint(ui.painter(), lr.shrink(3.0), if locked || row.locked_above { Icon::Lock } else { Icon::Unlock }, col);
+ }
+ if show_lock && !dev {
+ let lresp = ui.interact(lr, ui.scope_id().with(("lock", row.id)), Sense::click_and_drag());
+ a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above);
+ if lresp.clicked() {
+ lock = Some(row.id);
+ }
+ if lresp.drag_started() {
+ *sweep = Some(Sweep { lock: true, on: !n.locked, ids: vec![row.id] });
+ }
+ }
+ (vis, lock)
+}
+
+/// Expands the ancestors of a selection not seen yet (once, so manual collapsing sticks); whether it was new.
+fn reveal_selection(app: &mut FigApp, doc: &Document, page: NodeId, sel: &[NodeId]) -> bool {
+ let reveal = sel != app.ui.layers_seen_sel;
+ if reveal {
+ for s in sel {
+ for a in doc.ancestors(*s) {
+ if a != page {
+ app.ui.expanded.insert(a);
+ }
+ }
+ }
+ app.ui.layers_seen_sel = sel.to_vec();
+ }
+ reveal
+}
+
+/// A row's background: selected, inside a selected layer, or outlined when hovered.
+/// `state` is [selected, in_selected, component].
+fn paint_row_bg(ui: &Ui, t: &Tokens, rr: Rect, state: [bool; 3], hovered: bool) {
+ let [selected, in_selected, is_comp] = state;
+ if selected {
+ ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row });
+ } else if in_selected {
+ ui.painter().rect_filled(rr, CornerRadius::ZERO, t.selected_row_child);
+ } else if hovered {
+ ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(1.0, if is_comp { COMPONENT } else { ACCENT }), egui::StrokeKind::Inside);
+ }
+}
+
/// Draw the layer tree with search, cached rows, reveal-selection expansion and scrolling,
/// rename/Tab navigation, selection and drag/drop, sweep toggles and Dev Mode restrictions.
fn layers(app: &mut FigApp, ui: &mut Ui) {
@@ -761,17 +1014,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
let hover = ds.hover;
// Reveal a new selection: expand its ancestors and scroll its row into view, once, so
// "Collapse layers" and manual collapsing stick.
- let reveal = sel != app.ui.layers_seen_sel;
- if reveal {
- for s in &sel {
- for a in doc.ancestors(*s) {
- if a != page {
- app.ui.expanded.insert(a);
- }
- }
- }
- app.ui.layers_seen_sel = sel.clone();
- }
+ let reveal = reveal_selection(app, &doc, page, &sel);
let filter = app.ui.layer_filter.trim().to_lowercase();
let rows = rows_for(ui, &doc, page, &app.ui.expanded, &filter);
let mut scroll_to = if reveal { sel.first().and_then(|s| rows.iter().position(|r| r.id == *s)) } else { None };
@@ -831,24 +1074,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
ui.add(egui::Label::new(egui::RichText::new(hint).font(regular(11.0)).color(t.text_secondary)).wrap());
});
}
- // Dragging near the list's top or bottom edge scrolls it, faster closer to the edge.
- if dragging.is_some()
- && let Some(p) = ui.input(|i| i.pointer.hover_pos())
- {
- const EDGE: f32 = 28.0;
- let view = ui.clip_rect();
- let dy = if p.y < view.min.y + EDGE {
- (view.min.y + EDGE - p.y).min(EDGE)
- } else if p.y > view.max.y - EDGE {
- -(p.y - (view.max.y - EDGE)).min(EDGE)
- } else {
- 0.0
- };
- if dy != 0.0 && view.x_range().contains(p.x) {
- ui.scroll_with_delta(vec2(0.0, dy * 0.5));
- ui.ctx().request_repaint();
- }
- }
+ drag_autoscroll(ui, dragging.is_some());
let first = ((viewport.min.y / pitch).floor().max(0.0) as usize).min(rows.len());
let last = ((viewport.max.y / pitch).ceil().max(0.0) as usize).saturating_add(1).min(rows.len());
let band = Rect::from_x_y_ranges(ui.max_rect().x_range(), (top + first as f32 * pitch)..=(top + last as f32 * pitch));
@@ -879,18 +1105,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
a11y::ring_around(ui, rr, &resp);
// Over the row, its eye and padlock included (they sit on top of it).
let over = resp.contains_pointer();
- if selected {
- ui.painter().rect_filled(rr, CornerRadius::same(5), if is_comp { t.component_row } else { t.selected_row });
- } else if in_selected {
- ui.painter().rect_filled(rr, CornerRadius::ZERO, t.selected_row_child);
- } else if over || hover == Some(row.id) {
- ui.painter().rect_stroke(
- rr,
- CornerRadius::same(5),
- Stroke::new(1.0, if is_comp { COMPONENT } else { ACCENT }),
- egui::StrokeKind::Inside,
- );
- }
+ paint_row_bg(ui, &t, rr, [selected, in_selected, is_comp], over || hover == Some(row.id));
if over {
hovered_row = Some(row.id);
}
@@ -912,13 +1127,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
}
let icon = node_icon(&doc, row.id);
let shown = n.visible && !row.dimmed;
- let icon_col = if is_comp || icon == Icon::Slot {
- COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 })
- } else if shown {
- t.icon
- } else {
- t.text_tertiary
- };
+ let icon_col = row_color(is_comp || icon == Icon::Slot, shown, t.icon, t.text_tertiary);
icons::paint(ui.painter(), Rect::from_center_size(pos2(x0 + 22.0, r.center().y), vec2(14.0, 14.0)), icon, icon_col);
let name_x = x0 + 36.0;
if app.ui.rename.is_some_and(|rn| rn.id == row.id) {
@@ -926,68 +1135,18 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
rename_done = Some((row.id, new));
}
} else {
- let col = if is_comp {
- COMPONENT.gamma_multiply(if shown { 1.0 } else { 0.5 })
- } else if shown {
- t.text
- } else {
- t.text_tertiary
- };
- let font = if doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page())
- && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. }))
- {
- semibold(11.0)
- } else {
- regular(11.0)
- };
+ let col = row_color(is_comp, shown, t.text, t.text_tertiary);
+ let top_level = doc.node(n.parent.unwrap_or(page)).is_some_and(|p| p.is_page());
+ let font =
+ if top_level && (n.kind.is_frame_like() || matches!(n.kind, NodeKind::Section { .. })) { semibold(11.0) } else { regular(11.0) };
let name = if n.name.is_empty() { tr(n.kind.label()).to_string() } else { n.name.clone() };
// Room left of the lock and eye buttons.
let max_w = (rr.max.x - 56.0 - name_x).max(20.0);
crate::bidi::paint_name(ui.painter(), pos2(name_x, r.center().y), &name, font, col, max_w);
}
- // A sweep across the eyes or padlocks takes in the rows the pointer passes.
- if let Some(sw) = sweep.as_mut()
- && !sw.ids.contains(&row.id)
- && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.y_range().contains(p.y))
- {
- sw.ids.push(row.id);
- }
- let swept = |lock: bool| sweep.as_ref().filter(|sw| sw.lock == lock && sw.ids.contains(&row.id)).map(|sw| sw.on);
- // Lock / eye on hover (or when set); a layer in a locked one shows a faint padlock.
- let hidden = swept(false).unwrap_or(!n.visible);
- let locked = swept(true).unwrap_or(n.locked);
- // In Dev Mode they only show what is set.
- let show_lock = (over && !dev) || locked || row.locked_above;
- let show_eye = (over && !dev) || hidden;
- let er = Rect::from_center_size(pos2(rr.max.x - 14.0, r.center().y), vec2(20.0, 20.0));
- let lr = Rect::from_center_size(pos2(rr.max.x - 36.0, r.center().y), vec2(20.0, 20.0));
- if show_eye {
- icons::paint(ui.painter(), er.shrink(3.0), if hidden { Icon::EyeOff } else { Icon::Eye }, t.icon_secondary);
- }
- if show_eye && !dev {
- let eresp = ui.interact(er, ui.scope_id().with(("eye", row.id)), Sense::click_and_drag());
- a11y::toggle(&eresp, tr("Hidden"), hidden);
- if eresp.clicked() {
- vis = Some(row.id);
- }
- if eresp.drag_started() {
- sweep = Some(Sweep { lock: false, on: n.visible, ids: vec![row.id] });
- }
- }
- if show_lock {
- let col = if locked || !row.locked_above { t.icon_secondary } else { t.text_tertiary };
- icons::paint(ui.painter(), lr.shrink(3.0), if locked || row.locked_above { Icon::Lock } else { Icon::Unlock }, col);
- }
- if show_lock && !dev {
- let lresp = ui.interact(lr, ui.scope_id().with(("lock", row.id)), Sense::click_and_drag());
- a11y::toggle(&lresp, tr("Locked"), locked || row.locked_above);
- if lresp.clicked() {
- lock = Some(row.id);
- }
- if lresp.drag_started() {
- sweep = Some(Sweep { lock: true, on: !n.locked, ids: vec![row.id] });
- }
- }
+ let (v, l) = eye_lock(ui, &t, [r, rr], row, n, over && !dev, dev, &mut sweep);
+ vis = v.or(vis);
+ lock = l.or(lock);
if resp.clicked() {
clicked = Some((row.id, ui.input(|i| i.modifiers)));
}
@@ -1016,43 +1175,12 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
&& let Some(p) = ui.input(|i| i.pointer.hover_pos())
&& r.contains(p)
{
- let rel = (p.y - r.min.y) / r.height();
- let into = n.kind.is_container() && !matches!(n.kind, NodeKind::Instance { .. }) && (0.25..0.75).contains(&rel);
- let zone: i8 = if into {
- 0
- } else if rel < 0.5 {
- -1
- } else {
- 1
- };
- let y = match zone {
- -1 => r.min.y,
- 1 => r.max.y,
- _ => r.center().y,
- };
- if zone == 0 {
- ui.painter().rect_stroke(rr, CornerRadius::same(5), Stroke::new(2.0, ACCENT), egui::StrokeKind::Inside);
- // Holding over a collapsed group or frame opens it, to drop deeper inside.
- if row.has_kids && !app.ui.expanded.contains(&row.id) {
- let hold_id = Id::unique("layer_drag_hold");
- let now = ui.input(|i| i.time);
- match ui.data(|d| d.get_temp::<(NodeId, f64)>(hold_id)) {
- Some((id, since)) if id == row.id => {
- if now - since > 0.6 {
- toggle = Some(row.id);
- ui.data_mut(|d| d.remove::<(NodeId, f64)>(hold_id));
- }
- }
- _ => {
- ui.data_mut(|d| d.insert_temp(hold_id, (row.id, now)));
- }
- }
- ui.ctx().request_repaint_after(std::time::Duration::from_millis(100));
- }
- } else {
- crate::widgets::insertion_line(ui.painter(), x0, rr.max.x, y);
+ let collapsed = row.has_kids && !app.ui.expanded.contains(&row.id);
+ let (zone, open, released) = drop_zone(ui, p, [r, rr], x0, row.id, collapsed, n);
+ if open {
+ toggle = Some(row.id);
}
- if ui.input(|i| i.pointer.any_released()) {
+ if released {
drop = Some((row.id, zone));
}
}
@@ -1087,46 +1215,15 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
}
}
if let Some(sw) = sweep {
- let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.primary_down()));
- if released {
- let (cmd, key) = if sw.lock { ("object.toggleLock", "locked") } else { ("object.toggleVisible", "visible") };
- // A sweep started in the design can end after a switch to Dev Mode.
- if !crate::menus::dev_mode_refuses(app, cmd) {
- app.run(cmd, json!({"ids": sw.ids, key: if sw.lock { sw.on } else { !sw.on }}));
- }
- }
- // A sweep whose release the panel never saw (it wasn't shown) is dropped.
- if released || !down {
- ui.data_mut(|d| d.remove::(sweep_id()));
- } else {
- ui.data_mut(|d| d.insert_temp(sweep_id(), sw));
- }
+ finish_sweep(app, ui, sw);
}
if ui.input(|i| i.pointer.any_released()) {
ui.data_mut(|d| {
d.remove::(drag_id);
d.remove::<(NodeId, f64)>(Id::unique("layer_drag_hold"));
});
- // A drag started in the design can end after a switch to Dev Mode.
- if let (Some(_), Some((target, zone))) = (dragging, drop)
- && !crate::menus::dev_mode_refuses(app, "node.reparent")
- {
- let ids = moving.clone();
- match zone {
- 0 => {
- app.run("node.reparent", json!({"ids": ids, "parent": target}));
- }
- z => {
- // Above in the list = higher z = after the target among siblings.
- if let Some(parent) = doc.node(target).and_then(|n| n.parent) {
- let idx = doc.index_in_parent(target).unwrap_or(0);
- let gap = if z < 0 { idx + 1 } else { idx };
- // `index` is the final position: the dragged layers below the gap leave it.
- let below = doc.node(parent).map_or(0, |p| p.children.iter().take(gap).filter(|c| ids.contains(c)).count());
- app.run("node.reparent", json!({"ids": ids, "parent": parent, "index": gap - below}));
- }
- }
- }
+ if let (Some(_), Some((target, zone))) = (dragging, drop) {
+ drop_layers(app, &doc, &moving, target, zone);
}
}
if let Ok(ds) = app.session.ds_mut() {
@@ -1159,43 +1256,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) {
a11y::refocus_row(&mut app.ui.nav);
}
if let Some((id, m)) = clicked {
- app.ui.layers_focus = true;
- app.ui.pages_focus = None;
- let order: Vec = rows.iter().map(|r| r.id).collect();
- let pos = |x: NodeId| order.iter().position(|o| *o == x);
- let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos);
- if m.shift
- && let (Some(ai), Some(bi)) = (anchor, pos(id))
- {
- // Shift: the visible rows from the anchor to here (added to the selection with Cmd).
- let (lo, hi) = (ai.min(bi), ai.max(bi));
- let mut s: Vec = if m.command { sel.clone() } else { vec![] };
- for x in order.get(lo..=hi).unwrap_or_default() {
- if !s.contains(x) {
- s.push(*x);
- }
- }
- app.run("select.set", json!({"ids": s}));
- } else if m.shift || m.command {
- let mut s = sel.clone();
- if let Some(pos) = s.iter().position(|x| *x == id) {
- s.remove(pos);
- } else {
- s.push(id);
- }
- app.ui.layer_anchor = Some(id);
- app.run("select.set", json!({"ids": s}));
- } else {
- app.ui.layer_anchor = Some(id);
- app.run("select.set", json!({"ids": [id]}));
- }
- // A click in the panel needs no reveal scroll.
- app.ui.layers_seen_sel = app.session.selection();
- if let Ok(ds) = app.session.ds_mut()
- && ds.text_edit.is_some()
- {
- let _ = figcraft_engine::textedit::end(&mut app.session);
- }
+ select_clicked(app, &rows, &sel, id, m);
}
let _ = Color32::TRANSPARENT;
}
diff --git a/crates/ui-egui/src/panels/layout_panel.rs b/crates/ui-egui/src/panels/layout_panel.rs
index 0874da2f..c958007a 100644
--- a/crates/ui-egui/src/panels/layout_panel.rs
+++ b/crates/ui-egui/src/panels/layout_panel.rs
@@ -5,7 +5,7 @@
//! commands.
use egui::{Align2, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
-use figcraft_doc::*;
+use figcraft_doc::{AutoLayout, CellAlign, ContentAlign, CrossAlign, Document, LayoutMode, MainAlign, Node, NodeId, NodeKind, Sizing};
use serde_json::json;
use crate::FigApp;
diff --git a/crates/ui-egui/src/panels/proto_editor.rs b/crates/ui-egui/src/panels/proto_editor.rs
index 7163807e..32817e84 100644
--- a/crates/ui-egui/src/panels/proto_editor.rs
+++ b/crates/ui-egui/src/panels/proto_editor.rs
@@ -165,7 +165,7 @@ pub fn action_list(app: &mut FigApp, ui: &mut Ui, cx: &Cx, list: &[Action], pref
for (i, a) in list.iter().enumerate() {
let path = [prefix, &[i]].concat();
let is_open = single || open.starts_with(&path);
- let (header, extent) = action_item(app, ui, cx, a, path, list.len(), is_open, single);
+ let (header, extent) = action_item(app, ui, ActionItem { context: cx, action: a, path, count: list.len(), open: is_open, single });
if let Some(d) = &mut drag {
d.row(ui, i, header, extent, || action_summary(cx.doc, a));
}
@@ -222,8 +222,17 @@ fn add_action_row(app: &mut FigApp, ui: &mut Ui, cx: &Cx, path: Vec) {
/// One action: its kind (open) or a summary (closed) with a menu to move or remove it, and when
/// open its settings below. Returns its row and the area it covers with its settings.
-#[allow(clippy::too_many_arguments)]
-fn action_item(app: &mut FigApp, ui: &mut Ui, cx: &Cx, a: &Action, path: Vec, count: usize, open: bool, single: bool) -> (Rect, Rect) {
+struct ActionItem<'context, 'document> {
+ context: &'context Cx<'document>,
+ action: &'context Action,
+ path: Vec,
+ count: usize,
+ open: bool,
+ single: bool,
+}
+
+fn action_item(app: &mut FigApp, ui: &mut Ui, item: ActionItem<'_, '_>) -> (Rect, Rect) {
+ let ActionItem { context: cx, action: a, path, count, open, single } = item;
let t = Tokens::get(ui.ctx());
let i = path.last().copied().unwrap_or(0);
let removable = path.len() > 1 || count > 1;
@@ -451,7 +460,17 @@ fn condition_editor(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, b: usiz
Some(s) => builder_row(app, ui, cx, slot, b, &s),
None => {
let id = slot.id("proto_cond_expr").with(b);
- if let Some(e) = expr_field(ui, id, vars, Some(c), tr("e.g. count >= 3 and not done"), |e| e.check_condition(vars), None::) {
+ if let Some(e) = expr_field(
+ ui,
+ ExprInput {
+ id,
+ variables: vars,
+ current: Some(c),
+ placeholder: tr("e.g. count >= 3 and not done"),
+ check: |e: &Expr| e.check_condition(vars),
+ trailing: None::,
+ },
+ ) {
set_condition(app, slot, b, &e);
}
}
@@ -575,14 +594,16 @@ fn set_variable_fields(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, vari
Some(e) => {
let edited = expr_field(
ui,
- slot.id("proto_set_expr"),
- vars,
- Some(e),
- tr("e.g. count + 1"),
- |e| e.check_value(vars, var.kind),
- Some(|ui: &mut Ui| {
- toggled = toggle(ui);
- }),
+ ExprInput {
+ id: slot.id("proto_set_expr"),
+ variables: vars,
+ current: Some(e),
+ placeholder: tr("e.g. count + 1"),
+ check: |e: &Expr| e.check_value(vars, var.kind),
+ trailing: Some(|ui: &mut Ui| {
+ toggled = toggle(ui);
+ }),
+ },
);
if let Some(e) = edited {
set(app, value.clone(), Some(e));
@@ -616,16 +637,21 @@ fn set_variable_fields(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, vari
/// An expression typed as text in a row (with a `trailing` button after the field): what `check`
/// accepts is returned when committed; otherwise the text stays and why shows under it.
-#[allow(clippy::too_many_arguments)]
-fn expr_field(
- ui: &mut Ui,
+struct ExprInput<'a, Check, Trailing> {
id: Id,
- vars: &Variables,
- cur: Option<&Expr>,
- placeholder: &str,
- check: impl Fn(&Expr) -> Result<(), String>,
- trailing: Option,
-) -> Option {
+ variables: &'a Variables,
+ current: Option<&'a Expr>,
+ placeholder: &'a str,
+ check: Check,
+ trailing: Option,
+}
+
+fn expr_field(ui: &mut Ui, input: ExprInput<'_, Check, Trailing>) -> Option
+where
+ Check: Fn(&Expr) -> Result<(), String>,
+ Trailing: FnOnce(&mut Ui),
+{
+ let ExprInput { id, variables: vars, current: cur, placeholder, check, trailing } = input;
let t = Tokens::get(ui.ctx());
let shown = cur.map(|e| e.display(vars)).unwrap_or_default();
let key = id.with("draft");
diff --git a/crates/ui-egui/src/panels/proto_panel.rs b/crates/ui-egui/src/panels/proto_panel.rs
index 86dae3a8..e43815bd 100644
--- a/crates/ui-egui/src/panels/proto_panel.rs
+++ b/crates/ui-egui/src/panels/proto_panel.rs
@@ -8,7 +8,7 @@ use figcraft_doc::proto::{
Action, ConditionalBlock, Direction, Easing, GAMEPAD_BUTTONS, Interaction, KeyMods, Overflow, OverlayAnchor, OverlayPosition, ScrollPosition,
Transition, Trigger, is_gamepad_button,
};
-use figcraft_doc::*;
+use figcraft_doc::{Document, Node, NodeId, NodeKind, VariableValue, proto};
use serde_json::{Value, json};
use crate::FigApp;
@@ -640,15 +640,24 @@ fn interactions(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: NodeId, nod
{
let panel_left = ui.clip_rect().min.x;
let y = anchor.map(|a| a.min.y).unwrap_or(ui.clip_rect().min.y + 80.0);
- editor_popover(app, ui.ctx(), doc, page, id, k, it, pos2(panel_left - 8.0, y));
+ editor_popover(app, ui.ctx(), InteractionPopover { doc, page, node: id, index: k, interaction: it, anchor: pos2(panel_left - 8.0, y) });
} else if open.is_some_and(|(n, _)| n == id) {
open_editor(ui.ctx(), None);
}
}
/// The interaction editor, floating left of the panel (`at` is its top-right corner).
-#[allow(clippy::too_many_arguments)]
-fn editor_popover(app: &mut FigApp, ctx: &egui::Context, doc: &Document, page: NodeId, node: NodeId, k: usize, it: &Interaction, at: Pos2) {
+struct InteractionPopover<'a> {
+ doc: &'a Document,
+ page: NodeId,
+ node: NodeId,
+ index: usize,
+ interaction: &'a Interaction,
+ anchor: Pos2,
+}
+
+fn editor_popover(app: &mut FigApp, ctx: &egui::Context, popover: InteractionPopover<'_>) {
+ let InteractionPopover { doc, page, node, index: k, interaction: it, anchor: at } = popover;
let t = Tokens::get(ctx);
let width = 280.0;
let screen = ctx.content_rect();
@@ -832,17 +841,20 @@ fn trigger_rows(app: &mut FigApp, ui: &mut Ui, node: NodeId, k: usize, it: &Inte
const PAD_MENU_W: f32 = 92.0;
/// A destination picker for the action at `slot`: `make` builds the action for the destination.
-#[allow(clippy::too_many_arguments)]
-fn dest_dropdown(
- app: &mut FigApp,
- ui: &mut Ui,
- doc: &Document,
- options: &[NodeId],
- cur: Option,
- label: &str,
- make: impl Fn(NodeId) -> Action,
- slot: &Slot,
-) {
+struct DestinationPicker<'a, Make> {
+ doc: &'a Document,
+ options: &'a [NodeId],
+ current: Option,
+ label: &'a str,
+ make: Make,
+ slot: &'a Slot,
+}
+
+fn dest_dropdown(app: &mut FigApp, ui: &mut Ui, picker: DestinationPicker<'_, Make>)
+where
+ Make: Fn(NodeId) -> Action,
+{
+ let DestinationPicker { doc, options, current: cur, label, make, slot } = picker;
widgets::sublabels(ui, label, None);
row(ui, 32.0, |ui| {
let text = cur.map(|d| name_of(doc, d)).unwrap_or_else(|| tr("None").into());
@@ -871,22 +883,45 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
Action::Navigate { dest, preserve_scroll } => {
let (dest, preserve_scroll) = (*dest, *preserve_scroll);
let mk = |d| Action::Navigate { dest: d, preserve_scroll };
- dest_dropdown(app, ui, doc, &nav_targets(doc, page), Some(dest), tr("Destination"), mk, slot);
+ dest_dropdown(
+ app,
+ ui,
+ DestinationPicker { doc, options: &nav_targets(doc, page), current: Some(dest), label: tr("Destination"), make: mk, slot },
+ );
row(ui, 28.0, |ui| {
if widgets::checkbox(ui, preserve_scroll, tr("Preserve scroll position")) {
set_action(app, slot, Action::Navigate { dest, preserve_scroll: !preserve_scroll });
}
});
}
- Action::SwapOverlay { dest } => dest_dropdown(app, ui, doc, &frames, Some(*dest), tr("Overlay"), |d| Action::SwapOverlay { dest: d }, slot),
+ Action::SwapOverlay { dest } => dest_dropdown(
+ app,
+ ui,
+ DestinationPicker { doc, options: &frames, current: Some(*dest), label: tr("Overlay"), make: |d| Action::SwapOverlay { dest: d }, slot },
+ ),
Action::ChangeTo { dest } => {
let vs = variants_for(doc, node);
- dest_dropdown(app, ui, doc, &vs, Some(*dest), tr("Variant"), |d| Action::ChangeTo { dest: d }, slot);
+ dest_dropdown(
+ app,
+ ui,
+ DestinationPicker { doc, options: &vs, current: Some(*dest), label: tr("Variant"), make: |d| Action::ChangeTo { dest: d }, slot },
+ );
}
Action::ScrollTo { dest, offset } => {
let (dest, offset) = (*dest, *offset);
let layers: Vec = doc.top_level_frame(node).map(|f| doc.subtree(f).into_iter().skip(1).take(400).collect()).unwrap_or_default();
- dest_dropdown(app, ui, doc, &layers, Some(dest), tr("Scroll to layer"), move |d| Action::ScrollTo { dest: d, offset }, slot);
+ dest_dropdown(
+ app,
+ ui,
+ DestinationPicker {
+ doc,
+ options: &layers,
+ current: Some(dest),
+ label: tr("Scroll to layer"),
+ make: move |d| Action::ScrollTo { dest: d, offset },
+ slot,
+ },
+ );
// Where the layer ends up from the top-left of what scrolls (room for a fixed header).
widgets::sublabels(ui, tr("Offset"), None);
row(ui, 32.0, |ui| {
@@ -920,7 +955,7 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page:
Action::OpenOverlay { dest, position, close_on_outside, background, offset, anchor } => {
let (dest, position, close_on_outside, background, offset, anchor) = (*dest, *position, *close_on_outside, *background, *offset, *anchor);
let mk = move |d: NodeId| Action::OpenOverlay { dest: d, position, close_on_outside, background, offset, anchor };
- dest_dropdown(app, ui, doc, &frames, Some(dest), tr("Overlay"), mk, slot);
+ dest_dropdown(app, ui, DestinationPicker { doc, options: &frames, current: Some(dest), label: tr("Overlay"), make: mk, slot });
widgets::sublabels(ui, tr("Overlay position"), None);
row(ui, 32.0, |ui| {
let resp = widgets::dropdown(ui, tr(position.label()), ui.available_width(), None);
diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs
index f00a5821..b0c0e995 100644
--- a/crates/ui-egui/src/panels/right.rs
+++ b/crates/ui-egui/src/panels/right.rs
@@ -2,7 +2,11 @@
use egui::{Align2, Color32, CornerRadius, Id, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2};
use figcraft_color::{BlendMode, Paint, PaintKind};
-use figcraft_doc::*;
+use figcraft_doc::{
+ Blur, BlurType, BoolOp, Brush, Document, Effect, EffectKind, ExportFormat, ExportSetting, Glass, GridAlign, GridKind, LayoutGrid,
+ MAX_BLUR_RADIUS, MaskType, Node, NodeId, NodeKind, Noise, NoiseMode, Shadow, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps, Texture,
+ VariableType, WidthPoint, WidthPreset, WidthProfile,
+};
use figcraft_engine::cmd::lists::{moved_index, removed_index};
use serde_json::{Value, json};
diff --git a/crates/ui-egui/src/panels/right/effects.rs b/crates/ui-egui/src/panels/right/effects.rs
index ef62bbbc..f2d48eba 100644
--- a/crates/ui-egui/src/panels/right/effects.rs
+++ b/crates/ui-egui/src/panels/right/effects.rs
@@ -1,5 +1,9 @@
use super::menu::{context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect};
-use super::*;
+use super::{
+ Blur, BlurType, Document, Effect, EffectKind, FieldEdit, FigApp, Glass, GridAlign, GridKind, Icon, Id, LayoutGrid, Lead, ListDrag,
+ MAX_BLUR_RADIUS, NodeId, Noise, NoiseMode, PAD, Paint, Sense, Shadow, StyleSlot, Texture, Ui, VariableType, common, icons, json, move_flags,
+ moved_index, remove_flags, removed_index, style_picker, tr, trf, trn, vec2, widgets,
+};
/// Draw the effect list with add/style/type/toggle/remove/reorder, open settings, and keep an
/// open picker and settings flags aligned when rows move or are removed.
diff --git a/crates/ui-egui/src/panels/right/inspect.rs b/crates/ui-egui/src/panels/right/inspect.rs
index 658028f9..53403a5f 100644
--- a/crates/ui-egui/src/panels/right/inspect.rs
+++ b/crates/ui-egui/src/panels/right/inspect.rs
@@ -1,5 +1,8 @@
use super::menu::{menu_item, menu_popup, row};
-use super::*;
+use super::{
+ Align2, Color32, CornerRadius, Document, ExportFormat, ExportSetting, FigApp, Icon, Id, NodeId, PAD, Paint, Rect, Sense, Stroke, Tip, Tokens, Ui,
+ a11y, icons, pos2, regular, semibold, tr, trf, vec2, widgets,
+};
/// One group of inspected properties: label, value (with a swatch for colours) and the style or
/// variable it comes from. Clicking a row copies its value.
diff --git a/crates/ui-egui/src/panels/right/menu.rs b/crates/ui-egui/src/panels/right/menu.rs
index fed4a960..fb51d857 100644
--- a/crates/ui-egui/src/panels/right/menu.rs
+++ b/crates/ui-egui/src/panels/right/menu.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{BlendMode, Color32, CornerRadius, FIELD_H, Id, PAD, Rect, Sense, Tokens, Ui, a11y, pos2, regular, tr, vec2};
/// The smallest width a popup menu is measured in (see [`menu_popup`]).
const MENU_MIN_W: f32 = 48.0;
diff --git a/crates/ui-egui/src/panels/right/paints.rs b/crates/ui-egui/src/panels/right/paints.rs
index 66db55dd..b947e296 100644
--- a/crates/ui-egui/src/panels/right/paints.rs
+++ b/crates/ui-egui/src/panels/right/paints.rs
@@ -1,6 +1,10 @@
use super::menu::{blend_menu, context_popup, menu_check, menu_item, menu_popup, row, row_context_menu, row_rect};
use super::stroke::stroke_settings;
-use super::*;
+use super::{
+ Align2, BlendMode, CornerRadius, Document, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, ListDrag, MaskType, Node, NodeId, NodeKind, PAD, Paint,
+ PaintKind, Rect, Sense, Stroke, StrokeAlign, StyleSlot, Tip, Tokens, Ui, Value, VariableType, common, icons, json, moved_index, pos2, regular,
+ removed_index, style_picker, tr, trf, vec2, widgets,
+};
/// Draw visibility/blend/mask, opacity, uniform or per-corner radius, corner smoothing and
/// the blend-mode row.
diff --git a/crates/ui-egui/src/panels/right/stroke.rs b/crates/ui-egui/src/panels/right/stroke.rs
index c3bc8642..8576e642 100644
--- a/crates/ui-egui/src/panels/right/stroke.rs
+++ b/crates/ui-egui/src/panels/right/stroke.rs
@@ -1,5 +1,8 @@
use super::menu::{menu_item, menu_item_with, menu_popup, row};
-use super::*;
+use super::{
+ Brush, Color32, Document, FigApp, Icon, Id, Lead, NodeId, NodeKind, PAD, Pos2, Rect, Stroke, StrokeAlign, StrokeCap, StrokeJoin, StrokeProps,
+ Tokens, Ui, Value, WidthPoint, WidthPreset, WidthProfile, a11y, common, icons, json, pos2, tr, widgets,
+};
/// A width profile drawn as a filled band across `rect` (thickest point = full height); `None`
/// draws a uniform stroke.
diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs
index 046428f7..a95af934 100644
--- a/crates/ui-egui/src/panels/typography.rs
+++ b/crates/ui-egui/src/panels/typography.rs
@@ -8,7 +8,10 @@
use std::sync::Arc;
use egui::{Align2, Color32, CornerRadius, Id, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
-use figcraft_doc::*;
+use figcraft_doc::{
+ DecorationLength, Document, LeadingTrim, LetterSpacing, LineHeight, ListStyle, NodeId, NodeKind, TextAlignH, TextAlignV, TextAutoResize,
+ TextCase, TextData, TextDecoration, TextStyle, VariableType, trim_num,
+};
use serde_json::{Value, json};
use crate::FigApp;
diff --git a/crates/ui-egui/src/panels/typography/axes.rs b/crates/ui-egui/src/panels/typography/axes.rs
index 6c042dec..953d6fa1 100644
--- a/crates/ui-egui/src/panels/typography/axes.rs
+++ b/crates/ui-egui/src/panels/typography/axes.rs
@@ -1,5 +1,8 @@
use super::fields::menu_item_hover;
-use super::*;
+use super::{
+ Color32, CornerRadius, FIELD_H, FieldEdit, FigApp, Id, Lead, MIXED, NodeId, Rect, Sense, Stroke, TextStyle, Tokens, Ui, Value, commit_text, json,
+ pos2, regular, text_style_params, tr, trf, trim_num, uniform, vec2, widgets,
+};
/// Type settings ▸ Variable: the font's named instances, then a slider and a field per visible
/// axis (values the style name implies show until changed; "Mixed" when the shown styles differ).
diff --git a/crates/ui-egui/src/panels/typography/fields.rs b/crates/ui-egui/src/panels/typography/fields.rs
index 86fae14e..7104072f 100644
--- a/crates/ui-egui/src/panels/typography/fields.rs
+++ b/crates/ui-egui/src/panels/typography/fields.rs
@@ -2,7 +2,11 @@ use super::axes::variable_axes;
use super::glyphs::{LeadGlyph, caption, feature_row, text_seg};
use super::picker::{font_picker, remember_family};
use super::spacing::{SpacingField, commit_spacing, parse_letter_spacing, parse_line_height, spacing_field, spacing_input};
-use super::*;
+use super::{
+ Arc, DecorationLength, Document, FEATURES, FIELD_H, FieldEdit, FigApp, Icon, Id, Lead, LeadingTrim, ListStyle, MIXED, NodeId, PAD, SIZES, Sense,
+ TABS, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextData, TextDecoration, TextStyle, Tokens, Ui, Value, VariableType, common, field_row,
+ icons, json, live, menu_item, menu_popup, regular, row, semibold, text_style_params, tr, tr_noop, trf, trim_num, uniform, vec2, widgets,
+};
/// The font rows: family, style and size, line height and letter spacing.
pub(super) fn font_rows(
diff --git a/crates/ui-egui/src/panels/typography/glyphs.rs b/crates/ui-egui/src/panels/typography/glyphs.rs
index 3b71b7c4..5b379f4f 100644
--- a/crates/ui-egui/src/panels/typography/glyphs.rs
+++ b/crates/ui-egui/src/panels/typography/glyphs.rs
@@ -4,7 +4,7 @@ use super::axes::round_axis;
use super::picker::{FontPick, font_picker};
#[cfg(test)]
use super::spacing::{parse_letter_spacing, parse_line_height};
-use super::*;
+use super::{Align2, Arc, Color32, CornerRadius, FIELD_H, Icon, Id, Rect, Sense, Stroke, StrokeKind, Tokens, Ui, icons, pos2, regular, tr, vec2};
pub(super) fn caption(ui: &mut Ui, s: &str) {
let t = Tokens::get(ui.ctx());
@@ -147,6 +147,9 @@ pub(super) fn paint_lead(p: &egui::Painter, r: Rect, g: LeadGlyph, col: Color32)
#[cfg(test)]
mod tests {
use super::*;
+ use crate::panels::typography::{target_styles, uniform};
+ use figcraft_doc::{Document, LetterSpacing, LineHeight, Node, NodeKind, TextData, TextStyle};
+ use serde_json::json;
#[test]
fn small_caps_sample_draws_only_letters_the_ui_font_has() {
diff --git a/crates/ui-egui/src/panels/typography/picker.rs b/crates/ui-egui/src/panels/typography/picker.rs
index c7d8bea2..1dc88456 100644
--- a/crates/ui-egui/src/panels/typography/picker.rs
+++ b/crates/ui-egui/src/panels/typography/picker.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Align2, CornerRadius, Icon, Id, Rect, Sense, Tokens, Ui, icons, pos2, regular, semibold, tr, vec2, widgets};
// ---- Font picker ----
diff --git a/crates/ui-egui/src/panels/typography/spacing.rs b/crates/ui-egui/src/panels/typography/spacing.rs
index 971a3c0f..c6bc7a51 100644
--- a/crates/ui-egui/src/panels/typography/spacing.rs
+++ b/crates/ui-egui/src/panels/typography/spacing.rs
@@ -1,5 +1,8 @@
use super::glyphs::{LeadGlyph, paint_lead};
-use super::*;
+use super::{
+ Align2, CornerRadius, FIELD_H, FieldEdit, FigApp, Id, LetterSpacing, LineHeight, MIXED, NodeId, Rect, Sense, Stroke, StrokeKind, Tokens, Ui,
+ Value, VariableType, commit_text, json, live, pos2, regular, tr, trim_num, vec2, widgets,
+};
/// Line height, letter spacing or paragraph spacing: property, field id, glyph and tooltip.
pub(super) struct SpacingField {
diff --git a/crates/ui-egui/src/present/actions.rs b/crates/ui-egui/src/present/actions.rs
index 97f94eeb..ad2c646e 100644
--- a/crates/ui-egui/src/present/actions.rs
+++ b/crates/ui-egui/src/present/actions.rs
@@ -1,4 +1,17 @@
-use super::*;
+use super::{
+ Action, Document, Easing, FrameTex, NodeId, NodeKind, OverlayAnchor, OverlayPosition, OverlayState, Presenter, Rect, ScrollAnim, Temp,
+ Transition, Trigger, Waiting,
+};
+
+#[derive(Clone, Copy)]
+struct ActionTiming {
+ transition: Transition,
+ duration: f64,
+ easing: Easing,
+ rect: Option,
+ now: f64,
+ depth: usize,
+}
impl Presenter {
/// A mouse enter, leave, down or up on `node` (shown on `root`) met its interaction `k`: its
@@ -68,12 +81,12 @@ impl Presenter {
}
pub(crate) fn perform(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option, now: f64) -> bool {
- self.perform_at(a, node, tr, dur, easing, rect, now, 0)
+ self.perform_at(a, node, ActionTiming { transition: tr, duration: dur, easing, rect, now, depth: 0 })
}
/// One action; `depth` counts the conditional actions it is inside.
- #[allow(clippy::too_many_arguments)]
- fn perform_at(&mut self, a: &Action, node: NodeId, tr: Transition, dur: f64, easing: Easing, rect: Option, now: f64, depth: usize) -> bool {
+ fn perform_at(&mut self, a: &Action, node: NodeId, timing: ActionTiming) -> bool {
+ let ActionTiming { transition: tr, duration: dur, easing, rect, now, depth } = timing;
let Some(doc) = self.doc.clone() else { return false };
match a {
Action::Navigate { dest, preserve_scroll } => {
@@ -213,7 +226,7 @@ impl Presenter {
// Evaluated now: earlier actions of the interaction may have changed variables.
let mut did = false;
for inner in figcraft_engine::proto_run::branch(&doc, node, blocks) {
- did |= self.perform_at(&inner, node, tr, dur, easing, rect, now, depth + 1);
+ did |= self.perform_at(&inner, node, ActionTiming { depth: depth + 1, ..timing });
}
did
}
diff --git a/crates/ui-egui/src/present/chrome.rs b/crates/ui-egui/src/present/chrome.rs
index 80386eb7..eeaba1ce 100644
--- a/crates/ui-egui/src/present/chrome.rs
+++ b/crates/ui-egui/src/present/chrome.rs
@@ -1,7 +1,10 @@
use super::layout::{Chrome, FULL, Place};
use super::player::player;
use super::scroll::page_settings;
-use super::*;
+use super::{
+ Align2, Color32, CornerRadius, Document, Easing, FigApp, HashSet, Icon, Id, NodeId, Pos2, PresentScale, Presenter, PrototypeSettings, Rect,
+ Sense, Stroke, Tokens, Transition, Ui, icons, json, pos2, regular, tr, vec2, widgets,
+};
/// What the player's top bar asked for.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
diff --git a/crates/ui-egui/src/present/device.rs b/crates/ui-egui/src/present/device.rs
index 327fdd66..9827c225 100644
--- a/crates/ui-egui/src/present/device.rs
+++ b/crates/ui-egui/src/present/device.rs
@@ -1,5 +1,5 @@
use super::layout::{bezel, upright_w};
-use super::*;
+use super::{Color32, CornerRadius, DeviceColor, Rect, Stroke, StrokeKind, pos2, vec2};
/// The black glass around a device's screen.
const GLASS: Color32 = Color32::from_rgb(0x16, 0x16, 0x18);
diff --git a/crates/ui-egui/src/present/draw.rs b/crates/ui-egui/src/present/draw.rs
index 271f8af7..b4d67914 100644
--- a/crates/ui-egui/src/present/draw.rs
+++ b/crates/ui-egui/src/present/draw.rs
@@ -1,7 +1,7 @@
use super::actions::texture_scale;
use super::layout::{Layout, Place, hotspots};
use super::transition::{smart_interpolate, transition_frames};
-use super::*;
+use super::{ACCENT, Color32, CornerRadius, FLASH_SECS, NodeId, OverlayState, Presenter, Rect, Stroke, StrokeKind, Transition, pos2, vec2};
pub(super) fn draw(p: &mut Presenter, painter: &egui::Painter, ctx: &egui::Context, l: &Layout, now: f64) {
let ppp = f64::from(ctx.pixels_per_point());
diff --git a/crates/ui-egui/src/present/input.rs b/crates/ui-egui/src/present/input.rs
index 42df2286..bfebd4b1 100644
--- a/crates/ui-egui/src/present/input.rs
+++ b/crates/ui-egui/src/present/input.rs
@@ -1,20 +1,174 @@
use super::actions::find_trigger;
use super::chrome::layer_at;
-use super::layout::{Layout, hotspots};
+use super::layout::{Layout, Place, hotspots};
use super::navigate::next_delay;
use super::scroll::{COAST_MIN, DECAY_DRAG, DECAY_WHEEL, RUBBER_PTS, clamp_scroll, page_settings, rubber_band, scrollers_at, units_per_point};
use super::transition::{direction_vec, lerp};
-use super::*;
+use super::{Document, Easing, FLASH_SECS, Fling, KeyMods, NodeId, Pos2, Presenter, Rect, Scroller, Transition, Trigger, Vec2, vec2};
/// Keep a key the prototype used from reaching the editor's shortcuts.
fn consume_key(ctx: &egui::Context, key: egui::Key) {
ctx.input_mut(|i| i.events.retain(|e| !matches!(e, egui::Event::Key { key: k, .. } if *k == key)));
}
+/// A transition a drag started follows the pointer while it stays down, then plays on from there.
+fn follow_drag(p: &mut Presenter, pos: Option, down: bool, now: f64) {
+ if let Some(a) = p.anim.as_mut()
+ && let Some((from, dir, len)) = a.follow
+ {
+ match pos.filter(|_| down) {
+ Some(q) => a.start = now - f64::from(((q - from).dot(dir) / len).clamp(0.0, 1.0)) * a.dur,
+ None => a.follow = None,
+ }
+ }
+}
+
+/// A drag that scrolled lets go: it carries on at the pointer's speed (and springs back from
+/// past the ends).
+fn release_drag_scroll(p: &mut Presenter, l: &Layout, down: bool, velocity: Vec2, now: f64) {
+ if !down && let Some((t, _)) = p.drag_scroll.take() {
+ let u = units_per_point(t, l.scale);
+ let r = p.doc.as_ref().map_or((0.0, 0.0), |b| p.limits(b, t));
+ let along = |v: f32, r: f64| if r > 0.0 { -f64::from(v) * u } else { 0.0 };
+ p.fling = Some(Fling { target: t, vel: (along(velocity.x, r.0), along(velocity.y, r.1)), decay: DECAY_DRAG, bounce: true, at: now });
+ }
+}
+
+/// Scrolls the first of `targets` the wheel can still move, and smooths the wheel's speed over
+/// ~50 ms whatever the frame rate.
+fn wheel_scroll(p: &mut Presenter, l: &Layout, base: &Document, targets: Vec, wheel: Vec2, dt: f64) {
+ let mut moved = None;
+ for t in targets {
+ let u = units_per_point(t, l.scale);
+ let cur = p.offset(t);
+ let next = clamp_scroll((cur.0 - f64::from(wheel.x) * u, cur.1 - f64::from(wheel.y) * u), p.limits(base, t));
+ if (next.0 - cur.0).abs() > 1e-6 || (next.1 - cur.1).abs() > 1e-6 {
+ p.set_offset(t, next);
+ p.scroll_anim = None;
+ p.fling = None;
+ moved = Some((t, ((next.0 - cur.0) / dt, (next.1 - cur.1) / dt)));
+ break;
+ }
+ }
+ // The wheel's speed, smoothed over ~50 ms whatever the frame rate.
+ p.wheel = moved.map(|(t, inst)| {
+ let prev = p.wheel.filter(|w| w.0 == t).map_or(inst, |w| w.1);
+ let a = 1.0 - (-dt / 0.05).exp();
+ (t, (lerp(prev.0, inst.0, a), lerp(prev.1, inst.1, a)))
+ });
+}
+
+/// A drag begun inside a mobile screen moves its content, even if the pointer leaves the
+/// screen. The device body stays in place even when the drag begins outside its screen.
+/// Panning the whole view remains available only without a mobile device.
+fn begin_drag_scroll(p: &mut Presenter, doc: &Document, root: NodeId, place: &Place, start: Pos2, d: Vec2) {
+ if let Some(base) = p.doc.clone() {
+ let horizontal = d.x.abs() >= d.y.abs();
+ let scrolls = scrollers_at(p, &base, doc, root, place, start);
+ let on_axis = |range: (f64, f64)| if horizontal { range.0 > 0.0 } else { range.1 > 0.0 };
+ let mobile = page_settings(&base, root).device().is_some_and(|device| device.rotates);
+ let target = scrolls
+ .iter()
+ .copied()
+ .find(|f| on_axis(p.range(&base, *f)))
+ .or_else(|| scrolls.first().copied())
+ .map(Scroller::Frame)
+ .or((!mobile && p.pan_range != (0.0, 0.0)).then_some(Scroller::View));
+ if let Some(t) = target {
+ p.fling = None;
+ p.drag_scroll = Some((t, clamp_scroll(p.offset(t), p.limits(&base, t))));
+ }
+ }
+}
+
+/// Dragging scrolls with the pointer; past the ends it stretches.
+fn drag_scrolls(p: &mut Presenter, ctx: &egui::Context, l: &Layout, down: bool, pos: Option) {
+ if down
+ && let (Some((t, from)), Some(start), Some(q)) = (p.drag_scroll, p.press, pos)
+ && let Some(base) = p.doc.as_ref()
+ {
+ let (d, u) = (q - start, units_per_point(t, l.scale));
+ let r = p.limits(base, t);
+ let limit = RUBBER_PTS * u;
+ let to = (rubber_band(from.0 - f64::from(d.x) * u, r.0, limit), rubber_band(from.1 - f64::from(d.y) * u, r.1, limit));
+ p.set_offset(t, to);
+ p.scroll_anim = None;
+ ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
+ }
+}
+
+/// The player's own keys, when the prototype doesn't use them: R restarts, Z cycles the
+/// full-window presenter's size options. True when `key` was used.
+fn player_key(p: &mut Presenter, ctx: &egui::Context, l: &Layout, key: &egui::Key, mods: &egui::Modifiers, pane: bool, now: f64) -> bool {
+ if mods.is_none() {
+ match key {
+ egui::Key::R => {
+ p.restart(p.start, now);
+ consume_key(ctx, *key);
+ return true;
+ }
+ // (A responsive frame filling the window has no size options.)
+ egui::Key::Z if !pane && (l.device.is_some() || p.fit.is_none()) => {
+ p.set_scale(p.view.scale.next(l.device.is_some()));
+ consume_key(ctx, *key);
+ return true;
+ }
+ _ => {}
+ }
+ }
+ false
+}
+
+/// The arrows (and Space or N forward) step through top-level frames when nothing else uses
+/// them. True when `key` stepped.
+fn step_frames(p: &mut Presenter, ctx: &egui::Context, key: &egui::Key, mods: &egui::Modifiers, now: f64) -> bool {
+ let forward = match key {
+ egui::Key::ArrowRight => Some(true),
+ egui::Key::Space | egui::Key::N if mods.is_none() => Some(true),
+ egui::Key::ArrowLeft => Some(false),
+ _ => None,
+ };
+ if let Some(forward) = forward
+ && p.overlays.is_empty()
+ && let Some(base) = p.doc.as_ref()
+ {
+ let frames = base.top_frames(base.page_of(p.current).unwrap_or(p.current));
+ if let Some(i) = frames.iter().position(|f| *f == p.current) {
+ let next = if forward { frames.get(i + 1) } else { i.checked_sub(1).and_then(|j| frames.get(j)) };
+ if let Some(n) = next.copied() {
+ p.go(n, Transition::Instant, 0.0, Easing::Linear, now, true, false);
+ consume_key(ctx, *key);
+ return true;
+ }
+ }
+ }
+ false
+}
+
+/// Controller buttons: pressed alone, anywhere on the screen, like keys. True when one fired.
+fn fire_pads(p: &mut Presenter, doc: &Document, root: NodeId, pads: &[String], now: f64) -> bool {
+ for name in pads {
+ let found = doc.subtree(root).into_iter().find_map(|id| find_trigger(doc, id, |t| t.fires_on_key(name, KeyMods::default())).map(|k| (id, k)));
+ if let Some((id, k)) = found
+ && p.fire(id, k, None, now)
+ {
+ return true;
+ }
+ }
+ false
+}
+
/// Pointer, wheel, keys and timers. `over_area`: the pointer is over the player; `keys_on`: the
/// player takes the keyboard; `pane`: it plays in the preview pane (which always fits).
-#[allow(clippy::too_many_arguments)]
-pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_area: bool, keys_on: bool, pane: bool, now: f64) {
+pub(super) struct InputMode {
+ pub over_area: bool,
+ pub keys_on: bool,
+ pub pane: bool,
+ pub now: f64,
+}
+
+pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, mode: InputMode) {
+ let InputMode { over_area, keys_on, pane, now } = mode;
let (pos, pressed, released, down, wheel, velocity, dt, keys) = ctx.input(|i| {
let keys: Vec<(egui::Key, egui::Modifiers)> = i
.events
@@ -39,24 +193,9 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
// Controller buttons pressed while a link notice shows are dropped, as keys are.
let pads = std::mem::take(&mut p.pads);
let pads = if p.link_notice.is_none() { pads } else { Vec::new() };
- // A transition a drag started follows the pointer while it stays down, then plays on from there.
- if let Some(a) = p.anim.as_mut()
- && let Some((from, dir, len)) = a.follow
- {
- match pos.filter(|_| down) {
- Some(q) => a.start = now - f64::from(((q - from).dot(dir) / len).clamp(0.0, 1.0)) * a.dur,
- None => a.follow = None,
- }
- }
+ follow_drag(p, pos, down, now);
let pos = pos.filter(|_| over_area || p.press.is_some());
- // A drag that scrolled lets go: it carries on at the pointer's speed (and springs back from
- // past the ends).
- if !down && let Some((t, _)) = p.drag_scroll.take() {
- let u = units_per_point(t, l.scale);
- let r = p.doc.as_ref().map_or((0.0, 0.0), |b| p.limits(b, t));
- let along = |v: f32, r: f64| if r > 0.0 { -f64::from(v) * u } else { 0.0 };
- p.fling = Some(Fling { target: t, vel: (along(velocity.x, r.0), along(velocity.y, r.1)), decay: DECAY_DRAG, bounce: true, at: now });
- }
+ release_drag_scroll(p, l, down, velocity, now);
// The active layer: the top overlay, else the frame.
let (root, place, clip) = l.active(p.current);
let doc = &l.disp;
@@ -83,25 +222,7 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
if p.pan_range != (0.0, 0.0) {
targets.push(Scroller::View);
}
- let mut moved = None;
- for t in targets {
- let u = units_per_point(t, l.scale);
- let cur = p.offset(t);
- let next = clamp_scroll((cur.0 - f64::from(wheel.x) * u, cur.1 - f64::from(wheel.y) * u), p.limits(&base, t));
- if (next.0 - cur.0).abs() > 1e-6 || (next.1 - cur.1).abs() > 1e-6 {
- p.set_offset(t, next);
- p.scroll_anim = None;
- p.fling = None;
- moved = Some((t, ((next.0 - cur.0) / dt, (next.1 - cur.1) / dt)));
- break;
- }
- }
- // The wheel's speed, smoothed over ~50 ms whatever the frame rate.
- p.wheel = moved.map(|(t, inst)| {
- let prev = p.wheel.filter(|w| w.0 == t).map_or(inst, |w| w.1);
- let a = 1.0 - (-dt / 0.05).exp();
- (t, (lerp(prev.0, inst.0, a), lerp(prev.1, inst.1, a)))
- });
+ wheel_scroll(p, l, &base, targets, wheel, dt);
} else if wheel == Vec2::ZERO
&& let Some((t, v)) = p.wheel.take()
&& v.0.hypot(v.1) > COAST_MIN * units_per_point(t, l.scale)
@@ -202,37 +323,10 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
// A drag begun inside a mobile screen moves its content, even if the pointer leaves the
// screen. The device body stays in place even when the drag begins outside its screen.
// Panning the whole view remains available only without a mobile device.
- if let Some(base) = p.doc.clone() {
- let horizontal = d.x.abs() >= d.y.abs();
- let scrolls = scrollers_at(p, &base, doc, root, &place, start);
- let on_axis = |range: (f64, f64)| if horizontal { range.0 > 0.0 } else { range.1 > 0.0 };
- let mobile = page_settings(&base, root).device().is_some_and(|device| device.rotates);
- let target = scrolls
- .iter()
- .copied()
- .find(|f| on_axis(p.range(&base, *f)))
- .or_else(|| scrolls.first().copied())
- .map(Scroller::Frame)
- .or((!mobile && p.pan_range != (0.0, 0.0)).then_some(Scroller::View));
- if let Some(t) = target {
- p.fling = None;
- p.drag_scroll = Some((t, clamp_scroll(p.offset(t), p.limits(&base, t))));
- }
- }
+ begin_drag_scroll(p, doc, root, &place, start, d);
}
// Dragging scrolls with the pointer; past the ends it stretches.
- if down
- && let (Some((t, from)), Some(start), Some(q)) = (p.drag_scroll, p.press, pos)
- && let Some(base) = p.doc.as_ref()
- {
- let (d, u) = (q - start, units_per_point(t, l.scale));
- let r = p.limits(base, t);
- let limit = RUBBER_PTS * u;
- let to = (rubber_band(from.0 - f64::from(d.x) * u, r.0, limit), rubber_band(from.1 - f64::from(d.y) * u, r.1, limit));
- p.set_offset(t, to);
- p.scroll_anim = None;
- ctx.set_cursor_icon(egui::CursorIcon::Grabbing);
- }
+ drag_scrolls(p, ctx, l, down, pos);
if released && p.press.is_some() {
let dragged = p.dragged;
p.press = None;
@@ -262,14 +356,8 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
}
}
}
- // Controller buttons: pressed alone, anywhere on the screen, like keys.
- for name in &pads {
- let found = doc.subtree(root).into_iter().find_map(|id| find_trigger(doc, id, |t| t.fires_on_key(name, KeyMods::default())).map(|k| (id, k)));
- if let Some((id, k)) = found
- && p.fire(id, k, None, now)
- {
- return;
- }
+ if fire_pads(p, doc, root, &pads, now) {
+ return;
}
// Keys.
for (key, mods) in &keys {
@@ -282,45 +370,8 @@ pub(super) fn input(p: &mut Presenter, ctx: &egui::Context, l: &Layout, over_are
}
continue;
}
- // The player's own keys, when the prototype doesn't use them: R restarts, Z cycles the
- // full-window presenter's size options.
- if mods.is_none() {
- match key {
- egui::Key::R => {
- p.restart(p.start, now);
- consume_key(ctx, *key);
- return;
- }
- // (A responsive frame filling the window has no size options.)
- egui::Key::Z if !pane && (l.device.is_some() || p.fit.is_none()) => {
- p.set_scale(p.view.scale.next(l.device.is_some()));
- consume_key(ctx, *key);
- return;
- }
- _ => {}
- }
- }
- // The arrows (and Space or N forward) step through top-level frames when nothing else
- // uses them.
- let forward = match key {
- egui::Key::ArrowRight => Some(true),
- egui::Key::Space | egui::Key::N if mods.is_none() => Some(true),
- egui::Key::ArrowLeft => Some(false),
- _ => None,
- };
- if let Some(forward) = forward
- && p.overlays.is_empty()
- && let Some(base) = p.doc.as_ref()
- {
- let frames = base.top_frames(base.page_of(p.current).unwrap_or(p.current));
- if let Some(i) = frames.iter().position(|f| *f == p.current) {
- let next = if forward { frames.get(i + 1) } else { i.checked_sub(1).and_then(|j| frames.get(j)) };
- if let Some(n) = next.copied() {
- p.go(n, Transition::Instant, 0.0, Easing::Linear, now, true, false);
- consume_key(ctx, *key);
- return;
- }
- }
+ if player_key(p, ctx, l, key, mods, pane, now) || step_frames(p, ctx, key, mods, now) {
+ return;
}
}
// After delay (timers start when the frame appears; not while animating).
diff --git a/crates/ui-egui/src/present/layout.rs b/crates/ui-egui/src/present/layout.rs
index 9584fc81..1844c4f3 100644
--- a/crates/ui-egui/src/present/layout.rs
+++ b/crates/ui-egui/src/present/layout.rs
@@ -1,5 +1,8 @@
use super::scroll::{clamp_scroll, page_settings};
-use super::*;
+use super::{
+ Device, DeviceColor, Document, NodeId, OverlayState, Point, Pos2, PresentScale, PresentView, Presenter, Rect, Scroller, Vec2,
+ anchored_overlay_rect, pos2, vec2,
+};
/// Page → screen mapping for one presented frame.
#[derive(Clone, Copy, Debug)]
diff --git a/crates/ui-egui/src/present/navigate.rs b/crates/ui-egui/src/present/navigate.rs
index a320e80d..75969485 100644
--- a/crates/ui-egui/src/present/navigate.rs
+++ b/crates/ui-egui/src/present/navigate.rs
@@ -1,5 +1,5 @@
use super::scroll::{apply_scroll, clamp_scroll, fit_viewport, page_settings, raise_fixed, viewport};
-use super::*;
+use super::{Anim, Arc, Document, Easing, NodeId, Presenter, Scroller, Snapshot, Transition, Trigger, Value, Visit, json};
impl Presenter {
/// Close the top overlay; it animates out with its opening transition reversed.
diff --git a/crates/ui-egui/src/present/player.rs b/crates/ui-egui/src/present/player.rs
index dc47baa7..006d9521 100644
--- a/crates/ui-egui/src/present/player.rs
+++ b/crates/ui-egui/src/present/player.rs
@@ -1,12 +1,15 @@
use super::chrome::{BarAction, layer_at, paint_menu_chevron};
use super::device::{device_body, device_glass};
use super::draw::draw;
-use super::input::input;
+use super::input::{InputMode, input};
use super::layout::{Chrome, Layout, layout};
use super::navigate::next_delay;
use super::scroll::page_settings;
use super::transition::lerp;
-use super::*;
+use super::{
+ ACCENT, Align2, Arc, Color32, CornerRadius, Device, Icon, NodeKind, PresentScale, Presenter, Rect, Sense, Stroke, StrokeKind, Tip, Tokens, Ui,
+ icons, pos2, regular, semibold, tr, tr_noop, vec2,
+};
/// One frame of the player in `rect`: the top bar, then the prototype (on its device) below.
/// Returns what the bar asked for (restarting is done here).
@@ -55,7 +58,7 @@ pub(super) fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrom
// The pane only takes the keyboard while the pointer is over it. While a link notice shows
// the prototype takes neither keys nor the pointer (Escape there cancels the notice).
let keys = chrome.keys && p.link_notice.is_none() && (!chrome.pane || over);
- input(p, &ctx, &l, over && p.link_notice.is_none(), keys, chrome.pane, now);
+ input(p, &ctx, &l, InputMode { over_area: over && p.link_notice.is_none(), keys_on: keys, pane: chrome.pane, now });
} else {
p.pads.clear();
}
diff --git a/crates/ui-egui/src/present/scroll.rs b/crates/ui-egui/src/present/scroll.rs
index 12c682cc..ea3db153 100644
--- a/crates/ui-egui/src/present/scroll.rs
+++ b/crates/ui-egui/src/present/scroll.rs
@@ -1,5 +1,8 @@
use super::layout::Place;
-use super::*;
+use super::{
+ Affine, Document, HConstraint, HashMap, NodeId, NodeKind, Overflow, Pos2, Presenter, PrototypeSettings, ScrollMove, ScrollPosition, Scroller,
+ VConstraint,
+};
/// How quickly a fling slows down after a drag (1/s; it travels its speed / decay).
pub(super) const DECAY_DRAG: f64 = 2.5;
diff --git a/crates/ui-egui/src/present/transition.rs b/crates/ui-egui/src/present/transition.rs
index e31078d2..09403c1f 100644
--- a/crates/ui-egui/src/present/transition.rs
+++ b/crates/ui-egui/src/present/transition.rs
@@ -1,4 +1,7 @@
-use super::*;
+use super::{
+ Affine, BTreeMap, Direction, Document, HashMap, HashSet, Node, NodeId, OverlayPosition, Paint, PaintKind, Point, Rect, Transition, Vec2,
+ VecDeque, pos2,
+};
// ---------------------------------------------------------------------------------------------
// Pure prototype logic (tested below).
diff --git a/crates/ui-egui/src/services.rs b/crates/ui-egui/src/services.rs
index d7490de4..e5f915b2 100644
--- a/crates/ui-egui/src/services.rs
+++ b/crates/ui-egui/src/services.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Arc, FigApp, Future, PathBuf, Pin, Session, Value, Wake, i18n, json};
/// Platform services the app crate provides (file dialogs, clipboard, URLs).
pub trait Services {
diff --git a/crates/ui-egui/src/tabs.rs b/crates/ui-egui/src/tabs.rs
index 6f17910a..1a4c9f9b 100644
--- a/crates/ui-egui/src/tabs.rs
+++ b/crates/ui-egui/src/tabs.rs
@@ -105,21 +105,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let rect = ui.max_rect();
let buttons_w = if app.ui.title_bar == TitleBar::Custom { window_buttons(app, ui, rect) } else { 0.0 };
let mut x = rect.min.x + if app.ui.title_bar == TitleBar::Inset { 76.0 } else { 8.0 };
- // Home.
- let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT));
- let hresp = ui.interact(hr, ui.scope_id().with("home"), Sense::click()).tip(tr("Home"));
- let home_active = (app.ui.home || !app.has_files()) && app.ui.present.is_none();
- crate::a11y::tab(&hresp, tr("Home"), home_active);
- if home_active {
- ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active);
- } else if hresp.hovered() {
- ui.painter().rect_filled(hr, CornerRadius::ZERO, t.hover);
- }
- icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon);
- if hresp.clicked() {
- pause_present(app);
- app.ui.home = true;
- }
+ home_tab(app, ui, &t, x, rect);
x += 44.0;
ui.painter().line_segment([pos2(x, rect.min.y), pos2(x, rect.max.y)], Stroke::new(1.0, t.border));
let document_tabs: Vec = app.session.docs.iter().enumerate().filter_map(|(i, d)| app.shows(d.uid).then_some(i)).collect();
@@ -133,15 +119,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
motion.begin_frame(current_uids, now, reduce_motion);
motion.opening.clone()
});
- let preferred: Vec = document_tabs
- .iter()
- .map(|&i| {
- let d = &app.session.docs[i];
- let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 };
- let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 };
- (regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0)
- })
- .collect();
+ let preferred: Vec = document_tabs.iter().map(|&i| preferred_width(app, i)).collect();
// Leave the new-tab button, theme toggle, window controls and a draggable gap free.
let room = (rect.max.x - buttons_w - 70.0 - x - 34.0).max(0.0);
let present_pref: Vec = app.ui.presents.iter().map(present_tab_width).collect();
@@ -172,25 +150,17 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
let name = &d.doc.name;
// In a live session: a mark after the name says whether others get in.
let live = live_mark(app, d.uid);
- let mark_w = if live.is_some() { 18.0 } else { 0.0 };
let dirty = d.is_dirty();
let r = tab_rects[slot];
let body_r = Rect::from_min_max(r.min, pos2(r.max.x - 32.0, r.max.y));
let close_r = Rect::from_min_max(body_r.right_top(), r.max);
let resp = ui.interact(body_r, ui.scope_id().with(("tab", d.uid)), Sense::click_and_drag());
let cresp = ui.interact(close_r, ui.scope_id().with(("tabclose", d.uid)), Sense::click()).tip(tr("Close tab"));
- if (resp.dragged() || resp.drag_stopped())
- && let Some(p) = ui.input(|inp| inp.pointer.latest_pos())
- {
- if resp.drag_stopped() {
- if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 {
- detach = Some((i, Some(p)));
- } else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) {
- dropped = Some((i, p.x));
- }
- } else {
- dragging = Some((i, p));
- }
+ match tab_gesture(ui, &resp, rect, several) {
+ Some(TabGesture::Detach(p)) => detach = Some((i, Some(p))),
+ Some(TabGesture::Drop(x)) => dropped = Some((i, x)),
+ Some(TabGesture::Drag(p)) => dragging = Some((i, p)),
+ None => {}
}
if several {
resp.context_menu(|ui| {
@@ -201,51 +171,20 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
});
}
- let label = match live {
- Some(true) => trf("{name}, in a live session", &[("name", name)]),
- Some(false) => trf("{name}, online, open only to you", &[("name", name)]),
- None => name.clone(),
- };
+ let label = tab_label(name, live);
let resp = if live.is_some() { resp.on_hover_text(&label) } else { resp };
crate::a11y::tab(&resp, &label, active);
let progress = open_progress(&opening, d.uid, now);
- let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0));
- let painter = ui.painter().with_clip_rect(r);
- if active {
- painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress));
- } else if resp.hovered() || cresp.hovered() {
- painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress));
- }
- icons::paint(
- &painter,
- Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)),
- icons::file_mark(d.doc.kind),
- crate::theme::ACCENT.linear_multiply(progress),
- );
- // The unsaved-changes dot follows the name on screen, whichever way the name reads.
- let shown = crate::bidi::paint_name(
- &painter,
- pos2(paint_r.min.x + 32.0, paint_r.center().y),
+ let tab = TabPaint {
name,
- regular(12.0),
- t.text.linear_multiply(progress),
- (w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0),
- );
- if let Some(shared) = live {
- let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
- icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress));
- }
- if dirty {
- painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress));
- }
- let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0));
- // The close target never overlaps the draggable body, including on inactive tabs.
- if resp.hovered() || cresp.hovered() || active {
- if cresp.hovered() {
- painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress));
- }
- icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress));
- }
+ kind: d.doc.kind,
+ rects: [r, close_r],
+ width: w,
+ state: [active, resp.hovered(), cresp.hovered(), dirty],
+ live,
+ progress,
+ };
+ paint_tab(ui, &t, &tab);
if cresp.clicked() {
close = Some(i);
}
@@ -267,26 +206,8 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
}
let reorder = dropped.and_then(|(from, at)| tab_drop_position(&visible, from, at).map(|(to, _)| (from, to)));
if layout.overflow {
- let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT));
- let resp = ui.interact(r, ui.scope_id().with("tab_overflow"), Sense::click()).tip(tr("Open files"));
- crate::a11y::menu_button(&resp, tr("Open files"), crate::a11y::menu_open(&resp));
- if resp.hovered() {
- ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
- }
- icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon);
- crate::right::menu_popup(&resp).show(|ui| {
- egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
- for &i in &document_tabs {
- let Some(d) = app.session.docs.get(i) else { continue };
- let on = i == app.session.active && !app.ui.home && app.ui.present.is_none();
- let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() };
- if crate::right::menu_check(ui, on, &label, "") {
- switch = Some(i);
- ui.close();
- }
- }
- });
- });
+ let resp = overflow_button(ui, &t, pos2(x, rect.min.y), "tab_overflow", tr("Open files"));
+ switch = doc_overflow_menu(app, &resp, &document_tabs).or(switch);
x += OVERFLOW_WIDTH;
}
// Presentations live in their own tabs. Switching back to the design keeps the player
@@ -306,26 +227,8 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
ui.painter().line_segment([pos2(x, rect.min.y + 10.0), pos2(x, rect.max.y - 10.0)], Stroke::new(1.0, t.border));
}
if present_layout.overflow {
- let r = Rect::from_min_size(pos2(x, rect.min.y), vec2(OVERFLOW_WIDTH, HEIGHT));
- let resp = ui.interact(r, ui.scope_id().with("present_overflow"), Sense::click()).tip(tr("Open presentations"));
- crate::a11y::menu_button(&resp, tr("Open presentations"), crate::a11y::menu_open(&resp));
- if resp.hovered() {
- ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
- }
- icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon);
- crate::right::menu_popup(&resp).show(|ui| {
- egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
- for tab in &app.ui.presents {
- let on = present_on == Some(tab.id);
- if crate::right::menu_check(ui, on, &present_tab_name(tab), "") {
- if !on {
- to_present = Some(tab.id);
- }
- ui.close();
- }
- }
- });
- });
+ let resp = overflow_button(ui, &t, pos2(x, rect.min.y), "present_overflow", tr("Open presentations"));
+ to_present = present_overflow_menu(app, &resp, present_on).or(to_present);
x += OVERFLOW_WIDTH;
}
// New tab.
@@ -340,28 +243,9 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
app.run("file.new", json!({}));
app.ui.home = false;
}
- // Right side: theme toggle (left of the window buttons).
- let theme_r = Rect::from_center_size(pos2(rect.max.x - buttons_w - 24.0, rect.center().y), vec2(28.0, 28.0));
- let tresp = ui.interact(theme_r, ui.scope_id().with("theme"), Sense::click()).tip(tr("Toggle light/dark theme"));
- if tresp.hovered() {
- ui.painter().rect_filled(theme_r, CornerRadius::same(5), t.hover);
- }
- icons::paint(ui.painter(), theme_r.shrink(7.0), if app.ui.dark { Icon::Sun } else { Icon::Moon }, t.icon);
- if tresp.clicked() {
- let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" };
- app.run(cmd, json!({}));
- }
- // Only the free space drags the window. A full-bar drag target steals tab drags.
- if app.ui.title_bar != TitleBar::Native && x + 34.0 < theme_r.min.x {
- let free = Rect::from_min_max(pos2(x + 34.0, rect.min.y), pos2(theme_r.min.x, rect.max.y));
- let drag = ui.interact(free, ui.scope_id().with("titlebar_drag"), Sense::click_and_drag());
- if drag.drag_started() {
- ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
- }
- if drag.double_clicked() {
- let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
- ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
- }
+ let theme_r = theme_toggle(app, ui, &t, rect, buttons_w);
+ if app.ui.title_bar != TitleBar::Native {
+ drag_free_space(ui, rect, x + 34.0, theme_r.min.x);
}
if let Some(i) = switch {
pause_present(app);
@@ -374,11 +258,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
show_present(app, id);
}
if let Some((i, at)) = detach {
- // Where the pointer let go, on the screen.
- let at = at.and_then(|p| ui.input(|inp| inp.viewport().inner_rect).map(|r| r.min + p.to_vec2() - vec2(60.0, HEIGHT / 2.0)));
- if let Err(e) = crate::windows::open(app, ui.ctx(), Some(i), None, at) {
- app.toast(e);
- }
+ detach_tab(app, ui, i, at);
}
if let Some((from, to)) = reorder
&& from != to
@@ -386,20 +266,7 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
app.run("file.moveTab", json!({"from": from, "to": to}));
}
if let Some(i) = close {
- // A branch tab's edits live on in its main file: closing it loses nothing.
- let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none());
- if dirty {
- app.ui.find = None;
- app.toast(trf(
- "Unsaved changes: save first, or close again with {key} to discard",
- &[("key", &crate::toolbar::pretty_shortcut("Alt"))],
- ));
- if ui.input(|inp| inp.modifiers.alt) {
- app.run("file.close", json!({"index": i, "force": true}));
- }
- } else {
- app.run("file.close", json!({"index": i}));
- }
+ close_tab(app, ui, i);
}
if !opening.is_empty() {
ui.ctx().request_repaint();
@@ -407,6 +274,233 @@ pub fn tab_bar(app: &mut FigApp, ui: &mut Ui) {
});
}
+/// The chevron button at `at` that opens a menu of the tabs that did not fit.
+fn overflow_button(ui: &mut Ui, t: &Tokens, at: egui::Pos2, salt: &str, label: &'static str) -> egui::Response {
+ let r = Rect::from_min_size(at, vec2(OVERFLOW_WIDTH, HEIGHT));
+ let resp = ui.interact(r, ui.scope_id().with(salt), Sense::click()).tip(label);
+ crate::a11y::menu_button(&resp, label, crate::a11y::menu_open(&resp));
+ if resp.hovered() {
+ ui.painter().rect_filled(r, CornerRadius::ZERO, t.hover);
+ }
+ icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), Icon::Chevron, t.icon);
+ resp
+}
+
+/// Only the free space between `from_x` and `to_x` drags the window (a full-bar drag target would
+/// steal tab drags); double click maximizes.
+fn drag_free_space(ui: &mut Ui, rect: Rect, from_x: f32, to_x: f32) {
+ if from_x >= to_x {
+ return;
+ }
+ let free = Rect::from_min_max(pos2(from_x, rect.min.y), pos2(to_x, rect.max.y));
+ let drag = ui.interact(free, ui.scope_id().with("titlebar_drag"), Sense::click_and_drag());
+ if drag.drag_started() {
+ ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
+ }
+ if drag.double_clicked() {
+ let max = ui.input(|i| i.viewport().maximized.unwrap_or(false));
+ ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
+ }
+}
+
+/// Closes tab `i`; unsaved changes need a second close with Alt held.
+fn close_tab(app: &mut FigApp, ui: &Ui, i: usize) {
+ // A branch tab's edits live on in its main file: closing it loses nothing.
+ let dirty = app.session.docs.get(i).is_some_and(|d| d.is_dirty() && d.branch_of.is_none());
+ if dirty {
+ app.ui.find = None;
+ app.toast(trf("Unsaved changes: save first, or close again with {key} to discard", &[("key", &crate::toolbar::pretty_shortcut("Alt"))]));
+ if ui.input(|inp| inp.modifiers.alt) {
+ app.run("file.close", json!({"index": i, "force": true}));
+ }
+ } else {
+ app.run("file.close", json!({"index": i}));
+ }
+}
+
+/// The Home tab at `x`.
+fn home_tab(app: &mut FigApp, ui: &mut Ui, t: &Tokens, x: f32, rect: Rect) {
+ let hr = Rect::from_min_size(pos2(x, rect.min.y), vec2(44.0, HEIGHT));
+ let hresp = ui.interact(hr, ui.scope_id().with("home"), Sense::click()).tip(tr("Home"));
+ let home_active = (app.ui.home || !app.has_files()) && app.ui.present.is_none();
+ crate::a11y::tab(&hresp, tr("Home"), home_active);
+ if home_active {
+ ui.painter().rect_filled(hr, CornerRadius::ZERO, t.tab_active);
+ } else if hresp.hovered() {
+ ui.painter().rect_filled(hr, CornerRadius::ZERO, t.hover);
+ }
+ icons::paint(ui.painter(), Rect::from_center_size(hr.center(), vec2(16.0, 16.0)), Icon::Home, t.icon);
+ if hresp.clicked() {
+ pause_present(app);
+ app.ui.home = true;
+ }
+}
+
+/// The theme toggle, left of the window buttons; returns its rect.
+fn theme_toggle(app: &mut FigApp, ui: &mut Ui, t: &Tokens, rect: Rect, buttons_w: f32) -> Rect {
+ let theme_r = Rect::from_center_size(pos2(rect.max.x - buttons_w - 24.0, rect.center().y), vec2(28.0, 28.0));
+ let tresp = ui.interact(theme_r, ui.scope_id().with("theme"), Sense::click()).tip(tr("Toggle light/dark theme"));
+ if tresp.hovered() {
+ ui.painter().rect_filled(theme_r, CornerRadius::same(5), t.hover);
+ }
+ icons::paint(ui.painter(), theme_r.shrink(7.0), if app.ui.dark { Icon::Sun } else { Icon::Moon }, t.icon);
+ if tresp.clicked() {
+ let cmd = if app.ui.dark { "view.themeLight" } else { "view.themeDark" };
+ app.run(cmd, json!({}));
+ }
+ theme_r
+}
+
+/// What a drag on a document tab is doing.
+enum TabGesture {
+ /// Released far off the strip: the tab moves to a window of its own, at this point.
+ Detach(egui::Pos2),
+ /// Released on the strip, at this x.
+ Drop(f32),
+ /// Still dragging, at this point.
+ Drag(egui::Pos2),
+}
+
+/// The drag on a tab body `resp` in the tab bar `rect`, if any.
+fn tab_gesture(ui: &Ui, resp: &egui::Response, rect: Rect, several: bool) -> Option {
+ if !(resp.dragged() || resp.drag_stopped()) {
+ return None;
+ }
+ let p = ui.input(|inp| inp.pointer.latest_pos())?;
+ if !resp.drag_stopped() {
+ return Some(TabGesture::Drag(p));
+ }
+ if several && (p.y - rect.center().y).abs() > HEIGHT * 2.0 {
+ Some(TabGesture::Detach(p))
+ } else if (rect.min.y - 12.0..=rect.max.y + 12.0).contains(&p.y) {
+ Some(TabGesture::Drop(p.x))
+ } else {
+ None
+ }
+}
+
+/// The menu of document tabs that did not fit: the one picked.
+fn doc_overflow_menu(app: &FigApp, resp: &egui::Response, document_tabs: &[usize]) -> Option {
+ let mut switch = None;
+ crate::right::menu_popup(resp).show(|ui| {
+ egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
+ for &i in document_tabs {
+ let Some(d) = app.session.docs.get(i) else { continue };
+ let on = i == app.session.active && !app.ui.home && app.ui.present.is_none();
+ let label = if d.is_dirty() { format!("{} •", d.doc.name) } else { d.doc.name.clone() };
+ if crate::right::menu_check(ui, on, &label, "") {
+ switch = Some(i);
+ ui.close();
+ }
+ }
+ });
+ });
+ switch
+}
+
+/// The menu of presentation tabs that did not fit: the one picked (not the one already shown).
+fn present_overflow_menu(app: &FigApp, resp: &egui::Response, present_on: Option) -> Option {
+ let mut to_present = None;
+ crate::right::menu_popup(resp).show(|ui| {
+ egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| {
+ for tab in &app.ui.presents {
+ let on = present_on == Some(tab.id);
+ if crate::right::menu_check(ui, on, &present_tab_name(tab), "") {
+ if !on {
+ to_present = Some(tab.id);
+ }
+ ui.close();
+ }
+ }
+ });
+ });
+ to_present
+}
+
+/// Moves tab `i` to a window of its own; `at` is where the pointer let go, in the tab bar.
+fn detach_tab(app: &mut FigApp, ui: &Ui, i: usize, at: Option) {
+ // Where the pointer let go, on the screen.
+ let at = at.and_then(|p| ui.input(|inp| inp.viewport().inner_rect).map(|r| r.min + p.to_vec2() - vec2(60.0, HEIGHT / 2.0)));
+ if let Err(e) = crate::windows::open(app, ui.ctx(), Some(i), None, at) {
+ app.toast(e);
+ }
+}
+
+/// How wide document tab `i` wants to be: its name, the live mark and the unsaved dot.
+fn preferred_width(app: &FigApp, i: usize) -> f32 {
+ let d = &app.session.docs[i];
+ let mark_w = if live_mark(app, d.uid).is_some() { 18.0 } else { 0.0 };
+ let dot_w = if d.is_dirty() && mark_w > 0.0 { 12.0 } else { 0.0 };
+ (regular(12.0).size * 0.6 * d.doc.name.chars().count() as f32 + 64.0 + mark_w + dot_w).clamp(120.0, 220.0)
+}
+
+/// A tab's accessible name: `name`, and in a live session whether others get in.
+fn tab_label(name: &str, live: Option) -> String {
+ match live {
+ Some(true) => trf("{name}, in a live session", &[("name", &name)]),
+ Some(false) => trf("{name}, online, open only to you", &[("name", &name)]),
+ None => name.to_string(),
+ }
+}
+
+/// What to draw for one document tab.
+struct TabPaint<'a> {
+ name: &'a str,
+ kind: figcraft_doc::FileKind,
+ /// The tab, and its close target.
+ rects: [Rect; 2],
+ width: f32,
+ /// [active, body hovered, close hovered, unsaved changes].
+ state: [bool; 4],
+ /// In a live session: whether others get in.
+ live: Option,
+ /// How far the opening animation is (0..=1).
+ progress: f32,
+}
+
+/// Draws a document tab: background, icon, name, live mark, unsaved dot and close button.
+fn paint_tab(ui: &Ui, t: &Tokens, tab: &TabPaint) {
+ let TabPaint { name, kind, rects: [r, close_r], width: w, state: [active, hovered, close_hovered, dirty], live, progress } = *tab;
+ let mark_w = if live.is_some() { 18.0 } else { 0.0 };
+ let paint_r = r.translate(vec2(-28.0 * (1.0 - progress).powi(2), 0.0));
+ let painter = ui.painter().with_clip_rect(r);
+ if active {
+ painter.rect_filled(paint_r, CornerRadius::ZERO, t.tab_active.linear_multiply(progress));
+ } else if hovered || close_hovered {
+ painter.rect_filled(paint_r, CornerRadius::ZERO, t.hover.linear_multiply(progress));
+ }
+ icons::paint(
+ &painter,
+ Rect::from_center_size(pos2(paint_r.min.x + 18.0, paint_r.center().y), vec2(14.0, 14.0)),
+ icons::file_mark(kind),
+ crate::theme::ACCENT.linear_multiply(progress),
+ );
+ // The unsaved-changes dot follows the name on screen, whichever way the name reads.
+ let shown = crate::bidi::paint_name(
+ &painter,
+ pos2(paint_r.min.x + 32.0, paint_r.center().y),
+ name,
+ regular(12.0),
+ t.text.linear_multiply(progress),
+ (w - mark_w - if dirty { 72.0 } else { 60.0 }).max(0.0),
+ );
+ if let Some(shared) = live {
+ let at = Rect::from_center_size(pos2(paint_r.max.x - 38.0, paint_r.center().y), vec2(14.0, 14.0));
+ icons::paint(&painter, at, crate::online::mark(shared), if shared { t.success } else { t.icon_secondary }.linear_multiply(progress));
+ }
+ if dirty {
+ painter.text(pos2(shown.max.x + 4.0, paint_r.center().y), Align2::LEFT_CENTER, "•", regular(12.0), t.text.linear_multiply(progress));
+ }
+ let cr = Rect::from_center_size(close_r.center() + vec2(paint_r.min.x - r.min.x, 0.0), vec2(18.0, 18.0));
+ // The close target never overlaps the draggable body, including on inactive tabs.
+ if hovered || close_hovered || active {
+ if close_hovered {
+ painter.rect_filled(cr, CornerRadius::same(4), t.pressed.linear_multiply(progress));
+ }
+ icons::paint(&painter, cr.shrink(4.0), Icon::Close, t.icon_secondary.linear_multiply(progress));
+ }
+}
+
/// One presentation tab in `r`: whether it was clicked and whether it was closed (its X or a
/// middle click).
fn present_tab(ui: &mut Ui, t: &Tokens, tab: &PresentTab, r: Rect, active: bool) -> (bool, bool) {
diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs
index accb70fd..0a3740e1 100644
--- a/crates/ui-egui/src/widgets/colorpicker.rs
+++ b/crates/ui-egui/src/widgets/colorpicker.rs
@@ -3,7 +3,8 @@
use egui::{Align2, Color32, CornerRadius, Id, Mesh, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use figcraft_color::{
- Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint, PatternTile,
+ Color, ColorStop, Gradient, GradientKind, Hsb, ImageFilters, ImagePaint, ImageScaleMode, Paint, PaintKind, PatternAlign, PatternPaint,
+ PatternTile,
};
use figcraft_doc::{NodeId, NodeKind};
use serde_json::json;
@@ -537,6 +538,277 @@ fn alpha_slider(ui: &mut Ui, rect: Rect, color: Color) -> Option<(f32, bool)> {
slider_knob(ui, rect, color.a, Id::unique("cp_alpha"))
}
+/// The row of paint kinds (solid, gradients, image, pattern) with `paint`'s kind selected.
+fn paint_type_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: &mut Paint) {
+ let kinds = [
+ (Icon::Fill, tr("Solid")),
+ (Icon::GradientLinear, tr("Linear")),
+ (Icon::GradientRadial, tr("Radial")),
+ (Icon::GradientAngular, tr("Angular")),
+ (Icon::GradientDiamond, tr("Diamond")),
+ (Icon::Image, tr("Image")),
+ (Icon::ViewGrid, tr("Pattern")),
+ ];
+ let active = match &paint.kind {
+ PaintKind::Solid { .. } => 0,
+ PaintKind::Gradient(g) => match g.kind {
+ GradientKind::Linear => 1,
+ GradientKind::Radial => 2,
+ GradientKind::Angular => 3,
+ GradientKind::Diamond => 4,
+ },
+ PaintKind::Image(_) => 5,
+ PaintKind::Pattern(_) => 6,
+ };
+ ui.horizontal(|ui| {
+ ui.add_space(12.0);
+ if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {
+ let base = paint.preview_color();
+ let mut image_pending = false;
+ paint.kind = match i {
+ 0 => PaintKind::Solid { color: base.with_alpha(1.0) },
+ 5 => {
+ image_pending = true;
+ let target = target.clone();
+ app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {
+ let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };
+ let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
+ if let Some(mut paint) = current_paint(app, &target) {
+ paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
+ write(app, &target, &paint, false);
+ }
+ });
+ paint.kind.clone()
+ }
+ 6 => match pattern_sources(app, target).first() {
+ Some((id, _)) => Paint::pattern(id.0).kind,
+ None => {
+ app.toast(tr("Add a layer or component to tile first").to_string());
+ paint.kind.clone()
+ }
+ },
+ k => {
+ let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1];
+ match &paint.kind {
+ PaintKind::Gradient(g) => {
+ PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) })
+ }
+ _ => PaintKind::Gradient(Gradient::default_for(kind, base)),
+ }
+ }
+ };
+ if !image_pending {
+ write(app, target, paint, false);
+ }
+ }
+ });
+}
+
+/// The paint's own blend mode dropdown.
+fn blend_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: &mut Paint) {
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));
+ crate::right::menu_popup(&resp).width(200.0).show(|ui| {
+ if let Some(b) = crate::right::blend_menu(ui, false) {
+ paint.blend = b;
+ write(app, target, paint, false);
+ }
+ });
+ });
+}
+
+/// The image paint's scale mode, rotation, colour profile, presets and filter sliders.
+fn image_controls(app: &mut FigApp, ui: &mut Ui, target: &PickerTarget, paint: &mut Paint, mut img: ImagePaint, t: &Tokens) {
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None);
+ crate::right::menu_popup(&resp).show(|ui| {
+ for (l, m) in [
+ (tr("Fill"), ImageScaleMode::Fill),
+ (tr("Fit"), ImageScaleMode::Fit),
+ (tr("Crop"), ImageScaleMode::Crop),
+ (tr("Tile"), ImageScaleMode::Tile),
+ ] {
+ if crate::right::menu_item(ui, l, "") {
+ img.scale_mode = m;
+ paint.kind = PaintKind::Image(img.clone());
+ write(app, target, paint, false);
+ ui.close();
+ }
+ }
+ });
+ if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() {
+ img.rotation = (img.rotation + 1) % 4;
+ paint.kind = PaintKind::Image(img.clone());
+ write(app, target, paint, false);
+ }
+ });
+ // The image's own colour profile (decoding converts it into the file's).
+ let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image)));
+ let source = match source.as_deref() {
+ None => tr("Unknown — treated as sRGB"),
+ Some("") => tr("Embedded profile"),
+ Some(name) => name,
+ };
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ let text = trf("Color profile: {name}", &[("name", &source)]);
+ ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate());
+ });
+ image_presets(app, ui, target, paint, img.filters);
+ for (label, val) in [
+ (tr_noop("Exposure"), img.filters.exposure),
+ (tr_noop("Contrast"), img.filters.contrast),
+ (tr_noop("Saturation"), img.filters.saturation),
+ (tr_noop("Temperature"), img.filters.temperature),
+ (tr_noop("Tint"), img.filters.tint),
+ (tr_noop("Highlights"), img.filters.highlights),
+ (tr_noop("Shadows"), img.filters.shadows),
+ ] {
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ // Labels share a column so the sliders line up.
+ let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover());
+ ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text);
+ let mut v = val;
+ let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false));
+ if r.changed() {
+ let f = &mut img.filters;
+ match label {
+ "Exposure" => f.exposure = v,
+ "Contrast" => f.contrast = v,
+ "Saturation" => f.saturation = v,
+ "Temperature" => f.temperature = v,
+ "Tint" => f.tint = v,
+ "Highlights" => f.highlights = v,
+ _ => f.shadows = v,
+ }
+ paint.kind = PaintKind::Image(img.clone());
+ write(app, target, paint, !r.drag_stopped());
+ }
+ if r.drag_stopped() {
+ finish(app);
+ }
+ });
+ }
+}
+
+/// The gradient bar with its stops, the stop actions and the selected stop's colour variable.
+/// The selected stop's colour (what the colour fields then edit).
+fn gradient_controls(
+ app: &mut FigApp,
+ ui: &mut Ui,
+ target: &PickerTarget,
+ paint: &mut Paint,
+ mut g: Gradient,
+ t: &Tokens,
+ sel_stop_id: Id,
+) -> Option {
+ let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1));
+ let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());
+ let bar = bar.shrink2(vec2(16.0, 6.0));
+ widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0);
+ let n = 24;
+ for i in 0..n {
+ let a = i as f32 / n as f32;
+ let b = (i + 1) as f32 / n as f32;
+ let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y));
+ ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0)));
+ }
+ let mut changed = false;
+ let mut done = false;
+ // The bar first, so the stops on it take their own clicks.
+ let bresp = ui.interact(bar, Id::unique("cp_bar"), Sense::click());
+ for (i, s) in g.stops.clone().iter().enumerate() {
+ let x = bar.min.x + s.position * bar.width();
+ let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0));
+ let resp = ui.interact(r, Id::unique(("cp_stopk", i)), Sense::click_and_drag());
+ ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0)));
+ ui.painter().rect_stroke(
+ r,
+ CornerRadius::same(3),
+ Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }),
+ StrokeKind::Outside,
+ );
+ if resp.clicked() || resp.drag_started() {
+ sel = i;
+ }
+ if resp.dragged()
+ && let Some(p) = resp.interact_pointer_pos()
+ && let Some(st) = g.stops.get_mut(i)
+ {
+ st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
+ changed = true;
+ }
+ if resp.drag_stopped() {
+ done = true;
+ }
+ }
+ // Click on the bar adds a stop.
+ if bresp.clicked()
+ && let Some(p) = bresp.interact_pointer_pos()
+ {
+ let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
+ g.stops.push(ColorStop { position: pos, color: g.sample(pos) });
+ sel = g.stops.len() - 1;
+ changed = true;
+ done = true;
+ }
+ ui.data_mut(|d| d.insert_temp(sel_stop_id, sel));
+ if changed {
+ paint.kind = PaintKind::Gradient(g.clone());
+ write(app, target, paint, !done);
+ }
+ if done {
+ finish(app);
+ }
+ let edit_color = g.stops.get(sel).map(|s| s.color);
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 {
+ g.stops.remove(sel);
+ ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize));
+ paint.kind = PaintKind::Gradient(g.clone());
+ write(app, target, paint, false);
+ }
+ if ui.small_button(tr("Flip")).clicked() {
+ for s in &mut g.stops {
+ s.position = 1.0 - s.position;
+ }
+ paint.kind = PaintKind::Gradient(g.clone());
+ write(app, target, paint, false);
+ }
+ if ui.small_button(tr("Rotate 90°")).clicked() {
+ let (fx, fy) = g.from;
+ let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx));
+ g.to = rot(g.to);
+ g.width = rot(g.width);
+ paint.kind = PaintKind::Gradient(g.clone());
+ write(app, target, paint, false);
+ }
+ });
+ // The selected stop's colour can come from a colour variable.
+ if let PickerTarget::Paint { fills, index, ids } = target {
+ let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" });
+ let stop = g.stops.get(sel).map(|s| s.color);
+ ui.horizontal(|ui| {
+ ui.add_space(16.0);
+ crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| {
+ let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover());
+ ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
+ let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0));
+ if let Some(c) = stop {
+ widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c));
+ let text = trf("Stop {n} {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]);
+ ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text);
+ }
+ });
+ });
+ }
+ edit_color
+}
+
/// The colour picker window's area id. A fixed id (not `Window::new`'s, which egui keys by
/// `Id::unique(Some(title))`) so the style picker can tell a click inside the picker from one outside.
pub fn window_id() -> Id {
@@ -609,84 +881,14 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
target,
PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
) {
- let kinds = [
- (Icon::Fill, tr("Solid")),
- (Icon::GradientLinear, tr("Linear")),
- (Icon::GradientRadial, tr("Radial")),
- (Icon::GradientAngular, tr("Angular")),
- (Icon::GradientDiamond, tr("Diamond")),
- (Icon::Image, tr("Image")),
- (Icon::ViewGrid, tr("Pattern")),
- ];
- let active = match &paint.kind {
- PaintKind::Solid { .. } => 0,
- PaintKind::Gradient(g) => match g.kind {
- GradientKind::Linear => 1,
- GradientKind::Radial => 2,
- GradientKind::Angular => 3,
- GradientKind::Diamond => 4,
- },
- PaintKind::Image(_) => 5,
- PaintKind::Pattern(_) => 6,
- };
- ui.horizontal(|ui| {
- ui.add_space(12.0);
- if let Some(i) = widgets::segmented(ui, &kinds, Some(active), 30.0) {
- let base = paint.preview_color();
- let mut image_pending = false;
- paint.kind = match i {
- 0 => PaintKind::Solid { color: base.with_alpha(1.0) },
- 5 => {
- image_pending = true;
- let target = target.clone();
- app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| {
- let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return };
- let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return };
- if let Some(mut paint) = current_paint(app, &target) {
- paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind;
- write(app, &target, &paint, false);
- }
- });
- paint.kind.clone()
- }
- 6 => match pattern_sources(app, &target).first() {
- Some((id, _)) => Paint::pattern(id.0).kind,
- None => {
- app.toast(tr("Add a layer or component to tile first").to_string());
- paint.kind.clone()
- }
- },
- k => {
- let kind = [GradientKind::Linear, GradientKind::Radial, GradientKind::Angular, GradientKind::Diamond][k - 1];
- match &paint.kind {
- PaintKind::Gradient(g) => {
- PaintKind::Gradient(Gradient { kind, ..Gradient::default_for(kind, base).with_stops(g.stops.clone()) })
- }
- _ => PaintKind::Gradient(Gradient::default_for(kind, base)),
- }
- }
- };
- if !image_pending {
- write(app, &target, &paint, false);
- }
- }
- });
+ paint_type_row(ui, app, &target, &mut paint);
}
// The paint's own blend mode, with the paints and layers under it.
if !matches!(
target,
PickerTarget::Effect { .. } | PickerTarget::PageBackground | PickerTarget::Variable { .. } | PickerTarget::PluginField { .. }
) {
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- let resp = widgets::dropdown(ui, tr(paint.blend.label()), 208.0, Some(Icon::Blend)).on_hover_text(tr("Blend mode"));
- crate::right::menu_popup(&resp).width(200.0).show(|ui| {
- if let Some(b) = crate::right::blend_menu(ui, false) {
- paint.blend = b;
- write(app, &target, &paint, false);
- }
- });
- });
+ blend_row(ui, app, &target, &mut paint);
}
// Gradient stops.
let mut edit_color: Option = match &paint.kind {
@@ -694,184 +896,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) {
_ => None,
};
let sel_stop_id = stop_id();
- if let PaintKind::Gradient(g) = &paint.kind {
- let mut g = g.clone();
- let mut sel: usize = ui.data(|d| d.get_temp(sel_stop_id)).unwrap_or(0).min(g.stops.len().saturating_sub(1));
- let (bar, _) = ui.allocate_exact_size(vec2(240.0, 28.0), Sense::hover());
- let bar = bar.shrink2(vec2(16.0, 6.0));
- widgets::checker(&ui.painter().with_clip_rect(bar), bar, 4.0);
- let n = 24;
- for i in 0..n {
- let a = i as f32 / n as f32;
- let b = (i + 1) as f32 / n as f32;
- let r = Rect::from_min_max(pos2(bar.min.x + a * bar.width(), bar.min.y), pos2(bar.min.x + b * bar.width(), bar.max.y));
- ui.painter().rect_filled(r, CornerRadius::ZERO, doc_c32(ui.ctx(), g.sample((a + b) / 2.0)));
- }
- let mut changed = false;
- let mut done = false;
- // The bar first, so the stops on it take their own clicks.
- let bresp = ui.interact(bar, Id::unique("cp_bar"), Sense::click());
- for (i, s) in g.stops.clone().iter().enumerate() {
- let x = bar.min.x + s.position * bar.width();
- let r = Rect::from_center_size(pos2(x, bar.center().y), vec2(12.0, 18.0));
- let resp = ui.interact(r, Id::unique(("cp_stopk", i)), Sense::click_and_drag());
- ui.painter().rect_filled(r, CornerRadius::same(3), doc_c32(ui.ctx(), s.color.with_alpha(1.0)));
- ui.painter().rect_stroke(
- r,
- CornerRadius::same(3),
- Stroke::new(if i == sel { 2.0 } else { 1.0 }, if i == sel { crate::theme::ACCENT } else { Color32::WHITE }),
- StrokeKind::Outside,
- );
- if resp.clicked() || resp.drag_started() {
- sel = i;
- }
- if resp.dragged()
- && let Some(p) = resp.interact_pointer_pos()
- && let Some(st) = g.stops.get_mut(i)
- {
- st.position = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
- changed = true;
- }
- if resp.drag_stopped() {
- done = true;
- }
- }
- // Click on the bar adds a stop.
- if bresp.clicked()
- && let Some(p) = bresp.interact_pointer_pos()
- {
- let pos = ((p.x - bar.min.x) / bar.width()).clamp(0.0, 1.0);
- g.stops.push(ColorStop { position: pos, color: g.sample(pos) });
- sel = g.stops.len() - 1;
- changed = true;
- done = true;
- }
- ui.data_mut(|d| d.insert_temp(sel_stop_id, sel));
- if changed {
- paint.kind = PaintKind::Gradient(g.clone());
- write(app, &target, &paint, !done);
- }
- if done {
- finish(app);
- }
- edit_color = g.stops.get(sel).map(|s| s.color);
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- if ui.small_button(tr("Remove stop")).clicked() && g.stops.len() > 2 {
- g.stops.remove(sel);
- ui.data_mut(|d| d.insert_temp(sel_stop_id, 0usize));
- paint.kind = PaintKind::Gradient(g.clone());
- write(app, &target, &paint, false);
- }
- if ui.small_button(tr("Flip")).clicked() {
- for s in &mut g.stops {
- s.position = 1.0 - s.position;
- }
- paint.kind = PaintKind::Gradient(g.clone());
- write(app, &target, &paint, false);
- }
- if ui.small_button(tr("Rotate 90°")).clicked() {
- let (fx, fy) = g.from;
- let rot = |(x, y): (f64, f64)| (fx - (y - fy), fy + (x - fx));
- g.to = rot(g.to);
- g.width = rot(g.width);
- paint.kind = PaintKind::Gradient(g.clone());
- write(app, &target, &paint, false);
- }
- });
- // The selected stop's colour can come from a colour variable.
- if let PickerTarget::Paint { fills, index, ids } = &target {
- let field = format!("{}/{index}/stops/{sel}/color", if *fills { "fills" } else { "strokes" });
- let stop = g.stops.get(sel).map(|s| s.color);
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- crate::variable_ui::bindable(app, ui, ids, &field, figcraft_doc::VariableType::Color, 208.0, |_, ui| {
- let (r, _) = ui.allocate_exact_size(vec2(208.0, widgets::FIELD_H), Sense::hover());
- ui.painter().rect_filled(r, CornerRadius::same(5), t.field);
- let sw = Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0));
- if let Some(c) = stop {
- widgets::paint_swatch(ui.painter(), sw, &Paint::solid(c));
- let text = trf("Stop {n} {hex}", &[("n", &(sel + 1)), ("hex", &c.to_hex())]);
- ui.painter().text(pos2(sw.max.x + 8.0, r.center().y), Align2::LEFT_CENTER, text, regular(11.0), t.text);
- }
- });
- });
- }
+ if let PaintKind::Gradient(g) = paint.kind.clone() {
+ edit_color = gradient_controls(app, ui, &target, &mut paint, g, &t, sel_stop_id);
}
- if let PaintKind::Image(img) = &paint.kind {
- let mut img = img.clone();
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- let resp = widgets::dropdown(ui, tr(&format!("{:?}", img.scale_mode)), 100.0, None);
- crate::right::menu_popup(&resp).show(|ui| {
- for (l, m) in [
- (tr("Fill"), ImageScaleMode::Fill),
- (tr("Fit"), ImageScaleMode::Fit),
- (tr("Crop"), ImageScaleMode::Crop),
- (tr("Tile"), ImageScaleMode::Tile),
- ] {
- if crate::right::menu_item(ui, l, "") {
- img.scale_mode = m;
- paint.kind = PaintKind::Image(img.clone());
- write(app, &target, &paint, false);
- ui.close();
- }
- }
- });
- if icons::button(ui, Icon::Rotate, 24.0, false, tr("Rotate 90°")).clicked() {
- img.rotation = (img.rotation + 1) % 4;
- paint.kind = PaintKind::Image(img.clone());
- write(app, &target, &paint, false);
- }
- });
- // The image's own colour profile (decoding converts it into the file's).
- let source = app.session.doc().ok().and_then(|d| figcraft_render::images::profile_name(&img.image, d.images.get(&img.image)));
- let source = match source.as_deref() {
- None => tr("Unknown — treated as sRGB"),
- Some("") => tr("Embedded profile"),
- Some(name) => name,
- };
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- let text = trf("Color profile: {name}", &[("name", &source)]);
- ui.add(egui::Label::new(egui::RichText::new(text).font(regular(11.0)).color(t.text_secondary)).truncate());
- });
- image_presets(app, ui, &target, &mut paint, img.filters);
- for (label, val) in [
- (tr_noop("Exposure"), img.filters.exposure),
- (tr_noop("Contrast"), img.filters.contrast),
- (tr_noop("Saturation"), img.filters.saturation),
- (tr_noop("Temperature"), img.filters.temperature),
- (tr_noop("Tint"), img.filters.tint),
- (tr_noop("Highlights"), img.filters.highlights),
- (tr_noop("Shadows"), img.filters.shadows),
- ] {
- ui.horizontal(|ui| {
- ui.add_space(16.0);
- // Labels share a column so the sliders line up.
- let (lr, _) = ui.allocate_exact_size(vec2(72.0, 18.0), Sense::hover());
- ui.painter().text(lr.left_center(), Align2::LEFT_CENTER, tr(label), regular(11.0), t.text);
- let mut v = val;
- let r = ui.add(egui::Slider::new(&mut v, -1.0..=1.0).show_value(false));
- if r.changed() {
- let f = &mut img.filters;
- match label {
- "Exposure" => f.exposure = v,
- "Contrast" => f.contrast = v,
- "Saturation" => f.saturation = v,
- "Temperature" => f.temperature = v,
- "Tint" => f.tint = v,
- "Highlights" => f.highlights = v,
- _ => f.shadows = v,
- }
- paint.kind = PaintKind::Image(img.clone());
- write(app, &target, &paint, !r.drag_stopped());
- }
- if r.drag_stopped() {
- finish(app);
- }
- });
- }
+ if let PaintKind::Image(img) = paint.kind.clone() {
+ image_controls(app, ui, &target, &mut paint, img, &t);
}
if let PaintKind::Pattern(pt) = paint.kind.clone() {
pattern_controls(app, ui, &target, &mut paint, pt);
diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs
index 04e29397..aaf807ca 100644
--- a/crates/ui-egui/src/widgets/style_picker.rs
+++ b/crates/ui-egui/src/widgets/style_picker.rs
@@ -706,8 +706,17 @@ fn grid_label(g: &figcraft_doc::LayoutGrid) -> String {
/// The chip in `rect` of the variable `name` bound to `field` of text style `style`. A click on it
/// opens the variable picker to change it, and its detach button unbinds it.
-#[allow(clippy::too_many_arguments)]
-fn text_binding(ui: &mut Ui, style: &str, field: &str, kind: VariableType, rect: Rect, name: &str, lead: widgets::Lead, out: &mut Frame) {
+struct TextBinding<'a> {
+ style: &'a str,
+ field: &'a str,
+ kind: VariableType,
+ rect: Rect,
+ name: &'a str,
+ lead: widgets::Lead<'a>,
+}
+
+fn text_binding(ui: &mut Ui, binding: TextBinding<'_>, out: &mut Frame) {
+ let TextBinding { style, field, kind, rect, name, lead } = binding;
match widgets::bound_field(ui, Id::unique(("ts_bound", style, field)), lead, rect, name) {
Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, style, field, kind, at),
Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(style.to_string(), field.to_string())),
@@ -747,7 +756,18 @@ fn font_family_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle
match bound_name(doc, style, "fontFamily") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(vec2(W, widgets::FIELD_H), Sense::hover());
- text_binding(ui, id, "fontFamily", VariableType::String, r, &name, widgets::Lead::Named(tr("Font")), out);
+ text_binding(
+ ui,
+ TextBinding {
+ style: id,
+ field: "fontFamily",
+ kind: VariableType::String,
+ rect: r,
+ name: &name,
+ lead: widgets::Lead::Named(tr("Font")),
+ },
+ out,
+ );
None
}
None => Some(widgets::dropdown(ui, &def.font_family, W, None).on_hover_text(tr("Font"))),
@@ -762,7 +782,18 @@ fn font_style_size_row(ui: &mut Ui, doc: &Document, style: &Style, def: &TextSty
match bound_name(doc, style, "fontStyle") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(vec2(half, widgets::FIELD_H), Sense::hover());
- text_binding(ui, id, "fontStyle", VariableType::String, r, &name, widgets::Lead::Named(tr("Font style")), out);
+ text_binding(
+ ui,
+ TextBinding {
+ style: id,
+ field: "fontStyle",
+ kind: VariableType::String,
+ rect: r,
+ name: &name,
+ lead: widgets::Lead::Named(tr("Font style")),
+ },
+ out,
+ );
}
None => style_resp = Some(widgets::dropdown(ui, &def.font_style, half, None).on_hover_text(tr("Font style"))),
}
@@ -802,7 +833,18 @@ fn line_height_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle
match bound_name(doc, style, "lineHeight") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
- text_binding(ui, id, "lineHeight", VariableType::Number, r, &name, widgets::Lead::Named(tr("Line height")), out);
+ text_binding(
+ ui,
+ TextBinding {
+ style: id,
+ field: "lineHeight",
+ kind: VariableType::Number,
+ rect: r,
+ name: &name,
+ lead: widgets::Lead::Named(tr("Line height")),
+ },
+ out,
+ );
}
None => {
if let Some(v) = widgets::text_field(ui, Id::unique(("ts_lh", id)), &def.line_height.label(), half, tr("Line height"))
@@ -823,7 +865,18 @@ fn letter_spacing_field(ui: &mut Ui, doc: &Document, style: &Style, def: &TextSt
match bound_name(doc, style, "letterSpacing") {
Some(name) => {
let (r, _) = ui.allocate_exact_size(area.size(), Sense::hover());
- text_binding(ui, id, "letterSpacing", VariableType::Number, r, &name, widgets::Lead::Named(tr("Letter spacing")), out);
+ text_binding(
+ ui,
+ TextBinding {
+ style: id,
+ field: "letterSpacing",
+ kind: VariableType::Number,
+ rect: r,
+ name: &name,
+ lead: widgets::Lead::Named(tr("Letter spacing")),
+ },
+ out,
+ );
}
None => {
if let Some(v) = widgets::text_field(ui, Id::unique(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing"))
diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
index 59bfb3c9..1dbc3fc2 100644
--- a/xtask/Cargo.toml
+++ b/xtask/Cargo.toml
@@ -5,6 +5,9 @@ edition.workspace = true
license.workspace = true
publish = false
+[lints]
+workspace = true
+
[dependencies]
serde_json = { workspace = true }
figcraft-engine = { workspace = true }