diff --git a/Cargo.lock b/Cargo.lock index fabd85f8..2d84daad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1465,7 +1465,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "figcraft" -version = "0.10.0" +version = "0.11.2" dependencies = [ "arboard", "craft-window", @@ -1491,7 +1491,7 @@ dependencies = [ [[package]] name = "figcraft-cli" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-engine", @@ -1505,7 +1505,7 @@ dependencies = [ [[package]] name = "figcraft-collab" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", @@ -1521,7 +1521,7 @@ dependencies = [ [[package]] name = "figcraft-collab-net" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-collab", @@ -1544,7 +1544,7 @@ dependencies = [ [[package]] name = "figcraft-color" -version = "0.10.0" +version = "0.11.2" dependencies = [ "kurbo", "serde", @@ -1553,7 +1553,7 @@ dependencies = [ [[package]] name = "figcraft-doc" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-color", "figcraft-geom", @@ -1565,7 +1565,7 @@ dependencies = [ [[package]] name = "figcraft-engine" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "craft-docpath", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "figcraft-format" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-pathops", @@ -1608,7 +1608,7 @@ dependencies = [ [[package]] name = "figcraft-geom" -version = "0.10.0" +version = "0.11.2" dependencies = [ "kurbo", "serde", @@ -1616,7 +1616,7 @@ dependencies = [ [[package]] name = "figcraft-layout" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1624,7 +1624,7 @@ dependencies = [ [[package]] name = "figcraft-mcp" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-collab-net", @@ -1640,7 +1640,7 @@ dependencies = [ [[package]] name = "figcraft-pathops" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1649,7 +1649,7 @@ dependencies = [ [[package]] name = "figcraft-pdf" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-color", "figcraft-doc", @@ -1666,7 +1666,7 @@ dependencies = [ [[package]] name = "figcraft-plugin" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-color", "serde", @@ -1679,7 +1679,7 @@ dependencies = [ [[package]] name = "figcraft-render" -version = "0.10.0" +version = "0.11.2" dependencies = [ "crc32fast", "figcraft-color", @@ -1697,7 +1697,7 @@ dependencies = [ [[package]] name = "figcraft-server" -version = "0.10.0" +version = "0.11.2" dependencies = [ "crc32fast", "ctrlc", @@ -1721,7 +1721,7 @@ dependencies = [ [[package]] name = "figcraft-svg" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "figcraft-color", @@ -1737,7 +1737,7 @@ dependencies = [ [[package]] name = "figcraft-text" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-doc", "figcraft-geom", @@ -1751,7 +1751,7 @@ dependencies = [ [[package]] name = "figcraft-ui-egui" -version = "0.10.0" +version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", @@ -1777,7 +1777,7 @@ dependencies = [ [[package]] name = "figcraft-web" -version = "0.10.0" +version = "0.11.2" dependencies = [ "eframe", "egui", @@ -5881,7 +5881,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee" [[package]] name = "xtask" -version = "0.10.0" +version = "0.11.2" dependencies = [ "figcraft-engine", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 65809315..39abeca3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ exclude = ["plugins"] # default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"] [workspace.package] -version = "0.10.0" +version = "0.11.2" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" @@ -122,7 +122,8 @@ codegen-units = 4 # wasm32-unknown-unknown always aborts on panic, so panic = "abort" changes nothing there. [profile.web-release] inherits = "release" -opt-level = "s" +# "z", not "s": 1.9 MB smaller (24.0 MiB -> 22.2 MiB after wasm-opt, 0.11.1), back under the gate. +opt-level = "z" lto = "fat" codegen-units = 1 debug = false diff --git a/contributors/people.toml b/contributors/people.toml index 6a92a2a9..7491a05c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -76,3 +76,7 @@ sync_github_name = true real_name = "Gert Pellin" display_name = "Gert Pellin" sync_github_name = false + +[people.dotCooCoo] +real_name = "Robert Lee" +sync_github_name = true diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index b4f89dbc..1ee885f5 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -17,8 +17,10 @@ pub fn shape_path(n: &Node) -> Option { | NodeKind::Section { .. } => figcraft_geom::rounded_rect(w, h, c.radii, c.smoothing), NodeKind::Slice => figcraft_geom::rounded_rect(w, h, [0.0; 4], 0.0), NodeKind::Ellipse { start, sweep, inner } => figcraft_geom::ellipse(w, h, *start, *sweep, *inner), - NodeKind::Polygon { count } => figcraft_geom::rounded_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0]), - NodeKind::Star { count, inner_ratio } => figcraft_geom::rounded_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0]), + NodeKind::Polygon { count } => figcraft_geom::smoothed_polygon(&figcraft_geom::polygon_points(w, h, *count), c.radii[0], c.smoothing), + NodeKind::Star { count, inner_ratio } => { + figcraft_geom::smoothed_polygon(&figcraft_geom::star_points(w, h, *count, *inner_ratio), c.radii[0], c.smoothing) + } NodeKind::Line => { let mut p = BezPath::new(); p.move_to((0.0, 0.0)); @@ -126,7 +128,7 @@ pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolR /// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask /// below it, a hidden one masks nothing) doesn't cover the point, so nothing of `c` shows there. -fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool { +pub fn masked_out(doc: &Document, parent: &Node, c: NodeId, pt: Point) -> bool { if !doc.has_masks() { return false; } diff --git a/crates/doc/src/handoff.rs b/crates/doc/src/handoff.rs index 9700b56c..04bf5e95 100644 --- a/crates/doc/src/handoff.rs +++ b/crates/doc/src/handoff.rs @@ -71,8 +71,38 @@ impl Document { if let (Some(now), Some(r)) = (self.node(root), d.nodes.get_mut(&root)) { Arc::make_mut(r).parent = now.parent; } + // A damaged file can give a historical layer itself (or an ancestor) as a child; a copy + // with a cycle under the root would send every walk of it round forever. + if !d.is_tree_under(root) { + return None; + } d.dev_snapshots.clear(); d.dirty = crate::Dirty::default(); Some(d) } + + /// Whether every layer under `root` is reached once (no layer is its own descendant or listed + /// by two parents), walking iteratively and stopping at the first repeat. + fn is_tree_under(&self, root: NodeId) -> bool { + let mut seen = BTreeSet::new(); + let mut stack = vec![root]; + while let Some(id) = stack.pop() { + if !seen.insert(id) { + return false; + } + let Some(n) = self.nodes.get(&id) else { + return false; + }; + for &c in &n.children { + let Some(child) = self.nodes.get(&c) else { + return false; + }; + if child.parent != Some(id) { + return false; + } + stack.push(c); + } + } + true + } } diff --git a/crates/doc/src/handoff_tests.rs b/crates/doc/src/handoff_tests.rs index 7fc26898..79b97194 100644 --- a/crates/doc/src/handoff_tests.rs +++ b/crates/doc/src/handoff_tests.rs @@ -34,3 +34,54 @@ fn a_snapshot_shows_the_layer_as_it_was() { doc.dev_snapshots.clear(); assert!(serde_json::to_value(&doc).unwrap().get("dev_snapshots").is_none()); } + +#[test] +fn a_snapshot_with_a_layer_inside_itself_is_refused() { + let mut doc = Document::new("d"); + let page = doc.first_page().unwrap(); + let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap(); + let mut snap = doc.snapshot_subtree(f).unwrap(); + // A damaged file: the historical frame lists itself as a child. + let mut bad = (*snap.nodes[0]).clone(); + bad.children.push(f); + snap.nodes[0] = Arc::new(bad); + doc.dev_snapshots.insert(f, Arc::new(snap)); + assert!(doc.with_dev_snapshot(f).is_none()); +} + +#[test] +fn a_snapshot_with_missing_or_nonreciprocal_child_is_refused() { + use crate::NodeId; + let mut doc = Document::new("d"); + let page = doc.first_page().unwrap(); + let f = doc.create(page, NodeKind::Frame, "Screen", 0.0, 0.0, 100.0, 100.0).unwrap(); + let _c1 = doc.create(f, NodeKind::Rectangle, "R1", 0.0, 0.0, 10.0, 10.0).unwrap(); + let snap = doc.snapshot_subtree(f).unwrap(); + + let mut missing = snap.clone(); + let mut bad_root = (*missing.nodes[0]).clone(); + bad_root.children.push(NodeId(99999)); + missing.nodes[0] = Arc::new(bad_root); + doc.dev_snapshots.insert(f, Arc::new(missing)); + assert!(doc.with_dev_snapshot(f).is_none()); + + let mut nonrecip = snap.clone(); + let mut bad_child = (*nonrecip.nodes[1]).clone(); + bad_child.parent = Some(NodeId(88888)); + nonrecip.nodes[1] = Arc::new(bad_child); + doc.dev_snapshots.insert(f, Arc::new(nonrecip)); + assert!(doc.with_dev_snapshot(f).is_none()); +} + +#[test] +fn validate_is_linear_in_a_wide_parent() { + let mut doc = Document::new("d"); + let page = doc.first_page().unwrap(); + for i in 0..5_000 { + doc.create(page, NodeKind::Rectangle, "R", f64::from(i), 0.0, 1.0, 1.0).unwrap(); + } + assert!(doc.validate().is_empty()); + let kid = doc.node(page).unwrap().children[0]; + doc.get_mut(page).unwrap().children.retain(|c| *c != kid); + assert!(doc.validate().iter().any(|m| m.contains("not listed in parent"))); +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 70797dd8..9ff1d968 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1023,6 +1023,10 @@ impl Document { } } let mut issues = Vec::new(); + // Every (parent, child) edge and the page list, indexed once so each node's checks are + // lookups: a wide parent or a long page list doesn't make validation quadratic. + let edges: std::collections::BTreeSet<(NodeId, NodeId)> = self.nodes.iter().flat_map(|(p, n)| n.children.iter().map(|c| (*p, *c))).collect(); + let page_ids: std::collections::BTreeSet = self.pages.iter().copied().collect(); for (id, n) in self.nodes.iter() { if n.id != *id { note(&mut issues, || format!("node key {id} has id {}", n.id)); @@ -1039,12 +1043,12 @@ impl Document { } } if let Some(p) = n.parent - && !self.node(p).is_some_and(|pn| pn.children.contains(id)) + && !(self.node(p).is_some() && edges.contains(&(p, *id))) { note(&mut issues, || format!("{id} not listed in parent {p}")); } // The library store's root is a page that is never listed. - let listed = self.pages.contains(id) || (n.is_page() && self.libraries.store == Some(*id)); + let listed = page_ids.contains(id) || (n.is_page() && self.libraries.store == Some(*id)); if n.is_page() != listed { note(&mut issues, || format!("page list mismatch for {id}")); } diff --git a/crates/doc/src/node.rs b/crates/doc/src/node.rs index ca06fe53..2a284b05 100644 --- a/crates/doc/src/node.rs +++ b/crates/doc/src/node.rs @@ -397,6 +397,11 @@ impl NodeKind { self.has_corner_radius() && !matches!(self, NodeKind::Polygon { .. } | NodeKind::Star { .. } | NodeKind::Vector { .. }) } + /// Kinds whose rounded corners are drawn with corner smoothing (a vector's rounded points are drawn without it). + pub fn has_corner_smoothing(&self) -> bool { + self.has_corner_radius() && !matches!(self, NodeKind::Vector { .. }) + } + pub fn text(&self) -> Option<&TextData> { match self { NodeKind::Text(t) => Some(t), diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index f8e69f15..41e233c4 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -569,3 +569,60 @@ fn text_insert_at_run_boundary_keeps_runs_disjoint() { let runs: Vec<(usize, usize)> = t.runs.iter().map(|r| (r.start, r.end)).collect(); assert_eq!(runs, vec![(0, 7), (7, 12)]); } + +#[test] +fn many_small_vector_subpaths_build_and_stroke_without_rescans() { + // Many closed subpaths whose last point repeats the start (#F-332), a long open chain (#F-334) + // and a big rounded polygon (#F-333): each used to scan the whole network per step. + let mut p = figcraft_geom::kurbo::BezPath::new(); + for i in 0..3_000 { + let x = f64::from(i) * 10.0; + p.move_to((x, 0.0)); + p.line_to((x + 5.0, 0.0)); + p.line_to((x + 5.0, 5.0)); + p.line_to((x, 0.0)); + p.close_path(); + } + let net = VectorNetwork::from_path(&p); + assert_eq!(net.vertices.len(), 3 * 3_000); + assert_eq!(net.regions[0].loops.len(), 3_000); + assert!(net.segments.iter().all(|s| s.a < net.vertices.len() && s.b < net.vertices.len())); + assert_eq!(net.stroke_runs().len(), 3_000); + + let pts: Vec = (0..20_000).map(|i| Point::new(f64::from(i), f64::from(i % 7))).collect(); + let chain = VectorNetwork::from_points(&pts, false); + let runs = chain.stroke_runs(); + assert_eq!(runs.len(), 1); + assert!(!runs[0].closed); + + let ring: Vec = (0..4_000) + .map(|i| { + let t = f64::from(i) / 4_000.0 * std::f64::consts::TAU; + Point::new(1000.0 * t.cos(), 1000.0 * t.sin() * if i % 2 == 0 { 1.0 } else { 0.9 }) + }) + .collect(); + let mut poly = VectorNetwork::from_points(&ring, true); + for v in &mut poly.vertices { + v.radius = 0.5; + } + let rounded = poly.rounded().unwrap(); + assert!(rounded.regions[0].loops[0].len() > poly.segments.len(), "arcs are walked between the sides"); + assert_eq!(rounded.regions[0].loops[0].len(), rounded.segments.len()); +} + +#[test] +fn two_segment_closed_loop_rounded_corners() { + let mut v0 = Vertex::new(0.0, 0.0); + v0.radius = 2.0; + let mut v1 = Vertex::new(10.0, 0.0); + v1.radius = 2.0; + let net = VectorNetwork { + vertices: vec![v0, v1], + segments: vec![Segment { a: 0, b: 1, ta: (0.0, 0.0), tb: (0.0, 0.0) }, Segment { a: 1, b: 0, ta: (0.0, 5.0), tb: (0.0, -5.0) }], + regions: vec![Region { loops: vec![vec![0, 1]], winding: WindingRule::NonZero }], + }; + let r = net.rounded().unwrap(); + assert_eq!(r.regions[0].loops[0].len(), 4, "both arcs are in the loop"); + let path = net.fill_path(); + assert!(!path.elements().is_empty()); +} diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index 1165bd60..b3040b1e 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -134,6 +134,16 @@ pub struct VectorNetwork { pub regions: Vec, } +/// Point the subpath's last segment (the only one ending at the fresh vertex `from`) at `to`, +/// so merging a duplicate endpoint doesn't rescan the segments of earlier subpaths. +fn redirect_last_segment(net: &mut VectorNetwork, loop_segs: &[usize], from: usize, to: usize) { + if let Some(s) = loop_segs.last().and_then(|i| net.segments.get_mut(*i)) + && s.b == from + { + s.b = to; + } +} + impl VectorNetwork { /// A network from a polyline / polygon. pub fn from_points(points: &[Point], closed: bool) -> Self { @@ -169,11 +179,7 @@ impl VectorNetwork { if a == f || loop_segs.len() < 2 || (pa - pf).hypot() > 1e-6 || a + 1 != net.vertices.len() { return; } - for s in net.segments.iter_mut() { - if s.b == a { - s.b = f; - } - } + redirect_last_segment(net, loop_segs, a, f); net.vertices.pop(); all_loops.push(std::mem::take(loop_segs)); }; @@ -219,11 +225,7 @@ impl VectorNetwork { let same = matches!((close_pt, first_pt), (Some(c), Some(f)) if (c - f).hypot() <= 1e-6); if same && a != f { // Merge the duplicate end vertex into the first one. - for s in net.segments.iter_mut() { - if s.b == a { - s.b = f; - } - } + redirect_last_segment(&mut net, &loop_segs, a, f); if a + 1 == net.vertices.len() { net.vertices.pop(); } @@ -385,6 +387,7 @@ impl VectorNetwork { s.ta = (q.p1.x - q.p0.x, q.p1.y - q.p0.y); s.tb = (q.p2.x - q.p3.x, q.p2.y - q.p3.y); } + let mut by_vertex: std::collections::BTreeMap = std::collections::BTreeMap::new(); for c in corners { if let Some(v) = out.vertices.get_mut(c.v) { (v.x, v.y) = (c.a.x, c.a.y); @@ -400,15 +403,28 @@ impl VectorNetwork { } out.segments.push(Segment { a: c.v, b: nb, ta: c.ta, tb: c.tb }); let arc = out.segments.len() - 1; - // The arc goes between the two segments wherever a loop walks through the point. - for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { - let n = lp.len(); - let between = - |k: usize| matches!((lp.get(k), lp.get((k + 1) % n)), (Some(&x), Some(&y)) if (x, y) == (c.s1, c.s2) || (x, y) == (c.s2, c.s1)); - if let Some(k) = (0..n).find(|k| between(*k)) { - lp.insert(k + 1, arc); + by_vertex.insert(c.v, arc); + } + // The arc goes between the two segments wherever a loop walks through the point. Each loop + // is rebuilt in one pass, so a polygon with many rounded corners isn't quadratic. + for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { + let Some(&first_idx) = lp.first() else { continue }; + let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) { + (Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b, + (Some(first), _) => first.a, + _ => 0, + }; + let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len())); + let mut cur = start_v; + for &x in lp.iter() { + rebuilt.push(x); + let Some(s) = self.segments.get(x) else { continue }; + cur = if s.a == cur { s.b } else { s.a }; + if let Some(&arc) = by_vertex.get(&cur) { + rebuilt.push(arc); } } + *lp = rebuilt; } Some(out) } @@ -547,25 +563,40 @@ impl VectorNetwork { } } let degree = |v: usize| degrees.get(v).copied().unwrap_or(0); - let next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> { - self.segments.iter().enumerate().find_map(|(i, s)| { - if used.get(i).copied().unwrap_or(true) { - return None; + // Each vertex's segments in segment order, and how far down that list the used ones reach, + // so finding the next unused segment never rescans the whole network. + let mut adjacent: Vec> = vec![Vec::new(); self.vertices.len()]; + for (i, s) in self.segments.iter().enumerate() { + if let Some(l) = adjacent.get_mut(s.a) { + l.push((i, false)); + } + if s.b != s.a + && let Some(l) = adjacent.get_mut(s.b) + { + l.push((i, true)); + } + } + let mut cursor = vec![0usize; self.vertices.len()]; + let mut remaining = self.segments.len(); + let mut next_from = |v: usize, used: &[bool]| -> Option<(usize, bool)> { + let list = adjacent.get(v)?; + let at = cursor.get_mut(v)?; + while let Some(&(i, rev)) = list.get(*at) { + if !used.get(i).copied().unwrap_or(true) { + return Some((i, rev)); } - if s.a == v { - Some((i, false)) - } else if s.b == v { - Some((i, true)) - } else { - None - } - }) + *at += 1; + } + None }; let stop = |v: usize| degree(v) != 2 || breaks(v); // Start runs at endpoints, junctions and breaks first, then whatever remains (pure loops). let mut starts: Vec = (0..self.vertices.len()).filter(|v| stop(*v)).collect(); starts.extend((0..self.vertices.len()).filter(|v| !stop(*v))); for v0 in starts { + if remaining == 0 { + break; + } while let Some((si, rev)) = next_from(v0, &used) { let Some(start) = self.vertices.get(v0) else { break }; let mut path = BezPath::new(); @@ -574,8 +605,11 @@ impl VectorNetwork { let mut closed = false; let mut seg = Some((si, rev)); while let Some((i, r)) = seg { - if let Some(u) = used.get_mut(i) { + if let Some(u) = used.get_mut(i) + && !*u + { *u = true; + remaining = remaining.saturating_sub(1); } let Some(s) = self.segments.get(i) else { break }; self.push_seg(&mut path, s, r); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ec579d51..4bafa56d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -941,14 +941,37 @@ pub fn export_area(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> O .reduce(|a, b| a.union(b)) } -/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off, -/// and always for slices) every top-level layer of their page, so whatever shows in the area is in. -fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { - let slice = ids.iter().any(|i| doc.node(*i).is_some_and(|n| matches!(n.kind, NodeKind::Slice))); - match ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) { - Some(page) if slice || !setting.contents_only => page.children.clone(), - _ => super::edit::z_sorted(doc, ids), +/// What an export of `ids` draws, bottom to top: the layers themselves, or (contents only off) +/// every top-level layer of their page, so whatever shows in the area is in. A slice paints nothing +/// itself: with contents only on, a slice inside a frame or group draws that frame or group, and a +/// slice anywhere else draws every top-level layer of its page. +pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> Vec { + let Some(page) = ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)) else { + return super::edit::z_sorted(doc, ids); + }; + if !setting.contents_only { + return page.children.clone(); } + let mut own = Vec::with_capacity(ids.len()); + for &i in ids { + if !doc.node(i).is_some_and(|n| matches!(n.kind, NodeKind::Slice)) { + own.push(i); + continue; + } + match slice_container(doc, i) { + Some(c) => own.push(c), + None => return page.children.clone(), + } + } + own.sort_unstable(); + own.dedup(); + super::edit::z_sorted(doc, &super::edit::roots(doc, &own)) +} + +/// The frame or group a slice sits in: None on the page or in a section. +fn slice_container(doc: &Document, slice: NodeId) -> Option { + let parent = doc.node(slice)?.parent?; + doc.node(parent).is_some_and(|p| p.kind.is_frame_like() || matches!(p.kind, NodeKind::Group)).then_some(parent) } /// [`export_bytes`] with an export setting's format and options (its size is `scale`: see diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index be6907b5..23e24aac 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -155,6 +155,17 @@ pub fn specs() -> Vec { ok() } ), + spec!( + "proto.linkNotice", + "Notice before leaving for a link", + "{show? (default true; false skips it)} whether a presented prototype's Open link to a website first says that it leaves the prototype, with Open link and Cancel; kept with the preferences → {shown}", + false, + |s, p| { + let show = boolean(p, "show").unwrap_or(true); + s.prefs.link_notice_skipped = !show; + Ok(json!({"shown": show})) + } + ), ] } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a2f76115..813a6036 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -126,7 +126,9 @@ fn step_weight(s: &mut Session, p: &Value, up: bool) -> Result { } /// Put a text layer on a path: the text takes the path layer's place, geometry and transform, and -/// the path layer is removed. +/// the path layer is removed. The path's effects move to the text when it has any, and its fills +/// when one of them shows, each with its style and variable bindings; otherwise the text keeps +/// its own. fn on_path(s: &mut Session, p: &Value) -> Result { const CMD: &str = "text.onPath"; let doc = s.doc()?; @@ -144,6 +146,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result { } let (parent, index) = (pn.parent.ok_or_else(|| bad(CMD, "pages have no path"))?, doc.index_in_parent(path)); let (transform, w, h) = (pn.transform, pn.width, pn.height); + let fills = pn.fills.iter().any(|f| f.visible).then(|| (pn.fills.clone(), pn.styles.fill.clone())); + let effects = (!pn.effects.is_empty()).then(|| (pn.effects.clone(), pn.styles.effect.clone())); + let moved = |k: &str| match super::lists::list_binding(k) { + Some((super::lists::BoundList::Fills, ..)) => fills.is_some(), + Some((super::lists::BoundList::Effects, ..)) => effects.is_some(), + _ => false, + }; + let bound: Vec<(String, VariableId)> = pn.bound.iter().filter(|(k, _)| moved(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); let mut tp = TextPath::new(&geom); tp.start = num(p, "start").filter(|v| v.is_finite()).unwrap_or(0.0).clamp(0.0, 1.0); tp.flip = boolean(p, "flip").unwrap_or(false); @@ -153,6 +163,14 @@ fn on_path(s: &mut Session, p: &Value) -> Result { let n = doc.get_mut(text)?; n.transform = transform; (n.width, n.height) = (w, h); + n.bound.retain(|k, _| !moved(k)); + n.bound.extend(bound); + if let Some((fills, style)) = fills { + (n.fills, n.styles.fill) = (fills, style); + } + if let Some((effects, style)) = effects { + (n.effects, n.styles.effect) = (effects, style); + } if let NodeKind::Text(t) = &mut n.kind { t.path = Some(Box::new(tp)); } @@ -165,7 +183,7 @@ pub fn specs() -> Vec { spec!( "text.onPath", "Text on path", - "{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and the path layer is removed → {id}", + "{text? (text layer), path? (vector or shape layer; default: the selected text and other layer), start? (0..1 along the path), flip?} the text follows the path and takes its effects and its fills when one shows, and the path layer is removed → {id}", true, on_path ), diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 17f71b49..a81a222c 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -535,6 +535,9 @@ pub fn specs() -> Vec { spec!("tool.pen", "Pen", "{}", false, |s, _| tool(s, ToolId::Pen)), spec!("tool.pencil", "Pencil", "{}", false, |s, _| tool(s, ToolId::Pencil)), spec!("tool.text", "Text", "{}", false, |s, _| tool(s, ToolId::Text)), + spec!("tool.textOnPath", "Text on a path", "{} a click on a shape's or vector's path puts new text on it", false, |s, _| { + tool(s, ToolId::TextOnPath) + }), spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)), spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)), // `apply: false` is for the UI colour picker: the eyedropper only samples (into diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 7a9e0621..6ffb33e4 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -70,6 +70,7 @@ fn scene() -> (Session, NodeId, NodeId) { const EMPTY: (f64, f64) = (700.0, 600.0); const ON_SQUARE: (f64, f64) = (150.0, 150.0); +const ON_SQUARE_EDGE: (f64, f64) = (101.0, 150.0); const ON_TEXT: (f64, f64) = (420.0, 120.0); #[test] @@ -101,6 +102,9 @@ fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() { (ToolId::Text, NONE, EMPTY, Crosshair, "the Text tool makes a text where it is pressed"), (ToolId::Text, NONE, ON_SQUARE, Crosshair, "over a shape too"), (ToolId::Text, NONE, ON_TEXT, Text, "the I-beam over a text: a click edits it there"), + (ToolId::TextOnPath, NONE, EMPTY, Default, "text on a path: a click on empty canvas does nothing"), + (ToolId::TextOnPath, NONE, ON_SQUARE, Default, "nor inside a shape, away from its path"), + (ToolId::TextOnPath, NONE, ON_SQUARE_EDGE, Text, "the I-beam over a shape's path: a click puts text on it"), (ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"), (ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"), (ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"), diff --git a/crates/engine/src/draw_tools_tests.rs b/crates/engine/src/draw_tools_tests.rs index d67b0af4..607de266 100644 --- a/crates/engine/src/draw_tools_tests.rs +++ b/crates/engine/src/draw_tools_tests.rs @@ -60,6 +60,149 @@ fn text_goes_onto_a_path_and_off_again() { assert!(s.doc().unwrap().node(v).is_some() && text_path(&s, t).is_none(), "undo brings the path back"); } +/// A click (press and release) at (`x`, `y`) at 100%. +fn click(s: &mut Session, x: f64, y: f64) { + use crate::tools::{PointerEvent, PointerKind}; + s.pointer(&PointerEvent::at(PointerKind::Down, x, y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, x, y, 1.0)).unwrap(); +} + +fn editing(s: &Session) -> Option { + s.ds().unwrap().text_edit.as_ref().map(|te| te.id) +} + +#[test] +fn the_text_on_a_path_tool_types_new_text_onto_the_path_it_clicks() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100, "props": {"fill": "#FF0000"}})).unwrap()); + s.execute("effect.add", &json!({"ids": [v], "type": "dropShadow"})).unwrap(); + let (vxf, vw, fills, effects) = { + let n = s.doc().unwrap().node(v).unwrap(); + (n.transform, n.width, n.fills.clone(), n.effects.clone()) + }; + assert!(!fills.is_empty() && !effects.is_empty()); + s.set_tool(crate::tools::ToolId::TextOnPath); + // Away from the path a click does nothing. + click(&mut s, 140.0, 160.0); + assert!(editing(&s).is_none() && s.doc().unwrap().node(v).is_some(), "only a path takes text"); + // A quarter of the way along, a few pixels off the line. + click(&mut s, 90.0, 103.0); + let t = editing(&s).expect("typing starts on the path"); + assert!(s.doc().unwrap().node(v).is_none(), "the path layer became the text's path"); + crate::textedit::insert(&mut s, "Along").unwrap(); + crate::textedit::end(&mut s).unwrap(); + let doc = s.doc().unwrap(); + let n = doc.node(t).unwrap(); + assert_eq!((n.transform, n.width), (vxf, vw), "in the path's place"); + assert_eq!(n.kind.text().map(|t| t.characters.as_str()), Some("Along")); + assert_eq!((&n.fills, &n.effects), (&fills, &effects), "the path's fill and effects carry over to the text"); + let tp = text_path(&s, t).unwrap(); + assert!((tp.start - 0.25).abs() < 0.01, "the text starts where the path was clicked: {}", tp.start); + assert_eq!(s.tool.tool, crate::tools::ToolId::Move, "back to Move after placing, as with the Text tool"); + s.execute("edit.undo", &json!({})).unwrap(); + let doc = s.doc().unwrap(); + assert!(doc.node(v).is_some() && doc.node(t).is_none(), "one undo step brings the path back"); +} + +#[test] +fn a_shape_in_front_whose_path_is_away_from_the_click_does_not_hide_the_path_behind() { + let mut s = Session::new(); + let back = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100})).unwrap()); + // A filled square over the line: the click is inside it, 20 or more from its outline. + let front = id(s.execute("shape.rectangle", &json!({"x": 60, "y": 80, "width": 100, "height": 60})).unwrap()); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 90.0, 100.0); + assert!(editing(&s).is_some(), "typing starts on the path behind"); + let doc = s.doc().unwrap(); + assert!(doc.node(back).is_none() && doc.node(front).is_some(), "the clicked path took the text"); +} + +#[test] +fn a_path_scaled_more_one_way_is_reached_where_it_shows() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 L100 100", "x": 0, "y": 0})).unwrap()); + // Ten times wider than tall: on the page the line runs from (0, 0) to (1000, 100). + s.doc_mut().unwrap().get_mut(v).unwrap().transform = figcraft_geom::Affine::scale_non_uniform(10.0, 1.0); + s.set_tool(crate::tools::ToolId::TextOnPath); + // 5 below the line on the page, halfway along it. + click(&mut s, 500.0, 55.0); + let t = editing(&s).expect("typing starts on the path"); + let tp = text_path(&s, t).unwrap(); + assert!((tp.start - 0.5).abs() < 0.01, "{}", tp.start); + // A layer shown at a tenth of its size: its outline is still reached 5 screen pixels away. + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 1000, "height": 1000})).unwrap()); + s.doc_mut().unwrap().get_mut(r).unwrap().transform = figcraft_geom::Affine::scale(0.1); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 105.0, 50.0); + assert!(editing(&s).is_some() && s.doc().unwrap().node(r).is_none(), "typing starts on the small square's path"); +} + +#[test] +fn paths_a_mask_or_a_turned_clipping_frame_hides_take_no_text() { + let mut s = Session::new(); + // A square masked by a circle in its top-left corner: its right edge doesn't show. + let m = id(s.execute("shape.ellipse", &json!({"x": 0, "y": 0, "width": 50, "height": 50})).unwrap()); + let r = id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 100})).unwrap()); + s.execute("object.mask", &json!({"ids": [m, r]})).unwrap(); + // A clipping frame turned 45°, with a line outside it: inside the frame's bounding box, but clipped. + let f = id(s.execute("frame.create", &json!({"x": 300, "y": 0, "width": 100, "height": 100, "props": {"clip": true}})).unwrap()); + let l = id(s.execute("vector.create", &json!({"path": "M0 0 L40 0", "x": -60, "y": 50, "parent": f})).unwrap()); + s.execute("node.set", &json!({"id": f, "props": {"rotation": 45}})).unwrap(); + let hidden = s.doc().unwrap().world_transform(l) * figcraft_geom::Point::new(20.0, 0.0); + let frame_box = s.doc().unwrap().world_bounds(f).unwrap(); + assert!(frame_box.contains(hidden), "the line's middle is inside the frame's bounding box"); + s.set_tool(crate::tools::ToolId::TextOnPath); + for (x, y) in [(100.0, 50.0), (hidden.x, hidden.y)] { + click(&mut s, x, y); + assert!(editing(&s).is_none(), "nothing shows at ({x}, {y})"); + } + let doc = s.doc().unwrap(); + assert!(doc.node(r).is_some() && doc.node(l).is_some()); +} + +#[test] +fn text_on_a_stroke_only_path_gets_the_usual_fill() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100, "props": {"fills": []}})).unwrap()); + assert!(!s.doc().unwrap().node(v).unwrap().fills.iter().any(|f| f.visible), "a path with a stroke only"); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 40.0, 100.0); + let t = editing(&s).expect("typing starts on the path"); + assert!(s.doc().unwrap().node(t).unwrap().fills.iter().any(|f| f.visible), "the text can be seen"); +} + +#[test] +fn text_on_a_path_left_empty_puts_the_path_back_without_an_undo_step() { + let mut s = Session::new(); + let r = id(s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 100, "height": 100})).unwrap()); + let locked = id(s.execute("shape.rectangle", &json!({"x": 400, "y": 100, "width": 100, "height": 100, "props": {"locked": true}})).unwrap()); + let before = (s.doc().unwrap().clone(), s.undo_labels(100).len()); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 400.0, 150.0); + assert!(editing(&s).is_none(), "a locked layer's path doesn't take text"); + // Inside the square, away from its outline, nothing happens either. + click(&mut s, 150.0, 150.0); + assert!(editing(&s).is_none()); + click(&mut s, 100.0, 150.0); + assert!(editing(&s).is_some() && s.doc().unwrap().node(r).is_none()); + crate::textedit::end(&mut s).unwrap(); + assert_eq!(*s.doc().unwrap(), before.0, "the square is back as it was"); + assert_eq!(s.undo_labels(100).len(), before.1, "no undo step"); + assert!(s.doc().unwrap().node(locked).is_some()); + // A double-click on the path, then nothing typed: the same (with this tool and the Text tool). + use crate::tools::{PointerEvent, PointerKind, ToolId}; + for tool in [ToolId::TextOnPath, ToolId::Text] { + s.set_tool(tool); + click(&mut s, 100.0, 150.0); + s.pointer(&PointerEvent::at(PointerKind::DoubleClick, 100.0, 150.0, 1.0)).unwrap(); + assert!(editing(&s).is_some(), "{tool:?}"); + crate::textedit::end(&mut s).unwrap(); + assert_eq!(*s.doc().unwrap(), before.0, "{tool:?}: the file is back as it was"); + assert_eq!(s.undo_labels(100).len(), before.1, "{tool:?}: no undo step"); + } +} + #[test] fn svg_text_paths_import_as_text_on_a_path() { // SVG text is parsed with Inter standing in for every family (CI has no fonts). @@ -79,6 +222,83 @@ fn svg_text_paths_import_as_text_on_a_path() { assert_eq!(t.align_h, figcraft_doc::TextAlignH::Center); } +/// A text layer with its own green fill bound to `ink` and a layer blur, and a curve; the +/// variables `ink` and `shadow` exist. +fn text_and_curve(s: &mut Session, path_props: Value) -> (NodeId, NodeId) { + let t = id(s.execute("text.create", &json!({"text": "Around", "x": 0, "y": 0})).unwrap()); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 C50 -50 150 -50 200 0", "x": 40, "y": 100, "props": path_props})).unwrap()); + s.execute("variable.createCollection", &json!({"name": "Paint"})).unwrap(); + for (name, value) in [("ink", "#00FF00"), ("shadow", "#FF000080")] { + s.execute("variable.create", &json!({"collection": "Paint", "name": name, "type": "color", "value": value})).unwrap(); + } + s.execute("variable.bind", &json!({"ids": [t], "field": "fills/0/color", "variable": "ink"})).unwrap(); + s.execute("effect.add", &json!({"ids": [t], "type": "layerBlur"})).unwrap(); + (t, v) +} + +#[test] +fn text_put_on_a_filled_path_takes_its_fill_and_effects() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fill": "#FF0000"})); + let brand = s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": "#3366FF"})).unwrap()["id"].clone(); + s.execute("style.apply", &json!({"ids": [v], "style": brand})).unwrap(); + s.execute("effect.add", &json!({"ids": [v], "type": "dropShadow"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [v], "field": "effects/0/color", "variable": "shadow"})).unwrap(); + let path = s.doc().unwrap().node(v).unwrap().clone(); + let text = s.doc().unwrap().node(t).unwrap().clone(); + assert!(path.styles.fill.is_some() && path.bound.contains_key("effects/0/color")); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + // "the vector object's fill and effects are automatically transferred to the text layer" + assert_eq!((&n.fills, &n.styles.fill), (&path.fills, &path.styles.fill), "the path's fill and its style"); + assert_eq!((&n.effects, &n.styles.effect), (&path.effects, &path.styles.effect), "the path's effects"); + assert_eq!(n.bound, path.bound, "the path's bindings replace the text's fill binding"); + s.execute("edit.undo", &json!({})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!((&n.fills, &n.styles.fill), (&text.fills, &text.styles.fill), "undo gives the text its own fill back"); + assert_eq!((&n.effects, &n.styles.effect), (&text.effects, &text.styles.effect), "undo gives the text its own effects back"); + assert_eq!(n.bound, text.bound, "undo gives the text its own fill binding back"); +} + +#[test] +fn text_put_on_a_stroke_only_path_keeps_its_own_fill_and_effects() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fills": []})); + let text = s.doc().unwrap().node(t).unwrap().clone(); + assert!(!text.fills.is_empty() && !text.effects.is_empty()); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!((&n.fills, &n.effects, &n.bound), (&text.fills, &text.effects, &text.bound), "a path with no fill or effects to give"); +} + +#[test] +fn text_put_on_a_path_with_an_empty_effect_style_keeps_its_own_effects() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fills": []})); + let none = s.execute("style.create", &json!({"type": "effect", "name": "None", "value": []})).unwrap()["id"].clone(); + s.execute("style.apply", &json!({"ids": [v], "style": none})).unwrap(); + let path = s.doc().unwrap().node(v).unwrap().clone(); + assert!(path.effects.is_empty() && path.styles.effect.is_some()); + let text = s.doc().unwrap().node(t).unwrap().clone(); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!((&n.effects, &n.styles.effect), (&text.effects, &text.styles.effect), "a path whose effect style draws no effects"); +} + +#[test] +fn text_put_on_a_path_takes_a_fill_binding_made_through_fills() { + let mut s = Session::new(); + let (t, v) = text_and_curve(&mut s, json!({"fill": "#FF0000"})); + s.execute("variable.bind", &json!({"ids": [v], "field": "fills", "variable": "shadow"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [t], "field": "fills", "variable": "ink"})).unwrap(); + let path = s.doc().unwrap().node(v).unwrap().clone(); + assert!(path.bound.contains_key("fills")); + s.execute("text.onPath", &json!({"ids": [t, v]})).unwrap(); + let n = s.doc().unwrap().node(t).unwrap(); + assert_eq!(n.bound, path.bound, "the path's `fills` binding replaces the text's"); + assert_eq!(n.fills, path.fills); +} + #[test] fn pattern_fills_tile_their_source_live() { let mut s = Session::new(); @@ -131,3 +351,18 @@ fn a_pattern_of_itself_draws_nothing_instead_of_recursing() { assert!(figcraft_svg::export_svg(doc, &[r], &figcraft_svg::SvgOptions::default()).is_some()); assert!(figcraft_pdf::export_nodes_pdf(doc, &[r]).is_ok()); } + +#[test] +fn the_text_on_a_path_tool_carries_a_fill_bound_through_fills() { + let mut s = Session::new(); + let v = id(s.execute("vector.create", &json!({"path": "M0 0 L200 0", "x": 40, "y": 100, "props": {"fill": "#FF0000"}})).unwrap()); + s.execute("variable.createCollection", &json!({"name": "Paint"})).unwrap(); + s.execute("variable.create", &json!({"collection": "Paint", "name": "ink", "type": "color", "value": "#00FF00"})).unwrap(); + s.execute("variable.bind", &json!({"ids": [v], "field": "fills", "variable": "ink"})).unwrap(); + let bound = s.doc().unwrap().node(v).unwrap().bound.clone(); + assert!(bound.contains_key("fills")); + s.set_tool(crate::tools::ToolId::TextOnPath); + click(&mut s, 90.0, 103.0); + let t = editing(&s).expect("typing starts on the path"); + assert_eq!(s.doc().unwrap().node(t).unwrap().bound, bound, "the fill keeps following its variable"); +} diff --git a/crates/engine/src/export_behaviour_tests.rs b/crates/engine/src/export_behaviour_tests.rs index 6896ce3e..ec1a56e9 100644 --- a/crates/engine/src/export_behaviour_tests.rs +++ b/crates/engine/src/export_behaviour_tests.rs @@ -256,6 +256,43 @@ fn masks_and_slices_export_what_shows() { let _ = under; } +/// Ignore overlapping layers (contents only) on a slice: inside a frame or group the slice exports +/// only what is in that container; off, or on the page, everything in its area. +#[test] +fn a_slice_in_a_frame_or_group_exports_only_that_container() { + let mut s = Session::new(); + let slice_in = |s: &mut Session, parent: Option, x: f64| { + let mut p = json!({"type": "slice", "x": x, "y": 25, "width": 50, "height": 50}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(&ex(s, "node.create", p)) + }; + let at = |s: &mut Session, p: Value| pixels(&export(s, p)).get_pixel(40, 40).0; + // A frame holding a red square and a slice, with a blue square on the page above it. + let f = frame(&mut s, 0.0, 0.0, 100.0, 100.0, false); + rect(&mut s, 0.0, 0.0, 100.0, 100.0, "#FF0000", Some(f)); + let in_frame = slice_in(&mut s, Some(f), 25.0); + rect(&mut s, 50.0, 50.0, 100.0, 100.0, "#0000FF", None); + assert_eq!(png_size(&export(&mut s, json!({"ids": [in_frame]}))), (50, 50), "the slice still exports its own area"); + assert_eq!(at(&mut s, json!({"ids": [in_frame]})), [255, 0, 0, 255], "on by default: only the frame's content, the blue square left out"); + assert_eq!(at(&mut s, json!({"ids": [in_frame], "contentsOnly": false})), [0, 0, 255, 255], "off: everything in the slice's area"); + // The same in a group. + let red = rect(&mut s, 300.0, 0.0, 100.0, 100.0, "#FF0000", None); + let grouped = slice_in(&mut s, None, 325.0); + let g = id(&ex(&mut s, "object.group", json!({"ids": [red, grouped]}))); + assert_eq!(s.doc().unwrap().node(grouped).unwrap().parent, Some(g)); + rect(&mut s, 350.0, 50.0, 100.0, 100.0, "#0000FF", None); + assert_eq!(at(&mut s, json!({"ids": [grouped]})), [255, 0, 0, 255], "in a group: only the group's content"); + assert_eq!(at(&mut s, json!({"ids": [grouped], "contentsOnly": false})), [0, 0, 255, 255], "off: everything in its area"); + // On the page the option has no effect. + rect(&mut s, 600.0, 0.0, 100.0, 100.0, "#FF0000", None); + let top = slice_in(&mut s, None, 625.0); + rect(&mut s, 650.0, 50.0, 100.0, 100.0, "#0000FF", None); + assert_eq!(at(&mut s, json!({"ids": [top]})), [0, 0, 255, 255], "on the page: everything in its area, with the option on"); + assert_eq!(at(&mut s, json!({"ids": [top], "contentsOnly": false})), [0, 0, 255, 255], "on the page: everything in its area, with it off"); +} + #[test] fn several_layers_and_frames_to_pdf() { let mut s = Session::new(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2ac9da8..99b0f6f2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -691,6 +691,9 @@ pub struct Prefs { /// The Getting started tips on the home screen were closed (Help ▸ Getting started shows them /// again). pub getting_started_dismissed: bool, + /// A prototype's Open link goes to a website without first telling the viewer that it leaves + /// the prototype (the notice's "Don't show this again"; `proto.linkNotice` shows it again). + pub link_notice_skipped: bool, /// How each home screen section shows its files, by section id (`"recents"`): `"grid"` (cards /// with the picture each file carries) or `"list"` (a table). Missing: the grid. #[serde(skip_serializing_if = "BTreeMap::is_empty")] @@ -740,6 +743,7 @@ impl Default for Prefs { recent_files: Vec::new(), online_files: Vec::new(), getting_started_dismissed: false, + link_notice_skipped: false, home_views: BTreeMap::new(), home_sort: "modified".into(), home_order: "desc".into(), diff --git a/crates/engine/src/selection_colors.rs b/crates/engine/src/selection_colors.rs index 875c6416..56bc4b35 100644 --- a/crates/engine/src/selection_colors.rs +++ b/crates/engine/src/selection_colors.rs @@ -5,11 +5,12 @@ use std::collections::{BTreeMap, BTreeSet}; use figcraft_color::{Paint, PaintKind}; -use figcraft_doc::{Document, NodeId, NodeKind, StyleKind}; +use figcraft_doc::{Document, NodeId, NodeKind, StyleKind, VariableType, VariableValue}; use serde::{Deserialize, Serialize}; 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::{CommandSpec, Result, Session, bad, spec}; @@ -130,6 +131,20 @@ pub fn specs() -> Vec { true, apply_style ), + spec!( + "selection.bindColorVariable", + "Apply selection color variable", + "{targets, variable (id or name)}; binds the color variable to matching solid fill/stroke slots, replacing a color style there (text ranges with fills of their own take its value in the layer's modes)", + true, + bind_variable + ), + spec!( + "selection.detachColor", + "Detach selection color", + "{targets}; unlinks the color style or variable of matching fill/stroke lists, keeping their values", + true, + detach + ), spec!( "selection.selectColor", "Select layers with color", @@ -210,6 +225,117 @@ fn apply_style(s: &mut Session, p: &Value) -> Result { Ok(json!({"ok": true})) } +/// The binding key (`fills//color` or `strokes//color`) of the slot a target stands for, when the +/// target is the node's own list or a text range that shows the node's fills. Returns None for a range +/// with fills of its own, since those fills hold no link. +fn list_field(doc: &Document, target: &PaintTarget) -> Option { + if let Some((start, _)) = target.range + && doc.node(target.id)?.kind.text()?.style_at(start).fills.is_some() + { + return None; + } + let list = if target.fills { BoundList::Fills } else { BoundList::Strokes }; + Some(format!("{}/{}/color", list.prefix(), target.index)) +} + +/// Remove every binding key of the slot (`fill` and `fills/0/color` name the same one); true when +/// one was there. +fn unbind_slot(n: &mut figcraft_doc::Node, fills: bool, index: usize) -> bool { + let list = if fills { BoundList::Fills } else { BoundList::Strokes }; + let before = n.bound.len(); + n.bound.retain(|key, _| list_binding(key).is_none_or(|(l, i, field)| !(l == list && i == index && field == "color"))); + n.bound.len() != before +} + +/// Why `variable` can't be applied to `targets` (`selection.bindColorVariable`), if it can't: a +/// variable that isn't a color, or a gradient. Scopes don't refuse, as with `variable.bind`; they +/// only filter what the menu offers ([`in_scope`]). +pub fn bind_refusal(doc: &Document, targets: &[PaintTarget], variable: &str) -> Option { + let Some(var) = doc.variables.get(variable) else { return Some("no such variable".into()) }; + if var.kind != VariableType::Color { + return Some("a color variable is required".into()); + } + for target in targets { + if !target.paint(doc).is_some_and(|paint| matches!(paint.kind, PaintKind::Solid { .. })) { + return Some("color variables bind to solid paints".into()); + } + let list = if target.fills { BoundList::Fills } else { BoundList::Strokes }; + let field = format!("{}/{}/color", list.prefix(), target.index); + if !crate::cmd::variables::field_takes(doc, target.id, &field, var.kind) { + return Some(format!("`{field}` doesn't take a color variable")); + } + } + None +} + +/// Whether `variable`'s scopes cover every slot of `targets` (a variable hidden from pickers has +/// none), so the Selection colors menu offers it there. +pub fn in_scope(doc: &Document, targets: &[PaintTarget], variable: &str) -> bool { + let Some(var) = doc.variables.get(variable) else { return false }; + targets.iter().all(|target| { + let list = if target.fills { BoundList::Fills } else { BoundList::Strokes }; + let field = format!("{}/{}/color", list.prefix(), target.index); + crate::cmd::variables::field_scope(doc, target.id, &field).is_none_or(|scope| var.in_scope(scope)) + }) +} + +fn bind_variable(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "selection.bindColorVariable"; + let targets = checked_targets(s, p, CMD)?; + let name = req_string(CMD, p, "variable")?; + let doc = s.doc()?; + if let Some(why) = bind_refusal(doc, &targets, &name) { + return Err(bad(CMD, why)); + } + let vid = doc.variables.get(&name).map(|v| v.id.clone()).ok_or_else(|| bad(CMD, "no such variable"))?; + for target in &targets { + if let Some(field) = list_field(s.doc()?, target) { + let n = s.doc_mut()?.get_mut(target.id)?; + if target.fills { + n.styles.fill = None; + } else { + n.styles.stroke = None; + } + if !unbind_slot(n, target.fills, target.index) { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, true); + } + n.bound.insert(field, vid.clone()); + continue; + } + // A range's own fills can't hold a binding: they take the value, as a range edit does. + let doc = s.doc()?; + let Some(VariableValue::Color(color)) = doc.variables.resolve(&vid, &doc.modes_for(target.id)) else { continue }; + let mut paints = target.paints(doc).ok_or_else(|| bad(CMD, "invalid target"))?; + let Some(slot) = paints.get_mut(target.index) else { return Err(bad(CMD, "invalid paint index")) }; + let own = if slot.color_alpha > 0.0 { (slot.opacity / slot.color_alpha).clamp(0.0, 1.0) } else { 1.0 }; + slot.kind = PaintKind::Solid { color: color.with_alpha(1.0) }; + slot.opacity = own * color.a; + slot.color_alpha = color.a; + let Some((start, end)) = target.range else { continue }; + if let NodeKind::Text(t) = &mut s.doc_mut()?.get_mut(target.id)?.kind { + t.restyle(start, end, |style| style.fills = Some(paints.clone())); + } + } + Ok(json!({"ok": true})) +} + +fn detach(s: &mut Session, p: &Value) -> Result { + let targets = checked_targets(s, p, "selection.detachColor")?; + for target in &targets { + let Some(field) = list_field(s.doc()?, target) else { continue }; + let n = s.doc_mut()?.get_mut(target.id)?; + if target.fills { + n.styles.fill = None; + } else { + n.styles.stroke = None; + } + if unbind_slot(n, target.fills, target.index) { + rebind_paint_alpha(&mut n.fills, &mut n.strokes, &field, false); + } + } + Ok(json!({"ok": true})) +} + fn select_color(s: &mut Session, p: &Value) -> Result { let ts = checked_targets(s, p, "selection.selectColor")?; let ids: Vec<_> = ts.iter().map(|t| t.id).collect::>().into_iter().collect(); diff --git a/crates/engine/src/shapes_behaviour_tests.rs b/crates/engine/src/shapes_behaviour_tests.rs index 62069da3..e628e168 100644 --- a/crates/engine/src/shapes_behaviour_tests.rs +++ b/crates/engine/src/shapes_behaviour_tests.rs @@ -205,6 +205,42 @@ fn corner_radius_follows_the_documented_rules() { ); } +#[test] +fn corner_smoothing_follows_the_documented_rules() { + // Smoothing applies to any shape a radius applies to, and only to the entire shape. + // (layer, radius, derivation) + let rows: Vec<(&str, f64, &str)> = vec![ + ("shape.rectangle", 20.0, "rectangles smooth their rounded corners"), + ("shape.polygon", 12.0, "polygons smooth their rounded corners"), + ("shape.star", 4.0, "stars smooth their rounded corners"), + ]; + for (cmd, radius, why) in rows { + let mut s = Session::new(); + let id = create(&mut s, cmd, json!({"x": 0, "y": 0, "width": 100, "height": 100})); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": radius}})); + let round = figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap().area().abs(); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0.6}})); + let smooth = figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap().area().abs(); + assert!((smooth - round).abs() > 1.0, "{why}: {round} then {smooth}"); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerRadius": 0}})); + let sharp = figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap(); + ex(&mut s, "node.set", json!({"ids": [id], "props": {"cornerSmoothing": 0}})); + assert_eq!(sharp, figcraft_doc::geometry::shape_path(&node(&s, id)).unwrap(), "{why}: smoothing needs a radius"); + } + // (kind, takes smoothing, derivation) + let doc_rows: Vec<(NodeKind, bool, &str)> = vec![ + (NodeKind::Rectangle, true, "rectangles"), + (NodeKind::Frame, true, "frames"), + (NodeKind::Polygon { count: 5 }, true, "polygons"), + (NodeKind::Star { count: 5, inner_ratio: 0.4 }, true, "stars"), + (NodeKind::Vector { network: Default::default() }, false, "documented for closed vectors; their points round without it here"), + (NodeKind::Line, false, "lines take no radius, so no smoothing"), + ]; + for (kind, smoothing, why) in doc_rows { + assert_eq!(kind.has_corner_smoothing(), smoothing, "{why}"); + } +} + #[test] fn rounded_vector_points_are_drawn() { // A converted 100 × 100 square: a point's radius rounds that corner only (it wasn't drawn). diff --git a/crates/engine/src/style_vars.rs b/crates/engine/src/style_vars.rs index 97226aed..b2e5c182 100644 --- a/crates/engine/src/style_vars.rs +++ b/crates/engine/src/style_vars.rs @@ -42,6 +42,29 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option { } } +/// The variable scopes a picker offers for `key` of a style of `kind`: a variable scoped to any one +/// of them fits. A paint style's colors take fill and stroke variables, an effect style's fields +/// take effect variables, and a text style's fields take the variables of their font property. +/// None when every variable of the field's type fits (a grid's fields, as on layers) or the style +/// has no such field. +pub fn field_scopes(kind: &StyleKind, key: &str) -> Option<&'static [&'static str]> { + field_type(kind, key)?; + Some(match (kind, list_binding(key)) { + (StyleKind::Paint { .. }, _) => &["FRAME_FILL", "SHAPE_FILL", "TEXT_FILL", "STROKE_COLOR"], + (StyleKind::Effect { .. }, Some((_, _, "color"))) => &["EFFECT_COLOR"], + (StyleKind::Effect { .. }, _) => &["EFFECT_FLOAT"], + (StyleKind::Text { .. }, _) => match key { + "fontSize" => &["FONT_SIZE"], + "lineHeight" => &["LINE_HEIGHT"], + "letterSpacing" => &["LETTER_SPACING"], + "fontFamily" => &["FONT_FAMILY"], + "fontStyle" => &["FONT_STYLE"], + _ => return None, + }, + _ => return None, + }) +} + /// The key a style stores a binding of `key` under: the first paint's colour has one spelling. pub fn canonical(key: &str) -> String { match list_binding(key) { diff --git a/crates/engine/src/style_vars_tests.rs b/crates/engine/src/style_vars_tests.rs index dae9ef56..66768daa 100644 --- a/crates/engine/src/style_vars_tests.rs +++ b/crates/engine/src/style_vars_tests.rs @@ -182,6 +182,46 @@ fn what_a_style_cannot_bind_is_refused() { } } +#[test] +fn style_fields_offer_their_properties_scopes() { + let mut s = Session::new(); + let (_, _, a, _, sid) = two_modes(&mut s); + run(&mut s, "effect.add", json!({"ids": [a], "type": "dropShadow"})); + let fx = run(&mut s, "style.create", json!({"type": "effect", "name": "Drop", "from": a}))["id"].as_str().unwrap().to_string(); + let light = node(&s, a).parent.unwrap(); + run(&mut s, "grid.add", json!({"ids": [light]})); + let grid = run(&mut s, "style.create", json!({"type": "grid", "name": "Base", "from": light}))["id"].as_str().unwrap().to_string(); + let body = run(&mut s, "style.create", json!({"type": "text", "name": "Body", "value": {"fontFamily": "Inter", "fontSize": 14}}))["id"] + .as_str() + .unwrap() + .to_string(); + let doc = s.doc().unwrap(); + let scopes = |st: &str, key: &str| crate::style_vars::field_scopes(&doc.style(st).unwrap().kind, key); + let fills: &[&str] = &["FRAME_FILL", "SHAPE_FILL", "TEXT_FILL", "STROKE_COLOR"]; + for (st, key, want) in [ + (&sid, "fill", Some(fills)), + (&sid, "fills/0/color", Some(fills)), + (&fx, "effects/0/color", Some(&["EFFECT_COLOR"][..])), + (&fx, "effects/0/radius", Some(&["EFFECT_FLOAT"][..])), + (&fx, "effects/0/offsetX", Some(&["EFFECT_FLOAT"][..])), + (&body, "fontSize", Some(&["FONT_SIZE"][..])), + (&body, "lineHeight", Some(&["LINE_HEIGHT"][..])), + (&body, "letterSpacing", Some(&["LETTER_SPACING"][..])), + (&body, "fontFamily", Some(&["FONT_FAMILY"][..])), + (&body, "fontStyle", Some(&["FONT_STYLE"][..])), + // Grid fields take any number variable, as on layers; fields a style lacks take none. + (&grid, "layoutGrids/0/size", None), + (&sid, "fills/3/color", None), + (&body, "opacity", None), + ] { + assert_eq!(scopes(st, key), want, "{st} {key}"); + } + // As on layers, scopes limit pickers only: an out-of-scope variable still binds. + run(&mut s, "variable.update", json!({"variable": "size", "scopes": ["CORNER_RADIUS"]})); + run(&mut s, "style.bind", json!({"style": body, "field": "fontSize", "variable": "size"})); + assert!(s.doc().unwrap().style(&body).unwrap().bound.contains_key("fontSize")); +} + #[test] fn rest_and_fig_carry_style_bindings() { let mut s = Session::new(); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 36235426..7ab7a857 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -423,6 +423,7 @@ fn every_command_survives_junk_params() { // Selection color slots: malformed indexes, ranges and paints. json!({"targets": [{"id": 2, "fills": true, "index": -1, "range": [0, 1e308]}], "paint": {"type": "solid", "color": "invalid"}}), json!({"targets": [{"id": 2, "fills": "yes", "index": 0, "range": [4, 1]}], "style": 5}), + json!({"targets": [{"id": 2, "fills": false, "index": 99999999999u64, "range": null}], "variable": ["x"]}), // Smart-selection reorder targets and modes as junk. json!({"ids": [2, 3], "source": -1, "target": {"id": 3}, "swap": [], "fromGesture": true}), json!({"source": 1e308, "target": "NaN", "swap": "yes", "fromGesture": []}), diff --git a/crates/engine/src/textedit.rs b/crates/engine/src/textedit.rs index 85ea535b..2dfc774c 100644 --- a/crates/engine/src/textedit.rs +++ b/crates/engine/src/textedit.rs @@ -17,6 +17,8 @@ pub fn begin(s: &mut Session, id: NodeId, caret: Option, created: bool) - let n = t.char_count(); s.begin_interaction("Edit text")?; let ds = s.ds_mut()?; + // Editing the same layer again (a double-click into a text just made) keeps it new. + let created = created || ds.text_edit.as_ref().is_some_and(|te| te.id == id && te.created); let (caret, anchor) = match caret { Some(c) => (c.min(n), c.min(n)), None => (n, 0), @@ -44,9 +46,13 @@ pub fn end(s: &mut Session) -> Result<()> { // Nothing to undo for a text box that was never filled. The gesture snapshot was taken // after the layer was added, so it still holds it: re-base it on the document without the // layer, and committing then records no step (and leaves the redo stack alone). + // A snapshot taken before the layer was added (the Text on a path tool, which also took + // the path layer away) is put back whole instead. let ds = s.ds_mut()?; let doc = ds.doc.clone(); - if let Some(it) = ds.interaction.as_mut() { + if ds.interaction.as_ref().is_some_and(|it| it.doc.node(te.id).is_none()) { + s.cancel_interaction()?; + } else if let Some(it) = ds.interaction.as_mut() { it.doc = doc; } } diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 86feed4f..eb20de49 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -36,6 +36,7 @@ pub enum ToolId { Pen, Pencil, Text, + TextOnPath, Comment, Eyedropper, ShapeBuilder, @@ -50,7 +51,7 @@ pub enum ToolId { } impl ToolId { - pub const ALL: [ToolId; 25] = [ + pub const ALL: [ToolId; 26] = [ ToolId::Move, ToolId::Scale, ToolId::Hand, @@ -66,6 +67,7 @@ impl ToolId { ToolId::Pen, ToolId::Pencil, ToolId::Text, + ToolId::TextOnPath, ToolId::Comment, ToolId::Eyedropper, ToolId::ShapeBuilder, @@ -94,6 +96,7 @@ impl ToolId { ToolId::Pen => "pen", ToolId::Pencil => "pencil", ToolId::Text => "text", + ToolId::TextOnPath => "textOnPath", ToolId::Comment => "comment", ToolId::Eyedropper => "eyedropper", ToolId::ShapeBuilder => "shapeBuilder", @@ -126,6 +129,7 @@ impl ToolId { ToolId::Pen => "Pen", ToolId::Pencil => "Pencil", ToolId::Text => "Text", + ToolId::TextOnPath => "Text on a path", ToolId::Comment => "Comment", ToolId::Eyedropper => "Pick color", ToolId::ShapeBuilder => "Shape builder", @@ -277,7 +281,8 @@ impl ToolId { ToolId::Pen => Cursor::Pen(PenCursor::New), ToolId::Comment => Cursor::Comment, ToolId::Eyedropper => Cursor::Eyedropper, - ToolId::Move | ToolId::Scale => Cursor::Default, + // Text on a path acts only over a path, where it shows the I-beam. + ToolId::Move | ToolId::Scale | ToolId::TextOnPath => Cursor::Default, // The other tools make something where they are pressed: shapes, frames, slices, pencil // strokes, a new text (over a text the Text tool shows the I-beam instead). _ => Cursor::Crosshair, @@ -643,6 +648,31 @@ pub(crate) const SNAP_PX: f64 = 5.0; /// Screen pixels the pointer travels after a press before a move or resize starts: a click or a /// double-click made with an unsteady hand doesn't drag what it lands on. const DRAG_SLOP_PX: f64 = 3.0; +/// Screen pixels from a path within which the Text on a path tool takes it. +const PATH_REACH_PX: f64 = 6.0; + +/// The visible layers under `id` whose box, grown by `reach` (page units), holds `p`, front-most +/// first: what the Text on a path tool measures its distance to. Content a clipping frame or a +/// mask hides at `p` and the operands of boolean groups are left out. +fn path_candidates(doc: &Document, id: NodeId, p: Point, reach: f64, depth: usize, out: &mut Vec) { + let Some(n) = doc.node(id) else { return }; + if !n.visible || depth > 512 || matches!(n.kind, NodeKind::BooleanOp { .. }) { + return; + } + if n.clip && n.kind.is_frame_like() && !n.local_rect().contains(doc.world_transform(id).inverse() * p) { + return; + } + for c in n.paint_order().rev() { + let before = out.len(); + path_candidates(doc, c, p, reach, depth + 1, out); + if out.len() > before && figcraft_doc::geometry::masked_out(doc, n, c, p) { + out.truncate(before); + } + } + if n.children.is_empty() && !n.is_mask && doc.world_bounds(id).is_some_and(|b| b.inflate(reach, reach).contains(p)) { + out.push(id); + } +} /// Corners of the selection's handle box in page coordinates (tl, tr, br, bl). pub fn selection_box(doc: &Document, sel: &[NodeId]) -> Option<[Point; 4]> { @@ -996,7 +1026,7 @@ impl Session { let _ = crate::shapebuilder::arrangement(self, &sel); } if let Ok(ds) = self.ds_mut() { - if t != ToolId::Text && t != ToolId::Move { + if !matches!(t, ToolId::Text | ToolId::TextOnPath | ToolId::Move) { ds.text_edit = None; } ds.revision += 1; @@ -1056,6 +1086,7 @@ impl Session { Ok(()) } ToolId::Text => self.text_down(ev), + ToolId::TextOnPath => self.text_on_path_down(ev), ToolId::Pen => self.pen_down(ev), ToolId::Pencil => { self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] }; @@ -1111,6 +1142,9 @@ impl Session { if tool == ToolId::Text && (on_edited || on_text(self)) { cursor = Cursor::Text; } + if tool == ToolId::TextOnPath && (on_edited || self.path_at(&doc, page, ev).is_some()) { + cursor = Cursor::Text; + } let mut hover = None; let mut guides = Vec::new(); let mut measure_box = None; @@ -2124,6 +2158,115 @@ impl Session { Ok(()) } + /// The layer whose path is under `ev` for the Text on a path tool and where along it the press + /// is (0..1 by length): the front-most shape or vector layer within a few screen pixels of its + /// first subpath (the one text follows), not locked and not inside an instance. Layers in front + /// whose path is further away don't hide it. + pub(crate) fn path_at(&self, doc: &Document, page: NodeId, ev: &PointerEvent) -> Option<(NodeId, f64)> { + let reach = ev.tol() * PATH_REACH_PX; + let near = |id: NodeId| -> Option<(NodeId, f64)> { + let n = doc.node(id)?; + let shape = matches!( + n.kind, + NodeKind::Rectangle + | NodeKind::Ellipse { .. } + | NodeKind::Polygon { .. } + | NodeKind::Star { .. } + | NodeKind::Line + | NodeKind::Vector { .. } + ); + if !shape || locked_in(doc, id) || crate::components::inside_instance(doc, id) { + return None; + } + let geom = figcraft_doc::geometry::stroke_geometry(n)?; + let line = figcraft_geom::measure::polylines(&geom)?.into_iter().next()?; + // Measured on the page (a layer scaled more one way than the other is still reached + // where it shows), the start by length along the layer's own path. + let world = doc.world_transform(id); + let p = ev.p(); + let mut best: Option<(f64, f64)> = None; + for (i, w) in line.points.windows(2).enumerate() { + let (a, b) = (world * w[0], world * w[1]); + let d = b - a; + let l2 = d.hypot2(); + let u = if l2 > 1e-18 { ((p - a).dot(d) / l2).clamp(0.0, 1.0) } else { 0.0 }; + let dist = (a.lerp(b, u) - p).hypot(); + if best.is_none_or(|(_, bd)| dist < bd) { + let (la, lb) = (*line.lengths.get(i)?, *line.lengths.get(i + 1)?); + best = Some((la + u * (lb - la), dist)); + } + } + let (along, dist) = best?; + let len = line.length(); + (len > 1e-9 && dist <= reach).then_some((id, along / len)) + }; + let mut candidates = Vec::new(); + path_candidates(doc, page, ev.p(), reach, 0, &mut candidates); + candidates.into_iter().find_map(near) + } + + /// A press with the Text on a path tool: on a path, a new empty text layer goes onto it, + /// starting where it was pressed, and typing starts. The text takes the path layer's place, + /// geometry, transform, constraints and effects, and its fills when one of them shows; the path + /// layer is removed. Ending the edit with nothing typed puts the path back without an undo step + /// ([`textedit::end`]). Elsewhere a press does nothing. + fn text_on_path_down(&mut self, ev: &PointerEvent) -> Result<()> { + if let Some(te) = self.ds()?.text_edit.clone() { + if self.ds()?.doc.world_bounds(te.id).is_some_and(|b| b.contains(ev.p())) { + return self.text_click(te.id, ev, ev.shift); + } + textedit::end(self)?; + } + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + let Some((path, start)) = self.path_at(&doc, page, ev) else { return Ok(()) }; + let Some(pn) = doc.node(path) else { return Ok(()) }; + let (Some(parent), Some(geom)) = (pn.parent, figcraft_doc::geometry::stroke_geometry(pn)) else { return Ok(()) }; + let index = doc.index_in_parent(path); + self.begin_interaction("Text on a path")?; + let ds = self.ds_mut()?; + let d = ds.doc_mut(); + let id = d.alloc_id(); + let mut n = Node::new(id, NodeKind::Text(TextData::default()), "Text", pn.width, pn.height); + n.name = String::new(); + n.transform = pn.transform; + n.constraints = pn.constraints; + n.child_layout = pn.child_layout.clone(); + let effects = !pn.effects.is_empty(); + if effects { + n.effects = pn.effects.clone(); + n.styles.effect = pn.styles.effect.clone(); + } + // A path drawn with a stroke only has no fill to give: the text gets the usual one. + let filled = pn.fills.iter().any(|f| f.visible); + if filled { + n.fills = pn.fills.clone(); + n.styles.fill = pn.styles.fill.clone(); + } else if let Some(fills) = readable_text_fills(d, parent) { + n.fills = fills; + } + // As `text.onPath`: a binding goes with the list it binds into (`fill`, `fills`, `fills/N/…`). + let carried = |k: &str| match crate::cmd::lists::list_binding(k) { + Some((crate::cmd::lists::BoundList::Fills, ..)) => filled, + Some((crate::cmd::lists::BoundList::Effects, ..)) => effects, + _ => false, + }; + n.bound = pn.bound.iter().filter(|(k, _)| carried(k)).map(|(k, v)| (k.clone(), v.clone())).collect(); + if let NodeKind::Text(t) = &mut n.kind { + let mut tp = TextPath::new(&geom); + tp.start = start; + t.path = Some(Box::new(tp)); + } + d.insert(n, parent, index)?; + d.remove(path)?; + ds.selection = vec![id]; + textedit::begin(self, id, Some(0), true)?; + if !self.prefs.keep_tool { + self.tool.tool = ToolId::Move; + } + Ok(()) + } + /// Drag out a new text box: past the drag slop it has the drag's size (at least one line /// tall) and its top-left, and that size is fixed. fn text_box_drag(&mut self, ev: &PointerEvent, id: NodeId, start: Point, line: f64) -> Result<()> { diff --git a/crates/engine/tests/it/selection_colors.rs b/crates/engine/tests/it/selection_colors.rs index 4e559d27..c3fc4f97 100644 --- a/crates/engine/tests/it/selection_colors.rs +++ b/crates/engine/tests/it/selection_colors.rs @@ -156,7 +156,9 @@ fn invalid_or_stale_targets_roll_back_atomically() { assert!(s.execute("selection.setPaint", &json!({"targets":red.targets,"paint":"#0000ff"})).is_err()); assert_eq!(fill(&s, a), "FF0000"); assert_eq!(s.undo_depth(), before); - for command in ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle"] { + for command in + ["selection.setPaint", "selection.selectColor", "selection.applyColorStyle", "selection.bindColorVariable", "selection.detachColor"] + { for params in [json!({}), json!({"targets":[]}), json!({"targets":[{"id":a,"fills":true,"index":0,"range":[0,1]}]})] { assert!(s.execute(command, ¶ms).is_err()); } @@ -270,3 +272,152 @@ fn whole_text_fill_keeps_a_color_style_link() { run(&mut s, "style.update", json!({"style":style,"value":"#00ff00"})); assert_eq!(fill(&s, id), "00FF00"); } + +fn theme_variable(s: &mut Session, name: &str, hex: &str) -> Value { + if s.doc().unwrap().variables.collection("Theme").is_none() { + run(s, "variable.createCollection", json!({"name":"Theme"})); + } + run(s, "variable.create", json!({"collection":"Theme","name":name,"type":"color","value":hex}))["id"].clone() +} + +#[test] +fn applying_a_color_variable_binds_matching_fill_and_stroke_slots_only() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#0000ff"); + run(&mut s, "stroke.add", json!({"id":b,"color":"#ff0000"})); + let v = theme_variable(&mut s, "Brand", "#00ff00"); + let red = group(&s, &[a, b], "FF0000"); + let before = s.undo_depth(); + run(&mut s, "selection.bindColorVariable", json!({"targets":red.targets,"variable":"Brand"})); + assert_eq!(fill(&s, a), "00FF00"); + assert_eq!(fill(&s, b), "0000FF"); + assert_eq!(s.doc().unwrap().node(b).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00"); + assert_eq!(s.doc().unwrap().node(a).unwrap().bound.get("fills/0/color").map(String::as_str), v.as_str()); + assert_eq!(s.doc().unwrap().node(b).unwrap().bound.get("strokes/0/color").map(String::as_str), v.as_str()); + assert_eq!(s.undo_depth().0, before.0 + 1); + run(&mut s, "variable.set", json!({"variable":v,"value":"#ffff00"})); + assert_eq!(fill(&s, a), "FFFF00"); + assert_eq!(fill(&s, b), "0000FF"); + let bound = collect(s.doc().unwrap(), &[a, b]).into_iter().find(|g| g.variable.is_some()).unwrap(); + assert_eq!(bound.targets.len(), 2); + s.undo().unwrap(); + s.undo().unwrap(); + assert_eq!(fill(&s, a), "FF0000"); + assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty()); +} + +#[test] +fn a_color_variable_replaces_a_style_link_and_an_earlier_binding() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#ff0000"); + let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"}))["id"].clone(); + run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"})); + let old = theme_variable(&mut s, "Old", "#ff0000"); + run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":old})); + let new = theme_variable(&mut s, "New", "#0000ff"); + let targets: Vec<_> = collect(s.doc().unwrap(), &[a, b]).into_iter().flat_map(|g| g.targets).collect(); + run(&mut s, "selection.bindColorVariable", json!({"targets":targets,"variable":new})); + for id in [a, b] { + let n = s.doc().unwrap().node(id).unwrap(); + assert!(n.styles.fill.is_none()); + assert_eq!(n.bound.len(), 1, "one binding for the slot: {:?}", n.bound); + assert_eq!(n.bound.values().next().map(String::as_str), new.as_str()); + assert_eq!(fill(&s, id), "0000FF"); + } + assert_eq!(collect(s.doc().unwrap(), &[a, b]).len(), 1); +} + +#[test] +fn a_color_variable_on_a_text_range_with_its_own_fills_takes_its_value() { + let mut s = Session::new(); + let id = NodeId(run(&mut s, "text.create", json!({"text":"abc","props":{"fill":"#ff0000"}}))["id"].as_u64().unwrap()); + run(&mut s, "text.style", json!({"id":id,"start":1,"end":2,"fontSize":30,"textFills":["#0000ff"]})); + theme_variable(&mut s, "Brand", "#00ff0080"); + let blue = group(&s, &[id], "0000FF"); + run(&mut s, "selection.bindColorVariable", json!({"targets":blue.targets,"variable":"Brand"})); + let n = s.doc().unwrap().node(id).unwrap(); + let t = n.kind.text().unwrap(); + let paint = &t.style_at(1).fills.as_ref().unwrap()[0]; + assert_eq!(paint.solid_color().unwrap().to_css_hex(), "#00FF0080"); + assert_eq!(t.style_at(1).font_size, 30.0); + assert!(n.bound.is_empty()); + assert_eq!(fill(&s, id), "FF0000"); +} + +#[test] +fn detaching_a_selection_color_keeps_its_value() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let b = rectangle(&mut s, "#ff0000"); + let c = rectangle(&mut s, "#0000ff"); + let style = run(&mut s, "style.create", json!({"type":"paint","name":"Brand","value":"#00ff00"}))["id"].clone(); + run(&mut s, "style.apply", json!({"id":a,"style":style,"target":"fill"})); + let v = theme_variable(&mut s, "Accent", "#ffff0080"); + run(&mut s, "variable.bind", json!({"id":b,"field":"fill","variable":v})); + run(&mut s, "variable.bind", json!({"id":c,"field":"fill","variable":v})); + let colors = collect(s.doc().unwrap(), &[a, b]); + assert_eq!(colors.len(), 2); + let before = s.undo_depth(); + for g in &colors { + run(&mut s, "selection.detachColor", json!({"targets":g.targets})); + } + assert_eq!(s.undo_depth().0, before.0 + 2); + assert!(s.doc().unwrap().node(a).unwrap().styles.fill.is_none()); + assert!(s.doc().unwrap().node(b).unwrap().bound.is_empty()); + assert!(!s.doc().unwrap().node(c).unwrap().bound.is_empty()); + assert_eq!(fill(&s, a), "00FF00"); + assert_eq!(s.doc().unwrap().node(b).unwrap().fills[0].solid_color().unwrap().to_css_hex(), "#FFFF0080"); + run(&mut s, "variable.set", json!({"variable":v,"value":"#000000"})); + assert_eq!(fill(&s, b), "FFFF00"); + assert!(collect(s.doc().unwrap(), &[a, b]).iter().all(|g| g.style.is_none() && g.variable.is_none())); +} + +#[test] +fn color_variables_refuse_gradients_other_types_and_unknown_names() { + use figcraft_color::{Color, Gradient, GradientKind}; + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + let gradient = Paint::gradient(Gradient::default_for(GradientKind::Linear, Color::BLACK)); + run(&mut s, "fill.add", json!({"id":a,"paint":gradient})); + theme_variable(&mut s, "Brand", "#00ff00"); + run(&mut s, "variable.create", json!({"collection":"Theme","name":"Gap","type":"number","value":8})); + let colors = collect(s.doc().unwrap(), &[a]); + let solid = colors.iter().find(|g| g.paint.solid_color().is_some()).unwrap(); + let ramp = colors.iter().find(|g| g.paint.solid_color().is_none()).unwrap(); + let before = s.undo_depth(); + for (targets, variable) in [(&ramp.targets, "Brand"), (&solid.targets, "Gap"), (&solid.targets, "Missing")] { + assert!(s.execute("selection.bindColorVariable", &json!({"targets":targets,"variable":variable})).is_err()); + } + assert!(s.execute("selection.bindColorVariable", &json!({"targets":solid.targets})).is_err()); + assert!(s.doc().unwrap().node(a).unwrap().bound.is_empty()); + assert_eq!(s.undo_depth(), before); + run(&mut s, "selection.bindColorVariable", json!({"targets":solid.targets,"variable":"Brand"})); + assert_eq!(s.doc().unwrap().node(a).unwrap().bound.len(), 1); + assert_eq!(s.undo_depth().0, before.0 + 1); +} + +#[test] +fn scopes_filter_the_menu_but_the_command_binds_anyway() { + let mut s = Session::new(); + let a = rectangle(&mut s, "#ff0000"); + run(&mut s, "stroke.add", json!({"id":a,"color":"#ff0000"})); + theme_variable(&mut s, "Line", "#00ff00"); + theme_variable(&mut s, "Hidden", "#0000ff"); + run(&mut s, "variable.update", json!({"variable":"Line","scopes":["STROKE_COLOR"]})); + run(&mut s, "variable.update", json!({"variable":"Hidden","scopes":[]})); + let red = group(&s, &[a], "FF0000"); + let fill_slot: Vec<_> = red.targets.iter().filter(|t| t.fills).cloned().collect(); + let stroke_slot: Vec<_> = red.targets.iter().filter(|t| !t.fills).cloned().collect(); + let doc = s.doc().unwrap(); + let offered = |targets: &[figcraft_engine::selection_colors::PaintTarget], v: &str| figcraft_engine::selection_colors::in_scope(doc, targets, v); + assert!(offered(&stroke_slot, "Line")); + assert!(!offered(&red.targets, "Line") && !offered(&fill_slot, "Line") && !offered(&stroke_slot, "Hidden")); + run(&mut s, "selection.bindColorVariable", json!({"targets":stroke_slot,"variable":"Line"})); + assert_eq!(s.doc().unwrap().node(a).unwrap().strokes[0].solid_color().unwrap().to_hex(), "00FF00"); + assert_eq!(fill(&s, a), "FF0000"); + // As with `variable.bind`, the command itself binds outside the scopes. + run(&mut s, "selection.bindColorVariable", json!({"targets":fill_slot,"variable":"Line"})); + assert_eq!(fill(&s, a), "00FF00"); +} diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index e0a4dde0..012fca42 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -2,7 +2,7 @@ //! //! Re-exports [`kurbo`] and adds the shape builders a design tool needs: rectangles with independent //! corner radii and corner smoothing (the "squircle" continuous-curvature corner), regular polygons -//! and stars (with rounded vertices), and ellipse arcs / rings. Coordinates are y-down. +//! and stars (with rounded and smoothed vertices), and ellipse arcs / rings. Coordinates are y-down. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub use kurbo; @@ -230,6 +230,95 @@ pub fn rounded_polygon(points: &[Point], radius: f64) -> BezPath { path } +/// [`rounded_polygon`] with corner smoothing (0..=1, as [`rounded_rect`] takes it): each rounded +/// vertex keeps a circular arc over `1 - smoothing` of its turn and eases into both edges with +/// curves that start `1 + smoothing` times the plain corner's length from the vertex. As in +/// [`rounded_rect`], when a smoothed corner doesn't fit in half of its shorter edge, smoothing is +/// reduced first, then the radius. Without smoothing this is [`rounded_polygon`]. +pub fn smoothed_polygon(points: &[Point], radius: f64, smoothing: f64) -> BezPath { + let s = finite(smoothing).clamp(0.0, 1.0); + let radius = finite(radius).max(0.0); + let n = points.len(); + if s <= 0.0 || radius <= 0.0 || n < 3 { + return rounded_polygon(points, radius); + } + let mut path = BezPath::new(); + for i in 0..n { + let (Some(prev), Some(cur), Some(next)) = (points.get((i + n - 1) % n), points.get(i), points.get((i + 1) % n)) else { + continue; + }; + let (v1, v2) = (*prev - *cur, *next - *cur); + let (l1, l2) = (v1.hypot(), v2.hypot()); + let angle = if l1 < 1e-9 || l2 < 1e-9 { 0.0 } else { (v1 / l1).dot(v2 / l2).clamp(-1.0, 1.0).acos() }; + if angle < 1e-6 || (PI - angle) < 1e-6 { + line_or_move(&mut path, *cur); + continue; + } + let (u1, u2) = (v1 / l1, v2 / l2); + // The plain corner's length along each edge, then the fit: smoothing first, then the radius. + let max_p = l1.min(l2) / 2.0; + let mut r = radius; + let mut t = r / (angle / 2.0).tan(); + let mut sm = s; + if (1.0 + sm) * t > max_p { + sm = (max_p / t - 1.0).clamp(0.0, sm); + } + if t > max_p { + r *= max_p / t; + t = max_p; + } + let p = ((1.0 + sm) * t).min(max_p); + // Each easing curve covers `beta` of the turn and ends on the arc at `back` along the edge + // and `side` across it; its last handle points along the arc there. + let beta = (PI - angle) * sm / 2.0; + let back = t - r * beta.sin(); + let side = r * (1.0 - beta.cos()); + let c = r * (beta / 2.0).tan() * beta.cos(); + let b = ((p - back - c) / 3.0).max(0.0); + let a = 2.0 * b; + let bisector = (u1 + u2).normalize(); + let center = *cur + bisector * (r / (angle / 2.0).sin()); + // A point `along` an edge from the vertex and `across` it toward the arc's center. + let at = |u: Vec2, along: f64, across: f64| *cur + u * along + (bisector - u * bisector.dot(u)).normalize() * across; + line_or_move(&mut path, at(u1, p, 0.0)); + let p3 = at(u1, back, side); + let p4 = at(u2, back, side); + if a + b + c > 1e-9 { + path.curve_to(at(u1, p - a, 0.0), at(u1, p - a - b, 0.0), p3); + } else { + path.line_to(p3); + } + let a0 = (p3 - center).atan2(); + let mut sweep = (p4 - center).atan2() - a0; + while sweep <= -PI { + sweep += TAU; + } + while sweep > PI { + sweep -= TAU; + } + if sweep.abs() > 1e-9 { + kurbo::Arc::new(center, (r, r), a0, sweep, 0.0).to_cubic_beziers(0.01, |p1, p2, q| path.curve_to(p1, p2, q)); + } + let end = at(u2, p, 0.0); + if a + b + c > 1e-9 { + path.curve_to(at(u2, p - a - b, 0.0), at(u2, p - a, 0.0), end); + } else { + path.line_to(end); + } + } + path.close_path(); + path +} + +/// Starts `path` at `p`, or continues it there with a straight segment. +fn line_or_move(path: &mut BezPath, p: Point) { + if path.elements().is_empty() { + path.move_to(p); + } else { + path.line_to(p); + } +} + /// An ellipse in the `width`×`height` box; with arc data, a pie/arc/ring: `start` and `sweep` in /// radians (clockwise from 3 o'clock, y-down) and `inner` radius ratio 0..1. pub fn ellipse(width: f64, height: f64, start: f64, sweep: f64, inner: f64) -> BezPath { diff --git a/crates/geom/src/tests.rs b/crates/geom/src/tests.rs index 15e24d49..37e6ccab 100644 --- a/crates/geom/src/tests.rs +++ b/crates/geom/src/tests.rs @@ -47,6 +47,10 @@ fn degenerate_inputs_dont_panic() { let _ = star_points(10.0, 10.0, 1, -3.0); let _ = rounded_polygon(&[], 4.0); let _ = rounded_polygon(&[Point::ZERO, Point::ZERO, Point::ZERO], 4.0); + let _ = smoothed_polygon(&[Point::ZERO, Point::ZERO, Point::ZERO], 4.0, 0.6); + let _ = smoothed_polygon(&[Point::ZERO, Point::new(10.0, 0.0), Point::new(20.0, 0.0)], 4.0, 1.0); + let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), f64::INFINITY, f64::NAN); + let _ = smoothed_polygon(&polygon_points(10.0, 10.0, 5), 3.0, f64::INFINITY); let _ = ellipse(f64::NAN, 10.0, f64::NAN, 100.0, 2.0); } @@ -70,6 +74,56 @@ fn rounded_polygon_shrinks_area() { assert!(round < sharp && round > sharp * 0.8); } +#[test] +fn smoothed_polygon_matches_the_rectangle_corner() { + // A four-sided polygon is a square turned 45°: smoothed, it has the rounded rectangle's area. + let side = 50.0 * std::f64::consts::SQRT_2; + let pts = polygon_points(100.0, 100.0, 4); + for (r, s) in [(10.0, 0.0), (10.0, 0.6), (12.0, 1.0), (30.0, 0.6), (1000.0, 0.6)] { + let poly = area(&smoothed_polygon(&pts, r, s)); + let rect = area(&rounded_rect(side, side, [r; 4], s)); + assert!((poly - rect).abs() < 0.05, "r={r} s={s}: {poly} vs {rect}"); + } +} + +#[test] +fn smoothed_polygon_smooths_every_corner() { + use kurbo::{ParamCurve, ParamCurveArclen, ParamCurveNearest}; + let near = |p: &BezPath, at: Point| p.segments().map(|seg| seg.nearest(at, 1e-9).distance_sq).fold(f64::INFINITY, f64::min); + // (points, convex: smoothing only cuts area away) + let shapes = [(polygon_points(100.0, 100.0, 3), true), (polygon_points(120.0, 80.0, 6), true), (star_points(100.0, 100.0, 5, 0.38), false)]; + for (pts, convex) in shapes { + let plain = smoothed_polygon(&pts, 8.0, 0.0); + assert_eq!(plain, rounded_polygon(&pts, 8.0), "no smoothing draws the plain rounded corners"); + let sharp = area(&rounded_polygon(&pts, 0.0)); + let mut last = area(&plain); + for s in [0.3, 0.6, 1.0] { + let p = smoothed_polygon(&pts, 8.0, s); + let a = area(&p); + assert!(if convex { a < last } else { (a - last).abs() > 1e-3 }, "s={s}: more smoothing changes the area ({a} after {last})"); + assert!((a - sharp).abs() < sharp * 0.2, "s={s}: the shape stays close to its points"); + last = a; + // The outline has no kinks: each piece leaves in the direction the one before arrives. + let segs: Vec = p.segments().filter(|seg| seg.arclen(1e-6) > 1e-6).collect(); + for (i, seg) in segs.iter().enumerate() { + let next = segs[(i + 1) % segs.len()]; + let out = (seg.eval(1.0) - seg.eval(1.0 - 1e-6)).normalize(); + let into = (next.eval(1e-6) - next.eval(0.0)).normalize(); + assert!(out.dot(into) > 0.999, "s={s}: piece {i} joins the next smoothly ({out:?} then {into:?})"); + } + let b = p.bounding_box(); + assert!(b.x0 >= -1e-6 && b.y0 >= -1e-6 && b.x1 <= 120.0 + 1e-6 && b.y1 <= 100.0 + 1e-6, "s={s} {b:?}"); + // Every vertex is cut off, and the middle of every edge is still on the outline. + for (i, v) in pts.iter().enumerate() { + let next = pts[(i + 1) % pts.len()]; + let mid = v.midpoint(next); + assert!(near(&p, mid) < 1e-6, "s={s}: edge {i} keeps its middle"); + assert!(near(&p, *v) > 0.25, "s={s}: vertex {i} is rounded"); + } + } + } +} + #[test] fn ellipse_ring_area() { let full = area(&ellipse(100.0, 100.0, 0.0, TAU, 0.0)); diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 364c9632..c3453096 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -304,7 +304,6 @@ status = "draft — needs native review" "Constraints" = "সীমাবদ্ধতা (Constraints)" "Content" = "কন্টেন্ট" "Contents hidden" = "কন্টেন্ট লুকানো আছে" -"Contents only" = "শুধু কন্টেন্ট" "Contextual alternates" = "প্রসঙ্গভিত্তিক বিকল্প" "Contrast" = "কনট্রাস্ট (Contrast)" "Contributors" = "অবদানকারীগণ" @@ -628,6 +627,7 @@ status = "draft — needs native review" "If" = "যদি (If)" "If {condition}" = "যদি {condition}" "Ignore auto layout (absolute position)" = "অটো লেআউট উপেক্ষা করুন (অ্যাবসোলিউট পজিশন)" +"Ignore overlapping layers" = "ওভারল্যাপ করা লেয়ার উপেক্ষা করুন" "Image" = "ছবি" "Images" = "ছবিসমূহ" "Import" = "ইম্পোর্ট" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index b05427b2..139a195a 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -295,7 +295,6 @@ status = "draft — needs native review" "Constraints" = "Omezení" "Content" = "Obsah" "Contents hidden" = "Obsah skryt" -"Contents only" = "Pouze obsah" "Contextual alternates" = "Kontextové alternativy" "Contrast" = "Kontrast" "Contributors" = "Přispěvatelé" @@ -613,6 +612,7 @@ status = "draft — needs native review" "If" = "Pokud" "If {condition}" = "Pokud {condition}" "Ignore auto layout (absolute position)" = "Ignorovat automatické rozvržení (absolutní poloha)" +"Ignore overlapping layers" = "Ignorovat překrývající se vrstvy" "Image" = "Obrázek" "Images" = "Obrázky" "Import" = "Import" @@ -2086,7 +2086,9 @@ status = "draft — needs native review" "select.sameText" = "Vybrat stejné vlastnosti textu" "select.set" = "Vybrat" "selection.applyColorStyle" = "Použít barevný styl na výběr" +"selection.bindColorVariable" = "Použít barevnou proměnnou na výběr" "selection.colors" = "Barvy výběru" +"selection.detachColor" = "Odpojit barvu výběru" "selection.selectColor" = "Vybrat vrstvy s touto barvou" "selection.setPaint" = "Změnit barvu výběru" "shape.arrow" = "Šipka" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index da2a177a..dbdcab51 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -291,7 +291,6 @@ status = "draft — needs native review" "Constraints" = "Constraints" "Content" = "Inhalt" "Contents hidden" = "Inhalt ausgeblendet" -"Contents only" = "Nur Inhalt" "Contextual alternates" = "Kontextbedingte Varianten" "Contrast" = "Kontrast" "Contributors" = "Mitwirkende" @@ -600,6 +599,7 @@ status = "draft — needs native review" "If" = "Wenn" "If {condition}" = "Wenn {condition}" "Ignore auto layout (absolute position)" = "Auto-Layout ignorieren (absolute Position)" +"Ignore overlapping layers" = "Überlappende Ebenen ignorieren" "Image" = "Bild" "Images" = "Bilder" "Import" = "Importieren" @@ -2039,7 +2039,9 @@ status = "draft — needs native review" "select.sameText" = "Gleiche Texteigenschaften auswählen" "select.set" = "Auswählen" "selection.applyColorStyle" = "Farbstil auf Auswahl anwenden" +"selection.bindColorVariable" = "Farbvariable auf Auswahl anwenden" "selection.colors" = "Auswahlfarben" +"selection.detachColor" = "Auswahlfarbe lösen" "selection.selectColor" = "Ebenen mit dieser Farbe auswählen" "selection.setPaint" = "Farbe der Auswahl ändern" "shape.arrow" = "Pfeil" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index e1ed336e..bc61086e 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -305,7 +305,6 @@ name = "English" "Constraints" = "Constraints" "Content" = "Content" "Contents hidden" = "Contents hidden" -"Contents only" = "Contents only" "Contextual alternates" = "Contextual alternates" "Contrast" = "Contrast" "Contributors" = "Contributors" @@ -419,6 +418,7 @@ name = "English" "Distribute vertical spacing" = "Distribute vertical spacing" "Divider" = "Divider" "Don't Save" = "Don't Save" +"Don't show this again" = "Don't show this again" "Don't truncate" = "Don't truncate" "Done" = "Done" "Done editing" = "Done editing" @@ -631,6 +631,7 @@ name = "English" "If" = "If" "If {condition}" = "If {condition}" "Ignore auto layout (absolute position)" = "Ignore auto layout (absolute position)" +"Ignore overlapping layers" = "Ignore overlapping layers" "Image" = "Image" "Images" = "Images" "Import" = "Import" @@ -721,6 +722,7 @@ name = "English" "Layout grids" = "Layout grids" "Leave session" = "Leave session" "Leaves" = "Leaves" +"Leaving the prototype" = "Leaving the prototype" "Left" = "Left" "Left & right" = "Left & right" "Left padding" = "Left padding" @@ -1249,6 +1251,7 @@ name = "English" "Server address" = "Server address" "Server for online sharing" = "Server for online sharing" "Set as default" = "Set as default" +"Set corner smoothing to 60%" = "Set corner smoothing to 60%" "Set variable" = "Set variable" "Set variable mode" = "Set variable mode" "Set variable {name}" = "Set variable {name}" @@ -1401,6 +1404,7 @@ name = "English" "Text" = "Text" "Text align" = "Text align" "Text content" = "Text content" +"Text on a path" = "Text on a path" "Text size" = "Text size" "Text style" = "Text style" "Text styles" = "Text styles" @@ -1435,6 +1439,7 @@ name = "English" "This file is on {server}, open only to you. Share it to let others in with a new code." = "This file is on {server}, open only to you. Share it to let others in with a new code." "This frame is new on the branch." = "This frame is new on the branch." "This frame isn't on the branch." = "This frame isn't on the branch." +"This link opens a website outside the prototype:" = "This link opens a website outside the prototype:" "This server is open to the public: no key needed." = "This server is open to the public: no key needed." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." "This server takes files only with its key: see Advanced." = "This server takes files only with its key: see Advanced." diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index a4323c87..0eec9808 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -305,7 +305,6 @@ status = "draft — needs native review" "Constraints" = "Restricciones" "Content" = "Contenido" "Contents hidden" = "Contenido oculto" -"Contents only" = "Solo el contenido" "Contextual alternates" = "Alternativas contextuales" "Contrast" = "Contraste" "Contributors" = "Colaboradores" @@ -632,6 +631,7 @@ status = "draft — needs native review" "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorar diseño automático (posición absoluta)" +"Ignore overlapping layers" = "Ignorar capas superpuestas" "Image" = "Imagen" "Images" = "Imágenes" "Import" = "Importar" @@ -2154,7 +2154,9 @@ status = "draft — needs native review" "select.sameText" = "Seleccionar mismas propiedades de texto" "select.set" = "Seleccionar" "selection.applyColorStyle" = "Aplicar estilo de color a la selección" +"selection.bindColorVariable" = "Aplicar variable de color a la selección" "selection.colors" = "Colores de la selección" +"selection.detachColor" = "Separar color de la selección" "selection.selectColor" = "Seleccionar capas con este color" "selection.setPaint" = "Cambiar color de la selección" "shape.arrow" = "Flecha" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index fe979678..635b501f 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -303,7 +303,6 @@ status = "draft — needs native review" "Constraints" = "Rajoitteet" "Content" = "Sisältö" "Contents hidden" = "Sisältö kätketty" -"Contents only" = "Vain sisältö" "Contextual alternates" = "Kontekstuaaliset vaihtoehdot" "Contrast" = "Kontrasti" "Contributors" = "Tekijät" @@ -630,6 +629,7 @@ status = "draft — needs native review" "If it keeps happening, please report it at {link} with this message." = "Jos ongelma jatkuu, ilmoita siitä osoitteessa {link} ja liitä tämä viesti mukaan." "If {condition}" = "Jos {condition}" "Ignore auto layout (absolute position)" = "Ohita automaattinen asettelu (absoluuttinen sijainti)" +"Ignore overlapping layers" = "Ohita päällekkäiset tasot" "Image" = "Kuva" "Images" = "Kuvat" "Import" = "Tuo" @@ -2132,7 +2132,9 @@ status = "draft — needs native review" "select.sameText" = "Valitse samat tekstiominaisuudet" "select.set" = "Valitse" "selection.applyColorStyle" = "Käytä valinnan värityyliä" +"selection.bindColorVariable" = "Käytä valinnan värimuuttujaa" "selection.colors" = "Valinnan värit" +"selection.detachColor" = "Irrota valinnan väri" "selection.selectColor" = "Valitse tämänväriset tasot" "selection.setPaint" = "Muuta valinnan väriä" "shape.arrow" = "Nuoli" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index bb771e9f..b8d64b43 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -301,7 +301,6 @@ status = "draft — needs native review" "Constraints" = "Contraintes" "Content" = "Contenu" "Contents hidden" = "Contenu masqué" -"Contents only" = "Contenu uniquement" "Contextual alternates" = "Variantes contextuelles" "Contrast" = "Contraste" "Contributors" = "Contributeurs" @@ -629,6 +628,7 @@ status = "draft — needs native review" "If" = "Si" "If {condition}" = "Si {condition}" "Ignore auto layout (absolute position)" = "Ignorer la mise en page auto (position absolue)" +"Ignore overlapping layers" = "Ignorer les calques superposés" "Image" = "Image" "Images" = "Images" "Import" = "Importer" @@ -2134,7 +2134,9 @@ status = "draft — needs native review" "select.sameText" = "Sélectionner les mêmes propriétés de texte" "select.set" = "Sélectionner" "selection.applyColorStyle" = "Appliquer un style de couleur à la sélection" +"selection.bindColorVariable" = "Appliquer une variable de couleur à la sélection" "selection.colors" = "Couleurs de la sélection" +"selection.detachColor" = "Détacher la couleur de la sélection" "selection.selectColor" = "Sélectionner les calques de cette couleur" "selection.setPaint" = "Changer la couleur de la sélection" "shape.arrow" = "Flèche" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index a516db9f..601cb2c3 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -299,7 +299,6 @@ status = "draft — needs native review" "Constraints" = "Batasan" "Content" = "Konten" "Contents hidden" = "Konten disembunyikan" -"Contents only" = "Hanya konten" "Contextual alternates" = "Alternatif kontekstual" "Contrast" = "Kontras" "Contributors" = "Kontributor" @@ -617,6 +616,7 @@ status = "draft — needs native review" "If" = "Jika" "If {condition}" = "Jika {condition}" "Ignore auto layout (absolute position)" = "Abaikan auto layout (posisi absolut)" +"Ignore overlapping layers" = "Abaikan layer yang tumpang tindih" "Image" = "Gambar" "Images" = "Gambar" "Import" = "Impor" @@ -2090,7 +2090,9 @@ status = "draft — needs native review" "select.sameText" = "Pilih properti teks yang sama" "select.set" = "Pilih" "selection.applyColorStyle" = "Terapkan gaya warna ke pilihan" +"selection.bindColorVariable" = "Terapkan variabel warna ke pilihan" "selection.colors" = "Warna pilihan" +"selection.detachColor" = "Lepas warna pilihan" "selection.selectColor" = "Pilih layer dengan warna ini" "selection.setPaint" = "Ubah warna pilihan" "shape.arrow" = "Panah" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 641ec1f6..3fc535ae 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -302,7 +302,6 @@ status = "draft — needs native review" "Constraints" = "制約" "Content" = "コンテンツ" "Contents hidden" = "コンテンツ非表示" -"Contents only" = "コンテンツのみ" "Contextual alternates" = "前後関係に依存する字形" "Contrast" = "コントラスト" "Contributors" = "貢献者" @@ -624,6 +623,7 @@ status = "draft — needs native review" "If" = "条件" "If {condition}" = "条件:{condition}" "Ignore auto layout (absolute position)" = "オートレイアウトを無視(絶対位置)" +"Ignore overlapping layers" = "重なっているレイヤーを無視" "Image" = "画像" "Images" = "画像" "Import" = "インポート" @@ -2112,7 +2112,9 @@ status = "draft — needs native review" "select.sameText" = "同じテキストプロパティを選択" "select.set" = "選択" "selection.applyColorStyle" = "選択範囲にカラースタイルを適用" +"selection.bindColorVariable" = "選択範囲にカラー変数を適用" "selection.colors" = "選択範囲のカラー" +"selection.detachColor" = "選択範囲のカラーを切り離す" "selection.selectColor" = "このカラーのレイヤーを選択" "selection.setPaint" = "選択範囲のカラーを変更" "shape.arrow" = "矢印" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index b556e8d7..eefb2dd3 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -306,7 +306,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Constraints" = "შეზღუდვები" "Content" = "შიგთავსი" "Contents hidden" = "შიგთავსი დამალულია" -"Contents only" = "მხოლოდ შიგთავსი" "Contextual alternates" = "კონტექსტური ალტერნატივები" "Contrast" = "კონტრასტი" "Contributors" = "მონაწილეები" @@ -630,6 +629,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "If" = "თუ" "If {condition}" = "თუ {condition}" "Ignore auto layout (absolute position)" = "ავტოგანლაგების იგნორირება (აბსოლუტური პოზიცია)" +"Ignore overlapping layers" = "გადაფარული შრეების იგნორირება" "Image" = "გამოსახულება" "Images" = "გამოსახულებები" "Import" = "იმპორტი" @@ -2126,7 +2126,9 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "select.sameText" = "ტექსტის იგივე თვისებების მონიშვნა" "select.set" = "მონიშვნა" "selection.applyColorStyle" = "მონიშნულის ფერის სტილის გამოყენება" +"selection.bindColorVariable" = "მონიშნულზე ფერის ცვლადის გამოყენება" "selection.colors" = "მონიშნულის ფერები" +"selection.detachColor" = "მონიშნულის ფერის გამოყოფა" "selection.selectColor" = "ამ ფერის შრეების მონიშვნა" "selection.setPaint" = "მონიშნულის ფერის შეცვლა" "shape.arrow" = "ისარი" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 053348f2..35e5a59a 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -291,7 +291,6 @@ status = "draft — needs native review" "Constraints" = "제약" "Content" = "콘텐츠" "Contents hidden" = "콘텐츠 숨김" -"Contents only" = "콘텐츠만" "Contextual alternates" = "문맥 대체 글자" "Contrast" = "대비" "Contributors" = "기여자" @@ -596,6 +595,7 @@ status = "draft — needs native review" "If" = "조건" "If {condition}" = "조건: {condition}" "Ignore auto layout (absolute position)" = "자동 레이아웃 무시(절대 위치)" +"Ignore overlapping layers" = "겹치는 레이어 무시" "Image" = "이미지" "Images" = "이미지" "Import" = "가져오기" @@ -2030,7 +2030,9 @@ status = "draft — needs native review" "select.sameText" = "같은 텍스트 속성 선택" "select.set" = "선택" "selection.applyColorStyle" = "선택 항목에 색상 스타일 적용" +"selection.bindColorVariable" = "선택 항목에 색상 변수 적용" "selection.colors" = "선택 항목 색상" +"selection.detachColor" = "선택 항목 색상 분리" "selection.selectColor" = "이 색상의 레이어 선택" "selection.setPaint" = "선택 항목 색상 변경" "shape.arrow" = "화살표" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index bc4e0cfa..484909ea 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -303,7 +303,6 @@ status = "draft — needs native review" "Constraints" = "Constraints" "Content" = "Inhoud" "Contents hidden" = "Inhoud verborgen" -"Contents only" = "Alleen de inhoud" "Contextual alternates" = "Contextuele alternatieven" "Contrast" = "Contrast" "Contributors" = "Bijdragers" @@ -630,6 +629,7 @@ status = "draft — needs native review" "If it keeps happening, please report it at {link} with this message." = "Als dit blijft gebeuren, meld het dan op {link} met dit bericht." "If {condition}" = "Als {condition}" "Ignore auto layout (absolute position)" = "Auto layout negeren (absolute positie)" +"Ignore overlapping layers" = "Overlappende lagen negeren" "Image" = "Afbeelding" "Images" = "Afbeeldingen" "Import" = "Importeren" @@ -2131,7 +2131,9 @@ status = "draft — needs native review" "select.sameText" = "Dezelfde teksteigenschappen selecteren" "select.set" = "Selecteren" "selection.applyColorStyle" = "Kleurstijl op selectie toepassen" +"selection.bindColorVariable" = "Kleurvariabele op selectie toepassen" "selection.colors" = "Selectiekleuren" +"selection.detachColor" = "Selectiekleur loskoppelen" "selection.selectColor" = "Lagen op kleur selecteren" "selection.setPaint" = "Selectiekleur wijzigen" "shape.arrow" = "Pijl" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 25f287f9..a7dbfff0 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -302,7 +302,6 @@ status = "draft — needs native review" "Constraints" = "Więzy" "Content" = "Zawartość" "Contents hidden" = "Zawartość ukryta" -"Contents only" = "Tylko zawartość" "Contextual alternates" = "Alternatywy kontekstowe" "Contrast" = "Kontrast" "Contributors" = "Współtwórcy" @@ -627,6 +626,7 @@ status = "draft — needs native review" "If" = "Jeżeli" "If {condition}" = "Jeżeli {condition}" "Ignore auto layout (absolute position)" = "Ignoruj automatyczny układ (pozycja bezwzględna)" +"Ignore overlapping layers" = "Ignoruj nakładające się warstwy" "Image" = "Obraz" "Images" = "Obrazy" "Import" = "Importuj" @@ -2125,7 +2125,9 @@ status = "draft — needs native review" "select.sameText" = "Zaznacz elementy z takimi samymi właściwościami tekstu" "select.set" = "Zaznacz" "selection.applyColorStyle" = "Zastosuj styl koloru do zaznaczenia" +"selection.bindColorVariable" = "Zastosuj zmienną koloru do zaznaczenia" "selection.colors" = "Kolory zaznaczenia" +"selection.detachColor" = "Odłącz kolor zaznaczenia" "selection.selectColor" = "Zaznacz warstwy z kolorem" "selection.setPaint" = "Zmień kolor zaznaczenia" "shape.arrow" = "Strzałka" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index cb7b08b5..ece93fbd 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -289,7 +289,6 @@ status = "draft — needs native review" "Constraints" = "Restrições" "Content" = "Conteúdo" "Contents hidden" = "Conteúdo oculto" -"Contents only" = "Somente o conteúdo" "Contextual alternates" = "Alternativas contextuais" "Contrast" = "Contraste" "Contributors" = "Colaboradores" @@ -595,6 +594,7 @@ status = "draft — needs native review" "If" = "Se" "If {condition}" = "Se {condition}" "Ignore auto layout (absolute position)" = "Ignorar layout automático (posição absoluta)" +"Ignore overlapping layers" = "Ignorar camadas sobrepostas" "Image" = "Imagem" "Images" = "Imagens" "Import" = "Importar" @@ -2143,7 +2143,9 @@ status = "draft — needs native review" "select.sameText" = "Selecionar mesmas propriedades de texto" "select.set" = "Selecionar" "selection.applyColorStyle" = "Aplicar estilo de cor à seleção" +"selection.bindColorVariable" = "Aplicar variável de cor à seleção" "selection.colors" = "Cores da seleção" +"selection.detachColor" = "Desvincular cor da seleção" "selection.selectColor" = "Selecionar camadas com esta cor" "selection.setPaint" = "Alterar cor da seleção" "shape.arrow" = "Seta" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index ed1cc0eb..1635123c 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -302,7 +302,6 @@ name = "Русский" "Constraints" = "Ограничения" "Content" = "Содержимое" "Contents hidden" = "Содержимое скрыто" -"Contents only" = "Только содержимое" "Contextual alternates" = "Контекстные альтернативы" "Contrast" = "Контраст" "Contributors" = "Участники" @@ -629,6 +628,7 @@ name = "Русский" "If it keeps happening, please report it at {link} with this message." = "Если это повторяется, сообщите об этом по адресу {link}, приложив это сообщение." "If {condition}" = "Если {condition}" "Ignore auto layout (absolute position)" = "Игнорировать автолейаут (абсолютная позиция)" +"Ignore overlapping layers" = "Игнорировать перекрывающиеся слои" "Image" = "Изображение" "Images" = "Изображения" "Import" = "Импорт" @@ -2145,7 +2145,9 @@ name = "Русский" "select.sameText" = "Выделить с одинаковыми свойствами текста" "select.set" = "Выделить" "selection.applyColorStyle" = "Применить цветовой стиль к выделению" +"selection.bindColorVariable" = "Применить цветовую переменную к выделению" "selection.colors" = "Цвета выделения" +"selection.detachColor" = "Отвязать цвет выделения" "selection.selectColor" = "Выделить слои с этим цветом" "selection.setPaint" = "Изменить цвет выделения" "shape.arrow" = "Стрелка" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index bcb819e5..e4fff1a7 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -299,7 +299,6 @@ name = "Svenska" "Constraints" = "Begränsningar" "Content" = "Innehåll" "Contents hidden" = "Innehållet är dolt" -"Contents only" = "Endast innehåll" "Contextual alternates" = "Kontextuella alternativ" "Contrast" = "Kontrast" "Contributors" = "Bidragsgivare" @@ -624,6 +623,7 @@ name = "Svenska" "If" = "Om" "If {condition}" = "Om {condition}" "Ignore auto layout (absolute position)" = "Ignorera automatisk layout (absolut position)" +"Ignore overlapping layers" = "Ignorera överlappande lager" "Image" = "Bild" "Images" = "Bilder" "Import" = "Importera" @@ -2112,7 +2112,9 @@ name = "Svenska" "select.sameText" = "Markera samma textegenskaper" "select.set" = "Markera" "selection.applyColorStyle" = "Använd färgformatmall på markeringen" +"selection.bindColorVariable" = "Använd färgvariabel på markeringen" "selection.colors" = "Markeringsfärger" +"selection.detachColor" = "Koppla bort markeringsfärg" "selection.selectColor" = "Markera lager med den här färgen" "selection.setPaint" = "Ändra markeringens färg" "shape.arrow" = "Pil" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 4275f06a..fbb1f661 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -299,7 +299,6 @@ name = "Türkçe" "Constraints" = "Kısıtlamalar" "Content" = "İçerik" "Contents hidden" = "İçerik gizli" -"Contents only" = "Yalnızca içerik" "Contextual alternates" = "Bağlamsal alternatifler" "Contrast" = "Kontrast" "Contributors" = "Katkıda bulunanlar" @@ -617,6 +616,7 @@ name = "Türkçe" "If" = "Eğer" "If {condition}" = "Eğer {condition}" "Ignore auto layout (absolute position)" = "Otomatik yerleşimi yok say (mutlak konum)" +"Ignore overlapping layers" = "Çakışan katmanları yok say" "Image" = "Görüntü" "Images" = "Görüntüler" "Import" = "İçe aktar" @@ -2090,7 +2090,9 @@ name = "Türkçe" "select.sameText" = "Aynı metin özelliklerini seç" "select.set" = "Seç" "selection.applyColorStyle" = "Seçime renk stili uygula" +"selection.bindColorVariable" = "Seçime renk değişkeni uygula" "selection.colors" = "Seçim renkleri" +"selection.detachColor" = "Seçim rengini ayır" "selection.selectColor" = "Bu renkteki katmanları seç" "selection.setPaint" = "Seçimin rengini değiştir" "shape.arrow" = "Ok" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index da070c0e..acebfc76 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -292,7 +292,6 @@ status = "draft — needs native review" "Constraints" = "约束" "Content" = "内容" "Contents hidden" = "内容已隐藏" -"Contents only" = "仅内容" "Contextual alternates" = "上下文替代字" "Contrast" = "对比度" "Contributors" = "贡献者" @@ -597,6 +596,7 @@ status = "draft — needs native review" "If" = "如果" "If {condition}" = "如果 {condition}" "Ignore auto layout (absolute position)" = "忽略自动布局(绝对定位)" +"Ignore overlapping layers" = "忽略重叠图层" "Image" = "图片" "Images" = "图片" "Import" = "导入" @@ -2031,7 +2031,9 @@ status = "draft — needs native review" "select.sameText" = "选择相同文本属性" "select.set" = "选择" "selection.applyColorStyle" = "将颜色样式应用于所选项" +"selection.bindColorVariable" = "将颜色变量应用于所选项" "selection.colors" = "所选项颜色" +"selection.detachColor" = "分离所选项颜色" "selection.selectColor" = "选择使用此颜色的图层" "selection.setPaint" = "更改所选项颜色" "shape.arrow" = "箭头" diff --git a/crates/ui-egui/src/assets_panel.rs b/crates/ui-egui/src/assets_panel.rs index 36636c3d..3f1fcc77 100644 --- a/crates/ui-egui/src/assets_panel.rs +++ b/crates/ui-egui/src/assets_panel.rs @@ -195,19 +195,20 @@ pub(crate) fn thumbnail(ctx: &egui::Context, doc: &Document, id: NodeId, px: f32 Some(ctx.load_texture(format!("asset-thumb-{}", id.0), img, egui::TextureOptions::LINEAR)) } -/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the page content -/// under it, exactly as exporting one does (`figcraft_engine::cmd::file::drawn_layers`): without -/// this a slice's preview is a blank checkerboard next to a correct export. +/// The pixels a thumbnail shows. A slice paints nothing itself, so it shows the content under it +/// that exporting it with its first export setting writes (`figcraft_engine::cmd::file::drawn_layers`): +/// without this a slice's preview is a blank checkerboard next to a correct export. pub(crate) fn thumbnail_pixmap(doc: &Document, id: NodeId, px: f32) -> Option { - let slice = doc.node(id).is_some_and(|n| matches!(n.kind, NodeKind::Slice)); - let b = if slice { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? }; + let slice = doc.node(id).filter(|n| matches!(n.kind, NodeKind::Slice)); + let b = if slice.is_some() { doc.world_bounds(id)? } else { figcraft_render::export_bounds(doc, id)? }; let side = b.width().max(b.height()); if !(side.is_finite() && side > 0.0) { return None; } let scale = (f64::from(px) / side).min(2.0); - if slice { - let layers = doc.page_of(id).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default(); + if let Some(n) = slice { + let setting = n.exports.first().cloned().unwrap_or_default(); + let layers = figcraft_engine::cmd::file::drawn_layers(doc, &[id], &setting); return figcraft_render::render_area(doc, &layers, b, scale); } figcraft_render::render_node(doc, id, scale) @@ -346,6 +347,7 @@ fn updates_banner(app: &mut FigApp, ui: &mut Ui, w: f32, count: usize) { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(Rect::from_min_max(pos2(r.max.x - 70.0, r.min.y + 6.0), pos2(r.max.x - 6.0, r.max.y)))); if crate::widgets::button(&mut child, tr("Update"), true, true).clicked() + && !crate::menus::dev_mode_refuses(app, "library.acceptUpdates") && let Some(v) = app.run("library.acceptUpdates", json!({})) { let (c, s) = (v["components"].as_u64().unwrap_or(0), v["styles"].as_u64().unwrap_or(0)); @@ -666,7 +668,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } Some(Act::Style(src, style)) => apply_style(app, &src, &style), Some(Act::Hide(id, on)) => { - app.run("component.hideWhenPublishing", json!({"ids": [id], "on": on})); + if !crate::menus::dev_mode_refuses(app, "component.hideWhenPublishing") { + app.run("component.hideWhenPublishing", json!({"ids": [id], "on": on})); + } } Some(Act::Toggle(id)) => { let mut c = collapsed; @@ -683,29 +687,39 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { /// Place component `id` of `src` centred on page point (`x`, `y`). fn place(app: &mut FigApp, src: &Source, id: NodeId, x: f64, y: f64) { - match src { - Source::Local => app.run("instance.place", json!({"component": id, "x": x, "y": y})), - Source::Library(lib) => app.run("library.place", json!({"library": lib, "component": id, "x": x, "y": y})), + let (cmd, params) = match src { + Source::Local => ("instance.place", json!({"component": id, "x": x, "y": y})), + Source::Library(lib) => ("library.place", json!({"library": lib, "component": id, "x": x, "y": y})), }; + if !crate::menus::dev_mode_refuses(app, cmd) { + app.run(cmd, params); + } } fn apply_style(app: &mut FigApp, src: &Source, style: &str) { + let (cmd, params) = match src { + Source::Local => ("style.apply", json!({"style": style})), + Source::Library(lib) => ("library.applyStyle", json!({"library": lib, "style": style})), + }; + if crate::menus::dev_mode_refuses(app, cmd) { + return; + } if app.session.selection().is_empty() { app.toast(tr("Select layers to apply the style to")); return; } - match src { - Source::Local => app.run("style.apply", json!({"style": style})), - Source::Library(lib) => app.run("library.applyStyle", json!({"library": lib, "style": style})), - }; + app.run(cmd, params); } /// Swap the instance at page point (`x`, `y`) to component `id` of `src`. fn swap_at(app: &mut FigApp, src: &Source, id: NodeId, x: f64, y: f64) { - match src { - Source::Local => app.run("instance.swapAt", json!({"component": id, "x": x, "y": y})), - Source::Library(lib) => app.run("library.swapAt", json!({"library": lib, "component": id, "x": x, "y": y})), + let (cmd, params) = match src { + Source::Local => ("instance.swapAt", json!({"component": id, "x": x, "y": y})), + Source::Library(lib) => ("library.swapAt", json!({"library": lib, "component": id, "x": x, "y": y})), }; + if !crate::menus::dev_mode_refuses(app, cmd) { + app.run(cmd, params); + } } /// The component set (by name) a dragged asset belongs to, in the document it comes from: the @@ -922,7 +936,10 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { None => {} } let params = (publish || to_server).then(|| json!({"description": form.description.clone().unwrap_or(description), "include": include})); - if publish && let Some(r) = params.clone().and_then(|p| app.run("library.publish", p)) { + if publish + && !crate::menus::dev_mode_refuses(app, "library.publish") + && let Some(r) = params.clone().and_then(|p| app.run("library.publish", p)) + { form = PublishForm::default(); // Other files read the saved file. app.save(); @@ -1075,7 +1092,9 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { crate::widgets::text_field(ui, Id::new("asset_add_link"), "", w - PAD * 2.0, tr("Paste a team library link (a view link)")) }) .inner; - if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) { + if let Some(link) = pasted.map(|l| l.trim().to_string()).filter(|l| !l.is_empty()) + && !crate::menus::dev_mode_refuses(app, ADD) + { app.run_background(&ui.ctx().clone(), ADD, json!({"path": link}), Box::new(|app, _, r| added(app, &r))); } if app.session.waiting_for(ADD) { @@ -1087,10 +1106,13 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { } }); } - if let Some(id) = remove { + if let Some(id) = remove + && !crate::menus::dev_mode_refuses(app, "library.remove") + { app.run("library.remove", json!({"id": id})); } if let Some((from, to)) = swap + && !crate::menus::dev_mode_refuses(app, "library.swap") && let Some(r) = app.run("library.swap", json!({"from": from, "to": to})) { let moved = ["components", "styles", "variables"].iter().map(|k| r[*k].as_u64().unwrap_or(0)).sum::(); @@ -1101,7 +1123,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { trn("Swapped {n} assets; {missed} had no match and still use the old library", moved as usize, &[("missed", &missed)]) }); } - if add { + if add && !crate::menus::dev_mode_refuses(app, ADD) { app.pick_open(tr("Add library"), &["figcraft"], |app, p| { if let Some(r) = app.run(ADD, json!({"path": p.to_string_lossy()})) { added(app, &r); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5ced1edc..cb8df52d 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -183,9 +183,17 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { } } let own_cursor = input(app, ui, &resp); - // A browser names no dragged file until the drop, so documents there still say "import". + // A browser names no dragged file until the drop, so documents there still say "import", or + // in Dev Mode show the Dev Mode note, though they open when dropped. let documents = ctx.input(|i| i.raw.hovered_files.iter().all(|f| f.path.as_deref().is_some_and(crate::is_document))); - crate::drop_overlay(&ctx, ctx.content_rect(), if documents { tr("Drop to open") } else { tr("Drop to import") }); + let label = if documents { + tr("Drop to open") + } else if app.ui.right_tab == crate::RightTab::Inspect { + tr("Dev Mode only inspects: switch to Design (Shift+D) to edit") + } else { + tr("Drop to import") + }; + crate::drop_overlay(&ctx, ctx.content_rect(), label); crate::guides::end_released(&ctx); crate::live::track_pointer(app, &ctx); app.sync_preedit(); @@ -510,14 +518,20 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> bool { 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. + // 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. let dropped = ctx.input(|i| i.raw.dropped_files.clone()); + let mut refused = false; for f in dropped { let path = f.path().to_path_buf(); if crate::is_document(&path) { crate::pending_open::open_dropped(app, &f); continue; } + if refused || crate::menus::dev_mode_refuses(app, "file.import") { + refused = true; + continue; + } let pt = hover_pos.map(|h| app.to_page(h)).unwrap_or_default(); let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped.png".into()); let (params, read) = if path.exists() { diff --git a/crates/ui-egui/src/corner_individual_tests.rs b/crates/ui-egui/src/corner_individual_tests.rs index 5a36f073..316c15a4 100644 --- a/crates/ui-egui/src/corner_individual_tests.rs +++ b/crates/ui-egui/src/corner_individual_tests.rs @@ -1,5 +1,6 @@ //! Individual corners in the Design panel, driven headless: the four corner fields sit where -//! their corners are (top left, top right / bottom left, bottom right) and each sets its own. +//! their corners are (top left, top right / bottom left, bottom right) and each sets its own; +//! for a polygon or star the panel shows corner smoothing only. use egui::accesskit::Role; use egui::{Event, Id, Key, Modifiers, Pos2, Rect, pos2}; @@ -54,6 +55,21 @@ fn field(u: &Ui, k: usize) -> Rect { u.ctx.read_response(Id::new(("corner", k)).with("field")).expect("corner field shows").rect } +/// The iOS button next to Corner smoothing sets 60% in one undo step, as the help page's "click +/// iOS to set corner smoothing to 60%" describes. +#[test] +fn ios_button_sets_corner_smoothing_to_60_percent() { + let (mut u, r) = scene(); + let t = named_button(&mut u, "Individual corners"); + click(&mut u, t); + let history = u.app.session.undo_depth().0; + let ios = named_button(&mut u, "iOS"); + click(&mut u, ios); + let smoothing = u.app.session.doc().unwrap().node(r).unwrap().corners.smoothing; + assert!((smoothing - 0.6).abs() < 1e-9, "smoothing is {smoothing}"); + assert_eq!(u.app.session.undo_depth().0, history + 1); +} + /// The fields sit at their corners, and typing in the bottom right one sets only that corner in a /// single undo step (#1002: the bottom row showed bottom right on the left). #[test] @@ -73,3 +89,29 @@ fn corner_fields_sit_at_their_corners_and_set_their_own() { assert_eq!(radii(&u, r), [30.0, 0.0, 12.0, 30.0]); assert_eq!(u.app.session.undo_depth().0, history + 1); } + +/// For a rounded polygon or star the same panel shows corner smoothing, which applies to the +/// whole shape: no field per corner, and typing a percentage sets the smoothing in one undo step. +#[test] +fn polygons_and_stars_take_corner_smoothing() { + for cmd in ["shape.polygon", "shape.star"] { + let mut s = Session::new(); + let id = NodeId(s.execute(cmd, &json!({"x": 0, "y": 0, "width": 120, "height": 120})).unwrap()["id"].as_u64().unwrap()); + s.execute("node.set", &json!({"ids": [id], "props": {"cornerRadius": 8}})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let mut u = Ui::new(s); + // With no corners of their own, the button is named for what it opens. + let t = named_button(&mut u, "Corner smoothing"); + click(&mut u, t); + assert!((0..4).all(|k| u.ctx.read_response(Id::new(("corner", k)).with("field")).is_none()), "{cmd}: one radius, no corner fields"); + let smoothing = u.ctx.read_response(Id::new("smoothing").with("field")).expect("the smoothing field shows").rect.center(); + let history = u.app.session.undo_depth().0; + click(&mut u, smoothing); + u.frame(vec![Event::Text("60".into())]); + enter(&mut u); + u.frame(vec![]); + let n = u.app.session.doc().unwrap().node(id).unwrap().clone(); + assert!((n.corners.smoothing - 0.6).abs() < 1e-9, "{cmd}: {}", n.corners.smoothing); + assert_eq!(u.app.session.undo_depth().0, history + 1, "{cmd}: one undo step"); + } +} diff --git a/crates/ui-egui/src/devmode_tail_tests.rs b/crates/ui-egui/src/devmode_tail_tests.rs index b7901083..b7c7a755 100644 --- a/crates/ui-egui/src/devmode_tail_tests.rs +++ b/crates/ui-egui/src/devmode_tail_tests.rs @@ -151,17 +151,121 @@ fn dev_mode_layers_panel_selects_and_expands_but_edits_nothing() { let doc = u.app.session.doc().unwrap(); assert!(doc.node(b).unwrap().visible && !doc.node(b).unwrap().locked, "not hidden or locked"); assert_eq!(u.app.session.selection(), [b]); - // Dragging a row doesn't reorder. + // Dragging a row doesn't reorder, and says why. + u.app.ui.toast = None; u.drag_to(ra, rb + egui::vec2(0.0, 6.0)); u.button(rb + egui::vec2(0.0, 6.0), false); rest(&mut u); assert_eq!(order(&u), before, "not reordered"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D")), "the drag says why"); // Back in the design, the same double click renames. u.app.ui.right_tab = crate::RightTab::Design; u.frame(vec![]); click(&mut u, ra); click(&mut u, ra); assert!(u.app.ui.rename.is_some_and(|r| r.id == a)); + // A drag started in the design and dropped after switching to Dev Mode doesn't reorder either. + u.app.ui.rename = None; + rest(&mut u); + u.app.ui.toast = None; + let rf = u.row_at(f); + u.drag_to(ra, rf); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + u.button(rf, false); + rest(&mut u); + assert_eq!(order(&u), before, "not reordered after the switch"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D")), "the drop says why"); + // So does a sweep down the eye column. + u.app.ui.right_tab = crate::RightTab::Design; + rest(&mut u); + u.app.ui.toast = None; + let (ea, eb) = (Pos2::new(eye, u.row_at(a).y), Pos2::new(eye, u.row_at(b).y)); + u.drag_to(ea, eb); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + u.button(eb, false); + rest(&mut u); + let doc = u.app.session.doc().unwrap(); + assert!(doc.node(a).unwrap().visible && doc.node(b).unwrap().visible, "not hidden after the switch"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D")), "the sweep says why"); +} + +#[test] +fn dev_mode_pages_list_opens_pages_but_edits_none() { + use egui::{Event, Pos2}; + let mut s = Session::new(); + let home = s.page().unwrap(); + s.execute("page.rename", &json!({"id": home, "name": "Home"})).unwrap(); + let specs = id(&s.execute("page.add", &json!({"name": "Specs"})).unwrap()); + s.execute("page.select", &json!({"id": home})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.right_tab = crate::RightTab::Inspect; + let shapes = |u: &mut crate::layers_drag_tests::Ui| crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let s = shapes(&mut u); + let (rh, rs) = (crate::live_tests::text_center(&s, "Home"), crate::live_tests::text_center(&s, "Specs")); + // The add button, found by its name in the AccessKit tree. + u.ctx.enable_accesskit(); + let nodes = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; + let b = nodes.iter().find(|(_, n)| n.label() == Some("Add new page")).and_then(|(_, n)| n.bounds()).unwrap(); + let add = Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32); + u.ctx.disable_accesskit(); + // The pointer arrives first: a row that also takes drags counts a press there only then. + let click = |u: &mut crate::layers_drag_tests::Ui, p: Pos2| { + let button = |pressed| Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + u.frame(vec![Event::PointerMoved(p)]); + u.frame(vec![button(true)]); + u.frame(vec![button(false)]); + }; + let rest = |u: &mut crate::layers_drag_tests::Ui| { + for _ in 0..30 { + u.frame(vec![]); + } + }; + let pages = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().pages.clone(); + let said_why = |u: &mut crate::layers_drag_tests::Ui| u.app.ui.toast.take().is_some_and(|(m, _)| m.contains("Shift+D")); + // A click opens the page. + click(&mut u, rs); + assert_eq!(u.app.session.page().unwrap(), specs); + // A double click doesn't rename, and says why. + click(&mut u, rs); + assert!(u.app.ui.rename_page.is_none(), "no rename"); + assert!(said_why(&mut u)); + rest(&mut u); + // Dragging a page doesn't reorder, and says why. + u.drag_to(rs, rh - egui::vec2(0.0, 10.0)); + u.button(rh - egui::vec2(0.0, 10.0), false); + rest(&mut u); + assert_eq!(pages(&u), [home, specs], "not reordered"); + assert!(said_why(&mut u), "the drag says why"); + // The add button adds no page, and says why. + click(&mut u, add); + assert_eq!(pages(&u), [home, specs], "no page added"); + assert!(said_why(&mut u)); + rest(&mut u); + // The row's Duplicate page and Delete page do nothing, and say why. + for item in ["Duplicate page", "Delete page"] { + u.frame(vec![ + Event::PointerMoved(rs), + Event::PointerButton { pos: rs, button: egui::PointerButton::Secondary, pressed: true, modifiers: Default::default() }, + ]); + u.frame(vec![Event::PointerButton { pos: rs, button: egui::PointerButton::Secondary, pressed: false, modifiers: Default::default() }]); + let s = shapes(&mut u); + click(&mut u, crate::live_tests::text_center(&s, item)); + assert_eq!(pages(&u), [home, specs], "{item} changes no page"); + assert!(said_why(&mut u), "{item} says why"); + rest(&mut u); + } + // In the design the double click renames and the add button adds a page. + u.app.ui.right_tab = crate::RightTab::Design; + u.frame(vec![]); + click(&mut u, rs); + click(&mut u, rs); + assert!(u.app.ui.rename_page.as_ref().is_some_and(|(p, _)| *p == specs)); + u.app.ui.rename_page = None; + rest(&mut u); + click(&mut u, add); + assert_eq!(pages(&u).len(), 3, "a page is added"); } #[test] @@ -186,3 +290,162 @@ fn measurements_kept_on_the_canvas_draw_in_dev_mode_and_follow_their_layers() { u.frame(vec![]); assert!(labels(&u).is_empty()); } + +/// A file dropped with its contents and no file on disk. +#[derive(Debug)] +struct DroppedBytes(std::path::PathBuf, Vec); + +impl egui::DroppedFile for DroppedBytes { + fn path(&self) -> &std::path::Path { + &self.0 + } + + fn bytes(&self) -> Result, String> { + Ok(self.1.clone()) + } +} + +/// Hover `names` over the canvas (one frame: what it shows), then drop them there with `bytes`. +fn drop_on_canvas(u: &mut crate::layers_drag_tests::Ui, names: &[&str], bytes: &[u8]) -> Vec { + let at = u.app.canvas.rect.center(); + let path = |n: &str| std::path::Path::new("figcraft-dev-mode-drop").join(n); + let hovered = names.iter().map(|n| egui::HoveredFile { path: Some(path(n)), mime: String::new() }).collect(); + let out = u.frame_raw(egui::RawInput { events: vec![egui::Event::PointerMoved(at)], hovered_files: hovered, ..Default::default() }); + let files = names.iter().map(|n| std::sync::Arc::new(DroppedBytes(path(n), bytes.to_vec())) as egui::DroppedFileHandle).collect(); + u.frame_raw(egui::RawInput { events: vec![egui::Event::PointerMoved(at)], dropped_files: files, ..Default::default() }); + crate::live_tests::flat(&out.shapes) +} + +#[test] +fn dev_mode_imports_no_dropped_file_into_the_design_and_says_why() { + let svg = br#""#; + let refusal = "Dev Mode only inspects: switch to Design (Shift+D) to edit"; + let mut u = crate::layers_drag_tests::Ui::new(Session::new()); + let layers = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(u.app.session.page().unwrap()).unwrap().children.len(); + u.app.ui.right_tab = crate::RightTab::Inspect; + u.frame(vec![]); + let s = drop_on_canvas(&mut u, &["Logo.svg", "Mark.svg"], svg); + assert!(crate::live_tests::has_text(&s, refusal), "dragging images over the canvas says why they won't import"); + assert!(u.app.ui.pending_opens.is_empty(), "nothing waits to import"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m == refusal)); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(layers(&u), 0, "the design is unchanged"); + // A dropped document still opens as a file of its own. + let s = drop_on_canvas(&mut u, &["Other.figcraft"], b""); + assert!(crate::live_tests::has_text(&s, "Drop to open")); + assert_eq!(u.app.ui.pending_opens.iter().map(|p| p.command).collect::>(), ["file.open"]); + u.app.ui.pending_opens.clear(); + // In the design the same drop imports. + u.app.ui.right_tab = crate::RightTab::Design; + u.frame(vec![]); + let s = drop_on_canvas(&mut u, &["Logo.svg"], svg); + assert!(crate::live_tests::has_text(&s, "Drop to import")); + assert_eq!(u.app.ui.pending_opens.iter().map(|p| p.command).collect::>(), ["file.import"]); + for _ in 0..3 { + u.frame(vec![]); + } + assert_eq!(layers(&u), 1, "the image is imported"); +} + +#[test] +fn dev_mode_assets_panel_places_and_applies_nothing() { + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 80, "height": 40, "name": "Card"})).unwrap()); + s.execute("component.create", &json!({"ids": [f]})).unwrap(); + s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 200, "y": 0, "width": 40, "height": 40})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.left_tab = crate::LeftTab::Assets; + u.app.ui.right_tab = crate::RightTab::Inspect; + let instances = |u: &crate::layers_drag_tests::Ui| { + u.app.session.doc().unwrap().nodes().filter(|n| matches!(n.kind, figcraft_doc::NodeKind::Instance { .. })).count() + }; + let styled = |u: &crate::layers_drag_tests::Ui| u.app.session.doc().unwrap().node(r).is_some_and(|n| n.styles.fill.is_some()); + let click = |u: &mut crate::layers_drag_tests::Ui, text: &str| { + let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let at = crate::live_tests::text_center(&s, text); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); + }; + click(&mut u, "Card"); + click(&mut u, "Brand"); + let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let (tile, canvas) = (crate::live_tests::text_center(&s, "Card"), u.app.canvas.rect.center()); + u.drag_to(tile, canvas); + u.button(canvas, false); + u.frame(vec![]); + assert_eq!(instances(&u), 0, "a click on a component, or a drag onto the canvas, places no instance"); + assert!(!styled(&u), "a click on a style doesn't apply it"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + // In the design both edit. + u.app.ui.right_tab = crate::RightTab::Design; + click(&mut u, "Card"); + assert_eq!(instances(&u), 1); + u.drag_to(tile, canvas); + u.button(canvas, false); + u.frame(vec![]); + assert_eq!(instances(&u), 2, "the same drag places one"); + u.app.session.execute("select.set", &json!({"ids": [r]})).unwrap(); + click(&mut u, "Brand"); + assert!(styled(&u)); +} + +#[test] +fn dev_mode_assets_panel_accepts_no_library_updates() { + let dir = std::env::temp_dir().join(format!("figcraft-dev-mode-updates-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Library.figcraft"); + let mut lib = Session::new(); + lib.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0000FF"}]})).unwrap(); + lib.execute("library.publish", &json!({})).unwrap(); + lib.execute("file.saveAs", &json!({"path": path.to_string_lossy()})).unwrap(); + let mut s = Session::new(); + let added = s.execute("library.add", &json!({"path": path.to_string_lossy()})).unwrap(); + let r = id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 40})).unwrap()); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + s.execute("library.applyStyle", &json!({"library": added["id"], "style": "Brand"})).unwrap(); + lib.execute("style.update", &json!({"style": "Brand", "value": "#00FF00"})).unwrap(); + lib.execute("library.publish", &json!({})).unwrap(); + lib.execute("file.save", &json!({})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.left_tab = crate::LeftTab::Assets; + u.app.ui.right_tab = crate::RightTab::Inspect; + let updates = |u: &mut crate::layers_drag_tests::Ui| u.app.session.execute("library.updates", &json!({})).unwrap()["count"].as_u64(); + let update = |u: &mut crate::layers_drag_tests::Ui| { + let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let at = crate::live_tests::text_center(&s, "Update"); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); + }; + assert_eq!(updates(&mut u), Some(1)); + update(&mut u); + assert_eq!(updates(&mut u), Some(1), "Update in Dev Mode pulls nothing in"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); + u.app.ui.right_tab = crate::RightTab::Design; + update(&mut u); + assert_eq!(updates(&mut u), Some(0), "in the design it does"); + let _ = std::fs::remove_file(&path); + let _ = std::fs::remove_dir(&dir); +} + +#[test] +fn dev_mode_publishes_no_library() { + let mut s = Session::new(); + s.execute("style.create", &json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0D99FF"}]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + u.app.ui.left_tab = crate::LeftTab::Assets; + u.app.ui.libraries = true; + u.app.ui.right_tab = crate::RightTab::Inspect; + let s = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + let at = crate::live_tests::text_center(&s, "Publish"); + u.button(at, true); + u.button(at, false); + u.frame(vec![]); + assert!(u.app.session.doc().unwrap().published.is_none(), "Publish in Dev Mode publishes nothing"); + assert!(u.app.ui.toast.as_ref().is_some_and(|(m, _)| m.contains("Shift+D"))); +} diff --git a/crates/ui-egui/src/export_panel.rs b/crates/ui-egui/src/export_panel.rs index 8617c35a..5e05cc19 100644 --- a/crates/ui-egui/src/export_panel.rs +++ b/crates/ui-egui/src/export_panel.rs @@ -40,8 +40,9 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { if mixed { widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary); } + let text = sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))); for (i, e) in list.iter().enumerate() { - setting_row(app, ui, &ids, i, e); + setting_row(app, ui, &ids, i, e, text); } let jobs = export_jobs(doc, sel); if jobs.is_empty() { @@ -61,8 +62,9 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { } /// One export setting: size (typed or picked; images only, SVG and PDF are vector), suffix, -/// format, advanced options and remove; a PDF's print settings under it. -fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting) { +/// format, advanced options and remove; a PDF's print settings under it. `text`: every layer in +/// `ids` is a text layer. +fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &ExportSetting, text: bool) { row(ui, 32.0, |ui| { // Tighter spacing so the advanced options and remove buttons fit beside the fields. ui.spacing_mut().item_spacing.x = 4.0; @@ -98,7 +100,7 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo .width(216.0) .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) .show(|ui| { - if let Some(mut params) = advanced_options(ui, e) { + if let Some(mut params) = advanced_options(ui, e, text) { params["ids"] = json!(ids); params["index"] = json!(i); app.run("export.update", params); @@ -113,17 +115,21 @@ fn setting_row(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], i: usize, e: &Expo } } -/// A setting's advanced options: contents only and include bounding box, and for SVG outline -/// text, the id attribute and simplified strokes. Returns the changed option as command params. -fn advanced_options(ui: &mut Ui, e: &ExportSetting) -> Option { +/// A setting's advanced options: ignore overlapping layers (`contentsOnly`), include bounding box +/// for text layers (and for any setting that has it on, so it can be turned off), and for SVG +/// outline text, the id attribute and simplified strokes. Returns the changed option as command +/// params. +fn advanced_options(ui: &mut Ui, e: &ExportSetting, text: bool) -> Option { let mut out = None; let mut option = |ui: &mut Ui, key: &str, on: bool, label: &str| { if widgets::checkbox(ui, on, label) { out = Some(json!({ key: !on })); } }; - option(ui, "contentsOnly", e.contents_only, tr("Contents only")); - option(ui, "includeBoundingBox", e.include_bounding_box, tr("Include bounding box")); + option(ui, "contentsOnly", e.contents_only, tr("Ignore overlapping layers")); + if text || e.include_bounding_box { + option(ui, "includeBoundingBox", e.include_bounding_box, tr("Include bounding box")); + } if e.format == ExportFormat::Svg { option(ui, "svgOutlineText", e.svg_outline_text, tr("Outline text")); option(ui, "svgIdAttribute", e.svg_id_attribute, tr("Include id attribute")); diff --git a/crates/ui-egui/src/export_preview_tests.rs b/crates/ui-egui/src/export_preview_tests.rs index d57e294d..f888ab47 100644 --- a/crates/ui-egui/src/export_preview_tests.rs +++ b/crates/ui-egui/src/export_preview_tests.rs @@ -1,7 +1,7 @@ //! The Export section's Preview row: a slice paints nothing itself, so its preview must show the //! page content under it — the same thing exporting the slice writes — and not a blank //! transparency checkerboard next to a correct export. And its setting rows: a size only for -//! images. +//! images, and advanced options named as the reference app names them. use figcraft_doc::{Document, NodeId}; use figcraft_engine::Session; @@ -33,6 +33,23 @@ fn a_slice_previews_the_content_under_it() { assert!(paints(&pix), "the rectangle under the slice shows"); } +/// The preview follows the slice's first export setting: with Ignore overlapping layers (contents +/// only) on, a slice in a frame shows only the frame's content, as its export does. +#[test] +fn a_slice_in_a_frame_previews_only_the_frame() { + let mut s = Session::new(); + let new = |s: &mut Session, p: Value| NodeId(s.execute("node.create", &p).unwrap()["id"].as_u64().unwrap()); + let f = new(&mut s, json!({"type": "frame", "x": 0, "y": 0, "width": 200, "height": 100, "props": {"fills": []}})); + let slice = new(&mut s, json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100, "parent": f})); + new(&mut s, json!({"type": "rectangle", "x": 0, "y": 0, "width": 200, "height": 100})); + let pix = thumbnail_pixmap(s.doc().unwrap(), slice, 200.0).expect("the slice previews"); + assert_eq!([pix.width(), pix.height()], [200, 100]); + assert!(!paints(&pix), "the rectangle on the page above the empty frame is left out"); + s.execute("export.add", &json!({"ids": [slice], "setting": {"size": "1x", "contentsOnly": false}})).unwrap(); + let pix = thumbnail_pixmap(s.doc().unwrap(), slice, 200.0).expect("the slice previews"); + assert!(paints(&pix), "with the option off, the rectangle shows"); +} + #[test] fn a_slice_with_nothing_under_it_is_blank() { let (doc, ids) = doc_after(&[("node.create", json!({"type": "slice", "x": 0, "y": 0, "width": 200, "height": 100}))]); @@ -76,3 +93,33 @@ fn export_rows_offer_a_size_for_images_only() { assert!(size_menu(&u), "{format} has sizes again"); } } + +/// The advanced options: Ignore overlapping layers (the `contentsOnly` setting), and Include +/// bounding box for text layers only. A setting that already has the bounding box on keeps it, and +/// the option stays so it can be turned off. +#[test] +fn advanced_options_offer_the_bounding_box_for_text_layers() { + let mut s = Session::new(); + let r = crate::present_tests::id(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 80})).unwrap()); + let t = crate::present_tests::id(&s.execute("text.create", &json!({"text": "Hi", "x": 200, "y": 0})).unwrap()); + s.execute("export.add", &json!({"ids": [r]})).unwrap(); + s.execute("export.add", &json!({"ids": [t]})).unwrap(); + let mut u = crate::layers_drag_tests::Ui::new(s); + let options = |u: &mut crate::layers_drag_tests::Ui, id: NodeId| { + u.app.run("select.set", json!({"ids": [id]})); + u.frame(vec![]); + egui::Popup::open_id(&u.ctx, egui::Id::new(("export_more", 0))); + u.frame(vec![]); + crate::import_report_tests::shown_texts(u.frame_output(vec![]).shapes) + }; + let shown = options(&mut u, r); + assert!(shown.iter().any(|s| s == "Ignore overlapping layers"), "the reference app's name: {shown:?}"); + assert!(!shown.iter().any(|s| s == "Contents only")); + assert!(!shown.iter().any(|s| s == "Include bounding box"), "a rectangle has no bounding box option"); + let shown = options(&mut u, t); + assert!(shown.iter().any(|s| s == "Include bounding box"), "a text layer has it: {shown:?}"); + u.app.run("export.update", json!({"ids": [r], "index": 0, "includeBoundingBox": true})); + let shown = options(&mut u, r); + assert!(shown.iter().any(|s| s == "Include bounding box"), "a rectangle that has it on shows it: {shown:?}"); + assert!(u.app.session.doc().unwrap().node(r).unwrap().exports[0].include_bounding_box, "the setting is kept"); +} diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 2e5af4cb..aeb35096 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -40,6 +40,7 @@ icons! { Pen, Pencil, Text, + TextOnPath, Comment, Actions, Chevron, @@ -561,6 +562,12 @@ fn draw(p: &P, icon: Icon) { p.line(11.5, 3.0, 11.5, 5.5); p.line(5.5, 12.5, 9.5, 12.5); } + TextOnPath => { + // A small T above the curve it follows. + p.line(4.5, 3.0, 11.5, 3.0); + p.line(8.0, 3.0, 8.0, 8.5); + p.poly(&[(2.0, 13.5), (5.0, 11.5), (8.0, 10.8), (11.0, 11.5), (14.0, 13.5)], false); + } Comment => { let mut b: Vec<(f32, f32)> = arc_pts(8.5, 7.5, 5.0, 2.75, TAU + 1.95).collect(); b.push((2.5, 13.5)); diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 95c0b3bd..ffb74aef 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -175,6 +175,11 @@ pub fn left_panel(app: &mut FigApp, ui: &mut Ui) { fn pages(app: &mut FigApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); + // Dev Mode only inspects: rows open pages, but don't add, rename, reorder, duplicate or delete. + let dev = app.ui.right_tab == crate::RightTab::Inspect; + if dev { + app.ui.rename_page = None; + } let w = ui.available_width(); let (hr, _) = ui.allocate_exact_size(vec2(w, 40.0), Sense::hover()); let tresp = ui.interact(Rect::from_min_size(hr.min, vec2(w - 40.0, 40.0)), ui.id().with("pages_toggle"), Sense::click()); @@ -205,7 +210,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { icons::paint(ui.painter(), ar.shrink(5.0), Icon::Plus, t.icon); if aresp.clicked() { app.ui.pages_focus = app.session.ds().ok().map(|d| d.uid); - app.run("page.add", json!({})); + if !crate::menus::dev_mode_refuses(app, "page.add") { + app.run("page.add", json!({})); + } } if !app.ui.pages_open { return; @@ -241,13 +248,16 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } let sense = if filtering { Sense::click() } else { Sense::click_and_drag() }; let (r, resp) = ui.allocate_exact_size(vec2(w, 32.0), sense); + if dev && resp.drag_started() { + crate::menus::dev_mode_refuses(app, "page.move"); + } if id == cur { current_row_rendered = true; if scroll_to_cur { ui.scroll_to_rect(r, None); } } - if !filtering { + if !filtering && !dev { reorder.row_by(&resp, k, r, || if divider { tr("Divider").to_string() } else { name.clone() }); } a11y::list_item(&resp, if divider { tr("Divider") } else { &name }, id == cur); @@ -287,7 +297,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.pages_focus = Some(uid); app.ui.layers_focus = false; } - if resp.double_clicked() { + if resp.double_clicked() && !crate::menus::dev_mode_refuses(app, "page.rename") { app.ui.rename_page = Some((id, name.clone())); // The field takes the focus next frame, once it's drawn: focusing it now would put a // node that isn't there yet in the screen reader tree, which AccessKit aborts on. @@ -295,15 +305,21 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } crate::right::context_popup(&resp).show(|ui| { if ui.button(tr("Rename page")).clicked() { - app.ui.rename_page = Some((id, name.clone())); + if !crate::menus::dev_mode_refuses(app, "page.rename") { + app.ui.rename_page = Some((id, name.clone())); + } ui.close(); } if ui.button(tr("Duplicate page")).clicked() { - app.run("page.duplicate", json!({"id": id})); + if !crate::menus::dev_mode_refuses(app, "page.duplicate") { + app.run("page.duplicate", json!({"id": id})); + } ui.close(); } if ui.button(tr("Delete page")).clicked() { - delete_page(app, id); + if !crate::menus::dev_mode_refuses(app, "page.delete") { + delete_page(app, id); + } ui.close(); } }); @@ -321,6 +337,7 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { } if let Some((from, to)) = moved.inner && let Some((id, ..)) = pages.get(from) + && !crate::menus::dev_mode_refuses(app, "page.move") { app.run("page.move", json!({"id": id, "index": to})); } @@ -328,7 +345,9 @@ fn pages(app: &mut FigApp, ui: &mut Ui) { app.ui.rename_page = None; app.run("page.rename", json!({"id": id, "name": new})); } - if let Some((page, name)) = move_to { + if let Some((page, name)) = move_to + && !crate::menus::dev_mode_refuses(app, "object.moveToPage") + { let ids = dragged_layers(dragging, &sel); if app.run("object.moveToPage", json!({"ids": ids, "page": page})).is_some() { app.toast(trf("Moved to {name}", &[("name", &name)])); @@ -830,7 +849,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { let ui = &mut band_ui; for row in rows.iter().take(last).skip(first) { let Some(n) = doc.node(row.id) else { continue }; - let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), if dev { Sense::click() } else { Sense::click_and_drag() }); + let (r, resp) = ui.allocate_exact_size(vec2(w, ROW), Sense::click_and_drag()); let selected = sel.contains(&row.id); a11y::layer_row(&mut app.ui.nav, &resp); if sel.last() == Some(&row.id) { @@ -974,7 +993,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { app.ui.context_menu = Some((pos, vec![row.id])); } // Drag to reorder. - if resp.drag_started() { + if resp.drag_started() && !crate::menus::dev_mode_refuses(app, "node.reparent") { ui.data_mut(|d| d.insert_temp(drag_id, row.id)); } let over = dragging.is_some() && ui.input(|i| i.pointer.hover_pos()).is_some_and(|p| r.contains(p)); @@ -1061,7 +1080,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { 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") }; - app.run(cmd, json!({"ids": sw.ids, key: if sw.lock { sw.on } else { !sw.on }})); + // 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 { @@ -1075,7 +1097,10 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { d.remove::(drag_id); d.remove::<(NodeId, f64)>(Id::new("layer_drag_hold")); }); - if let (Some(_), Some((target, zone))) = (dragging, drop) { + // 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 => { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index eeb25e3d..c8d0159b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -107,10 +107,9 @@ fn clipboard_png(app: &mut FigApp) -> Option<(Vec, u32, u32)> { Some((out.into_inner(), w, h)) } -/// Dev Mode (the Inspect tab) refuses command `id` from the menus, shortcuts and clipboard: it -/// edits the design ([`figcraft_engine::devinspect::dev_mode_allows`]). Says so when it does. -/// shortcut: files dropped on the canvas still edit in Dev Mode; route them through here if people -/// trip over it. +/// Dev Mode (the Inspect tab) refuses command `id` from the menus, shortcuts, clipboard, the +/// Pages list, the layers panel, files dropped on the canvas and the Assets panel: it edits the +/// design ([`figcraft_engine::devinspect::dev_mode_allows`]). Says so when it does. pub fn dev_mode_refuses(app: &mut FigApp, id: &str) -> bool { let refused = app.ui.right_tab == crate::RightTab::Inspect && !figcraft_engine::devinspect::dev_mode_allows(id); if refused { diff --git a/crates/ui-egui/src/present.rs b/crates/ui-egui/src/present.rs index fae1e1a7..9b59015f 100644 --- a/crates/ui-egui/src/present.rs +++ b/crates/ui-egui/src/present.rs @@ -288,6 +288,9 @@ pub struct Presenter { /// The layer the Inspect panel shows (the last one under the pointer). inspected: Option, urls: Vec, + /// A link to a website waiting for the viewer to open it ([`link_notice`]), and whether "Don't + /// show this again" is ticked. + link_notice: Option<(String, bool)>, /// The editor's document as last copied (the player copies it again when it changes). source: Option>, /// Scrolling by dragging: what it scrolls and its offset when the drag began. @@ -368,6 +371,7 @@ impl Presenter { clock: 0.0, inspected: None, urls: Vec::new(), + link_notice: None, source: None, drag_scroll: None, view: PresentView::default(), @@ -630,6 +634,7 @@ impl Presenter { "zoom": self.zoom, "flinging": self.fling.is_some(), "gamepad": self.uses_pads, + "linkNotice": self.link_notice.as_ref().map(|(url, _)| url), }) } @@ -1693,8 +1698,9 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { } poll_pads(app, &mut pres, &ctx); let mut action = None; - // Typing a reply to a comment keeps the keys from the prototype; Shift+C shows the comments. - let typing = ctx.egui_wants_keyboard_input(); + // Typing a reply to a comment keeps the keys from the prototype, and so does the link notice + // while it shows; Shift+C shows the comments. + let typing = ctx.egui_wants_keyboard_input() || pres.link_notice.is_some(); if !typing && ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::C)) { action = Some(BarAction::Comments); } @@ -1734,6 +1740,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui) { app.ui.comments.close(); } finish(app, &mut pres); + link_notice(app, &mut pres, &ctx, Id::new("present_link_notice")); // The options carry over to the next presentation. app.ui.present_view = pres.view; if live && let Some(BarAction::Responsive(on)) = action { @@ -1879,16 +1886,61 @@ fn poll_pads(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context) { crate::repaint_in(ctx, PAD_POLL_SECS); } -/// After a player's frame: the links it opens and the widget clicks it took. +/// After a player's frame: the links it opens and the widget clicks it took. A link to a website +/// waits in [`link_notice`] unless the viewer chose to skip the notice; a second link to a website +/// that fires while the notice shows is dropped. fn finish(app: &mut FigApp, p: &mut Presenter) { for u in p.urls.drain(..) { - app.services.open_url(&u); + if app.session.prefs.link_notice_skipped || !goes_to_a_website(&u) { + app.services.open_url(&u); + } else if p.link_notice.is_none() { + p.link_notice = Some((u, false)); + } } if let Some(e) = p.click_widgets(&app.session) { app.toast(e); } } +/// Whether `url` is an `http` or `https` link (a `mailto:` link opens the mail app instead). +fn goes_to_a_website(url: &str) -> bool { + url.trim().split_once(':').is_some_and(|(scheme, _)| scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) +} + +/// The notice before a link leaves the prototype for a website: the link, "Don't show this +/// again" (saved with the preferences when Open link is pressed), Open link and Cancel. Escape or +/// the close button cancels. +fn link_notice(app: &mut FigApp, p: &mut Presenter, ctx: &egui::Context, id: Id) { + let Some((url, skip)) = p.link_notice.as_mut() else { return }; + let t = Tokens::get(ctx); + let (mut open, mut go, mut cancel) = (true, false, false); + crate::dialogs::window(tr("Leaving the prototype"), ctx).id(id).open(&mut open).show(ctx, |ui| { + ui.set_width(340.0); + widgets::label(ui, tr("This link opens a website outside the prototype:"), t.text); + ui.add(egui::Label::new(egui::RichText::new(url.as_str()).font(regular(11.0)).color(t.text_secondary)).wrap()); + ui.add_space(8.0); + if widgets::checkbox(ui, *skip, tr("Don't show this again")) { + *skip = !*skip; + } + ui.add_space(12.0); + ui.horizontal(|ui| { + go = widgets::button(ui, tr("Open link"), true, true).clicked(); + cancel = widgets::button(ui, tr("Cancel"), false, true).clicked(); + }); + }); + cancel |= !open || ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if go { + let Some((url, skip)) = p.link_notice.take() else { return }; + if skip { + app.run("proto.linkNotice", json!({"show": false})); + app.services.save_prefs(&app.session.prefs); + } + app.services.open_url(&url); + } else if cancel { + p.link_notice = None; + } +} + /// The width of the presenter's Inspect panel. const INSPECT_W: f32 = 280.0; @@ -1993,6 +2045,7 @@ pub fn show_pane(app: &mut FigApp, ctx: &egui::Context) { action = player(&mut pane.player, ui, rect, now, chrome); }); finish(app, &mut pane.player); + link_notice(app, &mut pane.player, ctx, pane_id().with("link_notice")); match action { Some(BarAction::Close) => {} Some(BarAction::Present) => { @@ -2167,8 +2220,10 @@ fn player(p: &mut Presenter, ui: &mut Ui, rect: Rect, now: f64, chrome: Chrome) let resp = ui.interact(area, ui.id().with("present_area"), Sense::click_and_drag()); let over = resp.contains_pointer(); if let Some(l) = layout(p, area, chrome) { - // The pane only takes the keyboard while the pointer is over it. - input(p, &ctx, &l, over, chrome.keys && (!chrome.pane || over), chrome.pane, now); + // 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); } let Some(l) = layout(p, area, chrome) else { return action }; p.shown = Some((p.current, l.place, l.clip.intersect(area))); diff --git a/crates/ui-egui/src/present_links_tests.rs b/crates/ui-egui/src/present_links_tests.rs index 8223890c..31a6b2bc 100644 --- a/crates/ui-egui/src/present_links_tests.rs +++ b/crates/ui-egui/src/present_links_tests.rs @@ -1,7 +1,9 @@ //! A prototype's Open link action carries a URL from the document and fires from whatever trigger //! the prototype sets, including a delay, so the player hands the opener only a web link //! ([`figcraft_engine::doc_urls`]). Without that, a saved document opens `javascript:` in the web -//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. +//! build's own origin (stored XSS) and `smb:`/`ms-…:` through the desktop's handlers. A link to a +//! website then waits behind a notice that it leaves the prototype, unless the viewer chose to skip +//! the notice. use std::sync::{Arc, Mutex}; @@ -11,9 +13,9 @@ use serde_json::{Value, json}; use crate::Services; use crate::present_tests::{Ui as Harness, id}; -/// Records every URL the player asks the platform to open. +/// Records every URL the player asks the platform to open, and every save of the preferences. #[derive(Default)] -struct Opened(Arc>>); +struct Opened(Arc>>, Arc>>); impl Services for Opened { fn open_url(&mut self, url: &str) { @@ -21,15 +23,28 @@ impl Services for Opened { v.push(url.to_string()); } } + + fn save_prefs(&mut self, prefs: &figcraft_engine::Prefs) { + if let Ok(mut v) = self.1.lock() { + v.push(prefs.clone()); + } + } } fn ex(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) } -/// A presented frame whose layer opens `url` 100ms after the screen appears — no click anywhere. -fn opens_after_a_delay(url: &str) -> Vec { +/// A presented frame whose layer opens `url` 100ms after the screen appears, with no click +/// anywhere, and with the notice skipped or not. Returns the harness and what the player opened. +fn presented(url: &str, skip_notice: bool) -> (Harness, Opened) { + played("proto.present", url, skip_notice) +} + +/// [`presented`], played by `cmd` (`proto.present` or `proto.preview`). +fn played(cmd: &str, url: &str, skip_notice: bool) -> (Harness, Opened) { let mut s = Session::new(); + s.prefs.link_notice_skipped = skip_notice; let f = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"}))); let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": f}))); ex( @@ -37,19 +52,126 @@ fn opens_after_a_delay(url: &str) -> Vec { "proto.interaction.add", json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": {"type": "openUrl", "url": url}, "transition": {"type": "instant"}}), ); - let seen = Arc::new(Mutex::new(Vec::new())); + let seen = Opened::default(); let mut u = Harness::new(s); - u.app.services = Box::new(Opened(seen.clone())); - u.run("proto.present", json!({"start": f})); + u.app.services = Box::new(Opened(seen.0.clone(), seen.1.clone())); + u.ctx.enable_accesskit(); + u.run(cmd, json!({"start": f})); // Well past the delay. u.frames(40, Vec::new); - seen.lock().map(|v| v.clone()).unwrap_or_default() + (u, seen) +} + +fn opened(seen: &Opened) -> Vec { + seen.0.lock().map(|v| v.clone()).unwrap_or_default() +} + +fn opens_after_a_delay(url: &str) -> Vec { + opened(&presented(url, true).1) +} + +/// The URL the presenter's notice waits to open. +fn notice(u: &Harness) -> Value { + u.app.inspect()["presenter"]["linkNotice"].clone() +} + +/// Clicks the control labeled `label`. +fn click(u: &mut Harness, label: &str) { + let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; + let n = tree.into_iter().map(|(_, n)| n).find(|n| n.label() == Some(label)).unwrap_or_else(|| panic!("no {label:?}")); + let b = n.bounds().unwrap(); + let at = egui::pos2(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32); + u.button(at, true); + u.button(at, false); } #[test] fn a_prototype_opens_web_links() { assert_eq!(opens_after_a_delay("https://example.com/a"), ["https://example.com/a"]); assert_eq!(opens_after_a_delay("mailto:a@b.c"), ["mailto:a@b.c"]); + // A mail link opens the mail app without the notice. + assert_eq!(opened(&presented("mailto:a@b.c", false).1), ["mailto:a@b.c"]); +} + +#[test] +fn a_link_to_a_website_waits_for_the_notice() { + let (mut u, seen) = presented("https://example.com/a", false); + assert!(opened(&seen).is_empty(), "the link waits for the viewer"); + assert_eq!(notice(&u), "https://example.com/a"); + click(&mut u, "Open link"); + assert_eq!(opened(&seen), ["https://example.com/a"]); + assert!(notice(&u).is_null()); + assert!(!u.app.session.prefs.link_notice_skipped && seen.1.lock().unwrap().is_empty(), "the notice shows next time"); +} + +#[test] +fn the_preview_pane_shows_the_notice_too() { + let (mut u, seen) = played("proto.preview", "https://example.com/a", false); + assert!(opened(&seen).is_empty()); + assert_eq!(u.app.inspect()["preview"]["linkNotice"], "https://example.com/a"); + click(&mut u, "Open link"); + assert_eq!(opened(&seen), ["https://example.com/a"]); +} + +#[test] +fn cancel_or_escape_drops_the_link() { + let (mut u, seen) = presented("https://example.com/a", false); + click(&mut u, "Cancel"); + assert!(notice(&u).is_null() && opened(&seen).is_empty()); + let (mut u, seen) = presented("https://example.com/a", false); + u.key(egui::pos2(700.0, 450.0), egui::Key::Escape); + assert!(notice(&u).is_null() && opened(&seen).is_empty()); + assert!(u.app.ui.present.is_some(), "Escape closes the notice, not the presentation"); +} + +#[test] +fn the_prototype_takes_no_keys_while_the_notice_shows() { + let mut s = Session::new(); + let a = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"}))); + let b = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 400, "name": "B"}))); + let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": a}))); + let link = json!({"type": "openUrl", "url": "https://example.com/a"}); + ex(&mut s, "proto.interaction.add", json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": link})); + let to_b = json!({"type": "navigate", "dest": b}); + ex(&mut s, "proto.interaction.add", json!({"id": a, "trigger": {"type": "keyPress", "key": "Escape"}, "action": to_b})); + let mut u = Harness::new(s); + u.app.services = Box::new(Opened::default()); + u.run("proto.present", json!({"start": a})); + u.frames(40, Vec::new); + assert_eq!(notice(&u), "https://example.com/a"); + u.key(egui::pos2(700.0, 450.0), egui::Key::Escape); + assert!(notice(&u).is_null(), "Escape cancels the notice"); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "the prototype's Escape trigger did not run"); +} + +#[test] +fn the_presenter_keys_wait_while_the_notice_shows() { + let (mut u, _) = presented("https://example.com/a", false); + assert_eq!(notice(&u), "https://example.com/a"); + let press = |key, modifiers| { + let k = |pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }; + vec![k(true), k(false)] + }; + let out = u.frame_output(press(egui::Key::F, egui::Modifiers::NONE)); + let cmds = out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.commands.clone()).unwrap_or_default(); + assert!(!cmds.iter().any(|c| matches!(c, egui::ViewportCommand::Fullscreen(_))), "F: {cmds:?}"); + u.frame(press(egui::Key::C, egui::Modifiers::NONE)); + assert_eq!(u.app.inspect()["presenter"]["comments"], false, "C"); + u.frame(press(egui::Key::C, egui::Modifiers::SHIFT)); + assert_eq!(u.app.inspect()["presenter"]["comments"], false, "Shift+C"); + assert_eq!(notice(&u), "https://example.com/a"); +} + +#[test] +fn dont_show_this_again_is_saved_with_the_preferences() { + let (mut u, seen) = presented("https://example.com/a", false); + click(&mut u, "Don't show this again"); + click(&mut u, "Open link"); + assert_eq!(opened(&seen), ["https://example.com/a"]); + assert!(u.app.session.prefs.link_notice_skipped); + assert!(seen.1.lock().unwrap().last().is_some_and(|p| p.link_notice_skipped), "the choice is saved"); + assert_eq!(u.run("proto.linkNotice", json!({})), json!({"shown": true})); + assert!(!u.app.session.prefs.link_notice_skipped); } #[test] @@ -69,3 +191,35 @@ fn a_prototype_cannot_open_a_script_or_handler_url() { assert!(opens_after_a_delay(url).is_empty(), "{url:?} must never reach the opener"); } } + +#[test] +fn the_prototype_takes_no_clicks_while_the_notice_shows() { + let mut s = Session::new(); + let a = id(&ex(&mut s, "frame.create", json!({"x": 0, "y": 0, "width": 300, "height": 400, "name": "A"}))); + let b = id(&ex(&mut s, "frame.create", json!({"x": 400, "y": 0, "width": 300, "height": 400, "name": "B"}))); + let layer = id(&ex(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 30, "parent": a}))); + let link = json!({"type": "openUrl", "url": "https://example.com/a"}); + ex(&mut s, "proto.interaction.add", json!({"id": layer, "trigger": {"type": "afterDelay", "ms": 100}, "action": link})); + let to_b = json!({"type": "navigate", "dest": b}); + ex(&mut s, "proto.interaction.add", json!({"id": a, "trigger": {"type": "onClick"}, "action": to_b, "transition": {"type": "instant"}})); + let mut u = Harness::new(s); + u.app.services = Box::new(Opened::default()); + u.run("proto.present", json!({"start": a})); + u.frames(40, Vec::new); + assert_eq!(notice(&u), "https://example.com/a"); + // On the frame, above the notice. + let at = egui::pos2(700.0, 290.0); + u.button(at, true); + u.button(at, false); + u.frames(5, Vec::new); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(a), "the click behind the notice did not navigate"); + assert_eq!(notice(&u), "https://example.com/a", "the notice still waits"); + let shapes = crate::live_tests::flat(&u.frame_output(vec![]).shapes); + crate::live_tests::text_center(&shapes, "Open link"); + u.key(egui::pos2(700.0, 450.0), egui::Key::Escape); + assert!(notice(&u).is_null()); + u.button(at, true); + u.button(at, false); + u.frames(5, Vec::new); + assert_eq!(u.app.inspect()["presenter"]["current"], json!(b), "with the notice gone the same click navigates"); +} diff --git a/crates/ui-egui/src/right.rs b/crates/ui-egui/src/right.rs index e3aba95b..2243d11a 100644 --- a/crates/ui-egui/src/right.rs +++ b/crates/ui-egui/src/right.rs @@ -852,6 +852,8 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let radius_ok = sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_radius())); // Only rectangles and frames round each corner on its own. let indep_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_independent_corners())); + // For polygons and stars the same panel holds only the Corner smoothing row. + let smooth_ok = radius_ok && sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.has_corner_smoothing())); let r = common(doc, sel, |n| n.corners.uniform().map(|r| (r * 100.0).round() / 100.0)); widgets::sublabels(ui, tr("Opacity"), radius_ok.then_some(tr("Corner radius"))); row(ui, 32.0, |ui| { @@ -874,12 +876,14 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { let radius_of = |n: &Node| n.corners.uniform().unwrap_or_else(|| n.corners.radii.iter().copied().fold(0.0, f64::max)); app.field_of(&ids, "cornerRadius", e, radius_of, |v| json!(v.max(0.0))); let open = ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false); - if indep_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tr("Individual corners")).clicked() { + // Polygons and stars have no corners of their own: the button opens only Corner smoothing. + let tip = if indep_ok { tr("Individual corners") } else { tr("Corner smoothing") }; + if smooth_ok && icons::button(ui, Icon::IndependentCorners, 24.0, open, tip).clicked() { ui.data_mut(|d| d.insert_temp(Id::new("indep_corners"), !open)); } } }); - if indep_ok && ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false) { + if smooth_ok && ui.data(|d| d.get_temp::(Id::new("indep_corners"))).unwrap_or(false) { let radii = common(doc, sel, |n| n.corners.radii); // `k` indexes `radii` ([top left, top right, bottom right, bottom left]); the fields sit // where their corners are: top left, top right / bottom left, bottom right. @@ -889,7 +893,8 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ("bottomLeftRadius", tr("Bottom left radius"), Icon::CornerBottomLeft, 3), ("bottomRightRadius", tr("Bottom right radius"), Icon::CornerBottomRight, 2), ]; - for pair in corners.chunks(2) { + // Polygons and stars have one radius, so no field per corner. + for pair in corners.chunks(2).filter(|_| indep_ok) { row(ui, 32.0, |ui| { for (key, name, icon, k) in pair { let e = crate::variable_ui::number_field( @@ -912,7 +917,9 @@ fn appearance(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { row(ui, 32.0, |ui| { let e = widgets::number_field(ui, Id::new("smoothing"), Lead::None, sm, "%", col, 1.0); app.field(&ids, "cornerSmoothing", e, |v| json!((v / 100.0).clamp(0.0, 1.0))); - widgets::label(ui, "iOS: 60%", Tokens::get(ui.ctx()).text_tertiary); + if widgets::button(ui, tr("iOS"), false, true).on_hover_text(tr("Set corner smoothing to 60%")).clicked() { + app.set_props(&ids, json!({"cornerSmoothing": 0.6})); + } }); } let blend = common(doc, sel, |n| n.blend); diff --git a/crates/ui-egui/src/selection_colors.rs b/crates/ui-egui/src/selection_colors.rs index f96fc609..aaece00c 100644 --- a/crates/ui-egui/src/selection_colors.rs +++ b/crates/ui-egui/src/selection_colors.rs @@ -5,8 +5,8 @@ use std::sync::Arc; use egui::{Id, Ui}; use figcraft_color::Color; -use figcraft_doc::{Document, NodeId, StyleKind}; -use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, collect}; +use figcraft_doc::{Document, NodeId, StyleKind, VariableType}; +use figcraft_engine::selection_colors::{PaintTarget, SelectionColor, bind_refusal, collect, in_scope}; use serde_json::json; use crate::a11y::Tip; @@ -106,24 +106,52 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId paint.opacity = (value / 100.0).clamp(0.0, 1.0); app.run("selection.setPaint", json!({"targets": group.targets, "paint": paint})); } - let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles")); - crate::right::menu_popup(&styles).show(|ui| { - let mut any = false; - for style in &doc.styles { - if !matches!(style.kind, StyleKind::Paint { .. }) { - continue; - } - any = true; - if crate::right::menu_item(ui, &style.name, "") { - app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id})); - app.ui.picker = None; - ui.close(); - } + if linked_name.is_some() { + let detach = if group.style.is_some() { tr("Detach style") } else { tr("Detach variable") }; + if icons::button(ui, Icon::Detach, 24.0, false, detach).clicked() { + app.run("selection.detachColor", json!({"targets": group.targets})); } - if !any { - widgets::label(ui, tr("No color styles"), t.text_secondary); - } - }); + } else { + let styles = icons::button(ui, Icon::Styles, 24.0, false, tr("Color styles")); + crate::right::menu_popup(&styles).show(|ui| { + let mut any = false; + for style in &doc.styles { + if !matches!(style.kind, StyleKind::Paint { .. }) { + continue; + } + any = true; + if crate::right::menu_item(ui, &style.name, "") { + app.run("selection.applyColorStyle", json!({"targets": group.targets, "style": style.id})); + app.ui.picker = None; + ui.close(); + } + } + if !any { + widgets::label(ui, tr("No color styles"), t.text_secondary); + } + let variables: Vec<_> = doc + .variables + .variables + .iter() + .filter(|v| { + v.kind == VariableType::Color + && bind_refusal(doc, &group.targets, &v.id).is_none() + && in_scope(doc, &group.targets, &v.id) + }) + .collect(); + if !variables.is_empty() { + ui.separator(); + widgets::label(ui, tr("Color variables"), t.text_secondary); + } + for v in variables { + if crate::right::menu_item(ui, &v.name, "") { + app.run("selection.bindColorVariable", json!({"targets": group.targets, "variable": v.id})); + app.ui.picker = None; + ui.close(); + } + } + }); + } if icons::button(ui, Icon::Move, 24.0, false, tr("Select layers with this color")).clicked() { app.run("selection.selectColor", json!({"targets": group.targets})); } @@ -273,4 +301,45 @@ mod tests { let colors = collect(p.app.session.doc().unwrap(), &[a, b]); assert_eq!(colors.len(), 2); } + + #[test] + fn styles_menu_applies_a_color_variable_to_the_row() { + let (mut p, a, b) = Panel::new(); + p.app.run("variable.createCollection", json!({"name":"Theme"})); + p.app.run("variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#00ff00"})); + p.app.run("variable.create", json!({"collection":"Theme","name":"Gap","type":"number","value":8})); + let red = p.frame(vec![]).into_iter().find(|(s, _)| s == "FF0000").unwrap().1; + p.click(Pos2::new(228.0, red.y)); + let texts = p.frame(vec![]); + assert!(texts.iter().any(|(s, _)| s == "Color variables")); + assert!(!texts.iter().any(|(s, _)| s == "Gap")); + let accent = texts.iter().find(|(s, _)| s == "Accent").unwrap().1; + p.click(accent); + let doc = p.app.session.doc().unwrap(); + assert_eq!(doc.node(a).unwrap().fills[0].solid_color().unwrap().to_hex(), "00FF00"); + assert!(!doc.node(a).unwrap().bound.is_empty()); + assert!(doc.node(b).unwrap().bound.is_empty()); + assert!(p.app.ui.last_error.is_none()); + assert!(p.frame(vec![]).iter().any(|(s, _)| s == "Accent")); + } + + #[test] + fn a_linked_row_detaches_its_style_or_variable() { + let (mut p, a, b) = Panel::new(); + p.app.run("style.create", json!({"type":"paint","name":"Brand","value":"#ff0000"})); + p.app.run("style.apply", json!({"id":a,"style":"Brand","target":"fill"})); + p.app.run("variable.createCollection", json!({"name":"Theme"})); + p.app.run("variable.create", json!({"collection":"Theme","name":"Accent","type":"color","value":"#0000ff"})); + p.app.run("variable.bind", json!({"id":b,"field":"fill","variable":"Accent"})); + for name in ["Brand", "Accent"] { + let row = p.frame(vec![]).into_iter().find(|(s, _)| s == name).unwrap().1; + p.click(Pos2::new(228.0, row.y)); + } + let doc = p.app.session.doc().unwrap(); + assert!(doc.node(a).unwrap().styles.fill.is_none()); + assert!(doc.node(b).unwrap().bound.is_empty()); + let texts = p.frame(vec![]); + assert!(texts.iter().any(|(s, _)| s == "FF0000") && texts.iter().any(|(s, _)| s == "0000FF")); + assert!(p.app.ui.last_error.is_none()); + } } diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 22713000..e63875ff 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -555,7 +555,11 @@ fn listing(app: &FigApp, ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut } } let colors = if slot.paints() { - crate::variable_ui::groups(doc, &libs, |_, v| v.kind == VariableType::Color && (q.is_empty() || v.name.to_lowercase().contains(&q))) + // Variables scoped to every selected layer's fill (or stroke), as the Design panel's pickers offer. + let scopes: Vec<_> = p.ids.iter().filter_map(|id| figcraft_engine::cmd::variables::field_scope(doc, *id, slot.key())).collect(); + crate::variable_ui::groups(doc, &libs, |_, v| { + v.kind == VariableType::Color && (q.is_empty() || v.name.to_lowercase().contains(&q)) && scopes.iter().all(|s| v.in_scope(s)) + }) } else { Vec::new() }; @@ -700,9 +704,24 @@ 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) { + match widgets::bound_field(ui, Id::new(("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())), + None => {} + } +} + /// A text style's typography in the editor: a sample, then font, style, size, line height and -/// letter spacing; a change redefines the style (every linked char follows). -fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { +/// letter spacing; a change redefines the style (every linked char follows). Each field takes a +/// variable: the font and style menus start with Apply variable, the other fields show a variable +/// button on hover, and a bound field shows its variable instead of its value. +fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, out: &mut Frame) { + let id = style.id.as_str(); + let bound = |field: &str| style.bound.get(field).map(|v| doc.variables.get(v).map_or_else(|| v.clone(), |x| x.name.clone())); let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(W, 40.0), Sense::hover()); ui.painter().rect_filled(r, CornerRadius::same(5), Color32::from_white_alpha(14)); @@ -722,45 +741,112 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { // The dropdowns' menus open outside the dark scope (they're regular popups). let (family_resp, style_resp) = crate::theme::on_dark(ui, |ui| { ui.spacing_mut().item_spacing = vec2(8.0, 6.0); - let family_resp = widgets::dropdown(ui, &def.font_family, W, None).on_hover_text(tr("Font")); + let family_resp = match bound("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); + None + } + None => Some(widgets::dropdown(ui, &def.font_family, W, None).on_hover_text(tr("Font"))), + }; let mut style_resp = None; ui.horizontal(|ui| { - style_resp = Some(widgets::dropdown(ui, &def.font_style, half, None).on_hover_text(tr("Font style"))); - if let Some(widgets::FieldEdit::Set(v) | widgets::FieldEdit::Scrub(v, true)) = - widgets::number_field(ui, Id::new(("ts_size", id)), widgets::Lead::Text(tr("Size")), Some(def.font_size), "", half, 1.0) - { + match bound("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); + } + None => style_resp = Some(widgets::dropdown(ui, &def.font_style, half, None).on_hover_text(tr("Font style"))), + } + let size = bound("fontSize"); + let (edit, ev) = widgets::number_field_bindable( + ui, + Id::new(("ts_size", id)), + widgets::Lead::Text(tr("Size")), + Some(def.font_size), + "", + half, + 1.0, + Some(size.as_deref()), + widgets::Empty::Text(tr("Mixed")), + ); + if let Some(widgets::FieldEdit::Set(v) | widgets::FieldEdit::Scrub(v, true)) = edit.and_then(|e| e.settled()) { set(out, &|d| d.font_size = v.clamp(1.0, 5000.0)); } + match ev { + Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, id, "fontSize", VariableType::Number, at), + Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(id.to_string(), "fontSize".to_string())), + None => {} + } }); ui.horizontal(|ui| { - if let Some(v) = widgets::text_field(ui, Id::new(("ts_lh", id)), &def.line_height.label(), half, tr("Line height")) - && let Some(lh) = crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| crate::typography::line_height_of(&v)) - { - set(out, &|d| d.line_height = lh); + let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H)); + match bound("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); + } + None => { + if let Some(v) = widgets::text_field(ui, Id::new(("ts_lh", id)), &def.line_height.label(), half, tr("Line height")) + && let Some(lh) = + crate::typography::parse_line_height(&v, Some(def.line_height)).and_then(|v| crate::typography::line_height_of(&v)) + { + set(out, &|d| d.line_height = lh); + } + if crate::variable_ui::variable_button(ui, area, Id::new(("ts_bind", id, "lineHeight"))) { + crate::variable_ui::open_style(ui, id, "lineHeight", VariableType::Number, area.left_bottom()); + } + } } - if let Some(v) = widgets::text_field(ui, Id::new(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing")) - && let Some(ls) = - crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing)).and_then(|v| crate::typography::letter_spacing_of(&v)) - { - set(out, &|d| d.letter_spacing = ls); + let area = Rect::from_min_size(ui.cursor().min, vec2(half, widgets::FIELD_H)); + match bound("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); + } + None => { + if let Some(v) = widgets::text_field(ui, Id::new(("ts_ls", id)), &def.letter_spacing.label(), half, tr("Letter spacing")) + && let Some(ls) = crate::typography::parse_letter_spacing(&v, Some(def.letter_spacing)) + .and_then(|v| crate::typography::letter_spacing_of(&v)) + { + set(out, &|d| d.letter_spacing = ls); + } + if crate::variable_ui::variable_button(ui, area, Id::new(("ts_bind", id, "letterSpacing"))) { + crate::variable_ui::open_style(ui, id, "letterSpacing", VariableType::Number, area.left_bottom()); + } + } } }); (family_resp, style_resp) }); - crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked { - set(out, &|d| { - d.font_family = f.clone(); - let styles = figcraft_text::family_styles(&f); - if !styles.is_empty() && !styles.contains(&d.font_style) { - d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default()); - } - }); - ui.close(); - } - }); + if let Some(family_resp) = family_resp { + crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + if crate::right::menu_item(ui, tr("Apply variable"), "") { + crate::variable_ui::open_style(ui, id, "fontFamily", VariableType::String, family_resp.rect.left_bottom()); + ui.close(); + return; + } + ui.separator(); + if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked { + set(out, &|d| { + d.font_family = f.clone(); + let styles = figcraft_text::family_styles(&f); + if !styles.is_empty() && !styles.contains(&d.font_style) { + d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default()); + } + }); + ui.close(); + } + }); + } if let Some(sr) = style_resp { crate::right::menu_popup(&sr).width(200.0).show(|ui| { + if crate::right::menu_item(ui, tr("Apply variable"), "") { + crate::variable_ui::open_style(ui, id, "fontStyle", VariableType::String, sr.rect.left_bottom()); + ui.close(); + return; + } + ui.separator(); egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { for s in figcraft_text::family_styles(&def.font_family) { let mark = if def.font_style == s { "✓ " } else { " " }; @@ -838,7 +924,7 @@ fn editor(ui: &mut Ui, doc: &Document, p: &mut Picker, out: &mut Frame) { binding_cell(ui, doc, &style, &format!("layoutGrids/{i}/size"), figcraft_doc::VariableType::Number, out); } } - StyleKind::Text { style: def } => text_properties(ui, &style.id, def, out), + StyleKind::Text { style: def } => text_properties(ui, doc, &style, def, out), } }); ui.add_space(8.0); diff --git a/crates/ui-egui/src/style_picker_tests.rs b/crates/ui-egui/src/style_picker_tests.rs index ad2c2971..6627716f 100644 --- a/crates/ui-egui/src/style_picker_tests.rs +++ b/crates/ui-egui/src/style_picker_tests.rs @@ -248,7 +248,15 @@ fn commit_text_field(def: &TextStyle, field: &str, typed: &str) -> Option Some(d), @@ -276,3 +284,162 @@ fn text_style_editor_commits_line_height_and_letter_spacing() { assert_eq!(TextStyle { line_height: def.line_height, ..d }, def); assert_eq!(lh(&def, "abc"), None); } + +/// A session with the text style "Body" (Inter Regular 14) and number and string variables to +/// bind to its fields; `bind` binds them all first. +fn text_variables_session(bind: bool) -> crate::layers_drag_tests::Ui { + let mut s = Session::new(); + s.execute("style.create", &json!({"type": "text", "name": "Body", "value": {"fontFamily": "Inter", "fontStyle": "Regular", "fontSize": 14}})) + .unwrap(); + s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Base"]})).unwrap(); + for (name, ty, value) in [ + ("Body size", "number", json!(18)), + ("Body leading", "number", json!(24)), + ("Body tracking", "number", json!(1)), + ("Body font", "string", json!("Inter")), + ("Body weight", "string", json!("Bold")), + ] { + s.execute("variable.create", &json!({"collection": "Type", "name": name, "type": ty, "values": {"Base": value}})).unwrap(); + } + if bind { + for (field, var) in [ + ("fontSize", "Body size"), + ("lineHeight", "Body leading"), + ("letterSpacing", "Body tracking"), + ("fontFamily", "Body font"), + ("fontStyle", "Body weight"), + ] { + s.execute("style.bind", &json!({"style": "Body", "field": field, "variable": var})).unwrap(); + } + } + let mut u = crate::layers_drag_tests::Ui::new(s); + open_for_selection(&mut u.app, StyleSlot::Text, Some("Body")); + for _ in 0..3 { + u.frame(vec![]); + } + assert!(u.app.ui.style_picker.as_ref().is_some_and(|p| p.edit.is_some()), "the text style editor is open"); + u +} + +/// The left edge of the style editor's content (its "Edit style" title starts 30 px in). +fn editor_left(u: &mut crate::layers_drag_tests::Ui) -> f32 { + let mut left = None; + for c in u.frame_output(vec![]).shapes { + if let egui::Shape::Text(t) = &c.shape + && t.galley.text() == "Edit style" + { + left = Some(t.pos.x - 30.0); + } + } + left.expect("the editor's title") +} + +/// The texts drawn in one frame, with their centers. +fn drawn_texts(u: &mut crate::layers_drag_tests::Ui) -> Vec<(String, Pos2)> { + fn walk(shape: &egui::Shape, out: &mut Vec<(String, Pos2)>) { + match shape { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.size() / 2.0)), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = Vec::new(); + for c in u.frame_output(vec![]).shapes { + walk(&c.shape, &mut out); + } + out +} + +fn text_at(texts: &[(String, Pos2)], s: &str) -> Pos2 { + texts + .iter() + .find(|(t, _)| t == s) + .map(|(_, p)| *p) + .unwrap_or_else(|| panic!("no text {s:?} in {:?}", texts.iter().map(|t| &t.0).collect::>())) +} + +/// Hover `p` (variable buttons show on hover), then press and release there. +fn click(u: &mut crate::layers_drag_tests::Ui, p: Pos2) { + u.frame(vec![egui::Event::PointerMoved(p)]); + u.button(p, true); + u.button(p, false); + u.frame(vec![]); +} + +/// The name of the variable on `field` of the style "Body". +fn bound_to(u: &crate::layers_drag_tests::Ui, field: &str) -> Option { + let doc = u.app.session.doc().unwrap(); + doc.style("Body").unwrap().bound.get(field).map(|v| doc.variables.get(v).map_or_else(|| v.clone(), |x| x.name.clone())) +} + +#[test] +fn text_style_editor_shows_bound_fields_as_variables_and_detaches_them() { + let mut u = text_variables_session(true); + let left = editor_left(&mut u); + let texts = drawn_texts(&mut u); + let half = (W - 8.0) / 2.0; + // (field, variable shown in its place, right edge of the field: full width or half) + let fields = [ + ("fontFamily", "Body font", W), + ("fontStyle", "Body weight", half), + ("fontSize", "Body size", W), + ("lineHeight", "Body leading", half), + ("letterSpacing", "Body tracking", W), + ]; + let at: Vec = fields.iter().map(|(_, name, _)| text_at(&texts, name).y).collect(); + for ((field, name, right), y) in fields.iter().zip(at) { + assert_eq!(bound_to(&u, field).as_deref(), Some(*name)); + // The chip's detach button sits at its right end. + click(&mut u, Pos2::new(left + right - 11.0, y)); + assert_eq!(bound_to(&u, field), None, "{field} is detached"); + } + assert!(u.app.ui.style_picker.is_some(), "the editor stays open"); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} + +#[test] +fn text_style_editor_binds_variables_from_its_fields() { + let mut u = text_variables_session(false); + let left = editor_left(&mut u); + let texts = drawn_texts(&mut u); + let half = (W - 8.0) / 2.0; + let size_row = text_at(&texts, "Size").y; + let spacing = { + let doc = u.app.session.doc().unwrap(); + let Some(StyleKind::Text { style: def }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") }; + def.letter_spacing.label() + }; + let in_editor: Vec<_> = texts.iter().filter(|(_, p)| p.x > left && p.x < left + W).cloned().collect(); + let spacing_row = text_at(&in_editor, &spacing).y; + // Size, line height and letter spacing: the variable button at the field's right end. + for (field, name, x, y) in [ + ("fontSize", "Body size", left + W - 11.0, size_row), + ("lineHeight", "Body leading", left + half - 11.0, spacing_row), + ("letterSpacing", "Body tracking", left + W - 11.0, spacing_row), + ] { + click(&mut u, Pos2::new(x, y)); + assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens"); + let texts = drawn_texts(&mut u); + assert!(!texts.iter().any(|(t, _)| t == "Body font"), "{field} offers number variables only"); + click(&mut u, text_at(&texts, name)); + assert_eq!(bound_to(&u, field).as_deref(), Some(name)); + } + // Font and font style: Apply variable at the top of their menus. + for (field, shown, name) in [("fontFamily", "Inter", "Body font"), ("fontStyle", "Regular", "Body weight")] { + // The menu's label, not the same name in the Local styles list behind the editor. + let texts: Vec<_> = drawn_texts(&mut u).into_iter().filter(|(_, p)| p.x > left && p.x < left + W).collect(); + click(&mut u, text_at(&texts, shown)); + let texts = drawn_texts(&mut u); + click(&mut u, text_at(&texts, "Apply variable")); + assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens"); + let texts = drawn_texts(&mut u); + // "Body size" shows once, on the size field's chip: the picker doesn't list it. + assert_eq!(texts.iter().filter(|(t, _)| t == "Body size").count(), 1, "{field} offers string variables only"); + click(&mut u, text_at(&texts, name)); + assert_eq!(bound_to(&u, field).as_deref(), Some(name)); + } + let doc = u.app.session.doc().unwrap(); + let Some(StyleKind::Text { style: def }) = doc.style("Body").map(|s| &s.kind) else { panic!("a text style") }; + assert_eq!((def.font_size, def.font_style.as_str()), (18.0, "Bold"), "the style takes the variables' values"); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index b95575b8..8f526acc 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -70,7 +70,7 @@ const GROUPS: &[Group] = &[ tr_noop("Drawing tools"), &[(ToolId::Pen, Icon::Pen), (ToolId::Pencil, Icon::Pencil), (ToolId::ShapeBuilder, Icon::ShapeBuilder)], ), - Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text)]), + Group::tools("text", tr_noop("Text"), &[(ToolId::Text, Icon::Text), (ToolId::TextOnPath, Icon::TextOnPath)]), Group::tools("comment", tr_noop("Comment"), &[(ToolId::Comment, Icon::Comment)]), ]; diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index eefe7c9b..8b1b794d 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -179,8 +179,11 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] let fstyle = uniform(styles, |s| s.font_style.clone()); row(ui, 32.0, |ui| { let w = ui.available_width(); - let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(tr(MIXED)), w, None); + let resp = font_dropdown(app, ui, ids, "fontFamily", family.as_deref(), w, editing); menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + if !editing && apply_variable_item(ui, ids, "fontFamily", &resp) { + return; + } let pick = font_picker(ui, family.as_deref(), resp.clicked()); if let Some(f) = pick.picked { remember_family(ui.ctx(), &f); @@ -197,29 +200,34 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] // Style + size. let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0; row(ui, 32.0, |ui| { - let resp = widgets::dropdown(ui, fstyle.as_deref().unwrap_or(tr(MIXED)), half, None); + let resp = font_dropdown(app, ui, ids, "fontStyle", fstyle.as_deref(), half, editing); let fam = family.clone(); - menu_popup(&resp).width(200.0).show(|ui| match &fam { - Some(fam) => { - let list = figcraft_text::family_styles(fam); - if list.is_empty() { - widgets::label(ui, tr("Font not installed"), t.text_tertiary); + menu_popup(&resp).width(200.0).show(|ui| { + if !editing && apply_variable_item(ui, ids, "fontStyle", &resp) { + return; + } + match &fam { + Some(fam) => { + let list = figcraft_text::family_styles(fam); + if list.is_empty() { + widgets::label(ui, tr("Font not installed"), t.text_tertiary); + } + egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { + for s in list { + let mark = if fstyle.as_deref() == Some(s.as_str()) { "✓ " } else { " " }; + let (clicked, hovered) = menu_item_hover(ui, &format!("{mark}{s}")); + if clicked { + apply_style(app, "fontStyle", json!(s)); + ui.close(); + } else if hovered { + // The canvas shows the style (a variable font's named instance too). + app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontStyle": s}))); + } + } + }); } - egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| { - for s in list { - let mark = if fstyle.as_deref() == Some(s.as_str()) { "✓ " } else { " " }; - let (clicked, hovered) = menu_item_hover(ui, &format!("{mark}{s}")); - if clicked { - apply_style(app, "fontStyle", json!(s)); - ui.close(); - } else if hovered { - // The canvas shows the style (a variable font's named instance too). - app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontStyle": s}))); - } - } - }); + None => widgets::label(ui, tr("Pick one font family first"), t.text_tertiary), } - None => widgets::label(ui, tr("Pick one font family first"), t.text_tertiary), }); let size = uniform(styles, |s| s.font_size); ui.spacing_mut().item_spacing.x = 0.0; @@ -355,6 +363,32 @@ fn menu_item_hover(ui: &mut Ui, label: &str) -> (bool, bool) { (r.clicked(), r.hovered()) } +/// The font family or font style dropdown for `field`, `width` wide, showing `shown` (Mixed when +/// None). While whole layers are edited and `field` is bound to the same variable on every one of +/// `ids`, the variable's chip is drawn in place of the dropdown (a click picks another variable, +/// its detach button unbinds it), and the returned response opens no menu. +fn font_dropdown(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], field: &str, shown: Option<&str>, width: f32, editing: bool) -> egui::Response { + if !editing && app.session.doc().ok().and_then(|d| crate::variable_ui::bound_name(d, ids, field)).is_some() { + let (rect, resp) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); + crate::variable_ui::bound_chip(app, ui, ids, field, VariableType::String, rect); + return resp; + } + widgets::dropdown(ui, shown.unwrap_or(tr(MIXED)), width, None) +} + +/// Apply variable at the top of the font family or font style menu opened from `anchor`: a click +/// opens the variable picker on string variables for `field` of `ids` and closes the menu (true). +fn apply_variable_item(ui: &mut Ui, ids: &[NodeId], field: &str, anchor: &egui::Response) -> bool { + let picked = menu_item(ui, tr("Apply variable"), ""); + if picked { + crate::variable_ui::open(ui, ids, field, VariableType::String, anchor.rect.left_bottom()); + ui.close(); + } else { + ui.separator(); + } + picked +} + /// Run `cmd` as a live preview while scrubbing; the gesture becomes one undo step when `done`. /// (While text is edited, run commands directly: the editing session already groups undo.) pub(crate) fn live(app: &mut FigApp, label: &str, cmd: &str, params: Value, done: bool) { @@ -1420,3 +1454,147 @@ mod tests { assert_eq!(target_styles(&doc, &[id], None).len(), 2); } } + +#[cfg(test)] +mod variable_tests { + use egui::{Event, Modifiers, PointerButton, Pos2, RawInput}; + use figcraft_engine::Session; + + use super::*; + use crate::NoServices; + + /// The Typography section and the variable picker, drawn for a selected text layer (Inter + /// Regular) in a file with the string variables "Heading font" and "Heading weight" and the + /// number variable "Heading size". + struct Panel { + app: FigApp, + ctx: egui::Context, + time: f64, + text: NodeId, + } + + impl Panel { + fn new() -> Self { + let mut s = Session::new(); + let text = NodeId(s.execute("text.create", &json!({"text": "Title"})).unwrap()["id"].as_u64().unwrap()); + s.execute("variable.createCollection", &json!({"name": "Type", "modes": ["Base"]})).unwrap(); + for (name, ty, value) in + [("Heading font", "string", json!("Inter")), ("Heading weight", "string", json!("Bold")), ("Heading size", "number", json!(32))] + { + s.execute("variable.create", &json!({"collection": "Type", "name": name, "type": ty, "values": {"Base": value}})).unwrap(); + } + s.execute("select.set", &json!({ "ids": [text] })).unwrap(); + let mut p = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0, text }; + crate::theme::install_fonts(&p.ctx, false, 0); + crate::theme::apply(&p.ctx, Tokens::light()); + for _ in 0..3 { + p.frame(vec![]); + } + p + } + + /// One frame with `events`; the texts drawn, with their centers. + fn frame(&mut self, events: Vec) -> Vec<(String, Pos2)> { + fn walk(shape: &egui::Shape, out: &mut Vec<(String, Pos2)>) { + match shape { + egui::Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos + t.galley.size() / 2.0)), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + self.time += 1.0 / 60.0; + let input = RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 600.0))), + time: Some(self.time), + events, + ..Default::default() + }; + let mut out = self.ctx.run_ui(input, |ui| { + let ds = self.app.session.ds().unwrap(); + let doc = ds.doc.clone(); + let sel = ds.selection.clone(); + typography(&mut self.app, ui, &doc, &sel); + crate::variable_ui::picker(&mut self.app, ui.ctx()); + }); + out.textures_delta.clear(); + let mut texts = Vec::new(); + for c in out.shapes { + walk(&c.shape, &mut texts); + } + texts + } + + /// Hover `p`, then press and release there; the texts drawn after. + fn click(&mut self, p: Pos2) -> Vec<(String, Pos2)> { + self.frame(vec![Event::PointerMoved(p)]); + for pressed in [true, false] { + self.frame(vec![Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + self.frame(vec![]) + } + + /// The name of the variable `field` of the text layer is bound to. + fn bound_to(&self, field: &str) -> Option { + let doc = self.app.session.doc().unwrap(); + doc.node(self.text).unwrap().bound.get(field).and_then(|v| doc.variables.get(v)).map(|v| v.name.clone()) + } + } + + fn at(texts: &[(String, Pos2)], s: &str) -> Pos2 { + texts + .iter() + .find(|(t, _)| t == s) + .map(|(_, p)| *p) + .unwrap_or_else(|| panic!("no text {s:?} in {:?}", texts.iter().map(|t| &t.0).collect::>())) + } + + fn shows(texts: &[(String, Pos2)], s: &str) -> bool { + texts.iter().any(|(t, _)| t == s) + } + + #[test] + fn font_family_and_style_menus_start_with_apply_variable() { + let mut p = Panel::new(); + for (field, shown, name) in [("fontStyle", "Regular", "Heading weight"), ("fontFamily", "Inter", "Heading font")] { + let texts = p.frame(vec![]); + let texts = p.click(at(&texts, shown)); + let texts = p.click(at(&texts, "Apply variable")); + assert!(crate::variable_ui::picker_open(&p.ctx), "{field}: the variable picker opens"); + assert!(shows(&texts, "Heading font") && shows(&texts, "Heading weight"), "{field} offers string variables"); + assert!(!shows(&texts, "Heading size"), "{field} offers no number variables"); + p.click(at(&texts, name)); + assert_eq!(p.bound_to(field).as_deref(), Some(name)); + } + assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error); + } + + #[test] + fn bound_font_family_and_style_show_their_variables_and_detach() { + let mut p = Panel::new(); + let ids = vec![p.text]; + for (field, var) in [("fontFamily", "Heading font"), ("fontStyle", "Heading weight")] { + p.app.run("variable.bind", json!({"ids": ids, "field": field, "variable": var})); + } + let texts = p.frame(vec![]); + assert!(shows(&texts, "Heading font") && shows(&texts, "Heading weight"), "the chips are drawn in place of the dropdowns"); + assert!(!shows(&texts, "Inter"), "the family dropdown is not drawn"); + // While characters are selected in the text, the dropdowns show the values, with no Apply variable. + figcraft_engine::textedit::begin(&mut p.app.session, p.text, Some(1), false).unwrap(); + figcraft_engine::textedit::set_caret(&mut p.app.session, 3, true).unwrap(); + let texts = p.frame(vec![]); + let texts = p.click(at(&texts, "Inter")); + assert!(!shows(&texts, "Apply variable"), "a selected range takes a font, not a variable"); + p.frame(vec![Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + figcraft_engine::textedit::end(&mut p.app.session).unwrap(); + p.frame(vec![]); + for field in ["fontFamily", "fontStyle"] { + let rect = p.ctx.read_response(Id::new(("bound_chip", field, &ids[..])).with("bound")).expect("the chip").rect; + // The chip's detach button sits at its right end. + p.click(Pos2::new(rect.max.x - 11.0, rect.center().y)); + assert_eq!(p.bound_to(field), None, "{field} is detached"); + } + let texts = p.frame(vec![]); + assert!(shows(&texts, "Inter") && shows(&texts, "Bold"), "the dropdowns show the last values"); + assert!(p.app.ui.last_error.is_none(), "{:?}", p.app.ui.last_error); + } +} diff --git a/crates/ui-egui/src/variable_ui.rs b/crates/ui-egui/src/variable_ui.rs index 39c5396e..7244a1d2 100644 --- a/crates/ui-egui/src/variable_ui.rs +++ b/crates/ui-egui/src/variable_ui.rs @@ -399,6 +399,11 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { Target::Alias { variable, .. } => (None, doc.variables.get(variable)), Target::Proto(_) | Target::Style { .. } => (None, None), }; + // A style's fields offer variables scoped to one of the properties the field sets. + let style_scopes = match &p.target { + Target::Style { style, field } => doc.style(style).and_then(|st| figcraft_engine::style_vars::field_scopes(&st.kind, field)), + _ => None, + }; // Prototypes and styles use this file's variables. let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. }); let field = match &p.target { @@ -410,6 +415,7 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { && !(local_only && lib.is_some()) && (q.is_empty() || v.name.to_lowercase().contains(&q)) && scope.is_none_or(|s| v.in_scope(s)) + && style_scopes.is_none_or(|list| list.iter().any(|s| v.in_scope(s))) && (lib.is_some() || own.is_none_or(|o| figcraft_engine::cmd::variables::alias_target(&doc.variables, o, &v.id).is_ok())) }; let groups = groups(&doc, &libs, keep); @@ -518,3 +524,128 @@ mod kind_tests { assert!(kind_fits(None, None, Boolean)); } } + +#[cfg(test)] +mod scope_tests { + use figcraft_engine::Session; + + use super::*; + use crate::layers_drag_tests::Ui as TestUi; + use crate::style_picker::StyleSlot; + + fn run(s: &mut Session, cmd: &str, p: serde_json::Value) -> serde_json::Value { + s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd} {p}: {e}")) + } + + /// A rectangle (selected, with a drop shadow), a frame with a layout grid, the styles "Brand" + /// (paint), "Shadow" (effect, from the rectangle), "Base" (grid, from the frame) and "Body" + /// (text), and variables each scoped to one property (`any` to none). + fn session() -> Session { + let mut s = Session::new(); + let r = run(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 40, "height": 40}))["id"].clone(); + run(&mut s, "effect.add", json!({"ids": [r], "type": "dropShadow"})); + let f = run(&mut s, "frame.create", json!({"x": 100, "y": 0, "width": 100, "height": 100}))["id"].clone(); + run(&mut s, "grid.add", json!({"ids": [f]})); + run(&mut s, "style.create", json!({"type": "paint", "name": "Brand", "value": [{"type": "solid", "color": "#0D99FF"}]})); + run(&mut s, "style.create", json!({"type": "effect", "name": "Shadow", "from": r})); + run(&mut s, "style.create", json!({"type": "grid", "name": "Base", "from": f})); + run(&mut s, "style.create", json!({"type": "text", "name": "Body", "value": {"fontFamily": "Inter", "fontSize": 14}})); + run(&mut s, "variable.createCollection", json!({"name": "Tokens", "modes": ["Base"]})); + for (name, ty, value, scope) in [ + ("frame fill", "color", json!("#FF0000"), Some("FRAME_FILL")), + ("shape fill", "color", json!("#00FF00"), Some("SHAPE_FILL")), + ("stroke color", "color", json!("#0000FF"), Some("STROKE_COLOR")), + ("shadow color", "color", json!("#000000"), Some("EFFECT_COLOR")), + ("radius", "number", json!(8), Some("CORNER_RADIUS")), + ("font size", "number", json!(18), Some("FONT_SIZE")), + ("blur", "number", json!(4), Some("EFFECT_FLOAT")), + ("any", "number", json!(2), None), + ("family", "string", json!("Inter"), Some("FONT_FAMILY")), + ("weight", "string", json!("Bold"), Some("FONT_STYLE")), + ] { + run(&mut s, "variable.create", json!({"collection": "Tokens", "name": name, "type": ty, "values": {"Base": value}})); + if let Some(sc) = scope { + run(&mut s, "variable.update", json!({"variable": name, "scopes": [sc]})); + } + } + run(&mut s, "select.set", json!({"ids": [r]})); + s + } + + /// The texts drawn in one frame. + fn drawn(u: &mut TestUi) -> Vec { + fn walk(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(t) => out.push(t.galley.text().to_string()), + egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = Vec::new(); + for c in u.frame_output(vec![]).shapes { + walk(&c.shape, &mut out); + } + out + } + + fn check(texts: &[String], shown: &[&str], hidden: &[&str], what: &str) { + for name in shown { + assert!(texts.iter().any(|t| t == name), "{what}: {name:?} offered, in {texts:?}"); + } + for name in hidden { + assert!(!texts.iter().any(|t| t == name), "{what}: {name:?} not offered, in {texts:?}"); + } + } + + #[test] + fn a_styles_variable_picker_offers_the_fields_scopes_only() { + use VariableType::{Color, Number, String}; + let mut u = TestUi::new(session()); + // (style, field, type, offered, not offered) + for (style, field, kind, shown, hidden) in [ + ("Brand", "fill", Color, &["frame fill", "shape fill", "stroke color"][..], &["shadow color"][..]), + ("Shadow", "effects/0/color", Color, &["shadow color"], &["frame fill", "shape fill", "stroke color"]), + ("Shadow", "effects/0/radius", Number, &["blur", "any"], &["radius", "font size"]), + ("Base", "layoutGrids/0/size", Number, &["radius", "font size", "blur", "any"], &[]), + ("Body", "fontSize", Number, &["font size", "any"], &["radius", "blur"]), + ("Body", "fontFamily", String, &["family"], &["weight"]), + ("Body", "fontStyle", String, &["weight"], &["family"]), + ] { + let id = u.app.session.doc().unwrap().style(style).unwrap().id.clone(); + let target = Target::Style { style: id, field: field.to_string() }; + u.ctx.data_mut(|d| d.insert_temp(picker_id(), Picker { target, kind: Some(kind), at: pos2(400.0, 100.0) })); + u.frame(vec![]); + check(&drawn(&mut u), shown, hidden, &format!("{style} {field}")); + } + } + + #[test] + fn the_fill_and_stroke_style_pickers_offer_scoped_color_variables() { + let mut u = TestUi::new(session()); + for (slot, shown, hidden) in [ + (StyleSlot::Fill, &["shape fill"][..], &["frame fill", "stroke color", "shadow color"][..]), + (StyleSlot::Stroke, &["stroke color"], &["frame fill", "shape fill", "shadow color"]), + ] { + crate::style_picker::open_for_selection(&mut u.app, slot, None); + u.frame(vec![]); + let texts = drawn(&mut u); + assert!(texts.iter().any(|t| t == "Color variables"), "{slot:?}: {texts:?}"); + check(&texts, shown, hidden, &format!("{slot:?}")); + } + } + + #[test] + fn a_mixed_selections_fill_picker_offers_variables_scoped_to_every_layer() { + let mut s = session(); + let r = s.selection()[0]; + let f = s.doc().unwrap().nodes().find(|n| matches!(n.kind, figcraft_doc::NodeKind::Frame)).unwrap().id; + run(&mut s, "variable.create", json!({"collection": "Tokens", "name": "both fills", "type": "color", "values": {"Base": "#FFFF00"}})); + run(&mut s, "variable.update", json!({"variable": "both fills", "scopes": ["FRAME_FILL", "SHAPE_FILL"]})); + // The frame first: its scope alone would offer "frame fill" for the rectangle too. + run(&mut s, "select.set", json!({"ids": [f, r]})); + let mut u = TestUi::new(s); + crate::style_picker::open_for_selection(&mut u.app, StyleSlot::Fill, None); + u.frame(vec![]); + check(&drawn(&mut u), &["both fills"], &["frame fill", "shape fill"], "frame and rectangle"); + } +} diff --git a/docs/components-parity.md b/docs/components-parity.md index 7f7c162f..9489d440 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 @@ -61,12 +61,12 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs` | | Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only | | Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) | -| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii and grid sizes, the rest through the command | +| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command | | Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); scopes filter pickers only | | Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay | | Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values | | Code syntax per platform (Web, iOS, Android) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose code doesn't name variables yet | -| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | +| Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | | Explicit modes per frame, library collections' modes | done | | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | @@ -77,6 +77,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Variables on a text style's fields from the style editor: the font and font style menus start with Apply variable (string variables), and the size, line height and letter spacing fields show a variable button on hover (number variables). A bound field shows its variable in place of the value; a click on it picks another variable and its detach button unbinds it (`style_picker_tests`). Left: the other effect and grid fields (commands only), library variables in the picker. Numbers unchanged. | | 2026-10-23 | minor | Instance-swap properties in the component playground: the Inspect tab's Playground shows a dropdown for each, with the same menu as the Design panel's (the property's preferred components first, then every component in the file). The component picked changes the preview and the code only, never the file. Left: slots in the playground. Numbers unchanged. | | 2026-10-20 | minor | A main component's copy within its file is an instance of it (#892): Duplicate (`edit.duplicate`) and Alt-drag on the canvas made a second main; they now place an instance where the copy went (same offset, the instance's default name, selected), as Copy then Paste already did. A variant duplicated in its set stays a new variant. | | 2026-10-18 | minor | A bound colour variable's alpha applies once (#802): a `#RRGGBBAA` paint keeps its alpha in the paint opacity, and binding a variable with alpha left that opacity in place, so a 50% fill bound to a 50% variable drew at 25%. Binding (`variable.bind`, `node.set` `boundVariables`, `style.bind`) now restarts the paint's opacity at 100% and unbinding (also deleting the variable) moves the colour's alpha back into the opacity; `.fig` and REST imports take the variable's alpha back out of a bound paint's opacity, so `.fig` round trips no longer square it. Colour-picker edits of a colour variable or a plugin colour field, generated CSS strokes and the document colours list counted the paint opacity twice and now count it once (`bound_alpha_tests`). Numbers unchanged | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f86ce764..c617f0ab 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -167,7 +167,8 @@ far the view is panned (`pan`, `panRange`), whether a scroll is still coasting ( active screen's `hotspots` (`[{id, rect: [x0, y0, x1, y1]}]` in window points, bottom-most first), whether a click where nothing reacts flashes them (`hotspotHints`) and they are flashing now (`hotspotFlash`), and whether the Inspect panel shows (`inspect`) with the layer it shows -(`inspected`) (`null` when nothing plays). `presentOptions` holds the options the next presentation +(`inspected`), and the link an Open link notice waits to open (`linkNotice`, else `null`) (`null` +when nothing plays). `presentOptions` holds the options the next presentation starts with (`scale`, `deviceFrame`, `comments`, `hotspotHints`, `inspect`). `textPreview` is true while a live text preview shows on the canvas (a hovered text style or named instance, a dragged axis slider; see `text.preview` in [mcp.md](mcp.md)). diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index 7f8a7fbe..9565730e 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -10,7 +10,7 @@ repository, third-party integrations. Remaining: 1–2 h. | Feature | Status | Notes | |---|---|---| -| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why); files dropped on the canvas still edit (ours to tighten) | +| Dev Mode toggle (Shift+D), Inspect panel | done | the canvas only inspects: presses select, pan and comment but never move, resize, draw or edit text and vectors; menus and shortcuts refuse design edits with a note (Dev Mode's annotations, statuses, dev resources, measurements, code connections and comments still work); a drawing tool chosen in the toolbar goes back to the design. The layers panel only selects and expands (no renaming, reordering, hiding or locking; a double click says why), and the Pages list only opens pages (no adding, renaming, reordering, duplicating or deleting). Files dropped on the canvas aren't imported (a dropped document still opens as a file of its own), and the Assets panel places, swaps and applies nothing, doesn't change Hide when publishing, and doesn't accept library updates or add, remove or swap libraries; each says why | | Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) | | Code: CSS, iOS (SwiftUI), Android (Compose) | done | plus HTML, React and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration | | Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours | @@ -28,6 +28,7 @@ repository, third-party integrations. Remaining: 1–2 h. | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Dev Mode refuses design edits from files dropped on the canvas (a dropped document still opens as a file of its own, and the drop overlay shows the Dev Mode note for other files), from the Pages list's add, rename, reorder, duplicate and delete, and from the Assets panel's place, drag, Alt-drop swap, style, Hide when publishing, Update, and adding, removing and swapping libraries. Percentages and hours unchanged | | 2026-10-24 | minor | Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Breadth 97% → 98%, ready 76% → 78%, 2–4 h → 1–2 h | | 2026-10-15 | minor | Dev Mode tail (gap 16): generated layer trees (React, Leptos, SwiftUI, Compose) start with the import lines of the connected components they call, once each, also those in a component's definition, in slots an instance fills and placed by a connected template's instance swaps; Compare changes takes any saved version as the before (`dev.compare {version}`, the view's Compare with menu; frames that aren't ready for dev compare with versions). Behaviour suite (5 tests; 4 failed before the fixes): the Dev Mode canvas only inspects (no moving, resizing, drawing, text or vector editing; menus, shortcuts, nudges, opacity keys and paste refuse design edits with a note; toolbar drawing tools go back to the design), the ready list filters and badges Changed items, statuses are refused on layers inside frames. Read-only plugins run from the Inspect tab show their panels (the runtime's panels, reused). Breadth 96% → 97% (measurements kept on the canvas and the read-only layers panel counted as missing), ready 72% → 76%, 0–1 h → 2–4 h | | 2026-10-12 | minor | Dev resources (gap 16): links for developers (a URL and a name) on any layer: `devResource.add` / `edit` / `remove` / `list`, undoable, saved and synced; an instance also lists its main component's (and that component's set's) links read-only with where they come from, and layers inside an instance show their main layer's; URLs are checked as web links (http or https, a host, no spaces, at most 2,048 characters) and never fetched (a link added without a name is named after its URL), opened only when clicked; the Inspect tab lists, adds, edits and removes them; `get_node` and `inspect_document` return them as `devResources`. The editor extension stays out of scope (the MCP server serves agents and editors). Dev Mode breadth 93% → 96%, ready 70% → 72%, 1–3 h → 0–1 h; feature breadth (~82%) and ready for real work (~51%) unchanged at this rounding; full parity ~304–593 h → ~303–591 h | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 73fbc602..d1819ee0 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (`.figcraft` carries the file kind (boards; `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732); SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -49,7 +49,7 @@ export limit, not a native document nesting limit. | Format | Figma | FigCraft | Notes | Hours | |---|---|---|---|---| -| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them; sides round up to whole pixels | — | +| PNG | yes, scales/suffixes | yes | export settings per layer, presets (`2x`, `512w`, `512h`), preview, "Export N layers"; contents only (off: every layer showing in the area) and include bounding box (the layer's own box); slashes in layer names export into folders. The exported area is what the layer paints: shadows towards their offset, outside and centre strokes, unclipped children, a mask group's mask, nothing for hidden layers; slices export everything under them, except that with contents only on a slice inside a frame or group exports only that frame or group; sides round up to whole pixels | — | | JPG | yes | yes | white where transparent; fixed quality (90) | — | | SVG | yes (outline text option, ids) | yes | alpha/luminance masks kept; noise, texture, progressive and background blur, glass embedded as images; outline text (off: `` with one `` per line, one style per layer), unique id attributes from layer names, explicit depth-limit refusal, nested frames clipped by their parent's clip (#327), simplified strokes (stroke attributes for centred strokes and for inside and outside strokes on boxes and ellipses) | — | | PDF | yes | yes | one page per frame, real selectable text in subset fonts, links, vectors and gradients as vectors, effects as images; bleed, crop/bleed marks, page boxes, naive CMYK; checked by rendering the PDF back against PNG exports; explicit depth-limit refusal | CMYK output intent 2–4 | @@ -159,12 +159,14 @@ each. | Local fonts | yes (font helper / desktop app) | yes (system scan) | | Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) | | Variable fonts | yes | yes (axes, named instances, per-range axis values) | -| Missing-font replacement on open | yes | warnings only (REST import) | +| Missing-font replacement on open | yes | listed, not replaced: in the desktop app the Missing fonts window opens after a file that uses them opens and from Text ▸ Missing fonts (`text.missingFonts`; `figcraft-cli info` prints `missing font` lines), and their text draws in a fallback font | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-24 | patch | Fonts: the missing-font row said "warnings only (REST import)"; it now names the Missing fonts window (#752), `text.missingFonts` and the `missing font` lines of `figcraft-cli info`. Replacing a missing font is still missing. Numbers unchanged | +| 2026-10-24 | minor | Slices and contents only (Figma's Ignore overlapping layers): with it on, a slice inside a frame or group exports only that frame or group and the layers in it, and its Export preview shows the same; with it off, or for a slice on the page or in a section, the slice exports everything in its area as before | | 2026-10-24 | minor | Boards (M12): `.figcraft` saves the file kind (`kind: "board"`, absent for design files) and each board object's `board` metadata; the `.jam` row points at boards | | 2026-10-15 | minor | `.fig` import (#279, #281; #266, #267): resized instances lay out their layers by their constraints, and explicit variable modes whose collection is named by its asset key come across (before, they were dropped and bound layers resolved in the default mode); fixture-tested, real files still owed. `.figcraft`: atomic saves and exports through unique staging files (#284), optional auto-save (#305). SVG export keeps a nested frame's fill and stroke inside its parent's clip (#327). Numbers unchanged | | 2026-10-20 | minor | `.fig` export writes grid track sizes (`gridColumnsSizing` / `gridRowsSizing`): fixed px, fractional fill and content-sized tracks keep their sizes and come back through our own importer (#465). Tracks whose size the format can't hold (a negative or out-of-range value) keep their id and read back equal, with a warning. Numbers unchanged | diff --git a/docs/gaps.md b/docs/gaps.md index f4f29c07..e76731e7 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -152,7 +152,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe ### 4. Interaction fidelity not compared tool by tool - **Missing:** a side-by-side pass of every tool and panel: cursors, modifier keys while dragging, - hover states, popovers, menus, snapping thresholds. + hover states, popovers, menus, snapping thresholds. Known: the Assets panel's export preview + follows a slice's first export setting, so a second setting with the other Ignore overlapping + layers value previews differently from what it exports. - **Landed (2026-10-11):** behaviour suites written from the public documentation for auto layout, selection and transform, text, and canvas navigation with rulers, guides, layout grids, outline mode and measuring, then (2026-10-12) comments and version history, and the layers and Assets @@ -315,8 +317,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Landed (2026-10-12):** several Bezier handles selected with Shift-click and dragged together, sharp points beside curved segments rounded, a stroke cap and join per point (canvas, PNG, SVG, PDF, outline stroke), the Pen outside edit mode connecting to its own points and continuing a - selected open path. Left: corner smoothing beyond rectangles and frames, per-point caps and joins - on dashed, variable-width and brush strokes. + selected open path. Left: corner smoothing on vectors, corner radius and smoothing on boolean + groups that contain rectangles, per-point caps and joins on dashed, variable-width and brush + strokes. - **Landed (2026-10-11):** boolean groups are clicked where their result is (a click in a subtracted hole reaches what's behind; with the group or an operand selected its operands are hit, so each can still be picked; "Select layer" lists the group only where its result is), live @@ -341,7 +344,8 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe collection (a brand theme extending a core one) can be extended again, its values flowing down the chain as each file accepts updates, every level's overrides kept. - **Missing:** checked with synthetic files only; extending a collection of the same file isn't - offered (extending takes library collections). + offered (extending takes library collections). A paint style's color offers variables scoped to + any fill or stroke: FigCraft's own rule, since the help center doesn't say which scopes it takes. - **Estimate:** 0–1 h. ### 15. Prototyping: media triggers and actions, controllers off Windows @@ -356,6 +360,9 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe importer warns and skips them, `crates/format/src/rest/proto.rs`); controllers on macOS, Linux and FreeBSD (the usual Rust controller library links libudev on Linux; a pure-Rust reader per platform, or an owner decision on that dependency); PlayStation and Switch button labels. + `proto.linkNotice` from the Actions palette or the control channel changes the preference in + memory only; it reaches the preferences file with the next preferences save (the notice's own + "Don't show this again" saves at once). - **Estimate:** 2–5 h, the media part after video fills (gap 12). ### 16. Dev Mode: the tail after dev resources @@ -392,9 +399,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe without them), drawn in Dev Mode as labelled redlines that follow the layers, and listed, placed and removed in the Inspect tab. The layers panel is read-only in Dev Mode (rows select and expand; no renaming, reordering, hiding or locking). -- **Missing:** files dropped on the canvas still edit in Dev Mode; publishing mappings from the - code repository with a CLI. An editor extension is out of scope: the MCP server already serves - design data, code and the connected snippets to agents and editors. +- **Landed (2026-10-24):** files dropped on the canvas, the Pages list and the Assets panel no + longer edit the design in Dev Mode (a dropped document still opens as a file of its own, and + the Pages list only opens pages). +- **Missing:** publishing mappings from the code repository with a CLI. An editor extension is out + of scope: the MCP server already serves design data, code and the connected snippets to agents + and editors. The file name field in the left panel still renames the file in Dev Mode + (`file.rename`); whether Figma allows that in Dev Mode needs checking before it changes. - **Estimate:** 1–2 h. ### 17. Branching and merging @@ -476,6 +487,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours unchanged | | 2026-10-24 | minor | Gap 16: Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Left: publishing mappings with a CLI; 2–4 h → 1–2 h | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. New gap 24 (the board tail, 30–60 h); the ranking above it is unchanged | | 2026-10-15 | minor | Gap 5: Turkish reviewed by a native speaker (#263), Czech (#469) and Indonesian (#477) drafts, the Japanese draft caught up (#288); Indonesian is no longer missing; counts re-measured (2,177 entries) and corrected: only Spanish is held to completeness, Russian included. Estimate unchanged at this rounding | diff --git a/docs/mcp.md b/docs/mcp.md index 8361286d..9de0273a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -315,7 +315,7 @@ Or in a project's `.mcp.json`: | `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | | `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. | | `select {ids}` | Replace the selection (`[]` deselects). | -| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | +| `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?}` | png, jpg, svg, webp, pdf, or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | | `open_file {path, variables?}` / `save_file {path?}` | Open `.figcraft`, `.json` (FigCraft JSON or REST API file JSON with its variables, see below), `.fig` (best effort, see below), `.svg` (see Importing SVG below) or an image; save as `.figcraft`. `open_file` returns `opened` (for a file opened anew, a `summary` of `name`, `pages`, `layers` and `images`, and `warnings` or `repairs`; a file that was already open just gives its `index`) and the document's `document` outline. | | `undo` / `redo` | Step through history. | @@ -708,7 +708,10 @@ device, whether a click where nothing reacts briefly flashes the screen's hotspo interactions, on by default; also in the presenter's Options menu) and whether its Inspect panel shows the size, colours and typography of the layer under the pointer (also the Inspect button in its bar); the options are kept for later presentations (in the presenter Z cycles them and R -restarts). The player's state is +restarts). An Open link to an `http` or `https` address first shows a notice that the link leaves +the prototype, with Open link, Cancel and "Don't show this again"; `proto.linkNotice {show?}` +turns the notice on (the default) or off, kept with the desktop app's preferences (the web build +keeps it until the page reloads). The player's state is in `inspect_ui` (`presenter`, `preview`: screen, scroll, `scale`, `zoom`, `pan`, the screen's `hotspots` and whether they are flashing, the `inspected` layer) when attached, and `ui.set {presentScroll: [x, y]}` scrolls its screen (see [control-protocol.md](control-protocol.md)). @@ -919,6 +922,8 @@ click or drag across regions with `pointer` to merge, add `alt` to remove; `shap - **Text on a path**: `text.onPath {text?, path?, start?, flip?}` (default: the selected text layer and the other selected layer) puts a text layer on a vector's or shape's path: the text takes the path layer's place, transform and box, and the path layer is removed (`edit.undo` brings it back). + The text also takes the path layer's effects when it has any, and its fills when one of them + shows, each with its style and variable bindings; otherwise the text keeps its own. The baseline follows the path from `start` (0..1 of its length); the text's horizontal alignment places it there (left starts, centre centres, right ends at it); `flip` runs it on the other side the other way. Glyphs past an open path's end are hidden; a closed path (ellipse, rectangle) wraps @@ -926,7 +931,12 @@ click or drag across regions with `pointer` to merge, add `alt` to remove; `shap them, `text.removeFromPath {ids?}` makes it plain auto-width text again. Resizing the layer scales the path. The text stays editable (`text.edit.*`, `type_text`); `get_node` shows `textPath {d (SVG path data in the layer's space), start, flip}`. Exports draw it as outlines - (PDF too: not selectable); underline and strikethrough aren't drawn on a path. + (PDF too: not selectable); underline and strikethrough aren't drawn on a path. With the tool + `tool.textOnPath`, a `pointer` press within 6 screen pixels of a shape's or vector's first + subpath puts a new, empty text layer on it, starting at the press, and starts typing: the text + also takes the path layer's constraints and effects, and its fills when one of them shows. Ending + the edit with nothing typed puts the path layer back with no undo step. Locked layers and layers + inside instances are skipped. - **Pattern fills**: `fill.pattern {source (layer id), ids?, index?, tile? (grid|horizontalHex| verticalHex), scale?, spacing? [x, y], align? (start|center|end), opacity?}` makes the fill at `index` (default the top one) the source layer repeated, at `scale` × its own size with `spacing` @@ -1613,6 +1623,14 @@ Pass the returned target list to: - `selection.applyColorStyle {targets, style}` to apply a document paint style. As with `style.apply`, this replaces the matching node's entire fill/stroke list and links the style. Text ranges copy resolved paints and keep their typography, without a paint-style link. +- `selection.bindColorVariable {targets, variable}` to bind a document color variable (id or name) + to those solid fill and stroke slots, as `variable.bind` does with `fills//color` or + `strokes//color`. A color style on the list and an earlier binding of the slot are removed. + Text ranges with fills of their own take the variable's value in the layer's modes, without a + binding. Gradients and other variable types are refused; scopes only filter what the Selection + colors menu offers, as for `variable.bind`. +- `selection.detachColor {targets}` to unlink the color style and the color variable of those + slots, keeping their values. Keep the targets fixed throughout a live interaction. A color becoming equal to another color must not add slots to the gesture. Invalid or missing targets fail atomically. Direct paint edits diff --git a/docs/prototyping-parity.md b/docs/prototyping-parity.md index 2ded779d..a7b943e7 100644 --- a/docs/prototyping-parity.md +++ b/docs/prototyping-parity.md @@ -1,6 +1,6 @@ # Prototyping parity -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-23 · **Change:** patch (top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** patch (the Triggers row no longer lists delays on mouse triggers as missing; top-level frames bigger than the device's screen scroll in the player; Change to, set variable and set variable mode lay out what they change in the player, as the canvas does; presentation tabs, one per page of a file; a Present tab with device and scale controls (#537); core editor leftovers: delays on mouse triggers and manual overlay offsets; controller buttons on key/gamepad triggers, widgets that react in prototypes, `proto.evaluate` and the mode lookup rules pinned) · **Target:** Figma Design (desktop app 126.10.7) **Breadth ~92%, ready ~74% (estimated).** Every Figma trigger except media, every action except media control, every transition and easing exist; key/gamepad triggers take controller buttons, @@ -15,7 +15,7 @@ larger real prototypes. Remaining: 2–5 h. | Group | Figma | FigCraft | Missing | |---|---|---|---| -| Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress (keys, combinations and controller buttons) | media triggers, delays on mouse enter/leave/down/up; controllers on macOS, Linux and FreeBSD | +| Triggers | On click, on drag, while hovering, while pressing, key/gamepad, mouse enter, mouse leave, mouse down, mouse up, after delay, on media hit, on media end | 10: OnClick, OnHover, OnPress, OnDrag, MouseEnter, MouseLeave, MouseDown, MouseUp, AfterDelay, KeyPress (keys, combinations and controller buttons) | media triggers; controllers on macOS, Linux and FreeBSD | | Actions | Navigate to, change to, back, scroll to, open link, open / swap / close overlay, set variable, set variable mode, conditional, play/pause/mute/seek media | 11: Navigate, Back, ScrollTo (with an offset), OpenOverlay, SwapOverlay, CloseOverlay, OpenUrl, ChangeTo, SetVariable, SetVariableMode, Conditional | media actions | | Transitions | Instant, dissolve, smart animate, move in/out, push, slide in/out | all 8 | — | | Easing | Linear, ease in/out/in-and-out, back variants, gentle, quick, bouncy, slow, custom bezier, custom spring | all 13 (springs physical) | — | @@ -93,6 +93,7 @@ case pins the current behaviour). | Hotspot hints, inspect panel in the presenter, comments in the presenter | done | | | Preview pane following edits live | done | | | Edit interactions on several layers at once | done | | +| Open link: a notice before a website opens, with a checkbox to skip it on this device | done | `http` and `https` links (a mail link opens at once); Open link opens it, Cancel or Escape drops it; the desktop app saves the checkbox with its preferences, and the web build keeps it until the page reloads; `proto.linkNotice {show}` turns it on or off (`present_links_tests`) | | Opening a link in place | missing | | | Controllers on macOS, Linux and FreeBSD | missing | the usual Rust controller library links the system's libudev on Linux; the pure-Rust desktop builds read none there yet | | Prototype in a separate window; mirror to a phone | missing | see [hardware-parity](hardware-parity.md) | @@ -103,6 +104,8 @@ case pins the current behaviour). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | patch | The Triggers row's Missing column no longer lists delays on mouse enter, leave, down and up, which landed on 2026-10-13 (`crates/engine/src/proto_delay_tests.rs`, `crates/ui-egui/src/present_delay_tests.rs`). Numbers unchanged | +| 2026-10-24 | patch | Open link to a website first shows a notice that it leaves the prototype, with the link, Open link, Cancel and "Don't show this again", which the desktop app saves with its preferences and the web build keeps until the page reloads (`proto.linkNotice` turns the notice back on); a second link to a website that fires while the notice shows is dropped, and a mail link still opens. Before, the player opened the link at once, also from an After delay trigger. Numbers unchanged | | 2026-10-23 | patch | A top-level frame taller (or wider) than the device's screen scrolls with no overflow setting, as Figma's documentation says; before, "No scrolling" frames such as the landing page template's Phone frame only clipped. Content past a frame's own edges still scrolls only as its overflow allows. Checked in the app on macOS. Numbers unchanged | | 2026-10-22 | minor | Change to, set variable and set variable mode lay out what they change in the player as the canvas does: text sizes to what it shows and hugging frames follow (an instance with a longer label changed to the main's width; a bound text kept its old size). Two player tests. Breadth and ready unchanged. | | 2026-10-17 | minor | Presentation tabs: one per page of a file instead of a single "Present" tab, named "file - page" (renames follow); Play shows a page's tab as it was; tabs outlive their file and come back when a saved file opens again. Numbers unchanged. | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e196eb64..88a92c7c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719); reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -96,10 +96,10 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | # | Area | Weight | Breadth | Ready | Remaining | Hours | |---|---|---|---|---|---|---| -| 1 | Canvas & navigation | 6 | 98% | 74% | Outline mode's Include hidden layers and Include object bounds, Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks) | 1–4 | +| 1 | Canvas & navigation | 6 | 98% | 74% | Display P3 on screen, QA at extreme zooms (behaviour suite for navigation, rulers, guides, layout grids and outline mode landed: guide selection, Auto grid counts, outline-mode clicks; outline mode's Include hidden layers and Include object bounds landed) | 1–4 | | 2 | Selection & transform | 8 | 97% | 72% | Handles, measuring and snapping checked tool by tool against Figma and real files (snap distance, handle reach, cursors); children turned off a right angle scale without skew, approximately (behaviour suite from the documentation, rotated groups and mixed selections, equal spacing snapping, Cmd/Ctrl to ignore constraints and marquees inside frames landed) | 3–5 | | 3 | Layers panel | 5 | 99% | 85% | QA with long real files (behaviour suite from the documentation, Rename layers dialog, sweeping eyes and padlocks, page dragging and dividers, several layers dropped together, keyboard box selection, F2 rename, Shift+F10 context menu and 10k-layer timings landed) | 1–2 | -| 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on polygons, stars and vectors; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | +| 4 | Shapes & vector | 8 | 99% | 73% | Corner smoothing on vectors, corner radius and smoothing on boolean groups that contain rectangles; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons and stars, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | | 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | | 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | @@ -423,7 +423,7 @@ Hours in this narrative are the builders' estimates; the tables above supersede presenter) and variables (the local variables JSON passed along, next to the file or embedded: collections, modes, aliases, scopes, layer bindings and explicit modes). Export panel: a row per export setting (typed or preset sizes like `2x` / `512w` / `512h`, PNG, - JPG, WebP, SVG and PDF, suffix, advanced options: contents only, include bounding box, and for + JPG, WebP, SVG and PDF, suffix, advanced options: ignore overlapping layers, include bounding box for text layers, and for SVG outline text, id attributes and simplified strokes), a preview, and "Export N layers" (`file.exportLayers`) in the panel and the Export dialog. Paste from other apps: images, SVG and text (the newest clipboard content wins over our own copy; pasted SVG lands centred in the view), and layers copied from @@ -641,6 +641,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-24 | patch | Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds, which landed (`view.outlinesHiddenLayers`, `view.outlinesObjectBounds`, `crates/engine/src/outline_options_tests.rs`). Numbers unchanged | | 2026-10-24 | minor | Boards (M12): a file kind of its own (`file.newBoard`, saved in `.figcraft`, carried to live-session joiners) with sticky notes, shapes with text, connectors that re-route when what they join moves (an engine recompute, the same for local edits, undo and others' edits), marker, highlighter and eraser, stamps with the placer's name, a board toolbar and a contextual bar; prototyping and Dev Mode refuse on boards; design files and boards have their own marks on tabs and file rows. Area 22 added at weight 0 (breadth ~35%, ready ~20%); headline numbers unchanged (the weighted sum leaves area 22 out) | | 2026-10-15 | minor | Localization (#263, #469, #477, #288): Turkish reviewed by a native speaker, Czech and Indonesian drafts, the Japanese draft caught up; coverage re-measured (2,177 entries; the five older drafts at 95%). Localization 39% and 29–51 h unchanged at this rounding (see [localization-parity](localization-parity.md)) | | 2026-10-22 | minor | Vector edit mode: the transform box around points selected in several layers (in the page's axes; scale, rotate and drag in one undo step). Shapes & vector's remaining work loses that item; numbers unchanged | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2b8c66be..e1c219c6 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -70,7 +70,7 @@ interaction feel not yet compared. | Transform box at small sizes | done | a press inside a layer smaller than its handles moves it, the nearest handle wins, handles answer just outside and are hidden under 16 px on screen; cursors follow each handle and the box's rotation | | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). No reorder rings for swapping layers; checked by its author on macOS and in the app on Windows (#402) | -| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a colour style of the document (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.selectColor`). No library styles or variable binding from there (#403) | +| Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) | | Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | ## Tools @@ -88,7 +88,7 @@ interaction feel not yet compared. | Text: point, auto height, fixed; caret, words, lines, IME | done | the font picker previews a family on the canvas while hovered (no undo step; Escape restores, #653) and Up/Down step through it (#352); a selection highlight after a line break starts at that line's start and an empty last line shows its mark (#665); carets step over whole emoji and accented letters; colour emoji draw from their colour tables (#300); IME composition replayed in tests as macOS sends it, not yet checked on a Mac (#747); CJK words by script run (no dictionary); vertical trim in Type settings; no spell check | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`) | | Comment (C), Shift+C to show/hide | done | local only | -| Text on a path (Figma Draw) | done | put on from the Design panel or `text.onPath`, not by clicking a path with the Text tool; the path is edited by resizing only | +| Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only | | Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | | Image crop handles | done | | @@ -172,6 +172,8 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-10-24 | minor | Selection colors: a row's Styles menu also lists the document's color variables, and picking one binds it to every fill and stroke the row stands for, replacing a color style there (`selection.bindColorVariable`; a text range with fills of its own takes the variable's value); a row linked to a style or variable has a Detach button that unlinks it and keeps the color (`selection.detachColor`). Still partial: no library styles or variables, no new style or variable from a row | +| 2026-10-24 | minor | The Text on a path tool (`tool.textOnPath`, in the Text flyout): a click on a shape's or vector's path puts new text on it, starting at the click, with the path's fill (when one shows) and effects, as the help center's Guide to text describes; the path layer is removed, and an edit left empty puts it back with no undo step (`draw_tools_tests`, `cursor_tests`) | | 2026-10-24 | minor | Boards (M12): eight board bindings pinned (tool keys Q sticky, B shapes, X connector, M marker, U highlighter, E eraser, W stamp, and Shift+Cmd/Ctrl+Enter for the next sticky; boards only, on keys no design-file shortcut uses): 49 → 57 pinned | | 2026-10-15 | minor | Community fixes (#262, #272, #277, #300, #308, #311, #322, #352, #363, #372, #390): first runs follow the system theme; new text is readable on the frame it is drawn in; a long pages list scrolls on its own and new pages take the current page's colour; the pen shows its handles while dragging a point out and ends its path when you leave Design; vector edit mode keeps the canvas still; a drag with the Text tool makes a fixed-size box; colour emoji; the selection highlight on the last line; Up and Down in the font picker with a live preview. Numbers unchanged | | 2026-10-22 | minor | Vector edit mode: points selected in several layers get the transform box, in the page's axes since the layers can be turned differently; its corners and edges scale them, outside a corner rotates them and inside drags them, each layer's points in its own coordinates and one undo step for all, and a click inside clears every layer's points (`transform_tests`). Numbers unchanged | diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index aeb58fd4..978874d3 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -19,7 +19,9 @@ fi TMP_BASE="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" TMP="$(mktemp -d "${TMP_BASE}/figcraft-signing.XXXXXX")" -trap 'rm -rf "$TMP"' EXIT +# Remove only the decoded certificate: the keychain lives in the same directory and codesign +# needs it until the workflow's "Remove temporary keychain" step deletes it. +trap 'rm -f "${CERT:-}"' EXIT KEYCHAIN="$TMP/figcraft-signing.keychain-db" CERT="$TMP/figcraft-signing.p12" KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}"